From 525ebe89211cb9c226af063270f134cce0c45c69 Mon Sep 17 00:00:00 2001 From: Brian Pugh Date: Thu, 21 May 2026 20:55:08 -0400 Subject: [PATCH] switch daemon to /api/oauth/usage endpoint --- README.md | 4 +- daemon/claude-usage-daemon.sh | 78 ++++++++++++++++++------------- daemon/claude_usage_daemon.py | 86 ++++++++++++++++++++--------------- install.sh | 2 +- 4 files changed, 100 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 4b955e1..f560a4f 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,8 @@ View logs: `journalctl --user -u claude-usage-daemon -f` ## How it works 1. The daemon reads your Claude Code OAuth token — from the macOS Keychain (service `Claude Code-credentials`) on macOS, or from `~/.claude/.credentials.json` on Linux. -2. It makes a minimal API call to `api.anthropic.com/v1/messages` — one token of Haiku, basically free. -3. The usage numbers come straight out of the response headers (`anthropic-ratelimit-unified-5h-utilization` and friends). +2. It GETs `api.anthropic.com/api/oauth/usage` (the same endpoint `claude /usage` uses) — costs zero tokens. +3. The response is JSON with `five_hour`, `seven_day`, `seven_day_sonnet`, `seven_day_opus`, `extra_usage` blocks; each window carries a `utilization` (0..1) and `resets_at` (ISO8601). 4. The daemon connects to the ESP32 over BLE and writes a JSON payload to the GATT RX characteristic. 5. The firmware parses it and updates the LVGL dashboard. 6. The firmware also tracks the rate of change of session % over a 5-minute window and picks splash animations from the matching mood group. diff --git a/daemon/claude-usage-daemon.sh b/daemon/claude-usage-daemon.sh index 0663904..a9f1110 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 @@ -194,42 +196,56 @@ write_gatt() { 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" \ + local body + body=$(curl -s \ + "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} - 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"; return 1; } log "Sending: $payload" write_gatt "$RX_CHAR_PATH" "$payload" || { log "Write failed"; return 1; } diff --git a/daemon/claude_usage_daemon.py b/daemon/claude_usage_daemon.py index 8484ba5..a660dbd 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 signal import subprocess import sys import time +from datetime import datetime from pathlib import Path import httpx @@ -36,18 +38,12 @@ KEYCHAIN_SERVICE = "Claude Code-credentials" 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 +153,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 +194,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: diff --git a/install.sh b/install.sh index 2357258..0b323a4 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"