Skip to content

thehandy-v3: hardcoded stop_on_target: true causes asymmetric rising/falling motion on Handy 2 #893

@tsunosekai

Description

@tsunosekai

Summary

When thehandy-v3 sends RequestHdspXpTSet with the current hardcoded stop_on_target: true, Handy 2 Standard and Handy 2 Pro exhibit asymmetric motion: rising direction completes noticeably faster than falling direction, even when given identical position deltas over identical durations. Original Handy (v1, using thehandy / handyplug.LinearCmd) is unaffected.

Environment

  • Intiface Central 4.x (bundling buttplug ~10.x)
  • thehandy-v3 protocol, hw_position_with_duration feature
  • Devices tested: The Handy 2 Standard (OHD_hw3_*), The Handy 2 Pro (OHD_hw4_*)
  • Client: custom WebSocket app sending HwPositionWithDuration (OutputCmd)

Steps to reproduce

  1. Connect a Handy 2 Standard or Pro via Intiface

  2. From the client, send the following sequence of HwPositionWithDuration commands (16 segments, mixed deltas and durations, including direction-symmetric pairs):

    fire time (s) position duration (ms) direction (vs prev)
    (initial sync) 100
    0.00 60 1000 ↓ Δ=40
    1.00 80 800 ↑ Δ=20
    1.80 60 1000 ↓ Δ=20
    2.80 100 1100 ↑ Δ=40
    3.90 80 500 ↓ Δ=20
    4.40 100 800 ↑ Δ=20
    5.20 60 1100 ↓ Δ=40
    6.30 100 900 ↑ Δ=40
    7.20 80 500 ↓ Δ=20
    7.70 100 500 ↑ Δ=20
    8.20 60 900 ↓ Δ=40
    9.10 90 700 ↑ Δ=30
    9.80 65 900 ↓ Δ=25
    10.70 90 900 ↑ Δ=25
    11.60 70 1100 ↓ Δ=20
    12.70 100 1000 ↑ Δ=30

    Notable symmetric pairs for direct A/B comparison:

    • Δ=40, dur=1100: rising (60→100) at t=2.80 ↔ falling (100→60) at t=5.20
    • Δ=20, dur=500: falling (100→80) at t=3.90 ↔ rising (80→100) at t=7.70
  3. Observe motion on the device

Expected

Symmetric pairs (same delta, same duration, opposite direction) feel perceptually equal in motion time.

Actual

Rising completes noticeably faster than falling for the symmetric pairs ("snap-back" feel on every ↑ stroke). Falling appears to honor the requested duration; rising appears to consume less than the requested duration.

Verification that client and buttplug are symmetric

Before filing, I traced the full pipeline using the test sequence above:

  • Client (verified by inspecting both the in-app engine log and the WebSocket frames sent to Intiface): emits identical position + duration for the symmetric pairs. All 16 segments matched expected values exactly.
  • crates/buttplug_server/src/device/protocol_impl/thehandy_v3/mod.rs: handle_hw_position_with_duration_cmd (L176–205) is stateless and has zero direction-dependent code. No delta/velocity computation; no v1/v2/v2pro variant branching (grep confirmed; hw3 and hw4 are only distinguished by display name in thehandy-v3.yml).
  • crates/buttplug_server/src/device/device_task.rs batching (Batch deadline reached, sending N commands log): direction-agnostic dedup by command_id.

Therefore the asymmetry must originate in the Handy 2 firmware's interpretation of the RequestHdspXpTSet { stop_on_target: true } command.

Root cause hypothesis

The hardcoded stop_on_target: true at L190:

params: Some(handy_rpc::request::Params::RequestHdspXpTSet(
  handy_rpc::RequestHdspXpTSet {
    stop_on_target: true,     // <-- hardcoded
    t: duration,
    xp: position as f32 / 100f32,
  },
)),

forces the firmware to plan a motion profile that decelerates to a stop at the target. The Handy 2 servo + mechanism appears to have asymmetric maximum deceleration between the lifting and lowering directions (likely mechanical: gravity, spring tension, sleeve mass). When the firmware tries to satisfy "reach xp at time t and stop there", the rising direction can stop sooner than the falling direction can, resulting in the rising stroke effectively finishing before the requested t.

Confirmation by patch

Changing L190 to stop_on_target: false, rebuilding intiface-engine (4.0.2 from current dev branch), and replaying the exact same test sequence above on a Handy 2 Standard: the rising/falling asymmetry is fully resolved — both directions now feel time-accurate. Continuous-motion use is unaffected for this client because the next segment is always sent before the device would coast off-target.

(Tested only on Handy 2 Standard so far; Handy 2 Pro was not re-tested with the patched build but uses the identical code path so the same result is expected.)

Suggested fix

Options, ordered by preference:

  1. Default to false for thehandy-v3 HwPositionWithDuration. Rationale: clients sending repeated HwPositionWithDuration generally expect time-accurate motion segments stitched together, not absolute target snapping. Devices that need to hold position at end of a stream can rely on the absence of further commands.
  2. Expose stop_on_target as a per-call option via a Buttplug message extension or device feature flag.
  3. Document the asymmetry and let downstream tools work around it.

Happy to submit a PR for (1) if that's the preferred direction.

Hardware tested

Device Protocol Behavior with stop_on_target: true Behavior with stop_on_target: false
The Handy 2 Standard (hw3) thehandy-v3 HDSP asymmetric (rising fast) ✓ reproduced symmetric ✓ verified
The Handy 2 Pro (hw4) thehandy-v3 HDSP asymmetric (rising fast) ✓ reproduced not re-tested with patched build (same code path as Standard)
The Handy v1 thehandy (handyplug.LinearCmd) unaffected (different code path) ✓ verified n/a

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions