markqvist___RNode_Firmware/Gui.h
GlassOnTin 57b1bb7f7e Add LXMF message receive: decrypt, parse, display on Messages screen
Implements OPPORTUNISTIC LXMF delivery receive — single-packet
encrypted messages without link establishment.

BeaconCrypto.h: beacon_crypto_decrypt() — reverse of encrypt.
  ECDH + HKDF + HMAC verify + AES-256-CBC decrypt + PKCS7 unpad.

LxmfBeacon.h: MsgpackReader for unpacking received messages.
  lxmf_parse_received() extracts source_hash, timestamp, title, content.
  lxmf_verify_signature() verifies Ed25519 against cached announce key.

MessageLog.h: Extended to detect DATA packets addressed to our
  lxmf_source_hash, decrypt, parse, and store content. Announce
  entries now cache sender's ed25519 public key for verification.

Gui.h: Messages screen shows LXMF content (green if verified,
  white if unverified) with sender name and time ago.
2026-04-07 21:34:49 +01:00

2128 lines
92 KiB
C++

// R-Watch GUI — LVGL integration for T-Watch Ultimate
// Tileview navigation with watch face, radio status, GPS, messages, settings
// Requires: CO5300.h (display), XL9555.h (power control), DRV2605.h (haptic)
#ifndef GUI_H
#define GUI_H
#if BOARD_MODEL == BOARD_TWATCH_ULT
#include <lvgl.h>
#include "soc/rtc_cntl_reg.h"
// Custom fonts: generated with lv_font_conv --no-compress from Montserrat Bold
// IMPORTANT: must use --no-compress or set LV_USE_FONT_COMPRESSED=1 in lv_conf.h
namespace _f96 {
#include "Fonts/montserrat_bold_96.c"
}
namespace _f28 {
#include "Fonts/montserrat_bold_28.c"
}
static const lv_font_t &font_time = _f96::montserrat_bold_96;
static const lv_font_t &font_mid = _f28::montserrat_bold_28;
// ---------------------------------------------------------------------------
// Color palette (24-bit hex for lv_color_hex)
// ---------------------------------------------------------------------------
#define GUI_COL_BLACK 0x000000
#define GUI_COL_WHITE 0xFFFFEF // Bone white
#define GUI_COL_DIM 0x404040 // Dividers, inactive labels
#define GUI_COL_MID 0x808080 // Secondary text
#define GUI_COL_AMBER 0xFFA500 // LoRa / radio accent
#define GUI_COL_TEAL 0x00FFC0 // GPS accent
#define GUI_COL_BLUE 0x4080FF // BLE accent
#define GUI_COL_RED 0xFF0000 // Warnings
#define GUI_COL_GREEN 0x00FF00 // Confirmations
// ---------------------------------------------------------------------------
// Layout constants (410x502 display)
// ---------------------------------------------------------------------------
#define GUI_W CO5300_WIDTH
#define GUI_H CO5300_HEIGHT
#define GUI_PAD 24
// Watch face zones (adjusted for 96px time font)
#define GUI_STATUS_Y 10
#define GUI_TIME_Y 40
#define GUI_DATE_Y 150
#define GUI_RULE1_Y 190
#define GUI_COMP_Y 200
#define GUI_COMP_H 90
#define GUI_RULE2_Y 295
// ---------------------------------------------------------------------------
// LVGL core objects
// ---------------------------------------------------------------------------
static lv_display_t *gui_display = NULL;
static lv_indev_t *gui_indev = NULL;
// Full-frame double buffer in PSRAM — tear-free rendering.
// LVGL only re-renders dirty areas within the buffer but always
// flushes the complete frame (~18ms via DMA SPI, CPU yields).
// Two buffers: 410*502*2 = 411,640 bytes each (823KB total).
#define GUI_BUF_LINES GUI_H
static uint8_t *gui_buf1 = NULL;
static uint8_t *gui_buf2 = NULL;
// Tileview and tiles
static lv_obj_t *gui_tileview = NULL;
static lv_obj_t *gui_tile_watch = NULL;
static lv_obj_t *gui_tile_radio = NULL;
static lv_obj_t *gui_tile_gps = NULL;
static lv_obj_t *gui_tile_msg = NULL;
static lv_obj_t *gui_tile_set = NULL;
// Convenience alias for display_unblank invalidation
static lv_obj_t *gui_screen = NULL;
// Watch face widgets
static lv_obj_t *gui_mode_label = NULL;
static lv_obj_t *gui_batt_label = NULL;
static lv_obj_t *gui_time_label = NULL;
static lv_obj_t *gui_date_label = NULL;
static lv_obj_t *gui_lora_value = NULL;
static lv_obj_t *gui_lora_label = NULL;
static lv_obj_t *gui_gps_value = NULL;
static lv_obj_t *gui_gps_label = NULL;
static lv_obj_t *gui_batt_value = NULL; // battery detail in complications
static lv_obj_t *gui_batt_detail = NULL;
static lv_obj_t *gui_batt_icon = NULL; // battery bar icon
static lv_obj_t *gui_batt_fill = NULL; // fill bar inside icon
static lv_obj_t *gui_batt_cell = NULL; // complication cell (for click event)
static uint8_t gui_batt_mode = 0; // 0=voltage, 1=percent, 2=time, 3=icon
#define GUI_BATT_MODES 4
static lv_obj_t *gui_step_label = NULL; // step count below complications
// Battery time estimation
static float batt_pct_history[4] = {-1,-1,-1,-1}; // last 4 readings (every 60s)
static uint32_t batt_hist_last = 0;
static float batt_rate_pct_hr = 0; // %/hour (positive=charging, negative=discharging)
// Radio status widgets
static lv_obj_t *gui_radio_freq = NULL;
static lv_obj_t *gui_radio_params = NULL;
static lv_obj_t *gui_radio_rssi_bar = NULL;
static lv_obj_t *gui_radio_rssi_lbl = NULL;
static lv_obj_t *gui_radio_util = NULL;
static lv_obj_t *gui_radio_ble = NULL;
static lv_obj_t *gui_radio_pkts = NULL;
// Radio screen additional widgets
static lv_obj_t *gui_radio_temp = NULL;
static lv_obj_t *gui_radio_batt = NULL;
// GPS screen widgets
static lv_obj_t *gui_gps_coords = NULL;
static lv_obj_t *gui_gps_fix = NULL;
static lv_obj_t *gui_gps_alt = NULL;
static lv_obj_t *gui_gps_beacon = NULL;
// Bubble level widgets
static lv_obj_t *gui_level_ring = NULL; // outer circle
static lv_obj_t *gui_level_dot = NULL; // moving bubble
static lv_obj_t *gui_level_cross_h = NULL; // crosshair horizontal
static lv_obj_t *gui_level_cross_v = NULL; // crosshair vertical
static lv_obj_t *gui_level_angle = NULL; // tilt angle text
#define GUI_LEVEL_SIZE 140 // ring diameter
#define GUI_LEVEL_DOT 16 // bubble diameter
#define GUI_LEVEL_Y 340 // vertical position on watch face
// Signal strength view (toggles with bubble level)
static lv_obj_t *gui_signal_cont = NULL; // container for signal view
static lv_obj_t *gui_signal_line = NULL; // sparkline graph
static lv_obj_t *gui_signal_rssi = NULL; // current RSSI text
static lv_obj_t *gui_signal_dir = NULL; // direction arrow/text
static bool gui_show_signal = false; // false=bubble level, true=signal
// RSSI history for sparkline
#define RSSI_HISTORY_LEN 60
static int16_t rssi_history[RSSI_HISTORY_LEN];
static uint8_t rssi_history_idx = 0;
static uint8_t rssi_history_count = 0;
static lv_point_precise_t rssi_graph_pts[RSSI_HISTORY_LEN];
// GPS+RSSI history for direction finding
#define DIR_HISTORY_LEN 8
struct dir_sample_t { double lat, lon; int rssi; };
static dir_sample_t dir_history[DIR_HISTORY_LEN];
static uint8_t dir_history_idx = 0;
static uint8_t dir_history_count = 0;
// Touch input via function pointer (set by .ino after touch init)
typedef bool (*gui_touch_fn_t)(int16_t *x, int16_t *y);
static gui_touch_fn_t gui_touch_fn = NULL;
// Data update throttle
static uint32_t gui_last_data_update = 0;
#define GUI_DATA_UPDATE_MS 500
// Track current tile for haptic feedback
static uint8_t gui_last_tile_col = 1;
static uint8_t gui_last_tile_row = 1;
static bool gui_was_scrolling = false;
// Frame timing metrics
static uint32_t gui_frame_count = 0;
// Main loop profiling (set from .ino, read by metrics command)
uint32_t prof_radio_us = 0;
uint32_t prof_serial_us = 0;
uint32_t prof_display_us = 0;
uint32_t prof_pmu_us = 0;
uint32_t prof_gps_us = 0;
uint32_t prof_bt_us = 0;
uint32_t prof_imu_us = 0;
uint32_t prof_other_us = 0;
static uint32_t gui_flush_us_total = 0;
static uint32_t gui_flush_us_last = 0;
static uint32_t gui_render_us_last = 0;
static uint32_t gui_render_start = 0;
static uint32_t gui_loop_us_last = 0; // time between gui_update() calls
static uint32_t gui_loop_us_max = 0; // worst case loop time
static uint32_t gui_last_update_us = 0;
// Remote touch injection
static int16_t gui_inject_x = -1;
static int16_t gui_inject_y = -1;
static bool gui_inject_pressed = false;
static uint32_t gui_inject_until = 0; // millis() deadline for injected touch
// ---------------------------------------------------------------------------
// LVGL display flush callback
// ---------------------------------------------------------------------------
// Shadow framebuffer for screenshots (RGB565 swapped / big-endian — same as display)
uint16_t *gui_screenshot_buf = NULL;
// Forward declarations — defined in Display.h / Power.h / .ino after Gui.h is included
void display_unblank();
extern float pmu_temperature;
extern volatile uint32_t imu_step_count;
// Sensor logger toggle — set by .ino after IMULogger.h is included
typedef bool (*gui_log_toggle_fn_t)();
static gui_log_toggle_fn_t gui_log_toggle_fn = NULL;
// SD file listing and download — set by .ino after SD is available
typedef void (*gui_list_files_fn_t)();
static gui_list_files_fn_t gui_list_files_fn = NULL;
typedef void (*gui_download_file_fn_t)(uint8_t index);
static gui_download_file_fn_t gui_download_file_fn = NULL;
static bool gui_imu_logging = false;
// Forward declarations for IMULogger.h variables (defined later in compilation)
extern bool imu_logging;
extern uint32_t imu_log_samples;
extern uint32_t imu_log_start_ms;
// Forward declaration for touch logging (defined in IMULogger.h)
void sensor_log_touch(int16_t x, int16_t y, bool pressed);
// Forward declarations for filtered accel and noise (defined in .ino)
extern volatile float imu_ax_f, imu_ay_f, imu_az_f;
extern volatile float imu_noise;
// Forward declarations for radio/GPS toggle (defined in .ino / GPS.h / IfacAuth.h)
bool startRadio();
void stopRadio();
void update_radio_lock();
void gps_power_on();
void gps_power_off();
void gps_setup();
extern bool ifac_configured;
#ifndef PMU_TEMP_MIN
#define PMU_TEMP_MIN -30
#endif
static volatile bool gui_screenshot_pending = false; // set true to capture next frame
static void gui_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map) {
uint16_t x1 = area->x1;
uint16_t y1 = area->y1;
uint16_t w = area->x2 - area->x1 + 1;
uint16_t h = area->y2 - area->y1 + 1;
uint16_t *pixels = (uint16_t *)px_map;
// Copy to shadow framebuffer when screenshot capture is active
if (gui_screenshot_buf && gui_screenshot_pending) {
for (uint16_t row = 0; row < h; row++) {
memcpy(&gui_screenshot_buf[(y1 + row) * GUI_W + x1],
&pixels[row * w], w * sizeof(uint16_t));
}
}
uint32_t t0 = micros();
co5300_push_pixels(x1, y1, w, h, pixels);
gui_flush_us_last = micros() - t0;
gui_flush_us_total += gui_flush_us_last;
gui_frame_count++;
lv_display_flush_ready(disp);
}
// ---------------------------------------------------------------------------
// LVGL touch input read callback
// ---------------------------------------------------------------------------
static void gui_touch_read_cb(lv_indev_t *indev, lv_indev_data_t *data) {
// Check for injected remote touch first
if (gui_inject_pressed && millis() < gui_inject_until) {
data->point.x = gui_inject_x;
data->point.y = gui_inject_y;
data->state = LV_INDEV_STATE_PRESSED;
last_unblank_event = millis();
return;
}
gui_inject_pressed = false;
// Real touch hardware
int16_t tx, ty;
if (gui_touch_fn && gui_touch_fn(&tx, &ty)) {
data->point.x = tx;
data->point.y = ty;
data->state = LV_INDEV_STATE_PRESSED;
last_unblank_event = millis();
#if HAS_SD
sensor_log_touch(tx, ty, true);
#endif
} else {
data->state = LV_INDEV_STATE_RELEASED;
}
}
void gui_set_touch_handler(gui_touch_fn_t fn) {
gui_touch_fn = fn;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static void gui_style_black_container(lv_obj_t *obj) {
lv_obj_set_style_bg_color(obj, lv_color_hex(GUI_COL_BLACK), 0);
lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(obj, 0, 0);
lv_obj_set_style_pad_all(obj, 0, 0);
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
}
static lv_obj_t *gui_create_rule(lv_obj_t *parent, lv_coord_t y) {
static lv_point_precise_t rule_pts[] = {
{GUI_PAD, 0}, {GUI_W - GUI_PAD, 0}
};
lv_obj_t *line = lv_line_create(parent);
lv_line_set_points(line, rule_pts, 2);
lv_obj_set_style_line_color(line, lv_color_hex(GUI_COL_DIM), 0);
lv_obj_set_style_line_width(line, 1, 0);
lv_obj_set_pos(line, 0, y);
return line;
}
static lv_obj_t *gui_label(lv_obj_t *parent, const lv_font_t *font,
uint32_t color, const char *text) {
lv_obj_t *lbl = lv_label_create(parent);
lv_obj_set_style_text_font(lbl, font, 0);
lv_obj_set_style_text_color(lbl, lv_color_hex(color), 0);
lv_label_set_text(lbl, text);
return lbl;
}
static lv_obj_t *gui_label_at(lv_obj_t *parent, const lv_font_t *font,
uint32_t color, const char *text,
lv_coord_t x, lv_coord_t y) {
lv_obj_t *lbl = gui_label(parent, font, color, text);
lv_obj_set_pos(lbl, x, y);
return lbl;
}
static void gui_create_complication(lv_obj_t *parent, lv_coord_t x, lv_coord_t w,
uint32_t color, const char *label_text,
lv_obj_t **value_out, lv_obj_t **label_out) {
lv_obj_t *cell = lv_obj_create(parent);
lv_obj_remove_style_all(cell);
lv_obj_set_size(cell, w, GUI_COMP_H);
lv_obj_set_pos(cell, x, 0);
lv_obj_t *val = gui_label(cell, &font_mid, color, "--");
lv_obj_set_width(val, w);
lv_obj_set_style_text_align(val, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(val, LV_ALIGN_TOP_MID, 0, 4);
lv_obj_t *lbl = gui_label(cell, &lv_font_montserrat_14, GUI_COL_DIM, label_text);
lv_obj_set_width(lbl, w);
lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(lbl, LV_ALIGN_TOP_MID, 0, 46);
if (value_out) *value_out = val;
if (label_out) *label_out = lbl;
}
// ---------------------------------------------------------------------------
// Screen: Watch Face (center tile)
// ---------------------------------------------------------------------------
static void gui_create_watchface(lv_obj_t *parent) {
gui_style_black_container(parent);
// Status bar
gui_mode_label = gui_label_at(parent, &lv_font_montserrat_14, GUI_COL_MID, "IDLE", GUI_PAD, GUI_STATUS_Y);
gui_batt_label = gui_label(parent, &lv_font_montserrat_14, GUI_COL_MID, "--%");
lv_obj_align(gui_batt_label, LV_ALIGN_TOP_RIGHT, -GUI_PAD, GUI_STATUS_Y);
// Time (96px custom font — digits and colon only)
gui_time_label = gui_label(parent, &font_time, GUI_COL_WHITE, "00:00");
lv_obj_set_style_text_letter_space(gui_time_label, 2, 0);
lv_obj_set_width(gui_time_label, GUI_W);
lv_obj_set_style_text_align(gui_time_label, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_pos(gui_time_label, 0, GUI_TIME_Y);
// Date
gui_date_label = gui_label(parent, &font_mid, GUI_COL_MID, "--- -- ---");
lv_obj_set_width(gui_date_label, GUI_W);
lv_obj_set_style_text_align(gui_date_label, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_pos(gui_date_label, 0, GUI_DATE_Y);
// Rule 1
gui_create_rule(parent, GUI_RULE1_Y);
// Complications
lv_obj_t *comp = lv_obj_create(parent);
lv_obj_remove_style_all(comp);
lv_obj_set_size(comp, GUI_W, GUI_COMP_H);
lv_obj_set_pos(comp, 0, GUI_COMP_Y);
lv_obj_clear_flag(comp, LV_OBJ_FLAG_SCROLLABLE);
int cw = (GUI_W - GUI_PAD * 2) / 3;
// LoRa complication — tap to toggle radio
{
lv_obj_t *cell = lv_obj_create(comp);
lv_obj_remove_style_all(cell);
lv_obj_set_size(cell, cw, GUI_COMP_H);
lv_obj_set_pos(cell, GUI_PAD, 0);
lv_obj_add_flag(cell, LV_OBJ_FLAG_CLICKABLE);
lv_obj_clear_flag(cell, (lv_obj_flag_t)(LV_OBJ_FLAG_GESTURE_BUBBLE | LV_OBJ_FLAG_SCROLLABLE));
lv_obj_add_event_cb(cell, [](lv_event_t *e) {
if (radio_online) { stopRadio(); }
else {
if (lora_freq == 0 || lora_freq == 0xFFFFFFFF) {
lora_freq = 868000000; lora_bw = 125000;
lora_sf = 7; lora_cr = 5; lora_txp = 17;
}
startRadio();
}
}, LV_EVENT_CLICKED, NULL);
gui_lora_value = gui_label(cell, &font_mid, GUI_COL_AMBER, "--");
lv_obj_set_width(gui_lora_value, cw);
lv_obj_set_style_text_align(gui_lora_value, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(gui_lora_value, LV_ALIGN_TOP_MID, 0, 4);
gui_lora_label = gui_label(cell, &lv_font_montserrat_14, GUI_COL_DIM, "LoRa");
lv_obj_set_width(gui_lora_label, cw);
lv_obj_set_style_text_align(gui_lora_label, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(gui_lora_label, LV_ALIGN_TOP_MID, 0, 46);
}
// GPS complication — tap to toggle GPS
{
lv_obj_t *cell = lv_obj_create(comp);
lv_obj_remove_style_all(cell);
lv_obj_set_size(cell, cw, GUI_COMP_H);
lv_obj_set_pos(cell, GUI_PAD + cw, 0);
lv_obj_add_flag(cell, LV_OBJ_FLAG_CLICKABLE);
lv_obj_clear_flag(cell, (lv_obj_flag_t)(LV_OBJ_FLAG_GESTURE_BUBBLE | LV_OBJ_FLAG_SCROLLABLE));
lv_obj_add_event_cb(cell, [](lv_event_t *e) {
if (gps_ready) { gps_power_off(); gps_ready = false; }
else { gps_power_on(); gps_setup(); }
}, LV_EVENT_CLICKED, NULL);
gui_gps_value = gui_label(cell, &font_mid, GUI_COL_TEAL, "--");
lv_obj_set_width(gui_gps_value, cw);
lv_obj_set_style_text_align(gui_gps_value, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(gui_gps_value, LV_ALIGN_TOP_MID, 0, 4);
gui_gps_label = gui_label(cell, &lv_font_montserrat_14, GUI_COL_DIM, "GPS");
lv_obj_set_width(gui_gps_label, cw);
lv_obj_set_style_text_align(gui_gps_label, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(gui_gps_label, LV_ALIGN_TOP_MID, 0, 46);
}
// Battery complication — custom with click-to-cycle and icon bar
{
int bx = GUI_PAD + cw * 2;
gui_batt_cell = lv_obj_create(comp);
lv_obj_remove_style_all(gui_batt_cell);
lv_obj_set_size(gui_batt_cell, cw, GUI_COMP_H);
lv_obj_set_pos(gui_batt_cell, bx, 0);
lv_obj_add_flag(gui_batt_cell, LV_OBJ_FLAG_CLICKABLE);
lv_obj_clear_flag(gui_batt_cell, (lv_obj_flag_t)(LV_OBJ_FLAG_GESTURE_BUBBLE | LV_OBJ_FLAG_SCROLLABLE));
lv_obj_add_event_cb(gui_batt_cell, [](lv_event_t *e) {
gui_batt_mode = (gui_batt_mode + 1) % GUI_BATT_MODES;
}, LV_EVENT_CLICKED, NULL);
gui_batt_value = gui_label(gui_batt_cell, &font_mid, GUI_COL_WHITE, "--");
lv_obj_set_width(gui_batt_value, cw);
lv_obj_set_style_text_align(gui_batt_value, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(gui_batt_value, LV_ALIGN_TOP_MID, 0, 4);
gui_batt_detail = gui_label(gui_batt_cell, &lv_font_montserrat_14, GUI_COL_DIM, "Batt");
lv_obj_set_width(gui_batt_detail, cw);
lv_obj_set_style_text_align(gui_batt_detail, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(gui_batt_detail, LV_ALIGN_TOP_MID, 0, 46);
// Battery icon bar (hidden until mode 3)
int bar_w = cw - 20, bar_h = 24;
gui_batt_icon = lv_obj_create(gui_batt_cell);
lv_obj_remove_style_all(gui_batt_icon);
lv_obj_set_size(gui_batt_icon, bar_w, bar_h);
lv_obj_align(gui_batt_icon, LV_ALIGN_TOP_MID, 0, 10);
lv_obj_set_style_radius(gui_batt_icon, 4, 0);
lv_obj_set_style_border_color(gui_batt_icon, lv_color_hex(GUI_COL_MID), 0);
lv_obj_set_style_border_width(gui_batt_icon, 2, 0);
lv_obj_set_style_bg_opa(gui_batt_icon, LV_OPA_TRANSP, 0);
lv_obj_add_flag(gui_batt_icon, LV_OBJ_FLAG_HIDDEN);
gui_batt_fill = lv_obj_create(gui_batt_icon);
lv_obj_remove_style_all(gui_batt_fill);
lv_obj_set_size(gui_batt_fill, bar_w - 6, bar_h - 6);
lv_obj_set_pos(gui_batt_fill, 2, 2);
lv_obj_set_style_radius(gui_batt_fill, 2, 0);
lv_obj_set_style_bg_color(gui_batt_fill, lv_color_hex(GUI_COL_GREEN), 0);
lv_obj_set_style_bg_opa(gui_batt_fill, LV_OPA_COVER, 0);
}
// Rule 2
gui_create_rule(parent, GUI_RULE2_Y);
// Step counter below complications
gui_step_label = gui_label(parent, &lv_font_montserrat_20, GUI_COL_DIM, "");
lv_obj_set_width(gui_step_label, GUI_W);
lv_obj_set_style_text_align(gui_step_label, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_pos(gui_step_label, 0, GUI_RULE2_Y + 15);
// Bubble level
int lx = (GUI_W - GUI_LEVEL_SIZE) / 2;
// Outer ring
gui_level_ring = lv_obj_create(parent);
lv_obj_remove_style_all(gui_level_ring);
lv_obj_set_size(gui_level_ring, GUI_LEVEL_SIZE, GUI_LEVEL_SIZE);
lv_obj_set_pos(gui_level_ring, lx, GUI_LEVEL_Y);
lv_obj_set_style_radius(gui_level_ring, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_border_color(gui_level_ring, lv_color_hex(GUI_COL_DIM), 0);
lv_obj_set_style_border_width(gui_level_ring, 1, 0);
lv_obj_set_style_bg_opa(gui_level_ring, LV_OPA_TRANSP, 0);
lv_obj_clear_flag(gui_level_ring, LV_OBJ_FLAG_SCROLLABLE);
// Crosshairs
static lv_point_precise_t ch_pts[] = {{0,0},{GUI_LEVEL_SIZE,0}};
gui_level_cross_h = lv_line_create(gui_level_ring);
lv_line_set_points(gui_level_cross_h, ch_pts, 2);
lv_obj_set_style_line_color(gui_level_cross_h, lv_color_hex(0x202020), 0);
lv_obj_set_style_line_width(gui_level_cross_h, 1, 0);
lv_obj_center(gui_level_cross_h);
static lv_point_precise_t cv_pts[] = {{0,0},{0,GUI_LEVEL_SIZE}};
gui_level_cross_v = lv_line_create(gui_level_ring);
lv_line_set_points(gui_level_cross_v, cv_pts, 2);
lv_obj_set_style_line_color(gui_level_cross_v, lv_color_hex(0x202020), 0);
lv_obj_set_style_line_width(gui_level_cross_v, 1, 0);
lv_obj_center(gui_level_cross_v);
// Bubble dot — positioned via lv_obj_set_pos() in update loop,
// do NOT use lv_obj_center() as it changes the alignment base
gui_level_dot = lv_obj_create(gui_level_ring);
lv_obj_remove_style_all(gui_level_dot);
lv_obj_set_size(gui_level_dot, GUI_LEVEL_DOT, GUI_LEVEL_DOT);
lv_obj_set_style_radius(gui_level_dot, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(gui_level_dot, lv_color_hex(GUI_COL_GREEN), 0);
lv_obj_set_style_bg_opa(gui_level_dot, LV_OPA_COVER, 0);
lv_obj_set_pos(gui_level_dot, GUI_LEVEL_SIZE/2 - GUI_LEVEL_DOT/2,
GUI_LEVEL_SIZE/2 - GUI_LEVEL_DOT/2);
// Angle text below ring
gui_level_angle = gui_label(parent, &lv_font_montserrat_14, GUI_COL_DIM, "");
lv_obj_set_width(gui_level_angle, GUI_W);
lv_obj_set_style_text_align(gui_level_angle, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_pos(gui_level_angle, 0, GUI_LEVEL_Y + GUI_LEVEL_SIZE + 5);
// Make bubble level area clickable to toggle signal view
lv_obj_add_flag(gui_level_ring, LV_OBJ_FLAG_CLICKABLE);
lv_obj_clear_flag(gui_level_ring, (lv_obj_flag_t)(LV_OBJ_FLAG_GESTURE_BUBBLE | LV_OBJ_FLAG_SCROLLABLE));
lv_obj_add_event_cb(gui_level_ring, [](lv_event_t *e) {
gui_show_signal = !gui_show_signal;
if (gui_show_signal) {
lv_obj_add_flag(gui_level_ring, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(gui_level_angle, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(gui_signal_cont, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_clear_flag(gui_level_ring, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(gui_level_angle, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(gui_signal_cont, LV_OBJ_FLAG_HIDDEN);
}
}, LV_EVENT_CLICKED, NULL);
// Signal strength view (hidden by default)
gui_signal_cont = lv_obj_create(parent);
lv_obj_remove_style_all(gui_signal_cont);
lv_obj_set_size(gui_signal_cont, GUI_W - 2 * GUI_PAD, GUI_LEVEL_SIZE + 20);
lv_obj_set_pos(gui_signal_cont, GUI_PAD, GUI_LEVEL_Y);
lv_obj_clear_flag(gui_signal_cont, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(gui_signal_cont, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(gui_signal_cont, LV_OBJ_FLAG_CLICKABLE);
lv_obj_clear_flag(gui_signal_cont, (lv_obj_flag_t)(LV_OBJ_FLAG_GESTURE_BUBBLE));
lv_obj_add_event_cb(gui_signal_cont, [](lv_event_t *e) {
gui_show_signal = false;
lv_obj_clear_flag(gui_level_ring, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(gui_level_angle, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(gui_signal_cont, LV_OBJ_FLAG_HIDDEN);
}, LV_EVENT_CLICKED, NULL);
// RSSI sparkline
int graph_w = GUI_W - 2 * GUI_PAD;
gui_signal_line = lv_line_create(gui_signal_cont);
lv_obj_set_style_line_color(gui_signal_line, lv_color_hex(GUI_COL_AMBER), 0);
lv_obj_set_style_line_width(gui_signal_line, 2, 0);
lv_obj_set_size(gui_signal_line, graph_w, GUI_LEVEL_SIZE - 30);
lv_obj_set_pos(gui_signal_line, 0, 0);
// Graph border
lv_obj_t *graph_border = lv_obj_create(gui_signal_cont);
lv_obj_remove_style_all(graph_border);
lv_obj_set_size(graph_border, graph_w, GUI_LEVEL_SIZE - 30);
lv_obj_set_pos(graph_border, 0, 0);
lv_obj_set_style_border_color(graph_border, lv_color_hex(GUI_COL_DIM), 0);
lv_obj_set_style_border_width(graph_border, 1, 0);
lv_obj_set_style_bg_opa(graph_border, LV_OPA_TRANSP, 0);
lv_obj_clear_flag(graph_border, LV_OBJ_FLAG_SCROLLABLE);
// Current RSSI and direction text
gui_signal_rssi = gui_label(gui_signal_cont, &font_mid, GUI_COL_AMBER, "---");
lv_obj_set_pos(gui_signal_rssi, 0, GUI_LEVEL_SIZE - 25);
gui_signal_dir = gui_label(gui_signal_cont, &lv_font_montserrat_14, GUI_COL_TEAL, "");
lv_obj_set_pos(gui_signal_dir, graph_w / 2, GUI_LEVEL_SIZE - 20);
// Init RSSI history
memset(rssi_history, 0, sizeof(rssi_history));
}
// ---------------------------------------------------------------------------
// Screen: Radio Status (top tile — swipe down from watch face)
// ---------------------------------------------------------------------------
static void gui_create_radio_screen(lv_obj_t *parent) {
gui_style_black_container(parent);
gui_label_at(parent, &lv_font_montserrat_14, GUI_COL_DIM, "RADIO STATUS", GUI_PAD, 12);
// Frequency
gui_radio_freq = gui_label_at(parent, &font_mid, GUI_COL_AMBER, "--- MHz", GUI_PAD, 40);
// LoRa parameters
gui_radio_params = gui_label_at(parent, &font_mid, GUI_COL_MID, "SF- BW- CR-", GUI_PAD, 80);
// RSSI
gui_create_rule(parent, 115);
gui_label_at(parent, &lv_font_montserrat_14, GUI_COL_DIM, "RSSI", GUI_PAD, 125);
gui_radio_rssi_lbl = gui_label(parent, &font_mid, GUI_COL_AMBER, "---");
lv_obj_align(gui_radio_rssi_lbl, LV_ALIGN_TOP_RIGHT, -GUI_PAD, 122);
lv_obj_t *bar = lv_bar_create(parent);
lv_obj_set_size(bar, GUI_W - GUI_PAD * 2, 20);
lv_obj_set_pos(bar, GUI_PAD, 150);
lv_bar_set_range(bar, -140, -40);
lv_bar_set_value(bar, -140, LV_ANIM_OFF);
lv_obj_set_style_bg_color(bar, lv_color_hex(GUI_COL_DIM), 0);
lv_obj_set_style_bg_color(bar, lv_color_hex(GUI_COL_AMBER), LV_PART_INDICATOR);
lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0);
lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_PART_INDICATOR);
gui_radio_rssi_bar = bar;
// Channel utilization
gui_create_rule(parent, 185);
gui_label_at(parent, &lv_font_montserrat_14, GUI_COL_DIM, "CHANNEL", GUI_PAD, 198);
gui_radio_util = gui_label(parent, &font_mid, GUI_COL_MID, "-- %");
lv_obj_align(gui_radio_util, LV_ALIGN_TOP_RIGHT, -GUI_PAD, 195);
// BLE status
gui_create_rule(parent, 230);
gui_label_at(parent, &lv_font_montserrat_14, GUI_COL_DIM, "BLE", GUI_PAD, 243);
gui_radio_ble = gui_label(parent, &font_mid, GUI_COL_BLUE, "---");
lv_obj_align(gui_radio_ble, LV_ALIGN_TOP_RIGHT, -GUI_PAD, 240);
// Packet counts
gui_create_rule(parent, 275);
gui_radio_pkts = gui_label_at(parent, &font_mid, GUI_COL_MID,
"RX: 0 TX: 0", GUI_PAD, 290);
// Battery and temperature
gui_create_rule(parent, 325);
gui_label_at(parent, &lv_font_montserrat_14, GUI_COL_DIM, "BATTERY", GUI_PAD, 338);
gui_radio_batt = gui_label(parent, &font_mid, GUI_COL_MID, "---");
lv_obj_align(gui_radio_batt, LV_ALIGN_TOP_RIGHT, -GUI_PAD, 335);
gui_create_rule(parent, 370);
gui_label_at(parent, &lv_font_montserrat_14, GUI_COL_DIM, "TEMPERATURE", GUI_PAD, 383);
gui_radio_temp = gui_label(parent, &font_mid, GUI_COL_MID, "---");
lv_obj_align(gui_radio_temp, LV_ALIGN_TOP_RIGHT, -GUI_PAD, 380);
}
// ---------------------------------------------------------------------------
// Screen: GPS (right tile — swipe right from watch face)
// ---------------------------------------------------------------------------
static void gui_create_gps_screen(lv_obj_t *parent) {
gui_style_black_container(parent);
lv_obj_t *gps_title = gui_label(parent, &lv_font_montserrat_14, GUI_COL_DIM, "GPS");
lv_obj_set_width(gps_title, GUI_W);
lv_obj_set_style_text_align(gps_title, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_pos(gps_title, 0, 12);
// Coordinates — centered for rounded corner clearance
gui_gps_coords = gui_label(parent, &font_mid, GUI_COL_TEAL, "-- --");
lv_obj_set_width(gui_gps_coords, GUI_W);
lv_obj_set_style_text_align(gui_gps_coords, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_pos(gui_gps_coords, 0, 40);
// Fix quality
gui_create_rule(parent, 110);
gui_gps_fix = gui_label_at(parent, &font_mid, GUI_COL_MID,
"Sats: -- HDOP: --", GUI_PAD, 125);
// Altitude and speed
gui_create_rule(parent, 160);
gui_gps_alt = gui_label_at(parent, &font_mid, GUI_COL_MID,
"Alt: -- Spd: --", GUI_PAD, 175);
// Beacon status
gui_create_rule(parent, 215);
gui_gps_beacon = gui_label_at(parent, &font_mid, GUI_COL_AMBER,
"Beacon: --", GUI_PAD, 230);
}
// ---------------------------------------------------------------------------
// Screen: Messages (right tile — swipe left from watch face)
// ---------------------------------------------------------------------------
static lv_obj_t *gui_msg_title = NULL;
static lv_obj_t *gui_msg_empty = NULL;
static lv_obj_t *gui_msg_cont = NULL;
#define GUI_MSG_ROWS 8 // max visible message rows
static lv_obj_t *gui_msg_name[GUI_MSG_ROWS];
static lv_obj_t *gui_msg_rssi[GUI_MSG_ROWS];
static lv_obj_t *gui_msg_time[GUI_MSG_ROWS];
static lv_obj_t *gui_msg_row[GUI_MSG_ROWS];
static uint32_t gui_msg_last_rebuild = 0;
// Message log types and forward declarations (defined in MessageLog.h)
#ifndef MSG_LOG_SIZE
#define MSG_LOG_SIZE 16
#define MSG_NAME_LEN 24
#endif
struct msg_entry_t {
uint32_t timestamp;
int16_t rssi;
int8_t snr;
uint8_t pkt_type;
uint8_t sender_hash[16];
char display_name[MSG_NAME_LEN];
char content[64]; // LXMF message content (first 63 chars)
uint8_t sender_ed25519_pub[32]; // cached from announce for signature verification
uint16_t pkt_len;
bool is_announce;
bool is_lxmf; // true if decrypted LXMF message
bool verified; // signature verified
bool has_pubkey; // sender_ed25519_pub is valid
};
extern msg_entry_t msg_log[];
extern uint8_t msg_log_head;
extern uint8_t msg_log_count;
extern uint32_t msg_log_last_change;
static void gui_create_msg_screen(lv_obj_t *parent) {
gui_style_black_container(parent);
gui_msg_title = gui_label_at(parent, &lv_font_montserrat_14, GUI_COL_DIM, "MESSAGES", GUI_PAD, 12);
gui_msg_empty = gui_label(parent, &lv_font_montserrat_14, GUI_COL_DIM, "Listening...");
lv_obj_align(gui_msg_empty, LV_ALIGN_CENTER, 0, 0);
// Scrollable message list container
gui_msg_cont = lv_obj_create(parent);
lv_obj_remove_style_all(gui_msg_cont);
lv_obj_set_size(gui_msg_cont, GUI_W, GUI_H - 40);
lv_obj_set_pos(gui_msg_cont, 0, 35);
lv_obj_set_style_bg_opa(gui_msg_cont, LV_OPA_TRANSP, 0);
lv_obj_clear_flag(gui_msg_cont, LV_OBJ_FLAG_SCROLLABLE);
// Pre-create row widgets
for (int i = 0; i < GUI_MSG_ROWS; i++) {
int y = i * 55;
gui_msg_row[i] = lv_obj_create(gui_msg_cont);
lv_obj_remove_style_all(gui_msg_row[i]);
lv_obj_set_size(gui_msg_row[i], GUI_W - 2 * GUI_PAD, 50);
lv_obj_set_pos(gui_msg_row[i], GUI_PAD, y);
lv_obj_add_flag(gui_msg_row[i], LV_OBJ_FLAG_HIDDEN);
// Display name (left)
gui_msg_name[i] = lv_label_create(gui_msg_row[i]);
lv_obj_set_style_text_font(gui_msg_name[i], &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(gui_msg_name[i], lv_color_hex(GUI_COL_WHITE), 0);
lv_obj_set_pos(gui_msg_name[i], 0, 0);
lv_obj_set_width(gui_msg_name[i], GUI_W - 2 * GUI_PAD - 70);
lv_label_set_long_mode(gui_msg_name[i], LV_LABEL_LONG_CLIP);
// RSSI (right)
gui_msg_rssi[i] = lv_label_create(gui_msg_row[i]);
lv_obj_set_style_text_font(gui_msg_rssi[i], &lv_font_montserrat_14, 0);
lv_obj_set_style_text_color(gui_msg_rssi[i], lv_color_hex(GUI_COL_AMBER), 0);
lv_obj_set_pos(gui_msg_rssi[i], GUI_W - 2 * GUI_PAD - 65, 0);
lv_obj_set_width(gui_msg_rssi[i], 65);
lv_obj_set_style_text_align(gui_msg_rssi[i], LV_TEXT_ALIGN_RIGHT, 0);
// Time ago (below name)
gui_msg_time[i] = lv_label_create(gui_msg_row[i]);
lv_obj_set_style_text_font(gui_msg_time[i], &lv_font_montserrat_10, 0);
lv_obj_set_style_text_color(gui_msg_time[i], lv_color_hex(GUI_COL_DIM), 0);
lv_obj_set_pos(gui_msg_time[i], 0, 20);
// Divider line at bottom of row
if (i > 0) {
lv_obj_t *div = lv_obj_create(gui_msg_row[i]);
lv_obj_remove_style_all(div);
lv_obj_set_size(div, GUI_W - 2 * GUI_PAD, 1);
lv_obj_set_pos(div, 0, -3);
lv_obj_set_style_bg_color(div, lv_color_hex(0x1A1A1A), 0);
lv_obj_set_style_bg_opa(div, LV_OPA_COVER, 0);
}
}
}
// ---------------------------------------------------------------------------
// Screen: Settings (bottom tile — swipe up from watch face)
// ---------------------------------------------------------------------------
static lv_obj_t *gui_set_disp_slider = NULL;
static lv_obj_t *gui_set_disp_val = NULL;
static lv_obj_t *gui_set_bcn_roller = NULL;
static lv_obj_t *gui_set_gps_roller = NULL;
static lv_obj_t *gui_set_bcn_sw = NULL;
static lv_obj_t *gui_set_log_sw = NULL;
static lv_obj_t *gui_set_log_status = NULL;
static void gui_set_disp_cb(lv_event_t *e) {
lv_obj_t *slider = (lv_obj_t *)lv_event_get_target(e);
int32_t val = lv_slider_get_value(slider);
display_blanking_timeout = (uint32_t)val * 1000;
char buf[8]; snprintf(buf, sizeof(buf), "%lds", (long)val);
lv_label_set_text(gui_set_disp_val, buf);
EEPROM.write(config_addr(ADDR_CONF_DISP_TIMEOUT), (uint8_t)val);
EEPROM.commit();
}
static void gui_set_bcn_en_cb(lv_event_t *e) {
lv_obj_t *sw = (lv_obj_t *)lv_event_get_target(e);
beacon_enabled = lv_obj_has_state(sw, LV_STATE_CHECKED);
EEPROM.write(config_addr(ADDR_CONF_BCN_EN), beacon_enabled ? 1 : 0);
EEPROM.commit();
}
static void gui_set_bcn_int_cb(lv_event_t *e) {
lv_obj_t *roller = (lv_obj_t *)lv_event_get_target(e);
uint16_t idx = lv_roller_get_selected(roller);
if (idx < BEACON_INTERVAL_OPTIONS_COUNT) {
beacon_interval_ms = beacon_interval_options[idx];
EEPROM.write(config_addr(ADDR_CONF_BCN_INT), (uint8_t)idx);
EEPROM.commit();
}
}
static void gui_set_gps_model_cb(lv_event_t *e) {
lv_obj_t *roller = (lv_obj_t *)lv_event_get_target(e);
uint16_t idx = lv_roller_get_selected(roller);
if (idx < GPS_MODEL_OPTIONS_COUNT) {
gps_set_dynamic_model(idx);
EEPROM.write(config_addr(ADDR_CONF_GPS_MODEL), (uint8_t)idx);
EEPROM.commit();
}
}
static void gui_set_log_cb(lv_event_t *e) {
bool on = lv_obj_has_state(gui_set_log_sw, LV_STATE_CHECKED);
if (on) {
if (gui_log_toggle_fn) gui_log_toggle_fn();
} else {
if (gui_log_toggle_fn && imu_logging) gui_log_toggle_fn();
}
}
static void gui_create_settings_screen(lv_obj_t *parent) {
gui_style_black_container(parent);
// Child container for settings content — do NOT set flex on the tile itself
lv_obj_t *cont = lv_obj_create(parent);
lv_obj_remove_style_all(cont);
lv_obj_set_size(cont, GUI_W, GUI_H);
lv_obj_set_style_bg_color(cont, lv_color_hex(GUI_COL_BLACK), 0);
lv_obj_set_style_bg_opa(cont, LV_OPA_COVER, 0);
lv_obj_clear_flag(cont, LV_OBJ_FLAG_SCROLLABLE);
// Title
gui_label_at(cont, &lv_font_montserrat_14, GUI_COL_DIM, "SETTINGS", GUI_PAD, 12);
// --- Row 1: Display timeout (y=50) ---
gui_label_at(cont, &lv_font_montserrat_14, GUI_COL_MID, "Display timeout", GUI_PAD, 55);
gui_set_disp_slider = lv_slider_create(cont);
lv_obj_set_size(gui_set_disp_slider, 180, 12);
lv_obj_set_pos(gui_set_disp_slider, GUI_PAD, 80);
lv_slider_set_range(gui_set_disp_slider, 5, 60);
lv_slider_set_value(gui_set_disp_slider, (int32_t)(display_blanking_timeout / 1000), LV_ANIM_OFF);
lv_obj_set_style_bg_color(gui_set_disp_slider, lv_color_hex(GUI_COL_DIM), 0);
lv_obj_set_style_bg_color(gui_set_disp_slider, lv_color_hex(GUI_COL_AMBER), LV_PART_INDICATOR);
lv_obj_set_style_bg_color(gui_set_disp_slider, lv_color_hex(GUI_COL_WHITE), LV_PART_KNOB);
lv_obj_set_style_pad_all(gui_set_disp_slider, 4, LV_PART_KNOB);
lv_obj_add_event_cb(gui_set_disp_slider, gui_set_disp_cb, LV_EVENT_VALUE_CHANGED, NULL);
char disp_buf[8]; snprintf(disp_buf, sizeof(disp_buf), "%lds", (long)(display_blanking_timeout / 1000));
gui_set_disp_val = gui_label_at(cont, &lv_font_montserrat_14, GUI_COL_WHITE, disp_buf, GUI_PAD + 200, 75);
gui_create_rule(cont, 110);
// --- Row 2: Beacon enable (y=120) ---
gui_label_at(cont, &lv_font_montserrat_14, GUI_COL_MID, "Beacon", GUI_PAD, 125);
gui_set_bcn_sw = lv_switch_create(cont);
lv_obj_set_pos(gui_set_bcn_sw, GUI_W - GUI_PAD - 50, 120);
lv_obj_set_size(gui_set_bcn_sw, 50, 26);
if (beacon_enabled) lv_obj_add_state(gui_set_bcn_sw, LV_STATE_CHECKED);
lv_obj_set_style_bg_color(gui_set_bcn_sw, lv_color_hex(GUI_COL_DIM), 0);
lv_obj_set_style_bg_color(gui_set_bcn_sw, lv_color_hex(GUI_COL_AMBER), LV_PART_INDICATOR | LV_STATE_CHECKED);
lv_obj_add_event_cb(gui_set_bcn_sw, gui_set_bcn_en_cb, LV_EVENT_VALUE_CHANGED, NULL);
gui_create_rule(cont, 160);
// --- Row 3: Beacon interval (y=170) ---
gui_label_at(cont, &lv_font_montserrat_14, GUI_COL_MID, "Beacon interval", GUI_PAD, 175);
gui_set_bcn_roller = lv_roller_create(cont);
lv_roller_set_options(gui_set_bcn_roller, "10s\n30s\n1min\n5min\n10min", LV_ROLLER_MODE_NORMAL);
lv_obj_set_pos(gui_set_bcn_roller, GUI_W - GUI_PAD - 100, 170);
lv_obj_set_size(gui_set_bcn_roller, 100, 60);
lv_obj_set_style_bg_color(gui_set_bcn_roller, lv_color_hex(GUI_COL_BLACK), 0);
lv_obj_set_style_text_color(gui_set_bcn_roller, lv_color_hex(GUI_COL_WHITE), 0);
lv_obj_set_style_text_font(gui_set_bcn_roller, &lv_font_montserrat_14, 0);
lv_obj_set_style_bg_color(gui_set_bcn_roller, lv_color_hex(GUI_COL_AMBER), LV_PART_SELECTED);
lv_obj_set_style_text_color(gui_set_bcn_roller, lv_color_hex(GUI_COL_BLACK), LV_PART_SELECTED);
// Set initial selection from current beacon_interval_ms
for (uint8_t i = 0; i < BEACON_INTERVAL_OPTIONS_COUNT; i++) {
if (beacon_interval_options[i] == beacon_interval_ms) {
lv_roller_set_selected(gui_set_bcn_roller, i, LV_ANIM_OFF);
break;
}
}
lv_obj_add_event_cb(gui_set_bcn_roller, gui_set_bcn_int_cb, LV_EVENT_VALUE_CHANGED, NULL);
gui_create_rule(cont, 245);
// --- Row 4: GPS dynamic model (y=255) ---
gui_label_at(cont, &lv_font_montserrat_14, GUI_COL_MID, "GPS model", GUI_PAD, 260);
gui_set_gps_roller = lv_roller_create(cont);
lv_roller_set_options(gui_set_gps_roller, "Portable\nStationary\nPedestrian\nAutomotive", LV_ROLLER_MODE_NORMAL);
lv_obj_set_pos(gui_set_gps_roller, GUI_W - GUI_PAD - 140, 255);
lv_obj_set_size(gui_set_gps_roller, 140, 60);
lv_obj_set_style_bg_color(gui_set_gps_roller, lv_color_hex(GUI_COL_BLACK), 0);
lv_obj_set_style_text_color(gui_set_gps_roller, lv_color_hex(GUI_COL_WHITE), 0);
lv_obj_set_style_text_font(gui_set_gps_roller, &lv_font_montserrat_14, 0);
lv_obj_set_style_bg_color(gui_set_gps_roller, lv_color_hex(GUI_COL_AMBER), LV_PART_SELECTED);
lv_obj_set_style_text_color(gui_set_gps_roller, lv_color_hex(GUI_COL_BLACK), LV_PART_SELECTED);
lv_roller_set_selected(gui_set_gps_roller, gps_dynamic_model, LV_ANIM_OFF);
lv_obj_add_event_cb(gui_set_gps_roller, gui_set_gps_model_cb, LV_EVENT_VALUE_CHANGED, NULL);
// --- Sensor logger ---
gui_create_rule(cont, 320);
gui_label_at(cont, &lv_font_montserrat_14, GUI_COL_MID, "Data logger", GUI_PAD, 335);
gui_set_log_sw = lv_switch_create(cont);
lv_obj_set_pos(gui_set_log_sw, GUI_W - GUI_PAD - 50, 330);
lv_obj_set_size(gui_set_log_sw, 50, 26);
lv_obj_set_style_bg_color(gui_set_log_sw, lv_color_hex(GUI_COL_DIM), 0);
lv_obj_set_style_bg_color(gui_set_log_sw, lv_color_hex(GUI_COL_GREEN), LV_PART_INDICATOR | LV_STATE_CHECKED);
lv_obj_add_event_cb(gui_set_log_sw, gui_set_log_cb, LV_EVENT_VALUE_CHANGED, NULL);
gui_set_log_status = gui_label_at(cont, &lv_font_montserrat_14, GUI_COL_DIM, "", GUI_PAD, 365);
}
// ---------------------------------------------------------------------------
// Tileview change event — haptic feedback
// ---------------------------------------------------------------------------
static void gui_tile_change_cb(lv_event_t *e) {
lv_obj_t *tv = (lv_obj_t *)lv_event_get_target(e);
lv_obj_t *tile = (lv_obj_t *)lv_tileview_get_tile_active(tv);
if (!tile) return;
// Get tile position
lv_coord_t col = lv_obj_get_x(tile) / GUI_W;
lv_coord_t row = lv_obj_get_y(tile) / GUI_H;
if (col != gui_last_tile_col || row != gui_last_tile_row) {
gui_last_tile_col = col;
gui_last_tile_row = row;
#if defined(DRV2605_H)
if (drv2605_ready) drv2605_play(HAPTIC_TRANSITION);
#endif
}
}
// ---------------------------------------------------------------------------
// Update all screen data from firmware globals
// ---------------------------------------------------------------------------
static const char *gui_month_names[] = {"JAN", "FEB", "MAR", "APR", "MAY", "JUN",
"JUL", "AUG", "SEP", "OCT", "NOV", "DEC"};
static bool gui_is_scrolling() {
if (!gui_tileview) return false;
lv_obj_t *tile = (lv_obj_t *)lv_tileview_get_tile_active(gui_tileview);
if (!tile) return false;
// Check if scroll position doesn't match tile alignment
lv_coord_t sx = lv_obj_get_scroll_x(gui_tileview);
lv_coord_t sy = lv_obj_get_scroll_y(gui_tileview);
lv_coord_t tx = lv_obj_get_x(tile);
lv_coord_t ty = lv_obj_get_y(tile);
return (sx != tx || sy != ty);
}
static void gui_update_data() {
if (!gui_time_label) return;
// Skip all GUI updates when display is blanked — no point rendering
// to a sleeping display. Saves CPU, SPI writes, and display power.
if (display_blanked) return;
// Skip data updates during scroll animation — frees CPU for rendering
if (gui_is_scrolling()) return;
uint32_t now = millis();
if (now - gui_last_data_update < GUI_DATA_UPDATE_MS) return;
gui_last_data_update = now;
// Detect current tile — only update visible screen's labels
lv_obj_t *cur_tile = (lv_obj_t *)lv_tileview_get_tile_active(gui_tileview);
bool on_watch = (cur_tile == gui_tile_watch);
bool on_radio = (cur_tile == gui_tile_radio);
bool on_gps = (cur_tile == gui_tile_gps);
bool on_settings = (cur_tile == gui_tile_set);
bool on_msg = (cur_tile == gui_tile_msg);
// ---- Watch face ----
// Time always updates (cheap, changes rarely)
lv_label_set_text_fmt(gui_time_label, "%02d:%02d", rtc_hour, rtc_minute);
if (!on_watch && !on_radio && !on_gps && !on_settings && !on_msg) return;
// ---- Watch face details (only when visible) ----
if (on_watch) {
#if HAS_RTC == true
if (rtc_year > 0) {
const char *mon = (rtc_month >= 1 && rtc_month <= 12) ? gui_month_names[rtc_month - 1] : "---";
lv_label_set_text_fmt(gui_date_label, "%d %s %d", rtc_day, mon, rtc_year);
}
#endif
// Mode indicator
if (bt_state == BT_STATE_CONNECTED) {
lv_label_set_text(gui_mode_label, "MODEM");
lv_obj_set_style_text_color(gui_mode_label, lv_color_hex(GUI_COL_BLUE), 0);
}
#if HAS_GPS == true
else if (beacon_mode_active) {
lv_label_set_text(gui_mode_label, "BEACON");
lv_obj_set_style_text_color(gui_mode_label, lv_color_hex(GUI_COL_AMBER), 0);
}
#endif
else if (radio_online) {
lv_label_set_text(gui_mode_label, "RADIO");
lv_obj_set_style_text_color(gui_mode_label, lv_color_hex(GUI_COL_MID), 0);
} else {
lv_label_set_text(gui_mode_label, "IDLE");
lv_obj_set_style_text_color(gui_mode_label, lv_color_hex(GUI_COL_DIM), 0);
}
// Battery
if (battery_state == BATTERY_STATE_CHARGING) {
lv_label_set_text_fmt(gui_batt_label, "%d%% +", (int)battery_percent);
} else {
lv_label_set_text_fmt(gui_batt_label, "%d%%", (int)battery_percent);
}
lv_obj_align(gui_batt_label, LV_ALIGN_TOP_RIGHT, -GUI_PAD, GUI_STATUS_Y);
// LoRa complication — show RSSI if receiving, TX count if beaconing, noise floor otherwise
if (radio_online) {
if (last_rssi > -292) {
// Received a packet — show RSSI
lv_label_set_text_fmt(gui_lora_value, "%d", last_rssi);
lv_obj_set_style_text_color(gui_lora_value, lv_color_hex(GUI_COL_AMBER), 0);
} else if (stat_tx > 0) {
// Beacon mode — show TX count
lv_label_set_text_fmt(gui_lora_value, "TX:%lu", stat_tx);
lv_obj_set_style_text_color(gui_lora_value, lv_color_hex(GUI_COL_AMBER), 0);
} else if (noise_floor > -292) {
// Radio listening — show noise floor
lv_label_set_text_fmt(gui_lora_value, "%d", noise_floor);
lv_obj_set_style_text_color(gui_lora_value, lv_color_hex(GUI_COL_DIM), 0);
} else {
lv_label_set_text(gui_lora_value, "---");
lv_obj_set_style_text_color(gui_lora_value, lv_color_hex(GUI_COL_DIM), 0);
}
lv_label_set_text(gui_lora_label, ifac_configured ? "LoRa" : "NO KEY");
lv_obj_set_style_text_color(gui_lora_label,
lv_color_hex(ifac_configured ? GUI_COL_DIM : GUI_COL_RED), 0);
} else {
lv_label_set_text(gui_lora_value, "OFF");
lv_obj_set_style_text_color(gui_lora_value, lv_color_hex(0x302000), 0);
lv_label_set_text(gui_lora_label, ifac_configured ? "LoRa" : "NO KEY");
lv_obj_set_style_text_color(gui_lora_label,
lv_color_hex(ifac_configured ? 0x302000 : GUI_COL_RED), 0);
}
// GPS complication — dim when disabled, color by fix quality
#if HAS_GPS == true
if (!gps_ready) {
lv_label_set_text(gui_gps_value, "OFF");
lv_obj_set_style_text_color(gui_gps_value, lv_color_hex(0x003020), 0);
lv_obj_set_style_text_color(gui_gps_label, lv_color_hex(0x003020), 0);
} else if (gps_sats > 0) {
lv_label_set_text_fmt(gui_gps_value, "%d sats", gps_sats);
uint32_t gps_col = (gps_hdop < 5.0) ? GUI_COL_TEAL :
(gps_hdop < 15.0) ? GUI_COL_AMBER : GUI_COL_MID;
lv_obj_set_style_text_color(gui_gps_value, lv_color_hex(gps_col), 0);
lv_obj_set_style_text_color(gui_gps_label, lv_color_hex(GUI_COL_DIM), 0);
} else {
lv_label_set_text(gui_gps_value, "no fix");
lv_obj_set_style_text_color(gui_gps_value, lv_color_hex(GUI_COL_DIM), 0);
lv_obj_set_style_text_color(gui_gps_label, lv_color_hex(GUI_COL_DIM), 0);
}
#endif
// Step counter
if (gui_step_label) {
if (imu_step_count > 0) {
lv_label_set_text_fmt(gui_step_label, "%lu steps", imu_step_count);
lv_obj_set_style_text_color(gui_step_label, lv_color_hex(GUI_COL_MID), 0);
} else {
lv_label_set_text(gui_step_label, "");
}
}
// Bubble level — spring-damper physics with non-linear sensitivity
if (gui_level_dot && imu_az_f != 0) {
static float bub_x = 0, bub_y = 0; // current bubble position (pixels)
static float vel_x = 0, vel_y = 0; // bubble velocity
static uint32_t bub_t = 0; // last update time
uint32_t now_ms = millis();
float dt = (bub_t > 0) ? (now_ms - bub_t) / 1000.0f : 0.016f;
if (dt > 0.1f) dt = 0.1f; // clamp for first frame / pauses
bub_t = now_ms;
float max_r = (GUI_LEVEL_SIZE - GUI_LEVEL_DOT) / 2.0f;
// Tilt vector from accelerometer (radians)
float tilt_x = atan2f(imu_ax_f, imu_az_f);
float tilt_y = atan2f(imu_ay_f, imu_az_f);
// Work in polar: magnitude + direction
float tilt_r = sqrtf(tilt_x * tilt_x + tilt_y * tilt_y);
float tilt_dir_x = (tilt_r > 0.001f) ? tilt_x / tilt_r : 0;
float tilt_dir_y = (tilt_r > 0.001f) ? tilt_y / tilt_r : 0;
// Non-linear radial mapping: tanh compresses large tilts,
// amplifies small ones. k=3 → full ring at ~20° tilt
float mapped_r = tanhf(tilt_r * 3.0f) * max_r;
// Project back to cartesian (invert y for natural bubble feel, x matches)
float target_x = tilt_dir_x * mapped_r;
float target_y = -tilt_dir_y * mapped_r;
// Adaptive spring-damper: snappy when still, viscous when noisy
// noise < 0.001 (desk): spring=80 damp=16 → settles in ~0.15s
// noise > 0.01 (hand): spring=30 damp=14 → settles in ~0.4s
float noise_t = imu_noise < 0.001f ? 0.0f :
imu_noise > 0.01f ? 1.0f :
(imu_noise - 0.001f) / 0.009f;
float spring = 80.0f - 50.0f * noise_t;
float damping = 16.0f - 2.0f * noise_t;
const float max_sub = 0.02f;
int steps = (int)(dt / max_sub) + 1;
float sdt = dt / steps;
for (int si = 0; si < steps; si++) {
vel_x += (spring * (target_x - bub_x) - damping * vel_x) * sdt;
vel_y += (spring * (target_y - bub_y) - damping * vel_y) * sdt;
bub_x += vel_x * sdt;
bub_y += vel_y * sdt;
}
// Clamp to ring boundary (bubble can't escape the fluid)
float dist = sqrtf(bub_x * bub_x + bub_y * bub_y);
if (dist > max_r) {
bub_x = bub_x * max_r / dist;
bub_y = bub_y * max_r / dist;
// Kill velocity component along the wall
float nx = bub_x / dist, ny = bub_y / dist;
float vdot = vel_x * nx + vel_y * ny;
if (vdot > 0) { vel_x -= vdot * nx; vel_y -= vdot * ny; }
}
lv_obj_set_pos(gui_level_dot,
(int)(GUI_LEVEL_SIZE / 2 - GUI_LEVEL_DOT / 2 + bub_x),
(int)(GUI_LEVEL_SIZE / 2 - GUI_LEVEL_DOT / 2 + bub_y));
// Tilt angle for display and colour
float tx = imu_ax_f / 4096.0f, ty = imu_ay_f / 4096.0f;
float tilt_deg = atan2f(sqrtf(tx*tx + ty*ty), fabsf(imu_az_f / 4096.0f)) * 57.2958f;
uint32_t dot_col = (tilt_deg < 2.0f) ? GUI_COL_GREEN :
(tilt_deg < 10.0f) ? GUI_COL_AMBER : GUI_COL_RED;
lv_obj_set_style_bg_color(gui_level_dot, lv_color_hex(dot_col), 0);
lv_label_set_text_fmt(gui_level_angle, "%.1f\xC2\xB0", tilt_deg);
lv_obj_set_style_text_color(gui_level_angle, lv_color_hex(dot_col), 0);
}
// Signal strength view — record RSSI and update graph
if (gui_signal_cont) {
// Record signal level periodically (RSSI if available, noise floor otherwise)
static int16_t prev_rssi = -292;
static uint32_t last_rssi_record = 0;
int16_t current_signal = (last_rssi > -292) ? last_rssi : noise_floor;
if (radio_online && millis() - last_rssi_record > 5000 && current_signal > -292) {
rssi_history[rssi_history_idx] = current_signal;
rssi_history_idx = (rssi_history_idx + 1) % RSSI_HISTORY_LEN;
if (rssi_history_count < RSSI_HISTORY_LEN) rssi_history_count++;
// Record GPS+RSSI for direction finding (only with real RSSI, not noise floor)
#if HAS_GPS == true
if (gps_has_fix && last_rssi > -292) {
dir_history[dir_history_idx] = { gps_lat, gps_lon, last_rssi };
dir_history_idx = (dir_history_idx + 1) % DIR_HISTORY_LEN;
if (dir_history_count < DIR_HISTORY_LEN) dir_history_count++;
}
#endif
last_rssi_record = millis();
}
// Update signal view when visible
if (gui_show_signal && rssi_history_count > 0) {
// Current signal display
if (last_rssi > -292 && radio_online) {
lv_label_set_text_fmt(gui_signal_rssi, "%d dBm", last_rssi);
uint32_t rssi_col = (last_rssi > -80) ? GUI_COL_GREEN :
(last_rssi > -100) ? GUI_COL_AMBER : GUI_COL_RED;
lv_obj_set_style_text_color(gui_signal_rssi, lv_color_hex(rssi_col), 0);
} else if (noise_floor > -292 && radio_online) {
lv_label_set_text_fmt(gui_signal_rssi, "floor %d", noise_floor);
lv_obj_set_style_text_color(gui_signal_rssi, lv_color_hex(GUI_COL_DIM), 0);
} else {
lv_label_set_text(gui_signal_rssi, "no signal");
lv_obj_set_style_text_color(gui_signal_rssi, lv_color_hex(GUI_COL_DIM), 0);
}
// Build sparkline points
int graph_w = GUI_W - 2 * GUI_PAD;
int graph_h = GUI_LEVEL_SIZE - 30;
// RSSI range: -130 to -30 dBm mapped to graph height
int n = rssi_history_count;
for (int i = 0; i < n; i++) {
int idx = (rssi_history_idx - n + i + RSSI_HISTORY_LEN) % RSSI_HISTORY_LEN;
float x = (float)i / (RSSI_HISTORY_LEN - 1) * graph_w;
float norm = (float)(rssi_history[idx] + 130) / 100.0f; // -130→0, -30→1
if (norm < 0) norm = 0; if (norm > 1) norm = 1;
float y = graph_h * (1.0f - norm);
rssi_graph_pts[i] = { (lv_value_precise_t)x, (lv_value_precise_t)y };
}
lv_line_set_points(gui_signal_line, rssi_graph_pts, n);
// Direction estimation from GPS+RSSI gradient
#if HAS_GPS == true
if (dir_history_count >= 3 && gps_has_fix) {
// Weighted centroid: stronger signal → weight toward that position
// Direction = from current position toward weighted centroid
double wlat = 0, wlon = 0, wsum = 0;
for (int i = 0; i < dir_history_count; i++) {
// Weight: convert RSSI to linear power (higher = closer to source)
double w = pow(10.0, (double)dir_history[i].rssi / 20.0);
wlat += dir_history[i].lat * w;
wlon += dir_history[i].lon * w;
wsum += w;
}
if (wsum > 0) {
wlat /= wsum; wlon /= wsum;
double dlat = wlat - gps_lat;
double dlon = (wlon - gps_lon) * cos(gps_lat * 0.01745329);
double bearing = atan2(dlon, dlat) * 57.2958;
if (bearing < 0) bearing += 360;
const char *dirs[] = {"N","NE","E","SE","S","SW","W","NW"};
int di = ((int)(bearing + 22.5) / 45) % 8;
lv_label_set_text_fmt(gui_signal_dir, "%s %.0f\xC2\xB0", dirs[di], bearing);
lv_obj_set_style_text_color(gui_signal_dir, lv_color_hex(GUI_COL_TEAL), 0);
}
} else {
lv_label_set_text(gui_signal_dir, "");
}
#endif
}
}
// Battery time estimation — sample every 60s
{
uint32_t bnow = millis();
if (battery_percent > 0 && (bnow - batt_hist_last > 60000 || batt_hist_last == 0)) {
batt_hist_last = bnow;
// Shift history
for (int i = 3; i > 0; i--) batt_pct_history[i] = batt_pct_history[i-1];
batt_pct_history[0] = battery_percent;
// Compute rate from oldest valid to newest (up to 4 min window)
int oldest = -1;
for (int i = 3; i >= 1; i--) { if (batt_pct_history[i] >= 0) { oldest = i; break; } }
if (oldest > 0) {
float delta_pct = batt_pct_history[0] - batt_pct_history[oldest];
float delta_hr = (oldest * 60.0f) / 3600.0f;
batt_rate_pct_hr = delta_pct / delta_hr;
}
}
}
// Battery complication — tap cycles through modes
{
bool charging = (battery_state == BATTERY_STATE_CHARGING);
bool charged = (battery_state == BATTERY_STATE_CHARGED);
uint32_t col = charged || charging ? GUI_COL_GREEN :
battery_percent < 15 ? GUI_COL_RED : GUI_COL_WHITE;
bool show_icon = (gui_batt_mode == 3);
// Show/hide text vs icon
if (show_icon) {
lv_obj_add_flag(gui_batt_value, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(gui_batt_icon, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_clear_flag(gui_batt_value, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(gui_batt_icon, LV_OBJ_FLAG_HIDDEN);
}
if (gui_batt_mode == 0) {
// Voltage
lv_label_set_text_fmt(gui_batt_value, "%.2fV", battery_voltage);
lv_obj_set_style_text_color(gui_batt_value, lv_color_hex(col), 0);
lv_label_set_text(gui_batt_detail, charging ? "Charging" : charged ? "Full" : "Batt");
} else if (gui_batt_mode == 1) {
// Percentage
lv_label_set_text_fmt(gui_batt_value, "%d%%", (int)battery_percent);
lv_obj_set_style_text_color(gui_batt_value, lv_color_hex(col), 0);
lv_label_set_text(gui_batt_detail, charging ? "Charging" : "Battery");
} else if (gui_batt_mode == 2) {
// Time remaining
if (charged) {
lv_label_set_text(gui_batt_value, "Full");
lv_obj_set_style_text_color(gui_batt_value, lv_color_hex(GUI_COL_GREEN), 0);
} else if (batt_rate_pct_hr < -0.5f) {
// Discharging: time to 0%
float hrs = -battery_percent / batt_rate_pct_hr;
if (hrs > 99) lv_label_set_text(gui_batt_value, "99h+");
else if (hrs >= 1) lv_label_set_text_fmt(gui_batt_value, "%.0fh%02d", floorf(hrs), (int)((hrs - floorf(hrs))*60));
else lv_label_set_text_fmt(gui_batt_value, "%dm", (int)(hrs * 60));
lv_obj_set_style_text_color(gui_batt_value, lv_color_hex(col), 0);
} else if (batt_rate_pct_hr > 0.5f && charging) {
// Charging: time to 100%
float hrs = (100.0f - battery_percent) / batt_rate_pct_hr;
if (hrs >= 1) lv_label_set_text_fmt(gui_batt_value, "%.0fh%02d", floorf(hrs), (int)((hrs - floorf(hrs))*60));
else lv_label_set_text_fmt(gui_batt_value, "%dm", (int)(hrs * 60));
lv_obj_set_style_text_color(gui_batt_value, lv_color_hex(GUI_COL_GREEN), 0);
} else {
lv_label_set_text(gui_batt_value, "---");
lv_obj_set_style_text_color(gui_batt_value, lv_color_hex(GUI_COL_DIM), 0);
}
lv_label_set_text(gui_batt_detail, charging ? "to full" : "remain");
} else {
// Icon mode — animated bar
int bar_inner_w = lv_obj_get_width(gui_batt_icon) - 6;
int fill_w = (int)(bar_inner_w * battery_percent / 100.0f);
if (fill_w < 2) fill_w = 2;
// Charging animation: pulse fill width
if (charging) {
static uint32_t anim_t = 0;
float phase = (float)((millis() - anim_t) % 2000) / 2000.0f;
float pulse = (sinf(phase * 6.2832f) + 1.0f) / 2.0f;
fill_w = (int)(fill_w + (bar_inner_w - fill_w) * pulse * 0.3f);
}
lv_obj_set_width(gui_batt_fill, fill_w);
lv_obj_set_style_bg_color(gui_batt_fill, lv_color_hex(col), 0);
lv_obj_set_style_border_color(gui_batt_icon, lv_color_hex(col), 0);
lv_label_set_text_fmt(gui_batt_detail, "%d%%", (int)battery_percent);
}
lv_obj_set_style_text_color(gui_batt_detail, lv_color_hex(charging || charged ? GUI_COL_GREEN : GUI_COL_DIM), 0);
}
} // end on_watch
// ---- Radio status screen (only when visible) ----
if (on_radio && gui_radio_freq) {
if (lora_freq > 0) {
lv_label_set_text_fmt(gui_radio_freq, "%.3f MHz", (float)lora_freq / 1000000.0);
} else {
lv_label_set_text(gui_radio_freq, "--- MHz");
}
lv_label_set_text_fmt(gui_radio_params, "SF%d BW%lu CR4/%d",
lora_sf, lora_bw / 1000, lora_cr);
if (radio_online && last_rssi > -292) {
lv_bar_set_value(gui_radio_rssi_bar, last_rssi, LV_ANIM_ON);
lv_label_set_text_fmt(gui_radio_rssi_lbl, "%d dBm", last_rssi);
} else {
lv_bar_set_value(gui_radio_rssi_bar, -140, LV_ANIM_OFF);
lv_label_set_text(gui_radio_rssi_lbl, "---");
}
lv_label_set_text_fmt(gui_radio_util, "%.1f%%",
(float)local_channel_util / 100.0);
if (bt_state == BT_STATE_CONNECTED) {
lv_label_set_text(gui_radio_ble, "Connected");
lv_obj_set_style_text_color(gui_radio_ble, lv_color_hex(GUI_COL_BLUE), 0);
} else if (bt_state == BT_STATE_ON) {
lv_label_set_text(gui_radio_ble, "Advertising");
lv_obj_set_style_text_color(gui_radio_ble, lv_color_hex(GUI_COL_MID), 0);
} else {
lv_label_set_text(gui_radio_ble, "Off");
lv_obj_set_style_text_color(gui_radio_ble, lv_color_hex(GUI_COL_DIM), 0);
}
lv_label_set_text_fmt(gui_radio_pkts, "RX: %lu TX: %lu", stat_rx, stat_tx);
// Battery detail
if (gui_radio_batt) {
lv_label_set_text_fmt(gui_radio_batt, "%.2fV %d%%", battery_voltage, (int)battery_percent);
lv_obj_align(gui_radio_batt, LV_ALIGN_TOP_RIGHT, -GUI_PAD, 335);
}
// Temperature
if (gui_radio_temp) {
if (pmu_temperature > (PMU_TEMP_MIN - 1)) {
lv_label_set_text_fmt(gui_radio_temp, "%.1f C", pmu_temperature);
} else {
lv_label_set_text(gui_radio_temp, "---");
}
lv_obj_align(gui_radio_temp, LV_ALIGN_TOP_RIGHT, -GUI_PAD, 380);
}
}
// ---- GPS screen (only when visible — float formatting is expensive) ----
#if HAS_GPS == true
if (on_gps && gui_gps_coords) {
bool good_fix = (gps_sats >= 4 && gps_hdop < 10.0 && gps_lat != 0.0);
bool any_fix = (gps_sats > 0 && gps_lat != 0.0);
// Coordinates — show when any fix, but dim when HDOP is poor
if (any_fix) {
lv_label_set_text_fmt(gui_gps_coords, "%.6f\n%.6f", gps_lat, gps_lon);
lv_obj_set_style_text_color(gui_gps_coords,
lv_color_hex(good_fix ? GUI_COL_TEAL : GUI_COL_MID), 0);
} else {
lv_label_set_text(gui_gps_coords, "No fix");
lv_obj_set_style_text_color(gui_gps_coords, lv_color_hex(GUI_COL_DIM), 0);
}
// Fix quality — color HDOP by quality
uint32_t hdop_col = (gps_hdop < 2.0) ? GUI_COL_GREEN :
(gps_hdop < 5.0) ? GUI_COL_TEAL :
(gps_hdop < 10.0) ? GUI_COL_AMBER : GUI_COL_RED;
lv_label_set_text_fmt(gui_gps_fix, "Sats: %d HDOP: %.1f", gps_sats, gps_hdop);
lv_obj_set_style_text_color(gui_gps_fix, lv_color_hex(hdop_col), 0);
// Alt/Speed — suppress speed when HDOP is poor (it's just noise)
if (good_fix) {
lv_label_set_text_fmt(gui_gps_alt, "Alt: %.0fm Spd: %.1fkm/h", gps_alt, gps_speed);
lv_obj_set_style_text_color(gui_gps_alt, lv_color_hex(GUI_COL_MID), 0);
} else if (any_fix) {
lv_label_set_text_fmt(gui_gps_alt, "Alt: %.0fm Spd: ---", gps_alt);
lv_obj_set_style_text_color(gui_gps_alt, lv_color_hex(GUI_COL_DIM), 0);
} else {
lv_label_set_text(gui_gps_alt, "Alt: --- Spd: ---");
lv_obj_set_style_text_color(gui_gps_alt, lv_color_hex(GUI_COL_DIM), 0);
}
if (beacon_mode_active) {
lv_label_set_text(gui_gps_beacon, "Beacon: active");
lv_obj_set_style_text_color(gui_gps_beacon, lv_color_hex(GUI_COL_AMBER), 0);
} else {
lv_label_set_text(gui_gps_beacon, "Beacon: off");
lv_obj_set_style_text_color(gui_gps_beacon, lv_color_hex(GUI_COL_DIM), 0);
}
}
#endif
// ---- Settings screen (logger status) ----
if (on_settings && gui_set_log_status) {
#if HAS_SD
if (imu_logging) {
uint32_t dur = (millis() - imu_log_start_ms) / 1000;
lv_label_set_text_fmt(gui_set_log_status, "%lu samples %lus", imu_log_samples, dur);
lv_obj_set_style_text_color(gui_set_log_status, lv_color_hex(GUI_COL_GREEN), 0);
if (!lv_obj_has_state(gui_set_log_sw, LV_STATE_CHECKED))
lv_obj_add_state(gui_set_log_sw, LV_STATE_CHECKED);
} else {
lv_label_set_text(gui_set_log_status, "SD card ready");
lv_obj_set_style_text_color(gui_set_log_status, lv_color_hex(GUI_COL_DIM), 0);
if (lv_obj_has_state(gui_set_log_sw, LV_STATE_CHECKED))
lv_obj_clear_state(gui_set_log_sw, LV_STATE_CHECKED);
}
#else
lv_label_set_text(gui_set_log_status, "No SD card");
#endif
}
// ---- Messages screen ----
if (on_msg && gui_msg_cont) {
if (msg_log_count == 0) {
lv_obj_clear_flag(gui_msg_empty, LV_OBJ_FLAG_HIDDEN);
for (int i = 0; i < GUI_MSG_ROWS; i++)
lv_obj_add_flag(gui_msg_row[i], LV_OBJ_FLAG_HIDDEN);
lv_label_set_text_fmt(gui_msg_title, "MESSAGES");
} else {
lv_obj_add_flag(gui_msg_empty, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text_fmt(gui_msg_title, "MESSAGES (%d)", msg_log_count);
// Display messages newest-first
int shown = 0;
for (int i = 0; i < msg_log_count && shown < GUI_MSG_ROWS; i++) {
int idx = (msg_log_head - 1 - i + MSG_LOG_SIZE) % MSG_LOG_SIZE;
msg_entry_t &m = msg_log[idx];
lv_obj_clear_flag(gui_msg_row[shown], LV_OBJ_FLAG_HIDDEN);
// Name — green for verified LXMF, amber for announces, white for data
if (m.is_lxmf && m.content[0]) {
// LXMF message: show content as primary text
lv_label_set_text(gui_msg_name[shown], m.content);
lv_obj_set_style_text_color(gui_msg_name[shown],
lv_color_hex(m.verified ? GUI_COL_GREEN : GUI_COL_WHITE), 0);
} else {
lv_label_set_text(gui_msg_name[shown], m.display_name);
lv_obj_set_style_text_color(gui_msg_name[shown],
lv_color_hex(m.is_announce ? GUI_COL_AMBER : GUI_COL_WHITE), 0);
}
// RSSI with colour coding
lv_label_set_text_fmt(gui_msg_rssi[shown], "%d dBm", m.rssi);
uint32_t rssi_col = (m.rssi > -80) ? GUI_COL_GREEN :
(m.rssi > -100) ? GUI_COL_AMBER : GUI_COL_RED;
lv_obj_set_style_text_color(gui_msg_rssi[shown], lv_color_hex(rssi_col), 0);
// Time ago + sender name for LXMF messages
uint32_t ago = (millis() - m.timestamp) / 1000;
if (m.is_lxmf) {
if (ago < 60) lv_label_set_text_fmt(gui_msg_time[shown], "%s %lus ago", m.display_name, ago);
else if (ago < 3600) lv_label_set_text_fmt(gui_msg_time[shown], "%s %lum ago", m.display_name, ago / 60);
else lv_label_set_text_fmt(gui_msg_time[shown], "%s %luh ago", m.display_name, ago / 3600);
} else {
if (ago < 60) lv_label_set_text_fmt(gui_msg_time[shown], "%lus ago %dB", ago, m.pkt_len);
else if (ago < 3600) lv_label_set_text_fmt(gui_msg_time[shown], "%lum ago %dB", ago / 60, m.pkt_len);
else lv_label_set_text_fmt(gui_msg_time[shown], "%luh ago %dB", ago / 3600, m.pkt_len);
}
shown++;
}
// Hide unused rows
for (int i = shown; i < GUI_MSG_ROWS; i++)
lv_obj_add_flag(gui_msg_row[i], LV_OBJ_FLAG_HIDDEN);
}
}
}
// ---------------------------------------------------------------------------
// Initialize LVGL and create all screens
// ---------------------------------------------------------------------------
bool gui_init() {
lv_init();
// --- Display driver ---
gui_display = lv_display_create(GUI_W, GUI_H);
if (!gui_display) return false;
lv_display_set_color_format(gui_display, LV_COLOR_FORMAT_RGB565_SWAPPED);
lv_display_set_flush_cb(gui_display, gui_flush_cb);
uint32_t buf_size = GUI_W * GUI_BUF_LINES * sizeof(uint16_t);
gui_buf1 = (uint8_t *)heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
gui_buf2 = (uint8_t *)heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!gui_buf1 || !gui_buf2) {
if (gui_buf1) free(gui_buf1);
if (gui_buf2) free(gui_buf2);
gui_buf2 = NULL;
gui_buf1 = (uint8_t *)malloc(buf_size);
if (!gui_buf1) return false;
}
lv_display_set_buffers(gui_display, gui_buf1, gui_buf2, buf_size,
LV_DISPLAY_RENDER_MODE_FULL);
// Shadow framebuffer for screenshots (410*502*2 = 411,640 bytes)
gui_screenshot_buf = (uint16_t *)heap_caps_malloc(GUI_W * GUI_H * sizeof(uint16_t),
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (gui_screenshot_buf) {
memset(gui_screenshot_buf, 0, GUI_W * GUI_H * sizeof(uint16_t));
Serial.printf("[gui] screenshot buf @ %p (%u bytes)\n",
gui_screenshot_buf, GUI_W * GUI_H * 2);
}
// --- Input driver ---
gui_indev = lv_indev_create();
if (gui_indev) {
lv_indev_set_type(gui_indev, LV_INDEV_TYPE_POINTER);
lv_indev_set_read_cb(gui_indev, gui_touch_read_cb);
lv_indev_set_scroll_throw(gui_indev, 20); // Moderate friction: decelerates into snap within ~1s at 10fps
}
// --- Screen setup ---
gui_screen = lv_screen_active();
gui_style_black_container(gui_screen);
// --- Tileview: 3x3 grid, 5 populated tiles ---
// (1,0) Radio
// (0,1) GPS (1,1) Watch (2,1) Messages
// (1,2) Settings
gui_tileview = lv_tileview_create(gui_screen);
lv_obj_set_style_bg_color(gui_tileview, lv_color_hex(GUI_COL_BLACK), 0);
lv_obj_set_style_bg_opa(gui_tileview, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(gui_tileview, 0, 0);
lv_obj_set_style_pad_all(gui_tileview, 0, 0);
lv_obj_set_scrollbar_mode(gui_tileview, LV_SCROLLBAR_MODE_OFF);
lv_obj_set_style_anim_duration(gui_tileview, 150, 0); // Snappy 150ms scroll snap
lv_obj_clear_flag(gui_tileview, LV_OBJ_FLAG_SCROLL_ELASTIC); // No bounce at tile edges
lv_obj_set_size(gui_tileview, GUI_W, GUI_H);
gui_tile_watch = lv_tileview_add_tile(gui_tileview, 1, 1, LV_DIR_ALL);
gui_tile_radio = lv_tileview_add_tile(gui_tileview, 1, 0, LV_DIR_BOTTOM);
gui_tile_gps = lv_tileview_add_tile(gui_tileview, 0, 1, LV_DIR_RIGHT);
gui_tile_msg = lv_tileview_add_tile(gui_tileview, 2, 1, LV_DIR_LEFT);
gui_tile_set = lv_tileview_add_tile(gui_tileview, 1, 2, LV_DIR_TOP);
// Start on watch face
lv_tileview_set_tile(gui_tileview, gui_tile_watch, LV_ANIM_OFF);
// Haptic feedback on tile change
lv_obj_add_event_cb(gui_tileview, gui_tile_change_cb, LV_EVENT_VALUE_CHANGED, NULL);
// --- Create screen content ---
gui_create_watchface(gui_tile_watch);
gui_create_radio_screen(gui_tile_radio);
gui_create_gps_screen(gui_tile_gps);
gui_create_msg_screen(gui_tile_msg);
gui_create_settings_screen(gui_tile_set);
return true;
}
// ---------------------------------------------------------------------------
// Screenshot: dump framebuffer as raw RGB565 to a file on SPIFFS,
// or output dimensions to serial for external tools.
// Call gui_screenshot() to write /screenshot.raw to SPIFFS (if mounted),
// or read gui_screenshot_buf directly via debugger.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Remote debug protocol over serial
// ---------------------------------------------------------------------------
// Trigger: 3-byte prefix [0x52, 0x57, 0x53] ("RWS") + command byte + optional payload
//
// Commands:
// 'S' (0x53) — Screenshot: captures next frame, responds RWSS + u16 w + u16 h + pixels
// 'T' (0x54) — Touch inject: reads 5 bytes (u16 x, u16 y, u8 duration_100ms)
// 'N' (0x4E) — Navigate: reads 2 bytes (u8 col, u8 row) — jump to tile
// 'M' (0x4D) — Metrics: responds RWSM + JSON stats
// 'I' (0x49) — Invalidate: force full screen redraw
// 'L' (0x4C) — Log toggle: start/stop IMU logging to SD card
// 'F' (0x46) — File list: lists files on SD card
// 'P' (0x50) — Profile: runs standardized performance test, reports JSON results
// 'B' (0x42) — Beacon dump: dumps last beacon packet pre/post IFAC
// 'C' (0x43) — Crypto test: runs IFAC test vectors, reports pass/fail
#define GUI_CMD_PREFIX_LEN 3
static const uint8_t gui_cmd_prefix[] = {0x52, 0x57, 0x53}; // "RWS"
static uint8_t gui_cmd_state = 0;
static uint8_t gui_cmd_id = 0;
static uint8_t gui_cmd_payload[8];
static uint8_t gui_cmd_payload_pos = 0;
static uint8_t gui_cmd_payload_len = 0;
static void gui_cmd_execute();
void gui_process_serial_byte(uint8_t b) {
// Match prefix
if (gui_cmd_state < GUI_CMD_PREFIX_LEN) {
if (b == gui_cmd_prefix[gui_cmd_state]) {
gui_cmd_state++;
} else {
gui_cmd_state = (b == gui_cmd_prefix[0]) ? 1 : 0;
}
return;
}
// Prefix matched — next byte is command
if (gui_cmd_state == GUI_CMD_PREFIX_LEN) {
gui_cmd_id = b;
gui_cmd_payload_pos = 0;
switch (b) {
case 'T': gui_cmd_payload_len = 5; break; // x(2) + y(2) + duration(1)
case 'N': gui_cmd_payload_len = 2; break; // col(1) + row(1)
case 'D': gui_cmd_payload_len = 1; break; // file index(1)
default: gui_cmd_payload_len = 0; break; // S, M, I, F, L, X, Z — no payload
}
gui_cmd_state++;
if (gui_cmd_payload_len == 0) {
gui_cmd_execute();
gui_cmd_state = 0;
}
return;
}
// Collecting payload
if (gui_cmd_payload_pos < gui_cmd_payload_len) {
gui_cmd_payload[gui_cmd_payload_pos++] = b;
if (gui_cmd_payload_pos >= gui_cmd_payload_len) {
gui_cmd_execute();
gui_cmd_state = 0;
}
}
}
static void gui_cmd_execute() {
const uint8_t hdr[] = {'R', 'W', 'S', gui_cmd_id};
switch (gui_cmd_id) {
case 'S': { // Screenshot
if (gui_screenshot_buf) {
// Unblank and force full redraw so screenshot captures entire screen
if (display_blanked) display_unblank();
lv_obj_invalidate(lv_screen_active());
gui_screenshot_pending = true;
// Force full-screen render into screenshot buffer
// Advance tick past the refresh period to ensure render fires
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
gui_update_data();
lv_timer_handler();
gui_screenshot_pending = false;
Serial.write(hdr, 4);
uint16_t w = GUI_W, h = GUI_H;
Serial.write((uint8_t *)&w, 2);
Serial.write((uint8_t *)&h, 2);
Serial.write((uint8_t *)gui_screenshot_buf, GUI_W * GUI_H * 2);
Serial.flush();
} else {
Serial.write(hdr, 4);
uint16_t z = 0;
Serial.write((uint8_t *)&z, 2);
Serial.write((uint8_t *)&z, 2);
Serial.flush();
}
break;
}
case 'T': { // Touch inject
gui_inject_x = gui_cmd_payload[0] | (gui_cmd_payload[1] << 8);
gui_inject_y = gui_cmd_payload[2] | (gui_cmd_payload[3] << 8);
uint32_t dur = gui_cmd_payload[4] * 100; // duration in 100ms units
if (dur == 0) dur = 200;
gui_inject_pressed = true;
gui_inject_until = millis() + dur;
// Unblank display on injected touch
if (display_blanked) display_unblank();
break;
}
case 'N': { // Navigate to tile
uint8_t col = gui_cmd_payload[0];
uint8_t row = gui_cmd_payload[1];
if (gui_tileview) {
// Find tile at position
lv_obj_t *target = NULL;
if (col == 1 && row == 1) target = gui_tile_watch;
else if (col == 1 && row == 0) target = gui_tile_radio;
else if (col == 0 && row == 1) target = gui_tile_gps;
else if (col == 2 && row == 1) target = gui_tile_msg;
else if (col == 1 && row == 2) target = gui_tile_set;
if (target) {
lv_tileview_set_tile(gui_tileview, target, LV_ANIM_ON);
}
}
if (display_blanked) display_unblank();
break;
}
case 'M': { // Metrics
Serial.write(hdr, 4);
char buf[192];
uint32_t avg_flush = gui_frame_count > 0 ? gui_flush_us_total / gui_frame_count : 0;
snprintf(buf, sizeof(buf),
"{\"build\":\"%s %s\",\"loop\":%lu,\"radio\":%lu,"
"\"serial\":%lu,\"disp\":%lu,\"pmu\":%lu,"
"\"gps\":%lu,\"bt\":%lu,\"imu\":%lu,"
"\"bcn_gate\":%d,\"hw_ready\":%d,"
"\"lxmf_id\":%d,\"bcn_crypto\":%d}\n",
__DATE__, __TIME__,
gui_loop_us_last, prof_radio_us, prof_serial_us,
prof_display_us, prof_pmu_us, prof_gps_us,
prof_bt_us, prof_imu_us,
beacon_gate, hw_ready ? 1 : 0,
lxmf_identity_configured ? 1 : 0,
beacon_crypto_configured ? 1 : 0);
gui_loop_us_max = 0;
Serial.write((uint8_t *)buf, strlen(buf));
Serial.flush();
break;
}
case 'P': { // Standardized performance profile test
Serial.write(hdr, 4);
if (display_blanked) display_unblank();
uint32_t p_t0, p_t1;
uint32_t p_idle_render = 0, p_idle_flush = 0;
uint32_t p_full_render = 0, p_full_flush = 0;
uint32_t p_nav_total = 0;
uint32_t p_data_update = 0;
uint32_t p_frames = 0;
uint32_t p_multi_total = 0;
// Test 1: Idle frame (nothing dirty)
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler(); // clear any pending
gui_flush_us_last = 0;
gui_frame_count = 0;
p_t0 = micros();
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler();
p_t1 = micros();
p_idle_render = p_t1 - p_t0;
p_idle_flush = gui_flush_us_last;
// Test 2: Full invalidation + render
lv_obj_invalidate(lv_screen_active());
gui_flush_us_last = 0;
p_t0 = micros();
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler();
p_t1 = micros();
p_full_render = p_t1 - p_t0;
p_full_flush = gui_flush_us_last;
// Test 3: Data update cycle
gui_last_data_update = 0; // force update
p_t0 = micros();
gui_update_data();
p_t1 = micros();
p_data_update = p_t1 - p_t0;
// Test 4: Navigate to each tile and back (5 transitions)
p_t0 = micros();
lv_tileview_set_tile(gui_tileview, gui_tile_radio, LV_ANIM_OFF);
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler();
lv_tileview_set_tile(gui_tileview, gui_tile_gps, LV_ANIM_OFF);
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler();
lv_tileview_set_tile(gui_tileview, gui_tile_msg, LV_ANIM_OFF);
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler();
lv_tileview_set_tile(gui_tileview, gui_tile_set, LV_ANIM_OFF);
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler();
lv_tileview_set_tile(gui_tileview, gui_tile_watch, LV_ANIM_OFF);
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler();
p_t1 = micros();
p_nav_total = p_t1 - p_t0;
// Test 5: Rapid frame burst (10 full frames)
p_t0 = micros();
for (int i = 0; i < 10; i++) {
lv_obj_invalidate(lv_screen_active());
lv_tick_inc(LV_DEF_REFR_PERIOD + 1);
lv_timer_handler();
}
p_t1 = micros();
p_multi_total = p_t1 - p_t0;
Serial.printf("{\"test\":\"profile\",\"build\":\"%s %s\","
"\"idle_us\":%lu,\"idle_flush_us\":%lu,"
"\"full_us\":%lu,\"full_flush_us\":%lu,"
"\"data_update_us\":%lu,"
"\"nav_5tile_us\":%lu,"
"\"burst_10frame_us\":%lu,"
"\"avg_frame_us\":%lu,"
"\"loop_us\":%lu,"
"\"heap\":%lu,\"psram\":%lu}\n",
__DATE__, __TIME__,
p_idle_render, p_idle_flush,
p_full_render, p_full_flush,
p_data_update,
p_nav_total,
p_multi_total, p_multi_total / 10,
gui_loop_us_last,
(uint32_t)esp_get_free_heap_size(),
(uint32_t)heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
Serial.flush();
break;
}
case 'B': { // Beacon packet dump
extern uint8_t diag_beacon_pre[];
extern uint16_t diag_beacon_pre_len;
extern uint8_t diag_beacon_post[];
extern uint16_t diag_beacon_post_len;
Serial.write(hdr, 4);
Serial.printf("{\"pre_len\":%d,\"post_len\":%d", diag_beacon_pre_len, diag_beacon_post_len);
if (diag_beacon_pre_len > 0) {
Serial.printf(",\"pre\":\"");
for (int i = 0; i < diag_beacon_pre_len; i++) Serial.printf("%02x", diag_beacon_pre[i]);
Serial.printf("\"");
}
if (diag_beacon_post_len > 0) {
Serial.printf(",\"post\":\"");
for (int i = 0; i < diag_beacon_post_len; i++) Serial.printf("%02x", diag_beacon_post[i]);
Serial.printf("\"");
}
Serial.println("}");
Serial.flush();
break;
}
case 'C': { // Crypto test — IFAC test vectors
#if HAS_GPS == true
Serial.write(hdr, 4);
// Test vectors from scripts/test_ifac.py
const uint8_t tv_key[64] = {0x3a, 0xc2, 0xe0, 0x12, 0xa0, 0x86, 0x04, 0x3c, 0x67, 0xcc, 0xef, 0x40, 0x6a, 0x0b, 0xdb, 0x38, 0xc0, 0x66, 0xb2, 0xee, 0x0a, 0x7f, 0x18, 0x27, 0xfa, 0x1c, 0xb9, 0xdc, 0xcf, 0xbb, 0x8e, 0x9d, 0x53, 0x48, 0xc5, 0x56, 0xf0, 0x8e, 0xed, 0xf3, 0x0b, 0xce, 0x46, 0x2b, 0xb2, 0x09, 0x6b, 0x99, 0x26, 0x08, 0xf4, 0xfc, 0xfd, 0x12, 0x32, 0x4b, 0xb2, 0x45, 0x86, 0x2b, 0x59, 0xd6, 0x11, 0xc7};
const uint8_t tv_pk[32] = {0x1a, 0x54, 0x5d, 0x78, 0x34, 0xc3, 0xe1, 0x6c, 0x53, 0x9d, 0xd5, 0xf5, 0x3a, 0xd1, 0x5b, 0x67, 0xae, 0x57, 0x5e, 0x97, 0x06, 0x05, 0x38, 0x5b, 0xeb, 0x76, 0xe9, 0x85, 0x2e, 0xf9, 0xe1, 0xdf};
const uint8_t tv_msg[19] = {0x00, 0x00, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99};
const uint8_t tv_sig[64] = {0x99, 0x80, 0x27, 0x05, 0xaf, 0xde, 0xb0, 0xe6, 0xfe, 0xe5, 0x2b, 0xbc, 0x35, 0x4a, 0x87, 0x93, 0xd8, 0xc2, 0x9c, 0x77, 0x41, 0x6c, 0x5c, 0x54, 0x62, 0x7e, 0x66, 0xc6, 0x50, 0x05, 0xe5, 0x0a, 0x02, 0x48, 0x94, 0x4b, 0xb1, 0x02, 0x5b, 0x3a, 0xaa, 0xa2, 0x9b, 0x26, 0xc4, 0x7f, 0x49, 0x4b, 0xa2, 0x1a, 0xf0, 0xb5, 0xd0, 0x08, 0x8f, 0x9b, 0x49, 0x5b, 0xf2, 0xc7, 0xe1, 0x83, 0x99, 0x01};
const uint8_t tv_mask[27] = {0x3b, 0x8f, 0x16, 0xab, 0xe6, 0x0b, 0x8e, 0x35, 0xcb, 0x47, 0x5a, 0x3d, 0x13, 0x00, 0x05, 0xe6, 0x79, 0x79, 0x99, 0x23, 0x35, 0x24, 0x64, 0xd8, 0x4b, 0xf5, 0x3c};
const uint8_t tv_result[27] = {0xbb, 0x8f, 0x49, 0x5b, 0xf2, 0xc7, 0xe1, 0x83, 0x99, 0x01, 0x48, 0x09, 0x45, 0x78, 0x9f, 0x5a, 0xa7, 0x89, 0x88, 0x01, 0x06, 0x60, 0x31, 0xbe, 0x3c, 0x7d, 0xa5};
// Test 1: Keypair derivation
uint8_t pk[32], sk[64];
crypto_sign_ed25519_seed_keypair(pk, sk, tv_key + 32);
bool pk_match = (memcmp(pk, tv_pk, 32) == 0);
// Test 2: Signature
uint8_t sig[64];
unsigned long long sig_len;
crypto_sign_ed25519_detached(sig, &sig_len, tv_msg, 19, sk);
bool sig_match = (memcmp(sig, tv_sig, 64) == 0);
// Test 3: HKDF
uint8_t mask[27];
rns_hkdf_var(sig + 56, 8, tv_key, 64, mask, 27);
bool mask_match = (memcmp(mask, tv_mask, 27) == 0);
// Test 4: Full IFAC apply
uint8_t pkt[64];
memcpy(pkt, tv_msg, 19);
// Save original ifac state and substitute test key
uint8_t saved_key[64]; bool saved_configured;
memcpy(saved_key, ifac_key, 64);
saved_configured = ifac_configured;
memcpy(ifac_key, tv_key, 64);
ifac_derive_keypair();
ifac_configured = true;
uint16_t result_len = ifac_apply(pkt, 19);
bool result_match = (result_len == 27) && (memcmp(pkt, tv_result, 27) == 0);
// Restore
memcpy(ifac_key, saved_key, 64);
if (saved_configured) ifac_derive_keypair();
ifac_configured = saved_configured;
// Report
// Dump the live IFAC key for verification
Serial.printf("{\"pk\":%s,\"sig\":%s,\"hkdf\":%s,\"ifac\":%s,\"configured\":%s",
pk_match ? "true" : "false",
sig_match ? "true" : "false",
mask_match ? "true" : "false",
result_match ? "true" : "false",
ifac_configured ? "true" : "false");
Serial.printf(",\"live_key\":\"");
for (int i = 0; i < 64; i++) Serial.printf("%02x", ifac_key[i]);
Serial.printf("\"");
// Dump actual values on failure for debugging
if (!sig_match) {
Serial.printf(",\"actual_sig\":\"");
for (int i = 0; i < 64; i++) Serial.printf("%02x", sig[i]);
Serial.printf("\"");
}
if (!mask_match) {
Serial.printf(",\"actual_mask\":\"");
for (int i = 0; i < 27; i++) Serial.printf("%02x", mask[i]);
Serial.printf("\"");
}
if (!result_match) {
Serial.printf(",\"actual_result\":\"");
for (int i = 0; i < (int)result_len; i++) Serial.printf("%02x", pkt[i]);
Serial.printf("\",\"result_len\":%d", result_len);
}
Serial.println("}");
Serial.flush();
#else
Serial.write(hdr, 4);
Serial.println("{\"error\":\"no_gps\"}");
Serial.flush();
#endif
break;
}
case 'I': { // Invalidate — force full redraw
if (gui_screen) lv_obj_invalidate(gui_screen);
if (display_blanked) display_unblank();
break;
}
case 'L': { // Toggle IMU logging
Serial.write(hdr, 4);
if (gui_log_toggle_fn) {
gui_imu_logging = gui_log_toggle_fn();
Serial.printf("{\"logging\":%s}\n", gui_imu_logging ? "true" : "false");
} else {
Serial.println("{\"logging\":false,\"error\":\"not_available\"}");
}
Serial.flush();
break;
}
case 'F': { // List files on SD card
Serial.write(hdr, 4);
if (gui_list_files_fn) {
gui_list_files_fn();
} else {
Serial.println("{\"error\":\"no_sd\"}");
}
Serial.flush();
break;
}
case 'D': { // Download file by index
Serial.write(hdr, 4);
if (gui_download_file_fn) {
gui_download_file_fn(gui_cmd_payload[0]);
} else {
Serial.println("{\"error\":\"no_sd\"}");
}
Serial.flush();
break;
}
case 'X': { // Hard reset
Serial.write(hdr, 4);
Serial.println("{\"reset\":true}");
Serial.flush();
delay(100);
ESP.restart();
break;
}
case 'Z': { // Reboot into download mode (no BOOT+RST needed)
Serial.write(hdr, 4);
Serial.println("{\"bootloader\":true}");
Serial.flush();
delay(100);
#if MCU_VARIANT == MCU_ESP32
REG_WRITE(RTC_CNTL_OPTION1_REG, RTC_CNTL_FORCE_DOWNLOAD_BOOT);
ESP.restart();
#endif
break;
}
}
}
void gui_screenshot_info() {
if (gui_screenshot_buf) {
Serial.printf("[screenshot] addr=%p size=%u w=%d h=%d\n",
gui_screenshot_buf, GUI_W * GUI_H * 2, GUI_W, GUI_H);
} else {
Serial.println("[screenshot] buffer not allocated");
}
}
// Write screenshot to SD card as raw RGB565 + BMP header
#if HAS_SD
#include <SD.h>
#include "SharedSPI.h"
bool gui_screenshot_sd(const char *path = "/screenshot.bmp") {
if (!gui_screenshot_buf) return false;
// Acquire shared SPI mutex for SD access
if (shared_spi_mutex) xSemaphoreTake(shared_spi_mutex, portMAX_DELAY);
SPI.begin(SD_CLK, SD_MISO, SD_MOSI, SD_CS);
if (!SD.begin(SD_CS, SPI, 4000000, "/sd", 5)) {
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
Serial.println("[screenshot] SD init failed");
return false;
}
File f = SD.open(path, FILE_WRITE);
if (!f) {
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
Serial.println("[screenshot] file open failed");
return false;
}
// Write BMP header (RGB565 LE, top-down)
uint32_t img_size = GUI_W * GUI_H * 2;
uint32_t file_size = 14 + 40 + 12 + img_size; // file + info + masks + pixels
uint32_t data_offset = 14 + 40 + 12;
// BMP file header (14 bytes)
uint8_t bmp_hdr[14] = {'B', 'M'};
memcpy(&bmp_hdr[2], &file_size, 4);
memset(&bmp_hdr[6], 0, 4); // reserved
memcpy(&bmp_hdr[10], &data_offset, 4);
f.write(bmp_hdr, 14);
// DIB header (BITMAPINFOHEADER, 40 bytes)
uint8_t dib[40] = {};
uint32_t dib_size = 40;
int32_t bmp_w = GUI_W;
int32_t bmp_h = -GUI_H; // negative = top-down
uint16_t planes = 1;
uint16_t bpp = 16;
uint32_t compression = 3; // BI_BITFIELDS
memcpy(&dib[0], &dib_size, 4);
memcpy(&dib[4], &bmp_w, 4);
memcpy(&dib[8], &bmp_h, 4);
memcpy(&dib[12], &planes, 2);
memcpy(&dib[14], &bpp, 2);
memcpy(&dib[16], &compression, 4);
memcpy(&dib[20], &img_size, 4);
f.write(dib, 40);
// RGB565 bitmasks (R, G, B)
uint32_t mask_r = 0xF800, mask_g = 0x07E0, mask_b = 0x001F;
f.write((uint8_t *)&mask_r, 4);
f.write((uint8_t *)&mask_g, 4);
f.write((uint8_t *)&mask_b, 4);
// Pixel data (RGB565 LE, row by row)
// BMP rows must be 4-byte aligned; 410*2=820 bytes per row, 820%4=0, no padding needed
f.write((uint8_t *)gui_screenshot_buf, img_size);
f.close();
if (shared_spi_mutex) xSemaphoreGive(shared_spi_mutex);
Serial.printf("[screenshot] saved %s (%u bytes)\n", path, file_size);
return true;
}
#endif
// ---------------------------------------------------------------------------
// Main GUI update — called from update_display()
// ---------------------------------------------------------------------------
void gui_update() {
static uint32_t last_tick = 0;
uint32_t now = millis();
lv_tick_inc(now - last_tick);
last_tick = now;
// Measure loop interval
uint32_t now_us = micros();
if (gui_last_update_us > 0) {
gui_loop_us_last = now_us - gui_last_update_us;
if (gui_loop_us_last > gui_loop_us_max) gui_loop_us_max = gui_loop_us_last;
}
gui_last_update_us = now_us;
gui_update_data();
// Skip LVGL rendering when display is blanked — no SPI traffic to sleeping display
if (!display_blanked) {
gui_render_start = micros();
lv_timer_handler();
gui_render_us_last = micros() - gui_render_start;
}
// After lv_timer_handler, a new DMA may be queued via gui_flush_cb.
// It runs in background on SPI3 until the next gui_update() call.
}
#endif // BOARD_MODEL == BOARD_TWATCH_ULT
#endif // GUI_H