From a5b57083dda15805eafeb7f00c82e36bf4fb9ee4 Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Sun, 24 May 2026 16:14:00 -0400 Subject: [PATCH 1/2] Fix 5-second polling on-error. Add exponential API query backup (maxing out at 5 minute period). --- daemon/claude-usage-daemon.sh | 34 ++++++++++++++++++++++++++++-- daemon/claude_usage_daemon.py | 39 ++++++++++++++++++++++++++++------- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/daemon/claude-usage-daemon.sh b/daemon/claude-usage-daemon.sh index a9f111049..aedc5311b 100755 --- a/daemon/claude-usage-daemon.sh +++ b/daemon/claude-usage-daemon.sh @@ -13,6 +13,9 @@ RX_CHAR_UUID="4c41555a-4465-7669-6365-000000000002" REQ_CHAR_UUID="4c41555a-4465-7669-6365-000000000004" POLL_INTERVAL=60 TICK=5 +# Exponential backoff cap on consecutive poll failures (e.g. when the +# /api/oauth/usage endpoint returns 429). Backoff doubles from POLL_INTERVAL. +MAX_BACKOFF_INTERVAL=300 SAVED_MAC_FILE="$HOME/.config/claude-usage-monitor/ble-address" REFRESH_FLAG="/tmp/claude-usage-refresh-$$" DBUS_DEST="org.bluez" @@ -302,14 +305,41 @@ while true; do # Poll loop: tick every $TICK seconds. Poll Anthropic when the # interval has elapsed OR when the ESP requested a refresh. LAST_POLL=0 + FAILURES=0 while is_connected; do NOW=$(date +%s) - if [ -f "$REFRESH_FLAG" ] || (( NOW - LAST_POLL >= POLL_INTERVAL )); then + ELAPSED=$((NOW - LAST_POLL)) + # Exponential backoff on consecutive failures: 60s, 120s, 240s, + # capped at MAX_BACKOFF_INTERVAL. Resets on success. + EFFECTIVE=$((POLL_INTERVAL << FAILURES)) + (( EFFECTIVE > MAX_BACKOFF_INTERVAL )) && EFFECTIVE=$MAX_BACKOFF_INTERVAL + + PERIODIC_DUE=0 + (( ELAPSED >= EFFECTIVE )) && PERIODIC_DUE=1 + # Refresh bypasses POLL_INTERVAL during steady state, but respects + # backoff — bypassing it on a rate-limited API would just keep + # tripping the same 429. + REFRESH_DUE=0 + if [ -f "$REFRESH_FLAG" ] && (( FAILURES == 0 )); then + REFRESH_DUE=1 + fi + + if (( PERIODIC_DUE || REFRESH_DUE )); then if [ -f "$REFRESH_FLAG" ]; then log "Refresh requested by device" rm -f "$REFRESH_FLAG" fi - poll && LAST_POLL=$NOW + if poll; then + FAILURES=0 + else + # Cap shift count to prevent integer overflow on long-running + # failures; the value is already pinned to MAX_BACKOFF_INTERVAL. + (( FAILURES < 10 )) && FAILURES=$((FAILURES + 1)) + NEXT=$((POLL_INTERVAL << FAILURES)) + (( NEXT > MAX_BACKOFF_INTERVAL )) && NEXT=$MAX_BACKOFF_INTERVAL + log "Poll failed (#$FAILURES); next attempt in ${NEXT}s" + fi + LAST_POLL=$NOW fi sleep "$TICK" done diff --git a/daemon/claude_usage_daemon.py b/daemon/claude_usage_daemon.py index a660dbd04..49aaeff2a 100755 --- a/daemon/claude_usage_daemon.py +++ b/daemon/claude_usage_daemon.py @@ -31,6 +31,9 @@ POLL_INTERVAL = 60 TICK = 5 SCAN_TIMEOUT = 8.0 +# Exponential backoff cap on consecutive poll failures (e.g. when the +# /api/oauth/usage endpoint returns 429). Backoff doubles from POLL_INTERVAL. +MAX_BACKOFF_INTERVAL = 300 # macOS: token lives in Keychain (service "Claude Code-credentials"). # Linux: token lives in ~/.claude/.credentials.json. @@ -264,27 +267,49 @@ async def connect_and_run(address: str, stop_event: asyncio.Event) -> bool: await session.setup_refresh_subscription() last_poll = 0.0 + failures = 0 used_successfully = False 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: + # Exponential backoff on consecutive failures: 60s, 120s, 240s, + # capped at MAX_BACKOFF_INTERVAL. Resets on success. + effective_interval = min(POLL_INTERVAL * (2**failures), MAX_BACKOFF_INTERVAL) + periodic_due = elapsed >= effective_interval + # Refresh (device boot signal) bypasses POLL_INTERVAL during the + # success steady state, but respects backoff — bypassing it on a + # rate-limited API would just keep tripping the same 429. + refresh_due = session.refresh_requested.is_set() and failures == 0 + + if periodic_due or refresh_due: session.refresh_requested.clear() token = read_token() + last_poll = time.time() if not token: log("No token; skipping poll") else: payload = await poll_api(token) - if payload is not None: + if payload is None: + failures += 1 + next_in = min(POLL_INTERVAL * (2**failures), MAX_BACKOFF_INTERVAL) + log(f"Poll failed (#{failures}); next attempt in {next_in}s") + else: if await session.write_payload(payload): - last_poll = time.time() used_successfully = True + failures = 0 - try: - await asyncio.wait_for(session.refresh_requested.wait(), timeout=TICK) - except asyncio.TimeoutError: - pass + # Wake on refresh only when not in backoff; otherwise the event + # stays set during the backoff window and would tight-loop the + # wait/check cycle. Plain sleep during backoff is fine — the + # event will be picked up on the next iteration after the window. + if failures == 0: + try: + await asyncio.wait_for(session.refresh_requested.wait(), timeout=TICK) + except asyncio.TimeoutError: + pass + else: + await asyncio.sleep(TICK) finally: try: await client.disconnect() From 1cbc74766f3dfa731960b4f6bb0fd51b5c4e07bd Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Mon, 25 May 2026 20:07:12 -0400 Subject: [PATCH 2/2] Add error-passing from daemon->device --- README.md | 20 +++++++++++- daemon/claude-usage-daemon.sh | 58 ++++++++++++++++++++++++++++++----- firmware/src/data.h | 3 ++ firmware/src/main.cpp | 35 ++++++++++++++++++--- firmware/src/ui.cpp | 18 +++++++++-- 5 files changed, 119 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f560a4f72..92aea037f 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,25 @@ JSON payload format (written to RX): { "s": 45, "sr": 120, "w": 28, "wr": 7200, "st": "allowed", "ok": true } ``` -Fields: `s` = session %, `sr` = session reset (minutes), `w` = weekly %, `wr` = weekly reset (minutes), `st` = status, `ok` = success flag. +On failure the daemon instead writes a short error payload, which the firmware surfaces in place of the rotating caption on the usage screen: + +```json +{ "ok": false, "err": "Rate limited" } +``` + +Field keys are kept short to fit in a single BLE write (ATT MTU is tight): + +| Key | Type | Meaning | Range / values | +| ----- | ------- | ------------------------------------------------ | --------------------------------------------------------- | +| `s` | int | Session utilization (5-hour window) | `0`–`100` (percent) | +| `sr` | int | Minutes until the session window resets | `0`+ minutes; `-1` if unknown | +| `w` | int | Weekly utilization (7-day window) | `0`–`100` (percent) | +| `wr` | int | Minutes until the weekly window resets | `0`+ minutes; `-1` if unknown | +| `st` | string | Account status from the API | `"allowed"`, `"limited"` (set to `"limited"` when `s≥100`)| +| `ok` | bool | Daemon-side success flag for this poll | `true` on a clean parse, `false` on any failure | +| `err` | string | Short error message shown on-device when `!ok` | e.g. `"Auth expired"`, `"Rate limited"`, `"No internet"`, `"Anthropic API down"`, `"Bad API response"` (≤30 chars) | + +Defaults if a key is missing: `s`/`w` → `0`, `sr`/`wr` → `-1`, `st` → `"unknown"`, `ok` → `false`, `err` → `""` (see `parse_json` in `firmware/src/main.cpp`). On an `ok: false` payload the firmware keeps the last-known percentages on screen and only swaps the caption — so the user still sees the most recent numbers next to the reason they're stale. If no BLE message arrives for more than ~70 minutes (longer than the daemon's worst-case backoff), the firmware synthesizes `err: "Daemon offline"` locally. ## Recompiling fonts diff --git a/daemon/claude-usage-daemon.sh b/daemon/claude-usage-daemon.sh index aedc5311b..caa723396 100755 --- a/daemon/claude-usage-daemon.sh +++ b/daemon/claude-usage-daemon.sh @@ -15,7 +15,9 @@ POLL_INTERVAL=60 TICK=5 # Exponential backoff cap on consecutive poll failures (e.g. when the # /api/oauth/usage endpoint returns 429). Backoff doubles from POLL_INTERVAL. -MAX_BACKOFF_INTERVAL=300 +# 1h cap because the OAuth usage endpoint has aggressive rate limiting and +# the firmware surfaces the error state, so silent rapid retries add no value. +MAX_BACKOFF_INTERVAL=3600 SAVED_MAC_FILE="$HOME/.config/claude-usage-monitor/ble-address" REFRESH_FLAG="/tmp/claude-usage-refresh-$$" DBUS_DEST="org.bluez" @@ -196,18 +198,60 @@ write_gatt() { WriteValue "aya{sv}" "$count" $bytes 0 2>/dev/null } +# Send a short error payload to the firmware so the device can surface the +# failure mode in place of the rotating spinner caption. Messages stay under +# 30 chars to fit the on-device label budget. Silently best-effort: a write +# failure here is logged in write_gatt but doesn't change poll's return code. +send_err() { + local msg="$1" + local payload + payload=$(printf '{"ok":false,"err":"%s"}' "$msg") + log "Sending error: $payload" + write_gatt "$RX_CHAR_PATH" "$payload" >/dev/null 2>&1 +} + poll() { local token - token=$(read_token) || { log "Error: could not read token"; return 1; } - - local body - body=$(curl -s \ + token=$(read_token) || { log "Error: could not read token"; send_err "No auth token"; return 1; } + + # Capture both body and HTTP status. curl -w appends "\n" so we + # split on the trailing newline. curl's own exit code distinguishes a + # network/DNS failure (non-zero) from an HTTP error (always exit 0 with + # -s, no --fail). + local response http_code body curl_exit + response=$(curl -s -w $'\n%{http_code}' \ "https://api.anthropic.com/api/oauth/usage" \ -H "Authorization: Bearer $token" \ -H "anthropic-beta: oauth-2025-04-20" \ -H "Accept: application/json" \ -H "User-Agent: claude-code/2.1.5" \ - 2>/dev/null) || { log "Error: API call failed"; return 1; } + 2>/dev/null) + curl_exit=$? + if (( curl_exit != 0 )); then + log "Error: curl exit $curl_exit (network failure)" + send_err "No internet" + return 1 + fi + http_code="${response##*$'\n'}" + body="${response%$'\n'*}" + + if (( http_code == 401 )); then + log "API HTTP 401: $body" + send_err "Auth expired" + return 1 + elif (( http_code == 429 )); then + log "API HTTP 429: $body" + send_err "Rate limited" + return 1 + elif (( http_code >= 500 )); then + log "API HTTP $http_code: $body" + send_err "Anthropic API down" + return 1 + elif (( http_code != 200 )); then + log "API HTTP $http_code: $body" + send_err "API error $http_code" + return 1 + fi local payload payload=$(python3 -c ' @@ -248,7 +292,7 @@ print(json.dumps({ "st": "limited" if s >= 100 else "allowed", "ok": True, }, separators=(",", ":"))) -' <<< "$body") || { log "Error: failed to parse usage JSON"; return 1; } +' <<< "$body") || { log "Error: failed to parse usage JSON"; send_err "Bad API response"; return 1; } log "Sending: $payload" write_gatt "$RX_CHAR_PATH" "$payload" || { log "Write failed"; return 1; } diff --git a/firmware/src/data.h b/firmware/src/data.h index f79447721..5c0277c50 100644 --- a/firmware/src/data.h +++ b/firmware/src/data.h @@ -1,5 +1,6 @@ #pragma once #include +#include struct UsageData { float session_pct; // 5-hour window utilization (0-100) @@ -9,4 +10,6 @@ struct UsageData { char status[16]; // "allowed" or "limited" bool ok; // data parse succeeded bool valid; // false until first successful parse + char err[32]; // short error message from daemon (empty when ok) + uint32_t last_msg_ms; // millis() of last BLE message (any kind); 0 = never }; diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index 08a5cb2d6..19d1229d3 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -104,12 +104,23 @@ static bool parse_json(const char* json, UsageData* out) { return false; } - out->session_pct = doc["s"] | 0.0f; - out->session_reset_mins = doc["sr"] | -1; - out->weekly_pct = doc["w"] | 0.0f; - out->weekly_reset_mins = doc["wr"] | -1; - strlcpy(out->status, doc["st"] | "unknown", sizeof(out->status)); out->ok = doc["ok"] | false; + if (out->ok) { + // Success payload: numeric fields are authoritative, clear any + // prior error so the UI returns to the rotating caption. + out->session_pct = doc["s"] | 0.0f; + out->session_reset_mins = doc["sr"] | -1; + out->weekly_pct = doc["w"] | 0.0f; + out->weekly_reset_mins = doc["wr"] | -1; + strlcpy(out->status, doc["st"] | "unknown", sizeof(out->status)); + out->err[0] = '\0'; + } else { + // Error payload: keep last known numeric values so the user can + // still read the most recent percentages while seeing why they're + // stale. err[] drives the UI caption override. + strlcpy(out->err, doc["err"] | "Unknown error", sizeof(out->err)); + } + out->last_msg_ms = millis(); out->valid = true; return true; } @@ -321,5 +332,19 @@ void loop() { } } + // Synthesize "Daemon offline" when no BLE message has arrived in longer + // than the daemon's worst-case backoff (1h MAX_BACKOFF_INTERVAL + slack). + // A daemon that's alive but failing API calls still sends an err payload + // every backoff cycle, so it won't trip this path. Edge-triggered to + // avoid spamming ui_update. + static const uint32_t DAEMON_OFFLINE_MS = 70UL * 60UL * 1000UL; + if (usage.valid && (millis() - usage.last_msg_ms) > DAEMON_OFFLINE_MS) { + if (strcmp(usage.err, "Daemon offline") != 0) { + strlcpy(usage.err, "Daemon offline", sizeof(usage.err)); + usage.ok = false; + ui_update(&usage); + } + } + delay(5); } diff --git a/firmware/src/ui.cpp b/firmware/src/ui.cpp index 6e1218832..a03cdebc6 100644 --- a/firmware/src/ui.cpp +++ b/firmware/src/ui.cpp @@ -135,6 +135,10 @@ static uint8_t anim_msg_idx = 0; static uint32_t anim_msg_start = 0; #define ANIM_MSG_MS 4000 +// When the daemon reports a failure (or the firmware synthesizes one for a +// dead daemon), the err string replaces the rotating caption. Empty = normal. +static char current_err[32] = {0}; + static const char* const spinner_frames[] = { "\xC2\xB7", "\xE2\x9C\xBB", "\xE2\x9C\xBD", "\xE2\x9C\xB6", "\xE2\x9C\xB3", "\xE2\x9C\xA2", @@ -449,6 +453,8 @@ void ui_init(void) { void ui_update(const UsageData* data) { if (!data->valid) return; + strlcpy(current_err, data->err, sizeof(current_err)); + int s_pct = (int)(data->session_pct + 0.5f); lv_label_set_text_fmt(lbl_session_pct, "%d%%", s_pct); @@ -484,10 +490,18 @@ void ui_tick_anim(void) { anim_spinner_idx = (anim_phase < SPINNER_COUNT) ? anim_phase : (SPINNER_PHASES - anim_phase); + // Error caption (e.g. "Rate limited", "Auth expired", "Daemon offline") + // replaces the playful rotating verb. Spinner keeps animating so the + // device still looks alive; trailing ellipsis is dropped because the + // error message is a state, not an in-progress action. + const char* caption = current_err[0] ? current_err : anim_messages[anim_msg_idx]; + const char* suffix = current_err[0] ? "" : "\xE2\x80\xA6"; + static char buf[80]; - snprintf(buf, sizeof(buf), "%s %s\xE2\x80\xA6", + snprintf(buf, sizeof(buf), "%s %s%s", spinner_frames[anim_spinner_idx], - anim_messages[anim_msg_idx]); + caption, + suffix); lv_label_set_text(lbl_anim, buf); } }