From b188349262e94d812da9e644d79efc815a674331 Mon Sep 17 00:00:00 2001 From: wenil Date: Sat, 20 Jun 2026 18:49:34 +0300 Subject: [PATCH] v2 Phase 4: decouple Session (local usage) from the rate-limit API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Session screen's tokens/cost are computed locally from transcripts and need no network, but they were gated behind a successful rate-limit API call — so an expired OAuth token or API outage froze the whole watch. Daemon: always compute and send local usage every cycle; rate-limit utilization is now best-effort and merged on top when available, with ok=true only when it's fresh (ok=false on expired token / API down). poll_api returns just the rate-limit fields; connect_and_run owns the payload + ok flag. Firmware: ui_update always refreshes the Session screen; the rate-limit bars and the freshness clock update only when data->ok, so the usage view falls back to its existing idle "Zzz" state instead of showing stale or zeroed percentages. Verified: with the token expired (HTTP 401) the daemon still sends tk/tc/tn/to + ok:false and the Session screen keeps updating. Co-Authored-By: Claude Opus 4.8 --- daemon/claude_usage_daemon_windows.py | 74 +++++++++++++++------------ firmware/src/ui.cpp | 37 +++++++++----- 2 files changed, 65 insertions(+), 46 deletions(-) diff --git a/daemon/claude_usage_daemon_windows.py b/daemon/claude_usage_daemon_windows.py index a645fe5..3ab9fec 100644 --- a/daemon/claude_usage_daemon_windows.py +++ b/daemon/claude_usage_daemon_windows.py @@ -253,18 +253,16 @@ async def poll_api(token: str) -> dict | None: except ValueError: return 0 - payload = { + # Rate-limit utilization only. The local token usage and the "ok" flag are + # merged by the caller (connect_and_run) so the Session screen keeps updating + # even when this call fails (expired token / API down) — see decoupling there. + return { "s": pct(hdr("anthropic-ratelimit-unified-5h-utilization")), "sr": reset_minutes(hdr("anthropic-ratelimit-unified-5h-reset")), "w": pct(hdr("anthropic-ratelimit-unified-7d-utilization")), "wr": reset_minutes(hdr("anthropic-ratelimit-unified-7d-reset")), "st": hdr("anthropic-ratelimit-unified-5h-status", "unknown"), - "ok": True, } - # Piggyback today's local token usage/cost onto the same 60s payload. Local - # file read (~tens of ms once per minute) — fine to do inline in the poller. - payload.update(compute_today_usage()) - return payload async def scan_for_device(): @@ -496,39 +494,51 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) -> elapsed = now - last_poll if session.refresh_requested.is_set() or elapsed >= POLL_INTERVAL: 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() + + # 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 token = read_token() # D-09: fresh each cycle if not token: - log("No token; skipping poll") - if tray_state: - tray_state.set_error("token expired — run claude login") + auth_problem = True + log("No token; sending local usage only") else: try: - payload = await poll_api(token) + rl = await poll_api(token) except AuthError: - # Real 401/403 — token genuinely needs a refresh. + auth_problem = True + + if rl is not None: + payload.update(rl) + payload["ok"] = True # rate-limit data is fresh + else: + payload["ok"] = False # rate-limit unknown -> watch usage view goes idle + + 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") - payload = None - if payload is not None: - if await session.write_payload(payload): - last_poll = time.time() - used_successfully = True - consecutive_failures = 0 # D-03: reset on success - if tray_state: - tray_state.set_connected(time.time()) - else: - consecutive_failures += 1 - if consecutive_failures >= ZOMBIE_BREAK_LIMIT: - log( - f"Zombie link detected ({consecutive_failures} consecutive" - f" write failures); abandoning connection" - ) - break - # else: payload is None from a TRANSIENT failure (network/DNS, - # timeout, rate-limit, 5xx). poll_api already logged it; do NOT - # toast "token expired" — that mislabeled a boot-time DNS blip - # as an auth problem (SC#5). Leave tray state unchanged; the next - # tick retries and set_connected() recovers it. + # transient rate-limit failure: leave tray state unchanged + else: + consecutive_failures += 1 + if consecutive_failures >= ZOMBIE_BREAK_LIMIT: + log( + f"Zombie link detected ({consecutive_failures} consecutive" + f" write failures); abandoning connection" + ) + break # Wake on a refresh request OR a stop, whichever comes first. Waking # promptly on stop_event is what lets the finally below run diff --git a/firmware/src/ui.cpp b/firmware/src/ui.cpp index 3f22b06..330c3b2 100644 --- a/firmware/src/ui.cpp +++ b/firmware/src/ui.cpp @@ -785,16 +785,36 @@ void ui_init(void) { void ui_update(const UsageData* data) { if (!data->valid) return; - last_data_ms = lv_tick_get(); // a valid usage update just landed → dot goes green + + char buf[48]; + + // Session screen — local token data, computed by the daemon from transcripts + // with no API/token needed. Always refresh it so it stays live even when the + // rate-limit data below is unavailable (data->ok == false). + if (sess_cost_lbl) { + lv_label_set_text_fmt(sess_cost_lbl, "$%d.%02d", + data->cost_cents_today / 100, data->cost_cents_today % 100); + format_tokens(data->tokens_today, buf, sizeof(buf)); + lv_label_set_text_fmt(sess_tokens_lbl, "%s tokens", buf); + format_tokens(data->output_today, buf, sizeof(buf)); + lv_label_set_text_fmt(sess_gen_lbl, "%s generated", buf); + lv_label_set_text_fmt(sess_msgs_lbl, "%d requests", data->messages_today); + } + + // 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 + // bump the freshness clock then, so the usage view falls back to its idle + // "Zzz" state instead of showing stale or zeroed percentages. + if (!data->ok) return; + + last_data_ms = lv_tick_get(); // fresh limit data just landed → dot goes green data_received = true; int s_pct = (int)(data->session_pct + 0.5f); - lv_label_set_text_fmt(lbl_session_pct, "%d%%", s_pct); lv_bar_set_value(bar_session, s_pct, LV_ANIM_ON); lv_obj_set_style_bg_color(bar_session, pct_color(data->session_pct), LV_PART_INDICATOR); - char buf[48]; format_reset_time(data->session_reset_mins, buf, sizeof(buf)); lv_label_set_text(lbl_session_reset, buf); @@ -805,17 +825,6 @@ void ui_update(const UsageData* data) { format_reset_time(data->weekly_reset_mins, buf, sizeof(buf)); lv_label_set_text(lbl_weekly_reset, buf); - - // Session screen — refreshed here so it's current whenever it's opened. - if (sess_cost_lbl) { - lv_label_set_text_fmt(sess_cost_lbl, "$%d.%02d", - data->cost_cents_today / 100, data->cost_cents_today % 100); - format_tokens(data->tokens_today, buf, sizeof(buf)); - lv_label_set_text_fmt(sess_tokens_lbl, "%s tokens", buf); - format_tokens(data->output_today, buf, sizeof(buf)); - lv_label_set_text_fmt(sess_gen_lbl, "%s generated", buf); - lv_label_set_text_fmt(sess_msgs_lbl, "%d requests", data->messages_today); - } } // Pick the usage-view sub-screen: pairing hint (BLE down), the idle "Zzz" screen