# OpenLogi tarpc/bincode IPC Protocol Contract: Desktop-to-Agent Communication

> Explore the tarpc bincode IPC protocol contract for OpenLogi desktop-to-agent communication. Understand the Agent trait and PROTOCOL_VERSION for seamless integration.

- Repository: [Xuan Zhang/OpenLogi](https://github.com/AprilNEA/OpenLogi)
- Tags: api-reference
- Published: 2026-08-23

---

**OpenLogi's desktop application communicates with its background agent through a tarpc RPC service using bincode serialization, defined by the `Agent` trait in [`crates/openlogi-ipc/src/ipc.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-ipc/src/ipc.rs) and governed by a strict `PROTOCOL_VERSION` constant that must match exactly between client and server.**

OpenLogi relies on a deterministic binary IPC protocol to synchronize device state and commands between its desktop UI and the background agent process. This article examines the complete tarpc/bincode contract implemented in the `AprilNEA/OpenLogi` repository, detailing the versioned wire format, generation-based state observation, and the specific RPC methods that enable real-time device management across UNIX domain sockets.

## Protocol Foundation: The Agent Trait and Wire Format

The entire IPC contract lives in the `Agent` trait defined in **[`crates/openlogi-ipc/src/ipc.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-ipc/src/ipc.rs)**. Because tarpc encodes RPC calls as method indices based on trait definition order, the sequence of methods in this file constitutes the wire format. Changing method order breaks binary compatibility, making this trait the canonical source of truth for the protocol.

The trait defines a **protocol version** constant that both sides check before accepting connections:

```rust
pub const PROTOCOL_VERSION: u32 = 26;

```

This value increments for every breaking change to the wire format. The GUI validates this version immediately after connecting, aborting with an `OutdatedGui` error if the agent reports a newer version.

### Handshake and Method Ordering Constraints

The `protocol_version()` method must remain **first** in the trait definition because tarpc assigns method indices sequentially. The desktop client calls this immediately after connecting to verify compatibility:

```rust
async fn protocol_version() -> u32;

```

If this method were moved, the binary encoding would shift, causing immediate protocol mismatch between old clients and new agents.

## State Synchronization via Generation-Based Observation

OpenLogi uses a **generation-based long-polling** pattern to keep the UI synchronized with agent state without websockets or streaming complexity.

### The observe Method and OBSERVE_HOLD

The core state channel is the `observe` method:

```rust
async fn observe(since: Generation) -> Observation;

```

The agent holds this request for up to **`OBSERVE_HOLD`** (20 seconds) before returning:

```rust
pub const OBSERVE_HOLD: Duration = Duration::from_secs(20);

```

If the state changes during this window, the agent returns immediately with the new `AgentSnapshot` and an incremented `Generation` value. If the timeout expires, it returns the unchanged state as a heartbeat, allowing the client to detect connection health while minimizing CPU usage.

The desktop implementation in **[`crates/openlogi-desktop/src/services/ipc.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-desktop/src/services/ipc.rs)** maintains a single `observe` request in flight continuously, updating the UI only when the returned generation differs from the last seen value.

### Atomic State Snapshots

To avoid race conditions between separate status and inventory calls, the protocol includes an atomic `snapshot` RPC added in protocol v4:

```rust
async fn snapshot() -> AgentSnapshot;

```

This returns the complete agent status and device inventory in a single call, ensuring consistency when the UI initializes or recovers from reconnection.

## Device Control and Configuration RPCs

Beyond state observation, the `Agent` trait exposes fire-and-forget methods for hardware control, all returning `Result<(), WriteError>`:

```rust
async fn set_dpi(route: DeviceRoute, dpi: Dpi) -> Result<(), WriteError>;
async fn set_lighting(route: DeviceRoute, lighting: Lighting) -> Result<(), WriteError>;
async fn set_smartshift(route: DeviceRoute, enabled: bool) -> Result<(), WriteError>;
async fn set_light(enabled: bool) -> Result<(), WriteError>;

```

These methods accept strongly-typed parameters defined in **`crates/openlogi-core/src/`**, such as `DeviceRoute` and `Dpi`, which are serialized via bincode into compact binary messages.

### Configuration Hot-Reload

The UI can request the agent to re-parse its [`config.toml`](https://github.com/AprilNEA/OpenLogi/blob/main/config.toml) without restart:

```rust
async fn reload_config() -> Result<(), ConfigReloadError>;

```

This enables the settings UI to apply changes immediately while maintaining the long-lived agent process.

## Pairing and Overlay Workflows

The protocol supports complex multi-step workflows for pairing Logi Bolt receivers and managing the Actions-Ring overlay.

### Bolt Receiver Pairing

The pairing workflow uses three primary RPCs:

```rust
async fn start_pairing(selector: ReceiverSelector) -> Result<(), PairingCommandError>;
async fn pair_device(pin: String) -> Result<(), PairingCommandError>;
async fn cancel_pairing() -> Result<(), PairingCommandError>;

```

Progress streams through the same `observe` state channel via `AgentSnapshot::pairing`, though legacy implementations may poll the deprecated `next_pairing()` method.

### Actions-Ring Overlay State

For the overlay helper process, the protocol exposes a parallel generation-based observer:

```rust
async fn observe_action_ring(since: Generation) -> RingObservation;
async fn action_ring_hover(index: u8);
async fn action_ring_activate();
async fn action_ring_cancel();

```

This allows the overlay to maintain synchronized state with the agent independently of the main desktop UI.

## Error Handling with Append-Only Enums

The protocol maintains backwards compatibility through **append-only error enums**. New failure modes are added as variants without removing existing ones:

- `WriteError` for device command failures
- `PairingCommandError` for pairing workflow errors
- `ActionRingCommandError` for overlay failures
- `PairingFailure` for specific pairing step failures

This strategy ensures that old GUI binaries can still deserialize responses from newer agents, even if they cannot handle new error variants explicitly.

## Desktop Client Implementation Reference

The client-side contract implementation resides in **[`crates/openlogi-desktop/src/services/ipc.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-desktop/src/services/ipc.rs)**. This module handles:

1. **Connection establishment** via `openlogi_ipc::client::connect()` to the agent's UNIX socket
2. **Version validation** in the `ensure()` function, which compares the agent's reported version against `PROTOCOL_VERSION`
3. **Long-poll management** through `observe_loop`, which maintains the continuous `observe` request and converts `Observation` structs into `GuiUpdate` events
4. **Command dispatch** for device control RPCs, mapping transport errors to reconnection logic and device errors to UI notifications
5. **Identity tracking** via the `identity()` RPC to detect agent restarts and trigger full state resynchronization

The generated tarpc client stub implements the `Agent` trait methods, handling bincode serialization and socket transport transparently.

## Code Examples

### Connecting and Verifying Protocol Version

```rust
use openlogi_ipc::client;
use tarpc::context;

#[tokio::main]
async fn main() {
    // Connect (handshake includes protocol version)
    let conn = client::connect().await.expect("cannot reach agent");
    let version = conn.client.protocol_version(context::current()).await
        .expect("rpc error");
    println!("Agent protocol version = {}", version);
}

```

### Observing State Changes with Generation Tracking

```rust
use openlogi_ipc::{AgentClient, Observation, Generation, OBSERVE_HOLD};
use tarpc::context;
use std::time::Instant;

async fn observe_state(mut client: AgentClient) {
    let mut seen: Generation = 0;
    loop {
        let mut ctx = context::current();
        ctx.deadline = Instant::now() + OBSERVE_HOLD + std::time::Duration::from_secs(5);
        match client.observe(ctx, seen).await {
            Ok(obs) => {
                // New generation → new snapshot
                if obs.generation != seen {
                    println!("New snapshot: {:?}", obs.snapshot);
                    seen = obs.generation;
                }
            }
            Err(_) => {
                eprintln!("Lost connection – reconnecting");
                break; // in production code you would retry
            }
        }
    }
}

```

### Changing Device DPI

```rust
use openlogi_ipc::client;
use openlogi_core::hid::{DeviceRoute, Dpi};
use tarpc::context;

async fn set_device_dpi(route: DeviceRoute, dpi: Dpi) {
    let conn = client::connect().await.expect("no agent");
    let res = conn.client.set_dpi(context::current(), route, dpi).await;
    match res {
        Ok(Ok(())) => println!("DPI applied"),
        Ok(Err(e)) => eprintln!("Device rejected DPI: {e:?}"),
        Err(_) => eprintln!("Agent unavailable"),
    }
}

```

### Initiating Bolt Pairing

```rust
use openlogi_ipc::client;
use openlogi_core::hid::ReceiverSelector;
use tarpc::context;

async fn start_pairing(selector: ReceiverSelector) {
    let conn = client::connect().await.unwrap();
    // Initiate pairing
    conn.client.start_pairing(context::current(), selector).await.unwrap();

    // Stream pairing steps – legacy `next_pairing` (still part of the wire format)
    loop {
        match conn.client.next_pairing(context::current()).await {
            Some(update) => println!("Pairing step: {:?}", update),
            None => break, // hold window elapsed with no new event
        }
    }
}

```

## Summary

- **The contract** is defined by the `Agent` trait in [`crates/openlogi-ipc/src/ipc.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-ipc/src/ipc.rs), where method order determines the tarpc wire encoding.
- **Version compatibility** is enforced through the `PROTOCOL_VERSION` constant (currently `26`), checked immediately upon connection in the desktop client's `ensure()` function.
- **State synchronization** uses generation-based long-polling via the `observe` method with a 20-second `OBSERVE_HOLD` timeout, returning `AgentSnapshot` data when generations change.
- **Device commands** are strongly-typed RPCs like `set_dpi` and `set_lighting` that return `Result<(), WriteError>` for immediate feedback.
- **Backwards compatibility** is maintained through append-only error enums and strict protocol versioning, allowing independent updates of the agent and desktop components.

## Frequently Asked Questions

### What serialization format does OpenLogi use for IPC?

OpenLogi uses **bincode** for binary serialization of all RPC messages. The tarpc framework handles the transport layer, while bincode provides compact, deterministic encoding for the `Agent` trait's method arguments and return types.

### Why must the `protocol_version` method remain first in the `Agent` trait?

Tarpc encodes RPC calls as method indices based on the trait definition order. Since the desktop client calls `protocol_version()` immediately after connecting to verify compatibility, moving this method would shift its index in the wire format, causing existing binaries to call the wrong method. The source code in [`ipc.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/ipc.rs) contains explicit comments warning that this method must stay first to preserve binary compatibility.

### How does the generation-based observation pattern prevent missed state updates?

The `observe` RPC accepts a `since: Generation` parameter and blocks until the agent's internal generation counter exceeds this value. When the agent state changes, it increments the generation and immediately returns the new `Observation` to all waiting clients. If no change occurs within `OBSERVE_HOLD` (20 seconds), the agent returns the current state anyway, functioning as a heartbeat that allows clients to detect stalled connections while maintaining low latency for updates.

### What happens if the desktop GUI and agent have mismatched protocol versions?

The desktop client checks the agent's reported version in the `ensure()` function within **[`crates/openlogi-desktop/src/services/ipc.rs`](https://github.com/AprilNEA/OpenLogi/blob/main/crates/openlogi-desktop/src/services/ipc.rs)**. If the agent returns a version higher than the client's `PROTOCOL_VERSION` constant, the client emits `GuiUpdate::OutdatedGui` and refuses to proceed, preventing undefined behavior from protocol mismatches and prompting the user to update the application.