Skip to content

feat(agent): expose device haptics to third-party apps - #712

Open
kacper-serewis wants to merge 5 commits into
AprilNEA:masterfrom
kacper-serewis:feat/haptic-api
Open

feat(agent): expose device haptics to third-party apps#712
kacper-serewis wants to merge 5 commits into
AprilNEA:masterfrom
kacper-serewis:feat/haptic-api

Conversation

@kacper-serewis

@kacper-serewis kacper-serewis commented Aug 20, 2026

Copy link
Copy Markdown

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_haptic on the tarpc contract (protocol v24), for callers that already speak it.
  • An opt-in JSON API on a local socket for callers that are not Rust — newline-delimited request/response, documented in 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 hidpp fork. 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-hid maps it to the firmware playWaveform ID 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.
  • A request arriving while another is mid-flight replaces any still-unplayed one. A stale buzz is worse than no buzz.

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.sock can already open agent.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 play is accepted and does nothing; a per-call check would double the HID++ traffic of a steady caller.

Changes

openlogi-corehid::HapticWaveform (append-only wire enum); paths::haptic_socket_path; app_settings.haptic_api, off by default.

openlogi-hidplay_haptic* take the wire type and map to the firmware ID; the hidpp waveform is now an implementation detail of write/haptic.rs.

openlogi-ipcAgent::play_haptic(Option<DeviceRoute>, HapticWaveform), PROTOCOL_VERSION 23 → 24, wire goldens; examples/haptic.rs as a reference client.

openlogi-agent-coreOrchestrator::haptic_route / haptic_route_for_key / haptic_devices; AgentDevice gains a display name.

openlogi-agentjson_api.rs (protocol, listener, connection loop); RingHapticPlayer::play_external with 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.

docsHAPTIC-API.md, CONFIGURATION.md, config.example.toml.

Testing

Full local gate, green on the final tree:

cargo fmt --all -- --check                              # ok
cargo clippy --workspace --all-targets -- -D warnings   # ok
cargo test --workspace                                  # ok
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --document-private-items \
  --exclude openlogi-ui --exclude openlogi-desktop --exclude openlogi-overlay --exclude openlogi-agent
                                                        # ok

cargo clippy --target x86_64-pc-windows-gnu \
  -p openlogi-core -p openlogi-hidpp -p openlogi-hid -p openlogi-hook \
  -p openlogi-agent -p openlogi-agent-core --all-targets -- -D warnings
                                                        # ok (cfg-gated bind)

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, not FeatureUnsupported — 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), id echo/omission, error-code distinctness, wire goldens, config default-off.

Runtime-verified on an MX Master 4 — haptics fire as expected. 0x19b0 is
reverse-engineered, so this is the confirmation that matters; devices without the
feature are refused with feature_unsupported rather than silently doing nothing.

To reproduce against a running agent:

SOCK="${XDG_RUNTIME_DIR:-$HOME/.config}/openlogi/haptic.sock"
echo '{"cmd":"devices"}' | socat - "UNIX-CONNECT:$SOCK"
echo '{"cmd":"play","waveform":"damp"}' | socat - "UNIX-CONNECT:$SOCK"

Notes for review

  • PROTOCOL_VERSION is bumped and the goldens regenerated, per .claude/rules/ipc-protocol.md.
  • json_api::bind is cfg-gated, so it was cross-linted with the repo's own recipe
    (devenv.nix's openlogi:check-windows, replicated via rustup target add x86_64-pc-windows-gnu) — clean. CI's native clippy (windows) still has final say.
  • Access matches agent.sock beside 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.
  • The mock agent serves the tarpc RPC but not the JSON socket — worth a follow-up if hardware-free integration work becomes common.

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
kacper-serewis force-pushed the feat/haptic-api branch 2 times, most recently from d271e24 to 57ed867 Compare August 20, 2026 11:59
@kacper-serewis
kacper-serewis marked this pull request as ready for review August 20, 2026 12:03
@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown

Greptile Summary

The PR exposes device haptics through tarpc and an opt-in newline-delimited JSON socket, routing both through the Actions Ring’s single-flight worker.

  • Adds haptic device discovery, route resolution, waveform mapping, and protocol version 24.
  • Adds the local JSON API, configuration toggle, documentation, examples, localization, and wire/framing tests.
  • Revises external firmware-arm tracking so freshness begins only after successful completion and queued arms are drained before playback.

Confidence Score: 4/5

The 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 pending_arm and a different route in the captured play request, producing an accepted but potentially silent haptic.

Files Needing Attention: crates/openlogi-agent/src/server.rs

Important Files Changed

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
Loading

Reviews (4): Last reviewed commit: "Merge branch 'master' into feat/haptic-a..." | Re-trigger Greptile

Comment thread crates/openlogi-agent/src/server.rs Outdated
Comment thread crates/openlogi-agent/src/server.rs Outdated
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.
@davidbudnick davidbudnick added type: feature New feature request platform: all Cross-platform issue labels Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

platform: all Cross-platform issue type: feature New feature request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants