08. Configurateur de session à distance
Reconfigurez une session déjà en cours d'exécution ailleurs — une autre application, une scène Unity, une démo Haply Hub — en envoyant des appels REST HTTP à son appareil. Ce tutoriel n'ouvre aucune WebSocket : il effectue seulement des GET, des POST et des DELETE pour modifier la base, le préréglage d'espace de travail ou la transformation de montage, pendant que l'autre application continue de produire les retours haptiques.
Cas d'utilisation
- Ajustement en direct d'une démo en cours. Lancez la démo Haply Hub Orb, puis exécutez ce tutoriel dans un second terminal pour changer la permutation de base, modifier le préréglage d'espace de travail ou ajuster légèrement la transformation de montage — le repère de coordonnées de l'Orb se déplace immédiatement, sans arrêter la démo.
- Étalonnage de l'espace de travail par utilisateur. Gardez la scène haptique active sur la machine principale et laissez un opérateur du même réseau transmettre un décalage, une rotation ou une échelle de
mountafin que l'espace de travail virtuel s'aligne sur le bureau de l'utilisateur. - Menu de sélection d'appareil. Les mêmes fonctions utilitaires HTTP peuvent interroger
GET /devices(voir le tutoriel 00) pour énumérer les appareils et construire un menu interactif — choisir un appareil, puis le reconfigurer — sans toucher au WebSocket de la session. Le tutoriel interroge/sessionset fixe*inverse/0dans le code, mais remplacer ce choix par un sélecteur piloté par/devicesne demande qu'une modification locale. - Configuration scriptée. Automatisez les étapes préalables (définir la base, le préréglage et le montage) avant qu'une session ne commence l'enregistrement, sans intégrer la configuration dans chaque client.
Prérequis
Le tutoriel 08 reconfigure une session déjà en cours d'exécution. Il vous faut une session haptique active quelconque — un autre tutoriel, une scène Unity ou une démo Haply Hub.
Ouvrez Haply Hub et lancez la démo Orb, puis ciblez-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. Parcourir en boucle la base ou le préréglage, ou ajuster légèrement la transformation de montage avec le tutoriel 08, déplacera visuellement le repère de coordonnées de l'Orb 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"
# 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
Au démarrage, le tutoriel affiche la base, le préréglage et le montage actuels de la session, puis attend des frappes de touches — chaque frappe envoie un seul appel REST.
Les sessions sans nom de profil ne peuvent être ciblées que par un identifiant numérique, qui change à chaque exécution. Faites appeler session.configure.profile.name par votre application principale dès son premier message : vous pourrez alors réutiliser un sélecteur stable comme --session :my_profile:0 à chaque exécution. Voir Sessions — nom de profil.
Raccourcis clavier
- Python
- C++
| Touche | Action |
|---|---|
B | Parcourir les permutations de base |
P | Parcourir les préréglages d'espace de travail |
W / E / R | Sélectionner le mode d'édition du montage — position (mm) / rotation (°) / échelle (%) |
← / → | Avancer de −X / +X dans le mode actuel |
↑ / ↓ | Avancer de +Y / −Y dans le mode actuel |
Page Up / Page Down | Avancer de +Z / −Z dans le mode actuel |
= / - | Mise à l'échelle uniforme ± sur les trois axes à la fois (toujours disponible) |
Delete | DELETE base + préréglage + montage — rétablir les valeurs par défaut de l'appareil |
H | Afficher l'aide |
Esc | Quitter (Ctrl+C fonctionne aussi) |
Par ligne — tapez puis appuyez sur ENTRÉE.
| Commande | Action |
|---|---|
b | Parcourir les permutations de base |
p | Parcourir les préréglages d'espace de travail |
w / e / r | Sélectionner le mode d'édition du montage — position (mm) / rotation (°) / échelle (%) |
x+[N] … z-[N] | Avancer l'axe courant de N dans l'unité naturelle du mode actif (x+ seul = 5 par défaut) |
sx+[N] … sz-[N] | Raccourci de mise à l'échelle non uniforme sur un axe (en pourcentage), toujours disponible |
u+[N] / u-[N] | Mise à l'échelle uniforme ± N % sur les trois axes à la fois |
reset | DELETE base + préréglage + montage |
h | Afficher l'aide |
Appuyez sur Ctrl+C (ou Ctrl+D / EOF) pour quitter.
Verbes HTTP — GET, POST, DELETE
Le tutoriel utilise trois verbes HTTP, et seulement ceux-là. Chaque appel renvoie l'enveloppe JSON standard ({"ok": true, "data": {...}} en cas de succès, {"ok": false, "error": "..."} en cas d'échec) ainsi que l'un de trois codes d'état : 200 succès, 400 requête mal formée, 404 aucun résultat pour le sélecteur.
| Verbe | Rôle | Chemins utilisés |
|---|---|---|
GET | Lire l'état actuel — liste des sessions, recherche d'une session ciblée, 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 une valeur de configuration à la valeur par défaut de l'appareil | /<device_selector>/config/{basis,preset,mount}?session=... |
Fonctions utilitaires HTTP
Une fine couche d'encapsulation autour des trois verbes, afin que le reste du tutoriel se lise comme de la logique métier :
- Python
- C++ (nlohmann)
- C++ (Glaze)
Python utilise requests.Session() pour le keep-alive HTTP (ramène la 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 (avec un D majuscule — delete est un mot-clé C++). POST nécessite une requête construite 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 l'enveloppe {"ok", "data": T}. Un seul gabarit encapsule chaque GET typé ; le même motif HttpRequest sert aux 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écouverte des sessions — GET /sessions
Le comportement dépend de --session :
--session SELECTORfourni → un seulGET /sessions/<SELECTOR>.200→ l'utiliser ;404→ signaler une erreur et arrêter.- Aucune option →
GET /sessions(liste) → afficher les sessions avec leur nom de profil → demander un index → construire le sélecteur final (préférer:profile:0lorsqu'il est 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 motif avec caractères génériques sur le nom de profil comme co.haply.hub::*:0. Le tutoriel transmet la chaîne telle quelle ; c'est le service qui 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 forme de la réponse à l'aide de structs ; Glaze les génère automatiquement par réflexion :
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 porte sur un appareil précis. Le tutoriel utilise un sélecteur joker de famille + index :
/*inverse/0/config/<key>
*inversecorrespond à tout appareil de la famille Inverse (inverse3,inverse3x,minverse) — le tutoriel fonctionne sans modification, quel que soit le modèle précis.0est l'index, à partir de zéro, dans cette famille — le tutoriel ne touche jamais qu'au premier Inverse.
Changer de cible ne demande qu'une seule modification de chaîne : passez --device (le chemin en est construit) :
/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
La classe :left / :right est appliquée avant l'indexation — *inverse:left/0 désigne « le 0e inverse gaucher », et non « le 0e inverse, espérons-le gaucher ». Voir Sélecteurs — les filtres de classe sont résolus avant l'indexation.
Le tutoriel émet un GET …/config/basis au démarrage. Si la session ne contient aucun
appareil correspondant à --device (par ex. --device '*inverse:left/0' avec une
configuration uniquement droitière), le service répond 404 no device matches selector '<sel>' ; le tutoriel affiche Device '<sel>' not found in session '<sel>'.
et se termine avec le statut 1. Aucune boucle clavier n'est lancée avec des valeurs par défaut obsolètes.
Consultez Sélecteurs — sélecteur d'appareil pour la grammaire complète. Pour créer un menu de sélection d'appareil au lieu de coder en dur, énumérez les appareils avec GET /devices?session=<selector> (Tutoriel 00) et intégrez le device_id choisi dans les chemins de configuration.
Configuration POST — basis, preset, mount
Trois clés de configuration, même structure de requête, schéma de corps différent. Chaque POST renvoie un 200 avec la valeur résultante dans data, ou un 404 si le sélecteur de session ou d'appareil ne correspond à rien.
Basis
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});
}
Preset
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]});
}
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}
}
}
Réponse : {"ok": true, "data": {"transform": { ... }}} — renvoie 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 mutuellementUn POST sur l'une des deux clés efface l'autre sur le périphérique. Le tutoriel n'en fait pas le suivi explicitement : chaque POST est autonome, et le serveur résout le conflit. Voir Tutoriel 07 pour la même règle, du côté WebSocket.
Réinitialisation DELETE — trois appels
reset déclenche un DELETE par clé de configuration. Chacun renvoie un 200 avec la valeur par défaut restaurée 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"));
}
Composition de la rotation du montage
transform.rotation est un quaternion unitaire transmis sur le réseau. Le tutoriel stocke la rotation sous forme de triplet d'Euler intrinsèque Z-Y-X (tangage autour de X, lacet autour de Z, roulis autour de Y — tous en 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, à repère droitier, à partie scalaire en premier (w) — même convention que le reste du service, voir quaternion. L'ordre de composition est Z-Y-X intrinsèque (q = q_z * q_y * q_x) : on applique d'abord le tangage autour de X, puis le roulis autour de Y, puis le lacet autour de Z.
Le tutoriel affiche le quaternion dérivé à côté du triplet d'Euler sur chaque ligne d'état, afin que vous puissiez vérifier la composition avant que le périphérique ne tourne. L'état d'Euler local démarre à (0, 0, 0), quel que soit l'état déjà présent dans la session — le premier mount POST remplace la valeur existante.
Modèle d'entrée (bref)
L'intégration de HTTP est l'essentiel ; l'ergonomie du clavier est secondaire. Deux raccourcis délibérés :
- Python utilise le paquet
keyboard— multiplateforme, il gère nativement la répétition des touches maintenues. Les touches fléchées,Page Up/Page Downet=/-déplacent les axes de montage tant qu'elles sont maintenues ;BetPfont défiler les valeurs de basis et de preset sur le front montant. - C++ utilise
std::getline(std::cin, ...)et une grammaire compacte de jetons (x+20,sx-5,u+10) — moins ergonomique pour les ajustements continus, mais portable sans recourir à des#ifdefpropres à chaque plateforme pour les API de console.
Source
Le tutoriel 08 est aussi installé localement avec le SDK — consultez tutorials/08-haply-inverse-http-remote-config/ dans le répertoire d'installation du service.
Voir aussi : Sessions — Contrôle à distance · Sélecteurs · Configuration des périphériques · Permutation de la base · Montage et espace de travail · Conventions JSON · Tutoriel 00 — Liste des périphériques · Tutoriel 07 — Basis et Mount (version WebSocket)