From 650af4221b68406073564d703f007913de5ed499 Mon Sep 17 00:00:00 2001 From: wenil Date: Sun, 21 Jun 2026 09:43:31 +0300 Subject: [PATCH] v2: real-time Now Playing cadence + robust long-title BLE writes Decouple the two data sources that share the BLE link: - Anthropic usage / rate-limit: still polled every 60s. - Windows media session: read every 3s and pushed the moment the track or play/pause state changes, so a song change reaches the watch in seconds instead of at the next 60s poll. The last usage payload is cached and merged into each now-playing write, so the firmware always gets one complete JSON object and the usage screens never blank between polls. Fix: long media titles (a 44-char Cyrillic title -> ~256 B once json escapes each char to \uXXXX) overflowed the ATT MTU, so every write-without-response failed with E_INVALIDARG and tripped the zombie-link break in a reconnect loop. Switch the RX write to response=True (WinRT does a reliable long write; the RX char already advertises WRITE and NimBLE reassembles into its 512 B buffer) and serialize with ensure_ascii=False so Cyrillic goes as 2-byte UTF-8 instead of 6-byte escapes. Verified on hardware: stable link, writes succeed across the 60s heartbeat. Firmware: only re-set the Now Playing labels when the track / state actually changed, so the faster cadence does not restart the circular title-scroll animation on every (often identical) payload. requirements-windows.txt: add winrt-Windows.Media[.Control]. Phase 5 imports them but they were never declared, so now-playing silently degraded to "nothing playing" on a fresh machine. tests: fix two stale poll_api assertions that expected an "ok" key poll_api has not emitted since that flag moved to the caller (connect_and_run). Co-Authored-By: Claude Opus 4.8 --- daemon/README-windows.md | 11 ++- daemon/claude_usage_daemon_windows.py | 110 ++++++++++++++++++-------- daemon/requirements-windows.txt | 5 ++ daemon/tests/test_windows_poll.py | 7 +- firmware/src/ui.cpp | 46 +++++++---- 5 files changed, 124 insertions(+), 55 deletions(-) diff --git a/daemon/README-windows.md b/daemon/README-windows.md index e32fc1a..5f75347 100644 --- a/daemon/README-windows.md +++ b/daemon/README-windows.md @@ -118,9 +118,14 @@ python daemon\claude_usage_daemon_windows.py payload within a few seconds of connect (warm token path). With a valid, non-expired token the device should leave its waiting screen and show session + weekly percentages within about 10 seconds of launch. -- The daemon then re-polls every 60 seconds while connected. If the device fires a refresh - request (e.g., after a button press), an immediate re-poll occurs without waiting for the - 60-second interval. +- The daemon then re-polls the Anthropic API every 60 seconds while connected. If the device + fires a refresh request (e.g., after a button press), an immediate re-poll occurs without + waiting for the 60-second interval. +- The **Now Playing** screen updates on a separate, faster cadence: the daemon reads the + Windows media session every 3 seconds and pushes an update the moment the track or + play/pause state changes — so the watch reflects a song change within a few seconds, not at + the next 60-second API poll. The cached usage data is re-sent with each of these updates, so + the rate-limit / token screens never blank between polls. - If the device disconnects or goes out of range, the daemon logs `Device disconnected` and re-scans automatically with exponential backoff (starting at 1 second, capped at 60 seconds). diff --git a/daemon/claude_usage_daemon_windows.py b/daemon/claude_usage_daemon_windows.py index 1e3ca9e..5a4ac49 100644 --- a/daemon/claude_usage_daemon_windows.py +++ b/daemon/claude_usage_daemon_windows.py @@ -33,8 +33,13 @@ SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001" RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002" REQ_CHAR_UUID = "4c41555a-4465-7669-6365-000000000004" -POLL_INTERVAL = 60 -TICK = 5 +POLL_INTERVAL = 60 # Anthropic rate-limit / usage poll cadence (seconds) +NOWPLAYING_INTERVAL = 3 # Windows media-session read cadence (seconds). The + # "music card" is refreshed ~20x faster than the API + # poll so a track / play-pause change reaches the watch + # within a few seconds instead of at the next 60s poll. + # Doubles as the inner-loop tick (was TICK=5) — the loop + # wakes this often to read media + detect a dropped link. SCAN_TIMEOUT = 8.0 CONNECT_RETRIES = 3 # D-01: attempts before giving up on a device CONNECT_RETRY_DELAY = 2.0 # D-01: seconds between failed connect attempts @@ -312,10 +317,21 @@ class Session: log(f"Refresh subscription unavailable: {e}") async def write_payload(self, payload: dict) -> bool: - data = json.dumps(payload, separators=(",", ":")).encode() + # 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 + # "now playing" payload. ArduinoJson parses UTF-8 directly and LVGL renders + # it, so this is a pure wire-size win with no firmware change. + data = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode() log(f"Sending: {data.decode()}") try: - await self.client.write_gatt_char(RX_CHAR_UUID, data, response=False) + # response=True (Write Request, not Write Command). WinRT performs a + # reliable long write when the payload exceeds the ATT MTU, so a long + # media title no longer fails with E_INVALIDARG the way a larger-than-MTU + # write-without-response does. The RX characteristic advertises WRITE as + # well as WRITE_NR and NimBLE reassembles the long write (512 B buffer). + # A successful write now also means the peer actually ACKed, which + # sharpens the zombie-link detection below. + await self.client.write_gatt_char(RX_CHAR_UUID, data, response=True) return True except (BleakError, OSError) as e: # WinRT can raise a raw OSError/WinError (NOT wrapped as BleakError) @@ -582,9 +598,9 @@ async def read_now_playing() -> dict: async def _wait_first(*events: asyncio.Event, timeout: float) -> None: """Return when any of `events` is set, or after `timeout` seconds. - Lets the poll loop's TICK wait wake immediately on a stop signal (clean, + Lets the poll loop's tick wait wake immediately on a stop signal (clean, responsive Quit) without losing the refresh-request wakeup — instead of - waiting only on refresh_requested and re-checking stop_event up to TICK + waiting only on refresh_requested and re-checking stop_event up to a tick later. Cancels and drains the loser tasks so they don't warn. """ tasks = [asyncio.ensure_future(e.wait()) for e in events] @@ -648,25 +664,35 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> session = Session(client) await session.setup_refresh_subscription() - last_poll = 0.0 # D-03: poll immediately on first connect + # Two cadences share one connection: the Anthropic usage / rate-limit poll runs + # every POLL_INTERVAL (60s) while the Windows media session is read every + # NOWPLAYING_INTERVAL (3s). The last usage payload is cached and merged into + # each now-playing write, so the firmware always gets one complete JSON object + # (its parser defaults any missing field to 0 — a partial write would blank the + # usage screens) and a track change shows within a few seconds, not 60. + last_claude_poll = 0.0 # 0 => poll Anthropic immediately on first connect + cached: dict = {} # last usage + rate-limit fields, re-sent every tick + last_np_sent = None # last now-playing fields actually written (change gate) used_successfully = False consecutive_failures = 0 # D-03: zombie-link break counter try: while client.is_connected and not stop_event.is_set(): now = time.time() - elapsed = now - last_poll - if session.refresh_requested.is_set() or elapsed >= POLL_INTERVAL: + claude_due = (session.refresh_requested.is_set() + or (now - last_claude_poll) >= POLL_INTERVAL) + auth_problem = False + + if claude_due: session.refresh_requested.clear() # Local token usage (Session screen) needs no network — compute it # every cycle so the watch keeps updating even with no/expired token. - payload = compute_today_usage() + fresh = compute_today_usage() # Rate-limit utilization is best-effort. A genuine 401/403 flags the # token; a transient failure (network/DNS/5xx) leaves the tray state # alone (SC#5: a DNS blip must not read as "token expired"). rl = None - auth_problem = False # Self-refresh the OAuth token before using it. In a managed # (Agent SDK) environment `claude login` is unavailable, so the @@ -702,29 +728,43 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> auth_problem = True if rl is not None: - payload.update(rl) - payload["ok"] = True # rate-limit data is fresh + fresh.update(rl) + fresh["ok"] = True # rate-limit data is fresh else: - payload["ok"] = False # rate-limit unknown -> watch usage view goes idle + fresh["ok"] = False # rate-limit unknown -> watch usage view goes idle - # Now Playing (Phase 5) — best-effort Windows media session. Local - # only, so it's attached regardless of the rate-limit "ok" above. - try: - payload.update(await read_now_playing()) - except Exception as e: # never let media reading break the poll loop - log(f"Now-playing skipped: {e!r}") + cached = fresh + last_claude_poll = now + # Now Playing (Phase 5) — best-effort Windows media session, read every + # tick (fast cadence). Local only, so it works regardless of "ok" above. + try: + np = await read_now_playing() + except Exception as e: # never let media reading break the poll loop + log(f"Now-playing skipped: {e!r}") + np = {"np": 0} + + # Write when the usage data was just refreshed (this is also the ~60s + # heartbeat) or when the track / playback state changed since the last + # write — skip otherwise so the link, and the field log, stay quiet + # between changes instead of repeating an identical payload every 3s. + if claude_due or np != last_np_sent: + payload = dict(cached) + payload.update(np) if await session.write_payload(payload): - last_poll = time.time() used_successfully = True consecutive_failures = 0 # D-03: reset on success - if rl is not None: - if tray_state: - tray_state.set_connected(time.time()) - elif auth_problem: - if tray_state: - tray_state.set_error("token expired — run claude login") - # transient rate-limit failure: leave tray state unchanged + last_np_sent = np + # The tray reflects the Anthropic data freshness, so only touch + # it on a usage poll — never on a now-playing-only write. + if claude_due: + if cached.get("ok"): + if tray_state: + tray_state.set_connected(time.time()) + elif auth_problem: + if tray_state: + tray_state.set_error("token expired — run claude login") + # transient rate-limit failure: leave tray state unchanged else: consecutive_failures += 1 if consecutive_failures >= ZOMBIE_BREAK_LIMIT: @@ -734,12 +774,14 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> ) break - # Wake on a refresh request OR a stop, whichever comes first. Waking - # promptly on stop_event is what lets the finally below run - # client.disconnect() before the process exits, so the peer gets a - # clean GATT disconnect (returns to its waiting screen) instead of - # being left frozen on stale data after Quit (SC#3 graceful shutdown). - await _wait_first(session.refresh_requested, stop_event, timeout=TICK) + # Wake on a refresh request OR a stop, whichever comes first, but no + # later than NOWPLAYING_INTERVAL so the media session is re-read on time. + # Waking promptly on stop_event is what lets the finally below run + # client.disconnect() before the process exits, so the peer gets a clean + # GATT disconnect (returns to its waiting screen) instead of being left + # frozen on stale data after Quit (SC#3 graceful shutdown). + await _wait_first(session.refresh_requested, stop_event, + timeout=NOWPLAYING_INTERVAL) finally: # Clean GATT disconnect on the way out — this is what tells the peripheral # the link is gone. WinRT can surface a raw OSError (not BleakError) here, diff --git a/daemon/requirements-windows.txt b/daemon/requirements-windows.txt index d333f8b..9d65b56 100644 --- a/daemon/requirements-windows.txt +++ b/daemon/requirements-windows.txt @@ -4,3 +4,8 @@ bleak httpx pystray Pillow +# Now Playing (Phase 5): read the Windows "now playing" media session (SMTC). +# bleak already pulls winrt-runtime + the Bluetooth projections; these add the +# media-control projection. pip resolves them to the same winrt-runtime as bleak. +winrt-Windows.Media +winrt-Windows.Media.Control diff --git a/daemon/tests/test_windows_poll.py b/daemon/tests/test_windows_poll.py index 9b61957..b768615 100644 --- a/daemon/tests/test_windows_poll.py +++ b/daemon/tests/test_windows_poll.py @@ -73,7 +73,7 @@ def test_poll_api_nominal(monkeypatch): assert payload["s"] == 42 assert payload["w"] == 10 assert payload["st"] == "allowed" - assert payload["ok"] is True + # "ok" is added by the caller (connect_and_run), not by poll_api itself. # reset_minutes allows ±1 minute tolerance assert abs(payload["sr"] - 60) <= 1, f"Expected ~60, got {payload['sr']}" assert abs(payload["wr"] - 1440) <= 1, f"Expected ~1440, got {payload['wr']}" @@ -416,9 +416,10 @@ def test_wire_bytes_compact_json_shape(monkeypatch): assert ": " not in wire_str, f"Non-compact JSON detected: {wire_str!r}" assert ", " not in wire_str, f"Non-compact JSON detected: {wire_str!r}" - # Must start with '{' and contain all required keys + # Must start with '{' and contain every key poll_api emits. ("ok" is added + # later by connect_and_run, so it is intentionally not part of poll_api output.) assert wire_str.startswith("{") - for key in ("s", "sr", "w", "wr", "st", "ok"): + for key in ("s", "sr", "w", "wr", "st"): assert f'"{key}"' in wire_str, f"Missing key {key!r} in wire bytes: {wire_str!r}" diff --git a/firmware/src/ui.cpp b/firmware/src/ui.cpp index 3df1e35..4c8bacc 100644 --- a/firmware/src/ui.cpp +++ b/firmware/src/ui.cpp @@ -1,6 +1,7 @@ #include "ui.h" #include "splash.h" #include +#include #include "logo.h" #include "icons.h" #include "hal/board_caps.h" @@ -860,22 +861,37 @@ void ui_update(const UsageData* data) { // Now Playing — local media session relayed by the daemon. Like Session, it's // independent of the rate-limit data, so refresh it before the ok==false bail. + // Only touch the labels when the track / playback state actually changed: the + // daemon pushes a payload every few seconds (fast "music card" cadence), and + // re-running lv_label_set_text() on an unchanged title restarts the circular + // scroll animation each time, so a long title would never get to scroll. if (nowplaying_container) { - if (data->np_state == 0) { - lv_label_set_text(np_icon_lbl, LV_SYMBOL_AUDIO); - lv_obj_set_style_text_color(np_icon_lbl, COL_DIM, 0); - lv_label_set_text(np_title_lbl, "Nothing playing"); - lv_obj_set_style_text_color(np_title_lbl, COL_DIM, 0); - lv_label_set_text(np_artist_lbl, ""); - lv_label_set_text(np_status_lbl, ""); - } else { - bool playing = (data->np_state == 1); - lv_label_set_text(np_icon_lbl, playing ? LV_SYMBOL_PLAY : LV_SYMBOL_PAUSE); - lv_obj_set_style_text_color(np_icon_lbl, COL_ACCENT, 0); - lv_label_set_text(np_title_lbl, data->np_title[0] ? data->np_title : "(no title)"); - lv_obj_set_style_text_color(np_title_lbl, COL_TEXT, 0); - lv_label_set_text(np_artist_lbl, data->np_artist); - lv_label_set_text(np_status_lbl, playing ? "Playing" : "Paused"); + static int np_state_shown = -1; + static char np_title_shown[64] = {0}; + static char np_artist_shown[64] = {0}; + if (data->np_state != np_state_shown + || strcmp(data->np_title, np_title_shown) != 0 + || strcmp(data->np_artist, np_artist_shown) != 0) { + np_state_shown = data->np_state; + strlcpy(np_title_shown, data->np_title, sizeof(np_title_shown)); + strlcpy(np_artist_shown, data->np_artist, sizeof(np_artist_shown)); + + if (data->np_state == 0) { + lv_label_set_text(np_icon_lbl, LV_SYMBOL_AUDIO); + lv_obj_set_style_text_color(np_icon_lbl, COL_DIM, 0); + lv_label_set_text(np_title_lbl, "Nothing playing"); + lv_obj_set_style_text_color(np_title_lbl, COL_DIM, 0); + lv_label_set_text(np_artist_lbl, ""); + lv_label_set_text(np_status_lbl, ""); + } else { + bool playing = (data->np_state == 1); + lv_label_set_text(np_icon_lbl, playing ? LV_SYMBOL_PLAY : LV_SYMBOL_PAUSE); + lv_obj_set_style_text_color(np_icon_lbl, COL_ACCENT, 0); + lv_label_set_text(np_title_lbl, data->np_title[0] ? data->np_title : "(no title)"); + lv_obj_set_style_text_color(np_title_lbl, COL_TEXT, 0); + lv_label_set_text(np_artist_lbl, data->np_artist); + lv_label_set_text(np_status_lbl, playing ? "Playing" : "Paused"); + } } }