跳过至主要内容

08. 远程会话配置器

重新配置已在其他位置(如其他应用、Unity 场景或Haply Hub演示等)中正在运行的会话。通过向设备发送 HTTP REST 调用来实现。本教程不会建立 WebSocket 连接,仅使用 GET、POST 和 DELETE 方法,在其他应用继续渲染触觉反馈的同时,修改基底、工作区预设和安装变换。

用例​

  • 实时调整正在运行的演示。请先在Haply Hub 上启动Orb演示,然后在第二个终端中运行本教程。切换基础排序、更改工作区预设或微调安装变换时,Orb 的坐标系会立即发生变化,而无需停止演示。
  • 按用户调整工作区。 在主机上持续运行Haptics场景的同时,同一网络上的操作员 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 中,当切换基底或预设,或者微调安装变换时,“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 方向移动
= / -将所有3个轴统一进行等比例缩放(±)(始终可用)
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 中,为了实现 HTTP Keep-Alive, 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})

安装​

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 进行重置 — 调用 3 次​

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 特征欧拉角的三元组(绕 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,
}
Quaternion 协议

单位哈密顿四元数、右手系、标量优先(w)です。本条款与本服务其他部分的条款相同。详情请参阅 quaternion 请参阅。合成顺序为Z-Y-X 固有(q = q_z * q_y * q_x)是:首先应用绕X轴的俯仰角,接着应用绕Y轴的滚转角,最后应用绕Z轴的偏航角。

在教程中,每个状态行都会显示推导出的四元数及其对应的欧拉角三元组,因此可以在设备旋转之前确认合成效果。本地欧拉状态与会话中已保存的值无关, (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)正在使用。虽然它不太适合进行连续的微调,但各平台的控制台API #ifdef 由于无需进行分支,因此具有可移植性。

来源​

随SDK安装程序一起提供

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

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