CrowPanel Tutorial: Cryptocurrency Ticker

0 - Introduction

In today’s cryptocurrency world staying updated with real-time market prices is crucial for traders, invertors and enthusiasts alike. While most people rely on smartphones or web platforms to track crypto prices, there is nothing better than having a dedicated ticker with an E-Ink display, keeping you updated while spending little to no power.

In this article we will use the Crowpanel 5.79” E-Paper kit to make a Bitcoin ticker using CoinGekko’s API. Due to the API limitations and also to save power, our ticker will only update every 2 minutes, but you can change that to your liking at the start of the main file. If you do not own a CrowPanel, you can get one here at Elecrow’s store.

As you will be able to see in the picture below, the E-Paper display keeps showing it’s content even with no power plugged in. Obviously, you will need to plug it in to get the latest prices, but even if we cut the power to the display (to save power), it will remain showing it’s content.

1 - Setup

Before we begin to code, we need to setup PlatformIO, if you don’t have it already, check this article. Once you have it installed, open your project’s folder and create a file named ‘platformio.ini’, this file is the configuration of our project and this one changes some ‘board_build’ settings to use the whole flash and PSRAM available in your Esp32 S3:

[env:esp32-s3-devkitc-1]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
board_build.arduino.memory_type = qio_opi
board_build.flash_mode = qio
board_build.psram_type = opi
board_upload.flash_size = 8MB
board_upload.maximum_size = 8388608
board_build.extra_flags = 
	-DBOARD_HAS_PSRAM
lib_deps = bblanchon/ArduinoJson@^7.2.0

If all you came here for is the code, take it here. With this big main file you can get everything running by changing the API key and WiFi credentials. Keep in mind that you still need the Platform IO file above but, besides that, just take the file, name it ‘main.cpp’, take the ‘platformio.ini’ file, restart VS Code and upload the code to your Crowpanel.

The file below is a very simple file that contains definitions for every pin you can use in the Crowpanel, this helps us while developing apps as instead of looking up which pin is the LED using, we can just use ‘IO_LED’.

#ifndef _crowpanelpins_h_
#define _crowpanelpins_h_

#include <Arduino.h>

// Crowpanel Pins
#define IO1_EXIT GPIO_NUM_1
#define IO2_MENU GPIO_NUM_2
#define IO3 GPIO_NUM_3
#define IO4_DOWN GPIO_NUM_4
#define IO5_CONF GPIO_NUM_5
#define IO6_UP GPIO_NUM_6
#define IO7_LCD_3_3_CTL GPIO_NUM_7
#define IO8 GPIO_NUM_8
#define IO9 GPIO_NUM_9
#define IO10_SPI_CS GPIO_NUM_10
#define IO11_SPI_MOSI GPIO_NUM_11
#define IO12_SPI_CLK GPIO_NUM_12
#define IO13_SPI_MISO GPIO_NUM_13
#define IO14 GPIO_NUM_14
#define IO15 GPIO_NUM_15
#define IO16 GPIO_NUM_16
#define IO17 GPIO_NUM_17
#define IO18 GPIO_NUM_18
#define IO19 GPIO_NUM_19
#define IO20 GPIO_NUM_20
#define IO21 GPIO_NUM_21

#define IO38 GPIO_NUM_38
#define IO39_SPI_CLK GPIO_NUM_39
#define IO40_SPI_MOSI GPIO_NUM_40
#define IO41_LED GPIO_NUM_41
#define IO42_TF_3_3_CTL GPIO_NUM_42

#define IO45_CS GPIO_NUM_45
#define IO46_DC GPIO_NUM_46
#define IO47_RES GPIO_NUM_47
#define IO48_BUSY GPIO_NUM_48

// SD Card Pins
#define IO_SD_CS IO10_SPI_CS
#define IO_SD_MOSI IO40_SPI_MOSI
#define IO_SD_CLK IO39_SPI_CLK
#define IO_SD_MISO IO13_SPI_MISO
#define IO_SD_POWER IO42_TF_3_3_CTL

// E-Ink display Pins
#define IO_EPD_BUSY IO48_BUSY
#define IO_EPD_RES IO47_RES
#define IO_EPD_DC IO46_DC
#define IO_EPD_CS IO45_CS
#define IO_EPD_CLK IO12_SPI_CLK
#define IO_EPD_MOSI IO11_SPI_MOSI
#define IO_EPD_POWER IO7_LCD_3_3_CTL
#define IO_EPD_SPI FSPI

