Author SHA1 Message Date
wenilandClaude Opus 4.8 1c64386996 v2 host: portable desktop app (tray + FastAPI + WebView2 panel), unified config, HA control
Fold the Windows tray/daemon into one self-contained Clawdmeter.exe with a
settings UI, and wire the host side of the watch features:

- config.py: single %LOCALAPPDATA%\Clawdmeter\config.json (ha/buttons/settings),
  atomic writes, auto-migration from the old ha_config.json.
- server.py: local FastAPI (127.0.0.1:8723) — GET/PUT /api/config (token masked),
  POST /api/ha/test, GET /api/ha/entities, GET /api/status.
- web/index.html: brand-styled settings panel (Status/HA/Buttons/Settings tabs).
- panel.py: pywebview/WebView2 window, launched as its own process (pywebview and
  pystray both want the main thread); tray "Settings" opens it via --panel.
- daemon: HA command dispatch (toggle/bri/ct), dynamic button labels + index→
  action mapping, watch-battery low warning toast, and the dimmer "dim" snapshot
  (dimreq → light_snapshot of the first entity) so the watch dial seeds from HA.
- clawdmeter.spec / requirements: bundle fastapi+uvicorn+pywebview+webview backend.
- build-exe.ps1: ASCII-only (Windows PowerShell 5.1 mangles em-dashes under cp1251).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:20:44 +03:00
wenilandClaude Opus 4.8 578bc04248 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>
2026-07-09 20:20:04 +03:00
wenilandClaude Opus 4.8 a6d391e9d1 v2 Phase 6 (step 1): Home Assistant REST client + live self-test
daemon/ha_client.py: HAClient (brightness, color_temp, on/off, state snapshot)
over the HA REST API with smooth `transition`; token is read from a local
%LOCALAPPDATA%\Clawdmeter\ha_config.json and is never logged. Verified
end-to-end against a real Gledopto GL-C-006P tunable-white dimmer.

