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 <noreply@anthropic.com>
This commit is contained in:
Hermann Björgvin Haraldsson
2026-05-14 18:39:13 +00:00
co-authored by Chris Davidson Claude Opus 4.7
parent 9aa6954d42
commit 512039e740
6 changed files with 539 additions and 8 deletions
+7
View File
@@ -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
+39 -8
View File
@@ -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.
+331
View File
@@ -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)
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.user.claude-usage-daemon</string>
<key>ProgramArguments</key>
<array>
<string>__PYTHON_BIN__</string>
<string>__DAEMON_PATH__</string>
</array>
<key>WorkingDirectory</key>
<string>__REPO_DIR__</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>__LOG_OUT__</string>
<key>StandardErrorPath</key>
<string>__LOG_ERR__</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>__HOME__</string>
<key>PATH</key>
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
Executable
+34
View File
@@ -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"
Executable
+85
View File
@@ -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"