#!/usr/bin/env python3 """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` 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 import getpass import json import os import re import signal import subprocess import sys import time from datetime import datetime from pathlib import Path import httpx from bleak import BleakClient, BleakScanner from bleak.exc import BleakError DEVICE_NAME = "Claude Controller" SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001" RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002" REQ_CHAR_UUID = "4c41555a-4465-7669-6365-000000000004" POLL_INTERVAL = 60 TICK = 5 SCAN_TIMEOUT = 8.0 # macOS: token lives in Keychain (service "Claude Code-credentials"). # Linux: token lives in ~/.claude/.credentials.json. 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/api/oauth/usage" API_HEADERS_TEMPLATE = { "anthropic-beta": "oauth-2025-04-20", "Accept": "application/json", "User-Agent": "claude-code/2.1.5", } def log(msg: str) -> None: print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) def _extract_access_token(blob: str) -> str | None: """Pull the accessToken out of a credentials blob. Claude Code stores credentials as a JSON object; the blob may also be nested ({"claudeAiOauth": {"accessToken": "..."}}). Fall back to a regex match so unexpected shapes still work, and finally treat the blob as a raw token if nothing else matches. """ blob = blob.strip() if not blob: return None try: data = json.loads(blob) except json.JSONDecodeError: data = None if isinstance(data, dict): # direct: {"accessToken": "..."} if isinstance(data.get("accessToken"), str): return data["accessToken"] # nested: {"claudeAiOauth": {"accessToken": "..."}} for v in data.values(): if isinstance(v, dict) and isinstance(v.get("accessToken"), str): return v["accessToken"] m = re.search(r'"accessToken"\s*:\s*"([^"]+)"', blob) if m: return m.group(1) # Raw token (no JSON wrapper) — must look plausible (sk-ant-... etc.) if re.fullmatch(r"[A-Za-z0-9_\-.~+/=]{20,}", blob): return blob return None def _read_token_keychain() -> str | None: try: out = subprocess.run( [ "security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", getpass.getuser(), "-w", ], check=True, capture_output=True, text=True, timeout=10, ) except subprocess.CalledProcessError as e: log(f"Keychain read failed (rc={e.returncode}): {e.stderr.strip()}") return None except (FileNotFoundError, subprocess.TimeoutExpired) as e: log(f"Keychain access error: {e}") return None return _extract_access_token(out.stdout) def _read_token_file() -> str | None: try: raw = CREDENTIALS_PATH.read_text() except OSError as e: log(f"Error reading credentials: {e}") return None return _extract_access_token(raw) def read_token() -> str | None: if sys.platform == "darwin": return _read_token_keychain() return _read_token_file() def load_cached_address() -> str | None: if not SAVED_ADDR_FILE.exists(): return None addr = SAVED_ADDR_FILE.read_text().strip() # Accept both Linux MAC (AA:BB:CC:DD:EE:FF) and macOS CoreBluetooth UUID # (E621E1F8-C36C-495A-93FC-0C247A3E6E5F). if re.fullmatch(r"(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}", addr) or re.fullmatch( r"[0-9A-Fa-f]{8}-(?:[0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}", addr ): return addr log("Cached address malformed, discarding") SAVED_ADDR_FILE.unlink(missing_ok=True) return None def save_address(addr: str) -> None: SAVED_ADDR_FILE.parent.mkdir(parents=True, exist_ok=True) SAVED_ADDR_FILE.write_text(addr) async def scan_for_device() -> str | None: log(f"Scanning for '{DEVICE_NAME}' ({SCAN_TIMEOUT}s)...") devices = await BleakScanner.discover(timeout=SCAN_TIMEOUT) for d in devices: if d.name == DEVICE_NAME: log(f"Found: {d.address}") return d.address 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.get(API_URL, headers=headers) except httpx.HTTPError as e: log(f"API call failed: {e}") return None if resp.status_code >= 400: log(f"API HTTP {resp.status_code}: {resp.text[:200]}") return None try: body = resp.json() except json.JSONDecodeError as e: log(f"API returned non-JSON: {e}") return None now = time.time() 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, } class Session: def __init__(self, client: BleakClient) -> None: self.client = client self.refresh_requested = asyncio.Event() def _on_refresh(self, _char, _data: bytearray) -> None: log("Refresh requested by device") self.refresh_requested.set() async def setup_refresh_subscription(self) -> None: try: await self.client.start_notify(REQ_CHAR_UUID, self._on_refresh) except (BleakError, ValueError) as e: log(f"Refresh subscription unavailable: {e}") async def write_payload(self, payload: dict) -> bool: data = json.dumps(payload, separators=(",", ":")).encode() log(f"Sending: {data.decode()}") try: await self.client.write_gatt_char(RX_CHAR_UUID, data, response=False) return True except BleakError as e: log(f"Write failed: {e}") return False async def connect_and_run(address: str, stop_event: asyncio.Event) -> bool: """Connect to a known address and poll until disconnected or stopped. Returns True if the connection was used successfully (so the caller keeps the cached address), False if the connection failed and the cache should be invalidated. """ log(f"Connecting to {address}...") client = BleakClient(address) try: await client.connect() except (BleakError, asyncio.TimeoutError) as e: log(f"Connection failed: {e}") return False if not client.is_connected: log("Connection failed (no error but not connected)") return False log("Connected") session = Session(client) await session.setup_refresh_subscription() last_poll = 0.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: session.refresh_requested.clear() token = read_token() if not token: log("No token; skipping poll") else: payload = await poll_api(token) if payload is not None: if await session.write_payload(payload): last_poll = time.time() used_successfully = True try: await asyncio.wait_for(session.refresh_requested.wait(), timeout=TICK) except asyncio.TimeoutError: pass finally: try: await client.disconnect() except BleakError: pass log("Device disconnected" if not stop_event.is_set() else "Stopping") return used_successfully async def main() -> None: stop_event = asyncio.Event() loop = asyncio.get_running_loop() def _stop(*_args: object) -> None: log("Daemon stopping") stop_event.set() for sig in (signal.SIGINT, signal.SIGTERM): try: loop.add_signal_handler(sig, _stop) except NotImplementedError: signal.signal(sig, _stop) log("=== Claude Usage Tracker Daemon (BLE, macOS) ===") log(f"Poll interval: {POLL_INTERVAL}s") backoff = 1 while not stop_event.is_set(): address = load_cached_address() if not address: address = await scan_for_device() if address: save_address(address) else: log(f"Device not found, retrying in {backoff}s...") try: await asyncio.wait_for(stop_event.wait(), timeout=backoff) except asyncio.TimeoutError: pass backoff = min(backoff * 2, 60) continue ok = await connect_and_run(address, stop_event) if not ok: log("Invalidating cached address") SAVED_ADDR_FILE.unlink(missing_ok=True) try: await asyncio.wait_for(stop_event.wait(), timeout=backoff) except asyncio.TimeoutError: pass backoff = min(backoff * 2, 60) else: backoff = 1 if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: sys.exit(0)