/* * ZephCore - UI Page Renderers * Copyright (c) 2025 ZephCore * SPDX-License-Identifier: MIT * * Multi-page UI system matching Arduino's ui-new HomeScreen implementation. * Each page renders via the display abstraction (any resolution/display type). * * Layout is resolution-independent — all positions derived from actual * display dimensions and font size queried at runtime: * Top bar: Node name left, battery right * Dots row: Page indicator dots (centered) * Separator: Horizontal line * Content: Page-specific rendering */ #include "ui_pages.h" #include "ui_task.h" #include "display.h" #include #include #include #include #include #include #include #include LOG_MODULE_REGISTER(ui_pages, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); /* ZephCore logo bitmap is defined in helpers/ui/ui_common.c and shared with * the joystick UI's splash. See display.h for declaration. */ /* ========== Layout ========== */ /* All layout is derived from the actual display and font dimensions * queried at runtime, so the UI adapts to any resolution/font size. * * Naming convention: UPPER_CASE macros call display getters (cheap — * they just return a cached static). This keeps page renderers * readable while being resolution-independent. * * Layout (top to bottom): * Row 0..FONT_H-1: Top bar (node name left, battery right) * DOTS_Y..SEP_Y: Page indicator dots + separator line * CONTENT_Y..end: Page-specific content */ /* Full-scale end of the radio page's TX power bar. The board's configured * ceiling when it has one (same symbol CommonCLI clamps "set tx" against), * else the common SX126x/LR11xx maximum. */ #ifdef CONFIG_ZEPHCORE_MAX_TX_POWER_DBM #define TX_POWER_BAR_MAX_DBM CONFIG_ZEPHCORE_MAX_TX_POWER_DBM #else #define TX_POWER_BAR_MAX_DBM 22 #endif #define FONT_W mc_display_font_width() #define FONT_H mc_display_font_height() #define DISP_W mc_display_width() #define DISP_H mc_display_height() #define TOP_BAR_Y 0 #define DOTS_Y (FONT_H + 2) /* just below top bar */ #define SEP_Y (DOTS_Y + 4) /* below dots */ #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 #define UI_COLOR_HEADER_BG 0x2104 /* dark neutral panel */ #define UI_COLOR_TITLE MC_COLOR_LIGHT_GRAY /* soft white, slightly recessed vs values */ #define UI_COLOR_LABEL MC_COLOR_WHITE /* small-TFT gamma renders mid-gray near-black */ #define UI_COLOR_VALUE MC_COLOR_WHITE #define UI_COLOR_OK MC_COLOR_GREEN #define UI_COLOR_ACTIVE UI_COLOR_OK #define UI_COLOR_WARN 0xffa0 #define UI_COLOR_ERROR MC_COLOR_RED #define UI_COLOR_DIM MC_COLOR_LIGHT_GRAY /* small-TFT gamma (T114) crushes anything * darker toward black — in person this reads * as a proper gray, not white (PR #62) */ #define UI_COLOR_DISABLED UI_COLOR_DIM /* off/disabled states dimmer than values */ #define UI_COLOR_FAINT 0x4208 /* dark structural fills (badge bg, separators, bar tracks) — * decoration, must stay subtle so content pops against it */ #define UI_COLOR_TX MC_COLOR_ORANGE #define UI_COLOR_RX UI_COLOR_OK /* Glyph cell of the color renderer (display.c upscales the 6x8 font under * LARGE_FONT) — NOT the CFB font metrics, which can differ (10x16 vs 9x12). */ #define COLOR_FONT_W mc_display_color_font_width() #define COLOR_FONT_H mc_display_color_font_height() #define ACTIVITY_GRAPH_SAMPLES 16 /* Vertically center `total` rows of text within the content area and * return the y pixel position for row `idx` (0-based). This replaces * the old hand-picked offsets (CONTENT_Y+16, +28, +34, ...) that were * calibrated for the 6x8 font and broke when switching to 10x16. * Works for any font size / display resolution. */ static inline int centered_row(int idx, int total) { int content_h = (int)DISP_H - CONTENT_Y; int used_h = total * LINE_H; int top = (content_h - used_h) / 2; if (top < 0) { top = 0; } return CONTENT_Y + top + idx * LINE_H; } /* ========== 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; } #if MC_DISPLAY_COLOR_PANEL static uint8_t activity_rx[ACTIVITY_GRAPH_SAMPLES]; static uint8_t activity_tx[ACTIVITY_GRAPH_SAMPLES]; static uint8_t activity_head; static bool activity_initialized; static uint32_t activity_last_rx; static uint32_t activity_last_tx; static uint32_t activity_last_sample_ms; #endif /* ========== Active Pages (role-dependent) ========== */ /* Repeater: minimal pages — status, radio, shutdown. * Companion: full page set matching Arduino UI. * Navigation walks this array instead of the raw enum. */ #ifdef ZEPHCORE_REPEATER static const enum ui_page active_pages[] = { UI_PAGE_STATUS, UI_PAGE_RADIO, UI_PAGE_SHUTDOWN, }; #else static const enum ui_page active_pages[] = { UI_PAGE_MESSAGES, UI_PAGE_RECENT, UI_PAGE_RADIO, UI_PAGE_TRAFFIC, UI_PAGE_BLUETOOTH, UI_PAGE_ADVERT, UI_PAGE_GPS, #ifdef CONFIG_ZEPHCORE_UI_BUZZER UI_PAGE_BUZZER, #endif UI_PAGE_LEDS, UI_PAGE_SENSORS, UI_PAGE_OFFGRID, UI_PAGE_DFU, UI_PAGE_SHUTDOWN, }; #endif #define ACTIVE_PAGE_COUNT ((int)(sizeof(active_pages) / sizeof(active_pages[0]))) /* Current index into active_pages[] */ static int current_page_idx; /* ========== Helper: Battery Percentage from mV ========== */ static uint8_t calc_battery_pct(uint16_t mv) { if (mv >= 4200) { return 100; } if (mv <= 3000) { return 0; } return (uint8_t)((mv - 3000) * 100 / 1200); } /* ========== Helper: local wall clock ========== * * state.rtc_epoch is ALWAYS UTC. The timezone offset is applied here, at * format time, and nowhere else: shifting the clock itself would read as a * backward jump to every timestamp consumer on the node (advert timestamps, * the repeater ACL's monotonic gate, MeshTimeSync), and a backward clock is a * silent mesh-wide mute. Never write these values back into state. */ static uint32_t local_epoch(void) { return state.rtc_epoch + (int32_t)state.tz_offset * 3600; } /* The zone the displayed digits are actually in: "UTC", "UTC+2", "UTC-11". * Static buffer -- every caller is on the UI thread inside ui_pages_render(). */ static const char *tz_label(void) { static char label[8]; if (state.tz_offset == 0) { return "UTC"; } snprintf(label, sizeof(label), "UTC%+d", (int)state.tz_offset); return label; } /* ========== Helper: Top Bar (Node Name + Battery) ========== */ /* One-char tag for where the displayed clock was last freshly synced from * (see time_sync_get_source() for the freshness window). Always returns a * letter — falls back to 'L' (local) when no recent external sync. */ static char time_source_tag(enum time_sync_source src) { switch (src) { case TIME_SYNC_GPS: return 'G'; /* GPS fix */ case TIME_SYNC_APP: return 'A'; /* phone/companion app */ case TIME_SYNC_WIFI: return 'N'; /* network (SNTP) */ case TIME_SYNC_MESH: return 'M'; /* mesh time-sync consensus */ default: return 'L'; /* local: manual/CLI or stale/none */ } } 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 * to the space that remains on the left, preventing overlap. */ char right[16] = ""; int pos = 0; /* 24h clock from RTC epoch (only if time has been synced). * Before sync, getCurrentTime() returns bare uptime (~seconds), * so check for a sane epoch (after Jan 1 2025 = 1735689600). */ if (state.rtc_epoch > 1735689600) { uint32_t day_sec = local_epoch() % 86400; uint8_t hh = day_sec / 3600; uint8_t mm = (day_sec % 3600) / 60; /* Source tag: G=GPS, A=app, N=network, L=local (always set). */ char src = time_source_tag(time_sync_get_source()); pos = snprintf(right, sizeof(right), "%02u:%02u%c ", hh, mm, src); } /* Battery percentage */ if (state.battery_mv > 0) { uint8_t pct = state.battery_pct; if (pct == 0) { pct = calc_battery_pct(state.battery_mv); } snprintf(right + pos, sizeof(right) - pos, "%u%%", pct); } int right_x = DISP_W; if (right[0]) { int fw = color ? COLOR_FONT_W : FONT_W; uint16_t batt_color = UI_COLOR_VALUE; if (state.battery_mv > 0) { uint8_t pct = state.battery_pct; if (pct == 0) { pct = calc_battery_pct(state.battery_mv); } batt_color = (pct <= 15) ? UI_COLOR_ERROR : (pct <= 30) ? UI_COLOR_WARN : UI_COLOR_OK; } right_x = DISP_W - ((int)strlen(right) * fw); if (color) { mc_display_color_text(right_x, TOP_BAR_Y, right, batt_color); } else { mc_display_text(right_x, TOP_BAR_Y, right, false); } } /* Node name on the left, clipped to the gap before the right block * (one char-width of padding so it never touches the clock). */ if (state.node_name[0]) { char name[16]; int fw = color ? COLOR_FONT_W : FONT_W; int max_chars = (right_x - fw) / fw; if (max_chars > (int)sizeof(name) - 1) { max_chars = sizeof(name) - 1; } if (max_chars < 0) { max_chars = 0; } strncpy(name, state.node_name, max_chars); name[max_chars] = '\0'; if (color) { mc_display_color_text(0, TOP_BAR_Y, name, UI_COLOR_TITLE); } else { mc_display_text(0, TOP_BAR_Y, name, false); } } } /* ========== Helper: Page Indicator Dots ========== */ 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. */ bool color = mc_display_has_color(); int total = ACTIVE_PAGE_COUNT; int dot_spacing = 10; int total_width = (total - 1) * dot_spacing; int start_x = (DISP_W - total_width) / 2; for (int i = 0; i < total; i++) { int x = start_x + i * dot_spacing; if (i == current_page_idx) { /* Current page: filled 3x3 block */ if (color) { mc_display_color_fill_rect(x - 2, DOTS_Y - 1, 5, 5, UI_COLOR_ACTIVE); } else { mc_display_fill_rect(x - 1, DOTS_Y, 3, 3); } } else { /* Other pages: single pixel */ if (color) { mc_display_color_fill_rect(x, DOTS_Y + 1, 1, 1, UI_COLOR_DIM); } else { mc_display_fill_rect(x, DOTS_Y + 1, 1, 1); } } } /* Separator line below dots */ if (color) { mc_display_color_fill_rect(0, SEP_Y, DISP_W, 1, UI_COLOR_FAINT); } else { mc_display_hline(0, SEP_Y, DISP_W); } } /* ========== Helper: Center text ========== */ static void draw_centered(int y, const char *text) { int len = (int)strlen(text); int x = (DISP_W - (len * FONT_W)) / 2; if (x < 0) { x = 0; } mc_display_text(x, y, text, false); } static void draw_centered_color(int y, const char *text, uint16_t color) { int len = (int)strlen(text); int x = (DISP_W - (len * COLOR_FONT_W)) / 2; if (x < 0) { x = 0; } mc_display_color_text(x, y, text, color); } static void draw_color_segments_at(int x, int y, const char *label, const char *value, uint16_t value_color) { mc_display_color_text(x, y, label, UI_COLOR_LABEL); x += (int)strlen(label) * COLOR_FONT_W; mc_display_color_text(x, y, value, value_color); } static void draw_color_segments(int y, const char *label, const char *value, uint16_t value_color) { draw_color_segments_at(0, y, label, value, value_color); } static int color_text_width(const char *text) { return (int)strlen(text) * COLOR_FONT_W; } static void draw_badge(int x, int y, const char *text, uint16_t color) { int w = color_text_width(text) + 4; mc_display_color_fill_rect(x, y - 1, w, COLOR_FONT_H + 2, UI_COLOR_FAINT); mc_display_color_text(x + 2, y, text, color); } static void draw_metric_bar(int x, int y, int w, int h, int value, int max_value, uint16_t color) { if (w <= 0 || h <= 0) { return; } if (max_value <= 0) { max_value = 1; } if (value < 0) { value = 0; } if (value > max_value) { value = max_value; } int filled = (w * value) / max_value; mc_display_color_fill_rect(x, y, w, h, UI_COLOR_FAINT); if (filled > 0) { mc_display_color_fill_rect(x, y, filled, h, color); } } static void fmt_compact_count(char *buf, size_t len, uint32_t value) { if (value >= 1000000U) { snprintf(buf, len, "%luM", (unsigned long)(value / 1000000U)); } else if (value >= 1000U) { snprintf(buf, len, "%luk", (unsigned long)(value / 1000U)); } else { snprintf(buf, len, "%lu", (unsigned long)value); } } #if MC_DISPLAY_COLOR_PANEL static bool use_compact_color_home(void) { return mc_display_has_color() && DISP_W <= 180 && DISP_H <= 100; } #endif /* Short label for the current LoRa radio state, shared across pages. */ static const char *radio_state_label(void) { return state.lora_tx_active ? "TX" : state.lora_in_rx ? "RX" : state.lora_radio_ready ? "RDY" : "WAIT"; } #if MC_DISPLAY_COLOR_PANEL static uint8_t clamp_activity_delta(uint32_t delta) { return (delta > 15U) ? 15U : (uint8_t)delta; } static void sample_activity_graph(void) { uint32_t now = k_uptime_get_32(); uint32_t rx = state.lora_packets_rx; uint32_t tx = state.lora_packets_tx; uint32_t rx_delta = 0; uint32_t tx_delta = 0; if (!activity_initialized) { activity_last_rx = rx; activity_last_tx = tx; activity_last_sample_ms = now; activity_initialized = true; return; } if (rx >= activity_last_rx) { rx_delta = rx - activity_last_rx; } if (tx >= activity_last_tx) { tx_delta = tx - activity_last_tx; } /* Age the graph slowly during quiet periods, but do not shift it on * every incidental repaint. Counter wrap/reset simply records zero. */ if (rx_delta == 0 && tx_delta == 0 && (now - activity_last_sample_ms) < 5000U) { return; } activity_last_rx = rx; activity_last_tx = tx; activity_last_sample_ms = now; activity_head = (activity_head + 1U) % ACTIVITY_GRAPH_SAMPLES; activity_rx[activity_head] = clamp_activity_delta(rx_delta); activity_tx[activity_head] = clamp_activity_delta(tx_delta); } static void draw_activity_graph(int x, int y, int w, int h) { uint8_t max_v = 0; int mid = y + h / 2; int col_w = w / ACTIVITY_GRAPH_SAMPLES; if (col_w < 1) { col_w = 1; } sample_activity_graph(); mc_display_color_fill_rect(x, y, w, h, UI_COLOR_BG); mc_display_color_fill_rect(x, mid, w, 1, UI_COLOR_FAINT); for (int i = 0; i < ACTIVITY_GRAPH_SAMPLES; i++) { uint8_t idx = (uint8_t)((activity_head + 1U + i) % ACTIVITY_GRAPH_SAMPLES); if (activity_rx[idx] > max_v) { max_v = activity_rx[idx]; } if (activity_tx[idx] > max_v) { max_v = activity_tx[idx]; } } if (max_v == 0) { mc_display_color_text(x + w - 30, y + 1, "RX", UI_COLOR_RX); mc_display_color_text(x + w - 14, y + 1, "TX", UI_COLOR_TX); return; } for (int i = 0; i < ACTIVITY_GRAPH_SAMPLES; i++) { uint8_t idx = (uint8_t)((activity_head + 1U + i) % ACTIVITY_GRAPH_SAMPLES); int bx = x + i * col_w; int draw_w = (col_w > 1) ? col_w - 1 : 1; int tx_h = ((h / 2 - 1) * activity_tx[idx]) / max_v; int rx_h = ((h / 2 - 1) * activity_rx[idx]) / max_v; if (tx_h > 0) { mc_display_color_fill_rect(bx, mid - tx_h, draw_w, tx_h, UI_COLOR_TX); } if (rx_h > 0) { mc_display_color_fill_rect(bx, mid + 1, draw_w, rx_h, UI_COLOR_RX); } } mc_display_color_text(x + w - 30, y + 1, "RX", UI_COLOR_RX); mc_display_color_text(x + w - 14, y + 1, "TX", UI_COLOR_TX); } #endif /* MC_DISPLAY_COLOR_PANEL */ /* ========== 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); } } /* * Per-page renderer split (UI profile pattern) * -------------------------------------------- * Each page's capability-divergent bodies live in dedicated _mono / _color * functions. render_() is a thin dispatcher that picks one: * - Mono / tiny / e-ink panels use render__mono(). * - RGB565 color panels use render__color(), which is compiled ONLY * when a `tft` node exists in devicetree (MC_DISPLAY_COLOR_PANEL). On a * mono board the color body — and every color-only helper it references — * drops out entirely, so no color RAM/flash is spent. * The bodies are moved verbatim from the old combined function, so on-screen * output is byte-identical to before. */ static void render_messages_mono(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; } snprintf(buf, sizeof(buf), "MSG: %u", state.msg_count); draw_centered(centered_row(0, 4), buf); if (state.ble_connected) { draw_centered(centered_row(1, 4), "< Connected >"); } else { draw_centered(centered_row(1, 4), "Waiting for app..."); } snprintf(buf, sizeof(buf), "Offgrid: %s", state.offgrid_enabled ? "ON" : "OFF"); draw_centered(centered_row(2, 4), buf); uint32_t up_s = (uint32_t)(k_uptime_get() / 1000); snprintf(buf, sizeof(buf), "Up: %ud %uh %um", up_s / 86400, (up_s % 86400) / 3600, (up_s % 3600) / 60); draw_centered(centered_row(3, 4), buf); } #if MC_DISPLAY_COLOR_PANEL static void render_messages_color(void) { char buf[24]; if (use_compact_color_home()) { int y = CONTENT_Y; uint16_t ble_color = state.ble_connected ? UI_COLOR_OK : UI_COLOR_WARN; const char *ble = state.ble_connected ? "BLE OK" : "BLE ADV"; const char *radio = radio_state_label(); uint16_t radio_color = state.lora_tx_active ? UI_COLOR_TX : state.lora_in_rx ? UI_COLOR_RX : state.lora_radio_ready ? UI_COLOR_OK : UI_COLOR_WARN; draw_badge(0, y, ble, ble_color); draw_badge(DISP_W - color_text_width(radio) - 4, y, radio, radio_color); y += LINE_H + 2; mc_display_color_text(0, y, "COMPANION", UI_COLOR_LABEL); snprintf(buf, sizeof(buf), "MSG %u", state.msg_count); mc_display_color_text(DISP_W - color_text_width(buf), y, buf, state.msg_count ? UI_COLOR_WARN : UI_COLOR_VALUE); y += LINE_H; if (state.ble_connected) { draw_centered_color(y, "Connected", UI_COLOR_OK); } else { mc_display_color_text(30, y, "Waiting for app", UI_COLOR_WARN); } y += LINE_H; snprintf(buf, sizeof(buf), "Offgrid %s", state.offgrid_enabled ? "on" : "off"); draw_centered_color(y, buf, state.offgrid_enabled ? UI_COLOR_ACTIVE : UI_COLOR_DISABLED); y += LINE_H; uint32_t up_s = (uint32_t)(k_uptime_get() / 1000); snprintf(buf, sizeof(buf), "Up: %ud %uh %um", up_s / 86400, (up_s % 86400) / 3600, (up_s % 3600) / 60); draw_centered_color(y, buf, UI_COLOR_LABEL); return; } int y = CONTENT_Y; uint16_t ble_color = state.ble_connected ? UI_COLOR_OK : UI_COLOR_WARN; const char *ble = state.ble_connected ? "BLE OK" : "BLE ADV"; draw_badge(0, y, ble, ble_color); draw_badge(DISP_W - color_text_width(state.offgrid_enabled ? "GRID" : "LOCAL") - 4, y, state.offgrid_enabled ? "GRID" : "LOCAL", state.offgrid_enabled ? UI_COLOR_ACTIVE : UI_COLOR_DISABLED); y += LINE_H + 2; mc_display_color_text(0, y, "MESSAGES", UI_COLOR_LABEL); snprintf(buf, sizeof(buf), "%u", state.msg_count); mc_display_color_text(DISP_W - color_text_width(buf), y, buf, state.msg_count ? UI_COLOR_WARN : UI_COLOR_VALUE); y += LINE_H; if (state.ble_connected) { draw_centered_color(y, "Connected", UI_COLOR_OK); } else { draw_centered_color(y, "Waiting for app", UI_COLOR_WARN); } y += LINE_H; snprintf(buf, sizeof(buf), "Offgrid %s", state.offgrid_enabled ? "on" : "off"); draw_centered_color(y, buf, state.offgrid_enabled ? UI_COLOR_ACTIVE : UI_COLOR_DISABLED); y += LINE_H; uint32_t up_s = (uint32_t)(k_uptime_get() / 1000); snprintf(buf, sizeof(buf), "Up: %ud %uh %um", up_s / 86400, (up_s % 86400) / 3600, (up_s % 3600) / 60); draw_centered_color(y, buf, UI_COLOR_LABEL); } #endif /* MC_DISPLAY_COLOR_PANEL */ static void render_messages(void) { #if MC_DISPLAY_COLOR_PANEL if (mc_display_has_color()) { render_messages_color(); return; } #endif render_messages_mono(); } static void render_recent(void) { if (state.recent_count == 0) { if (mc_display_has_color()) { draw_centered_color(centered_row(0, 1), "no contacts heard", UI_COLOR_DISABLED); } else { draw_centered(centered_row(0, 1), "(no contacts heard)"); } return; } int y = CONTENT_Y; for (int i = 0; i < state.recent_count && i < 4; i++) { /* age_s is recomputed from RTC timestamps each housekeeping * cycle (~5s), so it's always fresh and monotonic. */ uint32_t age_s = state.recent[i].age_s; /* Format elapsed time */ char time_str[8]; if (age_s < 60) { snprintf(time_str, sizeof(time_str), "%us", age_s); } else if (age_s < 3600) { snprintf(time_str, sizeof(time_str), "%um", age_s / 60); } else if (age_s < 86400) { snprintf(time_str, sizeof(time_str), "%uh", age_s / 3600); } else { snprintf(time_str, sizeof(time_str), "%ud", age_s / 86400); } /* Name left-aligned, time right-aligned */ char buf[24]; snprintf(buf, sizeof(buf), "%-13s %s", state.recent[i].name, time_str); if (mc_display_has_color()) { uint16_t age_color = (age_s < 300) ? UI_COLOR_OK : (age_s < 3600) ? UI_COLOR_WARN : UI_COLOR_DISABLED; mc_display_color_text(0, y, state.recent[i].name, UI_COLOR_VALUE); mc_display_color_text(DISP_W - color_text_width(time_str), y, time_str, age_color); } else { mc_display_text(0, y, buf, false); } y += LINE_H; } } static void render_radio_mono(void) { char buf[32]; int y = CONTENT_Y; uint32_t freq_mhz = state.lora_freq_hz / 1000000; uint32_t freq_frac = (state.lora_freq_hz % 1000000 + 500) / 1000; uint16_t bw_int = state.lora_bw_khz_x10 / 10; uint16_t bw_frac = state.lora_bw_khz_x10 % 10; const char *packet_state = radio_state_label(); const char *rx_mode = state.lora_rx_duty_cycle ? "DC" : "CONT"; if (ui_tiny()) { 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", state.lora_tx_power, rx_mode); draw_centered(centered_row(2, 3), buf); return; } if (bw_frac) { snprintf(buf, sizeof(buf), "%u.%03u BW%u.%u", freq_mhz, freq_frac, bw_int, bw_frac); } else { snprintf(buf, sizeof(buf), "%u.%03u BW%u", freq_mhz, freq_frac, bw_int); } mc_display_text(0, y, buf, false); y += LINE_H; snprintf(buf, sizeof(buf), "SF%u CR%u SW%02X P%u", state.lora_sf, state.lora_cr, state.lora_sync_word, state.lora_preamble_len); mc_display_text(0, y, buf, false); y += LINE_H; snprintf(buf, sizeof(buf), "TX:%ddBm %s/%s", state.lora_tx_power, packet_state, rx_mode); mc_display_text(0, y, buf, false); y += LINE_H; snprintf(buf, sizeof(buf), "NF%d R%lu T%lu E%lu", state.lora_noise_floor, (unsigned long)state.lora_packets_rx, (unsigned long)state.lora_packets_tx, (unsigned long)state.lora_packets_err); mc_display_text(0, y, buf, false); } #if MC_DISPLAY_COLOR_PANEL static void render_radio_color(void) { char buf[32]; int y = CONTENT_Y; uint32_t freq_mhz = state.lora_freq_hz / 1000000; uint32_t freq_frac = (state.lora_freq_hz % 1000000 + 500) / 1000; uint16_t bw_int = state.lora_bw_khz_x10 / 10; uint16_t bw_frac = state.lora_bw_khz_x10 % 10; const char *packet_state = radio_state_label(); const char *rx_mode = state.lora_rx_duty_cycle ? "DC" : "CONT"; { uint16_t state_color = state.lora_tx_active ? UI_COLOR_WARN : state.lora_in_rx ? UI_COLOR_ACTIVE : state.lora_radio_ready ? UI_COLOR_OK : UI_COLOR_DISABLED; uint16_t tx_color = UI_COLOR_OK; /* Scale the TX bar against the board's hardware ceiling, not * against the current setting — the old max came from APC * (effective vs. configured power) and with APC gone both ends * would be lora_tx_power, pinning the bar permanently full. */ int max_tx = TX_POWER_BAR_MAX_DBM; int badge_x; mc_display_color_fill_rect(0, y - 1, DISP_W, COLOR_FONT_H + 2, UI_COLOR_HEADER_BG); mc_display_color_text(2, y, "RADIO", UI_COLOR_TITLE); badge_x = DISP_W - color_text_width(rx_mode) - 6; draw_badge(badge_x, y, rx_mode, state.lora_rx_duty_cycle ? UI_COLOR_WARN : UI_COLOR_ACTIVE); badge_x -= color_text_width(packet_state) + 8; draw_badge(badge_x, y, packet_state, state_color); y += LINE_H; if (bw_frac) { snprintf(buf, sizeof(buf), "%u.%03u BW%u.%u", freq_mhz, freq_frac, bw_int, bw_frac); } else { snprintf(buf, sizeof(buf), "%u.%03u BW%u", freq_mhz, freq_frac, bw_int); } draw_color_segments(y, "RF ", buf, UI_COLOR_VALUE); y += LINE_H; snprintf(buf, sizeof(buf), "SF%u CR%u SW%02X P%u", state.lora_sf, state.lora_cr, state.lora_sync_word, state.lora_preamble_len); draw_color_segments(y, "LoRa ", buf, UI_COLOR_VALUE); y += LINE_H; snprintf(buf, sizeof(buf), "%ddBm", state.lora_tx_power); draw_color_segments(y, "TX ", buf, tx_color); draw_metric_bar(DISP_W - 50, y + 2, 48, 5, state.lora_tx_power, max_tx, tx_color); y += LINE_H; char rx_count[6]; char tx_count[6]; char err_count[6]; fmt_compact_count(rx_count, sizeof(rx_count), state.lora_packets_rx); fmt_compact_count(tx_count, sizeof(tx_count), state.lora_packets_tx); fmt_compact_count(err_count, sizeof(err_count), state.lora_packets_err); snprintf(buf, sizeof(buf), "NF%d R%s T%s E%s", state.lora_noise_floor, rx_count, tx_count, err_count); draw_color_segments(y, "PKT ", buf, state.lora_packets_err ? UI_COLOR_ERROR : UI_COLOR_VALUE); return; } } #endif /* MC_DISPLAY_COLOR_PANEL */ static void render_radio(void) { #if MC_DISPLAY_COLOR_PANEL if (mc_display_has_color()) { render_radio_color(); return; } #endif render_radio_mono(); } static void render_traffic_mono(void) { char rx_count[6]; char tx_count[6]; char err_count[6]; char buf[32]; int y = CONTENT_Y; fmt_compact_count(rx_count, sizeof(rx_count), state.lora_packets_rx); fmt_compact_count(tx_count, sizeof(tx_count), state.lora_packets_tx); fmt_compact_count(err_count, sizeof(err_count), state.lora_packets_err); draw_centered(y, "Traffic"); y += LINE_H; snprintf(buf, sizeof(buf), "RX:%s TX:%s", rx_count, tx_count); mc_display_text(0, y, buf, false); y += LINE_H; if (state.lora_packets_err > 0) { snprintf(buf, sizeof(buf), "ERR:%s", err_count); mc_display_text(0, y, buf, false); } } #if MC_DISPLAY_COLOR_PANEL static void render_traffic_color(void) { char rx_count[6]; char tx_count[6]; char err_count[6]; char buf[32]; int y = CONTENT_Y; int graph_y; int graph_h; int x; fmt_compact_count(rx_count, sizeof(rx_count), state.lora_packets_rx); fmt_compact_count(tx_count, sizeof(tx_count), state.lora_packets_tx); fmt_compact_count(err_count, sizeof(err_count), state.lora_packets_err); mc_display_color_fill_rect(0, y - 1, DISP_W, COLOR_FONT_H + 2, UI_COLOR_HEADER_BG); mc_display_color_text(2, y, "TRAFFIC", UI_COLOR_TITLE); if (state.lora_packets_err > 0) { snprintf(buf, sizeof(buf), "E %s", err_count); mc_display_color_text(DISP_W - color_text_width(buf), y, buf, UI_COLOR_ERROR); } y += LINE_H; x = 0; mc_display_color_text(x, y, "RX ", UI_COLOR_LABEL); x += 3 * COLOR_FONT_W; mc_display_color_text(x, y, rx_count, UI_COLOR_RX); x += color_text_width(rx_count) + 10; mc_display_color_text(x, y, "TX ", UI_COLOR_LABEL); x += 3 * COLOR_FONT_W; mc_display_color_text(x, y, tx_count, UI_COLOR_TX); graph_y = y + LINE_H + 2; graph_h = (int)DISP_H - graph_y - 2; if (graph_h < 16) { graph_h = 16; } draw_activity_graph(2, graph_y, DISP_W - 4, graph_h); } #endif /* MC_DISPLAY_COLOR_PANEL */ static void render_traffic(void) { #if MC_DISPLAY_COLOR_PANEL if (mc_display_has_color() && DISP_W >= 120 && DISP_H >= 64) { render_traffic_color(); return; } #endif render_traffic_mono(); } static void render_bluetooth_mono(void) { if (!state.ble_enabled) { draw_centered(centered_row(0, 2), "BLE: OFF"); draw_centered(centered_row(1, 2), "Press to Enable"); return; } if (state.ble_connected) { draw_centered(centered_row(0, 2), "BLE: Connected"); draw_centered(centered_row(1, 2), "Press to Disable"); } else { draw_centered(centered_row(0, 2), "BLE: Advertising"); draw_centered(centered_row(1, 2), "Press to Disable"); } } #if MC_DISPLAY_COLOR_PANEL static void render_bluetooth_color(void) { int y = CONTENT_Y; uint16_t color = !state.ble_enabled ? UI_COLOR_DISABLED : state.ble_connected ? UI_COLOR_OK : UI_COLOR_WARN; draw_badge(0, y, "BLE", color); mc_display_color_text(32, y, !state.ble_enabled ? "disabled" : state.ble_connected ? "connected" : "advertising", color); y += LINE_H + (FONT_H / 4); draw_centered_color(y, state.ble_enabled ? "Press to disable" : "Press to enable", UI_COLOR_VALUE); } #endif /* MC_DISPLAY_COLOR_PANEL */ static void render_bluetooth(void) { #if MC_DISPLAY_COLOR_PANEL if (mc_display_has_color()) { render_bluetooth_color(); return; } #endif render_bluetooth_mono(); } static void render_advert_mono(void) { /* Check for recent "Sent!" feedback (show for 2 seconds) */ uint32_t now = k_uptime_get_32(); bool just_sent = (state.advert_sent_time > 0 && (now - state.advert_sent_time) < 2000); draw_centered(centered_row(0, 3), "Send Zero-Hop Advert"); if (just_sent) { if (state.advert_was_flood) { draw_centered(centered_row(1, 3), ">> Flood Sent! <<"); } else { draw_centered(centered_row(1, 3), ">>> Sent! <<<"); } } else { draw_centered(centered_row(1, 3), "Press to Send"); } draw_centered(centered_row(2, 3), "(2x Press: Flood)"); } #if MC_DISPLAY_COLOR_PANEL static void render_advert_color(void) { uint32_t now = k_uptime_get_32(); bool just_sent = (state.advert_sent_time > 0 && (now - state.advert_sent_time) < 2000); int y = CONTENT_Y; draw_badge(0, y, "ADV", just_sent ? UI_COLOR_OK : UI_COLOR_ACTIVE); mc_display_color_text(32, y, "Zero-hop advert", UI_COLOR_VALUE); y += LINE_H + (FONT_H / 2); if (just_sent) { draw_centered_color(y, state.advert_was_flood ? "Flood sent" : "Sent", UI_COLOR_OK); } else { draw_centered_color(y, "Press to send", UI_COLOR_VALUE); } y += LINE_H; draw_centered_color(y, "2x press: flood", UI_COLOR_LABEL); } #endif /* MC_DISPLAY_COLOR_PANEL */ static void render_advert(void) { #if MC_DISPLAY_COLOR_PANEL if (mc_display_has_color()) { render_advert_color(); return; } #endif render_advert_mono(); } /* Format seconds into compact time string: "3m20s", "1h05m", "12s" */ static void fmt_duration(char *buf, size_t len, uint32_t secs) { if (secs >= 3600) { snprintf(buf, len, "%uh%02um", secs / 3600, (secs % 3600) / 60); } else if (secs >= 60) { snprintf(buf, len, "%um%02us", secs / 60, secs % 60); } else { snprintf(buf, len, "%us", secs); } } static void render_gps(void) { char buf[32]; 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) { draw_badge(0, y, "GPS", UI_COLOR_DISABLED); mc_display_color_text(32, y, "not detected", UI_COLOR_DISABLED); } else { mc_display_text(0, y, "GPS: not detected", false); } return; } /* GPS enabled/disabled state */ snprintf(buf, sizeof(buf), "GPS: %s", state.gps_enabled ? "on" : "off"); if (color) { draw_badge(0, y, "GPS", state.gps_enabled ? UI_COLOR_OK : UI_COLOR_DISABLED); mc_display_color_text(32, y, state.gps_enabled ? "on" : "off", state.gps_enabled ? UI_COLOR_OK : UI_COLOR_DISABLED); } else { mc_display_text(0, y, buf, false); } y += LINE_H; if (!state.gps_enabled) { if (color) { draw_centered_color(y + 8, "Press to enable", UI_COLOR_VALUE); } else { draw_centered(y + 8, "Press to Enable"); } return; } /* State-dependent display */ if (state.gps_state == 2) { /* ACQUIRING — actively searching for satellites */ snprintf(buf, sizeof(buf), "Searching... sat:%u", state.gps_satellites); if (color) { draw_color_segments(y, "SAT ", buf, UI_COLOR_WARN); } else { mc_display_text(0, y, buf, false); } y += LINE_H; /* Show last fix age if we have one */ if (state.gps_last_fix_age_s != UINT32_MAX) { char tbuf[12]; fmt_duration(tbuf, sizeof(tbuf), state.gps_last_fix_age_s); snprintf(buf, sizeof(buf), "Last fix: %s ago", tbuf); if (color) { draw_color_segments(y, "FIX ", buf, UI_COLOR_VALUE); } else { mc_display_text(0, y, buf, false); } } else { if (color) { mc_display_color_text(0, y, "No fix yet", UI_COLOR_WARN); } else { mc_display_text(0, y, "No fix yet", false); } } } else if (state.gps_state == 1) { /* STANDBY — sleeping between fix cycles */ if (state.gps_last_fix_age_s != UINT32_MAX) { char tbuf[12]; fmt_duration(tbuf, sizeof(tbuf), state.gps_last_fix_age_s); snprintf(buf, sizeof(buf), "Last fix: %s ago", tbuf); if (color) { draw_color_segments(y, "FIX ", buf, UI_COLOR_VALUE); } else { mc_display_text(0, y, buf, false); } y += LINE_H; } else { if (color) { mc_display_color_text(0, y, "No fix yet", UI_COLOR_WARN); } else { mc_display_text(0, y, "No fix yet", false); } y += LINE_H; } if (state.gps_next_search_s > 0) { char tbuf[12]; fmt_duration(tbuf, sizeof(tbuf), state.gps_next_search_s); snprintf(buf, sizeof(buf), "Next search: %s", tbuf); if (color) { draw_color_segments(y, "NEXT ", buf, UI_COLOR_LABEL); } else { mc_display_text(0, y, buf, false); } } } else { /* OFF — shouldn't reach here if gps_enabled is true */ if (color) { mc_display_color_text(0, y, "GPS off", UI_COLOR_DISABLED); } else { mc_display_text(0, y, "GPS off", false); } } } static void render_buzzer(void) { char buf[24]; int y = CONTENT_Y; uint8_t next = zephcore_buzzer_next_mode(state.buzzer_mode); snprintf(buf, sizeof(buf), "Alert: %s", zephcore_buzzer_mode_name(state.buzzer_mode)); mc_display_text(0, y, buf, false); y += LINE_H; snprintf(buf, sizeof(buf), "Press for %s", zephcore_buzzer_mode_name(next)); draw_centered(y + 8, buf); } static void render_leds_mono(void) { char buf[24]; int y = CONTENT_Y; snprintf(buf, sizeof(buf), "LEDs: %s", state.leds_disabled ? "off" : "on"); mc_display_text(0, y, buf, false); y += LINE_H; draw_centered(y + 8, state.leds_disabled ? "Press to Enable" : "Press to Disable"); } #if MC_DISPLAY_COLOR_PANEL static void render_leds_color(void) { int y = CONTENT_Y; draw_badge(0, y, "LED", state.leds_disabled ? UI_COLOR_DISABLED : UI_COLOR_OK); mc_display_color_text(32, y, state.leds_disabled ? "off" : "on", state.leds_disabled ? UI_COLOR_DISABLED : UI_COLOR_OK); y += LINE_H + 4; draw_centered_color(y, state.leds_disabled ? "Press to enable" : "Press to disable", UI_COLOR_VALUE); } #endif /* MC_DISPLAY_COLOR_PANEL */ static void render_leds(void) { #if MC_DISPLAY_COLOR_PANEL if (mc_display_has_color()) { render_leds_color(); return; } #endif render_leds_mono(); } static void render_sensors(void) { char buf[24]; int y = CONTENT_Y; bool color = mc_display_has_color(); /* Lazy read: only fetch sensors when the user is actually looking at * this page. Render is event-driven (schedule_render only on real UI * events), so this never fires periodically when idle. */ struct env_data edata; bool have_env = env_sensors_available() && env_sensors_read(&edata) == 0; if (have_env && edata.has_temperature) { int16_t t10 = (int16_t)(edata.temperature_c * 10); snprintf(buf, sizeof(buf), "Temp: %d.%d C", t10 / 10, abs(t10 % 10)); if (color) { draw_color_segments(y, "TMP ", buf, UI_COLOR_VALUE); } else { mc_display_text(0, y, buf, false); } y += LINE_H; } if (have_env && edata.has_pressure) { snprintf(buf, sizeof(buf), "Press: %u hPa", (unsigned)edata.pressure_hpa); if (color) { draw_color_segments(y, "BAR ", buf, UI_COLOR_VALUE); } else { mc_display_text(0, y, buf, false); } y += LINE_H; } if (have_env && edata.has_humidity) { uint16_t h10 = (uint16_t)(edata.humidity_pct * 10); snprintf(buf, sizeof(buf), "Humid: %u.%u%%", h10 / 10, h10 % 10); if (color) { draw_color_segments(y, "HUM ", buf, UI_COLOR_VALUE); } else { mc_display_text(0, y, buf, false); } y += LINE_H; } /* Battery at bottom */ snprintf(buf, sizeof(buf), "Batt: %u%% (%umV)", state.battery_pct > 0 ? state.battery_pct : calc_battery_pct(state.battery_mv), state.battery_mv); if (color) { uint8_t pct = state.battery_pct > 0 ? state.battery_pct : calc_battery_pct(state.battery_mv); uint16_t batt_color = (pct <= 15) ? UI_COLOR_ERROR : (pct <= 30) ? UI_COLOR_WARN : UI_COLOR_OK; snprintf(buf, sizeof(buf), "%u%% %umV", pct, state.battery_mv); draw_color_segments(y, "BAT ", buf, batt_color); draw_metric_bar(DISP_W - 44, y + 2, 42, 5, pct, 100, batt_color); } else { mc_display_text(0, y, buf, false); } } static void render_offgrid_mono(void) { char buf[24]; draw_centered(centered_row(0, 3), "Offgrid Mode"); snprintf(buf, sizeof(buf), "Status: %s", state.offgrid_enabled ? "ON" : "OFF"); draw_centered(centered_row(1, 3), buf); if (state.offgrid_confirm_time != 0) { draw_centered(centered_row(2, 3), "Press to confirm"); } else { draw_centered(centered_row(2, 3), state.offgrid_enabled ? "Press to Disable" : "Press to Enable"); } } #if MC_DISPLAY_COLOR_PANEL static void render_offgrid_color(void) { char buf[24]; int y = CONTENT_Y; draw_badge(0, y, "GRID", state.offgrid_enabled ? UI_COLOR_ACTIVE : UI_COLOR_DISABLED); mc_display_color_text(38, y, "client repeat", state.offgrid_enabled ? UI_COLOR_ACTIVE : UI_COLOR_LABEL); y += LINE_H + 4; snprintf(buf, sizeof(buf), "Status %s", state.offgrid_enabled ? "on" : "off"); draw_centered_color(y, buf, state.offgrid_enabled ? UI_COLOR_ACTIVE : UI_COLOR_DISABLED); y += LINE_H; draw_centered_color(y, state.offgrid_confirm_time != 0 ? "Press to confirm" : (state.offgrid_enabled ? "Press to disable" : "Press to enable"), state.offgrid_confirm_time != 0 ? UI_COLOR_WARN : UI_COLOR_VALUE); } #endif /* MC_DISPLAY_COLOR_PANEL */ static void render_offgrid(void) { /* Check if confirmation has expired */ if (state.offgrid_confirm_time != 0 && (k_uptime_get_32() - state.offgrid_confirm_time) > CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { state.offgrid_confirm_time = 0; } #if MC_DISPLAY_COLOR_PANEL if (mc_display_has_color()) { render_offgrid_color(); return; } #endif render_offgrid_mono(); } static void render_dfu_mono(void) { draw_centered(centered_row(0, 2), "BLE DFU Update"); if (state.dfu_confirm_time != 0) { draw_centered(centered_row(1, 2), "Press to confirm"); } else { draw_centered(centered_row(1, 2), "Press to Run"); } } #if MC_DISPLAY_COLOR_PANEL static void render_dfu_color(void) { int y = CONTENT_Y; draw_badge(0, y, "DFU", state.dfu_confirm_time != 0 ? UI_COLOR_WARN : UI_COLOR_ACTIVE); mc_display_color_text(32, y, "BLE update", UI_COLOR_VALUE); y += LINE_H + 4; draw_centered_color(y, state.dfu_confirm_time != 0 ? "Press to confirm" : "Press to run", state.dfu_confirm_time != 0 ? UI_COLOR_WARN : UI_COLOR_VALUE); } #endif /* MC_DISPLAY_COLOR_PANEL */ static void render_dfu(void) { /* Check if confirmation has expired */ if (state.dfu_confirm_time != 0 && (k_uptime_get_32() - state.dfu_confirm_time) > CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { state.dfu_confirm_time = 0; } #if MC_DISPLAY_COLOR_PANEL if (mc_display_has_color()) { render_dfu_color(); return; } #endif render_dfu_mono(); } static void render_shutdown_mono(void) { draw_centered(centered_row(0, 2), "Power Off"); if (state.shutdown_confirm_time != 0) { draw_centered(centered_row(1, 2), "Press to confirm"); } else { draw_centered(centered_row(1, 2), "Press to Run"); } } #if MC_DISPLAY_COLOR_PANEL static void render_shutdown_color(void) { int y = CONTENT_Y; draw_badge(0, y, "PWR", state.shutdown_confirm_time != 0 ? UI_COLOR_WARN : UI_COLOR_ERROR); mc_display_color_text(32, y, "power off", UI_COLOR_VALUE); y += LINE_H + 4; draw_centered_color(y, state.shutdown_confirm_time != 0 ? "Press to confirm" : "Press to run", state.shutdown_confirm_time != 0 ? UI_COLOR_WARN : UI_COLOR_VALUE); } #endif /* MC_DISPLAY_COLOR_PANEL */ static void render_shutdown(void) { /* Check if confirmation has expired */ if (state.shutdown_confirm_time != 0 && (k_uptime_get_32() - state.shutdown_confirm_time) > CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { state.shutdown_confirm_time = 0; } #if MC_DISPLAY_COLOR_PANEL if (mc_display_has_color()) { render_shutdown_color(); return; } #endif render_shutdown_mono(); } static void render_status(void) { char buf[28]; 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 = local_epoch() % 86400; snprintf(buf, sizeof(buf), "%02u:%02u %s", (unsigned)(ds / 3600), (unsigned)((ds % 3600) / 60), tz_label()); } 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); mc_display_color_text(38, y, "repeater", UI_COLOR_VALUE); } else { draw_centered(y, "REPEATER"); } y += LINE_H; /* Uptime */ uint32_t up_s = (uint32_t)(k_uptime_get() / 1000); uint32_t days = up_s / 86400; uint32_t hours = (up_s % 86400) / 3600; uint32_t mins = (up_s % 3600) / 60; snprintf(buf, sizeof(buf), "Up: %ud %uh %um", days, hours, mins); if (color) { draw_color_segments(y, "UP ", buf, UI_COLOR_VALUE); } else { mc_display_text(0, y, buf, false); } y += LINE_H; /* Clock — only if RTC has been synced (after Jan 1 2025) */ if (state.rtc_epoch > 1735689600) { uint32_t day_sec = local_epoch() % 86400; uint8_t hh = day_sec / 3600; uint8_t mm = (day_sec % 3600) / 60; uint8_t ss = day_sec % 60; /* "T:" rather than "Time:" so a two-digit zone still fits the * 20-column budget of the 200x200 e-paper boards, which pair * CONFIG_ZEPHCORE_DISPLAY_LARGE_FONT (10x16) with this page. */ snprintf(buf, sizeof(buf), "T: %02u:%02u:%02u %s", hh, mm, ss, tz_label()); if (color) { draw_color_segments(y, "CLK ", buf, UI_COLOR_OK); } else { mc_display_text(0, y, buf, false); } } else { if (color) { draw_color_segments(y, "CLK ", "not synced", UI_COLOR_WARN); } else { mc_display_text(0, y, "T: not synced", false); } } y += LINE_H; /* Battery */ if (state.battery_mv > 0) { uint8_t pct = state.battery_pct; if (pct == 0) { pct = calc_battery_pct(state.battery_mv); } snprintf(buf, sizeof(buf), "Batt: %u%% (%umV)", pct, state.battery_mv); if (color) { uint16_t batt_color = (pct <= 15) ? UI_COLOR_ERROR : (pct <= 30) ? UI_COLOR_WARN : UI_COLOR_OK; snprintf(buf, sizeof(buf), "%u%% %umV", pct, state.battery_mv); draw_color_segments(y, "BAT ", buf, batt_color); draw_metric_bar(DISP_W - 44, y + 2, 42, 5, pct, 100, batt_color); } else { mc_display_text(0, y, buf, false); } } } /* ========== Page render dispatch ========== */ typedef void (*page_render_fn)(void); static const page_render_fn renderers[] = { [UI_PAGE_MESSAGES] = render_messages, [UI_PAGE_RECENT] = render_recent, [UI_PAGE_RADIO] = render_radio, [UI_PAGE_TRAFFIC] = render_traffic, [UI_PAGE_BLUETOOTH] = render_bluetooth, [UI_PAGE_ADVERT] = render_advert, [UI_PAGE_GPS] = render_gps, [UI_PAGE_BUZZER] = render_buzzer, [UI_PAGE_LEDS] = render_leds, [UI_PAGE_SENSORS] = render_sensors, [UI_PAGE_OFFGRID] = render_offgrid, [UI_PAGE_DFU] = render_dfu, [UI_PAGE_SHUTDOWN] = render_shutdown, [UI_PAGE_STATUS] = render_status, }; /* ========== Public API ========== */ struct ui_state *ui_pages_get_state(void) { return &state; } void ui_pages_render(void) { /* Refresh battery lazily — ADC only fires when the cached reading is * stale (≥30 s). Render is event-driven, so during idle this never * runs. Telemetry / stats paths bypass the cache entirely. */ 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(); enum ui_page page = active_pages[current_page_idx]; if (page < UI_PAGE_COUNT && renderers[page]) { renderers[page](); } 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(); /* ZephCore logo bitmap (128x13px) — center horizontally */ int logo_w = 128; int logo_h = 13; int logo_x = (DISP_W >= logo_w) ? (DISP_W - logo_w) / 2 : 0; int y = 3; mc_display_xbm(logo_x, y, zephcore_logo, logo_w, logo_h); y += logo_h + LINE_H; /* "MeshCore on Zephyr" centered below logo */ draw_centered(y, "MeshCore on Zephyr"); y += LINE_H * 2; /* Build date centered below (format: "2026 Feb 15") */ #ifdef FIRMWARE_BUILD_DATE draw_centered(y, FIRMWARE_BUILD_DATE); #endif mc_display_finalize(); } 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; } state.current_page = active_pages[current_page_idx]; } 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; } state.current_page = active_pages[current_page_idx]; } 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) { current_page_idx = i; state.current_page = page; return; } } /* Page not in active list — go to first active page */ current_page_idx = 0; state.current_page = active_pages[0]; } enum ui_page ui_pages_current(void) { return active_pages[current_page_idx]; } void ui_pages_set_node_name(const char *name) { if (name) { /* Sanitize UTF-8 to Latin-1 (strips emojis, keeps accents) */ utf8_to_latin1(state.node_name, name, sizeof(state.node_name)); } } void ui_pages_advert_sent(bool flood) { state.advert_sent_time = k_uptime_get_32(); state.advert_was_flood = flood; }