跳到主要内容

08. 远程会话配置器

重新配置一个已在其他地方运行的会话——例如另一个应用、Unity 场景或 Haply Hub 演示——方法是对其设备发起 HTTP REST 调用。本教程不会打开任何 WebSocket:只需通过 GET、POST 和 DELETE 请求,即可在另一应用继续渲染触觉反馈的同时修改 basis、工作空间预设或 mount 变换。

使用场景​

  • 实时微调正在运行的演示。 启动 Haply Hub Orb 演示,然后在第二个终端中运行本教程,即可切换 basis 轴置换、更改工作空间预设或微调 mount 变换——Orb 的坐标系会立即变化,而演示无需停止。
  • 按用户进行工作空间校准。 让触觉场景在主机上保持运行,并允许同一网络中的操作员推送 mount 的偏移 / 旋转 / 缩放,使虚拟工作空间与用户的桌面对齐。
  • 带设备选择的选项菜单。 同样的 HTTP 辅助函数可以调用 GET /devices(参见 教程 00)来枚举设备并构建交互式菜单——选择一个设备后对其重新配置——而无需触及会话中的 WebSocket。本教程查询的是 /sessions 并硬编码了 *inverse/0,但改用由 /devices 驱动的选择器只是一处本地修改。
  • 脚本化重新配置。 在会话开始录制之前自动执行预检步骤(设置 basis + 预设 + mount),无需在每个客户端中嵌入这些设置。

前提条件​

教程 08 用于重新配置已在运行的会话。你需要任一处于活动状态的触觉会话——另一个教程、Unity 场景或 Haply Hub 演示。

最快启动会话的方法

打开 Haply Hub,启动 Orb 演示,然后直接将其作为目标:

./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"

Orb 场景会在设备工作空间中渲染一个球体。使用教程 08 循环切换 basis、预设或微调 mount 变换时,Orb 的坐标系会实时在视觉上发生偏移。

用法​

# 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"

# Pick a specific device on the session (default is *inverse/0)
./08-haply-inverse-http-remote-config --device 'inverse3/#A12' # by exact id
./08-haply-inverse-http-remote-config --device '*inverse:left/0' # first left-handed inverse
./08-haply-inverse-http-remote-config --device '*inverse:right/0' # first right-handed inverse
./08-haply-inverse-http-remote-config --device '*verse_grip/0' # first grip-family device

教程在启动时会打印会话当前的 basis / preset / mount,然后等待按键输入 — 每次按键恰好发送一次 REST 调用。

在仿真中设置 profile 名称

未设置 profile 名称的会话只能通过数字 id 指定 — 而该 id 每次运行都会变化。请让主应用在其第一条消息中调用 session.configure.profile.name,之后即可在每次运行中复用稳定的选择器,例如 --session :my_profile:0。参见 会话 — profile 名称。

按键绑定​

按键操作
B循环切换 basis 置换
P循环切换 工作空间预设
W / E / R选择挂载编辑模式 — 位置(mm)/ 旋转(°)/ 缩放(%)
← / →在当前模式下沿 −X / +X 步进
↑ / ↓在当前模式下沿 +Y / −Y 步进
Page Up / Page Down在当前模式下沿 +Z / −Z 步进
= / -三个轴同时统一缩放 ±(始终可用)
DeleteDELETE basis + preset + mount — 恢复为设备默认值
H显示帮助
Esc退出(Ctrl+C 也可以)

HTTP 动词 — GET, POST, DELETE​

本教程使用 HTTP 的三种动词,且仅使用这三种。每次调用都会返回标准的 JSON 响应包装格式(成功时为 {"ok": true, "data": {...}},失败时为 {"ok": false, "error": "..."}),并返回以下三种状态码之一:200 成功、400 请求格式错误、404 选择器未匹配到任何内容。

动词用途使用的路径
GET读取当前状态 — 会话列表、指定会话查询、当前配置值/sessions, /sessions/<selector>, /<device_selector>/config/{basis,preset,mount}?session=...
POST替换配置值 — 请求体为 JSON/<device_selector>/config/{basis,preset,mount}?session=...
DELETE将配置值恢复为设备默认值/<device_selector>/config/{basis,preset,mount}?session=...

HTTP 辅助函数​

这是对上述三种动词的轻量封装,使本教程其余部分读起来更像业务逻辑:

Python 使用 requests.Session() 实现 HTTP 长连接复用(可将单次请求延迟从约 50 ms 降至约 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}"

会话发现 — GET /sessions​

