From 578bc042482422d72845deef4a37fa7e0feae646 Mon Sep 17 00:00:00 2001 From: wenil Date: Thu, 9 Jul 2026 20:20:04 +0300 Subject: [PATCH] v2 firmware: HA command channel, battery screen, dynamic Home buttons, tilt-dimmer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- firmware/platformio.ini | 15 + firmware/src/battery_est.cpp | 55 ++ firmware/src/battery_est.h | 15 + firmware/src/ble.cpp | 18 + firmware/src/ble.h | 5 + firmware/src/boards/template/imu.cpp | 1 + firmware/src/boards/template/power.cpp | 2 + .../src/boards/waveshare_amoled_18/imu.cpp | 4 + .../src/boards/waveshare_amoled_18/power.cpp | 6 + .../src/boards/waveshare_amoled_206/imu.cpp | 21 +- .../src/boards/waveshare_amoled_206/power.cpp | 6 + .../src/boards/waveshare_amoled_216/imu.cpp | 5 + .../src/boards/waveshare_amoled_216/power.cpp | 6 + .../boards/waveshare_amoled_216_c6/imu.cpp | 4 + .../boards/waveshare_amoled_216_c6/power.cpp | 6 + firmware/src/hal/imu_hal.h | 6 + firmware/src/hal/power_hal.h | 5 + firmware/src/main.cpp | 142 +++- firmware/src/ui.cpp | 674 ++++++++++++++++-- firmware/src/ui.h | 33 +- 21 files changed, 957 insertions(+), 74 deletions(-) create mode 100644 firmware/src/battery_est.cpp create mode 100644 firmware/src/battery_est.h diff --git a/README.md b/README.md index 25a4d25..b7f5c35 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Run `pio run -d firmware` with no env to see the available board envs. ### Pair the device -The device is a bonded BLE HID keyboard, so pair it once: **Settings → Bluetooth & devices → Add device → Bluetooth**, then select "Claude Controller". Pairing is **required** — it enables the physical buttons and keeps a persistent connection (the device keeps showing your last-synced usage even after the daemon quits). To undo, use **Remove device** (this disables the buttons). +The device is a bonded BLE HID keyboard, so pair it once: **Settings → Bluetooth & devices → Add device → Bluetooth**, then select "Clawdmeter". Pairing is **required** — it enables the physical buttons and keeps a persistent connection (the device keeps showing your last-synced usage even after the daemon quits). To undo, use **Remove device** (this disables the buttons). ### Install the daemon (recommended) diff --git a/firmware/platformio.ini b/firmware/platformio.ini index edb3361..a8d2a0a 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -42,6 +42,11 @@ build_flags = -DLV_USE_ANIMIMG=0 -DLV_TICK_CUSTOM=1 -DLV_USE_SNAPSHOT=1 + ; Montserrat carries the LVGL symbol glyphs (FontAwesome subset) used for the + ; v2 launcher tile icons and the back chevron. The brand Styrene/Tiempos fonts + ; are Latin-only and have no symbols. Shared ui.cpp needs these on every board. + -DLV_FONT_MONTSERRAT_20=1 + -DLV_FONT_MONTSERRAT_28=1 lib_deps = moononournation/GFX Library for Arduino@^1.5.6 @@ -99,6 +104,11 @@ build_flags = -DLV_USE_ANIMIMG=0 -DLV_TICK_CUSTOM=1 -DLV_USE_SNAPSHOT=1 + ; Montserrat carries the LVGL symbol glyphs (FontAwesome subset) used for the + ; v2 launcher tile icons and the back chevron. The brand Styrene/Tiempos fonts + ; are Latin-only and have no symbols. Shared ui.cpp needs these on every board. + -DLV_FONT_MONTSERRAT_20=1 + -DLV_FONT_MONTSERRAT_28=1 lib_deps = ; 1.6.4+ ships Arduino_SH8601 in mainline @@ -230,6 +240,11 @@ build_flags = ; 480×480) which doesn't fit in C6 internal SRAM. send_screenshot ; prints SCREENSHOT_UNSUPPORTED on this board. -DLV_USE_SNAPSHOT=0 + ; Montserrat carries the LVGL symbol glyphs (FontAwesome subset) used for the + ; v2 launcher tile icons and the back chevron. The brand Styrene/Tiempos fonts + ; are Latin-only and have no symbols. Shared ui.cpp needs these on every board. + -DLV_FONT_MONTSERRAT_20=1 + -DLV_FONT_MONTSERRAT_28=1 lib_deps = ; this board uses the CO5300 (same controller as the S3 2.16); Arduino_CO5300 diff --git a/firmware/src/battery_est.cpp b/firmware/src/battery_est.cpp new file mode 100644 index 0000000..99f5410 --- /dev/null +++ b/firmware/src/battery_est.cpp @@ -0,0 +1,55 @@ +#include "battery_est.h" +#include + +// Anchor-based, not a ring buffer (cf. usage_rate.cpp): we remember (time, pct) +// at the moment discharge began and project from the average rate since then. +// rate = (anchor_pct - pct_now) / minutes_elapsed +// minutes_left = pct_now / rate +// A long baseline beats a short ring for a signal that only ticks every few +// minutes — the estimate just keeps tightening as the session runs. We withhold +// a number until the cell has dropped a meaningful amount over a few minutes, so +// the first figure isn't built on fuel-gauge settling noise right after boot. +#define EST_MIN_DROP_PCT 3 // need >= 3% drained since the anchor +#define EST_MIN_ELAPSED_MS 300000UL // ...over at least 5 minutes +#define EST_MAX_MINUTES (100 * 60) + +static bool have_anchor = false; +static uint32_t anchor_ms = 0; +static int anchor_pct = -1; +static int last_pct = -1; +static bool last_charging = false; + +void battery_est_update(int percent, bool charging) { + last_charging = charging; + last_pct = percent; + + if (charging || percent < 0) { + // On USB / charging / no reading the estimate is meaningless. Drop the + // anchor so the next discharge starts from a fresh baseline. + have_anchor = false; + return; + } + // Discharging. Start an anchor, or re-anchor if the gauge ticked UP (it + // relaxes upward when load drops) so we never compute a negative rate. + if (!have_anchor || percent > anchor_pct) { + have_anchor = true; + anchor_ms = millis(); + anchor_pct = percent; + } +} + +int battery_est_minutes(void) { + if (last_charging || last_pct < 0) return -2; + if (!have_anchor) return -1; + + int drop = anchor_pct - last_pct; + uint32_t dt = millis() - anchor_ms; + if (drop < EST_MIN_DROP_PCT || dt < EST_MIN_ELAPSED_MS) return -1; + + // Minutes each 1% has been taking, extrapolated across the remaining pct. + float minutes_per_pct = (float)dt / 60000.0f / (float)drop; + float mins = (float)last_pct * minutes_per_pct; + if (mins < 0) return -1; + if (mins > EST_MAX_MINUTES) mins = EST_MAX_MINUTES; + return (int)(mins + 0.5f); +} diff --git a/firmware/src/battery_est.h b/firmware/src/battery_est.h new file mode 100644 index 0000000..e6adc97 --- /dev/null +++ b/firmware/src/battery_est.h @@ -0,0 +1,15 @@ +#pragma once + +// Rough "time left on battery" estimator. Watches the AXP fuel-gauge percentage +// while discharging and projects minutes-to-empty from the average drain rate +// since discharge began. Coarse by design — the gauge moves in slow 1% steps — +// so the UI shows it as "~Xh Ym left", emphasis on the ~. +// +// Feed it the latest percent + charging flag whenever a fresh battery reading +// lands (the daemon-independent battery poll in main.cpp). +void battery_est_update(int percent, bool charging); + +// Estimated minutes remaining, or: +// -2 charging / on USB (no estimate to give) +// -1 not enough discharge history yet ("estimating…") +int battery_est_minutes(void); diff --git a/firmware/src/ble.cpp b/firmware/src/ble.cpp index c1773e4..2926e20 100644 --- a/firmware/src/ble.cpp +++ b/firmware/src/ble.cpp @@ -10,6 +10,7 @@ #define RX_CHAR_UUID "4c41555a-4465-7669-6365-000000000002" // host writes here #define TX_CHAR_UUID "4c41555a-4465-7669-6365-000000000003" // device ack/nack notifies #define REQ_CHAR_UUID "4c41555a-4465-7669-6365-000000000004" // device-initiated refresh request +#define CMD_CHAR_UUID "4c41555a-4465-7669-6365-000000000005" // device → host command (Phase 6) #define BLE_BUF_SIZE 512 @@ -60,6 +61,7 @@ static NimBLECharacteristic* input_kbd = nullptr; static NimBLECharacteristic* tx_char = nullptr; static NimBLECharacteristic* rx_char = nullptr; static NimBLECharacteristic* req_char = nullptr; +static NimBLECharacteristic* cmd_char = nullptr; static ble_state_t state = BLE_STATE_INIT; static bool need_advertise = false; @@ -201,6 +203,14 @@ void ble_init(void) { static ReqCallbacks reqCb; req_char->setCallbacks(&reqCb); + // Watch → host command channel (Phase 6). The daemon subscribes; the watch + // notifies a short JSON command which the daemon maps to a Home Assistant + // service call. Notify-only, same direction as the refresh char. + cmd_char = svc->createCharacteristic( + CMD_CHAR_UUID, + NIMBLE_PROPERTY::NOTIFY + ); + svc->start(); server->start(); start_advertising(); @@ -272,6 +282,14 @@ void ble_request_refresh(void) { } } +void ble_send_command(const char* json) { + if (state == BLE_STATE_CONNECTED && cmd_char) { + cmd_char->setValue(json); + cmd_char->notify(); + Serial.printf("BLE: command sent %s\n", json); + } +} + void ble_keyboard_press(uint8_t key, uint8_t modifier) { if (state != BLE_STATE_CONNECTED || !input_kbd) return; // HID report: [modifier, reserved, key1, key2, key3, key4, key5, key6] diff --git a/firmware/src/ble.h b/firmware/src/ble.h index 55c0e97..62926b7 100644 --- a/firmware/src/ble.h +++ b/firmware/src/ble.h @@ -21,6 +21,11 @@ void ble_send_ack(void); void ble_send_nack(void); void ble_request_refresh(void); +// Watch → PC command channel (Phase 6). Notifies a short JSON command (e.g. +// {"cmd":"toggle"}) to the host daemon on the CMD characteristic; the daemon +// turns it into a Home Assistant service call. No-op unless connected. +void ble_send_command(const char* json); + // BLE HID keyboard void ble_keyboard_press(uint8_t key, uint8_t modifier); void ble_keyboard_release(void); diff --git a/firmware/src/boards/template/imu.cpp b/firmware/src/boards/template/imu.cpp index eb5230b..0fce52c 100644 --- a/firmware/src/boards/template/imu.cpp +++ b/firmware/src/boards/template/imu.cpp @@ -7,3 +7,4 @@ void imu_hal_init(void) {} void imu_hal_tick(void) {} uint8_t imu_hal_rotation_quadrant(void) { return 0; } +bool imu_hal_read_accel(float* x, float* y, float* z) { return false; } diff --git a/firmware/src/boards/template/power.cpp b/firmware/src/boards/template/power.cpp index 362c9c5..e721339 100644 --- a/firmware/src/boards/template/power.cpp +++ b/firmware/src/boards/template/power.cpp @@ -17,8 +17,10 @@ void power_hal_init(void) {} void power_hal_tick(void) {} int power_hal_battery_pct(void) { return -1; } +int power_hal_battery_mv(void) { return 0; } // 0 = no battery / unsupported bool power_hal_is_charging(void) { return false; } bool power_hal_is_vbus_in(void) { return false; } +void power_hal_shutdown(void) {} // no controllable PMU bool power_hal_pwr_pressed(void) { return false; } // Hold-to-pair gesture signals. Mirror the 216 (PMU PKEY long/positive IRQs) // or the 1.8" (software hold-timing off a polled GPIO) port. Stub = no gesture. diff --git a/firmware/src/boards/waveshare_amoled_18/imu.cpp b/firmware/src/boards/waveshare_amoled_18/imu.cpp index f8de856..4a8c180 100644 --- a/firmware/src/boards/waveshare_amoled_18/imu.cpp +++ b/firmware/src/boards/waveshare_amoled_18/imu.cpp @@ -23,3 +23,7 @@ void imu_hal_tick(void) { } uint8_t imu_hal_rotation_quadrant(void) { return 0; } + +// Accelerometer is not configured on this board (only the bus is brought up), +// so there is no tilt data to report. +bool imu_hal_read_accel(float* x, float* y, float* z) { return false; } diff --git a/firmware/src/boards/waveshare_amoled_18/power.cpp b/firmware/src/boards/waveshare_amoled_18/power.cpp index 727d538..55dba31 100644 --- a/firmware/src/boards/waveshare_amoled_18/power.cpp +++ b/firmware/src/boards/waveshare_amoled_18/power.cpp @@ -23,6 +23,7 @@ static XPowersPMU pmu; static int cached_pct = -1; +static int cached_mv = 0; static bool cached_charging = false; static bool cached_vbus = false; static bool pwr_pressed_flag = false; @@ -49,6 +50,7 @@ void power_hal_init(void) { cached_charging = pmu.isCharging(); cached_vbus = pmu.isVbusIn(); cached_pct = pmu.getBatteryPercent(); + cached_mv = pmu.getBattVoltage(); } void power_hal_tick(void) { @@ -62,6 +64,7 @@ void power_hal_tick(void) { if (now - last_battery_ms >= BATTERY_POLL_MS) { last_battery_ms = now; cached_pct = pmu.getBatteryPercent(); + cached_mv = pmu.getBattVoltage(); } if (now - last_pwr_ms >= PWR_POLL_MS) { last_pwr_ms = now; @@ -83,9 +86,12 @@ void power_hal_tick(void) { } int power_hal_battery_pct(void) { return cached_pct; } +int power_hal_battery_mv(void) { return cached_mv; } bool power_hal_is_charging(void) { return cached_charging; } bool power_hal_is_vbus_in(void) { return cached_vbus; } +void power_hal_shutdown(void) { pmu.shutdown(); } + bool power_hal_pwr_pressed(void) { if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; } return false; diff --git a/firmware/src/boards/waveshare_amoled_206/imu.cpp b/firmware/src/boards/waveshare_amoled_206/imu.cpp index da76ac9..c699ce2 100644 --- a/firmware/src/boards/waveshare_amoled_206/imu.cpp +++ b/firmware/src/boards/waveshare_amoled_206/imu.cpp @@ -5,21 +5,34 @@ #include // AMOLED-2.06 ships with QMI8658 populated, but the panel is non-square and -// mounts in a fixed orientation, so rotation is disabled. We initialize the -// device anyway to keep the shared I2C bus healthy, but always report 0. +// mounts in a fixed orientation, so screen rotation is disabled and the quadrant +// is always 0. The accelerometer IS enabled, though: the tilt-dimmer screen +// reads raw g-vectors via imu_hal_read_accel() to derive wrist pitch/roll. static SensorQMI8658 imu; +static bool imu_ok = false; void imu_hal_init(void) { if (!imu.begin(Wire, QMI8658_L_SLAVE_ADDRESS, IIC_SDA, IIC_SCL)) { Serial.println("QMI8658 init failed"); return; } - Serial.println("QMI8658 init OK (rotation disabled on this board)"); + // 2g range is plenty for static tilt sensing (gravity is 1g); 62.5Hz keeps + // the dial responsive without flooding the I2C bus. LPF on (default) damps + // hand jitter so the brightness rate doesn't twitch. + imu.configAccelerometer(SensorQMI8658::ACC_RANGE_2G, SensorQMI8658::ACC_ODR_62_5Hz); + imu.enableAccelerometer(); + imu_ok = true; + Serial.println("QMI8658 init OK (accel enabled, screen rotation disabled)"); } void imu_hal_tick(void) { - // No-op — rotation is disabled. + // No-op — screen rotation is disabled; accel is polled on demand. } uint8_t imu_hal_rotation_quadrant(void) { return 0; } + +bool imu_hal_read_accel(float* x, float* y, float* z) { + if (!imu_ok || !imu.getDataReady()) return false; + return imu.getAccelerometer(*x, *y, *z); +} diff --git a/firmware/src/boards/waveshare_amoled_206/power.cpp b/firmware/src/boards/waveshare_amoled_206/power.cpp index cba8b4a..a9345f0 100644 --- a/firmware/src/boards/waveshare_amoled_206/power.cpp +++ b/firmware/src/boards/waveshare_amoled_206/power.cpp @@ -26,6 +26,7 @@ static XPowersPMU pmu; static int cached_pct = -1; +static int cached_mv = 0; static bool cached_charging = false; static bool cached_vbus = false; static bool pwr_pressed_flag = false; @@ -66,6 +67,7 @@ void power_hal_init(void) { cached_charging = pmu.isCharging(); cached_vbus = pmu.isVbusIn(); cached_pct = pmu.getBatteryPercent(); + cached_mv = pmu.getBattVoltage(); } void power_hal_tick(void) { @@ -79,6 +81,7 @@ void power_hal_tick(void) { if (now - last_battery_ms >= BATTERY_POLL_MS) { last_battery_ms = now; cached_pct = pmu.getBatteryPercent(); + cached_mv = pmu.getBattVoltage(); } if (now - last_pwr_ms >= PWR_POLL_MS) { last_pwr_ms = now; @@ -100,9 +103,12 @@ void power_hal_tick(void) { } int power_hal_battery_pct(void) { return cached_pct; } +int power_hal_battery_mv(void) { return cached_mv; } bool power_hal_is_charging(void) { return cached_charging; } bool power_hal_is_vbus_in(void) { return cached_vbus; } +void power_hal_shutdown(void) { pmu.shutdown(); } + bool power_hal_pwr_pressed(void) { if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; } return false; diff --git a/firmware/src/boards/waveshare_amoled_216/imu.cpp b/firmware/src/boards/waveshare_amoled_216/imu.cpp index 0ead894..82d2197 100644 --- a/firmware/src/boards/waveshare_amoled_216/imu.cpp +++ b/firmware/src/boards/waveshare_amoled_216/imu.cpp @@ -64,3 +64,8 @@ void imu_hal_tick(void) { } uint8_t imu_hal_rotation_quadrant(void) { return current_rotation; } + +bool imu_hal_read_accel(float* x, float* y, float* z) { + if (!imu_ok || !imu.getDataReady()) return false; + return imu.getAccelerometer(*x, *y, *z); +} diff --git a/firmware/src/boards/waveshare_amoled_216/power.cpp b/firmware/src/boards/waveshare_amoled_216/power.cpp index 1c04fb6..099438c 100644 --- a/firmware/src/boards/waveshare_amoled_216/power.cpp +++ b/firmware/src/boards/waveshare_amoled_216/power.cpp @@ -16,6 +16,7 @@ static XPowersPMU pmu; static int cached_pct = -1; +static int cached_mv = 0; static bool cached_charging = false; static bool cached_vbus = false; static bool pwr_pressed_flag = false; @@ -49,6 +50,7 @@ void power_hal_init(void) { cached_charging = pmu.isCharging(); cached_vbus = pmu.isVbusIn(); cached_pct = pmu.getBatteryPercent(); + cached_mv = pmu.getBattVoltage(); } void power_hal_tick(void) { @@ -62,6 +64,7 @@ void power_hal_tick(void) { if (now - last_battery_ms >= BATTERY_POLL_MS) { last_battery_ms = now; cached_pct = pmu.getBatteryPercent(); + cached_mv = pmu.getBattVoltage(); } if (now - last_pwr_ms >= PWR_POLL_MS) { last_pwr_ms = now; @@ -74,9 +77,12 @@ void power_hal_tick(void) { } int power_hal_battery_pct(void) { return cached_pct; } +int power_hal_battery_mv(void) { return cached_mv; } bool power_hal_is_charging(void) { return cached_charging; } bool power_hal_is_vbus_in(void) { return cached_vbus; } +void power_hal_shutdown(void) { pmu.shutdown(); } + bool power_hal_pwr_pressed(void) { if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; } return false; diff --git a/firmware/src/boards/waveshare_amoled_216_c6/imu.cpp b/firmware/src/boards/waveshare_amoled_216_c6/imu.cpp index 2b24d57..a4f1132 100644 --- a/firmware/src/boards/waveshare_amoled_216_c6/imu.cpp +++ b/firmware/src/boards/waveshare_amoled_216_c6/imu.cpp @@ -24,3 +24,7 @@ void imu_hal_tick(void) { } uint8_t imu_hal_rotation_quadrant(void) { return 0; } + +// Accelerometer is not configured on this board (only the bus is brought up), +// so there is no tilt data to report. +bool imu_hal_read_accel(float* x, float* y, float* z) { return false; } diff --git a/firmware/src/boards/waveshare_amoled_216_c6/power.cpp b/firmware/src/boards/waveshare_amoled_216_c6/power.cpp index 659162c..779cf9f 100644 --- a/firmware/src/boards/waveshare_amoled_216_c6/power.cpp +++ b/firmware/src/boards/waveshare_amoled_216_c6/power.cpp @@ -17,6 +17,7 @@ extern XPowersPMU board_pmu; #define pmu board_pmu static int cached_pct = -1; +static int cached_mv = 0; static bool cached_charging = false; static bool cached_vbus = false; static bool pwr_pressed_flag = false; @@ -50,6 +51,7 @@ void power_hal_init(void) { cached_charging = pmu.isCharging(); cached_vbus = pmu.isVbusIn(); cached_pct = pmu.getBatteryPercent(); + cached_mv = pmu.getBattVoltage(); } void power_hal_tick(void) { @@ -63,6 +65,7 @@ void power_hal_tick(void) { if (now - last_battery_ms >= BATTERY_POLL_MS) { last_battery_ms = now; cached_pct = pmu.getBatteryPercent(); + cached_mv = pmu.getBattVoltage(); } if (now - last_pwr_ms >= PWR_POLL_MS) { last_pwr_ms = now; @@ -75,9 +78,12 @@ void power_hal_tick(void) { } int power_hal_battery_pct(void) { return cached_pct; } +int power_hal_battery_mv(void) { return cached_mv; } bool power_hal_is_charging(void) { return cached_charging; } bool power_hal_is_vbus_in(void) { return cached_vbus; } +void power_hal_shutdown(void) { pmu.shutdown(); } + bool power_hal_pwr_pressed(void) { if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; } return false; diff --git a/firmware/src/hal/imu_hal.h b/firmware/src/hal/imu_hal.h index 08fd90f..6cdee07 100644 --- a/firmware/src/hal/imu_hal.h +++ b/firmware/src/hal/imu_hal.h @@ -9,3 +9,9 @@ void imu_hal_init(void); void imu_hal_tick(void); uint8_t imu_hal_rotation_quadrant(void); + +// Raw accelerometer read in g-units (gravity = 1.0). Used by the tilt-dimmer +// screen to derive wrist pitch/roll. Returns true and fills x/y/z when a fresh +// sample is available; returns false (leaving the outputs untouched) on boards +// without an accelerometer or when no new data is ready yet. +bool imu_hal_read_accel(float* x, float* y, float* z); diff --git a/firmware/src/hal/power_hal.h b/firmware/src/hal/power_hal.h index 08bbd10..9ad18eb 100644 --- a/firmware/src/hal/power_hal.h +++ b/firmware/src/hal/power_hal.h @@ -12,9 +12,14 @@ void power_hal_init(void); void power_hal_tick(void); int power_hal_battery_pct(void); // 0..100, or -1 if no battery (see BoardCaps.has_battery) +int power_hal_battery_mv(void); // battery voltage in mV, or 0 if no battery / unsupported bool power_hal_is_charging(void); bool power_hal_is_vbus_in(void); // USB cable present (true even without a battery) +// Power the device fully off (PMU shutdown). Used by the low-voltage protective +// cutoff in main.cpp. No-op on boards without a controllable PMU. +void power_hal_shutdown(void); + // Edge-triggered: returns true once per PWR short-press, then clears. bool power_hal_pwr_pressed(void); diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index f251647..928e4ab 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -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.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 @@ -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(); + 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(); + } + 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(); + 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(); diff --git a/firmware/src/ui.cpp b/firmware/src/ui.cpp index 4c8bacc..1473d0c 100644 --- a/firmware/src/ui.cpp +++ b/firmware/src/ui.cpp @@ -2,9 +2,11 @@ #include "splash.h" #include #include +#include #include "logo.h" #include "icons.h" #include "hal/board_caps.h" +#include "hal/imu_hal.h" // Custom fonts (scaled for 314 PPI, ~1.9x from original 165 PPI) LV_FONT_DECLARE(font_tiempos_56); @@ -124,12 +126,16 @@ static lv_obj_t* battery_img; static lv_obj_t* logo_img; static lv_image_dsc_t battery_dscs[5]; // empty, low, medium, full, charging +// Latest battery telemetry, pushed by ui_update_battery() and read by the +// battery detail screen. minutes_left: >=0 minutes, -1 estimating, -2 charging. +static int g_bat_pct = -1; +static int g_bat_mv = 0; +static int g_bat_min = -2; +static bool g_bat_charging = false; + // ---- v2 navigation: launcher, stub & bluetooth screens ---- static lv_obj_t* nav_back_btn; // top-left back chevron (shown off the home screen) static lv_obj_t* menu_container; // app launcher (scrollable tile grid) -static lv_obj_t* stub_container; // generic "coming soon" screen, retitled per app -static lv_obj_t* stub_title; -static lv_obj_t* stub_icon; static lv_obj_t* bt_container; // BLE connection info static lv_obj_t* bt_status_lbl; static lv_obj_t* bt_name_lbl; @@ -144,6 +150,25 @@ static lv_obj_t* np_icon_lbl; // play / pause / music glyph static lv_obj_t* np_title_lbl; // track title (scrolls if long) static lv_obj_t* np_artist_lbl; // track artist static lv_obj_t* np_status_lbl; // "Playing" / "Paused" +static lv_obj_t* battery_container; // battery detail (voltage + time left) +static lv_obj_t* bat_pct_lbl; // big percentage hero +static lv_obj_t* bat_bar; // horizontal charge bar +static lv_obj_t* bat_volt_lbl; // "3.92 V" +static lv_obj_t* bat_time_lbl; // "~2h 15m left" / "Charging" / "Estimating…" +static lv_obj_t* bat_note_lbl; // protective-cutoff note +static lv_obj_t* homeassist_container; // Home control: config-driven buttons (Phase 7 M2) +static lv_obj_t* ha_status_lbl; // bottom feedback line ("Sent: