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 名称的会话只能通过数字 id 指定 — 而该 id 每次运行都会变化。请让主应用在其第一条消息中调用 session.configure.profile.name,之后即可在每次运行中复用稳定的选择器,例如 --session :my_profile:0。参见 会话 — profile 名称。
按键绑定
- Python
- C++
| 按键 | 操作 |
|---|---|
B | 循环切换 basis 置换 |
P | 循环切换 工作空间预设 |
W / E / R | 选择挂载编辑模式 — 位置(mm)/ 旋转(°)/ 缩放(%) |
← / → | 在当前模式下沿 −X / +X 步进 |
↑ / ↓ | 在当前模式下沿 +Y / −Y 步进 |
Page Up / Page Down | 在当前模式下沿 +Z / −Z 步进 |
= / - | 三个轴同时统一缩放 ±(始终可用) |
Delete | DELETE basis + preset + mount — 恢复为设备默认值 |
H | 显示帮助 |
Esc | 退出(Ctrl+C 也可以) |
按行输入 — 键入后按 ENTER 键。
| 命令 | 操作 |
|---|---|
b | 循环切换基底置换 |
p | 循环切换工作空间预设 |
w / e / r | 选择挂载编辑模式 — 位置(mm)/ 旋转(°)/ 缩放(%) |
x+[N] … z-[N] | 以当前模式的自然单位沿当前轴步进 N(单独的 x+ 默认为 5) |
sx+[N] … sz-[N] | 对单个轴进行非均匀缩放的快捷方式(百分比),始终可用 |
u+[N] / u-[N] | 三个轴同时统一缩放 ±N % |
reset | DELETE basis + preset + mount |
h | 显示帮助 |
按 Ctrl+C(或 Ctrl+D / EOF)退出。
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
- C++ (nlohmann)
- C++ (Glaze)
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}"
libhv 提供 requests::get / requests::post / requests::Delete(注意首字母大写的 D — delete 是 C++ 的关键字)。POST 需要手动构造请求,以设置 Content-Type: application/json:
static std::string session_url(const std::string &endpoint) {
return BASE_URL + endpoint + "?session=" + session_selector;
}
static json http_get(const std::string &url) {
auto resp = requests::get(url.c_str());
if (!resp || resp->status_code != 200) return {};
try { return json::parse(resp->body); } catch (...) { return {}; }
}
static bool http_post_json(const std::string &url, const json &body) {
auto req = std::make_shared<HttpRequest>();
req->method = HTTP_POST;
req->url = url;
req->content_type = APPLICATION_JSON;
req->body = body.dump();
auto resp = requests::request(req);
return resp && resp->status_code == 200;
}
static bool http_delete(const std::string &url) {
auto resp = requests::Delete(url.c_str());
return resp && resp->status_code == 200;
}
响应体始终带有 {"ok", "data": T} 包装结构。单个模板即可封装所有带类型的 GET 请求;同样的 HttpRequest 模式配合 glz::write_json 可用于 POST:
template <typename T> struct envelope { bool ok{}; T data{}; };
template <typename Payload>
static std::optional<Payload> http_get_envelope(const std::string &url) {
auto resp = requests::get(url.c_str());
if (!resp || resp->status_code != 200) return std::nullopt;
envelope<Payload> env{};
if (glz::read<glz_settings>(env, resp->body)) return std::nullopt;
return std::move(env.data);
}
template <typename Body>
static bool http_post_json(const std::string &url, const Body &body) {
std::string buf;
if (glz::write_json(body, buf)) return false;
auto req = std::make_shared<HttpRequest>();
req->method = HTTP_POST;
req->url = url;
req->content_type = APPLICATION_JSON;
req->body = std::move(buf);
auto resp = requests::request(req);
return resp && resp->status_code == 200;
}
static bool http_delete(const std::string &url) {
auto resp = requests::Delete(url.c_str());
return resp && resp->status_code == 200;
}
会话发现 — 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 名称通配符 模式。本教程会原样转发该字符串,由服务端负责解析。
- Python
- C++ (nlohmann)
- C++ (Glaze)
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
static bool discover_session(const std::string &session_arg) {
if (!session_arg.empty()) {
const auto data = http_get(BASE_URL + "/sessions/" + session_arg);
if (data.is_null()) return false;
session_selector = session_arg;
return true;
}
const auto data = http_get(BASE_URL + "/sessions");
const json &list = data["data"]["sessions"];
for (size_t i = 0; i < list.size(); ++i) {
const int sid = list[i].value("session_id", 0);
std::string prof = "default";
if (list[i].contains("config") && list[i]["config"].contains("profile"))
prof = list[i]["config"]["profile"].value("name", std::string{"default"});
printf(" [%zu] session #%d profile=%s\n", i, sid, prof.c_str());
}
std::string line; std::getline(std::cin, line);
const json &picked = list[std::stoi(line)];
std::string prof;
if (picked.contains("config") && picked["config"].contains("profile"))
prof = picked["config"]["profile"].value("name", std::string{});
session_selector = (!prof.empty() && prof != "default")
? ":" + prof + ":0"
: "#" + std::to_string(picked.value("session_id", 0));
return true;
}
使用结构体对响应结构进行建模;Glaze 会自动对其进行反射处理:
struct profile_info { std::string name; };
struct session_config{ std::optional<profile_info> profile; };
struct session_info { int session_id{}; std::optional<session_config> config; };
struct sessions_list { int session_count{}; std::vector<session_info> sessions; };
static bool discover_session(const std::string &session_arg) {
if (!session_arg.empty()) {
auto resp = requests::get((BASE_URL + "/sessions/" + session_arg).c_str());
if (!resp || resp->status_code != 200) return false;
session_selector = session_arg;
return true;
}
auto list = http_get_envelope<sessions_list>(BASE_URL + "/sessions");
if (!list || list->sessions.empty()) return false;
for (size_t i = 0; i < list->sessions.size(); ++i) {
const auto &s = list->sessions[i];
std::string prof = "default";
if (s.config && s.config->profile) prof = s.config->profile->name;
printf(" [%zu] session #%d profile=%s\n", i, s.session_id, prof.c_str());
}
std::string line; std::getline(std::cin, line);
const auto &picked = list->sessions[std::atoi(line.c_str())];
std::string prof;
if (picked.config && picked.config->profile) prof = picked.config->profile->name;
session_selector = (!prof.empty() && prof != "default")
? ":" + prof + ":0"
: "#" + std::to_string(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,希望是左手的”。参见 选择器——类过滤器在索引前解析。
教程在启动时会发出 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"}}
- Python
- C++ (nlohmann)
- C++ (Glaze)
def post_basis():
perm, _ = BASIS_OPTIONS[basis_index]
api_post(session_url("/inverse3/0/config/basis"), {"permutation": perm})
static void post_basis() {
http_post_json(session_url("/inverse3/0/config/basis"),
{{"permutation", BASIS_OPTIONS[basis_index].first}});
}
struct basis_body { std::string permutation; };
static void post_basis() {
http_post_json(session_url("/inverse3/0/config/basis"),
basis_body{BASIS_OPTIONS[basis_index].first});
}
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"}}
- Python
- C++ (nlohmann)
- C++ (Glaze)
def post_preset():
preset = PRESET_OPTIONS[preset_index]
api_post(session_url("/inverse3/0/config/preset"), {"preset": preset})
static void post_preset() {
http_post_json(session_url("/inverse3/0/config/preset"),
{{"preset", PRESET_OPTIONS[preset_index]}});
}
struct preset_body { std::string preset; };
static void post_preset() {
http_post_json(session_url("/inverse3/0/config/preset"),
preset_body{PRESET_OPTIONS[preset_index]});
}
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。
- Python
- C++ (nlohmann)
- C++ (Glaze)
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)
static void post_mount() {
http_post_json(session_url("/inverse3/0/config/mount"), {
{"transform", {
{"position", {{"x", mount_pos[0]}, {"y", mount_pos[1]}, {"z", mount_pos[2]}}},
{"rotation", quat_from_euler_deg(mount_rot[0], mount_rot[1], mount_rot[2])},
{"scale", {{"x", mount_scale[0]}, {"y", mount_scale[1]}, {"z", mount_scale[2]}}},
}},
});
}
struct vec3 { float x{}, y{}, z{}; };
struct quat { float w{1.0f}, x{}, y{}, z{}; };
struct transform_t { vec3 position{}; quat rotation{}; vec3 scale{1.0f, 1.0f, 1.0f}; };
struct mount_body { transform_t transform; };
static void post_mount() {
http_post_json(session_url("/inverse3/0/config/mount"), mount_body{
transform_t{
.position = vec3{mount_pos[0], mount_pos[1], mount_pos[2]},
.rotation = quat_from_euler_deg(mount_rot[0], mount_rot[1], mount_rot[2]),
.scale = vec3{mount_scale[0], mount_scale[1], mount_scale[2]},
}});
}
mount 与 preset 互斥对其中一项执行 POST 会在设备上清除另一项。本教程并未显式跟踪这一点 — 每次 POST 都是独立的,由服务端负责解决冲突。关于同一规则从 WebSocket 端的角度说明,参见 教程 07。
DELETE 重置 — 三次调用
reset 会为每个配置项各发出一次 DELETE 请求。每次均返回 200,并在 data 中包含恢复后的默认值。
- Python
- C++ (nlohmann)
- C++ (Glaze)
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"))
static void reset_all() {
http_delete(session_url("/inverse3/0/config/basis"));
http_delete(session_url("/inverse3/0/config/preset"));
http_delete(session_url("/inverse3/0/config/mount"));
}
static void reset_all() {
http_delete(session_url("/inverse3/0/config/basis"));
http_delete(session_url("/inverse3/0/config/preset"));
http_delete(session_url("/inverse3/0/config/mount"));
}
组合挂载旋转
在传输时,transform.rotation 是一个单位四元数。本教程将旋转存储为 Z-Y-X 内旋欧拉角三元组(绕 X 轴的俯仰 pitch、绕 Z 轴的偏航 yaw、绕 Y 轴的横滚 roll,均以度为单位),并在每次 POST 时重新组合为四元数。
- Python
- C++ (nlohmann)
- C++ (Glaze)
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,
}
static json quat_from_euler_deg(float pitch_x, float yaw_z, float roll_y) {
constexpr float deg_to_rad = 3.14159265358979323846f / 180.0f;
const float hx = pitch_x * 0.5f * deg_to_rad;
const float hy = roll_y * 0.5f * deg_to_rad;
const float hz = yaw_z * 0.5f * deg_to_rad;
const float cx = std::cos(hx), sx = std::sin(hx);
const float cy = std::cos(hy), sy = std::sin(hy);
const float cz = std::cos(hz), sz = std::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},
};
}
static quat quat_from_euler_deg(float pitch_x, float yaw_z, float roll_y) {
constexpr float deg_to_rad = 3.14159265358979323846f / 180.0f;
const float hx = pitch_x * 0.5f * deg_to_rad;
const float hy = roll_y * 0.5f * deg_to_rad;
const float hz = yaw_z * 0.5f * deg_to_rad;
const float cx = std::cos(hx), sx = std::sin(hx);
const float cy = std::cos(hy), sy = std::sin(hy);
const float cz = std::cos(hz), sz = std::sin(hz);
return quat{
.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,因而更便于移植。
源代码
教程 08 也随 SDK 一起安装在本地——可在服务安装目录下的 tutorials/08-haply-inverse-http-remote-config/ 中找到。
相关内容: 会话 — 远程控制 · 选择器 · 设备配置 · 基底置换 · 挂载与工作空间 · JSON 约定 · 教程 00 — 设备列表 · 教程 07 — 基底与挂载(WebSocket 版本)