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:
@@ -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).
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
+31
-15
@@ -1,6 +1,7 @@
|
||||
#include "ui.h"
|
||||
#include "splash.h"
|
||||
#include <lvgl.h>
|
||||
#include <string.h>
|
||||
#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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user