v2 firmware: HA command channel, battery screen, dynamic Home buttons, tilt-dimmer

Watch-side features developed on the AMOLED-2.06 and shared across all boards:

- BLE command channel (…0005, notify watch→PC) via ble_send_command(); the
  watch stays "dumb" and the daemon maps commands to Home Assistant.
- Battery detail screen + protective low-voltage cutoff; power HAL gains
  battery_mv() / shutdown(), with battery_est.{h,cpp} for the time-left anchor.
- Home screen: dynamic 2-column button grid driven by the desktop config
  (RX "btns" labels); tap notifies {"cmd":"btn","i":N}.
- Dimmer screen: IMU tilt-joystick over the light's brightness / color temp.
  imu_hal_read_accel() added to the HAL (real on 2.06/2.16, no-op elsewhere);
  streams absolute {"cmd":"bri"|"ct","v":..}; daemon seeds the dial via "dim".
  Per-board tilt axis is calibrated with the DIM_CALIB overlay (off by default).

Multi-board fix: enable LV_FONT_MONTSERRAT_20/28 on the 2.16, 1.8 and C6 envs.
Shared ui.cpp uses these glyphs for the launcher tiles and back chevron, so
those three boards had failed to compile since the Phase-2 UI landed. All four
envs now build clean. README: correct the Windows pairing name to "Clawdmeter".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wenil
2026-07-09 20:20:04 +03:00
co-authored by Claude Opus 4.8
parent a6d391e9d1
commit 578bc04248
21 changed files with 957 additions and 74 deletions
+129 -13
View File
@@ -10,6 +10,7 @@
#include "version.h"
#include "splash.h"
#include "usage_rate.h"
#include "battery_est.h"
#include "idle.h"
#include "idle_cfg.h"
#include "brightness.h"
@@ -23,6 +24,21 @@
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
@@ -120,6 +136,36 @@ static bool parse_json(const char* json, UsageData* out) {
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);
}
return true;
}
@@ -227,7 +273,7 @@ void setup() {
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_is_charging());
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",
@@ -307,14 +353,22 @@ void loop() {
{
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 ble_keyboard_press(0x2C, 0); // HID Space, no mods
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 ble_keyboard_release();
else if (primary_was_dimmer) primary_was_dimmer = false;
else ble_keyboard_release();
}
primary_was = primary_now;
}
@@ -337,30 +391,92 @@ void loop() {
if (power_hal_pwr_pressed()) {
if (!idle_consume_wake_press()) {
// On splash: cycle animations. On the usage view: cycle
// screen brightness (single non-splash view, no more screens).
if (ui_get_current_screen() == SCREEN_SPLASH) splash_next();
else brightness_cycle();
// 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());
}
static int last_pct = -2;
static bool last_charging = false;
// ---- 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();
if (pct != last_pct || charging != last_charging) {
last_pct = pct;
last_charging = charging;
ui_update_battery(pct, 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();