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
|
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).
|
||||||
|
|
||||||
|
|||||||
@@ -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,23 +728,37 @@ 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
|
||||||
|
|
||||||
|
# Now Playing (Phase 5) — best-effort Windows media session, read every
|
||||||
|
# tick (fast cadence). Local only, so it works regardless of "ok" above.
|
||||||
try:
|
try:
|
||||||
payload.update(await read_now_playing())
|
np = await read_now_playing()
|
||||||
except Exception as e: # never let media reading break the poll loop
|
except Exception as e: # never let media reading break the poll loop
|
||||||
log(f"Now-playing skipped: {e!r}")
|
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
|
||||||
|
# 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:
|
if tray_state:
|
||||||
tray_state.set_connected(time.time())
|
tray_state.set_connected(time.time())
|
||||||
elif auth_problem:
|
elif auth_problem:
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +861,21 @@ 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) {
|
||||||
|
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) {
|
if (data->np_state == 0) {
|
||||||
lv_label_set_text(np_icon_lbl, LV_SYMBOL_AUDIO);
|
lv_label_set_text(np_icon_lbl, LV_SYMBOL_AUDIO);
|
||||||
lv_obj_set_style_text_color(np_icon_lbl, COL_DIM, 0);
|
lv_obj_set_style_text_color(np_icon_lbl, COL_DIM, 0);
|
||||||
@@ -878,6 +893,7 @@ void ui_update(const UsageData* data) {
|
|||||||
lv_label_set_text(np_status_lbl, playing ? "Playing" : "Paused");
|
lv_label_set_text(np_status_lbl, playing ? "Playing" : "Paused");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Rate-limit utilization (5h / 7d). The daemon sets ok=false when this data
|
// Rate-limit utilization (5h / 7d). The daemon sets ok=false when this data
|
||||||
// is unavailable (expired OAuth token, API down). Skip the bars and DON'T
|
// is unavailable (expired OAuth token, API down). Skip the bars and DON'T
|
||||||
|
|||||||
Reference in New Issue
Block a user