Replace USB serial/HID with Bluetooth Low Energy
Move all communication from USB to BLE. The device now advertises as "Claude Controller" and acts as both a BLE HID keyboard (for touch gestures) and a GATT data server (for usage updates from the daemon). - Add NimBLE-Arduino BLE module with custom GATT service + HID keyboard - Add third screen (Bluetooth) with connection status, MAC, reset button - Rewrite daemon in bash using bluetoothctl/busctl for BLE GATT writes - Add screenshot capture via LVGL snapshot over serial - Remove TinyUSB mode — normal pio upload works again - Update README with BLE architecture, screenshots, gesture docs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
930055170f
commit
10b8052bcc
@@ -1,5 +1,7 @@
|
||||
[Unit]
|
||||
Description=Claude Usage Tracker Daemon
|
||||
Description=Claude Usage Tracker Daemon (BLE)
|
||||
Requires=bluetooth.target
|
||||
After=bluetooth.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
+144
-46
@@ -1,14 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Claude Usage Tracker Daemon
|
||||
# Reads Claude Code OAuth token, polls usage via API, sends to ESP32 over serial.
|
||||
# Survives USB plug/unplug cycles using inotifywait for instant device detection.
|
||||
# Dependencies: curl, awk, inotify-tools
|
||||
# Claude Usage Tracker Daemon (BLE)
|
||||
# Reads Claude Code OAuth token, polls usage via API, sends to ESP32 over BLE GATT.
|
||||
# Auto-connects and reconnects to the Claude Controller BLE device.
|
||||
# Dependencies: curl, awk, bluetoothctl
|
||||
|
||||
SERIAL_PORT="${SERIAL_PORT:-/dev/ttyACM0}"
|
||||
SERIAL_DEV=$(basename "$SERIAL_PORT")
|
||||
DEVICE_NAME="Claude Controller"
|
||||
DEVICE_MAC="${DEVICE_MAC:-}" # auto-discovered if empty
|
||||
SERVICE_UUID="4c41555a-4465-7669-6365-000000000001"
|
||||
RX_CHAR_UUID="4c41555a-4465-7669-6365-000000000002"
|
||||
POLL_INTERVAL=30
|
||||
BAUD=115200
|
||||
SERIAL_FD=3
|
||||
SAVED_MAC_FILE="$HOME/.config/claude-usage-monitor/ble-address"
|
||||
DBUS_DEST="org.bluez"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%H:%M:%S')] $1"
|
||||
@@ -18,27 +20,112 @@ read_token() {
|
||||
grep -o '"accessToken":"[^"]*"' "$HOME/.claude/.credentials.json" | cut -d'"' -f4
|
||||
}
|
||||
|
||||
device_connected() {
|
||||
[ -e "$SERIAL_PORT" ]
|
||||
# Convert MAC to D-Bus path: AA:BB:CC:DD:EE:FF -> dev_AA_BB_CC_DD_EE_FF
|
||||
mac_to_dbus_path() {
|
||||
local adapter
|
||||
adapter=$(busctl call org.bluez / org.freedesktop.DBus.ObjectManager GetManagedObjects 2>/dev/null | grep -o '/org/bluez/hci[0-9]' | head -1)
|
||||
adapter=${adapter:-/org/bluez/hci0}
|
||||
echo "${adapter}/dev_$(echo "$1" | tr ':' '_')"
|
||||
}
|
||||
|
||||
open_serial() {
|
||||
stty -F "$SERIAL_PORT" "$BAUD" raw -echo -echoe -echok 2>/dev/null || return 1
|
||||
eval "exec ${SERIAL_FD}<>\"$SERIAL_PORT\"" 2>/dev/null || return 1
|
||||
return 0
|
||||
# Check if device is connected via D-Bus
|
||||
is_connected() {
|
||||
local path
|
||||
path=$(mac_to_dbus_path "$DEVICE_MAC")
|
||||
busctl get-property "$DBUS_DEST" "$path" org.bluez.Device1 Connected 2>/dev/null | grep -q "true"
|
||||
}
|
||||
|
||||
close_serial() {
|
||||
eval "exec ${SERIAL_FD}>&-" 2>/dev/null
|
||||
# Load saved MAC address
|
||||
load_mac() {
|
||||
if [ -n "$DEVICE_MAC" ]; then return 0; fi
|
||||
if [ -f "$SAVED_MAC_FILE" ]; then
|
||||
DEVICE_MAC=$(cat "$SAVED_MAC_FILE")
|
||||
[ -n "$DEVICE_MAC" ] && return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait for device to appear using inotifywait (blocks, zero CPU)
|
||||
wait_for_device() {
|
||||
while ! device_connected; do
|
||||
inotifywait -qq -e create /dev --include "$SERIAL_DEV" 2>/dev/null || sleep 1
|
||||
# Save MAC for fast reconnect
|
||||
save_mac() {
|
||||
mkdir -p "$(dirname "$SAVED_MAC_FILE")"
|
||||
echo "$DEVICE_MAC" > "$SAVED_MAC_FILE"
|
||||
}
|
||||
|
||||
# Scan for Claude Controller
|
||||
scan_for_device() {
|
||||
log "Scanning for '$DEVICE_NAME'..."
|
||||
# Start LE scan
|
||||
bluetoothctl scan le &>/dev/null &
|
||||
local scan_pid=$!
|
||||
sleep 8
|
||||
kill "$scan_pid" 2>/dev/null
|
||||
wait "$scan_pid" 2>/dev/null
|
||||
|
||||
# Find the device
|
||||
local found
|
||||
found=$(bluetoothctl devices 2>/dev/null | grep "$DEVICE_NAME" | awk '{print $2}')
|
||||
if [ -n "$found" ]; then
|
||||
DEVICE_MAC="$found"
|
||||
save_mac
|
||||
log "Found: $DEVICE_MAC"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Connect to the device
|
||||
connect_device() {
|
||||
log "Connecting to $DEVICE_MAC..."
|
||||
|
||||
# Trust first (allows auto-reconnect)
|
||||
bluetoothctl trust "$DEVICE_MAC" &>/dev/null
|
||||
|
||||
# Connect
|
||||
bluetoothctl connect "$DEVICE_MAC" &>/dev/null
|
||||
sleep 2
|
||||
|
||||
if is_connected; then
|
||||
log "Connected"
|
||||
return 0
|
||||
fi
|
||||
log "Connection failed"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Find the GATT characteristic handle via D-Bus
|
||||
find_rx_char_path() {
|
||||
local dev_path
|
||||
dev_path=$(mac_to_dbus_path "$DEVICE_MAC")
|
||||
|
||||
# Search for our RX characteristic UUID in the device's GATT tree
|
||||
busctl tree "$DBUS_DEST" 2>/dev/null | grep -o "${dev_path}/service[0-9a-f]*/char[0-9a-f]*" | while read -r char_path; do
|
||||
local uuid
|
||||
uuid=$(busctl get-property "$DBUS_DEST" "$char_path" org.bluez.GattCharacteristic1 UUID 2>/dev/null | tr -d '"' | awk '{print $2}')
|
||||
if [ "$uuid" = "$RX_CHAR_UUID" ]; then
|
||||
echo "$char_path"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Write data to the RX characteristic via D-Bus
|
||||
write_gatt() {
|
||||
local char_path="$1"
|
||||
local data="$2"
|
||||
|
||||
# Convert string to byte array for D-Bus: "hi" -> 0x68 0x69
|
||||
local bytes=""
|
||||
for ((i = 0; i < ${#data}; i++)); do
|
||||
local byte
|
||||
byte=$(printf "0x%02x" "'${data:$i:1}")
|
||||
bytes="$bytes $byte"
|
||||
done
|
||||
local count=${#data}
|
||||
|
||||
busctl call "$DBUS_DEST" "$char_path" org.bluez.GattCharacteristic1 \
|
||||
WriteValue "aya{sv}" "$count" $bytes 0 2>/dev/null
|
||||
}
|
||||
|
||||
poll() {
|
||||
local token
|
||||
token=$(read_token) || { log "Error: could not read token"; return 1; }
|
||||
@@ -69,7 +156,6 @@ poll() {
|
||||
s7d_reset=${s7d_reset:-0}
|
||||
status=${status:-unknown}
|
||||
|
||||
# Build JSON payload in a single awk process (utilization * 100, timestamps to minutes)
|
||||
local payload
|
||||
payload=$(awk -v u5="$s5h_util" -v r5="$s5h_reset" -v u7="$s7d_util" -v r7="$s7d_reset" -v st="$status" -v now="$now" \
|
||||
'BEGIN {
|
||||
@@ -80,49 +166,61 @@ poll() {
|
||||
printf "{\"s\":%s,\"sr\":%s,\"w\":%s,\"wr\":%s,\"st\":\"%s\",\"ok\":true}", sp, sr, wp, wr, st;
|
||||
}')
|
||||
|
||||
if ! echo "$payload" >&${SERIAL_FD} 2>/dev/null; then
|
||||
log "Write failed - device disconnected"
|
||||
return 1
|
||||
fi
|
||||
log "Sending: $payload"
|
||||
write_gatt "$RX_CHAR_PATH" "$payload" || { log "Write failed"; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
close_serial
|
||||
log "Daemon stopped"
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup INT TERM
|
||||
|
||||
log "=== Claude Usage Tracker Daemon ==="
|
||||
log "Serial port: $SERIAL_PORT"
|
||||
log "=== Claude Usage Tracker Daemon (BLE) ==="
|
||||
log "Poll interval: ${POLL_INTERVAL}s"
|
||||
|
||||
BACKOFF=1
|
||||
|
||||
while true; do
|
||||
# Wait for device (instant detection via inotify, or already plugged in)
|
||||
if ! device_connected; then
|
||||
log "Waiting for device..."
|
||||
wait_for_device
|
||||
# Find the device
|
||||
if ! load_mac; then
|
||||
scan_for_device || {
|
||||
log "Device not found, retrying in ${BACKOFF}s..."
|
||||
sleep "$BACKOFF"
|
||||
BACKOFF=$((BACKOFF < 60 ? BACKOFF * 2 : 60))
|
||||
continue
|
||||
}
|
||||
fi
|
||||
|
||||
log "Device connected"
|
||||
sleep 2 # wait for ESP32 boot
|
||||
# Connect if not connected
|
||||
if ! is_connected; then
|
||||
connect_device || {
|
||||
log "Retrying in ${BACKOFF}s..."
|
||||
sleep "$BACKOFF"
|
||||
BACKOFF=$((BACKOFF < 60 ? BACKOFF * 2 : 60))
|
||||
continue
|
||||
}
|
||||
fi
|
||||
|
||||
if ! open_serial; then
|
||||
log "Failed to open serial port"
|
||||
sleep 2
|
||||
# Find the GATT characteristic
|
||||
RX_CHAR_PATH=$(find_rx_char_path)
|
||||
if [ -z "$RX_CHAR_PATH" ]; then
|
||||
log "Error: RX characteristic not found, retrying..."
|
||||
sleep 5
|
||||
continue
|
||||
fi
|
||||
log "Serial port opened"
|
||||
log "GATT RX path: $RX_CHAR_PATH"
|
||||
|
||||
# Poll loop - polls API every POLL_INTERVAL, exits on disconnect or write failure
|
||||
while device_connected; do
|
||||
poll || break
|
||||
# Sleep POLL_INTERVAL but wake instantly if device disappears
|
||||
inotifywait -qq -e delete /dev --include "$SERIAL_DEV" -t "$POLL_INTERVAL" 2>/dev/null
|
||||
BACKOFF=1 # reset backoff on successful connection
|
||||
|
||||
# Poll loop
|
||||
while is_connected; do
|
||||
poll || log "Poll failed, will retry"
|
||||
sleep "$POLL_INTERVAL"
|
||||
done
|
||||
|
||||
# Disconnected - clean up and loop back to wait
|
||||
log "Device disconnected"
|
||||
close_serial
|
||||
log "Device disconnected, reconnecting..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
Reference in New Issue
Block a user