/* * ZephCore - UI Page Renderers * Copyright (c) 2025 ZephCore * SPDX-License-Identifier: Apache-2.0 * * 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 "display.h" #include #include #include #include #include LOG_MODULE_REGISTER(ui_pages, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); /* ZephCore logo bitmap - 128x13px, MSB-first (Adafruit drawBitmap format) */ static const uint8_t zephcore_logo[] = { 0x00, 0x01, 0xff, 0x7f, 0xe7, 0xf8, 0x70, 0x70, 0x3c, 0x01, 0xe0, 0x7f, 0xc3, 0xff, 0x00, 0x00, 0x00, 0x01, 0xff, 0x7f, 0xe7, 0xfc, 0x70, 0x70, 0xff, 0x07, 0xf8, 0x7f, 0xe3, 0xff, 0x00, 0x00, 0x00, 0x01, 0xff, 0x7f, 0xe7, 0xfe, 0x70, 0x71, 0xff, 0x0f, 0xfc, 0x7f, 0xf3, 0xff, 0x00, 0x00, 0x00, 0x00, 0x0e, 0x70, 0x07, 0x0e, 0x70, 0x71, 0xc7, 0x8e, 0x1c, 0x70, 0x73, 0x80, 0x00, 0x00, 0x00, 0x00, 0x1c, 0x70, 0x07, 0x0e, 0x70, 0x73, 0x83, 0x1c, 0x0e, 0x70, 0x73, 0x80, 0x00, 0x00, 0x00, 0x00, 0x38, 0x7f, 0xe7, 0xfe, 0x7f, 0xf3, 0x80, 0x1c, 0x0e, 0x7f, 0xf3, 0xff, 0x00, 0x00, 0x00, 0x00, 0x78, 0x7f, 0xe7, 0xfc, 0x7f, 0xf3, 0x80, 0x1c, 0x0e, 0x7f, 0xe3, 0xff, 0x00, 0x00, 0x00, 0x00, 0x70, 0x7f, 0xe7, 0xf8, 0x7f, 0xf3, 0x80, 0x1c, 0x0e, 0x7f, 0x83, 0xff, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x70, 0x07, 0x00, 0x70, 0x73, 0x83, 0x1c, 0x0e, 0x73, 0xc3, 0x80, 0x00, 0x00, 0x00, 0x01, 0xc0, 0x70, 0x07, 0x00, 0x70, 0x71, 0xc7, 0x8e, 0x1c, 0x71, 0xe3, 0x80, 0x00, 0x00, 0x00, 0x03, 0xff, 0x7f, 0xe7, 0x00, 0x70, 0x71, 0xff, 0x0f, 0xfc, 0x70, 0xe3, 0xff, 0x00, 0x00, 0x00, 0x03, 0xff, 0x7f, 0xe7, 0x00, 0x70, 0x70, 0xff, 0x07, 0xf8, 0x70, 0xf3, 0xff, 0x00, 0x00, 0x00, 0x03, 0xff, 0x7f, 0xe7, 0x00, 0x70, 0x70, 0x3c, 0x01, 0xe0, 0x70, 0x7b, 0xff, 0x00, 0x00, }; /* ========== UTF-8 to Latin-1 Sanitizer ========== */ /* Node names may contain UTF-8 emojis or accented chars. * Our font covers 32-255 (ASCII + Latin-1 Supplement). * This function: * - Passes ASCII (0x00-0x7F) unchanged * - Converts 2-byte Latin-1 sequences (U+00A0-U+00FF) to raw bytes * - Strips all other multi-byte sequences (emojis, CJK, etc.) * - Strips leading/trailing whitespace from removed chars */ void utf8_to_latin1(char *dst, const char *src, size_t dst_size) { size_t di = 0; size_t si = 0; while (src[si] && di < dst_size - 1) { uint8_t c = (uint8_t)src[si]; if (c < 0x80) { /* ASCII byte — pass through */ dst[di++] = (char)c; si++; } else if ((c & 0xE0) == 0xC0) { /* 2-byte UTF-8: 110xxxxx 10xxxxxx */ uint8_t c2 = (uint8_t)src[si + 1]; if ((c2 & 0xC0) != 0x80) { /* Invalid continuation — skip lead byte */ si++; continue; } uint16_t cp = ((c & 0x1F) << 6) | (c2 & 0x3F); if (cp >= 0xA0 && cp <= 0xFF) { /* Latin-1 Supplement — fits in our font */ dst[di++] = (char)(uint8_t)cp; } /* else: Latin Extended etc — skip */ si += 2; } else if ((c & 0xF0) == 0xE0) { /* 3-byte UTF-8 — skip (BMP: CJK, symbols) */ si += 3; } else if ((c & 0xF8) == 0xF0) { /* 4-byte UTF-8 — skip (emojis, supplementary) */ si += 4; } else { /* Invalid/continuation byte — skip */ si++; } } dst[di] = '\0'; /* Strip leading spaces (from removed emoji prefix) */ size_t start = 0; while (dst[start] == ' ') { start++; } if (start > 0) { memmove(dst, dst + start, di - start + 1); } } /* ========== 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 */ #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 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)) /* ========== Global State ========== */ static struct ui_state state; /* ========== 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_BLUETOOTH, UI_PAGE_ADVERT, UI_PAGE_GPS, UI_PAGE_BUZZER, 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: Top Bar (Node Name + Battery) ========== */ static void render_top_bar(void) { /* Node name on the left */ if (state.node_name[0]) { /* Truncate to fit left side (leave room for battery) */ char name[16]; strncpy(name, state.node_name, sizeof(name) - 1); name[sizeof(name) - 1] = '\0'; mc_display_text(0, TOP_BAR_Y, name, false); } /* Right side: "HH:MM XX%" — clock then battery, right-aligned */ { 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 = state.rtc_epoch % 86400; uint8_t hh = day_sec / 3600; uint8_t mm = (day_sec % 3600) / 60; pos = snprintf(right, sizeof(right), "%02u:%02u ", hh, mm); } /* 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); } if (right[0]) { int x = DISP_W - ((int)strlen(right) * FONT_W); mc_display_text(x, TOP_BAR_Y, right, false); } } } /* ========== Helper: Page Indicator Dots ========== */ static void render_page_indicator(void) { /* Centered dots matching Arduino layout: * Each dot spaced 10px apart, centered on screen. * Current page = filled 3x3 block, others = single pixel. */ 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 */ mc_display_fill_rect(x - 1, DOTS_Y, 3, 3); } else { /* Other pages: single pixel */ mc_display_fill_rect(x, DOTS_Y + 1, 1, 1); } } /* Separator line below dots */ 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); } /* ========== Page Renderers ========== */ static void render_messages(void) { /* Large centered message count (matching Arduino "MSG: X") */ char buf[24]; snprintf(buf, sizeof(buf), "MSG: %u", state.msg_count); draw_centered(CONTENT_Y + 2, buf); /* Connection status centered below */ if (state.ble_connected) { draw_centered(CONTENT_Y + 16, "< Connected >"); } else { draw_centered(CONTENT_Y + 16, "Waiting for app..."); } /* Offgrid mode status */ snprintf(buf, sizeof(buf), "Offgrid: %s", state.offgrid_enabled ? "ON" : "OFF"); draw_centered(CONTENT_Y + 32, buf); } static void render_recent(void) { if (state.recent_count == 0) { draw_centered(CONTENT_Y + 10, "(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); mc_display_text(0, y, buf, false); y += LINE_H; } } static void render_radio(void) { char buf[28]; int y = CONTENT_Y; /* Line 1: FQ, SF, BW */ 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; if (bw_frac) { snprintf(buf, sizeof(buf), "%u.%03u SF%u BW%u.%u", freq_mhz, freq_frac, state.lora_sf, bw_int, bw_frac); } else { snprintf(buf, sizeof(buf), "%u.%03u SF%u BW%u", freq_mhz, freq_frac, state.lora_sf, bw_int); } mc_display_text(0, y, buf, false); y += LINE_H; /* Line 2: CR, TX Power */ snprintf(buf, sizeof(buf), "CR:%u TX:%ddBm", state.lora_cr, state.lora_tx_power); mc_display_text(0, y, buf, false); y += LINE_H; /* Line 3: Noise floor */ snprintf(buf, sizeof(buf), "Noise: %ddBm", state.lora_noise_floor); mc_display_text(0, y, buf, false); 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); mc_display_text(0, y, buf, false); } static void render_bluetooth(void) { if (!state.ble_enabled) { draw_centered(CONTENT_Y + 10, "BLE: OFF"); draw_centered(CONTENT_Y + 28, "Press to Enable"); return; } if (state.ble_connected) { draw_centered(CONTENT_Y + 4, "BLE: Connected"); draw_centered(CONTENT_Y + 20, "Press to Disable"); } else { draw_centered(CONTENT_Y + 4, "BLE: Advertising"); draw_centered(CONTENT_Y + 20, "Press to Disable"); } } static void render_advert(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(CONTENT_Y, "Send Zero-Hop Advert"); if (just_sent) { if (state.advert_was_flood) { draw_centered(CONTENT_Y + 16, ">> Flood Sent! <<"); } else { draw_centered(CONTENT_Y + 16, ">>> Sent! <<<"); } } else { draw_centered(CONTENT_Y + 16, "Press to Send"); } draw_centered(CONTENT_Y + 34, "(Double Press: Flood)"); } /* 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; /* No GPS hardware on this board */ if (!state.gps_available) { 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"); mc_display_text(0, y, buf, false); y += LINE_H; if (!state.gps_enabled) { 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); 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); mc_display_text(0, y, buf, false); } 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); mc_display_text(0, y, buf, false); y += LINE_H; } 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); mc_display_text(0, y, buf, false); } } else { /* OFF — shouldn't reach here if gps_enabled is true */ mc_display_text(0, y, "GPS off", false); } } static void render_buzzer(void) { char buf[24]; int y = CONTENT_Y; snprintf(buf, sizeof(buf), "Buzzer: %s", state.buzzer_quiet ? "off" : "on"); mc_display_text(0, y, buf, false); y += LINE_H; draw_centered(y + 8, state.buzzer_quiet ? "Press to Enable" : "Press to Disable"); } static void render_sensors(void) { char buf[24]; int y = CONTENT_Y; /* Temperature */ if (state.temperature_c10 != 0) { snprintf(buf, sizeof(buf), "Temp: %d.%d C", state.temperature_c10 / 10, abs(state.temperature_c10 % 10)); mc_display_text(0, y, buf, false); y += LINE_H; } /* Pressure */ if (state.pressure_pa != 0) { snprintf(buf, sizeof(buf), "Press: %u hPa", (unsigned)(state.pressure_pa / 100)); mc_display_text(0, y, buf, false); y += LINE_H; } /* Humidity */ if (state.humidity_rh10 != 0) { snprintf(buf, sizeof(buf), "Humid: %u.%u%%", state.humidity_rh10 / 10, state.humidity_rh10 % 10); mc_display_text(0, y, buf, false); y += LINE_H; } /* Light */ if (state.light_lux != 0) { snprintf(buf, sizeof(buf), "Light: %u lux", state.light_lux); 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); mc_display_text(0, y, buf, false); } static void render_offgrid(void) { /* Check if confirmation has expired (500ms) */ if (state.offgrid_confirm_time != 0 && (k_uptime_get_32() - state.offgrid_confirm_time) > 500) { state.offgrid_confirm_time = 0; } draw_centered(CONTENT_Y + 2, "Offgrid Mode"); char buf[24]; snprintf(buf, sizeof(buf), "Status: %s", state.offgrid_enabled ? "ON" : "OFF"); draw_centered(CONTENT_Y + 16, buf); if (state.offgrid_confirm_time != 0) { draw_centered(CONTENT_Y + 34, "Press to confirm"); } else { draw_centered(CONTENT_Y + 34, state.offgrid_enabled ? "Press to Disable" : "Press to Enable"); } } static void render_dfu(void) { /* Check if confirmation has expired (500ms) */ if (state.dfu_confirm_time != 0 && (k_uptime_get_32() - state.dfu_confirm_time) > 500) { state.dfu_confirm_time = 0; } draw_centered(CONTENT_Y + 10, "BLE DFU Update"); if (state.dfu_confirm_time != 0) { draw_centered(CONTENT_Y + 28, "Press to confirm"); } else { draw_centered(CONTENT_Y + 28, "Press to Run"); } } static void render_shutdown(void) { /* Check if confirmation has expired (500ms) */ if (state.shutdown_confirm_time != 0 && (k_uptime_get_32() - state.shutdown_confirm_time) > 500) { state.shutdown_confirm_time = 0; } draw_centered(CONTENT_Y + 10, "Power Off"); if (state.shutdown_confirm_time != 0) { draw_centered(CONTENT_Y + 28, "Press to confirm"); } else { draw_centered(CONTENT_Y + 28, "Press to Run"); } } static void render_status(void) { char buf[28]; int y = CONTENT_Y; /* Role label */ 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); 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 = state.rtc_epoch % 86400; uint8_t hh = day_sec / 3600; uint8_t mm = (day_sec % 3600) / 60; uint8_t ss = day_sec % 60; snprintf(buf, sizeof(buf), "Time: %02u:%02u:%02u UTC", hh, mm, ss); mc_display_text(0, y, buf, false); } else { mc_display_text(0, y, "Time: 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); 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_BLUETOOTH] = render_bluetooth, [UI_PAGE_ADVERT] = render_advert, [UI_PAGE_GPS] = render_gps, [UI_PAGE_BUZZER] = render_buzzer, [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) { mc_display_clear(); 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(); } 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 */ 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 */ 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) { /* 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; }