I made a little widget to track your speed and approximate your travel distance using GPS. Here we'll explore how I did it.
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:
#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:
#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:
#ifdef ARDUINO
using namespace arduino;
#else
using namespace esp_idf;
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:
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);
static lcd_miser<4> dimmer;
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:
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:
static char rx_buffer[1024];
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.
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:
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:
display_screen(speed_screen);
break;
case 1:
display_screen(trip_screen);
break;
case 2:
display_screen(loc_screen);
break;
case 3:
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:
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:
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:
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:
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.
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.
static uint32_t poll_ts = millis();
static uint64_t total_ts = 0;
uint32_t diff_ts = millis()-poll_ts;
poll_ts = millis();
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.
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.
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.
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.
if(!speed_changed && dimmer.faded()) {
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 {
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;
}
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.
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);
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.
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.
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.
if(current_screen!=0) {
display_wake();
dimmer.wake();
}
display_update();
button_a.update();
button_b.update();
dimmer.update();
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.
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.
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.
#ifdef MILES
toggle_units();
#endif
If MILES
is defined we toggle the units since it starts in kilometers.
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.
#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()
.
#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.
#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.
constexpr static const size_t lcd_screen_total_size =
gfx::bitmap<typename LCD_FRAME_ADAPTER::pixel_type>
::sizeof_buffer(LCD_WIDTH,LCD_HEIGHT);
#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.
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.
extern screen_t* display_active_screen;
extern void display_init();
extern void display_update();
extern void display_screen(screen_t& new_screen);
extern void display_sleep();
extern void display_wake();
These are the declarations for our basic display functionality.
#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.
#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.
screen_t* display_active_screen = nullptr;
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.
#ifdef LCD_DMA
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.
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);
#ifndef LCD_DMA
if(active_screen!=nullptr) {
active_screen->flush_complete();
}
#endif
}
This routine sends bitmaps from the active screen to the display.
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.
void display_update() {
if(display_active_screen!=nullptr) {
display_active_screen->update();
}
}
display_update()
simply pumps the active screen.
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.
void display_sleep() {
if(!display_sleeping) {
#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) {
#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.
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:
#pragma once
#include "display.hpp"
#include <uix.hpp>
#include <gfx.hpp>
#include "svg_needle.hpp"
Nothing special here, just some includes.
using surface_t = screen_t::control_surface_type;
using label_t = uix::label<surface_t>;
using needle_t = svg_needle<surface_t>;
using color_t = gfx::color<typename screen_t::pixel_type>;
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.
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.
extern void ui_init();
This function just initializes all the UI objects.
Next we have ui.cpp:
#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.
const gfx::open_font& text_font = OpenSans_Regular;
This line is just an alias for our font so we can change it easily.
using namespace uix;
using namespace gfx;
These are just boilerplate.
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.
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.
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.
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.
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.
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.
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)
.
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.
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.
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.
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.
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.
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.
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.
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.
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