// Rotary Switch Pins
#define IO_RSW_UP IO6_UP
#define IO_RSW_DOWN IO4_DOWN
#define IO_RSW_SEL IO5_CONF

// Push switch Pins
#define IO_SW_MENU IO2_MENU
#define IO_SW_EXIT IO1_EXIT

// Power LED
#define IO_LED IO41_LED
#define IO_LED_INIT pinMode(IO_LED, OUTPUT)
#define IO_LED_ON digitalWrite(IO_LED, HIGH)
#define IO_LED_OFF digitalWrite(IO_LED, LOW)

// GPIO Pins
#define IO_GPIO_B1 IO38
#define IO_GPIO_B2 IO20
#define IO_GPIO_B3 IO18
#define IO_GPIO_B4 IO16
#define IO_GPIO_B5 IO14
#define IO_GPIO_B6 IO8

#define IO_GPIO_F1 IO21
#define IO_GPIO_F2 IO19
#define IO_GPIO_F3 IO17
#define IO_GPIO_F4 IO15
#define IO_GPIO_F5 IO9
#define IO_GPIO_F6 IO3

// Not Connected
#define IO_NC GPIO_NUM_NC

#endif

2 - EPD Library

To control our display we will need a library and Elecrow’s folks took the time to make one that is available in their ‘ESP32_S3_Ink-Screen’ Github repo’, from there you will need the following files:

EPD.cpp
EPD.h
EPD_Init.cpp
EPD_Init.h
EPDfont.h
spi,cpp
spi.h

If you don’t want to download all those files, I made this header file that has all the code, keep in mind that I did not code this, I just copied it all to the same file and made some minor format changes.

3 - Code

Before we start coding this application, we will need to create a file named ‘pic.h’, this header file will contain some pictures we will use to make the application look cooler. You can get that file here.

Going into the main file now, let’s declare some variables at the very top of the file, these will let us customize the app a little without having to dive into the code. You will need your wifi ssid and password, the Coin Gekko Api url, key and coin and the sleep times:

const char* wifi_ssid = "wifiName"; // Wifi network name
const char* wifi_pass = "**********"; // Wifi password
const char* coingekko_api = "https://api.coingecko.com/api/v3/coins/bitcoin/tickers?exchange_ids=binance&x_cg_demo_api_key=" "youAPIkeyHERE";
const char* coingekko_coin = "BTC"; // Coin to track (also change url above)
const int sleepWakeupTime = 120; // Time in seconds between falling asleep and waking up (basically time between requests)
const int sleepWaitTime = 10; // Time in seconds to fall asleep

////////// ////////// ////////// ////////// ////////// ////////// ////////// ////////// ////////// ////////// ////////// ////////// ////////// ////////// //////////

#include <Arduino.h> // Arduino libs
#include <WiFi.h> // Connect to Wifi
#include <HTTPClient.h> // Make API Request
#include <ArduinoJson.h> // Parse API Response
#include <time.h> // Get current time

#include "crowpanel_pins.h" // Pins
//#include "epd/EPD.h" // Display - library
#include "crowpanel_epd.h" // Display - single file
#include "pic.h" // Images

To help us draw on the screen, copy the following two functions. The first one ‘scaleVal’, takes a value in a scale and scales it to another one, for example imagine we have the value 50 in the scale 0 to 100, if we use the scaleVal function to scale it to -1 to 1 we would get 0.

The second function takes in two values and gives us the difference, in percentage, between the two.

float scaleVal(float val, float l0, float h0, float l1, float h1)
{
    // clamp
    if (val < l0)
        val = l0;
    if (val > h0)
        val = h0;
    // calculate multipliers
    float s0 = h0 - l0;
    float s1 = h1 - l1;
    // prevent divide by 0
    if (s0 == 0 || s1 == 0)
        return l1;
    // return result
    return (((val - l0) * s1) / s0) + l1;
}

float percDif(float v1, float v0)
{
    float a = (v1 - v0), b = (v1 + v0);
    return a / (b / 2.0) * 100.0;
}

