08. Configurateur de session à distance
Reconfigurez une session déjà en cours d'exécution ailleurs (dans une autre application, une scène Unity ou une démo Haply ) en envoyant des requêtes HTTP REST à son périphérique. Ce tutoriel n'ouvre aucun WebSocket : il utilise uniquement des requêtes GET, POST et DELETE pour modifier la base, le préréglage de l'espace de travail ou la transformation de montage, tandis que l'autre application continue de générer les sensations haptiques.
Cas d'utilisation
- Modifiez en temps réel une démo en cours d'exécution. Lancez la démo Haply Orb, puis exécutez ce tutoriel dans un deuxième terminal pour changer la permutation de base, modifier le préréglage de l'espace de travail ou ajuster légèrement la transformation de montage : le repère de coordonnées de l'Orb s'adapte immédiatement sans interrompre la démo.
- Réglage de l'espace de travail par utilisateur. Laissez la scène haptique tourner sur la machine principale et demandez à un opérateur connecté au même réseau d'envoyer une
mountdécalage / rotation / mise à l'échelle afin que l'espace de travail virtuel s'aligne avec le bureau de l'utilisateur. - Menu des options avec sélection de l'appareil. Ces mêmes aides HTTP permettent d'interroger
GET /devices(voir Tutoriel 00) pour répertorier les périphériques et créer un menu interactif — sélectionnez un périphérique, puis reconfigurez-le — sans toucher au WebSocket de la session. Le tutoriel interroge/sessionset les code en dur*inverse/0, mais en passant à un/devicesLe sélecteur basé sur les conditions est une modification locale. - Reconfiguration automatisée. Automatisez les étapes de préparation (définition de la base + préréglage + montage) avant le début de l'enregistrement d'une session, sans avoir à intégrer cette configuration dans chaque client.
Conditions préalables
Le tutoriel 08 permet de reconfigurer une session déjà en cours. Vous devez disposer d'une session haptique active — qu'il s'agisse d'un autre tutoriel, d'une scène Unity ou d'une démo Haply .
Ouvrez Haply et lancez la démo Orb, puis sélectionnez-la directement :
./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"
La scène « Orb » affiche une sphère dans l'espace de travail de l'appareil. En passant d'une base à l'autre, en sélectionnant un préréglage ou en ajustant légèrement la transformation de la monture à l'aide du tutoriel 08, le repère de coordonnées de l'Orb se déplacera visuellement en temps réel.
Utilisation
# 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"
Le tutoriel affiche la base, le préréglage et le montage actuels de la session au démarrage, puis attend que l'utilisateur appuie sur une touche — chaque pression déclenche exactement un appel REST.
Les sessions sans nom de profil ne peuvent être identifiées qu'à l'aide d'un identifiant numérique, qui change à chaque exécution. Faites en sorte que votre application principale appelle session.configure.profile.name lors de son premier message, et vous pouvez réutiliser un sélecteur stable tel que --session :my_profile:0 à chaque exécution. Voir Sessions — nom du profil.
Raccourcis clavier
- Python
- C++
| Clé | Action |
|---|---|
B | Permutation par cycles |
P | Préréglage de l'espace de travail Cycle |
W / E / R | Sélectionner le mode d'édition de la monture — position (mm) / rotation (°) / échelle (%) |
← / → | Incrémenter de −X / +X en mode actuel |
↑ / ↓ | Incrémenter de +Y / Décrémenter de −Y en mode actuel |
Page Up / Page Down | Incrémenter de +Z / Diminuer de −Z en mode actuel |
= / - | Échelle uniforme ± sur les trois axes simultanément (toujours disponible) |
Delete | DELETE base + préréglage + montage — rétablir les paramètres par défaut de l'appareil |
H | Afficher l'aide |
Esc | Quitter (Ctrl+C (ça marche aussi) |
Mode ligne par ligne : tapez votre texte, puis appuyez sur ENTRÉE.
| Commande | Action |
|---|---|
b | Permutation par cycles |
p | Préréglage de l'espace de travail Cycle |
w / e / r | Sélectionner le mode d'édition de la monture — position (mm) / rotation (°) / échelle (%) |
x+[N] … z-[N] | Incrémenter l'axe actuel de N dans l'unité naturelle du mode actif (sans x+ = par défaut 5) |
sx+[N] … sz-[N] | Raccourci pour une échelle non uniforme sur un axe (pourcentage), toujours disponible |
u+[N] / u-[N] | Échelle uniforme ± N % sur les trois axes simultanément |
reset | DELETE base + préréglage + support |
h | Afficher l'aide |
Appuyez sur Ctrl+C (ou Ctrl+D / EOF) pour quitter.
Verbes HTTP — GET, POST, DELETE
Ce tutoriel utilise trois verbes HTTP, et uniquement ces trois-là. Chaque requête renvoie la réponse standard Enveloppe JSON ({"ok": true, "data": {...}} en cas de réussite, {"ok": false, "error": "..."} en cas d'échec) et l'un des trois codes d'état suivants : 200 succès, 400 demande incorrecte, 404 Le sélecteur n'a donné aucun résultat.
| Verbe | Rôle | Chemins utilisés |
|---|---|---|
GET | Lire l'état actuel — liste des sessions, recherche d'une session spécifique, valeurs de configuration actuelles | /sessions, /sessions/<selector>, /<device_selector>/config/{basis,preset,mount}?session=... |
POST | Remplacer une valeur de configuration — le corps est au format JSON | /<device_selector>/config/{basis,preset,mount}?session=... |
DELETE | Rétablir la valeur par défaut de l'appareil pour un paramètre de configuration | /<device_selector>/config/{basis,preset,mount}?session=... |
Aides HTTP
Une fine couche d'encapsulation autour des trois verbes, de sorte que le reste du tutoriel se présente comme de la logique métier :
- Python
- C++ (nlohmann)
- C++ (Glaze)
Utilisations de Python requests.Session() pour le protocole HTTP Keep-Alive (réduit le temps de latence par requête d'environ 50 ms à environ 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 expose requests::get / requests::post / requests::Delete (capital D — delete (c'est un mot-clé C++). POST nécessite une requête créée manuellement pour définir 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;
}
Les corps de réponse ont toujours le {"ok", "data": T} enveloppe. Un seul modèle englobe toutes les requêtes GET saisies ; le même HttpRequest le modèle renvoie des requêtes POST avec glz::write_json:
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;
}
Détection de session — GET /sessions
Branches sur --session:
--session SELECTORétant donné que → unGET /sessions/<SELECTOR>.200→ utilisez-le ;404→ générer une erreur.- Pas de drapeau →
GET /sessions(liste) → afficher les sessions avec leurs noms de profil → demander un index → construire le sélecteur final (de préférence:profile:0si disponible ; sinon, utiliser#id).
SELECTOR accepte toutes les formes définies dans Sélecteurs — Sélecteur de session: :profile:instance, #id, :-1, :0, un nom de profil simple, ou un nom-de-profil avec caractère générique motif de type co.haply.hub::*:0. Le tutoriel transmet la chaîne telle quelle ; le service l'analyse.
- 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;
}
Modélise la structure de la réponse sous forme de structures ; Glaze les reflète automatiquement :
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;
}
Sélecteur d'appareil — *inverse/0
Chaque appel de configuration est associé à un périphérique. Le tutoriel utilise un sélecteur de famille avec caractère générique + index:
/*inverse/0/config/<key>
*inversecorrespond à n'importe quel appareil de la gamme Inverse (inverse3,inverse3x,minverse) — le tutoriel fonctionne de la même manière, quel que soit le modèle utilisé.0correspond à l'index (à partir de 0) dans cette famille — le tutoriel n'aborde que le premier Inverse.
Le reciblage se résume à une simple modification de chaîne :
/verse_grip/0/config/basis?session=... # target first wired VerseGrip
/*verse_grip/*/config/basis?session=... # target every grip, wired + wireless
/inverse3/A14/config/mount?session=... # target Inverse3 with id A14
Voir Sélecteurs — Sélecteur de périphériques pour consulter la grammaire complète. Pour créer un menu de sélection de périphériques au lieu d'utiliser du code fixe, utilisez la liste GET /devices?session=<selector> (Tutoriel 00) et raccorder le device_id dans les chemins de configuration.
Configuration POST — base, préréglage, montage
Trois clés, même format de requête, schéma de corps différent. Chaque requête POST renvoie un 200 avec la valeur obtenue dans dataou 404 si le sélecteur de session/appareil n'a donné aucun résultat.
Base
POST /*inverse/0/config/basis?session=:my_profile:0
Content-Type: application/json
{"permutation": "XZY"}
Réponse : {"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});
}
Préréglage
POST /*inverse/0/config/preset?session=:my_profile:0
Content-Type: application/json
{"preset": "arm_front_centered"}
Réponse : {"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]});
}
Mont
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}
}
}
Réponse : {"ok": true, "data": {"transform": { ... }}} — reflète la transformation effective après normalisation.
- 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 et preset s'excluent mutuellementL'envoi d'une requête efface l'autre sur l'appareil. Le tutoriel ne le précise pas explicitement : chaque requête POST est autonome, et c'est le serveur qui résout le conflit. Voir le tutoriel 07 pour la même règle du côté WebSocket.
Réinitialisation DELETE — trois appels
reset lance une commande DELETE par clé de configuration. Chacune renvoie 200 avec la valeur désormais par défaut dans 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"));
}
Définition de la rotation de la monture
transform.rotation est un quaternion unitaire en cours de transmission. Le tutoriel stocke la rotation sous la forme d'un triplet d'Euler intrinsèque Z-Y-X (tangage autour de l'axe X, lacet autour de l'axe Z, roulis autour de l'axe Y — tous les degrés) et recompose le quaternion à chaque 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,
};
}
Quaternion de Hamilton unitaire, à main droite, avec le scalaire en premier (w) — même convention que pour le reste du service, voir quaternion. L'ordre des éléments est Z-Y-X intrinsèque (q = q_z * q_y * q_x) : commencez par faire pivoter l'appareil autour de l'axe X, puis autour de l'axe Y, et enfin autour de l'axe Z.
Le tutoriel affiche le quaternion dérivé à côté du triplet d'Euler sur chaque ligne d'état, ce qui vous permet de vérifier la composition avant que l'appareil ne pivote. L'état d'Euler local commence à (0, 0, 0) quels que soient les éléments déjà présents dans la session — le premier mount POST remplace tout ce qui s'y trouvait.
Modèle d'entrée (résumé)
L'essentiel réside dans la gestion HTTP ; l'expérience utilisateur du clavier est secondaire. Deux raccourcis délibérés :
- Python utilise le
keyboardbibliothèque — multiplateforme, gère nativement les répétitions par maintien de touche. Touches fléchées,Page Up/Page Downet=/-déplacer les axes de montage tout en les maintenant enfoncés ;BetPpar cycle et déclenché sur le front montant. - C++ utilisations
std::getline(std::cin, ...)et une grammaire par jetons compacte (x+20,sx-5,u+10) — moins ergonomique pour les réglages fréquents, mais portable sans#ifdef- en développant des API de console spécifiques à chaque plateforme.
Source
Le tutoriel 08 est également installé localement avec le SDK — regardez dans tutorials/08-haply-inverse-http-remote-config/ dans le répertoire d'installation du service.
Connexes : Sessions — Télécommande · Sélecteurs · Configuration des appareils · Permutation de base · Montage et espace de travail · Conventions JSON · Tutoriel 00 — Liste des appareils · Tutoriel 07 — Base et montage (version WebSocket)