基于 GPS 的 TTGO T1 显示屏自行车速度计





5.00/5 (6投票s)
制作一个小部件,使用GPS跟踪您的速度并将其安装在自行车上。
引言
请注意,您需要PlatformIO和Lilygo TTGO T1显示屏才能使用此项目。
我需要锻炼。我在家工作,现在很少开车,而且亚马逊Prime的存在。从健康的角度来看,这很棒。我看起来比我应得的要健康得多,但我年纪大了,需要照顾好自己,所以我买了一辆自行车。
我是一个书呆子。我就是闲不下来。小时候,我喜欢用零件组装各种自行车。我曾经制作过一辆10速BMX,它在我接手之前至少是由3到4辆不同的自行车组成的。
成年后,我把一个小引擎装在山地自行车上,用它来买菜/作为谈资。
这里没什么复杂的东西,但如果我不做点什么,这辆自行车就不算我的了。
我想到的一个可能有用的小工具是速度计,但我不想买那种装在轮毂上的,而且它们通常也不适合BMX(我更喜欢BMX,适合在城市里飞驰)。
为什么不用GPS呢?在亚马逊上花7美元,你就可以买到一个小巧的Blox模块,它可以与卫星通信,并通过串行UART报告你的位置。
我已经有几个TTGO T1显示屏了,因为它们便宜而且功能极其强大。在亚马逊上大约18美元。
电池的价格在5到8美元之间,具体取决于尺寸。我买了一个大容量的,但我有点后悔,因为我目前的3D打印外壳装不下它。我没有3D打印机,而且我在想,我是否不得不在这样的设备和我现在的橘猫之间做出选择(我有三只猫,但橘猫让我担心)。
我有一个在澳大利亚的朋友,他容忍我,给我建议,并且已经帮我测试了好几个Code Project的投稿。我很感激他,因为在他穿上鞋子之前,他就已经完成了测试,3D打印了外壳,并将其安装在他的自行车上。
理解这段乱码
本项目使用了我的物联网和嵌入式平台的图形和用户界面库。
它使用了lwgps.h - 版权所有2023 Tilen Majerle - 用于解码GPS串行数据。请注意,我无法使用他的代码的原始构建树,因为它不支持PlatformIO,所以我将必要的文件手动直接导入到项目中。许可信息在相关文件中。
该项目可以为Arduino框架或ESP-IDF编译。
有几个屏幕。第一个是速度计屏幕。第二个屏幕是里程计数器。第三个屏幕是您的GPS位置,第四个是卫星连接状态。
左上角的按钮用于切换屏幕,左下角的按钮用于切换公制和英制单位。长按里程计数器屏幕上的按钮将重置里程计数器。如果当前是速度计屏幕并且速度为零,它将淡出并使显示屏休眠以节省电池。长按速度计屏幕上的左下角按钮可在放大和标准显示之间切换。
操作很简单。它定期读取ESP32第二个UART的串行数据。当有数据可用时,它会将其馈送到GPS处理器。GPS处理器会根据其能力报告更新信息,包括速度和位置。
里程计数器有点奇怪。它是近似的,最适合短距离。它的工作方式是每秒轮询十次以确定当前速度,然后将该预测距离添加到里程计数器中。实际上有两个计数器——一个用于MPH,一个用于KPH,所以你可以切换它们。
编写这个混乱的程序
main.cpp
这是主要逻辑所在,所以我们将最全面地探讨它。由于这是为Arduino或ESP-IDF设计的,代码在几个地方进行了分支。让我们从三个宏定义开始
#define MAX_SPEED 40.0f
#define MILES
#define BAUD 9600
这些宏定义了显示的最大速度(以KPH或MPH为单位),是否默认使用英里或公里,以及GPS的波特率。
接下来是包含文件
#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"
在#endif
之前的第一个部分是Arduino或ESP-IDF的样板包含文件。第二部分是通用库的包含文件,最后一部分是本地源文件的包含文件。
接下来我们导入一些命名空间,并在使用ESP-IDF时声明一个兼容层
#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;
现在来看一些全局声明——首先是我们的库声明
// 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;
首先是我们的按钮声明——引脚0和35上的原始按钮,然后是multi_button
包装器,以便我们可以执行诸如处理长按之类的操作。之后是引脚4上的调光器,然后是GPS结构。
接下来是本地UI全局变量
// 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;
我们有显示单位的字符串,当前单位的lwgps_speed_t
枚举,两个里程计数器——一个用于英里,一个用于公里。有保存里程计数器字符串的trip_buffer
,保存速度的speed_buffer
,GPS位置的字符串,以及状态屏幕的字符串。接下来是current_screen
,指示当前正在显示的屏幕。
接下来是一些基本的串行处理函数,用于ESP32的第二个UART。您会注意到代码是针对Arduino和ESP-IDF分支的。我们稍后使用rx_buffer
将其馈送到GPS处理代码。
// 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
}
之后是toggle_units()
,它在公制和英制之间切换。我们在这里做的是检查我们当前的单位是什么,然后将其设置为另一个单位,并更新单位字符串。最后,我们更新标签上的新文本,强制重绘它们。
// 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);
}
现在是我们的按钮处理程序。需要注意的是,如果屏幕正在变暗或关闭,每个按钮的功能都会改变,以便它只会唤醒屏幕,而不是执行其标准操作。这段代码位于每个处理程序的开头。
第一个是顶部的按钮。按钮A负责切换屏幕。基本上,我们递增并循环current_screen
,然后根据它,我们调用display_screen()
来显示相应的屏幕。请注意,这使用了原始的button
on_pressed_changed
回调,并在按钮释放时响应。我们使用原始回调是因为它更即时。multi_button回调有一个内置延迟,以处理多个连续点击作为单个事件,或处理长按,这最终使它们不那么灵敏。由于我们在这里不需要这些功能,所以我们使用了更即时的方法。
// 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;
}
}
}
第二个处理程序是底部按钮的短按处理程序。除了唤醒屏幕外,它还有两个其他上下文功能。通常它在单位之间切换。但是,如果在里程计数器屏幕上长按它,它会重置里程计数器。我们在这里使用multi_button
on_click
回调,如果点击次数是奇数,我们就切换单位。之所以检查奇数次点击,是因为我们可以正确处理多个连续点击。只有奇数次点击实际上才会改变单位,如果你仔细想想。1次点击改变东西。2次点击恢复到初始值。3次点击会改变东西,改回来,然后再次改变,以此类推。我们所做的只是切换单位,然后更新相应的标签上的新字符串,使它们重绘。我们可能也应该在这里重绘速度针和速度及里程标签,但遗憾的是,我们在这里没有足够的信息。没关系,因为GPS将在下次更新时拾取。
// 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);
}
}
最后一个处理程序是按钮的长按处理程序,它会重置里程计数器或根据屏幕更改速度计文本大小。它会适当地刷新控件。当速度屏幕改变时,大标签会变得不可见,常规控件会被隐藏,反之亦然:
// 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()
这是主应用程序循环,所以它相当庞大。我们将分块介绍。这里的许多逻辑侧重于避免不必要地更新显示,以节省电池寿命。由于这种逻辑,它有时会有些混乱。
我们做的第一件事是从串行端口获取任何传入数据,并将其馈送到GPS解码器。
// 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);
}
其余的大部分例程都受以下测试的保护
// if we have GPS data:
if(gps.is_valid) {
GPS解码器分块接收串行数据,但直到获得足够的数据块形成完整的GPS包才会报告。因此,根据我们在串行数据中的位置,此时我们可能没有有效的GPS读数。绝大多数其余代码只有在我们有有效GPS读数时才会执行。
// 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;
首先,我们在静态变量中保留旧值。这样我们就可以确定值何时发生变化,以便只更新屏幕的必要组件,这会大大减少屏幕重绘次数,从而延长电池寿命。初始值是无效值,以便在第一次更新时触发更改。它们不是全局变量,因为数量很多,而且只由这个例程使用。我不想污染默认命名空间。
// 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;
为了处理我们的里程计数器,我们需要计时。我们保留一个total_ts
来告诉我们自上次更新里程计数器以来经过了多少时间。每十分之一秒,我们将当前速度的预测距离添加到里程计数器中——包括公里和英里。这有助于实现这一点。
float kph = lwgps_to_speed(gps.speed,LWGPS_SPEED_KPH);
float mph = lwgps_to_speed(gps.speed,LWGPS_SPEED_MPH);
在这里,我们获取以英制和公制单位表示的速度,我们在多个地方使用它们。
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;
}
}
我们在这里做的是每十分之一秒将当前速度添加到我们的累加器中,并为每次添加减去100毫秒,直到total_ts
为零。当发生遗漏一次或两次迭代的情况时,这并不完美,但在那种情况下仍然可以,因为速度在这么短的时间间隔内可能变化不大。
为了计算实际距离,我们将累加器除以一小时的秒数 * 100。这可以预测我们的距离。
获得这个之后,我们将里程四舍五入到最接近的百分之一,并与旧的里程计数器进行比较,仅在它发生变化时更新标签。
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;
在这里,我们跟踪实际速度是否发生变化。
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;
}
}
如果速度没有变化且屏幕关闭,这段代码将短路。我们不想在没有必要时执行其余代码,所以我们在这里提前退出。
// 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;
}
}
在这里,如果速度发生变化,并且当前速度大于零,我们首先唤醒显示屏。
接下来,我们将速度填充到speed_buffer
中,然后设置相应的标签。
之后,我们计算针的角度,该角度从左到右以180度顺时针扫过。
请注意,我们只在角度发生变化时才去操作针。针是使用SVG渲染的,重绘它相对昂贵。
// 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;
}
在这里,我们在必要时使用当前GPS坐标更新位置屏幕。
// 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;
}
最后,我们对状态屏幕进行类似的操作。
// 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();
}
之后,我们只处理屏幕变暗和驱动各种对象。
initialize_common()
此例程负责通用的(平台无关的)初始化任务。
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);
在这里,我们初始化硬件驱动程序、UI,然后初始化GPS解码器。
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);
现在我们设置各种单位信息——速度缓冲区、标签文本和gps_units
变量。
#ifdef MILES
toggle_units();
#endif
如果定义了MILES
,我们将切换单位,因为它默认以公里为单位。
puts("Booted");
display_screen(speed_screen);
在这里,我们指示我们已成功启动,并显示速度屏幕。
main.cpp中其余的代码是平台特定的。
#ifdef ARDUINO
void setup() {
Serial.begin(115200);
Serial1.begin(BAUD,SERIAL_8N1,22,21);
initialize_common();
}
void loop() {
update_all();
}
对于Arduino来说非常简单。我们所做的只是初始化串行端口,然后在setup()
中调用initialize_common()
。在loop()
中,我们调用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
对于ESP-IDF来说,它更复杂一些。我们生成一个任务(loop_task
)来处理update_all()
功能。在app_main()
中,初始化串行端口需要相当多的样板代码,然后我们创建loop_task
,之后退出app_main()
。
display.hpp/display.cpp
这些文件负责初始化显示屏和设置显示屏的活动屏幕。我在许多项目中使用了这些文件的变体,所以里面有一些我们在此项目中不使用的东西,比如#ifndef LCD_DMA
分支。
#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>
在上面的display.hpp中,我们有我们需要的包含文件,以及一个定义——LCD_TRANSFER_KB
,它指示了我用户界面库UIX中LCD传输缓冲区可以分配的最大总SRAM量。
// 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
这有点复杂,但主要是因为它需要处理许多不同类型的显示屏。正如我所说,这是用于几个项目的通用代码。本质上,这里发生的是我们定义了总传输缓冲区大小,并在使用DMA(我们正在使用)的情况下将其分成两个缓冲区。
// declare the screen type
using screen_t = uix::screen_ex<LCD_FRAME_ADAPTER,LCD_X_ALIGN,LCD_Y_ALIGN>;
在这里,我们声明了我们的屏幕类型。帧适配器通常是一个位图,但有些屏幕,如SSD1306,使用奇怪的帧缓冲区映射,因此需要坐标转换。在这种情况下,我们只使用gfx::bitmap<>
。对齐参数通常是1,但对于某些显示屏,您无法更新每个像素,而是每8个像素更新一次,所以这些对齐值会相应设置。
// 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();
这些是我们基本显示功能的声明。
#define LCD_IMPLEMENTATION
#include <lcd_init.h>
#include <display.hpp>
这是display.cpp的包含文件。LCD_IMPLEMENTATION
定义是必需的,因为lcd_init.h在同一个文件中同时包含声明和实现,所以#define LCD_IMPLEMENTATION
必须在包含lcd_init.h头文件之前出现在一个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
这只是我们前面传输缓冲区声明的实现。
// the active screen
screen_t* display_active_screen = nullptr;
// whether the display is sleeping
static bool display_sleeping = false;
这是活动屏幕变量的实现,以及一个全局变量,指示显示屏是否处于休眠状态,以便我们只在必要时调用实际的休眠和唤醒命令。
#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
这是DMA回调,它告诉当前活动的屏幕我们已完成刷新显示屏,并且相应的传输缓冲区现在可以再次用于绘图。
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
}
此例程将位图从活动屏幕发送到显示屏。
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);
}
此例程在分配传输缓冲区内存后初始化显示屏。
void display_update() {
if(display_active_screen!=nullptr) {
display_active_screen->update();
}
}
display_update()
只是驱动活动屏幕。
void display_screen(screen_t& new_screen) {
display_active_screen = &new_screen;
display_active_screen->on_flush_callback(uix_flush);
display_active_screen->invalidate();
}
上面的例程设置活动屏幕并告知它需要重绘。
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;
}
}
这两个例程只是使用ESP LCD面板API来根据需要使显示屏休眠或唤醒。
svg_needle.hpp
这是速度计的针状控件。它使用SVG和一些简单的三角学来渲染。
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);
大部分代码是样板代码,但我们将介绍渲染部分。如果属性发生变化,控件将被标记为“脏”,并在下次重绘控件之前触发上述代码。
我们在这里做的是即时创建一个SVG文档。具体来说,它有一个形状,那就是针。它是一个多边形,也是一种路径,所以我们只是构建它,然后将其设置为svg_doc
。
ui.hpp/ui.cpp
这些文件负责定义和布局用户界面。首先,我们来看ui.hpp中的包含文件。
#pragma once
#include "display.hpp"
#include <uix.hpp>
#include <gfx.hpp>
#include "svg_needle.hpp"
这里没有什么特别的,只是包含一些文件。
// 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>>;
这些定义了UIX使用的几个类型别名。特别是,我们为我们使用的每种UIX控件类型声明了别名,然后是两个GFX虚拟X11颜色枚举,以便以后更方便地访问。请注意,我们有两个,因为一个是以屏幕的原生格式,而UIX使用的总是32位RGBA。
// 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;
在这里,我们声明了每个屏幕及其使用的控件,以便它们可以在应用程序的其他地方访问。
extern void ui_init();
此函数只是初始化所有UI对象。
接下来是ui.cpp。
// our font for the UI.
#define OPENSANS_REGULAR_IMPLEMENTATION
#include "fonts/OpenSans_Regular.hpp"
#include "ui.hpp"
这是我们的包含文件。注意我们那里有一个定义。字体文件是包含声明和实现的一个文件,所以这个定义必须出现在一个实现文件之前。顺便说一句,这个字体是从fontsquirrel.com下载的,并使用我的在线头文件生成器工具转换的。
// for easier modification
const gfx::open_font& text_font = OpenSans_Regular;
这一行只是我们字体的别名,以便我们轻松更改它。
using namespace uix;
using namespace gfx;
这些只是样板代码。
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);
在这里,我们定义了前面声明的每个屏幕和控件。
ui_init()
这个函数很简单但很长,所以我在这里将其分解。
// declare a transparent pixel/color
rgba_pixel<32> transparent(0, 0, 0, 0);
我们做的第一件事就是简单地创建一个透明像素,将所有通道设置为零——实际上只有最后一个(alpha)通道是重要的,但我们必须选择某种值,所以我们只是在所有通道上使用零。
speed_screen.dimensions({LCD_WIDTH,LCD_HEIGHT});
speed_screen.buffer_size(lcd_buffer_size);
speed_screen.buffer1(lcd_buffer1);
speed_screen.buffer2(lcd_buffer2);
这会以正确的尺寸和缓冲区设置speed_screen
。
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);
这里我们初始化针状控件。我们将其设置为128x128,即使它只使用了大约一半,因为针可以旋转360度,但我们只使用了其中的180度。我们将其垂直居中,然后稍微向下偏移一点以获得更好的外观。请注意,我喜欢使用相对值而不是固定值,这样如果我将其移植到另一个设备,会更容易。
其中一个是针的颜色,nc
。与transparent
类似,我们在初始化时设置了它的通道,但我们使用了带比例的浮点值,第一个参数是一个虚拟的bool
。在这种情况下,我们将红色通道设置为50%。
接下来,我们设置我们创建的针的颜色,并注册控件。
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);
这有点复杂。主要是确定边界有点棘手。我们测量当前text_font
中“888”的宽度在目标行高下,并用它来确定大小。其余的应该很容易理解。
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);
我们通过测量文本来寻找宽度,但我们在这里使用“MMM”,因为它是字母而不是数字。
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);
大号速度标签比小号标签更容易放置,因为我们没有针来处理。请注意,我们将字体设置得比屏幕略大。有一些我们不关心也不想显示的溢出部分,所以我们只是将字体弄得更大,因为我们可以。
另请注意,此控件初始时是不可见的——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);
大号单位标签位于大号字体右侧,垂直居中。控件初始时也是不可见的。
trip_screen.dimensions({LCD_WIDTH,LCD_HEIGHT});
trip_screen.buffer_size(lcd_buffer_size);
trip_screen.buffer1(lcd_buffer1);
trip_screen.buffer2(lcd_buffer2);
设置里程屏幕与速度屏幕相同。
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);
布局这个标签非常简单。
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);
这就是里程单位标签的内容。
loc_screen.dimensions({LCD_WIDTH,LCD_HEIGHT});
loc_screen.buffer_size(lcd_buffer_size);
loc_screen.buffer1(lcd_buffer1);
loc_screen.buffer2(lcd_buffer2);
设置位置屏幕与其他屏幕类似。
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);
首先,我们布局纬度标签。其余的都相对于它进行布局。
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);
这两个标签都布局在第一个标签下方,每个标签依次向下移动。
stat_screen.dimensions({LCD_WIDTH,LCD_HEIGHT});
stat_screen.buffer_size(lcd_buffer_size);
stat_screen.buffer1(lcd_buffer1);
stat_screen.buffer2(lcd_buffer2);
在这里,我们像以前一样设置状态屏幕的基本信息。
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);
这是状态标签。
UI就到这里了。
结论
我们没有介绍的文件要么不是我的代码,要么是另一个项目或我导入的库的一部分,超出了此项目的范围。例如,lcd_init.h在我的许多项目中都有使用,在这里介绍会很复杂。
总之,我希望您喜欢这个小玩具。祝您编码愉快!
历史
- 2024年7月23日 - 初始提交