Merge pull request #37 from HermannBjorgvin/revert-29-oauth-usage-endpoint

Revert "switch daemon to `/api/oauth/usage` endpoint"
This commit is contained in:
Hermann Björgvin
2026-05-24 20:04:18 +00:00
committed by GitHub
4 changed files with 70 additions and 100 deletions
+2 -2
View File
@@ -123,8 +123,8 @@ View logs: `journalctl --user -u claude-usage-daemon -f`
## How it works ## 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. 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 GETs `api.anthropic.com/api/oauth/usage` (the same endpoint `claude /usage` uses) — costs zero tokens. 2. It makes a minimal API call to `api.anthropic.com/v1/messages` — one token of Haiku, basically free.
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). 3. The usage numbers come straight out of the response headers (`anthropic-ratelimit-unified-5h-utilization` and friends).
4. The daemon connects to the ESP32 over BLE and writes a JSON payload to the GATT RX characteristic. 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. 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. 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.
+31 -47
View File
@@ -1,10 +1,8 @@
#!/bin/bash #!/bin/bash
# Claude Usage Tracker Daemon (BLE) # Claude Usage Tracker Daemon (BLE)
# Reads Claude Code OAuth token, polls usage via the OAuth usage endpoint # Reads Claude Code OAuth token, polls usage via API, sends to ESP32 over BLE GATT.
# (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. # Auto-connects and reconnects to the Claude Controller BLE device.
# Dependencies: curl, awk, python3, bluetoothctl # Dependencies: curl, awk, bluetoothctl
DEVICE_NAME="Claude Controller" DEVICE_NAME="Claude Controller"
DEVICE_MAC="${DEVICE_MAC:-}" # auto-discovered if empty DEVICE_MAC="${DEVICE_MAC:-}" # auto-discovered if empty
@@ -196,56 +194,42 @@ write_gatt() {
poll() { poll() {
local token local token
token=$(read_token) || { log "Error: could not read token"; return 1; } token=$(read_token) || { log "Error: could not read token"; return 1; }
local now
now=$(date +%s)
local body local headers
body=$(curl -s \ headers=$(curl -s -D - -o /dev/null \
"https://api.anthropic.com/api/oauth/usage" \ "https://api.anthropic.com/v1/messages" \
-H "Authorization: Bearer $token" \ -H "Authorization: Bearer $token" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: oauth-2025-04-20" \ -H "anthropic-beta: oauth-2025-04-20" \
-H "Accept: application/json" \ -H "Content-Type: application/json" \
-H "User-Agent: claude-code/2.1.5" \ -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; } 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 local payload
payload=$(python3 -c ' payload=$(awk -v u5="$s5h_util" -v r5="$s5h_reset" -v u7="$s7d_util" -v r7="$s7d_reset" -v st="$status" -v now="$now" \
import datetime, json, sys, time 'BEGIN {
sp = sprintf("%.0f", u5 * 100);
try: sr = (r5 - now) / 60; sr = sr > 0 ? sprintf("%.0f", sr) : 0;
data = json.loads(sys.stdin.read()) wp = sprintf("%.0f", u7 * 100);
except json.JSONDecodeError: wr = (r7 - now) / 60; wr = wr > 0 ? sprintf("%.0f", wr) : 0;
sys.exit(1) printf "{\"s\":%s,\"sr\":%s,\"w\":%s,\"wr\":%s,\"st\":\"%s\",\"ok\":true}", sp, sr, wp, wr, st;
}')
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" log "Sending: $payload"
write_gatt "$RX_CHAR_PATH" "$payload" || { log "Write failed"; return 1; } write_gatt "$RX_CHAR_PATH" "$payload" || { log "Write failed"; return 1; }
+36 -50
View File
@@ -1,10 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Claude Usage Tracker Daemon (BLE) — macOS port of claude-usage-daemon.sh. """Claude Usage Tracker Daemon (BLE) — macOS port of claude-usage-daemon.sh.
Polls the Claude OAuth usage endpoint (the same one Claude Code's `/usage` Polls Claude API rate-limit headers and writes a JSON payload to the
command uses) and writes a JSON payload to the ESP32 "Claude Controller" ESP32 "Claude Controller" peripheral over a custom GATT service. Uses
peripheral over a custom GATT service. Uses bleak (CoreBluetooth backend bleak (CoreBluetooth backend on macOS).
on macOS).
""" """
import asyncio import asyncio
@@ -16,7 +15,6 @@ import signal
import subprocess import subprocess
import sys import sys
import time import time
from datetime import datetime
from pathlib import Path from pathlib import Path
import httpx import httpx
@@ -38,12 +36,18 @@ KEYCHAIN_SERVICE = "Claude Code-credentials"
CREDENTIALS_PATH = Path.home() / ".claude" / ".credentials.json" CREDENTIALS_PATH = Path.home() / ".claude" / ".credentials.json"
SAVED_ADDR_FILE = Path.home() / ".config" / "claude-usage-monitor" / "ble-address" SAVED_ADDR_FILE = Path.home() / ".config" / "claude-usage-monitor" / "ble-address"
API_URL = "https://api.anthropic.com/api/oauth/usage" API_URL = "https://api.anthropic.com/v1/messages"
API_HEADERS_TEMPLATE = { API_HEADERS_TEMPLATE = {
"anthropic-version": "2023-06-01",
"anthropic-beta": "oauth-2025-04-20", "anthropic-beta": "oauth-2025-04-20",
"Accept": "application/json", "Content-Type": "application/json",
"User-Agent": "claude-code/2.1.5", "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: def log(msg: str) -> None:
@@ -153,40 +157,12 @@ async def scan_for_device() -> str | None:
return 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: async def poll_api(token: str) -> dict | None:
headers = dict(API_HEADERS_TEMPLATE) headers = dict(API_HEADERS_TEMPLATE)
headers["Authorization"] = f"Bearer {token}" headers["Authorization"] = f"Bearer {token}"
try: try:
async with httpx.AsyncClient(timeout=20.0) as http: async with httpx.AsyncClient(timeout=20.0) as http:
resp = await http.get(API_URL, headers=headers) resp = await http.post(API_URL, headers=headers, json=API_BODY)
except httpx.HTTPError as e: except httpx.HTTPError as e:
log(f"API call failed: {e}") log(f"API call failed: {e}")
return None return None
@@ -194,24 +170,34 @@ async def poll_api(token: str) -> dict | None:
log(f"API HTTP {resp.status_code}: {resp.text[:200]}") log(f"API HTTP {resp.status_code}: {resp.text[:200]}")
return None return None
try: def hdr(name: str, default: str = "0") -> str:
body = resp.json() return resp.headers.get(name, default)
except json.JSONDecodeError as e:
log(f"API returned non-JSON: {e}")
return None
now = time.time() now = time.time()
five_hour = body.get("five_hour")
seven_day = body.get("seven_day") def reset_minutes(reset_ts: str) -> int:
s_pct = _window_pct(five_hour) try:
return { r = float(reset_ts)
"s": s_pct, except ValueError:
"sr": _window_reset_mins(five_hour, now), return 0
"w": _window_pct(seven_day), mins = (r - now) / 60.0
"wr": _window_reset_mins(seven_day, now), return int(round(mins)) if mins > 0 else 0
"st": "limited" if s_pct >= 100 else "allowed",
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"),
"ok": True, "ok": True,
} }
return payload
class Session: class Session:
+1 -1
View File
@@ -11,7 +11,7 @@ echo ""
# Check dependencies # Check dependencies
echo "[1/3] Checking dependencies..." echo "[1/3] Checking dependencies..."
for cmd in curl awk python3 bluetoothctl busctl; do for cmd in curl awk bluetoothctl busctl; do
command -v "$cmd" >/dev/null || { echo "Error: $cmd is required but not installed"; exit 1; } command -v "$cmd" >/dev/null || { echo "Error: $cmd is required but not installed"; exit 1; }
done done
echo " All dependencies found" echo " All dependencies found"