tiny ui v1

This commit is contained in:
liquidraver
2026-07-07 23:14:22 +02:00
parent 4bcefbcda7
commit d081535a80
6 changed files with 254 additions and 11 deletions
@@ -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.
@@ -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)
@@ -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;
+190 -2
View File
@@ -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<S> 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) {
+8
View File
@@ -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.
*/
+8
View File
@@ -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
}