From 512039e7400eab22b19962498b54082fc869afc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hermann=20Bj=C3=B6rgvin=20Haraldsson?= Date: Thu, 14 May 2026 18:39:13 +0000 Subject: [PATCH] Add macOS host support Cherry-picks the macOS-specific pieces from PR #5: Python daemon using bleak (CoreBluetooth backend), LaunchAgent template, flash-mac.sh, and install-mac.sh. Token is read from the macOS Keychain ("Claude Code-credentials" service); Linux behavior is unchanged. README split into "macOS installation" and "Linux installation" sections with parallel Flash / Pair / Install subsections. Co-Authored-By: Chris Davidson <36679917+lorddavidson@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 --- .gitignore | 7 + README.md | 47 ++- daemon/claude_usage_daemon.py | 331 ++++++++++++++++++++++ daemon/com.user.claude-usage-daemon.plist | 43 +++ flash-mac.sh | 34 +++ install-mac.sh | 85 ++++++ 6 files changed, 539 insertions(+), 8 deletions(-) create mode 100755 daemon/claude_usage_daemon.py create mode 100644 daemon/com.user.claude-usage-daemon.plist create mode 100755 flash-mac.sh create mode 100755 install-mac.sh diff --git a/.gitignore b/.gitignore index 2ab1402..4b16631 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,10 @@ firmware/.vscode/ # Node tooling deps (npm install --no-save) tools/node_modules/ tools/package-lock.json + +# Mac daemon venv +daemon/.venv/ + +# Python bytecode cache +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 5d89ff2..7139c50 100644 --- a/README.md +++ b/README.md @@ -31,25 +31,56 @@ While the splash is up, the middle button cycles animations instead of screens. ## Prerequisites -- Linux (tested on Ubuntu) +- Linux (tested on Ubuntu) or macOS - [PlatformIO CLI](https://docs.platformio.org/en/latest/core/installation/index.html) -- `curl`, `bluetoothctl`, `busctl` (BlueZ Bluetooth stack) +- Linux: `curl`, `bluetoothctl`, `busctl` (BlueZ Bluetooth stack) +- macOS: `python3` (the installer sets up a venv with `bleak` and `httpx`) - Claude Code with an active subscription -## MacOS support +## macOS installation -MacOS is fully supported, that is as soon as you prompt it and create a pull request for it! +The macOS host pieces — Python daemon, LaunchAgent, and flash helper — were ported by [Chris Davidson (@lorddavidson)](https://github.com/lorddavidson). Thanks Chris! -I run Linux myself so it's harder for me to test this but anyone who wants MacOS support is welcome to contribute. +### Flash the firmware -## Flash the firmware +```bash +./flash-mac.sh # auto-detects /dev/cu.usbmodem* +./flash-mac.sh /dev/cu.usbmodem1101 # or pass an explicit USB serial port +``` + +### Pair the device + +After flashing, open **System Settings → Bluetooth** and click *Connect* next to "Claude Controller". The daemon will discover it on its next scan (~30 s). + +### Install the daemon + +The daemon reads your Claude OAuth token from the macOS Keychain (service `Claude Code-credentials`), polls usage every 60 s, and pushes it to the display over BLE. + +```bash +./install-mac.sh +``` + +The installer creates a Python venv in `daemon/.venv/`, installs `bleak` and `httpx`, renders a LaunchAgent into `~/Library/LaunchAgents/com.user.claude-usage-daemon.plist`, and loads it. The first run is launched interactively so macOS prompts for Bluetooth permission. + +Useful commands: + +```bash +launchctl list | grep claude-usage # check it's running +tail -F ~/Library/Logs/claude-usage-daemon.out.log # live logs +launchctl unload ~/Library/LaunchAgents/com.user.claude-usage-daemon.plist # stop +launchctl load -w ~/Library/LaunchAgents/com.user.claude-usage-daemon.plist # start +``` + +## Linux installation + +### Flash the firmware ```bash cd firmware pio run -t upload --upload-port /dev/ttyACM0 ``` -## Bluetooth pairing +### Pair the device After flashing, the device advertises as "Claude Controller". Pair it once: @@ -64,7 +95,7 @@ bluetoothctl trust F4:12:FA:C0:8F:E5 The MAC address is shown on the Bluetooth screen — press the middle (PWR) button to cycle to it. -## Install the daemon +### Install the daemon The daemon polls your Claude usage every 60 seconds and sends it to the display over BLE. diff --git a/daemon/claude_usage_daemon.py b/daemon/claude_usage_daemon.py new file mode 100755 index 0000000..4451e8a --- /dev/null +++ b/daemon/claude_usage_daemon.py @@ -0,0 +1,331 @@ +#!/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). +""" + +import asyncio +import getpass +import json +import os +import re +import signal +import subprocess +import sys +import time +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/v1/messages" +API_HEADERS_TEMPLATE = { + "anthropic-version": "2023-06-01", + "anthropic-beta": "oauth-2025-04-20", + "Content-Type": "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: + 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 + + +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) + except httpx.HTTPError as e: + log(f"API call failed: {e}") + return None + + def hdr(name: str, default: str = "0") -> str: + return resp.headers.get(name, default) + + 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"), + "ok": True, + } + return payload + + +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) diff --git a/daemon/com.user.claude-usage-daemon.plist b/daemon/com.user.claude-usage-daemon.plist new file mode 100644 index 0000000..1cdbe1c --- /dev/null +++ b/daemon/com.user.claude-usage-daemon.plist @@ -0,0 +1,43 @@ + + + + + Label + com.user.claude-usage-daemon + + ProgramArguments + + __PYTHON_BIN__ + __DAEMON_PATH__ + + + WorkingDirectory + __REPO_DIR__ + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + ThrottleInterval + 10 + + StandardOutPath + __LOG_OUT__ + + StandardErrorPath + __LOG_ERR__ + + EnvironmentVariables + + HOME + __HOME__ + PATH + /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin + + + diff --git a/flash-mac.sh b/flash-mac.sh new file mode 100755 index 0000000..1a3540d --- /dev/null +++ b/flash-mac.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Build and flash Clawdmeter firmware on macOS. +# Usage: +# ./flash-mac.sh # auto-detect /dev/cu.usbmodem* +# ./flash-mac.sh /dev/cu.usbmodem1101 # explicit USB serial port +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PORT="$1" + +if [ -z "$PORT" ]; then + PORT=$(ls /dev/cu.usbmodem* 2>/dev/null | head -1) + if [ -z "$PORT" ]; then + echo "Error: no /dev/cu.usbmodem* device found. Plug in via USB-C." + exit 1 + fi +fi + +if ! command -v pio >/dev/null; then + echo "Error: 'pio' not found. Install with:" + echo " brew install platformio" + exit 1 +fi + +echo "=== Flashing Clawdmeter ===" +echo "Port: $PORT" +echo "" + +cd "$SCRIPT_DIR/firmware" +pio run -t upload --upload-port "$PORT" + +echo "" +echo "=== Done ===" +echo "Monitor with: pio device monitor -p $PORT -b 115200" diff --git a/install-mac.sh b/install-mac.sh new file mode 100755 index 0000000..c594252 --- /dev/null +++ b/install-mac.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# macOS installer for Clawdmeter daemon (Python + bleak + launchd). +# Mirrors install.sh but uses LaunchAgents instead of systemd user units. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SERVICE_LABEL="com.user.claude-usage-daemon" +PLIST_SRC="$SCRIPT_DIR/daemon/$SERVICE_LABEL.plist" +PLIST_DST="$HOME/Library/LaunchAgents/$SERVICE_LABEL.plist" +VENV_DIR="$SCRIPT_DIR/daemon/.venv" +DAEMON_PY="$SCRIPT_DIR/daemon/claude_usage_daemon.py" +LOG_DIR="$HOME/Library/Logs" +LOG_OUT="$LOG_DIR/claude-usage-daemon.out.log" +LOG_ERR="$LOG_DIR/claude-usage-daemon.err.log" + +echo "=== Clawdmeter macOS install ===" +echo "" + +echo "[1/5] Checking prerequisites..." +for cmd in python3 curl; do + command -v "$cmd" >/dev/null || { echo "Error: $cmd is required"; exit 1; } +done +if [ ! -f "$HOME/.claude/.credentials.json" ]; then + echo "Warning: ~/.claude/.credentials.json not found." + echo " Sign in via Claude Code first, then re-run this installer." + echo " Continuing anyway — the daemon will retry on each poll." +fi +echo " OK" +echo "" + +echo "[2/5] Creating Python virtualenv at daemon/.venv ..." +if [ ! -d "$VENV_DIR" ]; then + python3 -m venv "$VENV_DIR" +fi +"$VENV_DIR/bin/pip" install --quiet --upgrade pip +"$VENV_DIR/bin/pip" install --quiet "bleak>=0.22" "httpx>=0.27" +PYTHON_BIN="$VENV_DIR/bin/python" +echo " OK ($PYTHON_BIN)" +echo "" + +echo "[3/5] Rendering launchd plist..." +mkdir -p "$HOME/Library/LaunchAgents" "$LOG_DIR" +sed \ + -e "s|__PYTHON_BIN__|${PYTHON_BIN}|g" \ + -e "s|__DAEMON_PATH__|${DAEMON_PY}|g" \ + -e "s|__REPO_DIR__|${SCRIPT_DIR}|g" \ + -e "s|__LOG_OUT__|${LOG_OUT}|g" \ + -e "s|__LOG_ERR__|${LOG_ERR}|g" \ + -e "s|__HOME__|${HOME}|g" \ + "$PLIST_SRC" > "$PLIST_DST" +echo " Installed: $PLIST_DST" +echo "" + +echo "[4/5] Bluetooth permission check..." +echo " On first run the daemon will trigger a Bluetooth permission prompt." +echo " macOS only prompts for foreground processes — so we'll run it" +echo " interactively once below. Press Ctrl+C after you see 'Scanning...'" +echo " and grant permission when prompted. Then re-run this installer" +echo " (or just continue) to enable launchd autostart." +echo "" +read -r -p "Run a permission-priming scan now? [Y/n] " ans +if [[ ! "$ans" =~ ^[Nn]$ ]]; then + "$PYTHON_BIN" "$DAEMON_PY" || true +fi +echo "" + +echo "[5/5] Loading launchd service..." +launchctl unload "$PLIST_DST" 2>/dev/null || true +launchctl load -w "$PLIST_DST" +echo " Loaded." +echo "" + +echo "=== Done ===" +echo "" +echo "First-time Bluetooth pairing (after firmware is flashed):" +echo " 1. Power on the device." +echo " 2. Open System Settings → Bluetooth." +echo " 3. Click 'Connect' next to 'Claude Controller'." +echo " 4. The daemon will discover it within ~30 s and start polling." +echo "" +echo "Useful commands:" +echo " launchctl list | grep claude-usage # check it's running" +echo " tail -F $LOG_OUT # live logs" +echo " launchctl unload $PLIST_DST # stop" +echo " launchctl load -w $PLIST_DST # start"