From 72524ef1fb525a270929b74d650eddbcd160aa2f Mon Sep 17 00:00:00 2001 From: Nathan Payne Date: Fri, 5 Jun 2026 11:26:57 -0700 Subject: [PATCH] firmware: idle screen when usage data goes stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the host is connected over BLE but no usage update has landed recently (~90s — token expired, daemon down, host asleep), show a calm idle screen instead of leaving hours-old numbers on screen as if they were live: a shrunk, animated "expression sleep" creature (reused claudepix art) centered between the header and the status line. The usage view resolves to three sub-screens via a data-freshness timer: live panels, this idle screen, or the existing pairing hint. The bottom status line animates present-participle gerunds: the idle screen alternates "Listening…" / "No data…" (alive + explicit), the disconnected screen uses "Waiting…" (replacing the static "Disconnected…"). splash_mini_create()/splash_mini_tick() render any claudepix animation shrunk into an arbitrary parent, keeping the art data in the splash module. Co-Authored-By: Claude Opus 4.8 (1M context) --- firmware/src/splash.cpp | 60 +++++++++++++++++++++++++++++++ firmware/src/splash.h | 8 +++++ firmware/src/ui.cpp | 79 ++++++++++++++++++++++++++++++++++------- 3 files changed, 134 insertions(+), 13 deletions(-) diff --git a/firmware/src/splash.cpp b/firmware/src/splash.cpp index c6e4fe6..fc0c285 100644 --- a/firmware/src/splash.cpp +++ b/firmware/src/splash.cpp @@ -89,6 +89,66 @@ static void render_frame(const uint8_t *cells, const uint16_t *palette) { if (canvas) lv_obj_invalidate(canvas); } +// ---- Mini creature: a small animated creature for embedding in other screens +// (e.g. the idle "sleeping" indicator). Self-contained — its own canvas and +// buffer, independent of the full-screen splash above. ---- +static lv_obj_t *mini_canvas = NULL; +static uint16_t *mini_buf = NULL; +static int mini_cell = 0; +static int mini_w = 0; +static const splash_anim_def_t *mini_anim = NULL; +static uint16_t mini_frame = 0; +static uint32_t mini_started = 0; + +static void mini_render(void) { + if (!mini_buf || !mini_anim) return; + const uint8_t *cells = mini_anim->frames[mini_frame]; + const uint16_t *pal = mini_anim->palette; + for (int gy = 0; gy < GRID; gy++) { + for (int gx = 0; gx < GRID; gx++) { + uint8_t code = cells[gy * GRID + gx]; + uint16_t color = (pal && code < SPLASH_PALETTE_SIZE) ? pal[code] : COL_EMPTY; + for (int dy = 0; dy < mini_cell; dy++) { + uint16_t *dst = &mini_buf[(gy * mini_cell + dy) * mini_w + gx * mini_cell]; + for (int dx = 0; dx < mini_cell; dx++) dst[dx] = color; + } + } + } + if (mini_canvas) lv_obj_invalidate(mini_canvas); +} + +lv_obj_t* splash_mini_create(lv_obj_t *parent, const char *anim_name, int px) { + mini_anim = NULL; + for (int i = 0; i < SPLASH_ANIM_COUNT; i++) { + if (strcmp(splash_anims[i].name, anim_name) == 0) { mini_anim = &splash_anims[i]; break; } + } + if (!mini_anim) return NULL; + mini_cell = px / GRID; + if (mini_cell < 1) mini_cell = 1; + mini_w = GRID * mini_cell; +#ifdef BOARD_HAS_PSRAM + const uint32_t caps = MALLOC_CAP_SPIRAM; +#else + const uint32_t caps = MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT; +#endif + mini_buf = (uint16_t*)heap_caps_malloc(mini_w * mini_w * 2, caps); + if (!mini_buf) return NULL; + mini_canvas = lv_canvas_create(parent); + lv_canvas_set_buffer(mini_canvas, mini_buf, mini_w, mini_w, LV_COLOR_FORMAT_RGB565); + mini_frame = 0; + mini_started = millis(); + mini_render(); + return mini_canvas; +} + +void splash_mini_tick(void) { + if (!mini_buf || !mini_anim || mini_anim->frame_count == 0) return; + if (millis() - mini_started < mini_anim->holds[mini_frame]) return; + mini_started = millis(); + mini_frame = (mini_frame + 1) % mini_anim->frame_count; + mini_render(); +} + static void show_placeholder() { // Solid dark background + centered status label. if (canvas_buf) { diff --git a/firmware/src/splash.h b/firmware/src/splash.h index ccfd9ec..ff7c74d 100644 --- a/firmware/src/splash.h +++ b/firmware/src/splash.h @@ -26,3 +26,11 @@ bool splash_is_active(void); // Root container (so ui.cpp can attach a click event). lv_obj_t* splash_get_root(void); + +// Mini animated creature for embedding elsewhere (e.g. the idle screen). +// Renders the named claudepix animation (e.g. "expression sleep") at ~px×px +// inside `parent`; returns the canvas object (position it with lv_obj_align) or +// NULL if the animation isn't found / allocation fails. Drive it with +// splash_mini_tick(). One mini creature at a time. +lv_obj_t* splash_mini_create(lv_obj_t *parent, const char *anim_name, int px); +void splash_mini_tick(void); diff --git a/firmware/src/ui.cpp b/firmware/src/ui.cpp index 395b936..ef37841 100644 --- a/firmware/src/ui.cpp +++ b/firmware/src/ui.cpp @@ -119,6 +119,16 @@ static lv_obj_t* battery_img; static lv_obj_t* logo_img; static lv_image_dsc_t battery_dscs[5]; // empty, low, medium, full, charging +// ---- Live-data freshness → which usage sub-view to show ---- +// usage panels when data is flowing, an idle "Zzz" screen when the host is +// connected but no usage update landed within DATA_FRESH_MS, the pairing hint +// when BLE is down. Re-evaluated every loop in ui_tick_anim(). +static lv_obj_t* idle_group; // the "Zzz" idle screen +static uint32_t last_data_ms = 0; // lv_tick when the last valid usage update landed +static bool data_received = false; // any valid update since boot +static int view_state = -1; // -1 unknown / 0 pair / 1 idle / 2 usage +static const uint32_t DATA_FRESH_MS = 90000; // usage counts as "live" within this window (daemon sends ~60s) + // ---- Shared ---- static lv_image_dsc_t logo_dsc; static screen_t current_screen = SCREEN_USAGE; @@ -322,6 +332,28 @@ static void build_pair_group(lv_obj_t* parent) { lv_obj_add_flag(pair_group, LV_OBJ_FLAG_HIDDEN); // ui_update_ble_status decides } +// Idle "Zzz" screen — shown when the host is connected but no usage update has +// landed recently (token expired, daemon down, host asleep…). Full-screen, like +// the pairing hint, so we never render hours-old numbers as if they were live. +static void build_idle_group(lv_obj_t* parent) { + idle_group = lv_obj_create(parent); + lv_obj_set_size(idle_group, L.scr_w, L.scr_h - L.content_y); + lv_obj_set_pos(idle_group, 0, L.content_y); + lv_obj_set_style_bg_opa(idle_group, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(idle_group, 0, 0); + lv_obj_set_style_pad_all(idle_group, 0, 0); + lv_obj_clear_flag(idle_group, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(idle_group, LV_OBJ_FLAG_EVENT_BUBBLE); + + // A shrunk-down sleeping creature (reused claudepix "expression sleep" art) + // sits between the header and the status line; the animated "Listening…" + // status line carries the words, so no extra text is needed here. + lv_obj_t* creature = splash_mini_create(idle_group, "expression sleep", 160); + if (creature) lv_obj_align(creature, LV_ALIGN_CENTER, 0, -20); + + lv_obj_add_flag(idle_group, LV_OBJ_FLAG_HIDDEN); // update_view_state decides +} + static void init_usage_screen(lv_obj_t* scr) { usage_container = lv_obj_create(scr); lv_obj_set_size(usage_container, L.scr_w, L.scr_h); @@ -358,6 +390,7 @@ static void init_usage_screen(lv_obj_t* scr) { &bar_weekly, &lbl_weekly_reset); build_pair_group(usage_container); + build_idle_group(usage_container); // Status line — always visible on the usage view. Driven by ui_tick_anim(). lbl_anim = lv_label_create(usage_container); @@ -393,10 +426,13 @@ void ui_init(void) { battery_img = lv_image_create(scr); lv_image_set_src(battery_img, &battery_dscs[0]); lv_obj_set_pos(battery_img, L.scr_w - 48 - L.margin, L.title_y); + } void ui_update(const UsageData* data) { if (!data->valid) return; + last_data_ms = lv_tick_get(); // a valid usage update just landed → dot goes green + data_received = true; int s_pct = (int)(data->session_pct + 0.5f); @@ -417,8 +453,33 @@ void ui_update(const UsageData* data) { lv_label_set_text(lbl_weekly_reset, buf); } +// Pick the usage-view sub-screen: pairing hint (BLE down), the idle "Zzz" screen +// (connected but data has gone stale), or the live usage panels. Only re-lays-out +// on an actual change. The animated status line stays visible everywhere — it +// reads "Listening…" on the idle screen, keeping it alive rather than frozen. +static void update_view_state(void) { + if (!usage_group || !pair_group || !idle_group) return; + int v; + if (!s_ble_connected) { + v = 0; // pairing hint + } else if (data_received && (lv_tick_get() - last_data_ms) < DATA_FRESH_MS) { + v = 2; // live usage + } else { + v = 1; // idle / Zzz + } + if (v == view_state) return; + view_state = v; + lv_obj_add_flag(pair_group, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(idle_group, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(usage_group, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(v == 0 ? pair_group : v == 1 ? idle_group : usage_group, + LV_OBJ_FLAG_HIDDEN); +} + void ui_tick_anim(void) { if (current_screen != SCREEN_USAGE) return; + update_view_state(); + if (view_state == 1) splash_mini_tick(); // animate the sleeping creature on the idle screen uint32_t now = lv_tick_get(); @@ -436,7 +497,9 @@ void ui_tick_anim(void) { // Status text by priority. Whimsical messages only when connected & settled. const char* text; if (!s_ble_connected) { - text = ble_has_bonds() ? "Disconnected" : "Pairing"; + text = "Waiting"; // advertising / waiting for a host connection + } else if (view_state == 1) { // idle — alternate so it reads as alive AND data-less + text = (anim_msg_idx & 1) ? "No data" : "Listening"; } else if (now - connected_at_ms < 5000) { text = "Connected"; } else { @@ -497,19 +560,9 @@ void ui_update_ble_status(ble_state_t state, const char* name, const char* mac) bool was_connected = s_ble_connected; s_ble_connected = (state == BLE_STATE_CONNECTED); - // Connected → usage panels; otherwise → pairing hint. The bottom status - // line carries the live state word (Connected / Disconnected / Pairing). - if (usage_group && pair_group) { - if (s_ble_connected) { - lv_obj_clear_flag(usage_group, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(pair_group, LV_OBJ_FLAG_HIDDEN); - } else { - lv_obj_add_flag(usage_group, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(pair_group, LV_OBJ_FLAG_HIDDEN); - } - } - if (s_ble_connected && !was_connected) connected_at_ms = lv_tick_get(); + // pair / idle / usage — picked from connection + data freshness. + update_view_state(); } void ui_update_battery(int percent, bool charging) {