From d081535a80b2faf18b4e663e17844d1154b35d46 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:14:22 +0200 Subject: [PATCH] tiny ui v1 --- .../nrf52840/lilygo_timpulse_plus/README.md | 27 ++- .../nrf52840/lilygo_timpulse_plus/board.conf | 2 +- .../lilygo_timpulse_plus.dts | 28 ++- zephcore/helpers/ui-button/ui_pages.c | 192 +++++++++++++++++- zephcore/helpers/ui-button/ui_pages.h | 8 + zephcore/helpers/ui-button/ui_task.c | 8 + 6 files changed, 254 insertions(+), 11 deletions(-) diff --git a/zephcore/boards/nrf52840/lilygo_timpulse_plus/README.md b/zephcore/boards/nrf52840/lilygo_timpulse_plus/README.md index fd6ea4e..1e1f544 100644 --- a/zephcore/boards/nrf52840/lilygo_timpulse_plus/README.md +++ b/zephcore/boards/nrf52840/lilygo_timpulse_plus/README.md @@ -32,7 +32,7 @@ Bootloader is Adafruit UF2 / SoftDevice **s140 v6.1.1**, app @ `0x26000` |-----------------|--------------------|-----------------------------------------------| | MCU | nRF52840 | BLE5, native USB, 1 MB flash / 256 KB RAM | | LoRa | SX1262 (AcSip S62F)| 22 dBm, embedded 32 MHz TCXO on DIO3 | -| Display | SSD1315 128x64 OLED| I2C0 @ 0x3C (SSD1306-compatible, no reset) | +| Display | SSD1315 64x32 OLED | I2C0 @ 0x3C (64x32 glass at RAM col 32/row 32) | | GNSS | u-blox MIA-M10Q | UART0 @ 38400, enable P1.10 | | Ext flash | ZD25WQ32C 32 Mbit | QSPI -> `/ext` LittleFS (contacts/channels) | | IMU | ICM-20948 | 2nd I2C bus @ 0x69 — present, not used | @@ -90,3 +90,28 @@ The QSPI flash needs no verification: the ZD25WQ32C JEDEC id (`ba 60 16`) and deep-power-down timings are datasheet-confirmed, and `/ext` reuses the same `nordic,qspi-nor` + LittleFS automount path as the older MX25R boards — a blank chip auto-formats on first mount, no formatter step required. + +## Display / UI status (known incomplete) + +The panel is a **64x32** glass mapped into the SSD1315's 128x64 RAM at column +32 / row 32 (LilyGo `Display.ino` inits `Adafruit_SSD1306(128,64)` then +`setOffsetCursor(32,32)`). The devicetree now expresses this — `width=64`, +`height=32`, `segment-offset=32`, `page-offset=4` — but two things remain: + +1. **Offsets are inferred, not bench-verified.** If the visible window is + misplaced, try the alternate `multiplex-ratio=31` + `display-offset=32` + + `page-offset=0`. +2. **Tiny UI (implemented, needs bench polish).** `helpers/ui-button/ui_pages.c` + now has a runtime "tiny mode" (auto-enabled when the panel is <48 px tall): + it drops the top bar + page-dots + separator, uses the full height for + content with tightened line spacing, and flashes the page title for ~1 s on + each change (there is no persistent bar — matches the "no bar, full content" + choice). Compact layouts are provided for Messages, Radio, GPS and Status; + the remaining pages (Recent, Traffic, Sensors, toggles) render through the + normal path at full height and may still clip horizontally until they get + their own compact branch. 128x64 boards are unaffected. Pixel positioning + was written from the datasheet, not a bench — expect to nudge it on hardware. + +Separately, early hardware testing showed **occasional spontaneous reboots** — +untriaged, likely power/brownout on TX or a fault loop; capture an RTT/USB +console log from a debug build to get a backtrace. diff --git a/zephcore/boards/nrf52840/lilygo_timpulse_plus/board.conf b/zephcore/boards/nrf52840/lilygo_timpulse_plus/board.conf index 8ba74d4..344023d 100644 --- a/zephcore/boards/nrf52840/lilygo_timpulse_plus/board.conf +++ b/zephcore/boards/nrf52840/lilygo_timpulse_plus/board.conf @@ -4,7 +4,7 @@ # Hardware: # - nRF52840 SoC, SX1262 LoRa (22 dBm, embedded 32 MHz TCXO via DIO3) # - GPIO antenna switch: VC1=TX (P1.13), VC2=RX (P1.07) -# - SSD1315 128x64 OLED @ I2C 0x3C (SSD1306-compatible, no reset pin) +# - SSD1315 64x32 OLED @ I2C 0x3C (SSD1306-compatible, 64x32 glass at RAM 32,32) # - u-blox MIA-M10Q GPS on UART0 @ 38400, power enable P1.10 # - ZD25WQ32C 32Mbit (4MB) QSPI flash -> /ext # - Battery ADC on AIN3 (P0.05), divider enable P0.25 (drive HIGH to read) diff --git a/zephcore/boards/nrf52840/lilygo_timpulse_plus/lilygo_timpulse_plus.dts b/zephcore/boards/nrf52840/lilygo_timpulse_plus/lilygo_timpulse_plus.dts index 926c2aa..1c71140 100644 --- a/zephcore/boards/nrf52840/lilygo_timpulse_plus/lilygo_timpulse_plus.dts +++ b/zephcore/boards/nrf52840/lilygo_timpulse_plus/lilygo_timpulse_plus.dts @@ -236,10 +236,24 @@ }; }; -/* ---- I2C0: SSD1315 OLED ---- +/* ---- I2C0: SSD1315 OLED (64x32 glass) ---- * SSD1315 is SSD1306-compatible; the solomon,ssd1306 driver drives it. No - * hardware reset line on this board (SCREEN_RST = -1). Standard 0.96" 128x64 - * orientation — if the panel is mounted rotated, flip segment-remap/com-invdir. */ + * hardware reset line (SCREEN_RST = -1). + * + * The physical glass is only 64x32, mapped into the controller's 128x64 RAM + * at column 32 / row 32 — LilyGo's Display.ino inits Adafruit_SSD1306(128,64) + * then calls setOffsetCursor(32,32), i.e. content is drawn into the visible + * window at RAM (32,32). We express that as a 64x32 panel whose origin is + * segment 32 / page 4 (row 32 = page 4), while keeping the vendor's 128x64 + * COM scan: multiplex-ratio 63, display-offset 0, alternative COM pins + * (NOT com-sequential). The offsets are inferred from the vendor example and + * may need bench trimming; if the window is misplaced, the usual alternate is + * multiplex-ratio 31 + display-offset 32 + page-offset 0. + * + * NOTE: this only fixes the panel geometry. ZephCore's UI is laid out for + * 128x64 — a 64x32 panel needs a simplified "tiny" UI (not yet in ZephCore; + * upstream MeshCore has a ui-tiny reference). Until then content will overflow + * the small window even though it is now positioned correctly. */ &i2c0 { compatible = "nordic,nrf-twim"; status = "okay"; @@ -251,10 +265,10 @@ oled: ssd1306@3c { compatible = "solomon,ssd1306"; reg = <0x3c>; - width = <128>; - height = <64>; - segment-offset = <0>; - page-offset = <0>; + width = <64>; + height = <32>; + segment-offset = <32>; + page-offset = <4>; display-offset = <0>; multiplex-ratio = <63>; segment-remap; diff --git a/zephcore/helpers/ui-button/ui_pages.c b/zephcore/helpers/ui-button/ui_pages.c index 8c60d4a..d80a5f9 100644 --- a/zephcore/helpers/ui-button/ui_pages.c +++ b/zephcore/helpers/ui-button/ui_pages.c @@ -49,13 +49,36 @@ LOG_MODULE_REGISTER(ui_pages, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); #define FONT_H mc_display_font_height() #define DISP_W mc_display_width() #define DISP_H mc_display_height() -#define LINE_H (FONT_H + 2) /* font height + 2px gap */ #define TOP_BAR_Y 0 #define DOTS_Y (FONT_H + 2) /* just below top bar */ #define SEP_Y (DOTS_Y + 4) /* below dots */ -#define CONTENT_Y (SEP_Y + 4) /* below separator */ #define MAX_CHARS (DISP_W / (FONT_W ? FONT_W : 1)) +/* ---- Tiny-panel mode (e.g. 64x32 wristband OLED) ---- + * When the panel is too short for the top bar + page dots + separator, drop + * that chrome and hand the whole panel to page content, tightening the line + * spacing so four rows of the 6x8 font fit in 32px. Auto-detected from the + * panel height, so 128x64 boards are unaffected. There is no persistent title + * bar on tiny panels — the page name is flashed briefly on a page change (see + * tiny_flash_kick / render_tiny_title). */ +static inline bool ui_tiny(void) +{ + return DISP_H < 48; +} + +static inline int ui_line_h(void) +{ + return ui_tiny() ? FONT_H : (FONT_H + 2); /* drop the inter-line gap */ +} + +static inline int ui_content_y(void) +{ + return ui_tiny() ? 0 : (SEP_Y + 4); /* full height when tiny */ +} + +#define LINE_H (ui_line_h()) +#define CONTENT_Y (ui_content_y()) + /* Compact RGB565 palette for small color TFTs. These are used only through * mc_display_has_color(); monochrome displays keep the existing CFB path. */ #define UI_COLOR_BG MC_COLOR_BLACK @@ -96,6 +119,18 @@ static inline int centered_row(int idx, int total) /* ========== Global State ========== */ static struct ui_state state; +/* Tiny-mode page-title flash: after a page change we show the page name for + * TINY_FLASH_MS, then re-render into content (there is no persistent bar on + * tiny panels). The follow-up render is scheduled by the UI task via + * ui_pages_flash_remaining_ms(). */ +#define TINY_FLASH_MS 1000U +static uint32_t tiny_flash_until; + +static void tiny_flash_kick(void) +{ + tiny_flash_until = k_uptime_get_32() + TINY_FLASH_MS; +} + static uint8_t activity_rx[ACTIVITY_GRAPH_SAMPLES]; static uint8_t activity_tx[ACTIVITY_GRAPH_SAMPLES]; static uint8_t activity_head; @@ -171,6 +206,10 @@ static char time_source_tag(enum time_sync_source src) static void render_top_bar(void) { + if (ui_tiny()) { + return; /* no room for a bar; page title is flashed on change */ + } + bool color = mc_display_has_color(); /* Right side: "HH:MM XX%" — clock (with 1-char source tag) then * battery, right-aligned. Built first so the node name can be clipped @@ -252,6 +291,10 @@ static void render_top_bar(void) static void render_page_indicator(void) { + if (ui_tiny()) { + return; /* no room for dots/separator on a tiny panel */ + } + /* Centered dots matching Arduino layout: * Each dot spaced 10px apart, centered on screen. * Current page = filled 3x3 block, others = single pixel. */ @@ -489,11 +532,65 @@ static void draw_activity_graph(int x, int y, int w, int h) /* ========== Page Renderers ========== */ +/* ========== Tiny-panel page title (flashed on page change) ========== */ + +static const char *tiny_page_title(enum ui_page p) +{ + switch (p) { + case UI_PAGE_MESSAGES: return "MESSAGES"; + case UI_PAGE_RECENT: return "RECENT"; + case UI_PAGE_RADIO: return "RADIO"; + case UI_PAGE_TRAFFIC: return "TRAFFIC"; + case UI_PAGE_BLUETOOTH: return "BLUETOOTH"; + case UI_PAGE_ADVERT: return "ADVERT"; + case UI_PAGE_GPS: return "GPS"; + case UI_PAGE_BUZZER: return "BUZZER"; + case UI_PAGE_LEDS: return "LEDS"; + case UI_PAGE_SENSORS: return "SENSORS"; + case UI_PAGE_OFFGRID: return "OFFGRID"; + case UI_PAGE_DFU: return "DFU"; + case UI_PAGE_SHUTDOWN: return "SHUTDOWN"; + case UI_PAGE_STATUS: return "STATUS"; + default: return ""; + } +} + +static void render_tiny_title(void) +{ + const char *title = tiny_page_title(active_pages[current_page_idx]); + int rows = (state.battery_mv > 0) ? 2 : 1; + + draw_centered(centered_row(0, rows), title); + + if (state.battery_mv > 0) { + char buf[8]; + uint8_t pct = state.battery_pct ? state.battery_pct + : calc_battery_pct(state.battery_mv); + + snprintf(buf, sizeof(buf), "%u%%", pct); + draw_centered(centered_row(1, rows), buf); + } +} + static void render_messages(void) { /* 3 centered rows: msg count, BLE status, offgrid status */ char buf[24]; + if (ui_tiny()) { + snprintf(buf, sizeof(buf), "MSG:%u", state.msg_count); + draw_centered(centered_row(0, 3), buf); + draw_centered(centered_row(1, 3), + state.ble_connected ? "BLE:conn" : "BLE:adv"); + + uint32_t up_s = (uint32_t)(k_uptime_get() / 1000); + + snprintf(buf, sizeof(buf), "%uh%02um", + (up_s % 86400) / 3600, (up_s % 3600) / 60); + draw_centered(centered_row(2, 3), buf); + return; + } + if (use_compact_color_home()) { int y = CONTENT_Y; uint16_t ble_color = state.ble_connected ? UI_COLOR_OK : UI_COLOR_WARN; @@ -659,6 +756,19 @@ static void render_radio(void) uint16_t warn_color = (state.lora_apc_enabled && state.lora_apc_reduction > 0) ? UI_COLOR_WARN : UI_COLOR_OK; + if (ui_tiny()) { + int tx = state.lora_effective_tx_power > 0 + ? state.lora_effective_tx_power : state.lora_tx_power; + + snprintf(buf, sizeof(buf), "%u.%uM", freq_mhz, freq_frac / 100); + draw_centered(centered_row(0, 3), buf); + snprintf(buf, sizeof(buf), "SF%u BW%u", state.lora_sf, bw_int); + draw_centered(centered_row(1, 3), buf); + snprintf(buf, sizeof(buf), "P%d %s", tx, rx_mode); + draw_centered(centered_row(2, 3), buf); + return; + } + if (mc_display_has_color()) { uint16_t state_color = state.lora_tx_active ? UI_COLOR_WARN : state.lora_in_rx ? UI_COLOR_ACTIVE : @@ -927,6 +1037,30 @@ static void render_gps(void) int y = CONTENT_Y; bool color = mc_display_has_color(); + if (ui_tiny()) { + if (!state.gps_available) { + draw_centered(centered_row(0, 1), "No GPS"); + } else if (!state.gps_enabled) { + draw_centered(centered_row(0, 1), "GPS off"); + } else if (state.gps_state == 2) { + snprintf(buf, sizeof(buf), "Sats:%u", state.gps_satellites); + draw_centered(centered_row(0, 2), "Searching"); + draw_centered(centered_row(1, 2), buf); + } else if (state.gps_state == 1) { + draw_centered(centered_row(0, 2), "Standby"); + if (state.gps_last_fix_age_s != UINT32_MAX) { + char tb[12]; + + fmt_duration(tb, sizeof(tb), state.gps_last_fix_age_s); + snprintf(buf, sizeof(buf), "fix %s", tb); + draw_centered(centered_row(1, 2), buf); + } + } else { + draw_centered(centered_row(0, 1), "GPS off"); + } + return; + } + /* No GPS hardware on this board */ if (!state.gps_available) { if (color) { @@ -1261,6 +1395,33 @@ static void render_status(void) int y = CONTENT_Y; bool color = mc_display_has_color(); + if (ui_tiny()) { + uint32_t up_s = (uint32_t)(k_uptime_get() / 1000); + + snprintf(buf, sizeof(buf), "Up %ud%02uh", + up_s / 86400, (up_s % 86400) / 3600); + draw_centered(centered_row(0, 3), buf); + + if (state.rtc_epoch > 1735689600) { + uint32_t ds = state.rtc_epoch % 86400; + + snprintf(buf, sizeof(buf), "%02u:%02u UTC", + (unsigned)(ds / 3600), (unsigned)((ds % 3600) / 60)); + } else { + snprintf(buf, sizeof(buf), "no time"); + } + draw_centered(centered_row(1, 3), buf); + + if (state.battery_mv > 0) { + uint8_t pct = state.battery_pct ? state.battery_pct + : calc_battery_pct(state.battery_mv); + + snprintf(buf, sizeof(buf), "Batt %u%%", pct); + draw_centered(centered_row(2, 3), buf); + } + return; + } + /* Role label */ if (color) { draw_badge(0, y, "MODE", UI_COLOR_ACTIVE); @@ -1364,6 +1525,16 @@ void ui_pages_render(void) ui_refresh_battery(); mc_display_clear(); + + /* Tiny panels flash the page title briefly after a change, then fall + * through to content on the follow-up render (scheduled by the UI task + * via ui_pages_flash_remaining_ms()). */ + if (ui_tiny() && k_uptime_get_32() < tiny_flash_until) { + render_tiny_title(); + mc_display_finalize(); + return; + } + render_top_bar(); render_page_indicator(); @@ -1376,6 +1547,20 @@ void ui_pages_render(void) mc_display_finalize(); } +/* Milliseconds until the tiny-panel title flash expires (0 when not flashing). + * The UI task uses this to schedule the one follow-up render that swaps the + * flashed title for page content. */ +uint32_t ui_pages_flash_remaining_ms(void) +{ + if (!ui_tiny()) { + return 0; + } + + uint32_t now = k_uptime_get_32(); + + return (now < tiny_flash_until) ? (tiny_flash_until - now) : 0; +} + void ui_pages_render_splash(void) { mc_display_clear(); @@ -1404,6 +1589,7 @@ void ui_pages_render_splash(void) void ui_pages_next(void) { state.shutdown_confirm_time = 0; /* Reset confirmation on navigate */ + tiny_flash_kick(); current_page_idx++; if (current_page_idx >= ACTIVE_PAGE_COUNT) { current_page_idx = 0; @@ -1414,6 +1600,7 @@ void ui_pages_next(void) void ui_pages_prev(void) { state.shutdown_confirm_time = 0; /* Reset confirmation on navigate */ + tiny_flash_kick(); current_page_idx--; if (current_page_idx < 0) { current_page_idx = ACTIVE_PAGE_COUNT - 1; @@ -1423,6 +1610,7 @@ void ui_pages_prev(void) void ui_pages_set(enum ui_page page) { + tiny_flash_kick(); /* Find page in active list, fall back to first active page */ for (int i = 0; i < ACTIVE_PAGE_COUNT; i++) { if (active_pages[i] == page) { diff --git a/zephcore/helpers/ui-button/ui_pages.h b/zephcore/helpers/ui-button/ui_pages.h index a161f48..8b72459 100644 --- a/zephcore/helpers/ui-button/ui_pages.h +++ b/zephcore/helpers/ui-button/ui_pages.h @@ -138,6 +138,14 @@ struct ui_state *ui_pages_get_state(void); */ void ui_pages_render(void); +/** + * Milliseconds until the tiny-panel page-title flash expires, or 0 when not + * flashing (and always 0 on normal-sized panels). The UI task uses this to + * schedule the single follow-up render that replaces the flashed title with + * page content. + */ +uint32_t ui_pages_flash_remaining_ms(void); + /** * Render a splash screen. */ diff --git a/zephcore/helpers/ui-button/ui_task.c b/zephcore/helpers/ui-button/ui_task.c index 8ee950e..2451891 100644 --- a/zephcore/helpers/ui-button/ui_task.c +++ b/zephcore/helpers/ui-button/ui_task.c @@ -162,6 +162,14 @@ static void render_work_handler(struct k_work *work) * OLED: only render when display is on (screen is black when off). */ if ((mc_display_is_on() || mc_display_is_epd()) && !splash_active) { ui_pages_render(); + + /* Tiny panels flash the page title on a change, then need one + * more render to swap it for content once the flash expires. */ + uint32_t flash_ms = ui_pages_flash_remaining_ms(); + + if (flash_ms > 0) { + k_work_reschedule(&render_work, K_MSEC(flash_ms + 20)); + } } #endif }