haply 1.3.1

Haply Robotics Client Library for the Inverse Service
Documentation
# haply


[![Crates.io](https://img.shields.io/crates/v/haply.svg)](https://crates.io/crates/haply)
[![License](https://img.shields.io/crates/l/haply.svg)](https://crates.io/crates/haply)

Rust client library for the [Haply Inverse Service](https://www.haply.co/) (v3.5+), covering ~98% of the WebSocket and HTTP APIs for controlling Haply haptic devices.

```toml
[dependencies]
haply = "0.8"
tokio = { version = "1", features = ["full"] }
```

## Features


- **Real-time haptic control** via WebSocket -- per-tick force, position, and torque commands with streaming device state
- **Device configuration** -- preset, basis, mount, damping, force gate, handedness, gravity compensation
- **Session management** -- profile naming, version constraints, basis configuration
- **Bubble navigation** -- rate-control locomotion with current 3.5 schema (shape, center behavior, springs, damping, collision detection)
- **Full HTTP REST client** -- device queries, sessions, settings, device config, filters, grip pairing, navigation
- **Event channel** -- real-time device connect/disconnect and system events over a dedicated WebSocket
- **Strongly typed** -- all wire types derive `Serialize`, `Deserialize`, and `TS` ([ts-rs]https://crates.io/crates/ts-rs) for TypeScript generation

## Quick start


```rust
use haply::HaplyDevice;
use haply::device_model::{ForceInput, Force};

#[tokio::main]

async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let mut device = HaplyDevice::new(
        "http://localhost:10001",
        "ws://localhost:10001/",
    ).await?;

    // Read device state from the WebSocket stream
    let state = device.read_state().await?;
    println!("Session {}, {} device(s)", state.session_id, state.inverse3.len());

    // Send zero force to the first Inverse3
    if let Some(inv) = state.inverse3.first() {
        device.update_force(vec![ForceInput {
            device_id: inv.device_id.clone(),
            forces: Force { x: 0.0, y: 0.0, z: 0.0 },
        }], Some(true), Some(true)).await?;
    }

    device.shutdown().await?;
    Ok(())
}
```

## Examples


Run any example with `cargo run --example <name>`. Each example has a `const USE_HTTP: bool` toggle to switch between HTTP and WebSocket paths.

| Example | Description | Flags |
|---------|-------------|-------|
| `device_basics` | Device discovery, state streaming, zero-force loop | `--http-only`, `--stream`, `--zero-force`, `--probe <ID>` |
| `haptic_sample` | Force feedback from virtual sphere + cube objects | |
| `profile_demo` | Set and verify session profile naming | |
| `ping_demo` | WS keepalive with live device discovery | |
| `navigation_demo` | Bubble navigation with cursor + workspace position streaming | |
| `damping_demo` | Damping filter with position/velocity/orientation streaming | |
| `events_demo` | Event channel polling (device connect/disconnect) | `POLL_RATE` env var |

All examples default to `localhost:10001`. For local development without hardware, start the included mock service first:

```sh
cd haply-service-mock && cargo run
```

## API coverage


### WebSocket


`HaplyDevice` provides convenience methods for all 3.5 WebSocket operations:

| Category | Methods |
|----------|---------|
| Force / Position | `update_force`, `update_position`, `update_angular_torques`, `update_angular_position` |
| Probes | `probe_cursor_position`, `probe_orientation` |
| Extension data | `update_extension_data` |
| Device config | `configure_inverse3`, `configure_versegrip` |
| Session config | `configure_session` |
| Session | `send_force_full_render`, `send_ping` |
| State | `read_state`, `force_read_full_state` |

For advanced use, construct `Command` enum variants directly and call `update_command_msg` + `send_command` for full control over frame composition.

### HTTP


`InverseHttpClient` covers the full 3.5 REST API:

| Category | Endpoints |
|----------|-----------|
| System | `get_version`, `get_expert_status`, `get_devices`, `get_devices_for_session` |
| Sessions | `get_sessions`, `get_session`, `get/set/delete_session_profile` |
| Settings | `get_settings`, `set_settings`, `get/set/delete_setting` |
| Device config | `handedness`, `torque_scaling`, `gravity_compensation`, `home_return` |
| Session-scoped | `basis`, `mount` (+PATCH), `preset`, `state_transform` (+PATCH) |
| Filters | `filters_damping`, `filters_force_gate` |
| Navigation | `get/set/delete_navigation` |
| Grip pairing | `get/set/delete_paired_with` |
| Utility | `reset_port`, `save_configuration` |

Session-scoped HTTP routes use `?session=<expr>` selectors (for example `:-1`, `#5`, `:default:0`) as the canonical crate format. Some service compatibility paths may also accept `session_id`, but this crate treats `session` selectors as the primary interface.

## Verse grip duplicate handling


Service 3.5 reports certain devices (e.g. the Ruko controller) in both `wireless_verse_grip` and `custom_verse_grip` with the same device ID. The wireless view includes Ruko-specific fields (`trigger`, `wheel`, directional buttons), while the custom view includes `extension_data` and generic `a/b/c` buttons.

By default, duplicates are resolved by keeping the custom view. You can change this at construction time:

```rust
use haply::{HaplyDevice, VerseGripDuplicateMode};

// Default: keep custom_verse_grip, hide wireless duplicates
let device = HaplyDevice::new("http://localhost:10001", "ws://localhost:10001/").await?;

// Keep wireless_verse_grip instead (exposes trigger, wheel, Ruko buttons)
let device = HaplyDevice::with_options(
    "http://localhost:10001",
    "ws://localhost:10001/",
    VerseGripDuplicateMode::PreferWireless,
).await?;

// Keep both views (no deduplication)
let device = HaplyDevice::with_options(
    "http://localhost:10001",
    "ws://localhost:10001/",
    VerseGripDuplicateMode::KeepBoth,
).await?;
```

The same option is available on the standalone HTTP client:

```rust
use haply::{http::InverseHttpClient, VerseGripDuplicateMode};

let http = InverseHttpClient::with_options(
    "http://localhost:10001",
    VerseGripDuplicateMode::PreferWireless,
);
```

The chosen mode applies consistently across both WebSocket state updates and HTTP device queries.

## Testing


```sh
cargo test              # all tests (unit + integration)
cargo test --lib        # unit tests only
cargo test --test integration_test  # integration tests against mock
```

The test suite includes serialization round-trips, wire format golden tests, command building, state merging, and end-to-end integration tests against the included mock service.

## Profiling (Tracy)


Tracy instrumentation is available behind the `tracy` feature. Existing `log` calls are forwarded into Tracy through a `tracing` bridge in the `test_tracy` example which recreates the same haptics as in `haptic_sample`.

```sh
cargo run --example test_tracy --features tracy
```

## Project structure


```text
src/
  device.rs               HaplyDevice high-level API
  state.rs                State management + command building
  device_model/           Wire types (devices, commands, enums, primitives, navigation)
  interfaces/http/        HTTP client (system, sessions, settings, device config)
  interfaces/websocket.rs WebSocket client
  interfaces/events.rs    Event channel
  physics.rs              Demo physics helpers (Sphere, Cube)
haply-service-mock/       Mock service for local development
examples/                 8 example apps
tests/                    Integration tests
```

## Coverage gaps


The following 3.5 service features are intentionally not implemented:

- **v3.0 legacy endpoints** (`GET /3.0/sessions`, deprecated `POST /force_scale`, etc.) -- use the current v3.1/3.5 equivalents
- **HTTP dry-run** (`execute: false` on config POST bodies) -- use the WebSocket `execute` field instead
- **Typed selector builders** -- device and session selectors are passed as `&str`, not typed enums

## Compatibility


> **Breaking change**: Starting with release 1.0.0, the crate targets **Haply Inverse Service v3.5+ exclusively**. Outgoing commands use 3.5 wire field names (`vector` instead of `values`, `probe_position` instead of `probe_cursor_position`) and are **not compatible with pre-3.5 services**. If you need to target an older service version, pin to a prior release of this crate from before 1.0.0.

- Incoming payloads from older services still deserialize correctly (all 3.5 fields default to `None`).

## Changelog


See [CHANGELOG.md](CHANGELOG.md) for release history.

## License


Dual-licensed under MIT or Apache-2.0.