Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / IoT / Arduino

A GPS Based Bike Speedometer on the TTGO T1 Display

5.00/5 (6 votes)
23 Jul 2024MIT16 min read 6.2K   81  
Make a little widget that tracks your speed using GPS and strap it to your bike.
I made a little widget to track your speed and approximate your travel distance using GPS. Here we'll explore how I did it.

Image 1

Introduction

Note that you'll need PlatformIO and a Lilygo TTGO T1 Display to work with this project.

I need to exercise. I work from home, I rarely drive these days, and Amazon Prime exists. In terms of my health, it's great. I look a lot more fit than I have any right to be but I'm getting older and I need to take care of myself, so I got a bike.

I'm a nerd. I can't just leave well enough alone. When I was a kid I used to build frankenbikes out of parts. I made a 10 speed BMX at one point that was at least 3 or 4 different bikes before I got to it.

As an adult I strapped a small engine to a mountain bike and used it as a grocery getter/conversation piece.

Nothing so  elaborate here, but the bike wouldn't be mine if I didn't do at least something to it.

One thing I thought might be useful was a speedometer, but I didn't just want to buy one with the wheel hub doodad on it, and they aren't usually geared for a BMX anyway (which is what I prefer, for proper urban bombing runs)

Why not GPS? $7 USD on Amazon gets you a little Blox monster that can talk to satellites and report your location via serial UART.

I already have several TTGO T1 Displays because they are cheap and incredibly versatile little beasts. They're like $18 on Amazon.

The battery runs between $5 and $8 depending on the size. I got a big one but I kind of regret it as my current 3d printed case won't accommodate it. I don't have a 3d printer, and wondering if I'd have to make the choice between such a device and my current orange cat (I have three, but the orange one is cause for concern/)

What I do have, is a buddy in Australia who puts up with me, gives me ideas, and has tested more than one Code Project submission for me now. I'm grateful to him because he did the testing, 3d printed the chassis and strapped it to his bike, before I even got both shoes on.

Understanding this Mess

This project uses my graphics and user interface libraries for IOT and embedded platforms.

It uses lwgps.h - copyright 2023 by Tilen Majerle - to decode the GPS serial data. Note that I couldn't use the original build tree for his code, because it doesn't like PlatformIO, so I imported the necessary files manually directly into the project. License information is in the relevant files.

The project can compile for the Arduino framework or the ESP-IDF.

There are several screens. The first is the speedometer screen. The second screen is the trip counter. The third screen is your GPS location, and the fourth is the satellite connection status.

The top left button changes which screen it's on, and the bottom left button changes between metric and imperial units. Long pressing the button on the trip counter screen will reset the trip counter. If the speedometer screen is the current screen and it's at zero it will fade out and sleep the display to save battery. Long pressing the bottom left button on the speedometer screen toggles between large and standard display.

It's pretty simple in operation. It regularly reads serial data off of the second UART on the ESP32. When there's data available, it feeds it to the GPS processor. As the GPS processor can, it reports updated information, including speed and location.

One thing that's a bit funky is the trip counter. It's approximate, and best for short distances. What it does is it polls ten times a second to determine the current speed, and then adds that projected distance to the trip counter. There are actually two of them - one for MPH and one for KPH so you can flip between them.

Coding this Mess

main.cpp

This is where the main logic is so we'll explore it first and most exhaustively. Since this is for Arduino or the ESP-IDF the code is forked in several places. Let's start with three defines:

C++
#define MAX_SPEED 40.0f
#define MILES
#define BAUD 9600

These determine the maximum speed displayed, in either KPH or MPH, whether or not we default to miles or kilometers, and the GPS baud rate.

On to the includes:

C++
#if __has_include(<Arduino.h>)
#include <Arduino.h>
#else
#include <stdio.h>
#include <stdint.h>
#include <memory.h>
#include <esp_lcd_panel_ops.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <driver/gpio.h>
#include <driver/uart.h>
#endif
#include <button.hpp>
#include <lcd_miser.hpp>
#include <uix.hpp>
#include "ui.hpp"
#include "lwgps.h"

The first section before #endif is boilerplate includes for either Arduino or the ESP-IDF. The second section is common library includes, and the final section is local source includes.

Next we import some namespaces and declare a compatibility shim if we're using the ESP-IDF:

C++
#ifdef ARDUINO
using namespace arduino;
#else
using namespace esp_idf;
// shim for compatibility
static uint32_t millis() {
    return pdTICKS_TO_MS(xTaskGetTickCount());
}
#endif

using namespace gfx;
using namespace uix;

Now onto some global declarations - firstly or library declarations:

C++
// two ttgo buttons
using button_a_t = basic_button;
using button_b_t = basic_button;
button_a_t button_a_raw(0,10,true);
button_b_t button_b_raw(35,10,true);
multi_button button_a(button_a_raw);
multi_button button_b(button_b_raw);

// screen dimmer
static lcd_miser<4> dimmer;

// gps decoder
static lwgps_t gps;

First we have our button declarations - the raw buttons on pin 0 and 35, and then multi_button wrappers so we can do things like process long clicks. After that is the dimmer on pin 4, followed by the GPS structure.

Local UI global variables come next:

C++
// ui data
static char speed_units[16];
static char trip_units[16];
static lwgps_speed_t gps_units;
static double trip_counter_miles = 0;
static double trip_counter_kilos = 0;
static char trip_buffer[64];
static char speed_buffer[3];
static char loc_lat_buffer[64];
static char loc_lon_buffer[64];
static char loc_alt_buffer[64];
static char stat_sat_buffer[64];
static int current_screen = 0;

