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可以重复使用诸如这样的固定选择器。 会话 — 个人资料名称请参阅。
键盘快捷键
- Python
- C++
| 键 | 动作 |
|---|---|
B | 基序 排列循环 |
P | 工作区预设循环 |
W / E / R | 选择云台编辑模式 — 位置(mm)/旋转(°)/放大倍率(%) |
← / → | 在当前模式下,沿 −X / +X 方向移动一步 |
↑ / ↓ | 在当前模式下沿 +Y / −Y 方向移动一步 |
Page Up / Page Down | 在当前模式下沿 +Z / −Z 方向移动一步 |
= / - | 同时均匀地对三个轴进行±倍率调整(始终可用) |
Delete | 基座 + 预设 + 安装座 DELETE 应用 — 恢复为设备默认设置 |
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 应用 |
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 为了实现 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}"
libhv是 requests::get / requests::post / requests::Delete提供(delete因为是 C++ 关键字,所以要大写 D(使用)。在 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:0优先使用,如果没有的话#id替换为)。
SELECTOR是 选择器 — 会话选择器允许所有在...中定义的格式: :profile:instance, #id, :-1, :0, 普通个人资料名称,或 co.haply.hub::*:0诸如…… 个人资料名称通配符 这是一个模式。教程会将该字符串原样传递,而解析工作则由服务负责。
- 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个 左撇子 是“逆元”,而不是“第0个逆元,大概是左边的那个”。详情请参见 选择器——在索引之前解析类过滤器请参阅。
教程在开始时 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"}}
- 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});
}
预设
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": { ... }}} — 直接返回规范化后的有效变换。
- 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。每次调用都会 data将该值(现已成为默认值)存入 200返回。
- 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 本质(intrinsic)欧拉三元组(以 X 轴为基准的俯仰角、以 Z 轴为基准的偏航角、以 Y 轴为基准的滚转角——单位均为度)。并且在每次 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在上升沿(rising edge)时,键会在基准值和预设值之间循环切换。 - C++ 在实现中,
std::getline(std::cin, ...)以及简洁的令牌语法(x+20,sx-5,u+10) 进行操作。虽然在进行连续微调时不太方便,但针对各平台的控制台 API,#ifdef无需使用……即可进行移植。
来源
教程 08 也会随 SDK 一起安装到本地。在服务安装目录下的 tutorials/08-haply-inverse-http-remote-config/ 请确认路径。
相关文档: 会话 — 远程控制·选择器·设备配置·基础排列·挂载与工作区·JSON 规范·教程 00 — 设备列表·教程 07 — 基础与挂载(WebSocket 版本)