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:
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user