With the helper functions done let’s turn our eyes to the global variables we will need:

  • ‘image’: the buffer that the ‘EPD’ and ‘Paint’ functions use to draw
  • ‘euro’: true = display price in euro, false = display in usd
  • ‘values_x’: the last 25 values (euro and usd)
  • ‘valuei’: how full the values array is, when 25 is reached, the array will be shifted to fit a new value at the end
  • ‘timestr’: the time string shown in the graph
  • ‘lastInteractionTime’: last time a button was pressed, used to know how much time has passed since the Esp finished drawing or a button was pressed
  • ‘button_wakeup_mask’: tells the ESP which buttons should wake it up from sleep

From these variables the ones with most relevance are the ones starting with ‘RTC_DATA_ATTR’, this attribute tells the linker / compiler that these variables need to be stored in the clock’s RAM, because the clock, by default, does not lose power when the ESP enters a deep sleep, all our data will be kept ‘alive’ in there.

// Image buffer
uint8_t image[27200];

// Values to save while sleeping
RTC_DATA_ATTR bool euro = true;
RTC_DATA_ATTR float values_eur[25] = {0};
RTC_DATA_ATTR float values_usd[25] = {0};
RTC_DATA_ATTR uint8_t valuei = 0;
RTC_DATA_ATTR char timestr[64];

// Sleep
uint32_t lastInteractionTime = 0;
const uint64_t button_wakeup_mask = (1ULL << IO2_MENU) | (1ULL << IO1_EXIT);

The next function we will write is called ‘addValue’, its job is going to be to fill up the array and check when it is full. When full, it will have to shift all its values to make room for one more at the end, basically discards the oldest value and adds the new one.

This function is what will keep the graph showing the latest 25 values.

void addValue(float eur, float usd)
{
    if (valuei >= 25)
    {
        for (int i = 0; i <= 23; i++)
        {
            values_eur[i] = values_eur[i + 1];
            values_usd[i] = values_usd[i + 1];
        }
        values_eur[24] = eur;
        values_usd[24] = usd;
    }
    else
    {
        values_eur[valuei] = eur;
        values_usd[valuei++] = usd;
    }
}

Moving into CoinGekkos API now, we will need to make a request to the url we specified at the top of our main file, to do that we need to check if WiFi is connected, after that we create a HTTP client and start it up, we make a GET request, check it’s response code, get it’s body as a string and finally we end the client and return the string:

String request()
{
    if (WiFi.status() != WL_CONNECTED)
        return "{}";
    HTTPClient http;
    http.begin(coingekko_api);
    int resp = http.GET();
    if (resp <= 0)
        return "{}";
    String data = http.getString();
    http.end();
    return data;
}

The function ‘getValue’ is the one that will be calling the previous ‘request’ function. The job of this one is to convert the json result we get by doing the request into usable data. If you want to learn more about the Json library check out ArduinoJson’s website.

void getValue()
{
    JsonDocument obj;
    // convert json string to document 
    deserializeJson(obj, request());
    float eur = 0, usd = 0;
    // get tickers array
    JsonArray tickers = obj["tickers"];
    for (JsonVariant item : tickers) // loop through array
    {
        // get ticker from coin to euro
        if (item["base"] == coingekko_coin && item["target"] == "EUR")
        {
            // get euro price 
            if (item["last"].is<float>())
            {
                float v = item["last"].as<float>();
                Serial.println(v);
                eur = v;
            }
            // get dollar price
            if (item["converted_last"]["usd"].is<float>())
            {
                float v = item["last"].as<float>();
                Serial.println(v);
                usd = v;
            }
        }
    }
    // check if values are valid
    if (eur == 0 || usd == 0)
        Serial.println("No data");
    else
        addValue(eur, usd);

    // update last update time
    struct tm timeinfo;
    if (getLocalTime(&timeinfo))
        strftime(timestr, 63, "Last update %d/%m/%Y %H:%M:%S", &timeinfo);
    else 
        snprintf(timestr, 63, "Last update 00/00/0000 00:00:00 (Time Error)");

    return;
}

One important step we can’t forget is to connect to the WiFi network, to do that we only need to initialize the wifi object with our ssid and password (set at the top of the main file) and wait until it connects. After connecting it also initialize the Time of our Esp to use my timezone and the NTP server I usually use. The delay after the ‘configTime’ call is there to improve our chances of getting time before we proceed as there is a change that the time is not set when we go to draw on our display, but it is totally optional.

