Skip to main content
Version: 3.5.x

08. Remote Session Configurator

Reconfigure a session that is already running somewhere else — another app, a Unity scene, a Haply Hub demo — by firing HTTP REST calls against its device. No WebSocket is opened by this tutorial: just GETs, POSTs, and DELETEs to change basis, workspace preset, or mount transform while the other app keeps rendering haptics.

Use cases

  • Live-tweak a running demo. Start the Haply Hub Orb demo, then run this tutorial in a second terminal to swap the basis permutation, change the workspace preset, or nudge the mount transform — the Orb's coordinate frame shifts immediately without stopping the demo.
  • Per-user workspace calibration. Keep the haptic scene running on the main machine and let an operator on the same network push a mount offset / rotation / scale so the virtual workspace lines up with the user's desk.
  • Option menu with device selection. The same HTTP helpers can query GET /devices (see Tutorial 00) to enumerate devices and build an interactive menu — pick a device, then reconfigure it — without touching the session's WebSocket. The tutorial queries /sessions and hard-codes *inverse/0, but swapping to a /devices-driven picker is a local change.
  • Scripted reconfiguration. Automate pre-flight steps (set basis + preset + mount) before a session starts recording, without embedding the setup in every client.

Prerequisites

Tutorial 08 reconfigures a session that is already running. You need any active haptic session — another tutorial, a Unity scene, or a Haply Hub demo.

Quickest way to get a session running

Open Haply Hub and launch the Orb demo, then target it directly:

./08-haply-inverse-http-remote-config --session co.haply.hub::demo-orb
python 08-haply-inverse-http-remote-config.py --session "co.haply.hub::demo-orb"

The Orb scene renders a sphere in the device workspace. Cycling basis, preset, or nudging the mount transform with Tutorial 08 will visually shift the Orb's coordinate frame in real time.

Usage

# Pick a session interactively (lists every session the service knows)
./08-haply-inverse-http-remote-config
python 08-haply-inverse-http-remote-config.py

# Target the Haply Hub Orb demo directly
./08-haply-inverse-http-remote-config --session co.haply.hub::demo-orb
python 08-haply-inverse-http-remote-config.py --session "co.haply.hub::demo-orb"

# Target one directly by selector
./08-haply-inverse-http-remote-config --session :my_profile:0
python 08-haply-inverse-http-remote-config.py --session "#42"

# Or by a wildcard profile pattern (first match) — handy when the exact profile is unknown
./08-haply-inverse-http-remote-config --session "co.haply.hub::*:0"

The tutorial prints the session's current basis / preset / mount on startup and then waits for key presses — each press sends exactly one REST call.

Set a profile name in your simulation

Sessions without a profile name can only be targeted by numeric id — which changes every run. Have your main app call session.configure.profile.name on its first message, and you can reuse a stable selector like --session :my_profile:0 across every run. See Sessions — profile name.

Key bindings

KeyAction
BCycle basis permutation
PCycle workspace preset
W / E / RSelect mount edit mode — position (mm) / rotation (°) / scale (%)
/ Step −X / +X in the current mode
/ Step +Y / −Y in the current mode
Page Up / Page DownStep +Z / −Z in the current mode
= / -Uniform scale ± on all three axes at once (always available)
DeleteDELETE basis + preset + mount — revert to device defaults
HShow help
EscExit (Ctrl+C also works)

HTTP verbs — GET, POST, DELETE

The tutorial uses three HTTP verbs and only three of them. Every call returns the standard JSON envelope ({"ok": true, "data": {...}} on success, {"ok": false, "error": "..."} on failure) and one of three status codes: 200 success, 400 malformed request, 404 selector matched nothing.

VerbRolePaths used
GETRead current state — sessions listing, targeted session lookup, current config values/sessions, /sessions/<selector>, /<device_selector>/config/{basis,preset,mount}?session=...
POSTReplace a config value — body is JSON/<device_selector>/config/{basis,preset,mount}?session=...
DELETERevert a config value to the device default/<device_selector>/config/{basis,preset,mount}?session=...

HTTP helpers

A thin wrapper around the three verbs so the rest of the tutorial reads as business logic:

Python uses requests.Session() for HTTP keep-alive (cuts per-request latency from ~50 ms to ~5 ms):

http = requests.Session()

def api_get(path):
r = http.get(f"{BASE_URL}{path}", timeout=3)
return r.json() if r.status_code == 200 else None

def api_post(path, body):
r = http.post(f"{BASE_URL}{path}", json=body, timeout=3)
return r.json() if r.status_code == 200 else None

def api_delete(path):
r = http.delete(f"{BASE_URL}{path}", timeout=3)
return r.json() if r.status_code == 200 else None

def session_url(endpoint):
return f"{endpoint}?session={session_selector}"

Session discovery — GET /sessions

