diff --git a/README.md b/README.md index 1f03645dd..188e1157a 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,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 0663904e2..caa723396 100755 --- a/daemon/claude-usage-daemon.sh +++ b/daemon/claude-usage-daemon.sh @@ -1,8 +1,10 @@ #!/bin/bash # Claude Usage Tracker Daemon (BLE) -# Reads Claude Code OAuth token, polls usage via API, sends to ESP32 over BLE GATT. +# Reads Claude Code OAuth token, polls usage via the OAuth usage endpoint +# (api.anthropic.com/api/oauth/usage — same one `claude /usage` uses, costs +# zero tokens), sends results to the ESP32 over BLE GATT. # Auto-connects and reconnects to the Claude Controller BLE device. -# Dependencies: curl, awk, bluetoothctl +# Dependencies: curl, awk, python3, bluetoothctl DEVICE_NAME="Claude Controller" DEVICE_MAC="${DEVICE_MAC:-}" # auto-discovered if empty @@ -11,6 +13,11 @@ 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. +# 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" @@ -191,45 +198,101 @@ 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 now - now=$(date +%s) - - local headers - headers=$(curl -s -D - -o /dev/null \ - "https://api.anthropic.com/v1/messages" \ + 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-version: 2023-06-01" \ -H "anthropic-beta: oauth-2025-04-20" \ - -H "Content-Type: application/json" \ + -H "Accept: application/json" \ -H "User-Agent: claude-code/2.1.5" \ - -d '{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":[{"role":"user","content":"hi"}]}' \ - 2>/dev/null) || { log "Error: API call failed"; return 1; } - - local s5h_util s5h_reset s7d_util s7d_reset status - s5h_util=$(echo "$headers" | grep -i "anthropic-ratelimit-unified-5h-utilization" | tr -d '\r' | awk '{print $2}') - s5h_reset=$(echo "$headers" | grep -i "anthropic-ratelimit-unified-5h-reset" | tr -d '\r' | awk '{print $2}') - s7d_util=$(echo "$headers" | grep -i "anthropic-ratelimit-unified-7d-utilization" | tr -d '\r' | awk '{print $2}') - s7d_reset=$(echo "$headers" | grep -i "anthropic-ratelimit-unified-7d-reset" | tr -d '\r' | awk '{print $2}') - status=$(echo "$headers" | grep -i "anthropic-ratelimit-unified-5h-status" | tr -d '\r' | awk '{print $2}') - - s5h_util=${s5h_util:-0} - s5h_reset=${s5h_reset:-0} - s7d_util=${s7d_util:-0} - s7d_reset=${s7d_reset:-0} - status=${status:-unknown} + 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=$(awk -v u5="$s5h_util" -v r5="$s5h_reset" -v u7="$s7d_util" -v r7="$s7d_reset" -v st="$status" -v now="$now" \ - 'BEGIN { - sp = sprintf("%.0f", u5 * 100); - sr = (r5 - now) / 60; sr = sr > 0 ? sprintf("%.0f", sr) : 0; - wp = sprintf("%.0f", u7 * 100); - wr = (r7 - now) / 60; wr = wr > 0 ? sprintf("%.0f", wr) : 0; - printf "{\"s\":%s,\"sr\":%s,\"w\":%s,\"wr\":%s,\"st\":\"%s\",\"ok\":true}", sp, sr, wp, wr, st; - }') + payload=$(python3 -c ' +import datetime, json, sys, time + +try: + data = json.loads(sys.stdin.read()) +except json.JSONDecodeError: + sys.exit(1) + +def pct(w): + if not isinstance(w, dict): + return 0 + u = w.get("utilization") + return int(round(u)) if isinstance(u, (int, float)) else 0 + +def reset_mins(w): + if not isinstance(w, dict): + return -1 + s = w.get("resets_at") + if not isinstance(s, str): + return -1 + try: + ts = datetime.datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() + except ValueError: + return -1 + m = (ts - time.time()) / 60.0 + return int(round(m)) if m > 0 else 0 + +fh = data.get("five_hour") +sd = data.get("seven_day") +s = pct(fh) +print(json.dumps({ + "s": s, + "sr": reset_mins(fh), + "w": pct(sd), + "wr": reset_mins(sd), + "st": "limited" if s >= 100 else "allowed", + "ok": True, +}, separators=(",", ":"))) +' <<< "$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; } @@ -286,14 +349,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 8484ba5b8..49aaeff2a 100755 --- a/daemon/claude_usage_daemon.py +++ b/daemon/claude_usage_daemon.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 """Claude Usage Tracker Daemon (BLE) — macOS port of claude-usage-daemon.sh. -Polls Claude API rate-limit headers and writes a JSON payload to the -ESP32 "Claude Controller" peripheral over a custom GATT service. Uses -bleak (CoreBluetooth backend on macOS). +Polls the Claude OAuth usage endpoint (the same one Claude Code's `/usage` +command uses) and writes a JSON payload to the ESP32 "Claude Controller" +peripheral over a custom GATT service. Uses bleak (CoreBluetooth backend +on macOS). """ import asyncio @@ -15,6 +16,7 @@ import subprocess import sys import time +from datetime import datetime from pathlib import Path import httpx @@ -29,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. @@ -36,18 +41,12 @@ CREDENTIALS_PATH = Path.home() / ".claude" / ".credentials.json" SAVED_ADDR_FILE = Path.home() / ".config" / "claude-usage-monitor" / "ble-address" -API_URL = "https://api.anthropic.com/v1/messages" +API_URL = "https://api.anthropic.com/api/oauth/usage" API_HEADERS_TEMPLATE = { - "anthropic-version": "2023-06-01", "anthropic-beta": "oauth-2025-04-20", - "Content-Type": "application/json", + "Accept": "application/json", "User-Agent": "claude-code/2.1.5", } -API_BODY = { - "model": "claude-haiku-4-5-20251001", - "max_tokens": 1, - "messages": [{"role": "user", "content": "hi"}], -} def log(msg: str) -> None: @@ -157,12 +156,40 @@ async def scan_for_device() -> str | None: return None +def _parse_iso8601(s: object) -> float | None: + if not isinstance(s, str) or not s: + return None + try: + return datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + + +def _window_pct(window: object) -> int: + if not isinstance(window, dict): + return 0 + util = window.get("utilization") + if not isinstance(util, (int, float)): + return 0 + return int(round(float(util))) + + +def _window_reset_mins(window: object, now: float) -> int: + if not isinstance(window, dict): + return -1 + ts = _parse_iso8601(window.get("resets_at")) + if ts is None: + return -1 + mins = (ts - now) / 60.0 + return int(round(mins)) if mins > 0 else 0 + + async def poll_api(token: str) -> dict | None: headers = dict(API_HEADERS_TEMPLATE) headers["Authorization"] = f"Bearer {token}" try: async with httpx.AsyncClient(timeout=20.0) as http: - resp = await http.post(API_URL, headers=headers, json=API_BODY) + resp = await http.get(API_URL, headers=headers) except httpx.HTTPError as e: log(f"API call failed: {e}") return None @@ -170,34 +197,24 @@ async def poll_api(token: str) -> dict | None: log(f"API HTTP {resp.status_code}: {resp.text[:200]}") return None - def hdr(name: str, default: str = "0") -> str: - return resp.headers.get(name, default) + try: + body = resp.json() + except json.JSONDecodeError as e: + log(f"API returned non-JSON: {e}") + return None now = time.time() - - def reset_minutes(reset_ts: str) -> int: - try: - r = float(reset_ts) - except ValueError: - return 0 - mins = (r - now) / 60.0 - return int(round(mins)) if mins > 0 else 0 - - def pct(util: str) -> int: - try: - return int(round(float(util) * 100)) - except ValueError: - return 0 - - payload = { - "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"), + five_hour = body.get("five_hour") + seven_day = body.get("seven_day") + s_pct = _window_pct(five_hour) + return { + "s": s_pct, + "sr": _window_reset_mins(five_hour, now), + "w": _window_pct(seven_day), + "wr": _window_reset_mins(seven_day, now), + "st": "limited" if s_pct >= 100 else "allowed", "ok": True, } - return payload class Session: @@ -250,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() 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); } } diff --git a/install.sh b/install.sh index 2357258d2..0b323a470 100755 --- a/install.sh +++ b/install.sh @@ -11,7 +11,7 @@ echo "" # Check dependencies echo "[1/3] Checking dependencies..." -for cmd in curl awk bluetoothctl busctl; do +for cmd in curl awk python3 bluetoothctl busctl; do command -v "$cmd" >/dev/null || { echo "Error: $cmd is required but not installed"; exit 1; } done echo " All dependencies found"