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
+76 -34
View File
@@ -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,