void initWifi()
{
    WiFi.begin(wifi_ssid, wifi_pass);
    Serial.println("Connecting to wifi...");
    while (WiFi.status() != WL_CONNECTED)
    {
        delay(500);
        Serial.print('.');
    }
    Serial.println("\nWiFi connected with address: " + WiFi.localIP().toString());
    // Time
    configTime(0, 0, "pool.ntp.org");
    vTaskDelay(500 / portTICK_PERIOD_MS);
    struct tm tminf;
    if (getLocalTime(&tminf))
        Serial.println(&tminf, "%d/%m/%Y %H:%M:%S");
}

The draw function will take the data we got from the API and the images we have in ‘pic.h’ and draw everything on the screen. All coordinates below were tested in Lopaka to see how they would look before building this application.

You can get the images I used for this program here.

void draw()
{
    float* values = euro ? values_eur : values_usd;

    char buf[64];

    // Turn on power to display
    digitalWrite(IO_EPD_POWER, HIGH);

    // Clear buffer
    Paint_Clear(WHITE);

    // Init display
    EPD_FastMode1Init();

    // Draw bitcoin
    EPD_ShowPicture(0, 0, 272, 272, gImage_bitcoin, WHITE);

    // Rectangle
    EPD_DrawRectangle(282, 10, 792 - 10, 272 - 50, BLACK, 0);

    if (valuei > 0)
    {
        // Calc min and max
        float valmin = std::numeric_limits<float>::max();
        float valmax = std::numeric_limits<float>::min();
        for (int i = 0; i < valuei; i++)
        {
            if (values[i] < valmin)
                valmin = values[i];
            if (values[i] > valmax)
                valmax = values[i];
        }

        // Points
        if (valuei > 1)
        {
            int x = 0, lastx = 0, y = 0, lasty = 0;
            for (int i = 0; i < valuei; i++)
            {
                lastx = x; lasty = y;
                x = 292 + (i * 20);
                y = scaleVal(values[i], valmin, valmax, 272 - 61, 31);
                if (i > 0)
                {
                    EPD_DrawLine(lastx, lasty, x, y, BLACK);
                    EPD_DrawLine(lastx, lasty + 1, x, y + 1, BLACK);
                    EPD_DrawLine(lastx, lasty - 1, x, y - 1, BLACK);
                    EPD_DrawLine(lastx + 1, lasty, x + 1, y, BLACK);
                    EPD_DrawLine(lastx - 1, lasty, x - 1, y, BLACK);
                    Serial.printf("%d: %f  (%d, %d) - (%d, %d)\n", i, values[i], lastx, lasty, x, y);
                }
                else 
                    Serial.printf("%d: %f  (%d, %d)\n", i, values[i], x, y);
            }
        }
        else
            EPD_DrawRectangle(291, 89, 293, 91, BLACK, 1);
        

        // Price
        float price = valuei < 25 ? values[valuei - 1] : values[24];
        if (euro)
        {
            if (price < 100000)
                snprintf(buf, 63, "EUR:%.2f", price);
            else
                snprintf(buf, 63, "EUR:%.1f", price);
        }
        else 
        {
            if (price < 100000)
                snprintf(buf, 63, "USD:%.2f", price);
            else
                snprintf(buf, 63, "USD:%.1f", price);
        }
        EPD_ShowString(292, 272 - 49, buf, 48, BLACK);

        // Percent
        float perc = 0;
        if (valuei >= 25)
            perc = percDif(values[24], values[23]);
        if (valuei >= 2)
            perc = percDif(values[valuei - 1], values[valuei - 2]);
        snprintf(buf, 63, "%.2f", perc < 0 ? perc * -1 : perc);
        EPD_ShowString((792 - 10 - 10 - (5 * 25)), 272 - 49, buf, 48, BLACK);

        // Up/Down
        if (perc < 0)
            EPD_ShowPicture((792 - 10 - 10 - (5 * 25)) - 47, 272 - 41, 32, 32, gImage_arrowdown, WHITE);
        else if (perc > 0)
            EPD_ShowPicture((792 - 10 - 10 - (5 * 25)) - 47, 272 - 41, 32, 32, gImage_arrowup, WHITE);
        else
            EPD_ShowPicture((792 - 10 - 10 - (5 * 25)) - 47, 272 - 41, 32, 32, gImage_arrowcenter, WHITE);
    }

    // Time
    EPD_ShowString(290, 12, timestr, 16, BLACK);

    // Send to display
    EPD_Display_Clear();
    EPD_Update();
    EPD_Display(image);
    EPD_FastUpdate();
    EPD_DeepSleep();

    // Turn off power to display
    digitalWrite(IO_EPD_POWER, LOW);
}

