Files
clawdmeter/firmware/src/main.cpp
T
wenilandClaude Opus 4.8 a975eb434b v3 M1c (firmware): Provider screen — active source + tap-to-switch
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>
2026-07-10 06:53:55 +03:00

517 lines
20 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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.52.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);
}