Branches on --session:

  • --session SELECTOR given → one GET /sessions/<SELECTOR>. 200 → use it; 404 → error out.
  • No flagGET /sessions (listing) → render sessions with profile names → prompt for an index → build the final selector (prefer :profile:0 when available; fall back to #id).

SELECTOR accepts every form defined in Selectors — session selector: :profile:instance, #id, :-1, :0, plain profile name, or a profile-name wildcard pattern like co.haply.hub::*:0. The tutorial forwards the string verbatim; the service parses it.

def discover_session(session_arg):
global session_selector

if session_arg:
# Direct lookup (e.g. ":my_profile:0", "#42", ":-1")
if api_get(f"/sessions/{session_arg}") is None:
return False
session_selector = session_arg
return True

# Otherwise: list and pick
data = api_get("/sessions")
sessions = data.get("data", {}).get("sessions", [])
for i, s in enumerate(sessions):
name = s.get("config", {}).get("profile", {}).get("name", "default")
print(f" [{i}] session #{s['session_id']} profile={name}")

picked = sessions[int(input("Pick session index: "))]
name = picked.get("config", {}).get("profile", {}).get("name", "")
# Prefer the profile selector — it survives restarts; id doesn't
session_selector = (f":{name}:0" if name and name != "default"
else f"#{picked['session_id']}")
return True

Device selector — *inverse/0

Every config call is scoped to a device. The tutorial uses a family wildcard + index selector:

/*inverse/0/config/<key>
  • *inverse matches any device in the Inverse family (inverse3, inverse3x, minverse) — the tutorial works unchanged regardless of the specific model.
  • 0 is the 0-based index into that family — the tutorial only ever touches the first Inverse.

Retargeting is one string change:

/verse_grip/0/config/basis?session=... # target first wired VerseGrip
/*verse_grip/*/config/basis?session=... # target every grip, wired + wireless
/inverse3/A14/config/mount?session=... # target Inverse3 with id A14

See Selectors — device selector for the full grammar. To build a device-picker menu instead of hard-coding, enumerate with GET /devices?session=<selector> (Tutorial 00) and wire the chosen device_id into the config paths.

POST config — basis, preset, mount

Three keys, same request shape, different body schema. Every POST returns a 200 with the resulting value in data, or 404 if the session/device selector matched nothing.

Basis

POST /*inverse/0/config/basis?session=:my_profile:0
Content-Type: application/json

{"permutation": "XZY"}

Response: {"ok": true, "data": {"permutation": "XZY"}}

def post_basis():
perm, _ = BASIS_OPTIONS[basis_index]
api_post(session_url("/inverse3/0/config/basis"), {"permutation": perm})

Preset

POST /*inverse/0/config/preset?session=:my_profile:0
Content-Type: application/json

{"preset": "arm_front_centered"}

Response: {"ok": true, "data": {"preset": "arm_front_centered"}}

def post_preset():
preset = PRESET_OPTIONS[preset_index]
api_post(session_url("/inverse3/0/config/preset"), {"preset": preset})

Mount

POST /*inverse/0/config/mount?session=:my_profile:0
Content-Type: application/json

{
"transform": {
"position": {"x": 0.02, "y": 0.0, "z": 0.0},
"rotation": {"w": 0.966, "x": 0.0, "y": 0.259, "z": 0.0},
"scale": {"x": 1.0, "y": 1.0, "z": 1.0}
}
}

Response: {"ok": true, "data": {"transform": { ... }}} — echoes the effective transform after normalisation.

def post_mount():
body = {
"transform": {
"position": {"x": mount_pos[0], "y": mount_pos[1], "z": mount_pos[2]},
"rotation": quat_from_euler_deg(*mount_rot),
"scale": {"x": mount_scale[0], "y": mount_scale[1], "z": mount_scale[2]},
}
}
api_post(session_url("/inverse3/0/config/mount"), body)
mount and preset are mutually exclusive

Posting one clears the other on the device. The tutorial doesn't track this explicitly — each POST is self-contained, and the server resolves the conflict. See Tutorial 07 for the same rule from the WebSocket side.

DELETE reset — three calls

reset fires one DELETE per config key. Each returns 200 with the now-default value in data.

def reset_all():
api_delete(session_url("/inverse3/0/config/basis"))
api_delete(session_url("/inverse3/0/config/preset"))
api_delete(session_url("/inverse3/0/config/mount"))

Composing the mount rotation

transform.rotation is a unit quaternion on the wire. The tutorial stores rotation as a Z-Y-X intrinsic Euler triple (pitch around X, yaw around Z, roll around Y — all degrees) and recomposes the quaternion on every POST.

def quat_from_euler_deg(pitch_x, yaw_z, roll_y):
"""Hamilton quaternion for q = q_z * q_y * q_x (apply X, then Y, then Z)."""
hx, hy, hz = (math.radians(a) * 0.5 for a in (pitch_x, roll_y, yaw_z))
cx, sx = math.cos(hx), math.sin(hx)
cy, sy = math.cos(hy), math.sin(hy)
cz, sz = math.cos(hz), math.sin(hz)
return {
"w": cz*cy*cx + sz*sy*sx,
"x": cz*cy*sx - sz*sy*cx,
"y": cz*sy*cx + sz*cy*sx,
"z": sz*cy*cx - cz*sy*sx,
}
Quaternion convention

Unit Hamilton quaternion, right-handed, scalar-first (w) — same convention as the rest of the service, see quaternion. Composition order is Z-Y-X intrinsic (q = q_z * q_y * q_x): apply pitch around X first, then roll around Y, then yaw around Z.

The tutorial prints the derived quaternion alongside the Euler triple on every status line so you can verify the composition before the device rotates. The local Euler state starts at (0, 0, 0) regardless of what the session already has — the first mount POST overrides whatever was there.

Input model (brief)

The HTTP wiring is the point; the keyboard UX is secondary. Two deliberate shortcuts:

  • Python uses the keyboard package — cross-platform, handles key-hold repeats natively. Arrow keys, Page Up / Page Down, and = / - step the mount axes while held; B and P cycle basis and preset on rising edge.
  • C++ uses std::getline(std::cin, ...) and a compact token grammar (x+20, sx-5, u+10) — less ergonomic for continuous tweaks, but portable without #ifdef-ing per-platform console APIs.

Source

Shipping with the SDK installer

Tutorial 08 is also installed locally with the SDK — look in tutorials/08-haply-inverse-http-remote-config/ under the service install directory.

Related: Sessions — Remote control · Selectors · Device Configuration · Basis Permutation · Mount & Workspace · JSON Conventions · Tutorial 00 — Device List · Tutorial 07 — Basis & Mount (WebSocket version)