New SCREEN_PROVIDER (Menu → "Provider"): shows the active provider's brand name
in the accent colour (recolors live on a theme switch via the shared accent
style) and its "i / c" position among the enabled providers. A Switch button
notifies {"cmd":"provnext"}; the daemon owns the enabled set/order and cycles to
the next one, so the watch stays dumb. When only one provider is enabled the
button is replaced by a hint.
main.cpp parses the optional pnm/pi/pc payload fields into ui_set_provider_badge.
Builds green on all four boards (206/216/18/216_c6); verified on 2.06 hardware
(OpenAI green theme + "2 / 3" + Switch).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
517 lines
20 KiB
C++
517 lines
20 KiB
C++
#include <Arduino.h>
|
||
#include <Wire.h>
|
||
#include <lvgl.h>
|
||
#include <ArduinoJson.h>
|
||
#include <esp_heap_caps.h>
|
||
|
||
#include "data.h"
|
||
#include "ui.h"
|
||
#include "ble.h"
|
||
#include "version.h"
|
||
#include "splash.h"
|
||
#include "usage_rate.h"
|
||
#include "battery_est.h"
|
||
#include "idle.h"
|
||
#include "idle_cfg.h"
|
||
#include "brightness.h"
|
||
|
||
#include "hal/board_caps.h"
|
||
#include "hal/display_hal.h"
|
||
#include "hal/touch_hal.h"
|
||
#include "hal/input_hal.h"
|
||
#include "hal/power_hal.h"
|
||
#include "hal/imu_hal.h"
|
||
|
||
static UsageData usage = {};
|
||
|
||
// ---- Low-voltage protective cutoff ----
|
||
// Below LOW_V_CUTOFF_MV on battery power (USB absent), the firmware warns on
|
||
// screen for LOW_V_WARN_MS then powers the PMU fully off, so the cell isn't
|
||
// driven into deep over-discharge. The reading must stay below for
|
||
// LOW_V_SUSTAIN_MS first: the cell voltage sags under the BLE + AMOLED load, so
|
||
// a momentary dip must not trigger a shutdown.
|
||
//
|
||
// 3000 mV (3.0 V) is a gentle Li-ion floor — well clear of the deep
|
||
// over-discharge zone (~2.5–2.8 V). Mirrored as the "Auto-off below 3.0 V"
|
||
// note on the battery screen (ui.cpp). Raise toward 3300 mV to be even kinder
|
||
// to the cell, or down to 2800 to squeeze out the last drops.
|
||
#define LOW_V_CUTOFF_MV 3000
|
||
#define LOW_V_SUSTAIN_MS 6000UL
|
||
#define LOW_V_WARN_MS 5000UL
|
||
|
||
// ---- LVGL draw buffers (partial render mode) ----
|
||
// PSRAM-equipped boards (S3) can comfortably hold larger strips. PSRAM-free
|
||
// boards (e.g. ESP32-C6) allocate from internal SRAM, so we shrink the strip
|
||
// — 480×20 RGB565 = 19 KB × 2 buffers = 38 KB, fits beside everything else.
|
||
#ifdef BOARD_HAS_PSRAM
|
||
#define BUF_LINES 40
|
||
#define LV_BUF_CAPS (MALLOC_CAP_SPIRAM)
|
||
#else
|
||
#define BUF_LINES 20
|
||
#define LV_BUF_CAPS (MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)
|
||
#endif
|
||
static uint16_t* buf1 = nullptr;
|
||
static uint16_t* buf2 = nullptr;
|
||
|
||
static uint32_t my_tick(void) { return millis(); }
|
||
|
||
static void my_flush_cb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) {
|
||
int32_t w = area->x2 - area->x1 + 1;
|
||
int32_t h = area->y2 - area->y1 + 1;
|
||
display_hal_draw_bitmap(area->x1, area->y1, w, h, (uint16_t*)px_map);
|
||
lv_display_flush_ready(disp);
|
||
}
|
||
|
||
static void rounder_cb(lv_event_t* e) {
|
||
lv_area_t* area = (lv_area_t*)lv_event_get_param(e);
|
||
display_hal_round_area(&area->x1, &area->y1, &area->x2, &area->y2);
|
||
}
|
||
|
||
// Touch policy is driven by IDLE_WAKE_ON_TOUCH:
|
||
// true → a press edge while asleep wakes the device and the first touch is
|
||
// swallowed (mirrors the button wake-consumption); a press while
|
||
// awake counts as activity.
|
||
// false → touch never counts as activity and is fully swallowed while the
|
||
// panel is dark, so pets/sleeves can't wake it overnight and LVGL
|
||
// can't quietly toggle splash<->usage on a black panel.
|
||
static void my_touch_cb(lv_indev_t* indev, lv_indev_data_t* data) {
|
||
uint16_t x, y;
|
||
bool pressed;
|
||
touch_hal_read(&x, &y, &pressed);
|
||
const bool raw_pressed = pressed;
|
||
|
||
if (IDLE_WAKE_ON_TOUCH) {
|
||
static bool touch_was = false;
|
||
static bool touch_wake_swallowed = false;
|
||
if (raw_pressed && !touch_was) {
|
||
// Press edge — consume as wake if asleep.
|
||
if (idle_consume_wake_press()) {
|
||
touch_wake_swallowed = true;
|
||
pressed = false;
|
||
}
|
||
} else if (!raw_pressed && touch_was) {
|
||
// Release edge.
|
||
if (touch_wake_swallowed) {
|
||
touch_wake_swallowed = false;
|
||
pressed = false;
|
||
}
|
||
} else if (raw_pressed && touch_wake_swallowed) {
|
||
// Held finger through wake — keep hiding until release.
|
||
pressed = false;
|
||
}
|
||
touch_was = raw_pressed;
|
||
} else if (idle_is_asleep()) {
|
||
pressed = false;
|
||
}
|
||
|
||
if (pressed) {
|
||
data->point.x = x;
|
||
data->point.y = y;
|
||
data->state = LV_INDEV_STATE_PRESSED;
|
||
} else {
|
||
data->state = LV_INDEV_STATE_RELEASED;
|
||
}
|
||
}
|
||
|
||
// Parse a JSON line into UsageData.
|
||
static bool parse_json(const char* json, UsageData* out) {
|
||
JsonDocument doc;
|
||
DeserializationError err = deserializeJson(doc, json);
|
||
if (err) {
|
||
Serial.printf("JSON parse error: %s\n", err.c_str());
|
||
return false;
|
||
}
|
||
|
||
out->session_pct = doc["s"] | 0.0f;
|
||
out->session_reset_mins = doc["sr"] | -1;
|
||
out->weekly_pct = doc["w"] | 0.0f;
|
||
out->weekly_reset_mins = doc["wr"] | -1;
|
||
strlcpy(out->status, doc["st"] | "unknown", sizeof(out->status));
|
||
out->ok = doc["ok"] | false;
|
||
out->tokens_today = doc["tk"] | (long long)0;
|
||
out->output_today = doc["to"] | (long long)0;
|
||
out->cost_cents_today = doc["tc"] | 0;
|
||
out->messages_today = doc["tn"] | 0;
|
||
out->np_state = doc["np"] | 0;
|
||
strlcpy(out->np_title, doc["nt"] | "", sizeof(out->np_title));
|
||
strlcpy(out->np_artist, doc["na"] | "", sizeof(out->np_artist));
|
||
out->valid = true;
|
||
|
||
// Dynamic Home buttons (Phase 7 M2) — optional "btns" array of labels pushed
|
||
// by the daemon from the desktop config (only in the ~60s heartbeat write).
|
||
// Absent (e.g. the frequent now-playing-only writes) => leave the current
|
||
// buttons untouched; present => replace the whole set. The label strings live
|
||
// in `doc`, which is valid until this function returns, and ui_set_buttons
|
||
// copies them immediately.
|
||
JsonArray btns = doc["btns"].as<JsonArray>();
|
||
if (!btns.isNull()) {
|
||
const char* labels[UI_MAX_BUTTONS];
|
||
int n = 0;
|
||
for (JsonVariant v : btns) {
|
||
if (n >= UI_MAX_BUTTONS) break;
|
||
labels[n++] = v.as<const char*>();
|
||
}
|
||
ui_set_buttons(labels, n);
|
||
}
|
||
|
||
// Tilt-dimmer snapshot (Phase 6 step 3 / M3) — optional "dim" object carrying
|
||
// the controlled light's live state so the dial seeds from reality. Pushed on
|
||
// the ~60s heartbeat and on demand when the watch opens the Dimmer screen.
|
||
JsonObject dim = doc["dim"].as<JsonObject>();
|
||
if (!dim.isNull()) {
|
||
bool on = dim["on"] | 0;
|
||
int bri = dim["bri"] | -1; // -1 = unknown (light off)
|
||
int ct = dim["ct"] | -1;
|
||
int mink = dim["mink"] | 2000;
|
||
int maxk = dim["maxk"] | 6500;
|
||
ui_dimmer_set_snapshot(on, bri, ct, mink, maxk);
|
||
}
|
||
|
||
// v3: provider theme — "pv" (id) selects the logo, "ac" (0xRRGGBB) the brand
|
||
// accent. Stamped on every usage payload; ui_set_theme is change-guarded so
|
||
// it only repaints on an actual provider switch.
|
||
const char* pv = doc["pv"] | (const char*)nullptr;
|
||
uint32_t ac = doc["ac"] | 0u;
|
||
if (pv != nullptr || ac != 0u) ui_set_theme(pv, ac);
|
||
|
||
// v3 Provider screen badge — optional "pnm" (name) + "pi"/"pc" (1-based
|
||
// position / count among enabled providers). Present on the ~60s heartbeat.
|
||
const char* pnm = doc["pnm"] | (const char*)nullptr;
|
||
int pi = doc["pi"] | 0;
|
||
int pc = doc["pc"] | 0;
|
||
if (pnm != nullptr || pc != 0) ui_set_provider_badge(pnm, pi, pc);
|
||
return true;
|
||
}
|
||
|
||
// ---- Serial command buffer ----
|
||
#define CMD_BUF_SIZE 64
|
||
static char cmd_buf[CMD_BUF_SIZE];
|
||
static int cmd_pos = 0;
|
||
|
||
static void send_screenshot() {
|
||
#ifndef BOARD_HAS_PSRAM
|
||
// A full RGB565 framebuffer doesn't fit in internal SRAM on PSRAM-free
|
||
// boards (e.g. 480×480×2 = 460 KB). Capture is unsupported there.
|
||
Serial.println("SCREENSHOT_UNSUPPORTED");
|
||
return;
|
||
#else
|
||
const uint32_t w = board_caps().width;
|
||
const uint32_t h = board_caps().height;
|
||
const uint32_t row_bytes = w * 2;
|
||
const uint32_t buf_size = row_bytes * h;
|
||
uint8_t* sbuf = (uint8_t*)heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM);
|
||
if (!sbuf) {
|
||
Serial.println("SCREENSHOT_ERR");
|
||
return;
|
||
}
|
||
|
||
lv_draw_buf_t draw_buf;
|
||
lv_draw_buf_init(&draw_buf, w, h, LV_COLOR_FORMAT_RGB565, row_bytes, sbuf, buf_size);
|
||
|
||
lv_result_t res = lv_snapshot_take_to_draw_buf(lv_screen_active(), LV_COLOR_FORMAT_RGB565, &draw_buf);
|
||
if (res != LV_RESULT_OK) {
|
||
heap_caps_free(sbuf);
|
||
Serial.println("SCREENSHOT_ERR");
|
||
return;
|
||
}
|
||
|
||
Serial.printf("SCREENSHOT_START %lu %lu %lu\n",
|
||
(unsigned long)w, (unsigned long)h, (unsigned long)buf_size);
|
||
Serial.flush();
|
||
Serial.write(sbuf, buf_size);
|
||
Serial.flush();
|
||
Serial.println();
|
||
Serial.println("SCREENSHOT_END");
|
||
heap_caps_free(sbuf);
|
||
#endif
|
||
}
|
||
|
||
static void check_serial_cmd() {
|
||
while (Serial.available()) {
|
||
char c = Serial.read();
|
||
if (c == '\n' || c == '\r') {
|
||
cmd_buf[cmd_pos] = '\0';
|
||
if (strcmp(cmd_buf, "screenshot") == 0) send_screenshot();
|
||
cmd_pos = 0;
|
||
} else if (cmd_pos < CMD_BUF_SIZE - 1) {
|
||
cmd_buf[cmd_pos++] = c;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Each board provides this. Must bring up the shared I2C bus (Wire.begin
|
||
// with the board's SDA/SCL pins) and any board-private hardware that has
|
||
// to settle before display/touch (e.g. an IO expander gating the LCD
|
||
// reset line). Called exactly once at the start of setup().
|
||
extern "C" void board_init(void);
|
||
|
||
void setup() {
|
||
Serial.begin(115200);
|
||
delay(300);
|
||
Serial.printf("{\"ready\":true,\"fw\":\"%s\"}\n", CLAWDMETER_VERSION);
|
||
|
||
board_init();
|
||
|
||
display_hal_init();
|
||
display_hal_begin();
|
||
idle_init(); // takes over panel brightness and starts the idle timer
|
||
brightness_init(); // load the user's saved brightness level and apply via idle
|
||
|
||
power_hal_init();
|
||
imu_hal_init();
|
||
touch_hal_init();
|
||
|
||
// ---- LVGL ----
|
||
const int W = board_caps().width;
|
||
const int H = board_caps().height;
|
||
|
||
lv_init();
|
||
lv_tick_set_cb(my_tick);
|
||
|
||
buf1 = (uint16_t*)heap_caps_malloc(W * BUF_LINES * 2, LV_BUF_CAPS);
|
||
buf2 = (uint16_t*)heap_caps_malloc(W * BUF_LINES * 2, LV_BUF_CAPS);
|
||
|
||
lv_display_t* disp = lv_display_create(W, H);
|
||
lv_display_set_color_format(disp, LV_COLOR_FORMAT_RGB565);
|
||
lv_display_set_flush_cb(disp, my_flush_cb);
|
||
lv_display_set_buffers(disp, buf1, buf2, W * BUF_LINES * 2,
|
||
LV_DISPLAY_RENDER_MODE_PARTIAL);
|
||
lv_display_add_event_cb(disp, rounder_cb, LV_EVENT_INVALIDATE_AREA, NULL);
|
||
|
||
lv_indev_t* indev = lv_indev_create();
|
||
lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
|
||
lv_indev_set_read_cb(indev, my_touch_cb);
|
||
|
||
ble_init();
|
||
input_hal_init();
|
||
|
||
ui_init();
|
||
ui_update_ble_status(ble_get_state(), ble_get_device_name(), ble_get_mac_address());
|
||
ui_update_battery(power_hal_battery_pct(), power_hal_battery_mv(), -1, power_hal_is_charging());
|
||
ui_show_screen(SCREEN_SPLASH);
|
||
|
||
Serial.printf("Dashboard ready (%s, %dx%d), waiting for data on BLE...\n",
|
||
board_caps().name, W, H);
|
||
}
|
||
|
||
static ble_state_t last_ble_state = BLE_STATE_INIT;
|
||
|
||
// Hold-to-pair gesture: hold the PWR button ~3s, then RELEASE → clear all BLE
|
||
// bonds and re-advertise. Clearing on *release* (not while held) is deliberate:
|
||
// holding to power the device OFF (AXP hardware shutdown at 8s) must not wipe
|
||
// the bond — a power-off hold never releases before shutdown. To stop a
|
||
// "chicken-out" release just before 8s from pairing, the gesture disarms at 6s.
|
||
//
|
||
// ~1.5s long-press edge → PENDING
|
||
// 3.0s (+1500) → ARMED (release from here clears bonds)
|
||
// 6.0s (+4500) → DISARMED (no clear; AXP powers off at 8s)
|
||
#define PAIR_ARM_AFTER_LONG_MS 1500 // 3.0s total
|
||
#define PAIR_DISARM_AFTER_LONG_MS 4500 // 6.0s total
|
||
enum pair_state_t { PAIR_IDLE, PAIR_PENDING, PAIR_ARMED };
|
||
static pair_state_t pair_state = PAIR_IDLE;
|
||
static uint32_t pair_long_seen_ms = 0;
|
||
|
||
static void pair_tick(void) {
|
||
if (pair_state == PAIR_IDLE && power_hal_pwr_long_pressed()) {
|
||
pair_state = PAIR_PENDING;
|
||
pair_long_seen_ms = millis();
|
||
(void)power_hal_pwr_released(); // drain any stale release edge
|
||
Serial.println("PWR long-press: hold to ~3s then release to pair");
|
||
return;
|
||
}
|
||
if (pair_state == PAIR_IDLE) return;
|
||
|
||
if (power_hal_pwr_released()) {
|
||
if (pair_state == PAIR_ARMED) {
|
||
Serial.println("Pair: released in window — clearing bonds, advertising");
|
||
ble_clear_bonds();
|
||
} else {
|
||
Serial.println("Pair: released too early — cancelled");
|
||
}
|
||
pair_state = PAIR_IDLE;
|
||
return;
|
||
}
|
||
|
||
uint32_t held = millis() - pair_long_seen_ms;
|
||
if (pair_state == PAIR_PENDING && held >= PAIR_ARM_AFTER_LONG_MS) {
|
||
pair_state = PAIR_ARMED;
|
||
Serial.println("Pair: armed — release to pair");
|
||
} else if (pair_state == PAIR_ARMED && held >= PAIR_DISARM_AFTER_LONG_MS) {
|
||
pair_state = PAIR_IDLE; // power-off territory; don't pair
|
||
Serial.println("Pair: disarmed (holding toward power-off)");
|
||
}
|
||
}
|
||
|
||
void loop() {
|
||
idle_tick();
|
||
lv_timer_handler();
|
||
ui_tick_anim();
|
||
ble_tick();
|
||
power_hal_tick();
|
||
imu_hal_tick();
|
||
splash_tick();
|
||
// Rotation transition (blank + ramp) would fight the idle fade — skip
|
||
// ticks while the panel is dark. A rotation that happens during sleep
|
||
// is detected by the next tick after wake and ramped in then.
|
||
if (!idle_is_asleep()) display_hal_tick();
|
||
|
||
// ---- Physical buttons ----
|
||
// PRIMARY → HID Space (Claude Code voice-mode PTT)
|
||
// SECONDARY → HID Shift+Tab (mode toggle; only if the board has one)
|
||
// PWR → on splash: cycle animations; on usage: cycle brightness;
|
||
// hold ~3s + release: pairing mode
|
||
// First press from sleep is consumed as a wake-only event by
|
||
// idle_consume_wake_press(); the normal action fires from the second
|
||
// press. Activity bookkeeping happens inside idle_consume_wake_press
|
||
// so no separate idle_note_activity() call is needed here.
|
||
{
|
||
static bool primary_was = false;
|
||
static bool primary_wake_swallowed = false;
|
||
static bool primary_was_dimmer = false; // press consumed by the dimmer (no HID)
|
||
bool primary_now = input_hal_is_held(INPUT_BTN_PRIMARY);
|
||
if (primary_now != primary_was) {
|
||
if (primary_now) {
|
||
if (idle_consume_wake_press()) primary_wake_swallowed = true;
|
||
else if (ui_get_current_screen() == SCREEN_DIMMER) {
|
||
ui_dimmer_arm(); // BOOT arms the tilt-dimmer (no HID here)
|
||
primary_was_dimmer = true;
|
||
} else {
|
||
ble_keyboard_press(0x2C, 0); // HID Space, no mods
|
||
primary_was_dimmer = false;
|
||
}
|
||
} else {
|
||
if (primary_wake_swallowed) primary_wake_swallowed = false;
|
||
else if (primary_was_dimmer) primary_was_dimmer = false;
|
||
else ble_keyboard_release();
|
||
}
|
||
primary_was = primary_now;
|
||
}
|
||
|
||
if (board_caps().button_count >= 2) {
|
||
static bool secondary_was = false;
|
||
static bool secondary_wake_swallowed = false;
|
||
bool secondary_now = input_hal_is_held(INPUT_BTN_SECONDARY);
|
||
if (secondary_now != secondary_was) {
|
||
if (secondary_now) {
|
||
if (idle_consume_wake_press()) secondary_wake_swallowed = true;
|
||
else ble_keyboard_press(0x2B, 0x02); // HID Tab + LEFT_SHIFT
|
||
} else {
|
||
if (secondary_wake_swallowed) secondary_wake_swallowed = false;
|
||
else ble_keyboard_release();
|
||
}
|
||
secondary_was = secondary_now;
|
||
}
|
||
}
|
||
|
||
if (power_hal_pwr_pressed()) {
|
||
if (!idle_consume_wake_press()) {
|
||
// On splash: cycle animations. On the dimmer: switch the
|
||
// controlled parameter (brightness ⇄ temp). Elsewhere: cycle
|
||
// screen brightness.
|
||
screen_t cs = ui_get_current_screen();
|
||
if (cs == SCREEN_SPLASH) splash_next();
|
||
else if (cs == SCREEN_DIMMER) ui_dimmer_switch_param();
|
||
else brightness_cycle();
|
||
}
|
||
}
|
||
|
||
pair_tick();
|
||
}
|
||
|
||
// Tilt-dimmer control loop (no-op unless the Dimmer screen is armed). Keep
|
||
// the panel awake while adjusting so the idle timer can't sleep mid-tilt.
|
||
ui_dimmer_tick();
|
||
if (ui_dimmer_is_armed()) idle_note_activity();
|
||
|
||
ble_state_t bs = ble_get_state();
|
||
if (bs != last_ble_state) {
|
||
last_ble_state = bs;
|
||
ui_update_ble_status(bs, ble_get_device_name(), ble_get_mac_address());
|
||
}
|
||
|
||
// ---- Battery telemetry → UI + time-left estimate ----
|
||
static int last_pct = -2;
|
||
static int last_mv_bucket = -2;
|
||
static bool last_charging = false;
|
||
static uint32_t last_bat_ui_ms = 0;
|
||
int pct = power_hal_battery_pct();
|
||
int mv = power_hal_battery_mv();
|
||
bool charging = power_hal_is_charging();
|
||
int mv_bucket = (mv <= 0) ? -1 : (mv / 20); // 20 mV buckets — ignore sub-bucket jitter
|
||
uint32_t now_ms = millis();
|
||
bool bat_changed = (pct != last_pct) || (charging != last_charging) || (mv_bucket != last_mv_bucket);
|
||
bool bat_periodic = (now_ms - last_bat_ui_ms >= 2000); // keep "time left" counting down live
|
||
if (bat_changed || bat_periodic) {
|
||
last_pct = pct;
|
||
last_mv_bucket = mv_bucket;
|
||
last_charging = charging;
|
||
last_bat_ui_ms = now_ms;
|
||
battery_est_update(pct, charging);
|
||
ui_update_battery(pct, mv, battery_est_minutes(), charging);
|
||
}
|
||
|
||
// Push battery state to the host (~60s, plus on charge-state flip) so the PC
|
||
// app can warn on low charge. Reuses the watch→PC command char (…0005), so no
|
||
// new GATT characteristic / re-pair. ble_send_command no-ops if disconnected.
|
||
{
|
||
static uint32_t last_bat_tx_ms = 0;
|
||
static bool last_tx_charging = false;
|
||
if (mv > 0 && (now_ms - last_bat_tx_ms >= 60000 || charging != last_tx_charging)) {
|
||
last_bat_tx_ms = now_ms;
|
||
last_tx_charging = charging;
|
||
char bm[64];
|
||
snprintf(bm, sizeof bm, "{\"bat\":%d,\"mv\":%d,\"chg\":%d}",
|
||
pct, mv, charging ? 1 : 0);
|
||
ble_send_command(bm);
|
||
}
|
||
}
|
||
|
||
// ---- Low-voltage protective cutoff ----
|
||
// Sustained below the floor on battery power → warn, then power fully off.
|
||
{
|
||
static uint32_t low_v_since = 0;
|
||
bool batt_present = (mv > 0); // 0 == no battery / not measurable
|
||
if (batt_present && !power_hal_is_vbus_in() && mv < LOW_V_CUTOFF_MV) {
|
||
if (low_v_since == 0) low_v_since = now_ms;
|
||
if (now_ms - low_v_since >= LOW_V_SUSTAIN_MS) {
|
||
Serial.printf("Low battery %d mV < %d mV - protective shutdown\n", mv, LOW_V_CUTOFF_MV);
|
||
idle_note_activity(); // wake the panel so the warning is visible
|
||
ui_show_low_battery();
|
||
uint32_t t0 = millis();
|
||
while (millis() - t0 < LOW_V_WARN_MS) { // pump LVGL so the warning paints + fades in
|
||
idle_tick(); // drive the wake-from-sleep brightness ramp
|
||
lv_timer_handler();
|
||
if (!idle_is_asleep()) display_hal_tick();
|
||
delay(20);
|
||
}
|
||
power_hal_shutdown();
|
||
delay(3000); // let the rail collapse
|
||
low_v_since = 0; // bench-supply fallback: don't spin if still powered
|
||
}
|
||
} else {
|
||
low_v_since = 0;
|
||
}
|
||
}
|
||
|
||
check_serial_cmd();
|
||
|
||
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 {
|
||
ble_send_nack();
|
||
}
|
||
}
|
||
|
||
delay(5);
|
||
}
|