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 <noreply@anthropic.com>
This commit is contained in:
wenil
2026-06-21 09:43:31 +03:00
co-authored by Claude Opus 4.8
parent b8d4daa03f
commit 650af4221b
5 changed files with 124 additions and 55 deletions
+8 -3
View File
@@ -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 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 the device should leave its waiting screen and show session + weekly percentages within
about 10 seconds of launch. about 10 seconds of launch.
- The daemon then re-polls every 60 seconds while connected. If the device fires a refresh - The daemon then re-polls the Anthropic API every 60 seconds while connected. If the device
request (e.g., after a button press), an immediate re-poll occurs without waiting for the fires a refresh request (e.g., after a button press), an immediate re-poll occurs without
60-second interval. 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 - 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). re-scans automatically with exponential backoff (starting at 1 second, capped at 60 seconds).
+76 -34
View File
@@ -33,8 +33,13 @@ 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"
POLL_INTERVAL = 60 POLL_INTERVAL = 60 # Anthropic rate-limit / usage poll cadence (seconds)
TICK = 5 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 SCAN_TIMEOUT = 8.0
CONNECT_RETRIES = 3 # D-01: attempts before giving up on a device CONNECT_RETRIES = 3 # D-01: attempts before giving up on a device
CONNECT_RETRY_DELAY = 2.0 # D-01: seconds between failed connect attempts CONNECT_RETRY_DELAY = 2.0 # D-01: seconds between failed connect attempts
@@ -312,10 +317,21 @@ class Session:
log(f"Refresh subscription unavailable: {e}") log(f"Refresh subscription unavailable: {e}")
async def write_payload(self, payload: dict) -> bool: 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()}") log(f"Sending: {data.decode()}")
try: 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 return True
except (BleakError, OSError) as e: except (BleakError, OSError) as e:
# WinRT can raise a raw OSError/WinError (NOT wrapped as BleakError) # 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: async def _wait_first(*events: asyncio.Event, timeout: float) -> None:
"""Return when any of `events` is set, or after `timeout` seconds. """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 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. later. Cancels and drains the loser tasks so they don't warn.
""" """
tasks = [asyncio.ensure_future(e.wait()) for e in events] 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) session = Session(client)
await session.setup_refresh_subscription() 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 used_successfully = False
consecutive_failures = 0 # D-03: zombie-link break counter consecutive_failures = 0 # D-03: zombie-link break counter
try: try:
while client.is_connected and not stop_event.is_set(): while client.is_connected and not stop_event.is_set():
now = time.time() now = time.time()
elapsed = now - last_poll claude_due = (session.refresh_requested.is_set()
if session.refresh_requested.is_set() or elapsed >= POLL_INTERVAL: or (now - last_claude_poll) >= POLL_INTERVAL)
auth_problem = False
if claude_due:
session.refresh_requested.clear() session.refresh_requested.clear()
# 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.
payload = compute_today_usage() fresh = compute_today_usage()
# Rate-limit utilization is best-effort. A genuine 401/403 flags the # Rate-limit utilization is best-effort. A genuine 401/403 flags the
# token; a transient failure (network/DNS/5xx) leaves the tray state # token; a transient failure (network/DNS/5xx) leaves the tray state
# alone (SC#5: a DNS blip must not read as "token expired"). # alone (SC#5: a DNS blip must not read as "token expired").
rl = None rl = None
auth_problem = False
# Self-refresh the OAuth token before using it. In a managed # Self-refresh the OAuth token before using it. In a managed
# (Agent SDK) environment `claude login` is unavailable, so the # (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 auth_problem = True
if rl is not None: if rl is not None:
payload.update(rl) fresh.update(rl)
payload["ok"] = True # rate-limit data is fresh fresh["ok"] = True # rate-limit data is fresh
else: 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 cached = fresh
# only, so it's attached regardless of the rate-limit "ok" above. last_claude_poll = now
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}")
# 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): if await session.write_payload(payload):
last_poll = time.time()
used_successfully = True used_successfully = True
consecutive_failures = 0 # D-03: reset on success consecutive_failures = 0 # D-03: reset on success
if rl is not None: last_np_sent = np
if tray_state: # The tray reflects the Anthropic data freshness, so only touch
tray_state.set_connected(time.time()) # it on a usage poll — never on a now-playing-only write.
elif auth_problem: if claude_due:
if tray_state: if cached.get("ok"):
tray_state.set_error("token expired — run claude login") if tray_state:
# transient rate-limit failure: leave tray state unchanged 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: else:
consecutive_failures += 1 consecutive_failures += 1
if consecutive_failures >= ZOMBIE_BREAK_LIMIT: if consecutive_failures >= ZOMBIE_BREAK_LIMIT:
@@ -734,12 +774,14 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
) )
break break
# Wake on a refresh request OR a stop, whichever comes first. Waking # Wake on a refresh request OR a stop, whichever comes first, but no
# promptly on stop_event is what lets the finally below run # later than NOWPLAYING_INTERVAL so the media session is re-read on time.
# client.disconnect() before the process exits, so the peer gets a # Waking promptly on stop_event is what lets the finally below run
# clean GATT disconnect (returns to its waiting screen) instead of # client.disconnect() before the process exits, so the peer gets a clean
# being left frozen on stale data after Quit (SC#3 graceful shutdown). # GATT disconnect (returns to its waiting screen) instead of being left
await _wait_first(session.refresh_requested, stop_event, timeout=TICK) # frozen on stale data after Quit (SC#3 graceful shutdown).
await _wait_first(session.refresh_requested, stop_event,
timeout=NOWPLAYING_INTERVAL)
finally: finally:
# Clean GATT disconnect on the way out — this is what tells the peripheral # 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, # the link is gone. WinRT can surface a raw OSError (not BleakError) here,
+5
View File
@@ -4,3 +4,8 @@ bleak
httpx httpx
pystray pystray
Pillow 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
+4 -3
View File
@@ -73,7 +73,7 @@ def test_poll_api_nominal(monkeypatch):
assert payload["s"] == 42 assert payload["s"] == 42
assert payload["w"] == 10 assert payload["w"] == 10
assert payload["st"] == "allowed" 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 # reset_minutes allows ±1 minute tolerance
assert abs(payload["sr"] - 60) <= 1, f"Expected ~60, got {payload['sr']}" assert abs(payload["sr"] - 60) <= 1, f"Expected ~60, got {payload['sr']}"
assert abs(payload["wr"] - 1440) <= 1, f"Expected ~1440, got {payload['wr']}" 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}"
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("{") 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}" assert f'"{key}"' in wire_str, f"Missing key {key!r} in wire bytes: {wire_str!r}"
+31 -15
View File
@@ -1,6 +1,7 @@
#include "ui.h" #include "ui.h"
#include "splash.h" #include "splash.h"
#include <lvgl.h> #include <lvgl.h>
#include <string.h>
#include "logo.h" #include "logo.h"
#include "icons.h" #include "icons.h"
#include "hal/board_caps.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 // 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. // 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 (nowplaying_container) {
if (data->np_state == 0) { static int np_state_shown = -1;
lv_label_set_text(np_icon_lbl, LV_SYMBOL_AUDIO); static char np_title_shown[64] = {0};
lv_obj_set_style_text_color(np_icon_lbl, COL_DIM, 0); static char np_artist_shown[64] = {0};
lv_label_set_text(np_title_lbl, "Nothing playing"); if (data->np_state != np_state_shown
lv_obj_set_style_text_color(np_title_lbl, COL_DIM, 0); || strcmp(data->np_title, np_title_shown) != 0
lv_label_set_text(np_artist_lbl, ""); || strcmp(data->np_artist, np_artist_shown) != 0) {
lv_label_set_text(np_status_lbl, ""); np_state_shown = data->np_state;
} else { strlcpy(np_title_shown, data->np_title, sizeof(np_title_shown));
bool playing = (data->np_state == 1); strlcpy(np_artist_shown, data->np_artist, sizeof(np_artist_shown));
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); if (data->np_state == 0) {
lv_label_set_text(np_title_lbl, data->np_title[0] ? data->np_title : "(no title)"); lv_label_set_text(np_icon_lbl, LV_SYMBOL_AUDIO);
lv_obj_set_style_text_color(np_title_lbl, COL_TEXT, 0); lv_obj_set_style_text_color(np_icon_lbl, COL_DIM, 0);
lv_label_set_text(np_artist_lbl, data->np_artist); lv_label_set_text(np_title_lbl, "Nothing playing");
lv_label_set_text(np_status_lbl, playing ? "Playing" : "Paused"); 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");
}
} }
} }