v2 Phase 4: decouple Session (local usage) from the rate-limit API
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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,26 +494,43 @@ 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.
|
||||
if tray_state:
|
||||
tray_state.set_error("token expired — run claude login")
|
||||
payload = None
|
||||
if payload is not None:
|
||||
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")
|
||||
# transient rate-limit failure: leave tray state unchanged
|
||||
else:
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures >= ZOMBIE_BREAK_LIMIT:
|
||||
@@ -524,11 +539,6 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
|
||||
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.
|
||||
|
||||
# Wake on a refresh request OR a stop, whichever comes first. Waking
|
||||
# promptly on stop_event is what lets the finally below run
|
||||
|
||||
+23
-14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user