Make BLE daemon resilient to device swaps and add device-initiated refresh
- Cache invalidation: drop ~/.config/claude-usage-monitor/ble-address and remove the dead MAC from bluez on connect failure, so swapping ESP boards no longer pins the daemon to a stale factory MAC - Scan robustness: pick first matching candidate (head -1), sanity-check cached content for valid MAC format, handle multiple Claude Controllers in bluez - 5s tick + 60s poll: inner loop wakes every 5s for fast disconnect detection while keeping Anthropic API cadence at one minute - Refresh-request channel: new GATT characteristic ...0004 NOTIFY, firmware fires once in onSubscribe when has_received_data is false; daemon subscribes via setsid'd dbus-monitor pipeline (process group cleanup avoids the bash-wait-on-job hang we hit on disconnect) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
97a5443601
commit
38d8894016
@@ -84,4 +84,14 @@ See `~/.claude/projects/.../memory/` files for persistent context (user is an em
|
|||||||
|
|
||||||
## Daemon / host side
|
## Daemon / host side
|
||||||
|
|
||||||
Unchanged from original project. Python daemon (`daemon/`) reads OAuth token, polls Anthropic API, sends JSON over BLE GATT. Run with `systemctl --user start claude-usage-daemon`.
|
Bash daemon (`daemon/claude-usage-daemon.sh`) reads OAuth token, polls Anthropic API, sends JSON over BLE GATT. Run with `systemctl --user start claude-usage-daemon`. The unit file's `ExecStart` is the absolute path to the script — repoint it when switching between the worktree and the main checkout.
|
||||||
|
|
||||||
|
**Discovery & resilience:**
|
||||||
|
- Connects by name (`"Claude Controller"`) on first run, caches resolved MAC at `~/.config/claude-usage-monitor/ble-address`. ESP32 BLE addresses are factory-burned per-chip, so swapping any board invalidates the cache.
|
||||||
|
- On connect failure: cache is dropped AND device is removed from bluez (`bluetoothctl remove`) so the next scan won't re-pick a dead MAC. Multi-candidate scans pick `head -1` and let the failure cycle converge.
|
||||||
|
- `POLL_INTERVAL=60`, `TICK=5`. Inner loop wakes every 5s to detect disconnects fast; polls Anthropic when 60s elapsed OR when ESP fires a refresh request.
|
||||||
|
|
||||||
|
**GATT characteristics on service `4c41555a-...0001`:**
|
||||||
|
- `...0002` RX — daemon writes JSON usage payload here.
|
||||||
|
- `...0003` TX — firmware notifies ack/nack (daemon doesn't subscribe).
|
||||||
|
- `...0004` REQ — firmware fires `0x01` notify in `onSubscribe` if `has_received_data` is false. Daemon subscribes via `setsid bash -c "stdbuf -oL dbus-monitor … | awk …"`; awk drops a flag file the inner loop picks up. See the `feedback_dbus_monitor_pipe` memory for the three subtle gotchas (pipe buffering, busctl-exits race, `wait` blocking on pipeline jobs).
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ DEVICE_NAME="Claude Controller"
|
|||||||
DEVICE_MAC="${DEVICE_MAC:-}" # auto-discovered if empty
|
DEVICE_MAC="${DEVICE_MAC:-}" # auto-discovered if empty
|
||||||
SERVICE_UUID="4c41555a-4465-7669-6365-000000000001"
|
SERVICE_UUID="4c41555a-4465-7669-6365-000000000001"
|
||||||
RX_CHAR_UUID="4c41555a-4465-7669-6365-000000000002"
|
RX_CHAR_UUID="4c41555a-4465-7669-6365-000000000002"
|
||||||
POLL_INTERVAL=30
|
REQ_CHAR_UUID="4c41555a-4465-7669-6365-000000000004"
|
||||||
|
POLL_INTERVAL=60
|
||||||
|
TICK=5
|
||||||
SAVED_MAC_FILE="$HOME/.config/claude-usage-monitor/ble-address"
|
SAVED_MAC_FILE="$HOME/.config/claude-usage-monitor/ble-address"
|
||||||
|
REFRESH_FLAG="/tmp/claude-usage-refresh-$$"
|
||||||
DBUS_DEST="org.bluez"
|
DBUS_DEST="org.bluez"
|
||||||
|
NOTIFY_PID=""
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
echo "[$(date '+%H:%M:%S')] $1"
|
echo "[$(date '+%H:%M:%S')] $1"
|
||||||
@@ -39,8 +43,13 @@ is_connected() {
|
|||||||
load_mac() {
|
load_mac() {
|
||||||
if [ -n "$DEVICE_MAC" ]; then return 0; fi
|
if [ -n "$DEVICE_MAC" ]; then return 0; fi
|
||||||
if [ -f "$SAVED_MAC_FILE" ]; then
|
if [ -f "$SAVED_MAC_FILE" ]; then
|
||||||
DEVICE_MAC=$(cat "$SAVED_MAC_FILE")
|
DEVICE_MAC=$(head -1 "$SAVED_MAC_FILE" | tr -d '\r\n ')
|
||||||
[ -n "$DEVICE_MAC" ] && return 0
|
if [[ "$DEVICE_MAC" =~ ^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$ ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log "Cached MAC is malformed, discarding"
|
||||||
|
rm -f "$SAVED_MAC_FILE"
|
||||||
|
DEVICE_MAC=""
|
||||||
fi
|
fi
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -61,9 +70,12 @@ scan_for_device() {
|
|||||||
kill "$scan_pid" 2>/dev/null
|
kill "$scan_pid" 2>/dev/null
|
||||||
wait "$scan_pid" 2>/dev/null
|
wait "$scan_pid" 2>/dev/null
|
||||||
|
|
||||||
# Find the device
|
# Pick the first matching device. Multiple matches happen when bluez
|
||||||
|
# remembers old hardware (e.g. after swapping ESP boards). Stale entries
|
||||||
|
# are removed on connect failure (see connect_device), so a few retry
|
||||||
|
# cycles will converge on the live device.
|
||||||
local found
|
local found
|
||||||
found=$(bluetoothctl devices 2>/dev/null | grep "$DEVICE_NAME" | awk '{print $2}')
|
found=$(bluetoothctl devices 2>/dev/null | grep "$DEVICE_NAME" | head -1 | awk '{print $2}')
|
||||||
if [ -n "$found" ]; then
|
if [ -n "$found" ]; then
|
||||||
DEVICE_MAC="$found"
|
DEVICE_MAC="$found"
|
||||||
save_mac
|
save_mac
|
||||||
@@ -89,25 +101,78 @@ connect_device() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
log "Connection failed"
|
log "Connection failed"
|
||||||
|
if [ -f "$SAVED_MAC_FILE" ] && [ "$(cat "$SAVED_MAC_FILE")" = "$DEVICE_MAC" ]; then
|
||||||
|
log "Invalidating cached MAC, will rescan by name"
|
||||||
|
rm -f "$SAVED_MAC_FILE"
|
||||||
|
fi
|
||||||
|
# Remove from bluez so the next scan won't re-pick this dead MAC.
|
||||||
|
# If the device comes back online it'll re-advertise and be re-discovered.
|
||||||
|
bluetoothctl remove "$DEVICE_MAC" &>/dev/null
|
||||||
|
DEVICE_MAC=""
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Find the GATT characteristic handle via D-Bus
|
# Find a GATT characteristic path by UUID via D-Bus
|
||||||
find_rx_char_path() {
|
find_char_path_by_uuid() {
|
||||||
|
local target_uuid="$1"
|
||||||
local dev_path
|
local dev_path
|
||||||
dev_path=$(mac_to_dbus_path "$DEVICE_MAC")
|
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
|
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
|
local uuid
|
||||||
uuid=$(busctl get-property "$DBUS_DEST" "$char_path" org.bluez.GattCharacteristic1 UUID 2>/dev/null | tr -d '"' | awk '{print $2}')
|
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
|
if [ "$uuid" = "$target_uuid" ]; then
|
||||||
echo "$char_path"
|
echo "$char_path"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Subscribe to refresh-request notifications. The ESP fires this when it
|
||||||
|
# has no usage data yet (e.g. after a fresh boot). Daemon awk drops a flag
|
||||||
|
# file that the inner loop picks up on its next 5s tick.
|
||||||
|
#
|
||||||
|
# Implementation notes:
|
||||||
|
# - dbus-monitor must be running BEFORE we call StartNotify, because busctl
|
||||||
|
# exits immediately, the subscription tears down within milliseconds, and
|
||||||
|
# the ESP's notify fires inside that brief window.
|
||||||
|
# - stdbuf -oL forces line-buffered stdout on dbus-monitor; without it,
|
||||||
|
# glibc switches to block buffering when stdout is a pipe and signals
|
||||||
|
# never reach awk until ~4KB accumulates.
|
||||||
|
# - The pipeline runs in a setsid'd child so we can kill the whole process
|
||||||
|
# group (dbus-monitor + awk) atomically. Killing only awk leaves
|
||||||
|
# dbus-monitor orphaned, and `wait $!` in bash waits on the whole job
|
||||||
|
# until every pipeline member exits, hanging the daemon.
|
||||||
|
start_notify_subscriber() {
|
||||||
|
local req_path
|
||||||
|
req_path=$(find_char_path_by_uuid "$REQ_CHAR_UUID")
|
||||||
|
if [ -z "$req_path" ]; then
|
||||||
|
log "Refresh char not found, skipping notify subscriber"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
setsid bash -c "stdbuf -oL dbus-monitor --system \"type='signal',interface='org.freedesktop.DBus.Properties',path='$req_path',member='PropertiesChanged'\" 2>/dev/null | awk -v flag='$REFRESH_FLAG' '/Value/ { system(\"touch \" flag); fflush() }'" &
|
||||||
|
NOTIFY_PID=$!
|
||||||
|
|
||||||
|
# Give dbus-monitor a moment to register its match rule, then trigger
|
||||||
|
# the GATT subscription that causes the ESP to fire its notify.
|
||||||
|
sleep 0.3
|
||||||
|
busctl call "$DBUS_DEST" "$req_path" org.bluez.GattCharacteristic1 StartNotify >/dev/null 2>&1
|
||||||
|
|
||||||
|
log "Refresh subscriber started (pgid=$NOTIFY_PID)"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_notify_subscriber() {
|
||||||
|
if [ -n "$NOTIFY_PID" ]; then
|
||||||
|
# Kill the whole process group (setsid made NOTIFY_PID the leader).
|
||||||
|
# Don't wait — we don't care about exit status and waiting can hang
|
||||||
|
# if any group member is slow to exit.
|
||||||
|
kill -TERM -"$NOTIFY_PID" 2>/dev/null
|
||||||
|
NOTIFY_PID=""
|
||||||
|
fi
|
||||||
|
rm -f "$REFRESH_FLAG"
|
||||||
|
}
|
||||||
|
|
||||||
# Write data to the RX characteristic via D-Bus
|
# Write data to the RX characteristic via D-Bus
|
||||||
write_gatt() {
|
write_gatt() {
|
||||||
local char_path="$1"
|
local char_path="$1"
|
||||||
@@ -172,6 +237,7 @@ poll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
|
stop_notify_subscriber
|
||||||
log "Daemon stopped"
|
log "Daemon stopped"
|
||||||
exit 0
|
exit 0
|
||||||
}
|
}
|
||||||
@@ -205,7 +271,7 @@ while true; do
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Find the GATT characteristic
|
# Find the GATT characteristic
|
||||||
RX_CHAR_PATH=$(find_rx_char_path)
|
RX_CHAR_PATH=$(find_char_path_by_uuid "$RX_CHAR_UUID")
|
||||||
if [ -z "$RX_CHAR_PATH" ]; then
|
if [ -z "$RX_CHAR_PATH" ]; then
|
||||||
log "Error: RX characteristic not found, retrying..."
|
log "Error: RX characteristic not found, retrying..."
|
||||||
sleep 5
|
sleep 5
|
||||||
@@ -215,12 +281,24 @@ while true; do
|
|||||||
|
|
||||||
BACKOFF=1 # reset backoff on successful connection
|
BACKOFF=1 # reset backoff on successful connection
|
||||||
|
|
||||||
# Poll loop
|
start_notify_subscriber
|
||||||
|
|
||||||
|
# Poll loop: tick every $TICK seconds. Poll Anthropic when the
|
||||||
|
# interval has elapsed OR when the ESP requested a refresh.
|
||||||
|
LAST_POLL=0
|
||||||
while is_connected; do
|
while is_connected; do
|
||||||
poll || log "Poll failed, will retry"
|
NOW=$(date +%s)
|
||||||
sleep "$POLL_INTERVAL"
|
if [ -f "$REFRESH_FLAG" ] || (( NOW - LAST_POLL >= POLL_INTERVAL )); then
|
||||||
|
if [ -f "$REFRESH_FLAG" ]; then
|
||||||
|
log "Refresh requested by device"
|
||||||
|
rm -f "$REFRESH_FLAG"
|
||||||
|
fi
|
||||||
|
poll && LAST_POLL=$NOW
|
||||||
|
fi
|
||||||
|
sleep "$TICK"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
stop_notify_subscriber
|
||||||
log "Device disconnected, reconnecting..."
|
log "Device disconnected, reconnecting..."
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|||||||
+33
-1
@@ -8,7 +8,8 @@
|
|||||||
// Custom GATT UUIDs for data channel
|
// Custom GATT UUIDs for data channel
|
||||||
#define SERVICE_UUID "4c41555a-4465-7669-6365-000000000001"
|
#define SERVICE_UUID "4c41555a-4465-7669-6365-000000000001"
|
||||||
#define RX_CHAR_UUID "4c41555a-4465-7669-6365-000000000002" // host writes here
|
#define RX_CHAR_UUID "4c41555a-4465-7669-6365-000000000002" // host writes here
|
||||||
#define TX_CHAR_UUID "4c41555a-4465-7669-6365-000000000003" // device notifies here
|
#define TX_CHAR_UUID "4c41555a-4465-7669-6365-000000000003" // device ack/nack notifies
|
||||||
|
#define REQ_CHAR_UUID "4c41555a-4465-7669-6365-000000000004" // device-initiated refresh request
|
||||||
|
|
||||||
#define BLE_BUF_SIZE 512
|
#define BLE_BUF_SIZE 512
|
||||||
|
|
||||||
@@ -45,11 +46,13 @@ static NimBLEHIDDevice* hid_dev = nullptr;
|
|||||||
static NimBLECharacteristic* input_kbd = nullptr;
|
static NimBLECharacteristic* input_kbd = nullptr;
|
||||||
static NimBLECharacteristic* tx_char = nullptr;
|
static NimBLECharacteristic* tx_char = nullptr;
|
||||||
static NimBLECharacteristic* rx_char = nullptr;
|
static NimBLECharacteristic* rx_char = nullptr;
|
||||||
|
static NimBLECharacteristic* req_char = nullptr;
|
||||||
|
|
||||||
static ble_state_t state = BLE_STATE_INIT;
|
static ble_state_t state = BLE_STATE_INIT;
|
||||||
static bool need_advertise = false;
|
static bool need_advertise = false;
|
||||||
static char rx_buf[BLE_BUF_SIZE];
|
static char rx_buf[BLE_BUF_SIZE];
|
||||||
static volatile bool data_ready = false;
|
static volatile bool data_ready = false;
|
||||||
|
static volatile bool has_received_data = false;
|
||||||
static char mac_str[18];
|
static char mac_str[18];
|
||||||
|
|
||||||
static void start_advertising() {
|
static void start_advertising() {
|
||||||
@@ -86,6 +89,19 @@ class RxCallbacks : public NimBLECharacteristicCallbacks {
|
|||||||
memcpy(rx_buf, val.c_str(), len);
|
memcpy(rx_buf, val.c_str(), len);
|
||||||
rx_buf[len] = '\0';
|
rx_buf[len] = '\0';
|
||||||
data_ready = true;
|
data_ready = true;
|
||||||
|
has_received_data = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// When the daemon enables notifications on the refresh char, ask for data
|
||||||
|
// if we have none yet. Firing on subscribe (not on connect) ensures the
|
||||||
|
// notification isn't dropped before the daemon's CCCD write completes.
|
||||||
|
class ReqCallbacks : public NimBLECharacteristicCallbacks {
|
||||||
|
void onSubscribe(NimBLECharacteristic* chr, NimBLEConnInfo& info, uint16_t subValue) override {
|
||||||
|
Serial.printf("BLE: req_char onSubscribe subValue=%u has_data=%d\n", subValue, has_received_data ? 1 : 0);
|
||||||
|
if (subValue != 0 && !has_received_data) {
|
||||||
|
ble_request_refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,6 +144,13 @@ void ble_init(void) {
|
|||||||
NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY
|
NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY
|
||||||
);
|
);
|
||||||
|
|
||||||
|
req_char = svc->createCharacteristic(
|
||||||
|
REQ_CHAR_UUID,
|
||||||
|
NIMBLE_PROPERTY::NOTIFY
|
||||||
|
);
|
||||||
|
static ReqCallbacks reqCb;
|
||||||
|
req_char->setCallbacks(&reqCb);
|
||||||
|
|
||||||
svc->start();
|
svc->start();
|
||||||
server->start();
|
server->start();
|
||||||
start_advertising();
|
start_advertising();
|
||||||
@@ -186,6 +209,15 @@ void ble_send_nack(void) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ble_request_refresh(void) {
|
||||||
|
if (state == BLE_STATE_CONNECTED && req_char) {
|
||||||
|
uint8_t v = 0x01;
|
||||||
|
req_char->setValue(&v, 1);
|
||||||
|
req_char->notify();
|
||||||
|
Serial.println("BLE: refresh requested");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void ble_keyboard_press(uint8_t key, uint8_t modifier) {
|
void ble_keyboard_press(uint8_t key, uint8_t modifier) {
|
||||||
if (state != BLE_STATE_CONNECTED || !input_kbd) return;
|
if (state != BLE_STATE_CONNECTED || !input_kbd) return;
|
||||||
// HID report: [modifier, reserved, key1, key2, key3, key4, key5, key6]
|
// HID report: [modifier, reserved, key1, key2, key3, key4, key5, key6]
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ bool ble_has_data(void);
|
|||||||
const char* ble_get_data(void);
|
const char* ble_get_data(void);
|
||||||
void ble_send_ack(void);
|
void ble_send_ack(void);
|
||||||
void ble_send_nack(void);
|
void ble_send_nack(void);
|
||||||
|
void ble_request_refresh(void);
|
||||||
|
|
||||||
// BLE HID keyboard
|
// BLE HID keyboard
|
||||||
void ble_keyboard_press(uint8_t key, uint8_t modifier);
|
void ble_keyboard_press(uint8_t key, uint8_t modifier);
|
||||||
|
|||||||
Reference in New Issue
Block a user