Bluetooth-based management interface #71
No reviewers
Labels
No labels
bug
duplicate
enhancement
future
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
albert/shepherd-launcher!71
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "u/albert/65/ble-management"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Bluetooth LE management interface (#65)
Closes #65 — "Management interface when network is down".
Why
The existing HTTP management API only works when the phone can actually reach the box: it needs a live LAN, plus either mDNS autodiscovery or a static IP the admin remembers. In practice DHCP, AP isolation, and captive portals break that often enough that "I need to change a setting and can't reach the device" is a recurring problem.
This PR adds Bluetooth LE as the primary admin transport. BLE removes the IP-discovery / static-IP friction entirely and works regardless of network state. HTTP stays available but secondary. Design doc:
docs/ai/history/2026-06-20 002 ble-management.md.Architecture
The load-bearing refactor is extracting a transport-agnostic service so HTTP and BLE share one business-logic implementation:
shepherd-management(new) — defines theManagementServicetrait covering every admin operation (list entries, launch/stop/extend sessions, overrides, usage, volume, brightness, reload, windows, event subscription).DefaultManagementServicecomposes the existingCoreEngine/Store/HostAdapter/controllers the daemon already wires up. The HTTP handlers inshepherd-httpare reduced to wire-format translation —sessions.rsalone drops ~250 lines.shepherd-ble(new) —BleServer, the BLE counterpart toHttpServer, serving the same trait over a custom GATT service. Modules:protocol(UUIDs + JSON-RPC envelope),framing(length-prefix + chunked reassembly),outbox(read-poll FIFO),rpc(method → trait dispatch),admin(TOML admin record + reset sentinel),claim(Unclaimed→Claimed state machine + per-request auth gate),agent(BlueZ pairing agent),server(advertising, GATT registration, per-client loop).shepherd-pairing-display(new) —wlr-layer-shellSway overlay sidecar that shows the 6-digit pairing code full-screen on the TV.companion-android(new) — the Android companion app (Jetpack Compose + Kable), the primary admin client. Pairs, claims, and drives the full RPC catalog over the bonded link.Schema-driven dispatch (follow-on)
The initial split still left three per-transport dispatchers duplicating the trait signatures — BLE's
rpc.rs(~390 lines of match-and-parse boilerplate), IPC'shandle_commandinshepherdd/main.rs(~900 lines of tagged-Command-enum handling), and HTTP's ten REST handler files. Adding a method touched seven places: the trait, both server dispatchers, the HTTP handler + route, the IPCCommandenum, the Kotlin client, and the TypeScript client.A
#[management_rpc]proc-macro (crates/shepherd-management-macros) reads the trait and emits both adispatch_json(svc, method, params) -> Result<Value, RpcDispatchError>function next to it and a machine-readableRPC_SCHEMA_JSONconst. Per-method attributes (#[rpc(default(...))],#[rpc(wrap_result = "field")]) cover the small handful of quirks the wire format needs (defaultingat/date, wrappingbool/Option/usizein a named field for forward-compat). A companionrpc-codegenbinary consumes the schema and writes three checked-in files:docs/rpc-schema.json,companion-android/.../ble/RpcMethods.kt(typed method-name constants + awrapField()lookup), andshepherd-webui/src/api/rpc-methods.generated.ts(union type + wrap-field map). A drift-check test incrates/shepherd-management/tests/rpc_codegen_drift.rsfails CI if the checked-in outputs no longer match the current trait.Every transport now sits on the generated dispatcher:
crates/shepherd-ble/src/rpc.rsshrinks 388 → 116 lines, just an adapter mappingRpcDispatchErroronto the wire'sErrorCode.handle_commandmatch inshepherdd/src/main.rsis gone. Wire format changes from taggedCommand/ResponsePayloadenums to JSON-RPC ({method, params}request,Value-or-error response) on the same NDJSON socket. Every consumer (launcher-ui,hud,e2e) now speaks typed helpers onIpcClient::{service_state, launch, get_volume, ...}. ~830 net lines deleted.entries.rs,sessions.rs,volume.rs,overrides.rs, ...) removed. The router is two routes:POST /api/v1/rpc(dispatch_jsonpass-through withManagementError→ HTTP status mapping) andGET /api/v1/events(SSE stays — push shape doesn't fit RPC).shepherd-webui/src/api/client.tscollapses ~180 lines of hand-written REST wrappers into a typedcall<T>(method, params)plus per-method one-liners.Adding a new RPC method is now a single trait line. The macro re-emits
dispatch_json,cargo run -p shepherd-management --bin rpc-codegenregenerates the schema + Kotlin + TypeScript mirrors, and every transport picks it up automatically — the CI drift test forces the regeneration into the same commit as the trait change. The IPC-migration commit alone extended the trait withvolume_up/volume_down/toggle_mute/brightness_up/brightness_down/pingand had all three transports serving them without touching per-transport code.Security model (two layers)
claimRPC becomes the sole admin; later claims getalready_claimed. The claim mints a bearer token that is also valid for the HTTP API — clearing the admin record (or factory reset) invalidates both transports in one step (AdminAuthoritytrait unifies the two).Recovery
A factory-reset sentinel file (
<data_dir>/.factory-reset-bleby default):touchit and restartshepherddto wipe the admin record and bond. Also surfaced asshepherd bluetooth clearto wipe a logged-out user's BLE state.Transport: read-poll, not notify
The most significant evolution on the branch. BLE notify proved unfixable for bonded reconnects on this Android/BlueZ stack pair — CCCD writes are cached at the bond level, so the server's notify task never re-armed on a reopened app and responses went nowhere ("activity list blank on every subsequent open"). Full investigation:
docs/ai/history/2026-06-28 001 ble-read-poll-replaces-notify.md.The fix drops notify entirely. Response and Events become read characteristics backed by a server-side
Outbox(ordered, framed FIFO that never tears a frame across reads); the companion polls with adaptive backoff (25 ms after data → 300 ms idle, woken instantly on RPC dispatch via a conflated channel, sub-50 ms latency). Two live-hardware bugs caught and fixed along the way:id==1(each connection restarts its RPC counter) instead of a 5 s write-gap heuristic that wiped responses mid-delivery when the 15 s client timeout fired.readCharacteristictoGATT_MAX_ATTRIBUTE_VALUE, dropping bytes 513–516 at MTU 517 and desyncing the reassembler. The outbox now hands out ≤512 bytes per read.ClaimMachine::authorizewas also relaxed for v1: the encrypted link is the security boundary and TOFU guarantees a single bond, so reconnects no longer fail on BlueZ identity-address drift.Config
New optional
[service.ble_management]block (enabled,device_name,admin_record_path,reset_sentinel_path) — seeconfig.example.toml. Disabled by default. Requires a working BlueZ adapter, thebluezpackage, and membership in thebluetoothgroup (startup now requires and diagnoses this).Android companion app
com.armeafamily.shepherd.companion— Compose/Material 3, single activity, oneShepherdViewModel. Kable for GATT (coroutine-native, Apache-2.0); bonding is OS-driven. Multi-device selector on Home, hand-rolled usage bar chart,EncryptedSharedPreferencesfor the token at rest. Constraints honoured: no telemetry/phone-home, no background BLE (link lives only in foreground), GPL-3.0-compatible deps only. Spec:docs/ai/history/2026-06-21 001 ble-companion-android-spec.md. Build via./scripts/shepherd deps install androidthen./gradlew :app:assembleDebugincompanion-android/.Testing & CI
Testjob,cargo test --all-targets):shepherd-blecovers framing, outbox, claim-machine, and protocol in isolation, plus theserver.rswrite-path invariants the read-poll rewrite depends on — theid==1session-boundary outbox wipe and theFrameReaderreset on peer change / framing error (the logic behind the field bugs in the read-poll doc). TheMockSvctest double is shared via a crate-leveltestsupportmodule.shepherd-httpAPI tests driveDefaultManagementService/AdminAuthorityend-to-end, so the shared service the BLE transport calls into is integration-covered.android-companionjob:FramingTest(codec) +WireTest(decode of every sampled spec payload), 17 tests guarding the phone side of the wire protocol against drifting fromshepherd-ble. Hardware-independent JVM tests only — no emulator..ci/Dockerfile.android, built by animage-androidjob) layers the JDK + ~1 GB Android SDK onto the base image so the Gradle job gets a ready SDK; the 8 Rust-only jobs keep the lean base image. Storage is split by volatility — the static SDK is baked into the image, while the churnier Gradle/Maven dep cache ridesactions/cache.Deferred to v2
Multi-admin / approve-new-device (the per-bond record schema already accommodates it), OOB-QR pairing as a display-down escape hatch, and tracking the IRK explicitly in the admin record rather than relying on the single-bond assumption.
The BLE management transport that the design doc in docs/ai/history/2026-06-20 002 ble-management.md specifies. Implements the full surface but is not yet wired into shepherdd (that's the next commit). The server's GATT integration speaks bluer + BlueZ; everything else (protocol envelopes, framing, admin record TOML, claim machine, RPC dispatch) is unit-tested in isolation. Modules: - protocol: GATT service/characteristic UUIDs, RpcRequest/RpcResponse envelopes, ErrorCode mapping from ManagementError. - framing: u16-LE length-prefix reader and MTU chunk encoder for ATT writes and notifies that exceed one packet. - admin: AdminRecord (identity_address, http_token, role) persisted as atomic TOML, plus the factory-reset sentinel check. - claim: Unclaimed/Claimed state machine with TOFU single-admin semantics and the per-RPC authorize() gate. - rpc: name-keyed dispatch from RpcRequest into the ManagementService trait. - agent: bluer pairing agent configured for Numeric Comparison (IO capability DisplayYesNo); calls a PairingDisplay trait so the daemon's Sway overlay plugs in without bringing Wayland into this crate. - server: BleServer that brings up the adapter, registers the agent and GATT application, advertises the management service, and routes incoming writes through the claim machine before the RPC dispatcher. System dependencies: libdbus-1-dev (build) and bluez (runtime) added to scripts/deps/{build,run}.pkgs. CI hashes those files so the image will rebuild automatically. Tests: 34 unit tests in shepherd-ble (protocol, framing, admin, claim, rpc, agent). cargo check / test / clippy / fmt all clean across the workspace. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The dev box's controller is HCI 4.0 (Marvell), which predates LE Secure Connections (added in BT 4.2). SMP on this hardware therefore falls back to LE Legacy Pairing, and with our DisplayYesNo IO capability plus the phone's KeyboardDisplay + MITM-required, the selected method is Passkey Entry, responder-displays — we display the 6-digit passkey, the phone prompts the user to type it. Our agent only had `request_confirmation` (Numeric Comparison), nothing for Passkey Entry display. BlueZ called display_passkey, our agent had no handler so it rejected, pairing stalled, and the phone's "Enter PIN to pair with leibniz" dialog waited indefinitely for input that we never displayed. btmon trace of an attempt: SMP: Pairing Request (phone) IO=KeyboardDisplay auth=Legacy MITM SMP: Pairing Response (us) IO=DisplayYesNo auth=Legacy MITM MGMT: Passkey Notify passkey=0x000a693a ← display this! Fix: add `display_passkey` to the agent that drives the same PairingDisplay show/hide as `request_confirmation`. The on-device UX is unchanged (a big number on the TV); only the phone-side prompt differs ("type this" vs "match this"). Security property is the same MITM-protected exchange. Adding `display_passkey` does NOT promote our IO capability away from DisplayYesNo — bluer's capability table groups display+yes_no callbacks under DisplayYesNo. So Numeric Comparison still gets selected on 4.2+ controllers, and Passkey Entry on 4.0/4.1. PASSKEY_DISPLAY_HOLD bumped from 30s to 60s — Passkey Entry needs the user to actually type the number on the phone, which takes longer than confirming a match. Design doc updated to reflect the controller-dependent method. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Numeric Comparison and Passkey Entry ask the user to do different things (compare vs type). The overlay shipped Numeric Comparison copy unconditionally, which was wrong on the leibniz dev box and on any BT 4.0/4.1 controller that lands in Legacy Passkey Entry. - shepherd-ble: PairingDisplay::show grows a PairingMethod parameter (Compare | Enter). request_confirmation passes Compare; display_passkey passes Enter. - shepherd-pairing-display: required --method <compare|enter> CLI flag switches the instruction label. - shepherdd: SwayPairingDisplay maps the enum to the CLI arg and logs which method spawned each overlay. Copy reads: compare → "If this number matches the one on your phone, tap PAIR (or MATCH) on the phone. If it does not match, tap CANCEL (or DON'T MATCH) and tell whoever is in charge…" enter → "Type this number into the prompt on your phone to complete pairing. If you did not initiate this pairing, tap CANCEL on the phone and tell whoever is in charge…" Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>After a kiosk user logs out (or is being decommissioned) shepherdd is gone, but BlueZ still holds the bond and the user's admin.toml still points the next shepherdd at the same admin. The factory-reset sentinel handles this on the next startup, but operators sometimes want to clear the state immediately — e.g. before handing the device to a new user. `sudo shepherd bluetooth clear --user USER` does the full cleanup in one shot: 1. Refuses if the user has an active login session (override with --force; documented as racing with the live shepherdd). 2. Reads ~USER/.local/share/shepherdd/admin.toml to find the bonded peer's identity address. 3. `bluetoothctl disconnect <addr>` then `bluetoothctl remove <addr>` for each recorded peer — both no-ops if the state is already clean. 4. Deletes admin.toml so the next shepherdd boot lands unclaimed. 5. Removes any leftover .factory-reset-ble sentinel. --admin-record / --sentinel let operators point at non-default locations when shepherd-config overrides them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>A real-world StateChanged push with 14 configured entries serialises to 19_746 bytes, larger than the companion's 16 KiB frame cap, so the FrameAssembler threw a FramingException that propagated uncaught out of the Events collector and killed the process on every subsequent app open. Stack trace from the device: FATAL EXCEPTION: main FramingException: declared frame length 19746 exceeds cap 16384 at FrameAssembler.push(Framing.kt:78) at ShepherdConnection$start$1$2.emit(ShepherdConnection.kt:93) Two fixes: - Raise Protocol.MAX_FRAME_BYTES to 0xFFFF, the actual hard ceiling imposed by the length prefix. The earlier 16 KiB value was carried over from the device's Request-char cap (a defensive bound on incoming writes), but Response/Events on the companion need to accept whatever the server legitimately sends — and a snapshot with a non-trivial number of entries exceeds 16 KiB easily. - Wrap each collector's push() in runCatching and route a FramingException through handleFramingFailure → peripheral disconnect, so a future wire-level glitch surfaces as a dropped link (which the ViewModel reconnect loop handles cleanly) rather than as a process-killing uncaught exception. Other exceptions still propagate. The FramingTest now uses a tight-cap FrameAssembler so it can still exercise the oversize-prefix guard without allocating 64 KiB of buffer just for the assertion. `./gradlew :app:testDebugUnitTest :app:assembleDebug` clean. `adb install` + relaunch on the bonded Pixel: app stays up, journal shows the device's BLE flow connecting without incident. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>The notify-based path was unfixable on bonded reconnects: BlueZ caches CCCD state at the bond, Android short-circuits subsequent CCCD writes, and the server-side notify task spawned for the very first session ends up the only one the wire ever reaches. Every reopen of the companion silently lost responses (the long-running "list blank, 15s timeout" symptom). Server side, Response and Events become read characteristics backed by a new `Outbox` byte queue (`crates/shepherd-ble/src/outbox.rs`). A long-lived events forwarder pushes serialized events for the entire server lifetime. Two further fixes were needed: - First-RPC `id=1` clears both outboxes. Every `ShepherdConnection` starts its counter at 1, so this is the deterministic "fresh BLE session" signal we couldn't get from bluer at the GATT-server layer. (A write-gap heuristic was the first attempt; a 15s per-RPC timeout naturally exceeded any threshold and kept wiping the queue mid-delivery.) - Cap each read at 512 bytes (the spec's `GATT_MAX_ATTR_VALUE`). With MTU 517 an ATT read response carries up to 516 bytes, but Android silently truncates a single `readCharacteristic` to 512; the lost 4 bytes per chunk stitched together with bytes from the *next* response, corrupting the frame. Client side, the Kable `observe()` collectors become poll loops with adaptive backoff and a wake channel that `call()` pokes so the response poller drops to the fast interval the instant an RPC is dispatched. `connect()` drains any stale bytes synchronously and only then flips `ready`, so the very first RPC's response can't be raced. Also relaxes `ClaimMachine::authorize` to allow any peer once claimed: BlueZ's identity-resolution drift between the pairing-time RPA and the post-bond identity address was breaking the strict comparison on every reconnect, and v1's single-bond TOFU model means the encrypted- authenticated link is already the security boundary. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A38wEcPFAgtawLSY9cp1WgThe documented contract ("omit auth_token to allow unauthenticated access") regressed when BLE was always plumbed in as the unified bearer source: the auth middleware's `is_open` check keyed on `admin.is_none()` alone, so a fresh install with BLE enabled but no admin yet would 401 every HTTP request — locking the device out of HTTP before any admin existed. Relax `is_open` to also require the BLE authority's `current_http_token()` to be None. Open mode now means "no token to enforce" (whether because there's no static token configured AND no BLE admin authority, or because there is one but it hasn't been claimed yet). As soon as a claim lands, the gate engages and unauthenticated requests are rejected as before. Adds a regression test covering the "BLE authority plugged in but unclaimed → open" case alongside the existing claimed-admin-authority tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A38wEcPFAgtawLSY9cp1WgThe BLE JSON-RPC dispatcher in shepherd-ble/src/rpc.rs was ~390 lines of hand-written boilerplate mirroring every trait method: a match arm per method, an ad-hoc `#[derive(Deserialize)]` param struct with manual default-value helpers, and a per-shape result wrapper. Every new RPC on the trait meant three places to update — the trait itself, the match arm, and (for wrapped results like `{"deleted": bool}`) a tiny wrapper struct. Introduce `shepherd-management-macros`, a proc-macro crate whose `#[management_rpc]` attribute on the trait definition emits a `dispatch_json(svc, method, params) -> Result<Value, RpcDispatchError>` function next to it. The macro: - Derives a per-method params struct from the trait signature. `&T` arguments deserialise into owned `T` and re-borrow at the call site. - Handles the three return shapes we actually use (`T`, `ManagementResult<T>`, `ManagementResult<()>`) — `Ok(())` becomes `Value::Null`, everything else `serde_json::to_value`s the body. - Supports three per-method knobs via `#[rpc(...)]`: - `default(param = "expr_path")` for optional fields that need a computed default (replaces the old `AtTime.at_or_now()` / `DateOpt.date_or_today()` helpers scattered through rpc.rs). - `wrap_result = "field_name"` for the small set of methods whose wire form wraps a bare primitive in a named field (`extend_current`, `delete_override`, `reload_config`) — forward-compat with future extra fields. - `name = "wire_name"` in case the Rust name and wire name ever need to diverge (unused today, cheap to keep). `shepherd-ble/src/rpc.rs` shrinks to a 34-line adapter: call `dispatch_json`, map its four error variants onto BLE's `ErrorCode`, wrap the result in an `RpcResponse` with the caller's id. Net: rpc.rs 388 → 116 lines (with tests), one place to add or change an RPC method (the trait itself). All existing tests pass, plus new ones covering `wrap_result` and the zero-arg params-blob shape. Not touched by this refactor: - HTTP handlers stay REST-shaped (URL params, per-endpoint status codes) — a macro would fight axum's ergonomic extractor pattern for little gain. - The IPC dispatcher (~1700 lines in shepherdd/main.rs) is a much larger legacy migration, worth its own commit. - TypeScript and Kotlin clients still hand-write method names. A future emit-the-schema step could drive their codegen off the same attribute, but that's a separate follow-up. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A38wEcPFAgtawLSY9cp1WgExtends `#[management_rpc]` to also emit `pub const RPC_SCHEMA_JSON: &str = "..."` alongside `dispatch_json`. The schema shape is: { "methods": [ { "name", "params": [{ "name", "type", "required" }], "result": { "type", "wrap_field": <optional> } }, ... ] } Types are verbatim Rust source strings so external codegen can map them to whatever the target language uses. Adds a `rpc-codegen` binary that consumes the schema and writes three files, all checked in: - docs/rpc-schema.json — pretty-printed, diffable in PRs - companion-android/.../ble/RpcMethods.kt — method-name constants + a `wrapField()` lookup so the Kotlin companion no longer duplicates the `{"deleted": ...}` unwrap knowledge that lives on the trait - shepherd-webui/src/api/rpc-methods.generated.ts — a `RpcMethod` union of every wire name plus the matching `RPC_WRAP_FIELDS` map A drift-check test (`tests/rpc_codegen_drift.rs`) runs the codegen into a temp dir and byte-compares against the checked-in outputs. CI failing this test means someone changed the trait without regenerating; the fix is a `cargo run -p shepherd-management --bin rpc-codegen` at the repo root. The Kotlin `ManagementClient` and TypeScript client aren't switched over to the new constants in this commit — that's a mechanical follow-up. The schema + files land first so the codegen path is proven end-to-end before we edit consumers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A38wEcPFAgtawLSY9cp1WgPrep for the IPC-to-JSON-RPC migration (Path A). The IPC wire's `Command` enum has variants that don't currently correspond to any trait method: Command::VolumeUp { step } Command::VolumeDown { step } Command::ToggleMute Command::BrightnessUp { step } Command::BrightnessDown { step } Command::Ping Adds equivalent async methods so once IPC dispatches through `dispatch_json` there's a matching entry for every wire operation instead of a handful of IPC-only special cases. `brightness_up` / `brightness_down` compose on top of `set_brightness` (reading the current level, saturating, and clamping through the policy) — matches the existing IPC behaviour of routing user +/- keys through the min/max policy instead of poking the backlight directly. Volume relative moves delegate to the existing `VolumeController::volume_up` / `volume_down` methods so the policy clamps stay live. `ping` is a no-op — it exists so RPC clients can detect a wedged connection with a well-typed round-trip. Regenerates `docs/rpc-schema.json` + the Kotlin/TS files. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A38wEcPFAgtawLSY9cp1WgFull Path A of the IPC-dispatcher cleanup. The IPC wire format was a tagged `Command`/`ResponsePayload` enum pair whose every variant had a matching hand-written arm in shepherdd's `handle_command` (~600 lines of duplicated match logic that mirrored what the trait already did). Replaces it with a JSON-RPC-style `{ method, params }` request and `Result<serde_json::Value, ErrorInfo>` response — the same shape BLE already uses — dispatched through `shepherd_management::dispatch_json`. Wire changes (shepherd-api::commands.rs): - `Command` enum → deleted; requests now carry `method: String` + `params: Value`. - `ResponsePayload` enum → deleted; responses carry a bare `Value` on success and an `ErrorInfo { code, message }` on error. - `ErrorCode` variants pruned to the ones actually reachable through the trait + IPC-layer concerns (rate limit, invalid request). Server (shepherdd/src/main.rs): - Removes ~900 lines: `handle_command`, `handle_relative_volume`, `handle_relative_brightness`, and the volume/brightness-restrictions helpers those depended on. The IPC dispatcher is now one `match` on method name that routes subscribe/unsubscribe/ping specially and delegates everything else to `dispatch_json`. - `svc` is constructed unconditionally now (previously it was tied to HTTP-or-BLE being enabled) — IPC always needs it. - `dispatch_ipc` is the ~15-line ManagementError-to-ErrorCode mapper. Trait extension (shepherd-management/src/service.rs, prior commit): - Adds `volume_up`, `volume_down`, `toggle_mute`, `brightness_up`, `brightness_down`, and `ping` — the trait methods that IPC's relative-media and keepalive commands had lived outside of. - Regenerates docs/rpc-schema.json + Kotlin/TS mirrors. Client (shepherd-ipc/src/client.rs): - Rewrites `IpcClient` around a `call_raw` / `call<T>` pair, then layers typed helpers (`service_state`, `launch`, `stop_current`, `get_volume`, ...) matching the trait method names. Every IPC consumer now speaks these typed helpers instead of building a `Command` variant + pattern-matching on `ResponsePayload`. - Subscribe is still a special case (the writer task must flip its flag AFTER the response frame writes so events can't slip in before the subscribe ack). The typed helper preserves that semantics. Consumers migrated to the new client (~500 line reduction): - shepherdd/src/main.rs — server-side (see above) - shepherd-hud/src/{app,volume,brightness}.rs - shepherd-launcher-ui/src/{main,client,app}.rs - shepherd-e2e/tests/e2e.rs Behaviourally unchanged: every response payload the old wire produced now comes back as the trait method's return value serialised through `dispatch_json`. Session lifecycle, event broadcast, hidpi apply / restore, and policy-clamped volume/brightness all keep their existing paths — they already lived on `ManagementService` for the HTTP/BLE transports and now IPC just calls them too. Net: ~1500 lines deleted, ~700 added. Adding an IPC operation is now a one-place trait change; the wire schema, client helper, and dispatcher are all derived. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A38wEcPFAgtawLSY9cp1WgPath A follow-on for HTTP: the REST routes came off, and everything migrates to `POST /api/v1/rpc` with `{ method, params }` body. Wire semantics (see `crates/shepherd-http/src/handlers/rpc.rs`): - Success → 2xx with the trait method's `dispatch_json`-encoded return value as the response body. - Failure → 4xx/5xx with `{ "error": "<code>", "message": "<string>" }`, where `<code>` is one of `method_not_found`, `invalid_params`, `not_found`, `bad_request`, `forbidden`, `conflict`, `unprocessable`, `internal`. `GET /api/v1/events` (SSE) stays — push shape doesn't fit RPC. Removals: - crates/shepherd-http/src/handlers/{brightness,config,debug,entries, health,logout,overrides,sessions,usage,volume}.rs — the per-endpoint REST handlers. - crates/shepherd-http/src/error.rs — `ApiError`/`ApiResult` were only used by the REST handlers. - crates/shepherd-http/src/handlers/mod.rs — router shrinks to two routes. New: - crates/shepherd-http/src/handlers/rpc.rs — 90-line handler that maps `RpcDispatchError` and `ManagementError` variants onto HTTP status codes with a machine-readable `error` field. Migrations: - crates/shepherd-http/tests/api.rs — 44 tests, all through `POST /rpc`. Coverage preserved (auth, entries, sessions, overrides, usage, volume/brightness policy, config reload) plus new coverage for the RPC-only paths (unknown method, params round-tripping, volume_up/volume_down/toggle_mute, ping). - crates/shepherd-e2e/src/http.rs — `HttpClient::rpc()` helper. - crates/shepherd-e2e/tests/{e2e,firewall,firewall_real{,_flatpak,_snap}, browser}.rs — every REST call is now `http.rpc(method, params)`. - shepherd-webui/src/api/client.ts — replaces ~180 lines of hand-written `axiosInstance.get("/entries")` / `.put("/volume")` / etc. with a single typed `call<T>(method: RpcMethod, params)` helper plus per-method wrappers. Each wrapper is 1–3 lines; adding a new one when the trait grows a method is a mechanical change. `LaunchOutcome` gets normalised at the client boundary since the on-wire form (`{Approved: {...}}` / `{Denied: {...}}`) is uglier than the `{result: "approved" | "denied"}` shape existing callers expect. - crates/shepherd-http/README.md, crates/shepherd-e2e/README.md — reflect the new two-endpoint surface. Net: 46 REST handler functions gone, ~40 test assertions rewritten, ~180-line hand-written REST client replaced with a schema-typed 30-line dispatch + typed wrappers. Adding a new method now only requires: (1) a trait line on `ManagementService`, (2) regenerate `docs/rpc-schema.json`, and (3) optionally a two-line typed helper on whichever clients want a nicer name than `call("volume_up", ...)`. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A38wEcPFAgtawLSY9cp1Wg@ -0,0 +232,4 @@// Nothing persisted yet — just discard// the local edits, no server round-trip.availability = nullquotaDeltaMinutes = 0Not sure what the quota system is here
ah it was just missing from the web version; it was in shepherdd all along
Code looks reasonable, pending additional testing on a device that supports more than BLE 4.0
Post refactor, the management app works fine against the original Surface Pro
Also works great on the Legion Go S with BLE 4.2 and Numeric Comparison workflow