Now on the loop function, we will check if the Exit button is being pressed (bottom right) and if it is, we get the latest value, redraw the screen and set the current time as the last interaction time. If the Menu button is pressed, we switch the current currency, redraw and update the last interaction time. Finally, we check if the time between now and the last interaction is greater than the one we set at the top of main (default is 10) we put the Esp into a deep sleep.

void loop() 
{
    // Get new value
    if (!digitalRead(IO1_EXIT))
    {
        getValue();
        draw();
        lastInteractionTime = millis();
    }
    // Change currency
    if (!digitalRead(IO2_MENU))
    {
        euro = !euro;
        draw();
        lastInteractionTime = millis();
    }

    // Sleep
    if ((millis() - lastInteractionTime) > (sleepWaitTime * 1000))
        esp_deep_sleep_start();
}

Finally we get to the setup function, this one will be split into various code blocks to make it easier to explain step by step.

We start by setting up the power LED’s pin as output and turning it and then we begin serial:

void setup() 
{
    IO_LED_INIT;
    IO_LED_ON;
    Serial.begin(115200)
    
    // ...
}

Now we need to handle waking up from sleep and getting the Esp ready to sleep again. Start by creating a variable that will later tell us if a new value should be requested from the API, then we get the wakeup cause and the GPIO state when the Esp woke up. Make a switch case for the causes, add a couple of log messages and if the Menu button was the wakeup reason, flip the currency and set ‘updatevalue’ to false. Outside of the switch, prepare the Esp to sleep again by setting two wakeup reasons, the buttons, by giving it a button mask and the timer by giving it the time to wakeup:

void setup() 
{
    // ...
    
    // Setup sleep
    bool updatevalue = true;
    esp_sleep_wakeup_cause_t wakeup_cause = esp_sleep_get_wakeup_cause();
    uint64_t wakeup_gpio;
    switch (wakeup_cause)
    {
        case ESP_SLEEP_WAKEUP_EXT1:
            wakeup_gpio = esp_sleep_get_ext1_wakeup_status();
            if (wakeup_gpio & (1ULL << IO2_MENU))
            {
                euro = !euro;
                Serial.println("Wakeup: Btn Currency");
                updatevalue = false;
            }
            else if (wakeup_gpio & (1ULL << IO1_EXIT))
                Serial.println("Wakeup: Btn Update");
            else 
                Serial.println("Wakeup: Btn Unknown");
            break;
        case ESP_SLEEP_WAKEUP_TIMER:
            Serial.println("Wakeup: Clock");
            break;
        default:
            Serial.println("Wakeup: first boot");
            break;
    }
    esp_sleep_enable_ext1_wakeup(button_wakeup_mask, ESP_EXT1_WAKEUP_ANY_LOW);
    esp_sleep_enable_timer_wakeup(1000000 * sleepWakeupTime);

    // ...
}

The next step is to initialize the display. Set it’s power pin as output, initialize it’s other pins, enable power and run ‘FastMode1Init’, hardware reset it, initialize the buffer and turn the power back off:

void setup() 
{
    // ...
    
    // Initialize Display pins and buffer
    pinMode(IO_EPD_POWER, OUTPUT);
    EPD_GPIOInit();
    
    digitalWrite(IO_EPD_POWER, HIGH);
    
    EPD_FastMode1Init();
    EPD_HW_RESET();
    Paint_NewImage(image, EPD_W, EPD_H, Rotation, WHITE);
    Paint_Clear(WHITE);
    
    digitalWrite(IO_EPD_POWER, LOW);
    
    // ...
}

Now, at the end of setup, initialize WiFi, if needed, get the most recent value, update the screen and save the current time:

void setup() 
{
    // ...
    
    // Connect to wifi
    initWifi();

    // Get value and draw
    if (updatevalue)
        getValue();
    draw();

    // Save current time
    lastInteractionTime = millis();
}

And that’s it, you should now be able to upload the code and see your ticker ticking! Thanks for reading and stay tuned for more tech insights and tutorials.

Until next time, and keep exploring the world of tech!