根据 --session 参数决定执行路径:

  • 提供了 --session SELECTOR → 发送一次 GET /sessions/<SELECTOR>。200 → 直接使用;404 → 报错退出。
  • 未提供参数 → GET /sessions(列表)→ 显示带 profile 名称的会话 → 提示输入索引 → 构建最终选择器(可用时优先使用 :profile:0,否则回退为 #id)。

SELECTOR 支持 选择器 — 会话选择器 中定义的所有形式::profile:instance、#id、:-1、:0、纯 profile 名称,或形如 co.haply.hub::*:0 的 profile 名称通配符 模式。本教程会原样转发该字符串,由服务端负责解析。

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

设备选择器 — *inverse/0​

每个配置调用都限定于某个设备。本教程使用系列通配符 + 索引形式的选择器:

/*inverse/0/config/<key>
  • *inverse 匹配 Inverse 系列中的任意设备(inverse3、inverse3x、minverse) — 无论具体是哪种型号,本教程都无需修改即可运行。
  • 0 是该系列中从 0 开始计数的索引 — 本教程只会操作第一台 Inverse 设备。

更换目标设备只需修改一个字符串 — 传入 --device 参数即可(路径由该参数生成):

/verse_grip/0/config/basis?session=... # --device verse_grip/0
/*verse_grip/0/config/basis?session=... # --device '*verse_grip/0' — first grip, any kind
/inverse3/A14/config/mount?session=... # --device 'inverse3/#A14'
/*inverse:left/0/config/basis?session=... # --device '*inverse:left/0' — first left-handed inverse
/*inverse:right/0/config/basis?session=... # --device '*inverse:right/0' — first right-handed inverse

:left / :right 类在索引之前生效——“*inverse:left/0”表示“第 0 个左手 inverse”,而不是“第 0 个 inverse,希望是左手的”。参见 选择器——类过滤器在索引前解析。

教程 08 在目标设备缺失时退出

教程在启动时会发出 GET …/config/basis 请求。如果会话中没有与 --device 匹配的设备(例如在仅有右手设备的环境中使用 --device '*inverse:left/0'),服务会返回 404 no device matches selector '<sel>';教程会打印 Device '<sel>' not found in session '<sel>'. 并以状态 1 退出。不会以过时的默认值进入键盘循环。

完整语法请参见 选择器——设备选择器。若要构建设备选择菜单而不是硬编码,请使用 GET /devices?session=<selector>(教程 00)枚举设备,并将所选的 device_id 写入配置路径。

POST 配置 — basis、preset、mount​

三个键的请求结构相同,但请求体结构不同。每个 POST 请求都会返回 200,并在 data 中包含结果值;如果会话或设备选择器没有匹配到任何对象,则返回 404。

Basis​

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

{"permutation": "XZY"}

响应:{"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"}

响应:{"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}
}
}

响应:{"ok": true, "data": {"transform": { ... }}} — 回显规范化之后生效的 transform。

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 与 preset 互斥

对其中一项执行 POST 会在设备上清除另一项。本教程并未显式跟踪这一点 — 每次 POST 都是独立的,由服务端负责解决冲突。关于同一规则从 WebSocket 端的角度说明,参见 教程 07。

DELETE 重置 — 三次调用​

reset 会为每个配置项各发出一次 DELETE 请求。每次均返回 200,并在 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"))

组合挂载旋转​

在传输时,transform.rotation 是一个单位四元数。本教程将旋转存储为 Z-Y-X 内旋欧拉角三元组(绕 X 轴的俯仰 pitch、绕 Z 轴的偏航 yaw、绕 Y 轴的横滚 roll,均以度为单位),并在每次 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,
}
四元数约定

单位哈密顿四元数,右手系,标量在前(w)——与服务中其他部分的约定一致,参见 quaternion。组合顺序为 Z-Y-X 内旋(intrinsic)(q = q_z * q_y * q_x):先绕 X 轴施加俯仰(pitch),再绕 Y 轴施加横滚(roll),最后绕 Z 轴施加偏航(yaw)。

教程会在每一行状态中同时输出推导得到的四元数和欧拉角三元组,以便你在设备转动前验证组合结果。无论会话中已有什么值,本地欧拉角状态都从 (0, 0, 0) 开始——第一次 mount POST 会覆盖原有的值。

输入模型(简要)​

HTTP 的连接逻辑才是重点,键盘交互只是次要的。这里有两处有意为之的简化:

  • Python 使用 keyboard 包——跨平台,原生处理按键长按重复。方向键、Page Up / Page Down 以及 = / - 会在按住期间逐步移动挂载轴;B 和 P 在上升沿循环切换基底和预设。
  • C++ 使用 std::getline(std::cin, ...) 和紧凑的令牌语法(x+20、sx-5、u+10)——对连续微调而言不够顺手,但无需用 #ifdef 区分各平台的控制台 API,因而更便于移植。

源代码​

随 SDK 安装程序一同提供

教程 08 也随 SDK 一起安装在本地——可在服务安装目录下的 tutorials/08-haply-inverse-http-remote-config/ 中找到。

相关内容: 会话 — 远程控制 · 选择器 · 设备配置 · 基底置换 · 挂载与工作空间 · JSON 约定 · 教程 00 — 设备列表 · 教程 07 — 基底与挂载(WebSocket 版本)