From 7ed4710d28b2877f5561ddd755518c803fb2ce52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hermann=20Bj=C3=B6rgvin=20Haraldsson?= Date: Mon, 11 May 2026 02:05:38 +0000 Subject: [PATCH] Drive splash animations by session usage rate Group splash animations into four moods (idle/normal/active/heavy) and pick which group plays based on a 5-min sliding rate of session_pct. Thresholds sit at 0.10/0.20/0.33 %/min so Heavy fires when usage matches or beats the 5-hour session reset pace. Splash auto-rotates within the current group every 20s; group recomputes on every BLE poll and the splash re-picks immediately if it changes mid-display. 4-min minimum window prevents single-sample bumps after boot from falsely landing in Heavy. --- firmware/src/main.cpp | 9 +++++ firmware/src/splash.cpp | 75 +++++++++++++++++++++++++++++++++++++ firmware/src/splash.h | 8 ++++ firmware/src/usage_rate.cpp | 73 ++++++++++++++++++++++++++++++++++++ firmware/src/usage_rate.h | 15 ++++++++ 5 files changed, 180 insertions(+) create mode 100644 firmware/src/usage_rate.cpp create mode 100644 firmware/src/usage_rate.h diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 10bcff9..37c6f15 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -8,6 +8,7 @@ #include "power.h" #include "imu.h" #include "splash.h" +#include "usage_rate.h" // Physical buttons (global, screen-independent): // BTN_BACK (GPIO 0) — left, send Space (Claude Code voice mode push-to-talk) @@ -402,6 +403,14 @@ void loop() { // Process incoming BLE data if (ble_has_data()) { if (parse_json(ble_get_data(), &usage)) { + int g_before = usage_rate_group(); + usage_rate_sample(usage.session_pct); + int g_after = usage_rate_group(); + if (g_after != g_before) { + Serial.printf("usage rate: group %d -> %d (s=%.2f%%)\n", + g_before, g_after, usage.session_pct); + if (splash_is_active()) splash_pick_for_current_rate(); + } ui_update(&usage); ble_send_ack(); } else { diff --git a/firmware/src/splash.cpp b/firmware/src/splash.cpp index f3a97df..55ebb95 100644 --- a/firmware/src/splash.cpp +++ b/firmware/src/splash.cpp @@ -1,7 +1,9 @@ #include "splash.h" #include "splash_animations.h" #include "theme.h" +#include "usage_rate.h" #include +#include #include // 20x20 grid scaled 24x to fill 480x480 @@ -23,8 +25,49 @@ static uint16_t *canvas_buf = NULL; // 480x480 RGB565 (PSRAM) static uint16_t cur_anim = 0; static uint16_t cur_frame = 0; static uint32_t frame_started_ms = 0; +static uint32_t last_pick_ms = 0; static bool active = false; +// While splash is showing, auto-cycle to the next animation in the current +// rate-driven group every this many ms. +#define SPLASH_ROTATE_INTERVAL_MS 20000 + +// Usage-rate animation groups: 4 groups × up to 4 animations each. +// Filled at init by matching literal names from splash_anims[]. +#define GROUP_COUNT 4 +#define GROUP_MAX 4 +static int8_t group_lists[GROUP_COUNT][GROUP_MAX]; +static uint8_t group_size[GROUP_COUNT] = {0}; +static uint8_t group_rotation[GROUP_COUNT] = {0}; + +static const char* GROUP_NAMES[GROUP_COUNT][GROUP_MAX] = { + // Group 0 — idle / sleepy + { "expression sleep", "idle breathe", "idle blink", "expression wink" }, + // Group 1 — normal pace + { "idle look around", "work think", "work coding", NULL }, + // Group 2 — active + { "dance sway", "expression surprise", "dance bounce", NULL }, + // Group 3 — heavy + { "dance bounce dj", "dance sway dj", "dance djmix", NULL }, +}; + +static void resolve_group_lists(void) { + for (int g = 0; g < GROUP_COUNT; g++) { + group_size[g] = 0; + for (int s = 0; s < GROUP_MAX; s++) { + group_lists[g][s] = -1; + const char* want = GROUP_NAMES[g][s]; + if (!want) continue; + for (int i = 0; i < SPLASH_ANIM_COUNT; i++) { + if (strcmp(splash_anims[i].name, want) == 0) { + group_lists[g][group_size[g]++] = (int8_t)i; + break; + } + } + } + } +} + static void render_frame(const uint8_t *cells, const uint16_t *palette) { for (int gy = 0; gy < GRID; gy++) { uint16_t row[CANVAS_W]; @@ -79,6 +122,8 @@ void splash_init(lv_obj_t *parent) { lv_obj_set_style_text_align(label_status, LV_TEXT_ALIGN_CENTER, 0); lv_obj_center(label_status); + resolve_group_lists(); + if (SPLASH_ANIM_COUNT == 0) { show_placeholder(); } else { @@ -93,6 +138,12 @@ void splash_init(lv_obj_t *parent) { void splash_tick(void) { if (!active || SPLASH_ANIM_COUNT == 0) return; + + // Auto-rotate to the next animation in the current group. + if (millis() - last_pick_ms >= SPLASH_ROTATE_INTERVAL_MS) { + splash_pick_for_current_rate(); + } + const splash_anim_def_t *a = &splash_anims[cur_anim]; if (a->frame_count == 0) return; @@ -109,6 +160,7 @@ void splash_next(void) { cur_anim = (cur_anim + 1) % SPLASH_ANIM_COUNT; cur_frame = 0; frame_started_ms = millis(); + last_pick_ms = frame_started_ms; const splash_anim_def_t *a = &splash_anims[cur_anim]; render_frame(a->frames[0], a->palette); Serial.printf("splash: -> %s\n", a->name); @@ -119,6 +171,7 @@ void splash_prev(void) { cur_anim = (cur_anim + SPLASH_ANIM_COUNT - 1) % SPLASH_ANIM_COUNT; cur_frame = 0; frame_started_ms = millis(); + last_pick_ms = frame_started_ms; const splash_anim_def_t *a = &splash_anims[cur_anim]; render_frame(a->frames[0], a->palette); Serial.printf("splash: <- %s\n", a->name); @@ -126,7 +179,29 @@ void splash_prev(void) { void splash_set_active(bool a) { active = a; } +void splash_pick_for_current_rate(void) { + if (SPLASH_ANIM_COUNT == 0) return; + int g = usage_rate_group(); + if (g < 0 || g >= GROUP_COUNT) g = 0; + if (group_size[g] == 0) return; + + uint8_t slot = group_rotation[g] % group_size[g]; + group_rotation[g]++; + int8_t idx = group_lists[g][slot]; + if (idx < 0) return; + + cur_anim = (uint16_t)idx; + cur_frame = 0; + frame_started_ms = millis(); + last_pick_ms = frame_started_ms; + const splash_anim_def_t *a = &splash_anims[cur_anim]; + render_frame(a->frames[0], a->palette); +} + +bool splash_is_active(void) { return active; } + void splash_show(void) { + splash_pick_for_current_rate(); if (splash_container) lv_obj_clear_flag(splash_container, LV_OBJ_FLAG_HIDDEN); active = true; } diff --git a/firmware/src/splash.h b/firmware/src/splash.h index 1612f92..9918290 100644 --- a/firmware/src/splash.h +++ b/firmware/src/splash.h @@ -22,6 +22,14 @@ void splash_set_active(bool active); void splash_show(void); void splash_hide(void); +// Pick the next animation matching the current usage-rate group. +// Called automatically by splash_show(); also exposed so other modules can +// trigger a re-pick when the rate group changes mid-display. +void splash_pick_for_current_rate(void); + +// True when splash is currently rendering (used to gate re-picks). +bool splash_is_active(void); + // Root container (so ui.cpp can attach a click event). lv_obj_t* splash_get_root(void); diff --git a/firmware/src/usage_rate.cpp b/firmware/src/usage_rate.cpp new file mode 100644 index 0000000..787b423 --- /dev/null +++ b/firmware/src/usage_rate.cpp @@ -0,0 +1,73 @@ +#include "usage_rate.h" +#include + +// Thresholds in %/min. A 5-hour (300 min) session ÷ 100% = 0.33 %/min to fill +// exactly at the same pace as the session itself resets — the user wants the +// "heavy" tier to start right there (filling in 4–5 hours). +// < 0.10 → Idle (17h+ to fill, basically dormant) +// < 0.20 → Normal (8–17h to fill, slow steady use) +// < 0.33 → Active (5–8h, heavy but not yet pace-matching) +// >=0.33 → Heavy (≤5h, matching or beating the session reset) +#define RATE_THRESH_NORMAL 0.10f +#define RATE_THRESH_ACTIVE 0.20f +#define RATE_THRESH_HEAVY 0.33f + +// Minimum span between oldest and newest sample before we trust the computed +// rate. The whole point of the ring buffer is to smooth out single-sample +// jitter — at 60s daemon polling, a 1% bump between two consecutive samples +// looks like 1 %/min (Heavy) but really just means you grew 1% in the last +// minute. We require ~4 min of accumulated history so the rate reflects a +// real trend, not one noisy delta. Side-effect: ~4 min warm-up after boot +// during which we report Idle. +#define MIN_WINDOW_MS 240000UL + +#define RING_SIZE 6 + +struct Sample { uint32_t ms; float pct; }; + +static Sample ring[RING_SIZE]; +static uint8_t count = 0; +static uint8_t head = 0; // index of next write slot + +static inline uint8_t oldest_idx(void) { + return (head + RING_SIZE - count) % RING_SIZE; +} + +void usage_rate_reset(void) { + count = 0; + head = 0; +} + +void usage_rate_sample(float session_pct) { + uint32_t now = millis(); + + if (count > 0) { + uint8_t latest = (head + RING_SIZE - 1) % RING_SIZE; + // Session reset: pct dropped substantially. Restart tracking. + if (session_pct + 5.0f < ring[latest].pct) { + usage_rate_reset(); + } + } + + ring[head] = { now, session_pct }; + head = (head + 1) % RING_SIZE; + if (count < RING_SIZE) count++; +} + +int usage_rate_group(void) { + if (count < 2) return 0; + + uint8_t o = oldest_idx(); + uint8_t l = (head + RING_SIZE - 1) % RING_SIZE; + uint32_t dt = ring[l].ms - ring[o].ms; + if (dt < MIN_WINDOW_MS) return 0; + + float dp = ring[l].pct - ring[o].pct; + if (dp < 0.0f) dp = 0.0f; + float rate = dp * 60000.0f / (float)dt; + + if (rate < RATE_THRESH_NORMAL) return 0; + if (rate < RATE_THRESH_ACTIVE) return 1; + if (rate < RATE_THRESH_HEAVY) return 2; + return 3; +} diff --git a/firmware/src/usage_rate.h b/firmware/src/usage_rate.h new file mode 100644 index 0000000..d1fcad4 --- /dev/null +++ b/firmware/src/usage_rate.h @@ -0,0 +1,15 @@ +#pragma once + +// Tracks short-term rate of change in session_pct (%/min) so the UI can react +// to *how heavily* Claude is being used right now, not just the current bucket +// level. Returns one of 4 group indices for the splash to pick animations from. + +// Feed in the latest session percentage every time fresh BLE data arrives. +void usage_rate_sample(float session_pct); + +// 0 = idle, 1 = normal, 2 = active, 3 = heavy. +// Defaults to 0 when the buffer doesn't have enough samples yet. +int usage_rate_group(void); + +// Clear the buffer (e.g. when the 5-hour session window resets). +void usage_rate_reset(void);