跳转至正文

08. 远程会话配置器

重新配置已在其他地方运行的会话(例如其他应用、Unity场景、Haply Hub演示等)。本教程不会打开WebSocket,仅向设备发送HTTP REST调用(GET、POST、DELETE)。在其他应用继续进行触觉渲染的同时,您可以更改基面、工作区预设和安装变换。

用例​

  • 对正在运行的演示进行实时调整。 Haply 先启动 HubOrb演示,然后在第二个终端中运行本教程,以更改基序列、修改工作区预设或微调蒙版变换。即使不停止演示,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”场景会在设备工作区中渲染一个球体(sphere)。按照教程 08 的操作,循环切换基底(basis)或预设(preset),或者微调安装变换(mount transform),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基序 排列循环
P工作区预设循环
W / E / R选择云台编辑模式 — 位置(mm)/旋转(°)/放大倍率(%)
← / →在当前模式下,沿 −X / +X 方向移动一步
↑ / ↓在当前模式下沿 +Y / −Y 方向移动一步
Page Up / Page Down在当前模式下沿 +Z / −Z 方向移动一步
= / -同时均匀地对三个轴进行±倍率调整(始终可用)
Delete基座 + 预设 + 安装座 DELETE 应用 — 恢复为设备默认设置
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 为了实现 HTTP 保持连接, requests.Session()使用(可将每次请求的延迟从约 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, 普通个人资料名称,或 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 设置 — 基础、预设、安装​

这三个键的请求格式相同,但正文架构各不相同。所有 POST 请求都会返回结果值 data放入 200并返回该值;如果会话或设备选择器没有匹配项,则 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})

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

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。每次调用都会 data将该值(现已成为默认值)存入 200返回。

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 本质(intrinsic)欧拉三元组(以 X 轴为基准的俯仰角、以 Z 轴为基准的偏航角、以 Y 轴为基准的滚转角——单位均为度)。并且在每次 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 在上升沿(rising edge)时,键会在基准值和预设值之间循环切换。
  • C++ 在实现中, std::getline(std::cin, ...)以及简洁的令牌语法(x+20, sx-5, u+10) 进行操作。虽然在进行连续微调时不太方便,但针对各平台的控制台 API, #ifdef无需使用……即可进行移植。

来源​

随 SDK 安装程序一起发布

教程 08 也会随 SDK 一起安装到本地。在服务安装目录下的 tutorials/08-haply-inverse-http-remote-config/ 请确认路径。

相关文档: 会话 — 远程控制·选择器·设备配置·基础排列·挂载与工作区·JSON 规范·教程 00 — 设备列表·教程 07 — 基础与挂载(WebSocket 版本)