Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d55dc4a268 | ||
|
|
1c64386996 | ||
|
|
578bc04248 | ||
|
|
a6d391e9d1 | ||
|
|
39a24eebad | ||
|
|
67df140ca4 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 [])}
|
||||||
@@ -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()))
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""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
|
||||||
|
# log_config=None disables uvicorn's default dictConfig. In the frozen,
|
||||||
|
# windowed exe there is no console, so sys.stderr is None and uvicorn's
|
||||||
|
# default logging setup dies with "Unable to configure formatter 'default'",
|
||||||
|
# taking the whole control-panel server down. We don't need uvicorn's logs
|
||||||
|
# (the daemon has its own file logger), so skip its logging config entirely.
|
||||||
|
server = uvicorn.Server(uvicorn.Config(
|
||||||
|
app, host="127.0.0.1", port=p, log_level="warning", log_config=None))
|
||||||
|
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")
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 & 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,'"')}"></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 (2000–6500)":"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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#include "battery_est.h"
|
||||||
|
#include <Arduino.h>
|
||||||
|
|
||||||
|
// Anchor-based, not a ring buffer (cf. usage_rate.cpp): we remember (time, pct)
|
||||||
|
// at the moment discharge began and project from the average rate since then.
|
||||||
|
// rate = (anchor_pct - pct_now) / minutes_elapsed
|
||||||
|
// minutes_left = pct_now / rate
|
||||||
|
// A long baseline beats a short ring for a signal that only ticks every few
|
||||||
|
// minutes — the estimate just keeps tightening as the session runs. We withhold
|
||||||
|
// a number until the cell has dropped a meaningful amount over a few minutes, so
|
||||||
|
// the first figure isn't built on fuel-gauge settling noise right after boot.
|
||||||
|
#define EST_MIN_DROP_PCT 3 // need >= 3% drained since the anchor
|
||||||
|
#define EST_MIN_ELAPSED_MS 300000UL // ...over at least 5 minutes
|
||||||
|
#define EST_MAX_MINUTES (100 * 60)
|
||||||
|
|
||||||
|
static bool have_anchor = false;
|
||||||
|
static uint32_t anchor_ms = 0;
|
||||||
|
static int anchor_pct = -1;
|
||||||
|
static int last_pct = -1;
|
||||||
|
static bool last_charging = false;
|
||||||
|
|
||||||
|
void battery_est_update(int percent, bool charging) {
|
||||||
|
last_charging = charging;
|
||||||
|
last_pct = percent;
|
||||||
|
|
||||||
|
if (charging || percent < 0) {
|
||||||
|
// On USB / charging / no reading the estimate is meaningless. Drop the
|
||||||
|
// anchor so the next discharge starts from a fresh baseline.
|
||||||
|
have_anchor = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Discharging. Start an anchor, or re-anchor if the gauge ticked UP (it
|
||||||
|
// relaxes upward when load drops) so we never compute a negative rate.
|
||||||
|
if (!have_anchor || percent > anchor_pct) {
|
||||||
|
have_anchor = true;
|
||||||
|
anchor_ms = millis();
|
||||||
|
anchor_pct = percent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int battery_est_minutes(void) {
|
||||||
|
if (last_charging || last_pct < 0) return -2;
|
||||||
|
if (!have_anchor) return -1;
|
||||||
|
|
||||||
|
int drop = anchor_pct - last_pct;
|
||||||
|
uint32_t dt = millis() - anchor_ms;
|
||||||
|
if (drop < EST_MIN_DROP_PCT || dt < EST_MIN_ELAPSED_MS) return -1;
|
||||||
|
|
||||||
|
// Minutes each 1% has been taking, extrapolated across the remaining pct.
|
||||||
|
float minutes_per_pct = (float)dt / 60000.0f / (float)drop;
|
||||||
|
float mins = (float)last_pct * minutes_per_pct;
|
||||||
|
if (mins < 0) return -1;
|
||||||
|
if (mins > EST_MAX_MINUTES) mins = EST_MAX_MINUTES;
|
||||||
|
return (int)(mins + 0.5f);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Rough "time left on battery" estimator. Watches the AXP fuel-gauge percentage
|
||||||
|
// while discharging and projects minutes-to-empty from the average drain rate
|
||||||
|
// since discharge began. Coarse by design — the gauge moves in slow 1% steps —
|
||||||
|
// so the UI shows it as "~Xh Ym left", emphasis on the ~.
|
||||||
|
//
|
||||||
|
// Feed it the latest percent + charging flag whenever a fresh battery reading
|
||||||
|
// lands (the daemon-independent battery poll in main.cpp).
|
||||||
|
void battery_est_update(int percent, bool charging);
|
||||||
|
|
||||||
|
// Estimated minutes remaining, or:
|
||||||
|
// -2 charging / on USB (no estimate to give)
|
||||||
|
// -1 not enough discharge history yet ("estimating…")
|
||||||
|
int battery_est_minutes(void);
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
#define RX_CHAR_UUID "4c41555a-4465-7669-6365-000000000002" // host writes here
|
#define 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]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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.5–2.8 V). Mirrored as the "Auto-off below 3.0 V"
|
||||||
|
// note on the battery screen (ui.cpp). Raise toward 3300 mV to be even kinder
|
||||||
|
// to the cell, or down to 2800 to squeeze out the last drops.
|
||||||
|
#define LOW_V_CUTOFF_MV 3000
|
||||||
|
#define LOW_V_SUSTAIN_MS 6000UL
|
||||||
|
#define LOW_V_WARN_MS 5000UL
|
||||||
|
|
||||||
// ---- LVGL draw buffers (partial render mode) ----
|
// ---- 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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user