Merge pull request #6 from HermannBjorgvin/macos-support
Add macOS host support
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Executable
+331
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
Reference in New Issue
Block a user