We have the strings for the units we display, the current units as a lwgps_speed_t enumeration, two trip counters - one for miles, and one for kilometers. There's the trip buffer that holds the string for our trip counter, the speed buffer, which holds the speed, the strings for the GPS location, and the string for the stat screen. The current_screen is next and indicates which screen is currently being displayed.

Next we have some basic serial handing functions for the ESP32's second UART. You'll note the code is forked for Arduino versus the ESP-IDF. We use the rx_buffer later to feed it to the GPS processing code:

C++
// serial incoming
static char rx_buffer[1024];
// reads from Serial1/UART_NUM_1
static size_t serial_read(char* buffer, size_t size) {
#ifdef ARDUINO
    if(Serial1.available()) {
        return Serial1.read(buffer,size);
    } else {
        return 0;
    }
#else
    int ret = uart_read_bytes(UART_NUM_1,buffer,size,0);
    if(ret>0) {
        return (size_t)ret;
    } else if(ret<0) {
        puts("Serial error");
    }
    return 0;
#endif
}

After that we have toggle_units() which toggles between imperial and metric. What we're doing here is checking to see what our current units are and then setting it to the other one, in addition to updating the unit strings. Finally we update the labels with the new text, forcing a redraw of them.

C++
// switch between imperial and metric units
void toggle_units() {
    if(gps_units==LWGPS_SPEED_KPH) {
        gps_units = LWGPS_SPEED_MPH;
        strcpy(speed_units,"mph");
        strcpy(trip_units,"miles");
    } else {
        gps_units = LWGPS_SPEED_KPH;
        strcpy(speed_units,"kph");
        strcpy(trip_units,"kilometers");
    }
    speed_label.invalidate();
    speed_big_label.invalidate();
    speed_units_label.text(speed_units);
    speed_big_units_label.text(speed_units);
    trip_label.invalidate();
    trip_units_label.text(trip_units);
}

Now we have our button handlers. It should be noted that if the screen is dimming or off each button's functionality changes so that it only wakes the screen rather than performing its standard action. This code is at the beginning of each handler.

The first one is the top button. Button A is responsible for switching screens. Basically we increment and wrap current_screen and then based on that, we call display_screen() with the appropriate screen. Note that this uses the raw button on_pressed_changed callback and respond when the button is released. We use the raw callback because it's more immediate. The multi_button callbacks have a built in delay in order to handle multiple successive clicks as one event, or to handle long presses which ultimately makes them a bit less responsive. Since we don't need that functionality here we use the more immediate approach:

C++
// top button handler - switch screens
void button_a_on_pressed_changed(bool pressed, void* state) {
    if(!pressed) {
        display_wake();
        bool dim = dimmer.dimmed();
        dimmer.wake();
        if(dim) {
            return;
        }
        if(++current_screen==4) {
            current_screen=0;
        }
        switch(current_screen) {
            case 0:
                // puts("Speed screen");
                display_screen(speed_screen);
                break;
            case 1:
                // puts("Trip screen");
                display_screen(trip_screen);
                break;
            case 2:
                // puts("Location screen");
                display_screen(loc_screen);
                break;
            case 3:
                // puts("Stat screen");
                display_screen(stat_screen);
                break;
        }
    } 
}

The second handler is the bottom button short click handler. In addition to waking the screen, it has two other contextual functions. Normally it switches between units. If you long press it on the trip counter screen however, it resets the trip counter. We use the multi_button on_click callback here and if it's an odd number we change toggle units. The reason we check for an odd number is so we can properly handle multiple successive clicks. Only odd numbers of clicks actually change the units if you think about it. 1 click changes things. 2 clicks reverts to the initial value. 3 clicks changes things, changes them back, and then changes them again, and so on. All we do is toggle the units and then update the appropriate labels with the new strings, causing them to redraw. We probably should also redraw the speed needle and speed and trip labels here but unfortunately we don't have the appropriate information here. It's fine, because it gets picked up by the GPS on the next update:

C++
// bottom button handler, toggle units
void button_b_on_click(int clicks, void* state) {
    display_wake();
    bool dim = dimmer.dimmed();
    dimmer.wake();
    if(dim) {
        return;
    }
    clicks&=1;
    if(clicks) {
        toggle_units();
        speed_units_label.text(speed_units);
        trip_units_label.text(trip_units);
    }
}

The final handler is our long click for the button button, which resets the trip counter or changes the speedometer text size depending on the screen. It refreshes the controls as appropriate. When the speed screen changes, the big labels are made invisible and the regular controls are hidden, or vice versa: 

C++
// long handler - reset trip counter
void button_b_on_long_click(void* state) {
    display_wake();
    dimmer.wake();
    switch(current_screen) {
        case 0:
            if(speed_label.visible()) {
                speed_label.visible(false);
                speed_units_label.visible(false);
                speed_needle.visible(false);
                speed_big_label.visible(true);
                speed_big_units_label.visible(true);
            } else {
                speed_label.visible(true);
                speed_units_label.visible(true);
                speed_needle.visible(true);
                speed_big_label.visible(false);
                speed_big_units_label.visible(false);
            }
        break;
        case 1:
            trip_counter_miles = 0;
            trip_counter_kilos = 0;
        
            snprintf(trip_buffer,sizeof(trip_buffer),"% .2f",0.0f);
            trip_label.text(trip_buffer);
        break;
    }
}

