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:
@@ -0,0 +1,55 @@
|
||||
#include "battery_est.h"
|
||||
#include <Arduino.h>
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5,21 +5,34 @@
|
||||
#include <SensorQMI8658.hpp>
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+129
-13
@@ -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<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();
|
||||
|
||||
+619
-55
@@ -2,9 +2,11 @@
|
||||
#include "splash.h"
|
||||
#include <lvgl.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#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: <label>")
|
||||
static lv_obj_t* ha_empty_lbl; // hint shown when no buttons are configured
|
||||
static lv_obj_t* ha_btn_grid; // flex grid holding the button tiles
|
||||
static lv_obj_t* ha_btns[UI_MAX_BUTTONS]; // pre-created tiles (shown/hidden by count)
|
||||
static lv_obj_t* ha_btn_lbls[UI_MAX_BUTTONS]; // each tile's text label
|
||||
static char ha_btn_text[UI_MAX_BUTTONS][20]; // copied labels (Cyrillic-safe, NUL-terminated)
|
||||
static int ha_btn_count = 0; // how many tiles are currently shown
|
||||
static lv_obj_t* dimmer_container; // tilt-to-dim light control (Phase 6 step 3 / M3)
|
||||
static lv_obj_t* dim_arc; // value dial (brightness % or color-temp K)
|
||||
static lv_obj_t* dim_value_lbl; // big number in the dial center
|
||||
static lv_obj_t* dim_param_lbl; // "Brightness" / "Temp"
|
||||
static lv_obj_t* dim_status_lbl; // hint / armed state line
|
||||
|
||||
// App registry — the launcher renders one tile per entry, so adding a screen is
|
||||
// one line here plus its builder. Order = tile order. "Animations" reuses the
|
||||
@@ -160,6 +185,7 @@ static const AppEntry APPS[] = {
|
||||
{ SCREEN_SESSION, "Session", LV_SYMBOL_LIST },
|
||||
{ SCREEN_NOWPLAYING, "Now Playing", LV_SYMBOL_PLAY },
|
||||
{ SCREEN_HOMEASSIST, "Home", LV_SYMBOL_HOME },
|
||||
{ SCREEN_DIMMER, "Dimmer", LV_SYMBOL_SETTINGS },
|
||||
{ SCREEN_BLUETOOTH, "Bluetooth", LV_SYMBOL_BLUETOOTH },
|
||||
};
|
||||
#define APP_COUNT (sizeof(APPS) / sizeof(APPS[0]))
|
||||
@@ -263,6 +289,8 @@ static void format_tokens(long long n, char* buf, size_t len) {
|
||||
static void global_click_cb(lv_event_t* e);
|
||||
static void logo_click_cb(lv_event_t* e);
|
||||
static void nav_back_cb(lv_event_t* e);
|
||||
static void battery_click_cb(lv_event_t* e);
|
||||
static void battery_screen_refresh(void);
|
||||
|
||||
static lv_obj_t* make_panel(lv_obj_t* parent, int x, int y, int w, int h) {
|
||||
lv_obj_t* panel = lv_obj_create(parent);
|
||||
@@ -453,13 +481,7 @@ static void init_usage_screen(lv_obj_t* scr) {
|
||||
lv_obj_align(lbl_anim, LV_ALIGN_BOTTOM_MID, 0, -15);
|
||||
}
|
||||
|
||||
// ======== v2: launcher, stub & bluetooth screens ========
|
||||
|
||||
static const AppEntry* app_for_screen(screen_t s) {
|
||||
for (size_t i = 0; i < APP_COUNT; i++)
|
||||
if (APPS[i].screen == s) return &APPS[i];
|
||||
return nullptr;
|
||||
}
|
||||
// ======== v2: launcher & bluetooth screens ========
|
||||
|
||||
// Centered screen title for the navigation screens. The back chevron lives in
|
||||
// the shared top-left chrome (nav_back_btn), so only the title is drawn here.
|
||||
@@ -542,34 +564,6 @@ static void init_menu_screen(lv_obj_t* scr) {
|
||||
lv_obj_add_flag(menu_container, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
// One generic "coming soon" screen, retitled per app in stub_show_for(). The
|
||||
// four future apps (Soundpad/Session/Now Playing/Home) point here until built.
|
||||
static void init_stub_screen(lv_obj_t* scr) {
|
||||
stub_container = lv_obj_create(scr);
|
||||
lv_obj_set_size(stub_container, L.scr_w, L.scr_h);
|
||||
lv_obj_set_pos(stub_container, 0, 0);
|
||||
lv_obj_set_style_bg_opa(stub_container, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(stub_container, 0, 0);
|
||||
lv_obj_set_style_pad_all(stub_container, 0, 0);
|
||||
lv_obj_clear_flag(stub_container, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
stub_title = make_screen_title(stub_container, "");
|
||||
|
||||
stub_icon = lv_label_create(stub_container);
|
||||
lv_label_set_text(stub_icon, LV_SYMBOL_SETTINGS);
|
||||
lv_obj_set_style_text_font(stub_icon, &lv_font_montserrat_28, 0);
|
||||
lv_obj_set_style_text_color(stub_icon, COL_DIM, 0);
|
||||
lv_obj_align(stub_icon, LV_ALIGN_CENTER, 0, -20);
|
||||
|
||||
lv_obj_t* soon = lv_label_create(stub_container);
|
||||
lv_label_set_text(soon, "coming soon");
|
||||
lv_obj_set_style_text_font(soon, &font_styrene_20, 0);
|
||||
lv_obj_set_style_text_color(soon, COL_DIM, 0);
|
||||
lv_obj_align(soon, LV_ALIGN_CENTER, 0, 30);
|
||||
|
||||
lv_obj_add_flag(stub_container, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
static void init_bt_screen(lv_obj_t* scr) {
|
||||
bt_container = lv_obj_create(scr);
|
||||
lv_obj_set_size(bt_container, L.scr_w, L.scr_h);
|
||||
@@ -609,14 +603,6 @@ static void bt_refresh(void) {
|
||||
lv_label_set_text_fmt(bt_mac_lbl, "%s", ble_get_mac_address());
|
||||
}
|
||||
|
||||
static void stub_show_for(screen_t s) {
|
||||
const AppEntry* app = app_for_screen(s);
|
||||
if (app) {
|
||||
lv_label_set_text(stub_title, app->label);
|
||||
lv_label_set_text(stub_icon, app->symbol);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Soundpad: 6 pads → HID F13..F18 (the host maps them to a soundboard) ----
|
||||
// F13..F24 are HID usage IDs 0x68..0x73; the report-map keycode ceiling was
|
||||
// raised to 0x73 in ble.cpp so these transmit.
|
||||
@@ -791,6 +777,506 @@ static void init_nowplaying_screen(lv_obj_t* scr) {
|
||||
lv_obj_add_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
// Home control (Phase 7 M2): a grid of buttons defined by the desktop config.
|
||||
// The watch is dumb — each tile only carries its index; on tap it notifies
|
||||
// {"cmd":"btn","i":N} and the daemon maps the index → action/entity → Home
|
||||
// Assistant call. Labels arrive in the RX payload's "btns" array (ui_set_buttons).
|
||||
static void ha_btn_click_cb(lv_event_t* e) {
|
||||
int idx = (int)(intptr_t)lv_event_get_user_data(e);
|
||||
char cmd[24];
|
||||
snprintf(cmd, sizeof cmd, "{\"cmd\":\"btn\",\"i\":%d}", idx);
|
||||
ble_send_command(cmd);
|
||||
if (ha_status_lbl && idx >= 0 && idx < ha_btn_count)
|
||||
lv_label_set_text_fmt(ha_status_lbl, "Sent: %s", ha_btn_text[idx]);
|
||||
}
|
||||
|
||||
static lv_obj_t* make_ha_button(lv_obj_t* parent, int idx, int w, int h) {
|
||||
lv_obj_t* btn = lv_obj_create(parent);
|
||||
lv_obj_set_size(btn, w, h);
|
||||
lv_obj_set_style_bg_color(btn, COL_PANEL, 0);
|
||||
lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_radius(btn, 16, 0);
|
||||
lv_obj_set_style_border_width(btn, 0, 0);
|
||||
lv_obj_set_style_border_width(btn, 3, LV_STATE_PRESSED); // accent ring on press
|
||||
lv_obj_set_style_border_color(btn, COL_ACCENT, LV_STATE_PRESSED);
|
||||
lv_obj_clear_flag(btn, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(btn, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_event_cb(btn, ha_btn_click_cb, LV_EVENT_CLICKED, (void*)(intptr_t)idx);
|
||||
|
||||
lv_obj_t* lbl = lv_label_create(btn);
|
||||
lv_obj_set_width(lbl, w - 16);
|
||||
lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT);
|
||||
lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_set_style_text_font(lbl, &font_styrene_cyr_20, 0); // Cyrillic-capable (labels may be RU)
|
||||
lv_obj_set_style_text_color(lbl, COL_TEXT, 0);
|
||||
lv_label_set_text(lbl, "");
|
||||
lv_obj_center(lbl);
|
||||
ha_btn_lbls[idx] = lbl;
|
||||
return btn;
|
||||
}
|
||||
|
||||
static void init_homeassist_screen(lv_obj_t* scr) {
|
||||
homeassist_container = lv_obj_create(scr);
|
||||
lv_obj_set_size(homeassist_container, L.scr_w, L.scr_h);
|
||||
lv_obj_set_pos(homeassist_container, 0, 0);
|
||||
lv_obj_set_style_bg_opa(homeassist_container, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(homeassist_container, 0, 0);
|
||||
lv_obj_set_style_pad_all(homeassist_container, 0, 0);
|
||||
lv_obj_clear_flag(homeassist_container, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
make_screen_title(homeassist_container, "Home");
|
||||
|
||||
// Button grid — same 2-column wrap/scroll pattern as the launcher & soundpad.
|
||||
int avail_h = L.scr_h - L.content_y - L.margin - 34; // leave room for the status line
|
||||
ha_btn_grid = lv_obj_create(homeassist_container);
|
||||
lv_obj_set_size(ha_btn_grid, L.content_w, avail_h);
|
||||
lv_obj_set_pos(ha_btn_grid, L.margin, L.content_y);
|
||||
lv_obj_set_style_bg_opa(ha_btn_grid, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(ha_btn_grid, 0, 0);
|
||||
lv_obj_set_style_pad_all(ha_btn_grid, 0, 0);
|
||||
lv_obj_set_style_pad_row(ha_btn_grid, 12, 0);
|
||||
lv_obj_set_style_pad_column(ha_btn_grid, 12, 0);
|
||||
lv_obj_set_flex_flow(ha_btn_grid, LV_FLEX_FLOW_ROW_WRAP);
|
||||
lv_obj_set_flex_align(ha_btn_grid, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START);
|
||||
lv_obj_set_scroll_dir(ha_btn_grid, LV_DIR_VER);
|
||||
lv_obj_set_scrollbar_mode(ha_btn_grid, LV_SCROLLBAR_MODE_AUTO);
|
||||
|
||||
int btn_w = (L.content_w - 16) / 2; // 2 columns, 4px slack
|
||||
int btn_h = (avail_h - 2 * 12) / 3 - 1; // up to 3 rows (6 buttons)
|
||||
for (int i = 0; i < UI_MAX_BUTTONS; i++) {
|
||||
ha_btns[i] = make_ha_button(ha_btn_grid, i, btn_w, btn_h);
|
||||
lv_obj_add_flag(ha_btns[i], LV_OBJ_FLAG_HIDDEN); // revealed by ui_set_buttons()
|
||||
}
|
||||
|
||||
// Placeholder shown until the daemon pushes a button set.
|
||||
ha_empty_lbl = lv_label_create(homeassist_container);
|
||||
lv_obj_set_width(ha_empty_lbl, L.content_w);
|
||||
lv_label_set_long_mode(ha_empty_lbl, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_style_text_align(ha_empty_lbl, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_set_style_text_font(ha_empty_lbl, &font_styrene_20, 0);
|
||||
lv_obj_set_style_text_color(ha_empty_lbl, COL_DIM, 0);
|
||||
lv_label_set_text(ha_empty_lbl, "Add buttons in the\ndesktop app");
|
||||
lv_obj_align(ha_empty_lbl, LV_ALIGN_CENTER, 0, -10);
|
||||
|
||||
// Bottom feedback line — shows "Sent: <label>" on press.
|
||||
ha_status_lbl = lv_label_create(homeassist_container);
|
||||
lv_label_set_text(ha_status_lbl, "");
|
||||
lv_obj_set_style_text_font(ha_status_lbl, &font_styrene_cyr_20, 0);
|
||||
lv_obj_set_style_text_color(ha_status_lbl, COL_DIM, 0);
|
||||
lv_obj_align(ha_status_lbl, LV_ALIGN_BOTTOM_MID, 0, -8);
|
||||
|
||||
// Start in the "no buttons yet" state (grid hidden, placeholder shown).
|
||||
lv_obj_add_flag(ha_btn_grid, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_add_flag(homeassist_container, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
// Apply the button labels pushed by the daemon (RX "btns"). Copies the strings
|
||||
// (the source JSON is freed right after the parse), reveals that many tiles,
|
||||
// hides the rest, and toggles the placeholder. A no-op when nothing changed, so
|
||||
// the ~60s refresh never churns the UI or restarts press animations.
|
||||
void ui_set_buttons(const char* const* labels, int count) {
|
||||
if (!homeassist_container) return;
|
||||
if (count < 0) count = 0;
|
||||
if (count > UI_MAX_BUTTONS) count = UI_MAX_BUTTONS;
|
||||
|
||||
bool changed = (count != ha_btn_count);
|
||||
for (int i = 0; i < count && !changed; i++) {
|
||||
const char* s = labels[i] ? labels[i] : "";
|
||||
if (strncmp(ha_btn_text[i], s, sizeof(ha_btn_text[i])) != 0) changed = true;
|
||||
}
|
||||
if (!changed) return;
|
||||
|
||||
ha_btn_count = count;
|
||||
for (int i = 0; i < UI_MAX_BUTTONS; i++) {
|
||||
if (i < count) {
|
||||
strlcpy(ha_btn_text[i], labels[i] ? labels[i] : "", sizeof(ha_btn_text[i]));
|
||||
lv_label_set_text(ha_btn_lbls[i], ha_btn_text[i]);
|
||||
lv_obj_clear_flag(ha_btns[i], LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
ha_btn_text[i][0] = '\0';
|
||||
lv_obj_add_flag(ha_btns[i], LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
}
|
||||
if (count > 0) {
|
||||
lv_obj_add_flag(ha_empty_lbl, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_clear_flag(ha_btn_grid, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
lv_obj_clear_flag(ha_empty_lbl, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_add_flag(ha_btn_grid, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
// ======== Tilt-dimmer (Phase 6 step 3 / M3) ========
|
||||
//
|
||||
// The wrist is a one-axis joystick. While "armed", the watch reads its IMU each
|
||||
// loop, measures how far the chosen device axis has tilted away from the neutral
|
||||
// pose captured at arm time, and ramps the working value at a rate proportional
|
||||
// to that tilt (zero inside a small deadzone, capped at full tilt). The working
|
||||
// value drives the on-screen dial and is streamed to the daemon as an absolute
|
||||
// {"cmd":"bri"|"ct","v":..} write, throttled to ~5 Hz. Holding the wrist still
|
||||
// for a few seconds, or tapping the screen, commits and locks; BOOT re-arms;
|
||||
// PWR switches which parameter (brightness ⇄ color temperature) the tilt drives.
|
||||
|
||||
enum { DIM_BRI = 0, DIM_CT = 1 };
|
||||
|
||||
// --- tuning ---------------------------------------------------------------
|
||||
static const float DIM_DEADZONE_DEG = 8.0f; // ignore tilt smaller than this
|
||||
static const float DIM_MAX_DEG = 45.0f; // tilt for full-speed ramp
|
||||
static const float DIM_BRI_RATE = 45.0f; // %/s at full tilt
|
||||
static const float DIM_CT_RATE_FRAC = 0.45f; // fraction of the K-range/s at full tilt
|
||||
static const uint32_t DIM_SEND_MS = 180; // BLE write cap (~5.5 Hz)
|
||||
static const uint32_t DIM_AUTOLOCK_MS = 3500; // stillness this long → auto-commit
|
||||
|
||||
// Which device axis drives the dimmer and its sign. Tilt is the change in
|
||||
// asin(normalized gravity[axis]) from neutral. Calibrated on hardware — flip
|
||||
// these after watching the live read-out below. 0=X, 1=Y, 2=Z.
|
||||
#define DIM_AXIS 1
|
||||
#define DIM_SIGN (+1.0f)
|
||||
// While 1, the armed status line shows the live gravity vector + selected-axis
|
||||
// tilt so the axis/sign above can be chosen on the bench. Set to 0 once locked.
|
||||
// (Flip to 1 when calibrating the tilt axis on a new board.)
|
||||
#define DIM_CALIB 0
|
||||
|
||||
// --- state ----------------------------------------------------------------
|
||||
static int dim_param = DIM_BRI;
|
||||
static bool dim_armed = false;
|
||||
static bool dim_capture_pending = false; // capture neutral on the next good read
|
||||
static bool dim_have_snapshot = false;
|
||||
static bool dim_light_on = false;
|
||||
static float dim_bri = 50.0f; // working brightness % (1..100)
|
||||
static float dim_ct = 3000.0f; // working color temp (K)
|
||||
static int dim_min_k = 2000;
|
||||
static int dim_max_k = 6500;
|
||||
static float dim_neutral_deg = 0.0f; // selected-axis angle at neutral
|
||||
static uint32_t dim_last_tick_ms = 0;
|
||||
static uint32_t dim_last_send_ms = 0;
|
||||
static uint32_t dim_still_since_ms = 0;
|
||||
static int dim_last_sent_val = -99999;
|
||||
|
||||
static float dim_axis_angle_deg(float x, float y, float z) {
|
||||
float n = sqrtf(x * x + y * y + z * z);
|
||||
if (n < 0.01f) return 0.0f;
|
||||
float a = (DIM_AXIS == 0 ? x : (DIM_AXIS == 1 ? y : z)) / n;
|
||||
if (a > 1.0f) a = 1.0f;
|
||||
if (a < -1.0f) a = -1.0f;
|
||||
return DIM_SIGN * asinf(a) * 57.2958f;
|
||||
}
|
||||
|
||||
static void dim_update_status(void) {
|
||||
if (!dim_status_lbl) return;
|
||||
if (dim_armed) {
|
||||
lv_obj_set_style_text_color(dim_status_lbl, COL_ACCENT, 0);
|
||||
lv_label_set_text(dim_status_lbl, "Tilt to adjust - tap to set");
|
||||
} else if (!dim_have_snapshot) {
|
||||
lv_obj_set_style_text_color(dim_status_lbl, COL_DIM, 0);
|
||||
lv_label_set_text(dim_status_lbl, "Connecting...");
|
||||
} else {
|
||||
lv_obj_set_style_text_color(dim_status_lbl, COL_DIM, 0);
|
||||
lv_label_set_text(dim_status_lbl, "BOOT to adjust - PWR switches");
|
||||
}
|
||||
}
|
||||
|
||||
static void dim_refresh_dial(void) {
|
||||
if (!dim_arc) return;
|
||||
if (dim_param == DIM_BRI) {
|
||||
int v = (int)lroundf(dim_bri);
|
||||
lv_arc_set_range(dim_arc, 0, 100);
|
||||
lv_arc_set_value(dim_arc, v);
|
||||
lv_label_set_text_fmt(dim_value_lbl, "%d%%", v);
|
||||
lv_label_set_text(dim_param_lbl, "Brightness");
|
||||
} else {
|
||||
int v = (int)lroundf(dim_ct);
|
||||
lv_arc_set_range(dim_arc, dim_min_k, dim_max_k);
|
||||
lv_arc_set_value(dim_arc, v);
|
||||
lv_label_set_text_fmt(dim_value_lbl, "%dK", v);
|
||||
lv_label_set_text(dim_param_lbl, "Temp");
|
||||
}
|
||||
}
|
||||
|
||||
// Push the working value to the daemon. `force` ignores the change-dedupe so a
|
||||
// commit lands exactly on the displayed number.
|
||||
static void dim_send_current(bool force) {
|
||||
char cmd[40];
|
||||
if (dim_param == DIM_BRI) {
|
||||
int v = (int)lroundf(dim_bri);
|
||||
if (!force && v == dim_last_sent_val) return;
|
||||
snprintf(cmd, sizeof cmd, "{\"cmd\":\"bri\",\"v\":%d}", v);
|
||||
dim_last_sent_val = v;
|
||||
} else {
|
||||
int v = (int)lroundf(dim_ct);
|
||||
if (!force && abs(v - dim_last_sent_val) < 20) return; // ~20K granularity
|
||||
snprintf(cmd, sizeof cmd, "{\"cmd\":\"ct\",\"v\":%d}", v);
|
||||
dim_last_sent_val = v;
|
||||
}
|
||||
ble_send_command(cmd);
|
||||
dim_light_on = true; // setting brightness/temp implicitly turns the light on
|
||||
}
|
||||
|
||||
static void dim_commit(void) {
|
||||
if (!dim_armed) return;
|
||||
dim_armed = false;
|
||||
dim_capture_pending = false;
|
||||
dim_send_current(true); // land HA exactly on the shown value
|
||||
dim_update_status();
|
||||
}
|
||||
|
||||
// Tap anywhere on the dial area commits (only meaningful while armed). The back
|
||||
// chevron and battery icon sit above this container and take their own taps.
|
||||
static void dim_tap_cb(lv_event_t* e) {
|
||||
(void)e;
|
||||
if (dim_armed) dim_commit();
|
||||
}
|
||||
|
||||
static void init_dimmer_screen(lv_obj_t* scr) {
|
||||
dimmer_container = lv_obj_create(scr);
|
||||
lv_obj_set_size(dimmer_container, L.scr_w, L.scr_h);
|
||||
lv_obj_set_pos(dimmer_container, 0, 0);
|
||||
lv_obj_set_style_bg_opa(dimmer_container, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(dimmer_container, 0, 0);
|
||||
lv_obj_set_style_pad_all(dimmer_container, 0, 0);
|
||||
lv_obj_clear_flag(dimmer_container, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(dimmer_container, LV_OBJ_FLAG_CLICKABLE); // tap → commit
|
||||
lv_obj_add_event_cb(dimmer_container, dim_tap_cb, LV_EVENT_CLICKED, NULL);
|
||||
|
||||
make_screen_title(dimmer_container, "Dimmer");
|
||||
|
||||
int arc_sz = (L.scr_h >= 460) ? 280 : 240;
|
||||
dim_arc = lv_arc_create(dimmer_container);
|
||||
lv_obj_set_size(dim_arc, arc_sz, arc_sz);
|
||||
lv_obj_align(dim_arc, LV_ALIGN_CENTER, 0, 4);
|
||||
lv_arc_set_rotation(dim_arc, 135);
|
||||
lv_arc_set_bg_angles(dim_arc, 0, 270);
|
||||
lv_arc_set_range(dim_arc, 0, 100);
|
||||
lv_arc_set_value(dim_arc, 50);
|
||||
lv_obj_remove_style(dim_arc, NULL, LV_PART_KNOB); // display-only: no draggable knob
|
||||
lv_obj_clear_flag(dim_arc, LV_OBJ_FLAG_CLICKABLE); // taps fall through to the container
|
||||
lv_obj_clear_flag(dim_arc, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_set_style_arc_color(dim_arc, COL_BAR_BG, LV_PART_MAIN);
|
||||
lv_obj_set_style_arc_color(dim_arc, COL_ACCENT, LV_PART_INDICATOR);
|
||||
lv_obj_set_style_arc_width(dim_arc, 14, LV_PART_MAIN);
|
||||
lv_obj_set_style_arc_width(dim_arc, 14, LV_PART_INDICATOR);
|
||||
|
||||
dim_value_lbl = lv_label_create(dimmer_container);
|
||||
lv_obj_set_style_text_font(dim_value_lbl, &font_styrene_48, 0);
|
||||
lv_obj_set_style_text_color(dim_value_lbl, COL_TEXT, 0);
|
||||
lv_label_set_text(dim_value_lbl, "50%");
|
||||
lv_obj_align(dim_value_lbl, LV_ALIGN_CENTER, 0, -6);
|
||||
|
||||
dim_param_lbl = lv_label_create(dimmer_container);
|
||||
lv_obj_set_style_text_font(dim_param_lbl, &font_styrene_20, 0);
|
||||
lv_obj_set_style_text_color(dim_param_lbl, COL_DIM, 0);
|
||||
lv_label_set_text(dim_param_lbl, "Brightness");
|
||||
lv_obj_align(dim_param_lbl, LV_ALIGN_CENTER, 0, 36);
|
||||
|
||||
dim_status_lbl = lv_label_create(dimmer_container);
|
||||
lv_obj_set_width(dim_status_lbl, L.content_w);
|
||||
lv_obj_set_style_text_align(dim_status_lbl, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_set_style_text_font(dim_status_lbl, &font_styrene_16, 0);
|
||||
lv_obj_set_style_text_color(dim_status_lbl, COL_DIM, 0);
|
||||
lv_label_set_text(dim_status_lbl, "Connecting...");
|
||||
lv_obj_align(dim_status_lbl, LV_ALIGN_BOTTOM_MID, 0, -16);
|
||||
|
||||
lv_obj_add_flag(dimmer_container, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
// The daemon pushes the light's live state (RX "dim"). Seed the working value
|
||||
// and dial from it — but only while LOCKED, so a heartbeat that arrives mid-
|
||||
// adjust can't yank the value out from under the user's wrist.
|
||||
void ui_dimmer_set_snapshot(bool on, int bri_pct, int ct_kelvin, int min_k, int max_k) {
|
||||
dim_have_snapshot = true;
|
||||
dim_light_on = on;
|
||||
if (min_k > 0) dim_min_k = min_k;
|
||||
if (max_k > dim_min_k) dim_max_k = max_k;
|
||||
if (!dim_armed) {
|
||||
if (bri_pct >= 0) dim_bri = (float)bri_pct;
|
||||
if (ct_kelvin > 0) dim_ct = (float)ct_kelvin;
|
||||
if (dim_ct < dim_min_k) dim_ct = dim_min_k;
|
||||
if (dim_ct > dim_max_k) dim_ct = dim_max_k;
|
||||
dim_refresh_dial();
|
||||
if (current_screen == SCREEN_DIMMER) dim_update_status();
|
||||
}
|
||||
}
|
||||
|
||||
void ui_dimmer_arm(void) {
|
||||
if (current_screen != SCREEN_DIMMER) return;
|
||||
dim_armed = true;
|
||||
dim_capture_pending = true; // neutral pose captured on the next good read
|
||||
dim_last_tick_ms = lv_tick_get();
|
||||
dim_still_since_ms = 0;
|
||||
dim_last_send_ms = 0;
|
||||
dim_last_sent_val = -99999;
|
||||
dim_update_status();
|
||||
}
|
||||
|
||||
void ui_dimmer_switch_param(void) {
|
||||
if (current_screen != SCREEN_DIMMER) return;
|
||||
dim_param = (dim_param == DIM_BRI) ? DIM_CT : DIM_BRI;
|
||||
dim_armed = false; // re-arm with BOOT for the newly selected param
|
||||
dim_capture_pending = false;
|
||||
dim_last_sent_val = -99999;
|
||||
if (dim_param == DIM_CT && dim_ct <= 0) dim_ct = (dim_min_k + dim_max_k) / 2.0f;
|
||||
dim_refresh_dial();
|
||||
dim_update_status();
|
||||
}
|
||||
|
||||
bool ui_dimmer_is_armed(void) { return dim_armed && current_screen == SCREEN_DIMMER; }
|
||||
|
||||
void ui_dimmer_tick(void) {
|
||||
if (current_screen != SCREEN_DIMMER || !dim_armed) return;
|
||||
|
||||
float x, y, z;
|
||||
if (!imu_hal_read_accel(&x, &y, &z)) return;
|
||||
float ang_abs = dim_axis_angle_deg(x, y, z);
|
||||
|
||||
if (dim_capture_pending) {
|
||||
dim_neutral_deg = ang_abs; // this pose is "centered"
|
||||
dim_capture_pending = false;
|
||||
dim_last_tick_ms = lv_tick_get();
|
||||
return;
|
||||
}
|
||||
|
||||
float ang = ang_abs - dim_neutral_deg; // signed tilt from neutral
|
||||
|
||||
#if DIM_CALIB
|
||||
// LVGL's lv_label_set_text_fmt has no %f, so format with the C library first.
|
||||
char dbg[48];
|
||||
snprintf(dbg, sizeof dbg, "x%+.2f y%+.2f z%+.2f tilt%+.0f", x, y, z, ang);
|
||||
lv_label_set_text(dim_status_lbl, dbg);
|
||||
lv_obj_set_style_text_color(dim_status_lbl, COL_ACCENT, 0);
|
||||
#endif
|
||||
|
||||
uint32_t now = lv_tick_get();
|
||||
uint32_t dt_ms = now - dim_last_tick_ms;
|
||||
dim_last_tick_ms = now;
|
||||
if (dt_ms > 500) dt_ms = 0; // first/stale tick — don't integrate a big jump
|
||||
float dt = dt_ms / 1000.0f;
|
||||
|
||||
float mag = fabsf(ang) - DIM_DEADZONE_DEG;
|
||||
float rate = 0.0f; // -1..+1 normalized
|
||||
if (mag > 0.0f) {
|
||||
float frac = mag / (DIM_MAX_DEG - DIM_DEADZONE_DEG);
|
||||
if (frac > 1.0f) frac = 1.0f;
|
||||
rate = (ang > 0.0f ? 1.0f : -1.0f) * frac;
|
||||
}
|
||||
|
||||
if (rate == 0.0f) { // inside the deadzone → hold; auto-lock on stillness
|
||||
if (dim_still_since_ms == 0) dim_still_since_ms = now;
|
||||
else if (now - dim_still_since_ms >= DIM_AUTOLOCK_MS) dim_commit();
|
||||
return;
|
||||
}
|
||||
dim_still_since_ms = 0;
|
||||
|
||||
if (dim_param == DIM_BRI) {
|
||||
dim_bri += rate * DIM_BRI_RATE * dt;
|
||||
if (dim_bri < 1.0f) dim_bri = 1.0f;
|
||||
if (dim_bri > 100.0f) dim_bri = 100.0f;
|
||||
} else {
|
||||
float span = (float)(dim_max_k - dim_min_k);
|
||||
dim_ct += rate * DIM_CT_RATE_FRAC * span * dt;
|
||||
if (dim_ct < dim_min_k) dim_ct = dim_min_k;
|
||||
if (dim_ct > dim_max_k) dim_ct = dim_max_k;
|
||||
}
|
||||
dim_refresh_dial();
|
||||
|
||||
if (now - dim_last_send_ms >= DIM_SEND_MS) {
|
||||
dim_send_current(false);
|
||||
dim_last_send_ms = now;
|
||||
}
|
||||
}
|
||||
|
||||
// Battery detail — opened by tapping the battery icon (not in the launcher).
|
||||
// Shows the gauge percentage, the raw cell voltage, and a rough "time left"
|
||||
// projected from the discharge rate (battery_est in main.cpp). The bottom note
|
||||
// states the protective auto-off threshold and reddens as the cell nears it.
|
||||
static lv_color_t batt_color(int pct) {
|
||||
if (pct < 0) return COL_DIM; // unknown
|
||||
if (pct <= 15) return COL_RED;
|
||||
if (pct <= 35) return COL_AMBER;
|
||||
return COL_GREEN; // low % is bad here — inverse of the usage bars
|
||||
}
|
||||
|
||||
static void init_battery_screen(lv_obj_t* scr) {
|
||||
battery_container = lv_obj_create(scr);
|
||||
lv_obj_set_size(battery_container, L.scr_w, L.scr_h);
|
||||
lv_obj_set_pos(battery_container, 0, 0);
|
||||
lv_obj_set_style_bg_opa(battery_container, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(battery_container, 0, 0);
|
||||
lv_obj_set_style_pad_all(battery_container, 0, 0);
|
||||
lv_obj_clear_flag(battery_container, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
make_screen_title(battery_container, "Battery");
|
||||
|
||||
bat_pct_lbl = lv_label_create(battery_container);
|
||||
lv_label_set_text(bat_pct_lbl, "--%");
|
||||
lv_obj_set_style_text_font(bat_pct_lbl, &font_tiempos_56, 0);
|
||||
lv_obj_set_style_text_color(bat_pct_lbl, COL_TEXT, 0);
|
||||
lv_obj_align(bat_pct_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 10);
|
||||
|
||||
bat_bar = make_bar(battery_container, L.margin, L.content_y + 115, L.content_w, 22);
|
||||
|
||||
bat_volt_lbl = lv_label_create(battery_container);
|
||||
lv_label_set_text(bat_volt_lbl, "--.-- V");
|
||||
lv_obj_set_style_text_font(bat_volt_lbl, &font_styrene_28, 0);
|
||||
lv_obj_set_style_text_color(bat_volt_lbl, COL_DIM, 0);
|
||||
lv_obj_align(bat_volt_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 155);
|
||||
|
||||
bat_time_lbl = lv_label_create(battery_container);
|
||||
lv_label_set_text(bat_time_lbl, "Estimating time left\xE2\x80\xA6");
|
||||
lv_obj_set_style_text_font(bat_time_lbl, &font_styrene_28, 0);
|
||||
lv_obj_set_style_text_color(bat_time_lbl, COL_ACCENT, 0);
|
||||
lv_obj_align(bat_time_lbl, LV_ALIGN_TOP_MID, 0, L.content_y + 205);
|
||||
|
||||
bat_note_lbl = lv_label_create(battery_container);
|
||||
lv_label_set_text(bat_note_lbl, "Auto-off below 3.0 V");
|
||||
lv_obj_set_style_text_font(bat_note_lbl, &font_styrene_16, 0);
|
||||
lv_obj_set_style_text_color(bat_note_lbl, COL_DIM, 0);
|
||||
lv_obj_align(bat_note_lbl, LV_ALIGN_BOTTOM_MID, 0, -36);
|
||||
|
||||
lv_obj_add_flag(battery_container, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
static void battery_screen_refresh(void) {
|
||||
if (!battery_container) return;
|
||||
char buf[48];
|
||||
|
||||
if (g_bat_pct < 0) {
|
||||
lv_label_set_text(bat_pct_lbl, "--%");
|
||||
} else {
|
||||
snprintf(buf, sizeof buf, "%d%%", g_bat_pct);
|
||||
lv_label_set_text(bat_pct_lbl, buf);
|
||||
}
|
||||
lv_obj_set_style_text_color(bat_pct_lbl, batt_color(g_bat_pct), 0);
|
||||
|
||||
lv_bar_set_value(bat_bar, (g_bat_pct < 0) ? 0 : g_bat_pct, LV_ANIM_OFF);
|
||||
lv_obj_set_style_bg_color(bat_bar, batt_color(g_bat_pct), LV_PART_INDICATOR);
|
||||
|
||||
if (g_bat_mv <= 0) {
|
||||
lv_label_set_text(bat_volt_lbl, "--.-- V");
|
||||
} else {
|
||||
snprintf(buf, sizeof buf, "%d.%02d V", g_bat_mv / 1000, (g_bat_mv % 1000) / 10);
|
||||
lv_label_set_text(bat_volt_lbl, buf);
|
||||
}
|
||||
|
||||
if (g_bat_charging) {
|
||||
lv_label_set_text(bat_time_lbl, LV_SYMBOL_CHARGE " Charging");
|
||||
lv_obj_set_style_text_color(bat_time_lbl, COL_GREEN, 0);
|
||||
} else if (g_bat_min < 0) {
|
||||
lv_label_set_text(bat_time_lbl, "Estimating time left...");
|
||||
lv_obj_set_style_text_color(bat_time_lbl, COL_DIM, 0);
|
||||
} else {
|
||||
int h = g_bat_min / 60, m = g_bat_min % 60;
|
||||
if (h > 0) snprintf(buf, sizeof buf, "~%dh %dm left", h, m);
|
||||
else snprintf(buf, sizeof buf, "~%dm left", m);
|
||||
lv_label_set_text(bat_time_lbl, buf);
|
||||
lv_obj_set_style_text_color(bat_time_lbl, COL_ACCENT, 0);
|
||||
}
|
||||
|
||||
// Reddens as the cell nears the cutoff. 3.0 V mirrors LOW_V_CUTOFF_MV in
|
||||
// main.cpp; below 3.2 V is the "getting close" warn band.
|
||||
bool near_cutoff = (!g_bat_charging && g_bat_mv > 0 && g_bat_mv < 3200);
|
||||
lv_obj_set_style_text_color(bat_note_lbl, near_cutoff ? COL_RED : COL_DIM, 0);
|
||||
}
|
||||
|
||||
// ======== Public API ========
|
||||
|
||||
void ui_init(void) {
|
||||
@@ -812,11 +1298,13 @@ void ui_init(void) {
|
||||
|
||||
// v2 screens — created hidden, shown on navigation.
|
||||
init_menu_screen(scr);
|
||||
init_stub_screen(scr);
|
||||
init_bt_screen(scr);
|
||||
init_soundpad_screen(scr);
|
||||
init_session_screen(scr);
|
||||
init_nowplaying_screen(scr);
|
||||
init_homeassist_screen(scr);
|
||||
init_dimmer_screen(scr);
|
||||
init_battery_screen(scr);
|
||||
|
||||
logo_img = lv_image_create(scr);
|
||||
lv_image_set_src(logo_img, &logo_dsc);
|
||||
@@ -832,13 +1320,16 @@ void ui_init(void) {
|
||||
lv_obj_set_style_text_color(nav_back_btn, COL_TEXT, 0);
|
||||
lv_obj_set_pos(nav_back_btn, L.margin + 4, L.title_y + 10); // clear of the rounded top-left corner
|
||||
lv_obj_add_flag(nav_back_btn, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_set_ext_click_area(nav_back_btn, 18);
|
||||
lv_obj_set_ext_click_area(nav_back_btn, 40); // generous tap zone — the bare chevron was hard to hit
|
||||
lv_obj_add_event_cb(nav_back_btn, nav_back_cb, LV_EVENT_CLICKED, NULL);
|
||||
lv_obj_add_flag(nav_back_btn, LV_OBJ_FLAG_HIDDEN);
|
||||
|
||||
battery_img = lv_image_create(scr);
|
||||
lv_image_set_src(battery_img, &battery_dscs[0]);
|
||||
lv_obj_set_pos(battery_img, L.scr_w - 48 - L.margin, L.title_y);
|
||||
lv_obj_add_flag(battery_img, LV_OBJ_FLAG_CLICKABLE); // tap → battery detail
|
||||
lv_obj_set_ext_click_area(battery_img, 16); // generous tap target near the corner
|
||||
lv_obj_add_event_cb(battery_img, battery_click_cb, LV_EVENT_CLICKED, NULL);
|
||||
}
|
||||
|
||||
void ui_update(const UsageData* data) {
|
||||
@@ -982,10 +1473,15 @@ void ui_tick_anim(void) {
|
||||
}
|
||||
|
||||
static screen_t prev_non_splash_screen = SCREEN_USAGE;
|
||||
static screen_t screen_before_battery = SCREEN_USAGE; // where the battery tap came from
|
||||
static void apply_battery_visibility(void) {
|
||||
if (!battery_img) return;
|
||||
if (current_screen == SCREEN_SPLASH) lv_obj_add_flag(battery_img, LV_OBJ_FLAG_HIDDEN);
|
||||
else lv_obj_clear_flag(battery_img, LV_OBJ_FLAG_HIDDEN);
|
||||
// Hidden on the splash, and on the battery detail itself (no point offering a
|
||||
// tap target for the screen you're already on).
|
||||
if (current_screen == SCREEN_SPLASH || current_screen == SCREEN_BATTERY)
|
||||
lv_obj_add_flag(battery_img, LV_OBJ_FLAG_HIDDEN);
|
||||
else
|
||||
lv_obj_clear_flag(battery_img, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
|
||||
// Tapping the splash/animations screen returns to the home (usage) screen.
|
||||
@@ -1000,9 +1496,20 @@ static void logo_click_cb(lv_event_t* e) {
|
||||
ui_show_screen(SCREEN_MENU);
|
||||
}
|
||||
|
||||
// Back chevron: from the launcher → home; from any app screen → launcher.
|
||||
// The battery icon (top-right, on every non-splash screen) opens the detail
|
||||
// screen. Remember where we came from so the back chevron returns there.
|
||||
static void battery_click_cb(lv_event_t* e) {
|
||||
(void)e;
|
||||
if (current_screen == SCREEN_SPLASH || current_screen == SCREEN_BATTERY) return;
|
||||
screen_before_battery = current_screen;
|
||||
ui_show_screen(SCREEN_BATTERY);
|
||||
}
|
||||
|
||||
// Back chevron: from the battery detail → wherever it was opened from; from the
|
||||
// launcher → home; from any other app screen → launcher.
|
||||
static void nav_back_cb(lv_event_t* e) {
|
||||
(void)e;
|
||||
if (current_screen == SCREEN_BATTERY) { ui_show_screen(screen_before_battery); return; }
|
||||
ui_show_screen(current_screen == SCREEN_MENU ? SCREEN_USAGE : SCREEN_MENU);
|
||||
}
|
||||
|
||||
@@ -1024,13 +1531,19 @@ static void update_nav_chrome(screen_t screen) {
|
||||
void ui_show_screen(screen_t screen) {
|
||||
lv_obj_add_flag(usage_container, LV_OBJ_FLAG_HIDDEN);
|
||||
if (menu_container) lv_obj_add_flag(menu_container, LV_OBJ_FLAG_HIDDEN);
|
||||
if (stub_container) lv_obj_add_flag(stub_container, LV_OBJ_FLAG_HIDDEN);
|
||||
if (bt_container) lv_obj_add_flag(bt_container, LV_OBJ_FLAG_HIDDEN);
|
||||
if (soundpad_container) lv_obj_add_flag(soundpad_container, LV_OBJ_FLAG_HIDDEN);
|
||||
if (session_container) lv_obj_add_flag(session_container, LV_OBJ_FLAG_HIDDEN);
|
||||
if (nowplaying_container) lv_obj_add_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN);
|
||||
if (homeassist_container) lv_obj_add_flag(homeassist_container, LV_OBJ_FLAG_HIDDEN);
|
||||
if (dimmer_container) lv_obj_add_flag(dimmer_container, LV_OBJ_FLAG_HIDDEN);
|
||||
if (battery_container) lv_obj_add_flag(battery_container, LV_OBJ_FLAG_HIDDEN);
|
||||
splash_hide();
|
||||
|
||||
// Leaving the dimmer disarms it so the wrist can't keep driving the light
|
||||
// from another screen.
|
||||
if (current_screen == SCREEN_DIMMER && screen != SCREEN_DIMMER) dim_armed = false;
|
||||
|
||||
switch (screen) {
|
||||
case SCREEN_SPLASH: splash_show(); break;
|
||||
case SCREEN_USAGE: lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_HIDDEN); break;
|
||||
@@ -1040,8 +1553,18 @@ void ui_show_screen(screen_t screen) {
|
||||
case SCREEN_NOWPLAYING: lv_obj_clear_flag(nowplaying_container, LV_OBJ_FLAG_HIDDEN); break;
|
||||
case SCREEN_BLUETOOTH: bt_refresh(); lv_obj_clear_flag(bt_container, LV_OBJ_FLAG_HIDDEN); break;
|
||||
case SCREEN_HOMEASSIST:
|
||||
stub_show_for(screen);
|
||||
lv_obj_clear_flag(stub_container, LV_OBJ_FLAG_HIDDEN);
|
||||
lv_obj_clear_flag(homeassist_container, LV_OBJ_FLAG_HIDDEN);
|
||||
break;
|
||||
case SCREEN_DIMMER:
|
||||
dim_armed = false;
|
||||
dim_refresh_dial();
|
||||
dim_update_status();
|
||||
ble_send_command("{\"cmd\":\"dimreq\"}"); // ask the daemon for a fresh light snapshot
|
||||
lv_obj_clear_flag(dimmer_container, LV_OBJ_FLAG_HIDDEN);
|
||||
break;
|
||||
case SCREEN_BATTERY:
|
||||
battery_screen_refresh();
|
||||
lv_obj_clear_flag(battery_container, LV_OBJ_FLAG_HIDDEN);
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
@@ -1071,7 +1594,12 @@ void ui_update_ble_status(ble_state_t state, const char* name, const char* mac)
|
||||
update_view_state();
|
||||
}
|
||||
|
||||
void ui_update_battery(int percent, bool charging) {
|
||||
void ui_update_battery(int percent, int voltage_mv, int minutes_left, bool charging) {
|
||||
g_bat_pct = percent;
|
||||
g_bat_mv = voltage_mv;
|
||||
g_bat_min = minutes_left;
|
||||
g_bat_charging = charging;
|
||||
|
||||
int idx;
|
||||
if (charging) {
|
||||
idx = 4;
|
||||
@@ -1088,4 +1616,40 @@ void ui_update_battery(int percent, bool charging) {
|
||||
}
|
||||
lv_image_set_src(battery_img, &battery_dscs[idx]);
|
||||
apply_battery_visibility();
|
||||
|
||||
// Keep the detail screen live while it's the one on display.
|
||||
if (current_screen == SCREEN_BATTERY) battery_screen_refresh();
|
||||
}
|
||||
|
||||
void ui_show_low_battery(void) {
|
||||
// Top layer so it covers whatever screen was active, including the splash.
|
||||
lv_obj_t* ov = lv_obj_create(lv_layer_top());
|
||||
lv_obj_set_size(ov, L.scr_w, L.scr_h);
|
||||
lv_obj_set_pos(ov, 0, 0);
|
||||
lv_obj_set_style_bg_color(ov, COL_BG, 0);
|
||||
lv_obj_set_style_bg_opa(ov, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_border_width(ov, 0, 0);
|
||||
lv_obj_set_style_pad_all(ov, 0, 0);
|
||||
lv_obj_clear_flag(ov, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t* icon = lv_label_create(ov);
|
||||
lv_label_set_text(icon, LV_SYMBOL_WARNING);
|
||||
lv_obj_set_style_text_font(icon, &lv_font_montserrat_28, 0);
|
||||
lv_obj_set_style_text_color(icon, COL_RED, 0);
|
||||
lv_obj_align(icon, LV_ALIGN_CENTER, 0, -70);
|
||||
|
||||
lv_obj_t* t = lv_label_create(ov);
|
||||
lv_label_set_text(t, "Battery critically low");
|
||||
lv_obj_set_style_text_font(t, L.bt_status_font, 0);
|
||||
lv_obj_set_style_text_color(t, COL_TEXT, 0);
|
||||
lv_obj_align(t, LV_ALIGN_CENTER, 0, -10);
|
||||
|
||||
lv_obj_t* s = lv_label_create(ov);
|
||||
lv_obj_set_width(s, L.content_w);
|
||||
lv_label_set_long_mode(s, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_style_text_align(s, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_label_set_text(s, "Shutting down to protect the cell");
|
||||
lv_obj_set_style_text_font(s, L.bt_device_font, 0);
|
||||
lv_obj_set_style_text_color(s, COL_DIM, 0);
|
||||
lv_obj_align(s, LV_ALIGN_CENTER, 0, 55);
|
||||
}
|
||||
|
||||
+32
-1
@@ -10,7 +10,9 @@ enum screen_t {
|
||||
SCREEN_SESSION, // today's tokens / cost (Phase 4)
|
||||
SCREEN_NOWPLAYING, // media now-playing (Phase 5)
|
||||
SCREEN_HOMEASSIST, // Home Assistant controls (Phase 6)
|
||||
SCREEN_DIMMER, // tilt-to-dim light control (Phase 6 step 3 / M3)
|
||||
SCREEN_BLUETOOTH, // BLE connection info
|
||||
SCREEN_BATTERY, // battery detail (voltage + time left) — opened by tapping the battery icon
|
||||
SCREEN_COUNT,
|
||||
};
|
||||
|
||||
@@ -21,4 +23,33 @@ void ui_show_screen(screen_t screen);
|
||||
void ui_toggle_splash(void);
|
||||
screen_t ui_get_current_screen(void);
|
||||
void ui_update_ble_status(ble_state_t state, const char* name, const char* mac);
|
||||
void ui_update_battery(int percent, bool charging);
|
||||
|
||||
// minutes_left: >=0 estimated minutes remaining, -1 estimating, -2 charging.
|
||||
void ui_update_battery(int percent, int voltage_mv, int minutes_left, bool charging);
|
||||
|
||||
// Full-screen "battery critically low" overlay shown by the protective cutoff in
|
||||
// main.cpp just before power_hal_shutdown(). Drawn on the top layer so it covers
|
||||
// whatever screen was active.
|
||||
void ui_show_low_battery(void);
|
||||
|
||||
// Dynamic Home-control buttons (Phase 7 M2). The desktop config defines each
|
||||
// button's action/entity; the watch stays dumb — it only renders the labels and
|
||||
// reports the pressed index (the daemon maps index → action). Labels arrive in
|
||||
// the RX payload's optional "btns" array. Cap kept small for the 2-column grid.
|
||||
#define UI_MAX_BUTTONS 6
|
||||
void ui_set_buttons(const char* const* labels, int count);
|
||||
|
||||
// Tilt-dimmer (Phase 6 step 3 / M3). On the Dimmer screen the watch reads its
|
||||
// IMU and ramps the brightness OR color temperature of the configured light
|
||||
// while "armed", sending absolute {"cmd":"bri"|"ct","v":..} to the daemon
|
||||
// (which maps it to the first HA entity). The wrist acts as a joystick: tilt
|
||||
// past the deadzone to change the value at a rate proportional to the tilt,
|
||||
// hold neutral to stop. BOOT arms and captures the neutral pose; PWR switches
|
||||
// the controlled parameter; a tap (or a few seconds of stillness) commits and
|
||||
// locks. The daemon pushes the light's current state as an RX "dim" object so
|
||||
// the dial starts from reality.
|
||||
void ui_dimmer_set_snapshot(bool on, int bri_pct, int ct_kelvin, int min_k, int max_k);
|
||||
void ui_dimmer_arm(void); // BOOT press on the Dimmer screen
|
||||
void ui_dimmer_switch_param(void); // PWR press on the Dimmer screen
|
||||
void ui_dimmer_tick(void); // called every loop; no-op unless armed
|
||||
bool ui_dimmer_is_armed(void); // main.cpp keeps the panel awake while true
|
||||
|
||||
Reference in New Issue
Block a user