feat(agent): expose device haptics to third-party apps - #712
Open
kacper-serewis wants to merge 5 commits into
Open
feat(agent): expose device haptics to third-party apps#712kacper-serewis wants to merge 5 commits into
kacper-serewis wants to merge 5 commits into
Conversation
kacper-serewis
force-pushed
the
feat/haptic-api
branch
from
August 20, 2026 11:47
f3667d2 to
297d317
Compare
The waveform vocabulary is about to cross the agent IPC boundary, and that contract is append-only: serde encodes the declaration index, so a protocol crate free to reorder its variants could silently turn one pulse into another across a version skew. Define it in `openlogi-core::hid` alongside the other wire types and map to the firmware `playWaveform` ID inside `openlogi-hid`, where the `hidpp` enum becomes an implementation detail. The mapping is an explicit match rather than a cast, because the two enums answer to different masters and must be free to disagree on ordering. `openlogi_hid::HapticWaveform` keeps its name and path, so this is a drop-in swap for existing callers.
kacper-serewis
force-pushed
the
feat/haptic-api
branch
2 times, most recently
from
August 20, 2026 11:59
d271e24 to
57ed867
Compare
kacper-serewis
marked this pull request as ready for review
August 20, 2026 12:03
Greptile SummaryThe PR exposes device haptics through tarpc and an opt-in newline-delimited JSON socket, routing both through the Actions Ring’s single-flight worker.
Confidence Score: 4/5The PR is not yet safe to merge because concurrent requests for different devices can still arm one route while playing another. The arm and play remain independently published through separate latest-wins slots, so an interleaving of two external requests can leave the worker with one route in Files Needing Attention: crates/openlogi-agent/src/server.rs
|
| Filename | Overview |
|---|---|
| crates/openlogi-agent/src/server.rs | Adds external haptic RPC handling, completion-based arm freshness, and worker sequencing, but the independently coalesced arm and play publications still permit cross-route divergence. |
| crates/openlogi-agent/src/json_api.rs | Implements the opt-in local newline-delimited JSON protocol with bounded request lines, structured errors, and connection framing tests. |
| crates/openlogi-agent-core/src/orchestrator.rs | Adds haptic-capable device listing and route resolution with distinct offline and unsupported-feature errors. |
| crates/openlogi-ipc/src/ipc.rs | Extends the tarpc contract with haptic playback and advances the wire protocol version. |
| crates/openlogi-hid/src/write/haptic.rs | Maps the shared wire waveform type to the firmware-specific haptic waveform identifier. |
| crates/openlogi-agent/src/main.rs | Starts the JSON haptics listener only when enabled in startup configuration. |
Sequence Diagram
sequenceDiagram
participant Client as Third-party client
participant API as tarpc / JSON API
participant Player as RingHapticPlayer
participant Worker as Single-flight worker
participant Device as HID++ device
Client->>API: play(route, waveform)
API->>Player: play_external(route, waveform)
Player->>Player: publish pending arm
Player->>Player: publish latest play
Worker->>Player: snapshot latest play
Worker->>Player: drain pending arm
Worker->>Device: verify/re-arm firmware
Worker->>Device: play waveform
Reviews (4): Last reviewed commit: "Merge branch 'master' into feat/haptic-a..." | Re-trigger Greptile
kacper-serewis
force-pushed
the
feat/haptic-api
branch
from
August 20, 2026 12:18
0ef0979 to
5abc083
Compare
Haptics were reachable only as a side effect of an Actions Ring hover or activation — both gated on a live session id — so nothing outside the ring could ask for one. Append `play_haptic` (protocol v24), taking an optional route so a caller with no inventory of its own can still say "the device in front of the user". Plays route through the ring's single-flight worker rather than writing directly. HID++ allows one in-flight transaction per channel, shared with the input-capture path, so a caller free to queue buzzes faster than the receiver drains them would time out unrelated DPI and SmartShift writes for seconds. That shapes the contract: acceptance is synchronous and resolves the two failures a caller can act on (no such device, no haptic engine), while the play itself is fire-and-forget and a superseded buzz is dropped rather than delivered late. Out-of-band plays re-arm the firmware when the last check on that route has aged out. Some power transitions clear the haptic engine, after which `play` is accepted and produces nothing; checking per call would double the HID++ traffic of a caller buzzing steadily. Offline resolves to `DeviceNotFound` rather than `FeatureUnsupported`: a sleeping device is worth retrying and a device without 0x19b0 never is, so the two must stay distinguishable. `examples/haptic.rs` is the reference client — `openlogi-cli` cannot be one, since it publishes to crates.io and `openlogi-ipc` does not.
The tarpc contract is bincode over a positional wire format both ends must agree on exactly — fine for the GUI that ships beside the agent, useless for a Python script. Serve haptics as newline-delimited JSON on a second local socket so any language can reach them. It lives in the agent rather than a relay process. A relay was the right shape while this was going to be a websocket: a network listener has no business inside the binary that owns the input hook and holds Accessibility. A local socket has no network reach, so that argument does not transfer — a relay would add a binary to launch, supervise and version, another hop of latency, and one more way to be silently dead, in exchange for no boundary that is not already there. Any process that can open this socket can already open agent.sock. What does carry over is scope. The endpoint plays waveforms and lists what can be buzzed; it cannot write DPI, pair a device, or read config, so it stays strictly weaker than the socket beside it. Off unless `app_settings.haptic_api` is set: it is an integration surface, and a local-first app should not open one the user never asked for. Requests are addressed by the stable config key rather than by `DeviceRoute`, which is an internal addressing detail that changes when a device is re-paired.
The setting was config-file only, which also meant a GUI built before it existed rejected the whole file — `AppSettings` is `deny_unknown_fields`, so a hand-edited config broke the app it was meant to configure. Unconditional, unlike the tray toggle above it: the socket is a Unix-domain socket on Unix and a named pipe on Windows, so every platform can serve it. Like the menu-bar switch it describes the next agent launch rather than this instant, since the socket is bound once at startup; the copy says so.
kacper-serewis
force-pushed
the
feat/haptic-api
branch
from
August 20, 2026 12:28
5abc083 to
78e83c1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Haptics were reachable only as a side effect of an Actions Ring hover or activation, so an app that wanted to buzz the mouse had no way in. This adds two entry points on top of the existing haptic write path:
Agent::play_hapticon the tarpc contract (protocol v24), for callers that already speak it.docs/HAPTIC-API.md.Off by default (
app_settings.haptic_api), with a toggle in General settings.Design notes
The waveform type moved out of the
hidppfork. It rides the IPC wire now, and that contract is append-only, so the protocol crate must not be free to reorder its variants.openlogi-hidmaps it to the firmwareplayWaveformID at the point of the write.Both entry points route through the Actions Ring's single-flight worker rather than writing directly. HID++ allows one in-flight transaction per channel, shared with input capture, so a caller free to queue buzzes faster than the receiver drains them would time out unrelated DPI/SmartShift writes for seconds. Consequences, both documented at the API surface:
Ok(())/{"accepted":true}means queued, not played. The two errors a caller can act on — no such device, no haptic engine — are resolved synchronously before queueing; a failure during the play itself is logged, not reported.The JSON API lives in the agent, not a relay process. A relay is the right shape for a websocket — a network listener has no business inside the binary that owns the input hook and holds Accessibility. A local socket has no network reach, so that argument doesn't transfer: a relay would add a binary to launch, supervise and version for no boundary that isn't already there. Any process that can open
haptic.sockcan already openagent.sock. What does carry over is scope: this endpoint plays waveforms and lists what's buzzable, nothing else.Out-of-band plays re-arm the firmware if the last check on that route is over a minute old. Some power transitions clear the haptic engine, after which
playis accepted and does nothing; a per-call check would double the HID++ traffic of a steady caller.Changes
openlogi-core—hid::HapticWaveform(append-only wire enum);paths::haptic_socket_path;app_settings.haptic_api, off by default.openlogi-hid—play_haptic*take the wire type and map to the firmware ID; thehidppwaveform is now an implementation detail ofwrite/haptic.rs.openlogi-ipc—Agent::play_haptic(Option<DeviceRoute>, HapticWaveform),PROTOCOL_VERSION23 → 24, wire goldens;examples/haptic.rsas a reference client.openlogi-agent-core—Orchestrator::haptic_route/haptic_route_for_key/haptic_devices;AgentDevicegains a displayname.openlogi-agent—json_api.rs(protocol, listener, connection loop);RingHapticPlayer::play_externalwith the re-arm window; the RPC handler; the mock answers both.openlogi-desktop/openlogi-ui— a Haptics API switch in General settings, plus the two new keys across all 20 locale catalogs.docs —
HAPTIC-API.md,CONFIGURATION.md,config.example.toml.Testing
Full local gate, green on the final tree:
New tests: the connection loop driven over an in-memory pipe (several requests
share a connection; a malformed line is answered without closing it; blank lines
are skipped; an over-long line is refused and closes the socket; the length cap
applies per line, not per connection — that last one was a real bug, and it fails
if the cap is moved back onto the stream); orchestrator route resolution (offline reports
DeviceNotFound, notFeatureUnsupported— one is worth retrying and the other never is; unprobed capabilities read as "no haptics"), JSON protocol parsing (unknown command and unknown waveform are rejected rather than silently defaulted),idecho/omission, error-code distinctness, wire goldens, config default-off.Runtime-verified on an MX Master 4 — haptics fire as expected.
0x19b0isreverse-engineered, so this is the confirmation that matters; devices without the
feature are refused with
feature_unsupportedrather than silently doing nothing.To reproduce against a running agent:
Notes for review
PROTOCOL_VERSIONis bumped and the goldens regenerated, per.claude/rules/ipc-protocol.md.json_api::bindis cfg-gated, so it was cross-linted with the repo's own recipe(
devenv.nix'sopenlogi:check-windows, replicated viarustup target add x86_64-pc-windows-gnu) — clean. CI's nativeclippy (windows)still has final say.agent.sockbeside it: the runtime directory's permissions on Unix,the default pipe DACL on Windows (creating user and administrators). Fine for a
single-user desktop; neither isolates users on a shared machine. Tightening either is
a hardening point, deliberately not claimed here.