ha_config.example.json is the committed template; the real config (with the
token) lives outside the repo and is .gitignored as a safety net.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:33:38 +03:00
wenilandClaude Opus 4.8 39a24eebad v2: point release links at the current Gitea domain (bvrdo.online)
gitea.wenil.tech is the old (internal-DNS-only) domain; the live host is
gitea.bvrdo.online. Use a direct release-asset link so download works even
while the instance ROOT_URL still points at the internal IP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:12:08 +03:00
wenilandClaude Opus 4.8 67df140ca4 v2: link the Gitea release as the standalone .exe download
Point README-windows.md at the published v2.0-beta.1 release so users
download Clawdmeter.exe directly from Gitea instead of building it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:55:36 +03:00
34 changed files with 2170 additions and 86 deletions
+8
View File
@@ -27,3 +27,11 @@ __pycache__/
/build/ /build/
/dist/ /dist/
*_dbg.spec *_dbg.spec
# Local Home Assistant config (holds a secret token - never commit)
daemon/ha_config.json
# Session scratch — BLE daemon run logs + one-off on-device QA screenshots
/m3_daemon*.log
/dimmer_qa.png
/build-exe.log
+1 -1
View File
@@ -141,7 +141,7 @@ Run `pio run -d firmware` with no env to see the available board envs.
### Pair the device ### 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) ### Install the daemon (recommended)
+3 -3
View File
@@ -1,4 +1,4 @@
# build-exe.ps1 build the standalone Clawdmeter.exe (PyInstaller). # build-exe.ps1 - build the standalone Clawdmeter.exe (PyInstaller).
# #
# Produces dist\Clawdmeter.exe: a single self-contained tray + daemon executable # Produces dist\Clawdmeter.exe: a single self-contained tray + daemon executable
# that runs on ANY Windows 11 machine with no Python install and no pip. Build it # that runs on ANY Windows 11 machine with no Python install and no pip. Build it
@@ -26,7 +26,7 @@ Log "=== Clawdmeter exe build ==="
if (-not (Test-Path $PythonExe)) { if (-not (Test-Path $PythonExe)) {
Log "Creating virtual environment at .venv ..." Log "Creating virtual environment at .venv ..."
& python -m venv $VenvDir & python -m venv $VenvDir
if ($LASTEXITCODE -ne 0) { throw "venv creation failed (exit $LASTEXITCODE) is Python on PATH?" } if ($LASTEXITCODE -ne 0) { throw "venv creation failed (exit $LASTEXITCODE) - is Python on PATH?" }
} }
# 2. Runtime dependencies + PyInstaller (the only build-time extra) # 2. Runtime dependencies + PyInstaller (the only build-time extra)
@@ -45,4 +45,4 @@ $ExePath = Join-Path $RepoRoot "dist\Clawdmeter.exe"
if (-not (Test-Path $ExePath)) { throw "Build reported success but $ExePath is missing" } if (-not (Test-Path $ExePath)) { throw "Build reported success but $ExePath is missing" }
$sizeMB = [math]::Round((Get-Item $ExePath).Length / 1MB, 1) $sizeMB = [math]::Round((Get-Item $ExePath).Length / 1MB, 1)
Log "Build complete: $ExePath ($sizeMB MB)" Log "Build complete: $ExePath ($sizeMB MB)"
Log "Distribute this exe via a Gitea release it is intentionally not committed to git." Log "Distribute this exe via a Gitea release - it is intentionally not committed to git."
+21 -2
View File
@@ -21,18 +21,37 @@
from PyInstaller.utils.hooks import collect_all from PyInstaller.utils.hooks import collect_all
datas = [('firmware/src/logo.h', 'firmware/src')] # tray icon parsed at runtime datas = [
('firmware/src/logo.h', 'firmware/src'), # tray icon parsed at runtime
('daemon/web', 'daemon/web'), # Phase 7 control-panel UI (server._web_dir)
]
binaries = [] binaries = []
hiddenimports = [ hiddenimports = [
# Imported lazily inside tray_windows.main(), so name them explicitly. # Imported lazily inside tray_windows.main(), so name them explicitly.
'daemon.claude_usage_daemon_windows', 'daemon.claude_usage_daemon_windows',
'daemon.autostart_windows', 'daemon.autostart_windows',
'daemon.icon_assets', 'daemon.icon_assets',
# Phase 7 control panel (lazy string-imports inside tray_windows / server).
'daemon.config',
'daemon.server',
'daemon.panel',
'daemon.ha_client',
# The exact winrt media modules read_now_playing() pulls in. # The exact winrt media modules read_now_playing() pulls in.
'winrt.windows.media', 'winrt.windows.media',
'winrt.windows.media.control', 'winrt.windows.media.control',
# pywebview's Windows backend + pythonnet bridge load these dynamically.
'webview.platforms.edgechromium',
'clr',
] ]
for _pkg in ('winrt', 'bleak', 'pystray', 'PIL'): # Phase 7 adds the FastAPI control panel + the pywebview WebView2 window. uvicorn
# and pywebview both import their submodules (loop/protocol pickers; the
# edgechromium backend + bundled WebView2 DLLs) dynamically, which PyInstaller's
# static analysis misses — collect_all pulls submodules, binaries and data files.
# clr_loader/pythonnet ship the .NET runtime-config JSON pywebview's WinForms host
# needs. If a package is absent the build fails loudly (better than a silent
# blank window at runtime).
for _pkg in ('winrt', 'bleak', 'pystray', 'PIL',
'fastapi', 'uvicorn', 'webview', 'clr_loader', 'pythonnet'):
_d, _b, _h = collect_all(_pkg) _d, _b, _h = collect_all(_pkg)
datas += _d datas += _d
binaries += _b binaries += _b
+7 -1
View File
@@ -65,8 +65,14 @@ To run Clawdmeter on a machine **without Python**, use the single-file
`Clawdmeter.exe`. It bundles its own Python, the WinRT BLE stack and the `Clawdmeter.exe`. It bundles its own Python, the WinRT BLE stack and the
media-session reader, so nothing needs to be installed. media-session reader, so nothing needs to be installed.
> **Download:** the repo is private, so **sign in to Gitea first**, then download
> **`Clawdmeter.exe`** with this direct link:
> <https://gitea.bvrdo.online/wenil/clawdmeter/releases/download/v2.0-beta.1/Clawdmeter.exe>
> — or browse [all releases](https://gitea.bvrdo.online/wenil/clawdmeter/releases) for the newest build.
1. Pair the device with Windows once (see [Pair the device](#pair-the-device-one-time)). 1. Pair the device with Windows once (see [Pair the device](#pair-the-device-one-time)).
2. Get `Clawdmeter.exe` — download it from the project's Gitea release, or build it 2. Get `Clawdmeter.exe` — download it from the
[latest Gitea release](https://gitea.bvrdo.online/wenil/clawdmeter/releases/latest), or build it
yourself (below). yourself (below).
3. Double-click `Clawdmeter.exe`. The tray icon appears and the watch starts 3. Double-click `Clawdmeter.exe`. The tray icon appears and the watch starts
updating within ~10 seconds. updating within ~10 seconds.
+195 -6
View File
@@ -32,6 +32,14 @@ DEVICE_ADDRESS = os.environ.get("CLAWDMETER_ADDRESS")
SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001" SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001"
RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002" RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002"
REQ_CHAR_UUID = "4c41555a-4465-7669-6365-000000000004" REQ_CHAR_UUID = "4c41555a-4465-7669-6365-000000000004"
CMD_CHAR_UUID = "4c41555a-4465-7669-6365-000000000005" # watch → host commands (Phase 6 HA)
# Phase 7 M2 — dynamic watch buttons. The daemon pushes up to this many button
# labels (each truncated) in the RX payload's "btns" array on the ~60s heartbeat;
# the watch renders a grid and reports only the pressed index. Bounds keep the
# merged BLE payload comfortably under the firmware's 512-byte RX buffer.
WATCH_MAX_BUTTONS = 6
WATCH_LABEL_MAX = 16
POLL_INTERVAL = 60 # Anthropic rate-limit / usage poll cadence (seconds) POLL_INTERVAL = 60 # Anthropic rate-limit / usage poll cadence (seconds)
NOWPLAYING_INTERVAL = 3 # Windows media-session read cadence (seconds). The NOWPLAYING_INTERVAL = 3 # Windows media-session read cadence (seconds). The
@@ -296,9 +304,58 @@ async def scan_for_device():
class Session: class Session:
def __init__(self, client: BleakClient) -> None: def __init__(self, client: BleakClient, tray_state=None) -> None:
self.client = client self.client = client
self.tray_state = tray_state
self.refresh_requested = asyncio.Event() self.refresh_requested = asyncio.Event()
self.dim_requested = asyncio.Event() # watch opened the Dimmer screen → push a fresh light snapshot
self.ha = None # HAClient once a valid HA config is loaded
self.ha_entities: list = [] # controllable entity_ids from the config
self._loop = None # event loop captured for thread-safe BLE dispatch
self.low_bat_pct = 15 # warn at/below this watch battery %
self._battery_warned = False
self._last_logged_bat = None
self.buttons = [] # config buttons; list index == watch button index
def reload_buttons(self) -> None:
"""Re-read the config's button list so panel edits propagate to the watch
(called each poll). The list index is the index the watch reports back."""
try:
try:
from daemon.config import load_config
except ImportError:
from config import load_config
self.buttons = load_config().get("buttons") or []
except Exception as e:
log(f"Button reload failed: {e!r}")
def button_labels(self) -> list:
"""Compact label list for the RX 'btns' payload (count + length capped so
the merged BLE write stays under the firmware's 512-byte buffer)."""
out = []
for b in self.buttons[:WATCH_MAX_BUTTONS]:
lbl = str(b.get("label") or b.get("entity") or "Button").strip()
out.append(lbl[:WATCH_LABEL_MAX])
return out
async def dim_snapshot(self) -> dict | None:
"""Compact live state of the tilt-dimmer's light for the RX 'dim' object,
so the watch dial seeds from reality. The dimmer targets the first HA
entity (same default the bri/ct commands fall back to). Returns None when
HA is off or the read fails the watch then just shows its last value."""
if not self.ha or not self.ha_entities:
return None
snap = await self.ha.light_snapshot(self.ha_entities[0])
if not snap:
return None
out = {"on": 1 if snap["on"] else 0,
"mink": int(snap["min_kelvin"]),
"maxk": int(snap["max_kelvin"])}
if snap["brightness_pct"] is not None:
out["bri"] = int(snap["brightness_pct"])
if snap["color_temp_kelvin"] is not None:
out["ct"] = int(snap["color_temp_kelvin"])
return out
def _on_refresh(self, _char, _data: bytearray) -> None: def _on_refresh(self, _char, _data: bytearray) -> None:
log("Refresh requested by device") log("Refresh requested by device")
@@ -316,6 +373,112 @@ class Session:
except (BleakError, ValueError, OSError) as e: except (BleakError, ValueError, OSError) as e:
log(f"Refresh subscription unavailable: {e}") log(f"Refresh subscription unavailable: {e}")
async def setup_command_subscription(self) -> None:
# Watch → host channel (…0005): battery telemetry (always) plus HA commands
# (when HA is configured). We subscribe regardless of HA so the low-battery
# warning works even with Home Assistant unset. Imports are lazy so a
# missing config/dep can never break daemon startup, and so they resolve
# after `log` is defined (ha_client imports it).
self._loop = asyncio.get_running_loop() # thread-safe dispatch from the BLE callback
try:
try:
from daemon.ha_client import HAClient
from daemon.config import load_config, ha_settings
except ImportError:
from ha_client import HAClient
from config import load_config, ha_settings
cfg = load_config()
self.low_bat_pct = int(cfg.get("settings", {}).get("low_battery_pct", 15))
self.buttons = cfg.get("buttons") or []
ha = ha_settings(cfg)
if ha:
self.ha = HAClient(ha["url"], ha["token"])
self.ha_entities = ha["entities"]
except Exception as e:
log(f"Config/HA init failed ({e!r}); HA control disabled")
try:
await self.client.start_notify(CMD_CHAR_UUID, self._on_command)
log(f"Command channel ready (HA={'on' if self.ha else 'off'}, "
f"low-batt={self.low_bat_pct}%, entities={self.ha_entities})")
except (BleakError, ValueError, OSError) as e:
log(f"Command subscription unavailable: {e}")
def _on_command(self, _char, data: bytearray) -> None:
try:
payload = json.loads(bytes(data).decode("utf-8", "replace"))
except (ValueError, UnicodeDecodeError):
log(f"Watch msg: bad payload {bytes(data)!r}")
return
# Battery telemetry ({"bat":pct,"mv":..,"chg":0/1}) is handled inline (quick,
# no await). HA commands ({"cmd":...}) are dispatched onto the loop because
# bleak may deliver this callback on a non-loop thread.
if "bat" in payload:
self._handle_battery(payload)
return
# Dimmer screen opened (M3): ask the poll loop to push a fresh light
# snapshot on its next tick (~3s). asyncio.Event isn't thread-safe and
# this callback may run off-loop, so flip it via the loop.
if payload.get("cmd") == "dimreq":
loop = self._loop
if loop is not None:
loop.call_soon_threadsafe(self.dim_requested.set)
return
# Phase 7 M2: a watch button press carries only its index — map it to the
# configured action/entity/value here (the watch stays dumb).
if payload.get("cmd") == "btn":
i = payload.get("i")
if not isinstance(i, int) or not (0 <= i < len(self.buttons)):
log(f"Watch button {i}: out of range (have {len(self.buttons)})")
return
b = self.buttons[i]
payload = {"cmd": b.get("action") or "toggle", "e": b.get("entity")}
if b.get("value") is not None:
payload["v"] = b["value"]
log(f"Watch button {i} -> {payload}")
loop = self._loop
if loop is None:
return
asyncio.run_coroutine_threadsafe(self._dispatch_command(payload), loop)
def _handle_battery(self, payload: dict) -> None:
pct = payload.get("bat")
charging = bool(payload.get("chg", 0))
if not isinstance(pct, int):
return
if pct != self._last_logged_bat:
self._last_logged_bat = pct
log(f"Watch battery: {pct}%{' (charging)' if charging else ''}")
if self.tray_state is not None:
self.tray_state.battery_pct = pct
# Warn once on the way down; re-arm when charging or comfortably recovered.
if charging or pct > self.low_bat_pct + 10:
self._battery_warned = False
if not charging and pct <= self.low_bat_pct and not self._battery_warned:
self._battery_warned = True
log(f"Watch battery low: {pct}% (<= {self.low_bat_pct}%)")
if self.tray_state is not None:
self.tray_state.toasts.put(
("Clawdmeter", f"Watch battery low - {pct}%. Time to charge it."))
async def _dispatch_command(self, payload: dict) -> None:
if not self.ha:
return
cmd = payload.get("cmd")
entity = payload.get("e") or (self.ha_entities[0] if self.ha_entities else None)
if not entity:
log("HA cmd: no entity to target")
return
if cmd == "toggle":
ok = await self.ha.toggle(entity)
elif cmd == "bri": # step 3: brightness %, joystick
ok = await self.ha.set_brightness(entity, float(payload.get("v", 0)))
elif cmd == "ct": # step 3: color temperature, kelvin
ok = await self.ha.set_color_temp(entity, float(payload.get("v", 0)))
else:
log(f"HA cmd: unknown cmd {cmd!r}")
return
log(f"HA cmd {cmd} -> {'ok' if ok else 'FAIL'}")
async def write_payload(self, payload: dict) -> bool: async def write_payload(self, payload: dict) -> bool:
# ensure_ascii=False keeps non-Latin titles as compact UTF-8 (~2 bytes/char) # ensure_ascii=False keeps non-Latin titles as compact UTF-8 (~2 bytes/char)
# instead of \uXXXX (6 bytes), which roughly thirds the size of a Cyrillic # instead of \uXXXX (6 bytes), which roughly thirds the size of a Cyrillic
@@ -661,8 +824,9 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
return False return False
log("Connected") log("Connected")
session = Session(client) session = Session(client, tray_state)
await session.setup_refresh_subscription() await session.setup_refresh_subscription()
await session.setup_command_subscription()
# Two cadences share one connection: the Anthropic usage / rate-limit poll runs # Two cadences share one connection: the Anthropic usage / rate-limit poll runs
# every POLL_INTERVAL (60s) while the Windows media session is read every # every POLL_INTERVAL (60s) while the Windows media session is read every
@@ -680,10 +844,14 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
now = time.time() now = time.time()
claude_due = (session.refresh_requested.is_set() claude_due = (session.refresh_requested.is_set()
or (now - last_claude_poll) >= POLL_INTERVAL) or (now - last_claude_poll) >= POLL_INTERVAL)
dim_due = session.dim_requested.is_set() # watch opened the Dimmer screen
if dim_due:
session.dim_requested.clear()
auth_problem = False auth_problem = False
if claude_due: if claude_due:
session.refresh_requested.clear() session.refresh_requested.clear()
session.reload_buttons() # pick up desktop-panel edits to the button set
# Local token usage (Session screen) needs no network — compute it # Local token usage (Session screen) needs no network — compute it
# every cycle so the watch keeps updating even with no/expired token. # every cycle so the watch keeps updating even with no/expired token.
@@ -744,13 +912,29 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
log(f"Now-playing skipped: {e!r}") log(f"Now-playing skipped: {e!r}")
np = {"np": 0} np = {"np": 0}
# Light snapshot for the tilt-dimmer dial — fetched only when the watch
# opens the Dimmer screen (dim_due), to seed the dial from reality. No
# per-heartbeat fetch: during a session the watch is authoritative (it
# streams absolute values), and it re-requests on every re-open. Best-
# effort — a slow/dead HA just omits the field, never stalls the loop.
dim_snap = None
if dim_due:
try:
dim_snap = await session.dim_snapshot()
except Exception as e:
log(f"Dim snapshot skipped: {e!r}")
# Write when the usage data was just refreshed (this is also the ~60s # Write when the usage data was just refreshed (this is also the ~60s
# heartbeat) or when the track / playback state changed since the last # heartbeat), when the track / playback state changed since the last
# write — skip otherwise so the link, and the field log, stay quiet # write, or when the watch asked for a dimmer snapshot — skip otherwise
# between changes instead of repeating an identical payload every 3s. # so the link, and the field log, stay quiet between changes.
if claude_due or np != last_np_sent: if claude_due or dim_due or np != last_np_sent:
payload = dict(cached) payload = dict(cached)
payload.update(np) payload.update(np)
if claude_due:
payload["btns"] = session.button_labels() # ~60s heartbeat only
if dim_snap is not None:
payload["dim"] = dim_snap
if await session.write_payload(payload): if await session.write_payload(payload):
used_successfully = True used_successfully = True
consecutive_failures = 0 # D-03: reset on success consecutive_failures = 0 # D-03: reset on success
@@ -790,6 +974,11 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
await client.disconnect() await client.disconnect()
except (BleakError, OSError): except (BleakError, OSError):
pass pass
if session.ha:
try:
await session.ha.aclose()
except Exception:
pass
log("Device disconnected" if not stop_event.is_set() else "Stopping") log("Device disconnected" if not stop_event.is_set() else "Stopping")
return used_successfully return used_successfully
+116
View File
@@ -0,0 +1,116 @@
"""Unified Clawdmeter configuration (Phase 7).
Single source of truth at ``%LOCALAPPDATA%\\Clawdmeter\\config.json``, shared by
the tray daemon and the FastAPI control panel. Replaces the Phase-6
``ha_config.json`` (auto-migrated on first load).
Shape::
{
"version": 1,
"ha": {"url": str, "token": str, "entities": [str, ...]},
"buttons": [{"id": str, "label": str, "icon": str,
"action": "toggle"|"bri"|"ct", "entity": str, "value": int|None}],
"settings": {"device_address": str, "autostart": bool}
}
The token is a secret: it lives only in this file (outside the repo, gitignored)
and is NEVER logged (only its length) same discipline as ha_client.py. The
control panel masks it in API responses.
"""
import json
import os
from pathlib import Path
CONFIG_VERSION = 1
DEFAULT_PORT = 8723 # FastAPI control panel — bound to 127.0.0.1 only
VALID_ACTIONS = ("toggle", "bri", "ct")
def _dir() -> Path:
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
return base / "Clawdmeter"
def config_path() -> Path:
if override := os.environ.get("CLAWDMETER_CONFIG"):
return Path(override)
return _dir() / "config.json"
def _legacy_ha_path() -> Path:
return _dir() / "ha_config.json"
def default_config() -> dict:
return {
"version": CONFIG_VERSION,
"ha": {"url": "", "token": "", "entities": []},
"buttons": [],
"settings": {"device_address": "", "autostart": False, "low_battery_pct": 15},
}
def _migrate_legacy(cfg: dict) -> dict:
"""Import the Phase-6 ha_config.json into the unified config when the unified
HA section is still empty. Non-destructive: the old file is left in place."""
legacy = _legacy_ha_path()
if cfg["ha"]["url"] or not legacy.exists():
return cfg
try:
# utf-8-sig: PowerShell's Out-File writes a BOM that json.loads chokes on.
old = json.loads(legacy.read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError):
return cfg
cfg["ha"]["url"] = (old.get("url") or "").strip().rstrip("/")
cfg["ha"]["token"] = (old.get("token") or "").strip()
cfg["ha"]["entities"] = list(old.get("entities") or [])
return cfg
def load_config() -> dict:
"""Load the unified config, filling defaults for any missing section so new
keys added in later versions always resolve. Never raises."""
cfg = default_config()
try:
loaded = json.loads(config_path().read_text(encoding="utf-8-sig"))
except (OSError, json.JSONDecodeError):
loaded = None
if isinstance(loaded, dict):
for section in ("ha", "settings"):
if isinstance(loaded.get(section), dict):
cfg[section].update(loaded[section])
if isinstance(loaded.get("buttons"), list):
cfg["buttons"] = loaded["buttons"]
if not cfg["ha"]["url"] and not cfg["ha"]["token"]:
cfg = _migrate_legacy(cfg)
# Normalize the HA section the same way ha_client expects it.
cfg["ha"]["url"] = (cfg["ha"].get("url") or "").strip().rstrip("/")
cfg["ha"]["token"] = (cfg["ha"].get("token") or "").strip()
cfg["ha"]["entities"] = list(cfg["ha"].get("entities") or [])
return cfg
def save_config(cfg: dict) -> None:
"""Persist atomically (temp file + os.replace) so a crash mid-write can't
leave a truncated config."""
path = config_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
def ha_settings(cfg: dict | None = None) -> dict | None:
"""Return {url, token, entities} when HA is configured (url + real token),
else None the shape the daemon needs to build an HAClient. Logging of the
token is the caller's responsibility (ha_client logs only the length)."""
if cfg is None:
cfg = load_config()
ha = cfg.get("ha", {})
url = (ha.get("url") or "").strip().rstrip("/")
token = (ha.get("token") or "").strip()
if not url or not token or token.startswith("PASTE_"):
return None
return {"url": url, "token": token, "entities": list(ha.get("entities") or [])}
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Home Assistant REST client for the Clawdmeter daemon (Phase 6).
The watch stays "dumb": it sends a short command over BLE (which entity, which
parameter, what value) and this module turns that into a Home Assistant service
call over the local network. Everything HA-specific the base URL, the
long-lived access token, the controllable entities lives on the PC, never on
the device.
Config lives OUTSIDE the repo at %LOCALAPPDATA%\\Clawdmeter\\ha_config.json so
the token is never committed. Copy daemon/ha_config.example.json there and fill
in `url` + `token`. The token is a secret: it is sent only as the HTTP
Authorization header and is NEVER written to any log (mirrors how the OAuth
secret is handled in the main daemon).
Live self-test against your real light (after the config is filled in), run
from the repo root with the venv python:
.venv\\Scripts\\python.exe -m daemon.ha_client
"""
import asyncio
import json
import os
from pathlib import Path
import httpx
# Reuse the daemon's logger so HA lines land in the same daemon.log. Importing
# the daemon module is safe (the tray already imports it; it has no import-time
# side effects beyond setting up the file logger). Fall back to print() when run
# in isolation before packaging.
try:
from daemon.claude_usage_daemon_windows import log
except Exception: # pragma: no cover - standalone/fallback
def log(msg: str) -> None:
print(msg, flush=True)
# GL-C-006P advertises feature 32 (transition), so every change can EASE instead
# of snapping. Keep it short: the joystick sends ~5x/sec, so a long transition
# would lag behind the wrist. 0.3s reads as "smooth" without feeling laggy.
DEFAULT_TRANSITION = 0.3
DEFAULT_MIN_KELVIN = 2000
DEFAULT_MAX_KELVIN = 6500
def _config_path() -> Path:
if override := os.environ.get("CLAWDMETER_HA_CONFIG"):
return Path(override)
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
return base / "Clawdmeter" / "ha_config.json"
def load_ha_config() -> dict | None:
"""Load {url, token, entities} from the local HA config file.
Returns None (with a helpful log) when the file is missing or still holds the
placeholder token, so the daemon runs fine with HA simply disabled.
"""
path = _config_path()
try:
# utf-8-sig: PowerShell's `Out-File -Encoding utf8` writes a BOM, which
# would otherwise make json.loads choke on the first character.
raw = path.read_text(encoding="utf-8-sig")
except OSError:
log(f"HA: no config at {path} - HA control disabled "
f"(copy daemon/ha_config.example.json there)")
return None
try:
cfg = json.loads(raw)
except json.JSONDecodeError as e:
log(f"HA: config at {path} is not valid JSON ({e}) — HA control disabled")
return None
url = (cfg.get("url") or "").strip().rstrip("/")
token = (cfg.get("token") or "").strip()
entities = cfg.get("entities") or []
if not url or not token or token.startswith("PASTE_"):
log("HA: config present but `url`/`token` not filled in - HA control disabled")
return None
# Confirm load WITHOUT ever logging the token itself.
log(f"HA: config loaded - url={url}, entities={entities}, token len={len(token)}")
return {"url": url, "token": token, "entities": list(entities)}
class HAClient:
"""Thin async wrapper over the Home Assistant REST API.
One long-lived httpx client; calls are local and quick. Methods return
True/False (or parsed state) and never raise on an HTTP/Zigbee failure a
dead HA or a flaky mesh must not crash the daemon loop.
"""
def __init__(self, url: str, token: str, *, timeout: float = 10.0) -> None:
self._url = url.rstrip("/")
self._http = httpx.AsyncClient(
timeout=timeout,
headers={"Authorization": f"Bearer {token}",
"Content-Type": "application/json"},
)
async def aclose(self) -> None:
await self._http.aclose()
async def _service(self, domain: str, service: str, data: dict) -> bool:
url = f"{self._url}/api/services/{domain}/{service}"
try:
resp = await self._http.post(url, json=data)
except httpx.HTTPError as e:
log(f"HA: {domain}.{service} failed: {e}")
return False
if resp.status_code >= 400:
# 401 (token) and 400 (bad entity) both land here. Log status + short
# body only — never the request headers, which carry the token.
log(f"HA: {domain}.{service} HTTP {resp.status_code}: {resp.text[:160]}")
return False
return True
async def get_state(self, entity_id: str) -> dict | None:
url = f"{self._url}/api/states/{entity_id}"
try:
resp = await self._http.get(url)
except httpx.HTTPError as e:
log(f"HA: get_state({entity_id}) failed: {e}")
return None
if resp.status_code >= 400:
log(f"HA: get_state({entity_id}) HTTP {resp.status_code}")
return None
try:
return resp.json()
except ValueError:
return None
# --- light helpers --------------------------------------------------------
async def turn_off(self, entity_id: str, transition: float = DEFAULT_TRANSITION) -> bool:
return await self._service("light", "turn_off",
{"entity_id": entity_id, "transition": transition})
async def toggle(self, entity_id: str) -> bool:
return await self._service("light", "toggle", {"entity_id": entity_id})
async def set_brightness(self, entity_id: str, pct: float,
transition: float = DEFAULT_TRANSITION) -> bool:
"""Set brightness 0-100 %. 0 (or below) turns the light off."""
pct = max(0, min(100, round(pct)))
if pct <= 0:
return await self.turn_off(entity_id, transition)
return await self._service("light", "turn_on", {
"entity_id": entity_id, "brightness_pct": pct, "transition": transition})
async def set_color_temp(self, entity_id: str, kelvin: float,
min_k: int = DEFAULT_MIN_KELVIN,
max_k: int = DEFAULT_MAX_KELVIN,
transition: float = DEFAULT_TRANSITION) -> bool:
kelvin = int(max(min_k, min(max_k, round(kelvin))))
return await self._service("light", "turn_on", {
"entity_id": entity_id, "color_temp_kelvin": kelvin, "transition": transition})
async def light_snapshot(self, entity_id: str) -> dict | None:
"""Read the light's current values so the watch dial starts from the real
state instead of 0. Returns normalized fields, or None on failure."""
st = await self.get_state(entity_id)
if not st:
return None
attrs = st.get("attributes", {})
bri_255 = attrs.get("brightness") # 0-255, or None when off
bri_pct = round(bri_255 / 255 * 100) if isinstance(bri_255, (int, float)) else None
return {
"on": st.get("state") == "on",
"brightness_pct": bri_pct,
"color_temp_kelvin": attrs.get("color_temp_kelvin"),
"min_kelvin": attrs.get("min_color_temp_kelvin", DEFAULT_MIN_KELVIN),
"max_kelvin": attrs.get("max_color_temp_kelvin", DEFAULT_MAX_KELVIN),
}
async def _self_test() -> int:
"""Live smoke test: drive the first configured light so you can SEE it react,
then restore its original state."""
cfg = load_ha_config()
if not cfg:
return 1
if not cfg["entities"]:
log("HA self-test: no entities in config")
return 1
eid = cfg["entities"][0]
ha = HAClient(cfg["url"], cfg["token"])
try:
snap = await ha.light_snapshot(eid)
if snap is None:
log(f"HA self-test: could not read {eid} - check url / token / entity_id")
return 1
log(f"HA self-test: {eid} now -> {snap}")
min_k, max_k = snap["min_kelvin"], snap["max_kelvin"]
sequence = [
("brightness 30%", lambda: ha.set_brightness(eid, 30)),
("brightness 80%", lambda: ha.set_brightness(eid, 80)),
(f"warm {min_k}K", lambda: ha.set_color_temp(eid, min_k, min_k, max_k)),
(f"cool {max_k}K", lambda: ha.set_color_temp(eid, max_k, min_k, max_k)),
]
for label, action in sequence:
ok = await action()
log(f"HA self-test: {label} -> {'ok' if ok else 'FAIL'}")
await asyncio.sleep(1.3)
# Restore the original state.
if snap["on"]:
if snap["brightness_pct"]:
await ha.set_brightness(eid, snap["brightness_pct"])
if snap["color_temp_kelvin"]:
await ha.set_color_temp(eid, snap["color_temp_kelvin"], min_k, max_k)
else:
await ha.turn_off(eid)
log("HA self-test: done (original state restored)")
return 0
finally:
await ha.aclose()
if __name__ == "__main__":
raise SystemExit(asyncio.run(_self_test()))
+6
View File
@@ -0,0 +1,6 @@
{
"_comment": "Home Assistant config for Clawdmeter Phase 6. COPY this file to %LOCALAPPDATA%\\Clawdmeter\\ha_config.json and fill in url + token. The real file lives OUTSIDE the repo so the token is never committed. token = a Home Assistant Long-Lived Access Token (HA -> your profile -> Security -> Create Token).",
"url": "http://homeassistant.local:8123",
"token": "PASTE_LONG_LIVED_ACCESS_TOKEN_HERE",
"entities": ["light.dimmer_v_spalne"]
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""WebView2 settings window for Clawdmeter (Phase 7, M1).
Opens a native Windows WebView2 window (via pywebview) onto the local control
panel the tray process already serves on 127.0.0.1. Launched as a SEPARATE
PROCESS from the tray ``Clawdmeter.exe --panel`` when frozen, ``python -m
daemon.panel`` in source because pywebview and pystray each need to own the
main thread and cannot coexist in one process (see tray_windows._on_settings).
The port comes from CLAWDMETER_PANEL_PORT (set by the tray when it spawns us)
and falls back to the config default, so the window always points at the server
the tray actually started.
"""
from __future__ import annotations
import os
import sys
import time
import urllib.request
# Make the `daemon` package importable when run as a bare script or frozen exe,
# mirroring tray_windows.py's bootstrap (logon autostart starts us with cwd =
# System32, and the frozen exe loads the package from the bundle root).
if getattr(sys, "frozen", False):
_REPO_ROOT = sys._MEIPASS # type: ignore[attr-defined]
else:
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
_VENV_SITE = os.path.join(_REPO_ROOT, ".venv", "Lib", "site-packages")
if os.path.isdir(_VENV_SITE):
import site
site.addsitedir(_VENV_SITE)
try:
from daemon import config as cfgmod
except ImportError: # running as a plain script (cwd on path)
import config as cfgmod
WINDOW_TITLE = "Clawdmeter"
WINDOW_W = 860
WINDOW_H = 720
WINDOW_MIN = (640, 560)
BRAND_BG = "#131211" # paint the chrome brand-dark so there's no white flash
def _port() -> int:
raw = os.environ.get("CLAWDMETER_PANEL_PORT")
if raw:
try:
return int(raw)
except ValueError:
pass
return cfgmod.DEFAULT_PORT
def _wait_for_server(url: str, timeout: float = 6.0) -> bool:
"""Poll the server's status endpoint until it answers or the timeout elapses.
The tray starts the HTTP server in a thread a moment before it can spawn us,
so a freshly-clicked Settings might briefly beat the socket. Polling avoids a
blank window in that race; a miss just means we open anyway and the UI's own
fetch retries.
"""
deadline = time.time() + timeout
probe = url.rstrip("/") + "/api/status"
while time.time() < deadline:
try:
with urllib.request.urlopen(probe, timeout=1.0) as r:
if r.status == 200:
return True
except Exception:
time.sleep(0.25)
return False
def run() -> None:
"""Open the control-panel window and block until the user closes it."""
import webview # imported here so the tray never pays for it unless --panel
url = f"http://127.0.0.1:{_port()}/"
_wait_for_server(url)
webview.create_window(
WINDOW_TITLE, url,
width=WINDOW_W, height=WINDOW_H,
min_size=WINDOW_MIN,
background_color=BRAND_BG,
)
# gui defaults to auto-detect; on Windows 11 that resolves to EdgeChromium
# (WebView2), which ships with the OS — the modern engine the brand CSS needs.
# start() blocks on the native GUI loop until the window closes.
webview.start()
if __name__ == "__main__":
run()
+7
View File
@@ -9,3 +9,10 @@ Pillow
# media-control projection. pip resolves them to the same winrt-runtime as bleak. # media-control projection. pip resolves them to the same winrt-runtime as bleak.
winrt-Windows.Media winrt-Windows.Media
winrt-Windows.Media.Control winrt-Windows.Media.Control
# Phase 7 control panel: a local FastAPI server (bound to 127.0.0.1) serves the
# brand UI, and pywebview hosts it in a native Edge WebView2 window. pywebview
# pulls pythonnet (clr) on Windows for the EdgeChromium backend. httpx (above)
# is reused for the HA test/entities calls.
fastapi
uvicorn
pywebview
+166
View File
@@ -0,0 +1,166 @@
"""Local control-panel HTTP API for the Clawdmeter app (Phase 7, M1).
A FastAPI app bound to 127.0.0.1 only. Serves the brand-styled web UI (``web/``)
and a small REST API over the unified config (``config.py``). Runs in a daemon
thread alongside the tray and the BLE daemon one process, one exe.
Security: bound to loopback; the HA token is masked in GET responses and never
logged (mirrors ha_client / config).
"""
from __future__ import annotations
import copy
import os
import sys
import threading
from pathlib import Path
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
try:
from daemon import config as cfgmod
except ImportError: # running as a plain script (cwd on path)
import config as cfgmod
MASK = "********" # what the UI sees instead of the real token; echo it back to keep it
def _web_dir() -> Path:
"""The static UI directory: next to this file in source, under the PyInstaller
bundle root when frozen (added via clawdmeter.spec datas)."""
if getattr(sys, "frozen", False):
return Path(sys._MEIPASS) / "daemon" / "web" # type: ignore[attr-defined]
return Path(__file__).parent / "web"
def _masked(cfg: dict) -> dict:
c = copy.deepcopy(cfg)
if c.get("ha", {}).get("token"):
c["ha"]["token"] = MASK
return c
app = FastAPI(title="Clawdmeter")
@app.get("/api/config")
def get_config() -> dict:
return _masked(cfgmod.load_config())
class ConfigIn(BaseModel):
ha: dict | None = None
buttons: list | None = None
settings: dict | None = None
@app.put("/api/config")
def put_config(incoming: ConfigIn) -> dict:
cfg = cfgmod.load_config()
data = incoming.model_dump(exclude_none=True)
if "ha" in data:
ha = dict(data["ha"])
# Mask echoed back unchanged => keep the stored token (UI never holds it).
if ha.get("token") == MASK:
ha["token"] = cfg["ha"]["token"]
cfg["ha"].update(ha)
if "settings" in data:
cfg["settings"].update(data["settings"])
if "buttons" in data:
cfg["buttons"] = data["buttons"]
cfgmod.save_config(cfg)
return _masked(cfg)
def _resolve_token(token: str) -> str:
return cfgmod.load_config()["ha"]["token"] if token == MASK else token
@app.post("/api/ha/test")
async def ha_test(body: dict) -> dict:
url = (body.get("url") or "").strip().rstrip("/")
token = _resolve_token((body.get("token") or "").strip())
if not url or not token:
raise HTTPException(status_code=400, detail="url and token are required")
try:
async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.get(f"{url}/api/",
headers={"Authorization": f"Bearer {token}"})
except httpx.HTTPError as e:
return {"ok": False, "error": str(e)}
if resp.status_code == 200:
try:
msg = resp.json().get("message", "API running")
except ValueError:
msg = "API running"
return {"ok": True, "message": msg}
return {"ok": False, "error": f"HTTP {resp.status_code}"}
@app.get("/api/ha/entities")
async def ha_entities() -> dict:
ha = cfgmod.ha_settings()
if not ha:
return {"entities": [], "error": "Home Assistant not configured"}
try:
async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.get(f"{ha['url']}/api/states",
headers={"Authorization": f"Bearer {ha['token']}"})
except httpx.HTTPError as e:
return {"entities": [], "error": str(e)}
if resp.status_code != 200:
return {"entities": [], "error": f"HTTP {resp.status_code}"}
try:
states = resp.json()
except ValueError:
return {"entities": [], "error": "bad response"}
lights = [s["entity_id"] for s in states
if isinstance(s, dict) and str(s.get("entity_id", "")).startswith("light.")]
return {"entities": sorted(lights)}
_status_provider = None # set by the tray to expose live BLE/daemon state
def set_status_provider(fn) -> None:
"""The tray injects a callable returning the live status dict (connected,
battery, state). Kept out of import-time so server.py runs standalone."""
global _status_provider
_status_provider = fn
@app.get("/api/status")
def get_status() -> dict:
if _status_provider is not None:
try:
return _status_provider()
except Exception:
pass
return {"connected": False, "battery": None, "state": "unknown"}
# Static UI mounted LAST so the /api/* routes above take precedence.
_wd = _web_dir()
if _wd.exists():
app.mount("/", StaticFiles(directory=str(_wd), html=True), name="web")
def serve_in_thread(port: int | None = None) -> threading.Thread:
"""Start uvicorn on 127.0.0.1 in a daemon thread; return the thread."""
import uvicorn
p = port or cfgmod.DEFAULT_PORT
server = uvicorn.Server(uvicorn.Config(
app, host="127.0.0.1", port=p, log_level="warning"))
t = threading.Thread(target=server.run, daemon=True, name="clawd-http")
t.start()
return t
if __name__ == "__main__":
# Dev: run the server in the foreground with autoreload-free uvicorn.
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=cfgmod.DEFAULT_PORT, log_level="info")
+95
View File
@@ -18,6 +18,8 @@ Run: python -m pytest daemon/tests/test_windows_tray.py -x -q
""" """
import os import os
import queue
import subprocess
import sys import sys
import threading import threading
import time import time
@@ -67,6 +69,8 @@ class TrayState:
self.state: str = "scanning" # "connected" | "scanning" | "error" self.state: str = "scanning" # "connected" | "scanning" | "error"
self.reason: str = "" # error reason string (D-04) self.reason: str = "" # error reason string (D-04)
self.last_sync: float | None = None # time.time() of last successful write self.last_sync: float | None = None # time.time() of last successful write
self.battery_pct: int | None = None # latest watch battery %, from the …0005 channel
self.toasts: "queue.Queue" = queue.Queue() # (title, message) toasts for the tray to show
# Populated by daemon main() at startup: # Populated by daemon main() at startup:
self.loop = None # asyncio running loop (for call_soon_threadsafe) self.loop = None # asyncio running loop (for call_soon_threadsafe)
@@ -162,6 +166,34 @@ def _acquire_single_instance():
return handle return handle
# ---------------------------------------------------------------------------
# control-panel glue (Phase 7): live status feed + settings-window subprocess
# ---------------------------------------------------------------------------
def _status_dict(ts: TrayState) -> dict:
"""Live status for the control panel's GET /api/status. Shape matches what
web/index.html reads (connected / state / battery / last_sync); a pure read
of TrayState scalars, safe to call from the server's request thread."""
return {
"connected": ts.state == "connected",
"state": ts.state,
"reason": ts.reason,
"battery": ts.battery_pct,
"last_sync": ts.last_sync,
}
def _panel_argv() -> list:
"""Command that launches the settings window as a SEPARATE process. Frozen:
re-invoke this same exe with --panel. Source: run panel.py by ABSOLUTE path
not ``-m daemon.panel``, which would break under autostart (cwd = System32).
panel.py rebuilds its own sys.path from __file__, so cwd doesn't matter. Kept
a separate process because pywebview wants the main thread, which pystray owns."""
if getattr(sys, "frozen", False):
return [sys.executable, "--panel"]
return [sys.executable, os.path.join(_REPO_ROOT, "daemon", "panel.py")]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# main() — tray entry (pystray on main thread, daemon loop in bg thread) # main() — tray entry (pystray on main thread, daemon loop in bg thread)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -173,6 +205,14 @@ def main() -> None:
so the module can be imported on a GTK-less Linux dev box for unit tests so the module can be imported on a GTK-less Linux dev box for unit tests
of the pure helpers (TrayState, header_text) without pystray failing. of the pure helpers (TrayState, header_text) without pystray failing.
""" """
# --panel: we ARE the settings window, launched as a child of the tray. Open
# it and exit WITHOUT touching the single-instance mutex or the BLE daemon —
# pywebview owns this process's main thread; the tray owns the other one.
if "--panel" in sys.argv:
from daemon.panel import run as run_panel
run_panel()
return
# Single-instance guard FIRST — before icons, the daemon thread, or any BLE # Single-instance guard FIRST — before icons, the daemon thread, or any BLE
# work. If another tray already owns the session mutex (e.g. ARSO restored a # work. If another tray already owns the session mutex (e.g. ARSO restored a
# console instance and the headless autostart also fired), exit silently. # console instance and the headless autostart also fired), exit silently.
@@ -227,6 +267,25 @@ def main() -> None:
daemon_thread = threading.Thread(target=_run_daemon, daemon=True) daemon_thread = threading.Thread(target=_run_daemon, daemon=True)
daemon_thread.start() daemon_thread.start()
# --- control-panel HTTP server (one process, one exe) ---
# Serve the brand UI + REST API on 127.0.0.1 in a daemon thread, and feed it
# live BLE/daemon status. Best-effort: a server failure must never stop the
# tray itself from coming up (the watch sync is the primary job).
panel_port = None
try:
from daemon import server as panel_server
from daemon.config import DEFAULT_PORT
panel_port = DEFAULT_PORT
panel_server.set_status_provider(lambda: _status_dict(ts))
panel_server.serve_in_thread(panel_port)
daemon_log(f"Control panel: http://127.0.0.1:{panel_port}")
except Exception as e:
daemon_log(f"Control panel unavailable: {e!r}")
# Holds the settings-window child process so we don't stack windows and can
# tear it down on Quit. Mutated by _on_settings / _on_quit below.
_panel = {"proc": None}
# --- menu --- # --- menu ---
def _on_quit(icon_ref, _item) -> None: def _on_quit(icon_ref, _item) -> None:
# NEVER call ts.stop_event.set() directly from the tray thread; # NEVER call ts.stop_event.set() directly from the tray thread;
@@ -246,6 +305,13 @@ def main() -> None:
except RuntimeError: except RuntimeError:
pass # loop already closed (e.g. mid-restart) — quit_evt handles it pass # loop already closed (e.g. mid-restart) — quit_evt handles it
daemon_thread.join(timeout=6.0) daemon_thread.join(timeout=6.0)
# Close the settings window too, if the user left it open.
proc = _panel["proc"]
if proc is not None and proc.poll() is None:
try:
proc.terminate()
except Exception:
pass
icon_ref.stop() icon_ref.stop()
def _on_toggle(_icon_ref, _item) -> None: def _on_toggle(_icon_ref, _item) -> None:
@@ -258,9 +324,30 @@ def main() -> None:
autostart.enable(tray_script=os.path.abspath(__file__)) autostart.enable(tray_script=os.path.abspath(__file__))
icon.update_menu() icon.update_menu()
def _on_settings(_icon_ref, _item) -> None:
# Open the WebView2 settings window as a child process. If one is already
# alive, leave it — re-spawning would stack duplicate windows.
proc = _panel["proc"]
if proc is not None and proc.poll() is None:
return
env = dict(os.environ)
if panel_port:
env["CLAWDMETER_PANEL_PORT"] = str(panel_port)
kwargs = {}
if sys.platform == "win32":
# No phantom console window for the child (it's a GUI of its own).
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
try:
_panel["proc"] = subprocess.Popen(_panel_argv(), env=env, **kwargs)
except Exception as e:
daemon_log(f"Could not open settings window: {e!r}")
icon.menu = Menu( icon.menu = Menu(
# Non-clickable status header; text updates via update_menu() on state change. # Non-clickable status header; text updates via update_menu() on state change.
MenuItem(lambda _item: header_text(ts), None, enabled=False), MenuItem(lambda _item: header_text(ts), None, enabled=False),
# Settings = the WebView2 control panel. default=True opens it on a plain
# left-click of the tray icon (right-click still shows the full menu).
MenuItem("Settings", _on_settings, default=True),
# Start-at-login toggle: checked= is a CALLABLE for live query (Pitfall 6). # Start-at-login toggle: checked= is a CALLABLE for live query (Pitfall 6).
MenuItem("Start at login", _on_toggle, checked=lambda _item: autostart.is_enabled()), MenuItem("Start at login", _on_toggle, checked=lambda _item: autostart.is_enabled()),
MenuItem("Quit", _on_quit), MenuItem("Quit", _on_quit),
@@ -290,6 +377,14 @@ def main() -> None:
prev_state["state"] = current prev_state["state"] = current
prev_state["last_sync"] = last_sync prev_state["last_sync"] = last_sync
_icon.update_menu() _icon.update_menu()
# Drain daemon-queued toasts (e.g. low watch battery) — runs every
# tick regardless of state change.
try:
while True:
_title, _msg = ts.toasts.get_nowait()
_icon.notify(_msg, _title)
except queue.Empty:
pass
time.sleep(1.0) time.sleep(1.0)
# Blocks the main thread until icon.stop() is called from _on_quit. # Blocks the main thread until icon.stop() is called from _on_quit.
+272
View File
@@ -0,0 +1,272 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Clawdmeter</title>
<style>
:root{
--bg:#131211; --panel:#1f1f1e; --panel2:#232220; --text:#faf9f5; --dim:#b0aea5;
--accent:#d97757; --accent-text:#4a1b0c; --green:#788c5d; --red:#c0392b;
--border:rgba(255,255,255,.09);
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{background:var(--bg);color:var(--text);font-family:"Segoe UI",system-ui,sans-serif;font-size:14px}
.app{display:flex;height:100vh}
.nav{width:188px;flex-shrink:0;background:#161514;border-right:1px solid var(--border);padding:14px 10px;display:flex;flex-direction:column;gap:3px}
.brand{display:flex;align-items:center;gap:9px;padding:4px 10px 14px;font-weight:500}
.brand .dot{width:11px;height:11px;border-radius:50%;background:var(--accent)}
.nav button.tabbtn{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:8px;border:none;background:transparent;color:var(--dim);font-size:14px;cursor:pointer;text-align:left;width:100%}
.nav button.tabbtn:hover{background:#1c1b1a}
.nav button.tabbtn.active{background:var(--panel2);color:var(--text)}
.nav button.tabbtn.active svg{color:var(--accent)}
.nav svg{width:18px;height:18px;flex-shrink:0}
.navstatus{margin-top:auto;display:flex;align-items:center;gap:8px;padding:10px;font-size:12px;color:var(--dim)}
.led{width:8px;height:8px;border-radius:50%;background:#555;flex-shrink:0}
.led.on{background:var(--green)}
.content{flex:1;overflow-y:auto;padding:22px 26px}
h1{font-size:18px;font-weight:500;margin:0}
.sub{font-size:13px;color:var(--dim);margin:3px 0 18px}
label{font-size:12px;color:var(--dim);display:block;margin:14px 0 6px}
input[type=text],input[type=password],input[type=number],select{width:100%;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--text);font-size:14px;outline:none}
input:focus,select:focus{border-color:var(--accent)}
button.primary{background:var(--accent);color:var(--accent-text);border:none;border-radius:8px;padding:9px 18px;font-size:14px;font-weight:500;cursor:pointer}
button.ghost{background:transparent;border:1px solid var(--border);color:var(--dim);border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer}
button.ghost:hover{color:var(--text);border-color:rgba(255,255,255,.2)}
.row{display:flex;gap:10px;align-items:center}
.pill{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:4px 11px;font-size:12px;margin-top:8px}
.pill.ok{background:rgba(120,140,93,.16);color:#9bb074}
.pill.err{background:rgba(192,57,43,.16);color:#e3897f}
.chips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:6px}
.chip{display:inline-flex;align-items:center;gap:8px;background:var(--panel2);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px}
.chip b{cursor:pointer;color:#7d7b74;font-weight:400}
.card{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px;margin-bottom:10px}
.grid{display:grid;grid-template-columns:1.4fr 1fr 90px auto;gap:10px;align-items:end}
.tab{display:none}
.tab.active{display:block}
.actions{margin-top:22px;display:flex;justify-content:flex-end;gap:10px}
.statgrid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:8px}
.stat{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px}
.stat .k{font-size:12px;color:var(--dim)}
.stat .v{font-size:22px;font-weight:500;margin-top:4px}
.muted{color:var(--dim);font-size:12.5px}
#toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--panel2);border:1px solid var(--border);color:var(--text);padding:10px 16px;border-radius:8px;font-size:13px;opacity:0;pointer-events:none;transition:opacity .2s}
#toast.show{opacity:1}
</style>
</head>
<body>
<div class="app">
<nav class="nav">
<div class="brand"><span class="dot"></span>Clawdmeter</div>
<button class="tabbtn active" data-tab="status"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg>Status</button>
<button class="tabbtn" data-tab="ha"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 1 4 10 4 4 0 0 0-1 3H9a4 4 0 0 0-1-3 6 6 0 0 1 4-10z"/></svg>Home Assistant</button>
<button class="tabbtn" data-tab="buttons"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>Buttons</button>
<button class="tabbtn" data-tab="settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/><circle cx="9" cy="6" r="2" fill="var(--bg)"/><circle cx="15" cy="12" r="2" fill="var(--bg)"/><circle cx="8" cy="18" r="2" fill="var(--bg)"/></svg>Settings</button>
<div class="navstatus"><span class="led" id="navled"></span><span id="navstate">Connecting…</span></div>
</nav>
<main class="content">
<!-- STATUS -->
<section class="tab active" data-tab="status">
<h1>Status</h1>
<div class="sub">Watch connection and live readings</div>
<div class="statgrid">
<div class="stat"><div class="k">Watch</div><div class="v" id="st-conn"></div></div>
<div class="stat"><div class="k">Battery</div><div class="v" id="st-batt"></div></div>
<div class="stat"><div class="k">Daemon</div><div class="v" id="st-state"></div></div>
<div class="stat"><div class="k">Last update</div><div class="v" id="st-sync"></div></div>
</div>
</section>
<!-- HOME ASSISTANT -->
<section class="tab" data-tab="ha">
<h1>Home Assistant</h1>
<div class="sub">Connection &amp; devices</div>
<label>Server URL</label>
<input type="text" id="ha-url" placeholder="https://homeassistant.local:8123" autocomplete="off">
<label>Long-lived access token</label>
<div class="row">
<input type="password" id="ha-token" placeholder="Paste token" autocomplete="off">
<button class="ghost" id="ha-test">Test</button>
</div>
<div id="ha-testresult"></div>
<label>Controlled entities</label>
<div class="chips" id="ha-chips"></div>
<div class="row" style="margin-top:10px">
<select id="ha-picker"><option value="">Load devices to add…</option></select>
<button class="ghost" id="ha-load">Load devices</button>
<button class="ghost" id="ha-add">Add</button>
</div>
<div class="actions"><button class="primary" id="ha-save">Save</button></div>
</section>
<!-- BUTTONS -->
<section class="tab" data-tab="buttons">
<h1>Buttons</h1>
<div class="sub">Actions you can fire (and, later, show on the watch)</div>
<div id="btn-list"></div>
<button class="ghost" id="btn-add">+ Add button</button>
<div class="actions"><button class="primary" id="btn-save">Save</button></div>
</section>
<!-- SETTINGS -->
<section class="tab" data-tab="settings">
<h1>Settings</h1>
<div class="sub">App behaviour</div>
<label>Low-battery warning at (%)</label>
<input type="number" id="set-lowbatt" min="1" max="100" step="1">
<label>Watch BLE address <span class="muted">(optional — for bonded, non-advertising watches)</span></label>
<input type="text" id="set-addr" placeholder="44:1B:F6:85:1E:51" autocomplete="off">
<div class="actions"><button class="primary" id="set-save">Save</button></div>
</section>
</main>
</div>
<div id="toast"></div>
<script>
const MASK = "********";
let cfg = null;
let entities = []; // current controlled entities (chips)
let lights = []; // available light.* from HA
let buttons = []; // current button defs
const $ = s => document.querySelector(s);
async function api(path, method="GET", body=null){
const opt = {method, headers:{}};
if(body){opt.headers["Content-Type"]="application/json"; opt.body=JSON.stringify(body);}
const r = await fetch(path, opt);
if(!r.ok) throw new Error("HTTP "+r.status);
return r.json();
}
function toast(msg){const t=$("#toast");t.textContent=msg;t.classList.add("show");clearTimeout(t._h);t._h=setTimeout(()=>t.classList.remove("show"),1800);}
// ---- tabs ----
document.querySelectorAll(".tabbtn").forEach(b=>b.onclick=()=>{
document.querySelectorAll(".tabbtn").forEach(x=>x.classList.toggle("active",x===b));
const id=b.dataset.tab;
document.querySelectorAll(".tab").forEach(s=>s.classList.toggle("active",s.dataset.tab===id));
});
// ---- load ----
async function loadConfig(){
cfg = await api("/api/config");
$("#ha-url").value = cfg.ha.url || "";
$("#ha-token").value = cfg.ha.token || ""; // server sends MASK when a token is set
entities = (cfg.ha.entities||[]).slice();
buttons = (cfg.buttons||[]).map(b=>Object.assign({}, b));
$("#set-lowbatt").value = cfg.settings.low_battery_pct ?? 15;
$("#set-addr").value = cfg.settings.device_address || "";
renderChips(); renderButtons();
}
// ---- HA ----
function renderChips(){
const c=$("#ha-chips"); c.innerHTML="";
if(!entities.length){c.innerHTML='<span class="muted">No devices yet — add some below.</span>';}
entities.forEach(e=>{
const s=document.createElement("span"); s.className="chip";
s.innerHTML = e+' <b title="Remove"></b>';
s.querySelector("b").onclick=()=>{entities=entities.filter(x=>x!==e);renderChips();renderButtons();};
c.appendChild(s);
});
}
$("#ha-test").onclick = async ()=>{
const box=$("#ha-testresult"); box.innerHTML='<span class="muted">Testing…</span>';
try{
const r = await api("/api/ha/test","POST",{url:$("#ha-url").value.trim(), token:$("#ha-token").value});
box.innerHTML = r.ok ? '<span class="pill ok">✓ '+(r.message||"Connected")+'</span>'
: '<span class="pill err">✕ '+(r.error||"Failed")+'</span>';
}catch(e){box.innerHTML='<span class="pill err">✕ '+e.message+'</span>';}
};
$("#ha-load").onclick = async ()=>{
// Persist url+token first so the server can query HA with them.
await saveHA(true);
const r = await api("/api/ha/entities");
lights = r.entities||[];
const sel=$("#ha-picker"); sel.innerHTML="";
if(!lights.length){sel.innerHTML='<option value="">'+(r.error||"No lights found")+'</option>';return;}
sel.appendChild(new Option("Select a device…",""));
lights.forEach(l=>sel.appendChild(new Option(l,l)));
toast(lights.length+" devices loaded");
};
$("#ha-add").onclick = ()=>{
const v=$("#ha-picker").value; if(!v) return;
if(!entities.includes(v)){entities.push(v);renderChips();renderButtons();}
};
async function saveHA(silent){
const ha={url:$("#ha-url").value.trim(), token:$("#ha-token").value, entities};
cfg = await api("/api/config","PUT",{ha});
$("#ha-token").value = cfg.ha.token; // re-mask
if(!silent) toast("Home Assistant saved");
}
$("#ha-save").onclick = ()=>saveHA(false).catch(e=>toast("Error: "+e.message));
// ---- Buttons ----
const ACTIONS=[["toggle","Toggle on/off"],["bri","Set brightness %"],["ct","Set color temp K"]];
function renderButtons(){
const list=$("#btn-list"); list.innerHTML="";
if(!buttons.length){list.insertAdjacentHTML("beforeend",'<div class="muted" style="margin-bottom:10px">No buttons yet.</div>');}
buttons.forEach((b,i)=>{
const card=document.createElement("div"); card.className="card";
const entOpts = entities.map(e=>`<option value="${e}" ${e===b.entity?"selected":""}>${e}</option>`).join("");
const actOpts = ACTIONS.map(([v,l])=>`<option value="${v}" ${v===b.action?"selected":""}>${l}</option>`).join("");
const showVal = b.action==="bri"||b.action==="ct";
card.innerHTML=`
<div class="grid">
<div><label>Label</label><input type="text" data-i="${i}" data-f="label" value="${(b.label||"").replace(/"/g,'&quot;')}"></div>
<div><label>Device</label><select data-i="${i}" data-f="entity">${entOpts||'<option value="">— add a device first —</option>'}</select></div>
<div><label>Action</label><select data-i="${i}" data-f="action">${actOpts}</select></div>
<div><button class="ghost" data-del="${i}">Remove</button></div>
</div>
<div data-val="${i}" style="${showVal?"":"display:none"};margin-top:8px">
<label>${b.action==="ct"?"Kelvin (20006500)":"Brightness %"}</label>
<input type="number" data-i="${i}" data-f="value" value="${b.value??(b.action==="ct"?3000:60)}" style="max-width:160px">
</div>`;
list.appendChild(card);
});
list.querySelectorAll("[data-f]").forEach(el=>el.oninput=()=>{
const i=+el.dataset.i, f=el.dataset.f;
buttons[i][f] = f==="value" ? +el.value : el.value;
if(f==="action"){ buttons[i].icon=el.value; renderButtons(); }
});
list.querySelectorAll("[data-del]").forEach(el=>el.onclick=()=>{buttons.splice(+el.dataset.del,1);renderButtons();});
}
$("#btn-add").onclick = ()=>{
buttons.push({id:"b"+Date.now().toString(36), label:"Light", icon:"toggle",
action:"toggle", entity:entities[0]||"", value:null});
renderButtons();
};
$("#btn-save").onclick = async ()=>{
try{ cfg = await api("/api/config","PUT",{buttons}); toast("Buttons saved"); }
catch(e){ toast("Error: "+e.message); }
};
// ---- Settings ----
$("#set-save").onclick = async ()=>{
try{
cfg = await api("/api/config","PUT",{settings:{
low_battery_pct:+$("#set-lowbatt").value, device_address:$("#set-addr").value.trim()}});
toast("Settings saved");
}catch(e){ toast("Error: "+e.message); }
};
// ---- Status poll ----
async function pollStatus(){
try{
const s = await api("/api/status");
const conn = s.connected;
$("#navled").classList.toggle("on",conn);
$("#navstate").textContent = conn?"Watch connected":(s.state==="unknown"?"Daemon off":"Scanning…");
$("#st-conn").textContent = conn?"Connected":"Disconnected";
$("#st-batt").textContent = (s.battery==null)?"—":s.battery+"%";
$("#st-state").textContent = s.state||"—";
$("#st-sync").textContent = s.last_sync? new Date(s.last_sync*1000).toLocaleTimeString():"—";
}catch(e){ $("#navstate").textContent="Panel only"; }
}
loadConfig().catch(e=>toast("Load failed: "+e.message));
pollStatus(); setInterval(pollStatus,3000);
</script>
</body>
</html>
+15
View File
@@ -42,6 +42,11 @@ build_flags =
-DLV_USE_ANIMIMG=0 -DLV_USE_ANIMIMG=0
-DLV_TICK_CUSTOM=1 -DLV_TICK_CUSTOM=1
-DLV_USE_SNAPSHOT=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 = lib_deps =
moononournation/GFX Library for Arduino@^1.5.6 moononournation/GFX Library for Arduino@^1.5.6
@@ -99,6 +104,11 @@ build_flags =
-DLV_USE_ANIMIMG=0 -DLV_USE_ANIMIMG=0
-DLV_TICK_CUSTOM=1 -DLV_TICK_CUSTOM=1
-DLV_USE_SNAPSHOT=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 = lib_deps =
; 1.6.4+ ships Arduino_SH8601 in mainline ; 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 ; 480×480) which doesn't fit in C6 internal SRAM. send_screenshot
; prints SCREENSHOT_UNSUPPORTED on this board. ; prints SCREENSHOT_UNSUPPORTED on this board.
-DLV_USE_SNAPSHOT=0 -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 = lib_deps =
; this board uses the CO5300 (same controller as the S3 2.16); Arduino_CO5300 ; this board uses the CO5300 (same controller as the S3 2.16); Arduino_CO5300
+55
View File
@@ -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);
}
+15
View File
@@ -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);
+18
View File
@@ -10,6 +10,7 @@
#define RX_CHAR_UUID "4c41555a-4465-7669-6365-000000000002" // host writes here #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 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 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 #define BLE_BUF_SIZE 512
@@ -60,6 +61,7 @@ static NimBLECharacteristic* input_kbd = nullptr;
static NimBLECharacteristic* tx_char = nullptr; static NimBLECharacteristic* tx_char = nullptr;
static NimBLECharacteristic* rx_char = nullptr; static NimBLECharacteristic* rx_char = nullptr;
static NimBLECharacteristic* req_char = nullptr; static NimBLECharacteristic* req_char = nullptr;
static NimBLECharacteristic* cmd_char = nullptr;
static ble_state_t state = BLE_STATE_INIT; static ble_state_t state = BLE_STATE_INIT;
static bool need_advertise = false; static bool need_advertise = false;
@@ -201,6 +203,14 @@ void ble_init(void) {
static ReqCallbacks reqCb; static ReqCallbacks reqCb;
req_char->setCallbacks(&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(); svc->start();
server->start(); server->start();
start_advertising(); 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) { void ble_keyboard_press(uint8_t key, uint8_t modifier) {
if (state != BLE_STATE_CONNECTED || !input_kbd) return; if (state != BLE_STATE_CONNECTED || !input_kbd) return;
// HID report: [modifier, reserved, key1, key2, key3, key4, key5, key6] // HID report: [modifier, reserved, key1, key2, key3, key4, key5, key6]
+5
View File
@@ -21,6 +21,11 @@ void ble_send_ack(void);
void ble_send_nack(void); void ble_send_nack(void);
void ble_request_refresh(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 // BLE HID keyboard
void ble_keyboard_press(uint8_t key, uint8_t modifier); void ble_keyboard_press(uint8_t key, uint8_t modifier);
void ble_keyboard_release(void); void ble_keyboard_release(void);
+1
View File
@@ -7,3 +7,4 @@
void imu_hal_init(void) {} void imu_hal_init(void) {}
void imu_hal_tick(void) {} void imu_hal_tick(void) {}
uint8_t imu_hal_rotation_quadrant(void) { return 0; } uint8_t imu_hal_rotation_quadrant(void) { return 0; }
bool imu_hal_read_accel(float* x, float* y, float* z) { return false; }
+2
View File
@@ -17,8 +17,10 @@ void power_hal_init(void) {}
void power_hal_tick(void) {} void power_hal_tick(void) {}
int power_hal_battery_pct(void) { return -1; } 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_charging(void) { return false; }
bool power_hal_is_vbus_in(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; } bool power_hal_pwr_pressed(void) { return false; }
// Hold-to-pair gesture signals. Mirror the 216 (PMU PKEY long/positive IRQs) // 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. // 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; } 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 XPowersPMU pmu;
static int cached_pct = -1; static int cached_pct = -1;
static int cached_mv = 0;
static bool cached_charging = false; static bool cached_charging = false;
static bool cached_vbus = false; static bool cached_vbus = false;
static bool pwr_pressed_flag = false; static bool pwr_pressed_flag = false;
@@ -49,6 +50,7 @@ void power_hal_init(void) {
cached_charging = pmu.isCharging(); cached_charging = pmu.isCharging();
cached_vbus = pmu.isVbusIn(); cached_vbus = pmu.isVbusIn();
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
void power_hal_tick(void) { void power_hal_tick(void) {
@@ -62,6 +64,7 @@ void power_hal_tick(void) {
if (now - last_battery_ms >= BATTERY_POLL_MS) { if (now - last_battery_ms >= BATTERY_POLL_MS) {
last_battery_ms = now; last_battery_ms = now;
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
if (now - last_pwr_ms >= PWR_POLL_MS) { if (now - last_pwr_ms >= PWR_POLL_MS) {
last_pwr_ms = now; 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_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_charging(void) { return cached_charging; }
bool power_hal_is_vbus_in(void) { return cached_vbus; } bool power_hal_is_vbus_in(void) { return cached_vbus; }
void power_hal_shutdown(void) { pmu.shutdown(); }
bool power_hal_pwr_pressed(void) { bool power_hal_pwr_pressed(void) {
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; } if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
return false; return false;
@@ -5,21 +5,34 @@
#include <SensorQMI8658.hpp> #include <SensorQMI8658.hpp>
// AMOLED-2.06 ships with QMI8658 populated, but the panel is non-square and // 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 // mounts in a fixed orientation, so screen rotation is disabled and the quadrant
// device anyway to keep the shared I2C bus healthy, but always report 0. // 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 SensorQMI8658 imu;
static bool imu_ok = false;
void imu_hal_init(void) { void imu_hal_init(void) {
if (!imu.begin(Wire, QMI8658_L_SLAVE_ADDRESS, IIC_SDA, IIC_SCL)) { if (!imu.begin(Wire, QMI8658_L_SLAVE_ADDRESS, IIC_SDA, IIC_SCL)) {
Serial.println("QMI8658 init failed"); Serial.println("QMI8658 init failed");
return; 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) { 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; } 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 XPowersPMU pmu;
static int cached_pct = -1; static int cached_pct = -1;
static int cached_mv = 0;
static bool cached_charging = false; static bool cached_charging = false;
static bool cached_vbus = false; static bool cached_vbus = false;
static bool pwr_pressed_flag = false; static bool pwr_pressed_flag = false;
@@ -66,6 +67,7 @@ void power_hal_init(void) {
cached_charging = pmu.isCharging(); cached_charging = pmu.isCharging();
cached_vbus = pmu.isVbusIn(); cached_vbus = pmu.isVbusIn();
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
void power_hal_tick(void) { void power_hal_tick(void) {
@@ -79,6 +81,7 @@ void power_hal_tick(void) {
if (now - last_battery_ms >= BATTERY_POLL_MS) { if (now - last_battery_ms >= BATTERY_POLL_MS) {
last_battery_ms = now; last_battery_ms = now;
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
if (now - last_pwr_ms >= PWR_POLL_MS) { if (now - last_pwr_ms >= PWR_POLL_MS) {
last_pwr_ms = now; 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_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_charging(void) { return cached_charging; }
bool power_hal_is_vbus_in(void) { return cached_vbus; } bool power_hal_is_vbus_in(void) { return cached_vbus; }
void power_hal_shutdown(void) { pmu.shutdown(); }
bool power_hal_pwr_pressed(void) { bool power_hal_pwr_pressed(void) {
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; } if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
return false; return false;
@@ -64,3 +64,8 @@ void imu_hal_tick(void) {
} }
uint8_t imu_hal_rotation_quadrant(void) { return current_rotation; } 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 XPowersPMU pmu;
static int cached_pct = -1; static int cached_pct = -1;
static int cached_mv = 0;
static bool cached_charging = false; static bool cached_charging = false;
static bool cached_vbus = false; static bool cached_vbus = false;
static bool pwr_pressed_flag = false; static bool pwr_pressed_flag = false;
@@ -49,6 +50,7 @@ void power_hal_init(void) {
cached_charging = pmu.isCharging(); cached_charging = pmu.isCharging();
cached_vbus = pmu.isVbusIn(); cached_vbus = pmu.isVbusIn();
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
void power_hal_tick(void) { void power_hal_tick(void) {
@@ -62,6 +64,7 @@ void power_hal_tick(void) {
if (now - last_battery_ms >= BATTERY_POLL_MS) { if (now - last_battery_ms >= BATTERY_POLL_MS) {
last_battery_ms = now; last_battery_ms = now;
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
if (now - last_pwr_ms >= PWR_POLL_MS) { if (now - last_pwr_ms >= PWR_POLL_MS) {
last_pwr_ms = now; 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_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_charging(void) { return cached_charging; }
bool power_hal_is_vbus_in(void) { return cached_vbus; } bool power_hal_is_vbus_in(void) { return cached_vbus; }
void power_hal_shutdown(void) { pmu.shutdown(); }
bool power_hal_pwr_pressed(void) { bool power_hal_pwr_pressed(void) {
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; } if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
return false; return false;
@@ -24,3 +24,7 @@ void imu_hal_tick(void) {
} }
uint8_t imu_hal_rotation_quadrant(void) { return 0; } 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 #define pmu board_pmu
static int cached_pct = -1; static int cached_pct = -1;
static int cached_mv = 0;
static bool cached_charging = false; static bool cached_charging = false;
static bool cached_vbus = false; static bool cached_vbus = false;
static bool pwr_pressed_flag = false; static bool pwr_pressed_flag = false;
@@ -50,6 +51,7 @@ void power_hal_init(void) {
cached_charging = pmu.isCharging(); cached_charging = pmu.isCharging();
cached_vbus = pmu.isVbusIn(); cached_vbus = pmu.isVbusIn();
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
void power_hal_tick(void) { void power_hal_tick(void) {
@@ -63,6 +65,7 @@ void power_hal_tick(void) {
if (now - last_battery_ms >= BATTERY_POLL_MS) { if (now - last_battery_ms >= BATTERY_POLL_MS) {
last_battery_ms = now; last_battery_ms = now;
cached_pct = pmu.getBatteryPercent(); cached_pct = pmu.getBatteryPercent();
cached_mv = pmu.getBattVoltage();
} }
if (now - last_pwr_ms >= PWR_POLL_MS) { if (now - last_pwr_ms >= PWR_POLL_MS) {
last_pwr_ms = now; 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_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_charging(void) { return cached_charging; }
bool power_hal_is_vbus_in(void) { return cached_vbus; } bool power_hal_is_vbus_in(void) { return cached_vbus; }
void power_hal_shutdown(void) { pmu.shutdown(); }
bool power_hal_pwr_pressed(void) { bool power_hal_pwr_pressed(void) {
if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; } if (pwr_pressed_flag) { pwr_pressed_flag = false; return true; }
return false; return false;
+6
View File
@@ -9,3 +9,9 @@
void imu_hal_init(void); void imu_hal_init(void);
void imu_hal_tick(void); void imu_hal_tick(void);
uint8_t imu_hal_rotation_quadrant(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);
+5
View File
@@ -12,9 +12,14 @@ void power_hal_init(void);
void power_hal_tick(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_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_charging(void);
bool power_hal_is_vbus_in(void); // USB cable present (true even without a battery) 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. // Edge-triggered: returns true once per PWR short-press, then clears.
bool power_hal_pwr_pressed(void); bool power_hal_pwr_pressed(void);
+129 -13
View File
@@ -10,6 +10,7 @@
#include "version.h" #include "version.h"
#include "splash.h" #include "splash.h"
#include "usage_rate.h" #include "usage_rate.h"
#include "battery_est.h"
#include "idle.h" #include "idle.h"
#include "idle_cfg.h" #include "idle_cfg.h"
#include "brightness.h" #include "brightness.h"
@@ -23,6 +24,21 @@
static UsageData usage = {}; static UsageData usage = {};
// ---- Low-voltage protective cutoff ----
// Below LOW_V_CUTOFF_MV on battery power (USB absent), the firmware warns on
// screen for LOW_V_WARN_MS then powers the PMU fully off, so the cell isn't
// driven into deep over-discharge. The reading must stay below for
// LOW_V_SUSTAIN_MS first: the cell voltage sags under the BLE + AMOLED load, so
// a momentary dip must not trigger a shutdown.
//
// 3000 mV (3.0 V) is a gentle Li-ion floor — well clear of the deep
// over-discharge zone (~2.52.8 V). Mirrored as the "Auto-off below 3.0 V"
// note on the battery screen (ui.cpp). Raise toward 3300 mV to be even kinder
// to the cell, or down to 2800 to squeeze out the last drops.
#define LOW_V_CUTOFF_MV 3000
#define LOW_V_SUSTAIN_MS 6000UL
#define LOW_V_WARN_MS 5000UL
// ---- LVGL draw buffers (partial render mode) ---- // ---- LVGL draw buffers (partial render mode) ----
// PSRAM-equipped boards (S3) can comfortably hold larger strips. PSRAM-free // 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 // 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_title, doc["nt"] | "", sizeof(out->np_title));
strlcpy(out->np_artist, doc["na"] | "", sizeof(out->np_artist)); strlcpy(out->np_artist, doc["na"] | "", sizeof(out->np_artist));
out->valid = true; 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; return true;
} }
@@ -227,7 +273,7 @@ void setup() {
ui_init(); ui_init();
ui_update_ble_status(ble_get_state(), ble_get_device_name(), ble_get_mac_address()); 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); ui_show_screen(SCREEN_SPLASH);
Serial.printf("Dashboard ready (%s, %dx%d), waiting for data on BLE...\n", 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_was = false;
static bool primary_wake_swallowed = 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); bool primary_now = input_hal_is_held(INPUT_BTN_PRIMARY);
if (primary_now != primary_was) { if (primary_now != primary_was) {
if (primary_now) { if (primary_now) {
if (idle_consume_wake_press()) primary_wake_swallowed = true; 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 { } else {
if (primary_wake_swallowed) primary_wake_swallowed = false; 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; primary_was = primary_now;
} }
@@ -337,30 +391,92 @@ void loop() {
if (power_hal_pwr_pressed()) { if (power_hal_pwr_pressed()) {
if (!idle_consume_wake_press()) { if (!idle_consume_wake_press()) {
// On splash: cycle animations. On the usage view: cycle // On splash: cycle animations. On the dimmer: switch the
// screen brightness (single non-splash view, no more screens). // controlled parameter (brightness ⇄ temp). Elsewhere: cycle
if (ui_get_current_screen() == SCREEN_SPLASH) splash_next(); // screen brightness.
else brightness_cycle(); 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(); 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(); ble_state_t bs = ble_get_state();
if (bs != last_ble_state) { if (bs != last_ble_state) {
last_ble_state = bs; last_ble_state = bs;
ui_update_ble_status(bs, ble_get_device_name(), ble_get_mac_address()); ui_update_ble_status(bs, ble_get_device_name(), ble_get_mac_address());
} }
static int last_pct = -2; // ---- Battery telemetry → UI + time-left estimate ----
static bool last_charging = false; 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 pct = power_hal_battery_pct();
int mv = power_hal_battery_mv();
bool charging = power_hal_is_charging(); bool charging = power_hal_is_charging();
if (pct != last_pct || charging != last_charging) { int mv_bucket = (mv <= 0) ? -1 : (mv / 20); // 20 mV buckets — ignore sub-bucket jitter
last_pct = pct; uint32_t now_ms = millis();
last_charging = charging; bool bat_changed = (pct != last_pct) || (charging != last_charging) || (mv_bucket != last_mv_bucket);
ui_update_battery(pct, charging); 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(); check_serial_cmd();
+619 -55
View File
@@ -2,9 +2,11 @@
#include "splash.h" #include "splash.h"
#include <lvgl.h> #include <lvgl.h>
#include <string.h> #include <string.h>
#include <math.h>
#include "logo.h" #include "logo.h"
#include "icons.h" #include "icons.h"
#include "hal/board_caps.h" #include "hal/board_caps.h"
#include "hal/imu_hal.h"
// Custom fonts (scaled for 314 PPI, ~1.9x from original 165 PPI) // Custom fonts (scaled for 314 PPI, ~1.9x from original 165 PPI)
LV_FONT_DECLARE(font_tiempos_56); LV_FONT_DECLARE(font_tiempos_56);
@@ -124,12 +126,16 @@ static lv_obj_t* battery_img;
static lv_obj_t* logo_img; static lv_obj_t* logo_img;
static lv_image_dsc_t battery_dscs[5]; // empty, low, medium, full, charging 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 ---- // ---- 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* 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* 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_container; // BLE connection info
static lv_obj_t* bt_status_lbl; static lv_obj_t* bt_status_lbl;
static lv_obj_t* bt_name_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_title_lbl; // track title (scrolls if long)
static lv_obj_t* np_artist_lbl; // track artist static lv_obj_t* np_artist_lbl; // track artist
static lv_obj_t* np_status_lbl; // "Playing" / "Paused" 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 // 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 // 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_SESSION, "Session", LV_SYMBOL_LIST },
{ SCREEN_NOWPLAYING, "Now Playing", LV_SYMBOL_PLAY }, { SCREEN_NOWPLAYING, "Now Playing", LV_SYMBOL_PLAY },
{ SCREEN_HOMEASSIST, "Home", LV_SYMBOL_HOME }, { SCREEN_HOMEASSIST, "Home", LV_SYMBOL_HOME },
{ SCREEN_DIMMER, "Dimmer", LV_SYMBOL_SETTINGS },
{ SCREEN_BLUETOOTH, "Bluetooth", LV_SYMBOL_BLUETOOTH }, { SCREEN_BLUETOOTH, "Bluetooth", LV_SYMBOL_BLUETOOTH },
}; };
#define APP_COUNT (sizeof(APPS) / sizeof(APPS[0])) #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 global_click_cb(lv_event_t* e);
static void logo_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 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) { 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); 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); lv_obj_align(lbl_anim, LV_ALIGN_BOTTOM_MID, 0, -15);
} }
// ======== v2: launcher, stub & bluetooth screens ======== // ======== v2: launcher & 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;
}
// Centered screen title for the navigation screens. The back chevron lives in // 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. // 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); 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) { static void init_bt_screen(lv_obj_t* scr) {
bt_container = lv_obj_create(scr); bt_container = lv_obj_create(scr);
lv_obj_set_size(bt_container, L.scr_w, L.scr_h); 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()); 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) ---- // ---- 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 // F13..F24 are HID usage IDs 0x68..0x73; the report-map keycode ceiling was
// raised to 0x73 in ble.cpp so these transmit. // 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); 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 ======== // ======== Public API ========
void ui_init(void) { void ui_init(void) {
@@ -812,11 +1298,13 @@ void ui_init(void) {
// v2 screens — created hidden, shown on navigation. // v2 screens — created hidden, shown on navigation.
init_menu_screen(scr); init_menu_screen(scr);
init_stub_screen(scr);
init_bt_screen(scr); init_bt_screen(scr);
init_soundpad_screen(scr); init_soundpad_screen(scr);
init_session_screen(scr); init_session_screen(scr);
init_nowplaying_screen(scr); init_nowplaying_screen(scr);
init_homeassist_screen(scr);
init_dimmer_screen(scr);
init_battery_screen(scr);
logo_img = lv_image_create(scr); logo_img = lv_image_create(scr);
lv_image_set_src(logo_img, &logo_dsc); 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_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_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_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_event_cb(nav_back_btn, nav_back_cb, LV_EVENT_CLICKED, NULL);
lv_obj_add_flag(nav_back_btn, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(nav_back_btn, LV_OBJ_FLAG_HIDDEN);
battery_img = lv_image_create(scr); battery_img = lv_image_create(scr);
lv_image_set_src(battery_img, &battery_dscs[0]); 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_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) { 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 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) { static void apply_battery_visibility(void) {
if (!battery_img) return; if (!battery_img) return;
if (current_screen == SCREEN_SPLASH) lv_obj_add_flag(battery_img, LV_OBJ_FLAG_HIDDEN); // Hidden on the splash, and on the battery detail itself (no point offering a
else lv_obj_clear_flag(battery_img, LV_OBJ_FLAG_HIDDEN); // 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. // 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); 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) { static void nav_back_cb(lv_event_t* e) {
(void)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); 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) { void ui_show_screen(screen_t screen) {
lv_obj_add_flag(usage_container, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(usage_container, LV_OBJ_FLAG_HIDDEN);
if (menu_container) lv_obj_add_flag(menu_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 (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 (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 (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 (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(); 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) { switch (screen) {
case SCREEN_SPLASH: splash_show(); break; case SCREEN_SPLASH: splash_show(); break;
case SCREEN_USAGE: lv_obj_clear_flag(usage_container, LV_OBJ_FLAG_HIDDEN); 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_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_BLUETOOTH: bt_refresh(); lv_obj_clear_flag(bt_container, LV_OBJ_FLAG_HIDDEN); break;
case SCREEN_HOMEASSIST: case SCREEN_HOMEASSIST:
stub_show_for(screen); lv_obj_clear_flag(homeassist_container, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(stub_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; break;
default: 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(); 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; int idx;
if (charging) { if (charging) {
idx = 4; idx = 4;
@@ -1088,4 +1616,40 @@ void ui_update_battery(int percent, bool charging) {
} }
lv_image_set_src(battery_img, &battery_dscs[idx]); lv_image_set_src(battery_img, &battery_dscs[idx]);
apply_battery_visibility(); 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
View File
@@ -10,7 +10,9 @@ enum screen_t {
SCREEN_SESSION, // today's tokens / cost (Phase 4) SCREEN_SESSION, // today's tokens / cost (Phase 4)
SCREEN_NOWPLAYING, // media now-playing (Phase 5) SCREEN_NOWPLAYING, // media now-playing (Phase 5)
SCREEN_HOMEASSIST, // Home Assistant controls (Phase 6) 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_BLUETOOTH, // BLE connection info
SCREEN_BATTERY, // battery detail (voltage + time left) — opened by tapping the battery icon
SCREEN_COUNT, SCREEN_COUNT,
}; };
@@ -21,4 +23,33 @@ void ui_show_screen(screen_t screen);
void ui_toggle_splash(void); void ui_toggle_splash(void);
screen_t ui_get_current_screen(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_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