update_all()

This is the main application loop so it's pretty meaty. We'll cover it in parts. A lot of the logic here focuses on avoiding updating the display unless it's necessary in order to save battery life. Due to that logic, it's a bit hairy at points.

The first thing we do is get any incoming data off the serial port and feed it to the GPS decoder:

C++
// try to read from the GPS
size_t read = serial_read(rx_buffer,sizeof(rx_buffer));
if(read>0) {
    lwgps_process(&gps,rx_buffer,read);
}

Most of the rest of the routine is guarded by the following test:

C++
// if we have GPS data:
if(gps.is_valid) {

The GPS decoder takes serial data in chunks, but it doesn't report until it gets enough chunks to form a complete GPS packet. Ergo, depending on where we are in the serial data, we may not have a valid GPS reading at this point. The vast majority of the rest of the code only executes if we do.

C++
// old values so we know when changes occur
// (avoid refreshing unless changed)
static double old_trip = NAN;
static int old_sats_in_use = -1;
static int old_sats_in_view = -1;
static float old_lat = NAN, old_lon = NAN, old_alt = NAN;
static float old_mph = NAN, old_kph = NAN;
static int old_angle = -1;

First we keep old values around in static variables. This is so we can determine when a value has changed so that we only update the necessary components of the screens, which dramatically reduces screen redraws, therefore increasing battery life. The initial values are invalid values in order to trigger a change on the first update. They aren't global because there are quite a few, and they are only used by this routine. I wanted to avoid polluting the default namespace.

C++
// for timing the trip counter
static uint32_t poll_ts = millis();
static uint64_t total_ts = 0;
// compute how long since the last
uint32_t diff_ts = millis()-poll_ts;
poll_ts = millis();
// add it to the total
total_ts += diff_ts;

In order to handle our trip counter, we need to time things. We keep a total_ts to tell us how much time has elapsed since we last updated the trip counter. Every 10th of a second we add the projected distance of the current speed to the trip counter - both km and miles. This facilitates that.

C++
float kph = lwgps_to_speed(gps.speed,LWGPS_SPEED_KPH);
float mph = lwgps_to_speed(gps.speed,LWGPS_SPEED_MPH);

Here we grab the speed in imperial and metric units which we use at several points.

C++
if(total_ts>=100) {
    while(total_ts>=100) {
        total_ts-=100;
        trip_counter_miles+=mph;
        trip_counter_kilos+=kph;
    }
    double trip = 
        (double)((gps_units==LWGPS_SPEED_KPH)? 
        trip_counter_kilos:
        trip_counter_miles)
        /(60.0*60.0*10.0);
    if(round(old_trip*100.0)!=round(trip*100.0)) {
        snprintf(trip_buffer,sizeof(trip_buffer),"% .2f",trip);
        trip_label.text(trip_buffer);
        old_trip = trip;
    }
}

What we're doing here is every 10th of a second we add the current speed to our accumulators, and subtract 100ms for every addition until total_ts is zero. This isn't perfect in the case where we happen to miss an iteration or two, but it's still okay in that instance because the speed probably isn't changing much between such short periods.

To compute the actual distance we divide our accumulator by the number of seconds in an hour * 100. This projects our distance.

After we get that we round the trip to the nearest hundredth and compare it to the old trip counter, only updating the label if it has changed.

C++
bool speed_changed = false;
int sp;
int old_sp;
if(gps_units==LWGPS_SPEED_KPH) {
    sp=(int)roundf(kph);
    old_sp = (int)roundf(old_kph);
    if(old_sp!=sp) {
        speed_changed = true;
    }
} else {
    sp=(int)roundf(mph);
    old_sp = (int)roundf(old_mph);
    if(old_sp!=sp) {
        speed_changed = true;
    }
}
old_mph = mph;
old_kph = kph;

Here we track if our actual speed has changed.

C++
if(!speed_changed && dimmer.faded()) {
    // if the speed isn't zero or it's not the speed screen wake the screen up
    if(current_screen!=0 || (gps_units==LWGPS_SPEED_KPH && ((int)roundf(kph))>0) || (gps_units==LWGPS_SPEED_MPH && ((int)roundf(mph))>0)) {
        display_wake();
        dimmer.wake();
    } else {
        // force a refresh next time the screen is woken
        if(old_angle!=-1) {
            old_trip = NAN;
            old_sats_in_use = -1;
            old_sats_in_view = -1;
            old_lat = NAN; old_lon = NAN; old_alt = NAN;
            old_angle = -1;
        }
        
        // make sure we pump before returning
        button_a.update();
        button_b.update();
        dimmer.update();
        return;
    }
}

This code is a short circuit if our speed hasn't changed and the screen is off. We don't want to bother executing the rest of the code if there's no need so we early out here.

C++
// update the speed
if(speed_changed) {
    if((gps_units==LWGPS_SPEED_KPH && 
        ((int)roundf(kph))>0) || 
            (gps_units==LWGPS_SPEED_MPH && 
                ((int)roundf(mph))>0)) {
        display_wake();
        dimmer.wake();
    }
    itoa((int)roundf(sp>MAX_SPEED?MAX_SPEED:sp),speed_buffer,10);
    speed_label.text(speed_buffer);
    speed_big_label.text(speed_buffer);
    // figure the needle angle
    float f = gps_units == LWGPS_SPEED_KPH?kph:mph;
    int angle = (270 + 
        ((int)roundf(((f>MAX_SPEED?MAX_SPEED:f)/MAX_SPEED)*180.0f)));
    while(angle>=360) angle-=360;
    if(old_angle!=angle) {
        speed_needle.angle(angle);
        old_angle = angle;
    }
}

Here if the speed changes first we wake the display if our current speed is greater than zero.

Next we fill the speed_buffer with the speed and then set the corresponding label.

After that we compute the needle angle which sweeps 180 degrees from left clockwise to right.

Note that we only mess with the needle if the angle has changed. The needle is rendered using SVG and redrawing it is relatively expensive.

C++
// update the position data
if(roundf(old_lat*100.0f)!=roundf(gps.latitude*100.0f)) {
    snprintf(loc_lat_buffer,sizeof(loc_lat_buffer),"lat: % .2f",gps.latitude);
    loc_lat_label.text(loc_lat_buffer);
    old_lat = gps.latitude;
}
if(roundf(old_lon*100.0f)!=roundf(gps.longitude*100.0f)) {
    snprintf(loc_lon_buffer,sizeof(loc_lon_buffer),"lon: % .2f",gps.longitude);
    loc_lon_label.text(loc_lon_buffer);
    old_lon = gps.longitude;
}
if(roundf(old_alt*100.0f)!=roundf(gps.altitude*100.0f)) {
    snprintf(loc_alt_buffer,sizeof(loc_lon_buffer),"alt: % .2f",gps.altitude);
    loc_alt_label.text(loc_alt_buffer);
    old_alt = gps.altitude;
}

Here we update the location screen with the current GPS coordinates as necessary.

C++
// update the stat data
if(gps.sats_in_use!=old_sats_in_use||
        gps.sats_in_view!=old_sats_in_view) {
    snprintf(stat_sat_buffer,
        sizeof(stat_sat_buffer),"%d/%d sats",
        (int)gps.sats_in_use,
        (int)gps.sats_in_view);
    stat_sat_label.text(stat_sat_buffer);
    old_sats_in_use = gps.sats_in_use;
    old_sats_in_view = gps.sats_in_view;
}

Finally, we do similar with the stats screen.

C++
// only screen zero auto-dims
if(current_screen!=0) {
    display_wake();
    dimmer.wake();
}
// update the various objects
display_update();
button_a.update();
button_b.update();
dimmer.update();

// if the backlight is off
// sleep the display
if(dimmer.faded()) {
    display_sleep();
}

After that we just handle screen dimming and pumping the various objects.

initialize_common()

This routine is responsible for the common (platform agnostic) initialization tasks.

C++
display_init();
button_a.initialize();
button_b.initialize();
button_a.on_pressed_changed(button_a_on_pressed_changed);
button_b.on_click(button_b_on_click);
button_b.on_long_click(button_b_on_long_click);
dimmer.initialize();
ui_init();
lwgps_init(&gps);

Here we initialize the hardware drivers, the UI, and then the GPS decoder.

C++
strcpy(speed_buffer,"--");
speed_label.text(speed_buffer);
speed_big_label.text(speed_buffer);
gps_units = LWGPS_SPEED_KPH;
strcpy(speed_units,"kph");
strcpy(trip_units,"kilometers");
speed_units_label.text(speed_units);
speed_big_units_label.text(speed_units);
trip_units_label.text(trip_units);

Now we set up various unit information - the speed buffer, the label texts and the gps_units variable.

C++
#ifdef MILES
    toggle_units();
#endif

If MILES is defined we toggle the units since it starts in kilometers.

C++
puts("Booted");

display_screen(speed_screen);

Here we just indicate that we've successfully booted and display the speed screen.

The rest of the code in main.cpp is platform specific.

C++
#ifdef ARDUINO
void setup() {
    Serial.begin(115200);
    Serial1.begin(BAUD,SERIAL_8N1,22,21);
    initialize_common();
}

void loop() {
    update_all();    
}

For Arduino it's pretty simple. All we do is initialize the serial ports and the call initialize_common() in setup(). In loop() we call update_all().

C++
#else
static void loop_task(void* state) {
    while(true) {
        update_all();
        vTaskDelay(1);
    }
}
extern "C" void app_main() {
    uart_config_t ucfg;
    memset(&ucfg,0,sizeof(ucfg));
    ucfg.baud_rate = BAUD;
    ucfg.data_bits = UART_DATA_8_BITS;
    ucfg.flow_ctrl = UART_HW_FLOWCTRL_DISABLE;
    ucfg.parity = UART_PARITY_DISABLE;
    ucfg.stop_bits = UART_STOP_BITS_1;
    ucfg.source_clk = UART_SCLK_DEFAULT;
    ESP_ERROR_CHECK(uart_param_config(UART_NUM_1,&ucfg));
    ESP_ERROR_CHECK(uart_set_pin(UART_NUM_1, 21, 22, -1, -1));
    const int uart_buffer_size = (1024 * 2);
    QueueHandle_t uart_queue;
    ESP_ERROR_CHECK(uart_driver_install(
        UART_NUM_1, 
        uart_buffer_size, 
        uart_buffer_size, 
        10, 
        &uart_queue, 
        0));
    initialize_common();
    TaskHandle_t htask = nullptr;
    xTaskCreate(loop_task,"loop_task",4096,nullptr,uxTaskPriorityGet(nullptr),&htask);
    if(htask==nullptr) {
        printf("Unable to create loop task\n");
    }
}
#endif

With the ESP-IDF it's more involved. We spawn a task (loop_task) to handle the update_all() functionality. In app_main() initializing the serial port takes quite a bit of boilerplate, and then we create the loop_task before exiting app_main().

display.hpp/display.cpp

These files handle initializing the display and setting the display's active screen. I use variations of these files in many projects, so there's stuff like #ifndef LCD_DMA forks in there that we aren't using for this project.

C++
#pragma once
#define LCD_TRANSFER_KB 64
#if __has_include(<Arduino.h>)
#include <Arduino.h>
#endif
#include <gfx.hpp>
#include "lcd_config.h"
#include <uix.hpp>

In display.hpp above we have includes we need, and a single define - LCD_TRANSFER_KB which indicates the maximum total amount of SRAM to allocate to the LCD transfer buffers in my user interface library, UIX.

C++
// here we compute how many bytes are needed in theory to store the total screen.
constexpr static const size_t lcd_screen_total_size = 
    gfx::bitmap<typename LCD_FRAME_ADAPTER::pixel_type>
        ::sizeof_buffer(LCD_WIDTH,LCD_HEIGHT);
// define our transfer buffer(s) 
// For devices with no DMA we only use one buffer.
// Our total size is either LCD_TRANSFER_KB 
// Or the lcd_screen_total_size - whatever
// is smaller
// Note that in the case of DMA the memory
// is divided between two buffers.

#ifdef LCD_DMA
constexpr static const size_t lcd_buffer_size = (LCD_TRANSFER_KB*512) >
    lcd_screen_total_size?lcd_screen_total_size:(LCD_TRANSFER_KB*512);
extern uint8_t* lcd_buffer1;
extern uint8_t* lcd_buffer2;
#else
#ifdef LCD_PSRAM_BUFFER
constexpr static const size_t lcd_buffer_size = LCD_PSRAM_BUFFER;
#else
constexpr static const size_t lcd_buffer_size = (LCD_TRANSFER_KB*1024) > 
    lcd_screen_total_size?lcd_screen_total_size:(LCD_TRANSFER_KB*1024);
#endif
extern uint8_t* lcd_buffer1;
static uint8_t* const lcd_buffer2 = nullptr;
#endif

This is a bit complicated but only because it's for handling many different kinds of displays. As I said this is generic code used in several projects. Essentially what's happening here is we're defining our total transfer buffer size, and dividing that between two buffers in the case of using DMA - which we are.

C++
// declare the screen type
using screen_t = uix::screen_ex<LCD_FRAME_ADAPTER,LCD_X_ALIGN,LCD_Y_ALIGN>;

Here we declare our screen type. The frame adapter is usually a bitmap, but some screens, like the SSD1306 use a strange frame buffer mapping, and so coordinate translation is necessary. In this case we're just using a gfx::bitmap<>. The align arguments are usually 1, but for some displays you can't update every pixel but rather for example, every 8 pixels, so these align values are set accordingly.

C++
// the active screen pointer
extern screen_t* display_active_screen;

// initializes the display
extern void display_init();
// updates the display, redrawing as necessary
extern void display_update();
// switches the active screen
extern void display_screen(screen_t& new_screen);
// puts the LCD to sleep
extern void display_sleep();
// wakes the LCD
extern void display_wake();

These are the declarations for our basic display functionality.

C++
#define LCD_IMPLEMENTATION
#include <lcd_init.h>
#include <display.hpp>

Here are the includes for display.cpp. The LCD_IMPLEMENTATION define is necessary due to lcd_init.h containing both declarations and implementation in the same file, so #define LCD_IMPLEMENTATION must be present in one C++ file before including the lcd_init.h header.

C++
// our transfer buffers
// For screens with no DMA we only 
// have one buffer
#ifdef LCD_DMA
uint8_t* lcd_buffer1=nullptr;
uint8_t* lcd_buffer2=nullptr;
#else
uint8_t* lcd_buffer1=nullptr;
#endif

This is just the implementation for our earlier transfer buffer declarations.

C++
// the active screen
screen_t* display_active_screen = nullptr;

// whether the display is sleeping
static bool display_sleeping = false;

This is the implementation for the active screen variable, and a global indicating if the display is asleep so that we only call the actual sleep and wake commands when necessary.

C++
#ifdef LCD_DMA
// only needed if DMA enabled
static bool lcd_flush_ready(esp_lcd_panel_io_handle_t panel_io, 
                            esp_lcd_panel_io_event_data_t* edata, 
                            void* user_ctx) {
    if(display_active_screen!=nullptr) {
        display_active_screen->flush_complete();
    }
    return true;
}
#endif

This is the DMA callback that tells the current active screen that we're done flushing the display and the requisite transfer buffer is now available for drawing again.

C++
static void uix_flush(const gfx::rect16& bounds, 
                    const void* bmp, 
                    void* state) {
    lcd_panel_draw_bitmap(bounds.x1,bounds.y1,bounds.x2,bounds.y2,(void*)bmp);
    // no DMA, so we are done once the above completes
#ifndef LCD_DMA
    if(active_screen!=nullptr) {
        active_screen->flush_complete();
    }
#endif
}

This routine sends bitmaps from the active screen to the display.

C++
void display_init() {
    lcd_buffer1 = (uint8_t*)heap_caps_malloc(lcd_buffer_size,MALLOC_CAP_DMA);
    if(lcd_buffer1==nullptr) {
        puts("Error allocating LCD buffer 1");
        while(1);
    }
#ifdef LCD_DMA
    lcd_buffer2 = (uint8_t*)heap_caps_malloc(lcd_buffer_size,MALLOC_CAP_DMA);
    if(lcd_buffer2==nullptr) {
        puts("Error allocating LCD buffer 2");
        while(1);
    }
#endif
    lcd_panel_init(lcd_buffer_size,lcd_flush_ready);
}

This routine initializes the display after allocating the memory for the transfer buffers. 

C++
void display_update() {
    if(display_active_screen!=nullptr) {
        display_active_screen->update();
    }
}

display_update() simply pumps the active screen.

C++
void display_screen(screen_t& new_screen) {
    display_active_screen = &new_screen;
    display_active_screen->on_flush_callback(uix_flush);
    display_active_screen->invalidate();   
}

The above routine sets the active screen and tells it it needs to repaint.

C++
void display_sleep() {
    if(!display_sleeping) {
        //esp_lcd_panel_io_tx_param(lcd_io_handle,0x10,NULL,0);
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
        esp_lcd_panel_disp_on_off(lcd_handle, false);
#else
        esp_lcd_panel_disp_off(lcd_handle, true);
#endif
        display_sleeping = true;
    }
}
void display_wake() {
    if(display_sleeping) {
        //esp_lcd_panel_io_tx_param(lcd_io_handle,0x11,NULL,0);
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
        esp_lcd_panel_disp_on_off(lcd_handle, true);
#else
        esp_lcd_panel_disp_off(lcd_handle, false);
#endif
        display_sleeping = false;
    }
}

These two routines simply use the ESP LCD Panel API to sleep or wake the display as needed.

svg_needle.hpp

This is the needle control for the speedometer. It uses SVG and some simple trigonometry to render.

C++
gfx::svg_shape_info si;
si.fill.type = gfx::svg_paint_type::color;
si.stroke.type = gfx::svg_paint_type::color;
gfx::pointf offset(0, 0);
gfx::pointf center(0, 0);
float rotation(0);
float ctheta, stheta;
gfx::ssize16 size = this->bounds().dimensions();
gfx::rectf b = gfx::sizef(size.width, size.height).bounds();
gfx::svg_doc_builder db(b.dimensions());
gfx::svg_path_builder pb;
gfx::svg_path* path;
float w = b.width();
float h = b.height();
if(w>h) w= h;
center = gfx::pointf(w * 0.5f + 1, w * 0.5f + 1);
gfx::rectf sr = gfx::rectf(0, w / 40, w / 16, w / 2);
sr.center_horizontal_inplace(b);
rotation = m_angle;
update_transform(rotation, ctheta, stheta);
pb.move_to(translate(ctheta, stheta, center, offset, sr.x1 + sr.width() * 0.5f, sr.y1));
pb.line_to(translate(ctheta, stheta, center, offset, sr.x2, sr.y2));
pb.line_to(translate(ctheta, stheta, center, offset, sr.x1 + sr.width() * 0.5f, sr.y2 + (w / 20)));
pb.line_to(translate(ctheta, stheta, center, offset, sr.x1, sr.y2));
pb.to_path(&path, true);
si.fill.color = m_needle_color;
si.stroke.color = m_needle_border_color;
si.stroke_width = m_needle_border_width;
db.add_path(path, si);
db.to_doc(&m_svg);

Most of the code is boilerplate, but we'll cover the rendering part. If the properties were changed the control will be marked as "dirty" and will trigger the above code before the next time the control is repainted.

What we're doing is creating an SVG document on the fly. Specifically it has one shape in it, which is the needle. It's a polygon which is a kind of path so we just build that and then set the svg_doc to it.

ui.hpp/ui.cpp

These files are responsible for defining and laying out the user interface. Starting off, we the includes in ui.hpp:

C++
#pragma once
#include "display.hpp"
#include <uix.hpp>
#include <gfx.hpp>
#include "svg_needle.hpp"

Nothing special here, just some includes.

C++
// for our controls
using surface_t = screen_t::control_surface_type;
using label_t = uix::label<surface_t>;
using needle_t = svg_needle<surface_t>;
// X11 colors (used for screen)
using color_t = gfx::color<typename screen_t::pixel_type>;
// RGBA8888 X11 colors (used for controls)
using color32_t = gfx::color<gfx::rgba_pixel<32>>;

These define several type aliases used by UIX. Namely we're declaring aliases for each type of UIX control we're using, and then two GFX virtual X11 color enumerations, for easier access later. Note that we have two of those because one is in the screen's native format, and the one UIX uses is always 32-bit RGBA.

C++
// the screens and controls
extern screen_t speed_screen;
extern needle_t speed_needle;
extern label_t speed_label;
extern label_t speed_units_label;
extern label_t speed_big_label;
extern label_t speed_big_units_label;

extern screen_t trip_screen;
extern label_t trip_label;
extern label_t trip_units_label;

extern screen_t loc_screen;
extern label_t loc_lat_label;
extern label_t loc_lon_label;
extern label_t loc_alt_label;

extern screen_t stat_screen;
extern label_t stat_sat_label;

Here we're declaring each of the screens and the controls they use so that they can accessed elsewhere in the application.

C++
extern void ui_init();

This function just initializes all the UI objects.

Next we have ui.cpp:

C++
// our font for the UI. 
#define OPENSANS_REGULAR_IMPLEMENTATION
#include "fonts/OpenSans_Regular.hpp"
#include "ui.hpp"

Here our are includes. Note we have the define there. The font file is a single file containing the declaration and implementation both, so this define must appear before the include in exactly one implementation file. The font by the way, was downloaded from fontsquirrel.com and translated using my online header generator tool.

C++
// for easier modification
const gfx::open_font& text_font = OpenSans_Regular;

This line is just an alias for our font so we can change it easily.

C++
using namespace uix;
using namespace gfx;

These are just boilerplate.

C++
screen_t speed_screen;
needle_t speed_needle(speed_screen);
label_t speed_label(speed_screen);
label_t speed_units_label(speed_screen);
label_t speed_big_label(speed_screen);
label_t speed_big_units_label(speed_screen);

screen_t trip_screen;
label_t trip_label(trip_screen);
label_t trip_units_label(trip_screen);

screen_t loc_screen;
label_t loc_lat_label(loc_screen);
label_t loc_lon_label(loc_screen);
label_t loc_alt_label(loc_screen);

screen_t stat_screen;
label_t stat_sat_label(stat_screen);

Here we define each screen and control we declared earlier.

ui_init()

This function is simple but long, so I've broken it up here.

C++
// declare a transparent pixel/color
rgba_pixel<32> transparent(0, 0, 0, 0);

The first thing we do is simply make a transparent pixel by setting all the channels to zero - really only the final (alpha) channel matters, but we have to choose some kind of value, so we just use zeroes all the way across.

C++
speed_screen.dimensions({LCD_WIDTH,LCD_HEIGHT});
speed_screen.buffer_size(lcd_buffer_size);
speed_screen.buffer1(lcd_buffer1);
speed_screen.buffer2(lcd_buffer2);

This sets up the speed_screen with the proper dimensions and buffers.

C++
speed_needle.bounds(srect16(0,0,127,127).center_vertical(
    speed_screen.bounds()).offset(0,speed_screen.dimensions().height/5));
speed_needle.needle_border_color(color32_t::red);
rgba_pixel<32> nc(true,.5f,0,0);
speed_needle.needle_color(nc);
speed_needle.angle(270);
speed_screen.register_control(speed_needle);

Here's where we initialize the needle control. We make it 128x128 even though it only uses about half that because the needle can go 360 degrees but we only use 180 of it. We center it vertically but then offset it just a hair further down for a better look. Note that I like to use relative values rather than fixed values so if I ever port this to another device it's much easier.

One thing is the needle color, nc. Similar to transparent, we set the channels for it on initialization, except we use scaled floating point values as indicated by the first parameter being a dummy bool. In this case we set the red channel to 50%.

Next we just set the needle color we created, and register the control.

C++
speed_label.text_open_font(&text_font);
const size_t text_height = (int)floorf(speed_screen.dimensions().height/1.5f);
speed_label.text_line_height(text_height);
srect16 speed_rect = text_font.
    measure_text(ssize16::max(),
        spoint16::zero(),
        "888",
        text_font.scale(text_height),
        0,
        speed_label.text_encoding())
            .bounds()
            .center_vertical(speed_screen.bounds());
speed_rect.offset_inplace(speed_screen.dimensions().width-speed_rect.width(),0);
speed_label.text_justify(uix_justify::top_right);
speed_label.border_color(transparent);
speed_label.background_color(transparent);
speed_label.text_color(color32_t::white);
speed_label.bounds(speed_rect);
speed_label.text("--");
speed_screen.register_control(speed_label);

This is a bit more complicated. Mainly finding the bounds is a bit tricky. What we do is measure the width of "888" in the current text_font at our target line height and use that to determine the size. The rest of it should be pretty self explanatory.

C++
const size_t speed_unit_height = text_height/4;
const size_t speed_unit_width = text_font.measure_text(
    ssize16::max(),
    spoint16::zero(),
    "MMM",
    text_font.scale(speed_unit_height)).width;
speed_units_label.bounds(
    srect16(speed_label.bounds().x1,
        speed_label.bounds().y1+text_height,
        speed_label.bounds().x2,
        speed_label.bounds().y1+text_height+speed_unit_height));
speed_units_label.text_open_font(&text_font);
speed_units_label.text_line_height(speed_unit_height);
speed_units_label.text_justify(uix_justify::top_right);
speed_units_label.border_color(transparent);
speed_units_label.background_color(transparent);
speed_units_label.text_color(color32_t::white);
speed_units_label.text("---");
speed_screen.register_control(speed_units_label);

We do similar with measuring the text to find the width, only we use "MMM" here since it's letters instead of digits.

C++
speed_big_label.bounds(
    srect16(0,
        0,
        speed_screen.dimensions().width-speed_unit_width-3,
        speed_screen.bounds().y2));
speed_big_label.text_open_font(&text_font);
speed_big_label.text_line_height(speed_screen.dimensions().height*1.2);
speed_big_label.border_color(transparent);
speed_big_label.background_color(transparent);
speed_big_label.text_color(color32_t::white);
speed_big_label.text("--");
speed_big_label.visible(false);
speed_big_label.text_justify(uix_justify::center_right);
speed_screen.register_control(speed_big_label);

The big speed label is easier to place than the little one because we don't have a needle to contend with. Note that we're making the font slightly bigger than the screen. There's overhang that we don't care about and don't want to display, so we just make the font bigger since we can.

Also note that this control starts out invisible - visible(false).

C++
speed_big_units_label.bounds(
    srect16(speed_screen.dimensions().width-speed_unit_width-1,
        0,
        speed_screen.bounds().x2,
        speed_unit_height-1)
            .center_vertical(speed_screen.bounds()));
speed_big_units_label.text_open_font(&text_font);
speed_big_units_label.text_line_height(speed_unit_height);
speed_big_units_label.text_justify(uix_justify::center_right);
speed_big_units_label.border_color(transparent);
speed_big_units_label.background_color(transparent);
speed_big_units_label.text_color(color32_t::white);
speed_big_units_label.text("---");
speed_big_units_label.visible(false);
speed_screen.register_control(speed_big_units_label);

The big units label is to the right of the big font, vertically centered. The control is also invisible to start with.

C++
trip_screen.dimensions({LCD_WIDTH,LCD_HEIGHT});
trip_screen.buffer_size(lcd_buffer_size);
trip_screen.buffer1(lcd_buffer1);
trip_screen.buffer2(lcd_buffer2);

Setting up the trip screen is the same as the speed screen.

C++
trip_label.text_open_font(&text_font);
trip_label.text_justify(uix_justify::top_right);
trip_label.text_line_height(text_height);
trip_label.padding({10,0});
trip_label.background_color(transparent);
trip_label.border_color(transparent);
trip_label.text_color(color32_t::orange);
trip_label.bounds(srect16(0,0,trip_screen.bounds().x2,text_height+1));
trip_label.text("----");
trip_screen.register_control(trip_label);

Laying out this label is pretty simple.

C++
trip_units_label.bounds(
        srect16(trip_label.bounds().x1,
            trip_label.bounds().y1+text_height+1,
            trip_label.bounds().x2,
            trip_label.bounds().y1+text_height+speed_unit_height+1));
    trip_units_label.text_open_font(&text_font);
    trip_units_label.text_line_height(speed_unit_height);
    trip_units_label.text_justify(uix_justify::top_right);
    trip_units_label.border_color(transparent);
    trip_units_label.background_color(transparent);
    trip_units_label.text_color(color32_t::white);
    trip_units_label.text("---");
    trip_screen.register_control(trip_units_label);

That's all she wrote for the trip units label.

C++
loc_screen.dimensions({LCD_WIDTH,LCD_HEIGHT});
loc_screen.buffer_size(lcd_buffer_size);
loc_screen.buffer1(lcd_buffer1);
loc_screen.buffer2(lcd_buffer2);

Setting up the location screen is just like the others.

C++
const size_t loc_height = trip_screen.dimensions().height/4;
loc_lat_label.bounds(
    srect16(spoint16(10,loc_height/2),
        ssize16(trip_screen.dimensions().width-20,loc_height)));
loc_lat_label.text_open_font(&text_font);
loc_lat_label.text_line_height(loc_height);
loc_lat_label.padding({0,0});
loc_lat_label.border_color(transparent);
loc_lat_label.background_color(transparent);
loc_lat_label.text_color(color32_t::aqua);
loc_lat_label.text("lat: --");
loc_screen.register_control(loc_lat_label);

First we layout the latitude label. The rest are laid out relative to it.

C++
loc_lon_label.bounds(loc_lat_label.bounds().offset(0,loc_height));
loc_lon_label.text_open_font(&text_font);
loc_lon_label.text_line_height(loc_height);
loc_lon_label.padding({0,0});
loc_lon_label.border_color(transparent);
loc_lon_label.background_color(transparent);
loc_lon_label.text_color(color32_t::aqua);
loc_lon_label.text("lon: --");
loc_screen.register_control(loc_lon_label);
loc_alt_label.bounds(loc_lon_label.bounds().offset(0,loc_height));
loc_alt_label.text_open_font(&text_font);
loc_alt_label.text_line_height(loc_height);
loc_alt_label.padding({0,0});
loc_alt_label.border_color(transparent);
loc_alt_label.background_color(transparent);
loc_alt_label.text_color(color32_t::aqua);
loc_alt_label.text("alt: --");
loc_screen.register_control(loc_alt_label);

Both of these labels are just laid out under the first one, each successively further down the screen.

C++
stat_screen.dimensions({LCD_WIDTH,LCD_HEIGHT});
stat_screen.buffer_size(lcd_buffer_size);
stat_screen.buffer1(lcd_buffer1);
stat_screen.buffer2(lcd_buffer2);

Here we set the status screen vitals, as before.

C++
stat_sat_label.text_open_font(&text_font);
stat_sat_label.text_justify(uix_justify::center);
stat_sat_label.text_line_height(text_height/2);
stat_sat_label.padding({10,0});
stat_sat_label.background_color(transparent);
stat_sat_label.border_color(transparent);
stat_sat_label.text_color(color32_t::light_blue);
stat_sat_label.bounds(
    srect16(0,0,stat_screen.bounds().x2,text_height+1));
stat_sat_label.text("-/- sats");
stat_screen.register_control(stat_sat_label);

There's the status label.

That's it for the UI.

Conclusion

The files we didn't cover are either not my code, or are part of another project or lib that I imported and are outside the scope of this project. lcd_init.h for example, is used in many projects of mine, and would be a beast to cover here.

At any rate, I hope you enjoy this little toy. Happy coding!

History

  • 23rd July, 2024 - Initial submission

License

This article, along with any associated source code and files, is licensed under The MIT License