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.
This commit is contained in:
Hermann Björgvin Haraldsson
2026-05-11 02:05:38 +00:00
parent 12172d12bf
commit 7ed4710d28
5 changed files with 180 additions and 0 deletions
+9
View File
@@ -8,6 +8,7 @@
#include "power.h" #include "power.h"
#include "imu.h" #include "imu.h"
#include "splash.h" #include "splash.h"
#include "usage_rate.h"
// Physical buttons (global, screen-independent): // Physical buttons (global, screen-independent):
// BTN_BACK (GPIO 0) — left, send Space (Claude Code voice mode push-to-talk) // BTN_BACK (GPIO 0) — left, send Space (Claude Code voice mode push-to-talk)
@@ -402,6 +403,14 @@ void loop() {
// Process incoming BLE data // Process incoming BLE data
if (ble_has_data()) { if (ble_has_data()) {
if (parse_json(ble_get_data(), &usage)) { 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); ui_update(&usage);
ble_send_ack(); ble_send_ack();
} else { } else {
+75
View File
@@ -1,7 +1,9 @@
#include "splash.h" #include "splash.h"
#include "splash_animations.h" #include "splash_animations.h"
#include "theme.h" #include "theme.h"
#include "usage_rate.h"
#include <Arduino.h> #include <Arduino.h>
#include <string.h>
#include <esp_heap_caps.h> #include <esp_heap_caps.h>
// 20x20 grid scaled 24x to fill 480x480 // 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_anim = 0;
static uint16_t cur_frame = 0; static uint16_t cur_frame = 0;
static uint32_t frame_started_ms = 0; static uint32_t frame_started_ms = 0;
static uint32_t last_pick_ms = 0;
static bool active = false; 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) { static void render_frame(const uint8_t *cells, const uint16_t *palette) {
for (int gy = 0; gy < GRID; gy++) { for (int gy = 0; gy < GRID; gy++) {
uint16_t row[CANVAS_W]; 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_set_style_text_align(label_status, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_center(label_status); lv_obj_center(label_status);
resolve_group_lists();
if (SPLASH_ANIM_COUNT == 0) { if (SPLASH_ANIM_COUNT == 0) {
show_placeholder(); show_placeholder();
} else { } else {
@@ -93,6 +138,12 @@ void splash_init(lv_obj_t *parent) {
void splash_tick(void) { void splash_tick(void) {
if (!active || SPLASH_ANIM_COUNT == 0) return; 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]; const splash_anim_def_t *a = &splash_anims[cur_anim];
if (a->frame_count == 0) return; if (a->frame_count == 0) return;
@@ -109,6 +160,7 @@ void splash_next(void) {
cur_anim = (cur_anim + 1) % SPLASH_ANIM_COUNT; cur_anim = (cur_anim + 1) % SPLASH_ANIM_COUNT;
cur_frame = 0; cur_frame = 0;
frame_started_ms = millis(); frame_started_ms = millis();
last_pick_ms = frame_started_ms;
const splash_anim_def_t *a = &splash_anims[cur_anim]; const splash_anim_def_t *a = &splash_anims[cur_anim];
render_frame(a->frames[0], a->palette); render_frame(a->frames[0], a->palette);
Serial.printf("splash: -> %s\n", a->name); 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_anim = (cur_anim + SPLASH_ANIM_COUNT - 1) % SPLASH_ANIM_COUNT;
cur_frame = 0; cur_frame = 0;
frame_started_ms = millis(); frame_started_ms = millis();
last_pick_ms = frame_started_ms;
const splash_anim_def_t *a = &splash_anims[cur_anim]; const splash_anim_def_t *a = &splash_anims[cur_anim];
render_frame(a->frames[0], a->palette); render_frame(a->frames[0], a->palette);
Serial.printf("splash: <- %s\n", a->name); 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_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) { void splash_show(void) {
splash_pick_for_current_rate();
if (splash_container) lv_obj_clear_flag(splash_container, LV_OBJ_FLAG_HIDDEN); if (splash_container) lv_obj_clear_flag(splash_container, LV_OBJ_FLAG_HIDDEN);
active = true; active = true;
} }
+8
View File
@@ -22,6 +22,14 @@ void splash_set_active(bool active);
void splash_show(void); void splash_show(void);
void splash_hide(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). // Root container (so ui.cpp can attach a click event).
lv_obj_t* splash_get_root(void); lv_obj_t* splash_get_root(void);
+73
View File
@@ -0,0 +1,73 @@
#include "usage_rate.h"
#include <Arduino.h>
// 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 45 hours).
// < 0.10 → Idle (17h+ to fill, basically dormant)
// < 0.20 → Normal (817h to fill, slow steady use)
// < 0.33 → Active (58h, 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;
}
+15
View File
@@ -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);