跳至主要内容

08. 远程会话配置器

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

使用场景​

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

先决条件​

教程 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 调用。

在仿真中设置配置文件名称

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

按键绑定​

按键操作
B循环切换basis 置换
P循环切换工作区预设
W / E / R选择安装编辑模式 — 位置(mm)/ 旋转(°)/ 缩放(%)
← / →在当前模式下沿 −X / +X 方向步进
↑ / ↓在当前模式下沿 +Y / −Y 方向步进
Page Up / Page Down在当前模式下沿 +Z / −Z 方向步进
= / -三个轴同时统一缩放 ±(始终可用)
DeleteDELETE 基础设置 + 预设 + 安装 — 恢复为设备默认值
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 毫秒降至约 5 毫秒):

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:0,否则回退为 #id)。

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

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 个左手 “逆”,而不是“第 0 个逆,希望是左手的”。参见 选择器——类过滤器在索引前进行解析。

教程 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。

基础​

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})

预设​

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})

安装​

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": { ... }}} — 回显规范化后生效的变换。

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 内旋(固有)(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 版本)