08. Konfigurator für Fernsitzungen
Konfigurieren Sie eine Sitzung neu , die bereits an anderer Stelle läuft – in einer anderen App, einer Unity-Szene oder einer Haply –, indem Sie HTTP-REST-Aufrufe an das entsprechende Gerät senden. In diesem Tutorial wird kein WebSocket geöffnet: Es werden lediglich GET-, POST- und DELETE-Anfragen verwendet, um die Basis, die Arbeitsbereichsvoreinstellung oder die Mount-Transformation zu ändern, während die andere App weiterhin haptische Rückmeldungen rendert.
Anwendungsfälle
- Nimm live Anpassungen an einer laufenden Demo vor. Starte die Haply Orb-Demo und führe dieses Tutorial anschließend in einem zweiten Terminal aus, um die Basispermutation zu tauschen, die Arbeitsbereichsvoreinstellung zu ändern oder die Befestigungstransformation leicht zu verschieben – das Koordinatensystem des Orbs verschiebt sich sofort, ohne dass die Demo angehalten wird.
- Kalibrierung des Arbeitsbereichs pro Benutzer. Lassen Sie die haptische Anwendung auf dem Hauptrechner laufen und lassen Sie einen Bediener im selben Netzwerk einen
mountVerschieben / Drehen / Skalieren, damit der virtuelle Arbeitsbereich mit dem Schreibtisch des Benutzers übereinstimmt. - Optionsmenü mit Geräteauswahl. Die gleichen HTTP-Helfer können Abfragen durchführen
GET /devices(siehe Anleitung 00) um Geräte aufzulisten und ein interaktives Menü zu erstellen – ein Gerät auswählen und es dann neu konfigurieren –, ohne den WebSocket der Sitzung zu berühren. Das Tutorial fragt ab/sessionsund fest codiert*inverse/0, aber der Wechsel zu einem/devicesDer -gesteuerte Picker ist eine lokale Änderung. - Skriptgesteuerte Neukonfiguration. Automatisieren Sie Vorbereitungsschritte (Basis einstellen + Voreinstellung + Einbinden) vor Beginn der Aufzeichnung einer Sitzung, ohne die Konfiguration in jeden Client einbetten zu müssen.
Voraussetzungen
In Tutorial 08 wird eine bereits laufende Sitzung neu konfiguriert. Du benötigst eine beliebige aktive haptische Sitzung – ein anderes Tutorial, eine Unity-Szene oder eine Haply Demo.
Öffnen Sie Haply und starten Sie die Orb -Demo, und wählen Sie sie dann direkt aus:
./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"
Die „Orb“-Szene rendert eine Kugel im Arbeitsbereich des Geräts. Durch das Durchlaufen der Basis- und Voreinstellungsoptionen oder das Verschieben der Halterungstransformation mit Tutorial 08 wird das Koordinatensystem des „Orb“ in Echtzeit visuell verschoben.
Verwendung
# 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"
Das Tutorial gibt beim Start die aktuelle Basis, das Preset und den Mount der Sitzung aus und wartet anschließend auf Tastendrücke – jeder Tastendruck löst genau einen REST-Aufruf aus.
Sitzungen ohne Profilnamen können nur anhand ihrer numerischen ID angesprochen werden – diese ändert sich bei jedem Durchlauf. Lassen Sie Ihre Hauptanwendung folgenden Aufruf ausführen: session.configure.profile.name bei seiner ersten Nachricht, und du kannst einen stabilen Selektor wie --session :my_profile:0 bei jedem Durchlauf. Siehe Sitzungen – Profilname.
Tastenkombinationen
- Python
- C++
| Schlüssel | Aktion |
|---|---|
B | Zyklusbasierte Permutation |
P | Voreinstellung für den Arbeitsbereich „Cycle“ |
W / E / R | Bearbeitungsmodus für Halterung auswählen – Position (mm) / Drehung (°) / Skalierung (%) |
← / → | Schritt −X / +X im aktuellen Modus |
↑ / ↓ | Schritt +Y / −Y im aktuellen Modus |
Page Up / Page Down | Schritt +Z / −Z im aktuellen Modus |
= / - | Einheitliche Skalierung ± auf allen drei Achsen gleichzeitig (immer verfügbar) |
Delete | DELETE Basis + Voreinstellung + Befestigung – auf Geräte-Standardeinstellungen zurücksetzen |
H | Hilfe anzeigen |
Esc | Beenden (Ctrl+C (funktioniert auch) |
Zeilenbasiert – Geben Sie den Text ein und drücken Sie die Eingabetaste.
| Befehl | Aktion |
|---|---|
b | Zyklusbasierte Permutation |
p | Voreinstellung für den Arbeitsbereich „Cycle“ |
w / e / r | Bearbeitungsmodus für Halterung auswählen – Position (mm) / Drehung (°) / Skalierung (%) |
x+[N] … z-[N] | Die aktuelle Achse um N in der natürlichen Einheit des aktiven Modus (ohne x+ = Standard 5) |
sx+[N] … sz-[N] | Abkürzung für nicht einheitliche Skalierung auf einer Achse (Prozent), immer verfügbar |
u+[N] / u-[N] | Einheitliche Skalierung ± N % auf allen drei Achsen gleichzeitig |
reset | DELETE Basis + Voreinstellung + Halterung |
h | Hilfe anzeigen |
Drücke Strg+C (oder Strg+D / EOF), um das Programm zu beenden.
HTTP-Verben – GET, POST, DELETE
Das Tutorial verwendet drei HTTP-Verben – und zwar genau diese drei. Jeder Aufruf gibt den Standardwert zurück JSON-Umschlag ({"ok": true, "data": {...}} bei Erfolg, {"ok": false, "error": "..."} (bei einem Fehler) und einen von drei Statuscodes: 200 Erfolg, 400 fehlerhafte Anfrage, 404 Der Selektor hat keine Übereinstimmung gefunden.
| Verb | Rolle | Verwendete Pfade |
|---|---|---|
GET | Aktuellen Status abrufen – Liste der Sitzungen, gezielte Suche nach Sitzungen, aktuelle Konfigurationswerte | /sessions, /sessions/<selector>, /<device_selector>/config/{basis,preset,mount}?session=... |
POST | Einen Konfigurationswert ersetzen – der Textkörper ist JSON | /<device_selector>/config/{basis,preset,mount}?session=... |
DELETE | Einen Konfigurationswert auf die Geräte-Standardeinstellung zurücksetzen | /<device_selector>/config/{basis,preset,mount}?session=... |
HTTP-Hilfsfunktionen
Eine schlanke Hülle um die drei Verben, damit sich der Rest des Tutorials wie Geschäftslogik liest:
- Python
- C++ (nlohmann)
- C++ (Glaze)
Anwendungsbereiche von Python requests.Session() für HTTP-Keep-Alive (reduziert die Latenz pro Anfrage von ca. 50 ms auf ca. 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 enthüllt requests::get / requests::post / requests::Delete (Hauptstadt D — delete (ist ein C++-Schlüsselwort). Für POST muss eine manuell erstellte Anfrage erstellt werden, um 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;
}
Antworttexte haben immer die {"ok", "data": T} Umschlag. Eine einzige Vorlage umschließt jeden eingegebenen GET-Befehl; dasselbe HttpRequest Das Muster verarbeitet POST-Anfragen mit 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;
}
Sitzungserkennung – GET /sessions
Zweige auf --session:
--session SELECTORangesichts → einsGET /sessions/<SELECTOR>.200→ benutze es;404→ Fehlermeldung.- Keine Flagge →
GET /sessions(Auflistung) → Sitzungen mit Profilnamen darstellen → nach einem Index fragen → den endgültigen Selektor erstellen (bevorzugt:profile:0sofern verfügbar; andernfalls auf#id).
SELECTOR akzeptiert jede in Selektoren – Sitzungsselektor: :profile:instance, #id, :-1, :0, einfacher Profilname oder ein Profilname (Platzhalter) ähnlich wie co.haply.hub::*:0. Das Tutorial leitet die Zeichenfolge unverändert weiter; der Dienst wertet sie aus.
- 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;
}
Modelliert die Antwortform als Strukturen; Glaze bildet diese automatisch ab:
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;
}
Geräteauswahl — *inverse/0
Jeder Konfigurationsaufruf ist auf ein Gerät beschränkt. Das Tutorial verwendet einen Familien-Platzhalter in Verbindung mit einem Indexselektor:
/*inverse/0/config/<key>
*inversepasst zu jedem Gerät der Inverse-Familie (inverse3,inverse3x,minverse) – Das Tutorial funktioniert unabhängig vom jeweiligen Modell unverändert.0ist der 0-basierte Index dieser Familie – im Tutorial wird immer nur die erste Inverse behandelt.
Das Retargeting erfolgt durch eine einzige Zeichenfolgenänderung:
/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
Siehe Selektoren – Geräteselektor für die vollständige Syntax. Um ein Menü zur Geräteauswahl zu erstellen, anstatt die Werte fest zu programmieren, führen Sie eine Aufzählung mit GET /devices?session=<selector> (Anleitung 00) und den ausgewählten device_id in die Konfigurationspfade.
POST-Konfiguration – Basis, Voreinstellung, Einbindung
Drei Schlüssel, gleiche Anfrageform, unterschiedliches Body-Schema. Jeder POST-Aufruf gibt eine 200 mit dem resultierenden Wert in data, oder 404 falls der Sitzungs-/Geräte-Selektor keine Übereinstimmung gefunden hat.
Grundlage
POST /*inverse/0/config/basis?session=:my_profile:0
Content-Type: application/json
{"permutation": "XZY"}
Antwort: {"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});
}
Voreinstellung
POST /*inverse/0/config/preset?session=:my_profile:0
Content-Type: application/json
{"preset": "arm_front_centered"}
Antwort: {"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]});
}
Halterung
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}
}
}
Antwort: {"ok": true, "data": {"transform": { ... }}} — gibt die effektive Transformation nach der Normalisierung wieder.
- 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 und preset schließen sich gegenseitig ausDas Senden einer Anfrage löscht die andere auf dem Gerät. Das Tutorial geht darauf nicht ausdrücklich ein – jede POST-Anfrage ist in sich geschlossen, und der Server löst den Konflikt. Siehe Tutorial 07 für dieselbe Regel auf der WebSocket-Seite.
DELETE reset — drei Aufrufe
reset führt pro Konfigurationsschlüssel einen DELETE-Befehl aus. Jeder gibt 200 mit dem nun als Standardwert festgelegten Wert in 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"));
}
Die Drehung des Reittiers festlegen
transform.rotation ist ein Einheitsquaternion im Speicher. Das Tutorial speichert die Drehung als intrinsisches Euler-Tripel Z-Y-X (Neigung um die X-Achse, Gierung um die Z-Achse, Rollbewegung um die Y-Achse – alle Winkel) und bildet das Quaternion bei jedem POST neu.
- 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,
};
}
Hamilton-Quaternion, rechtshändig, Skalar zuerst (w) – gleiche Konvention wie im restlichen Dienst, siehe quaternion. Die Reihenfolge der Kompositionen ist Z-Y-X intrinsisch (q = q_z * q_y * q_x): Zuerst die Neigung um die X-Achse, dann die Rollbewegung um die Y-Achse und schließlich die Gierbewegung um die Z-Achse.
Das Tutorial gibt in jeder Statuszeile das abgeleitete Quaternion zusammen mit dem Euler-Tupel aus, sodass Sie die Zusammensetzung überprüfen können, bevor sich das Gerät dreht. Der lokale Euler-Zustand beginnt bei (0, 0, 0) unabhängig davon, was die Sitzung bereits enthält – das Erste mount POST überschreibt alles, was vorher da war.
Eingabemodell (kurz)
Das HTTP-Verhalten ist entscheidend; die Benutzererfahrung der Tastatur ist zweitrangig. Zwei bewusste Abstriche:
- Python verwendet die
keyboardPaket – plattformübergreifend, unterstützt die Wiederholung bei gedrückter Taste nativ. Pfeiltasten,Page Up/Page Downund=/-die Achsen der Halterung verschieben, während sie festgehalten werden;BundPpro Zyklus und bei steigender Flanke voreingestellt. - C++ Verwendungszwecke
std::getline(std::cin, ...)und eine kompakte Token-Grammatik (x+20,sx-5,u+10) – weniger ergonomisch für ständige Anpassungen, aber ohne#ifdef- plattformspezifische Konsolen-APIs.
Quelle
Tutorial 08 wird ebenfalls lokal mit dem SDK installiert – schau mal unter tutorials/08-haply-inverse-http-remote-config/ im Installationsverzeichnis des Dienstes.
Verwandte Themen: Sitzungen – Fernsteuerung · Selektoren · Gerätekonfiguration · Basis-Permutation · Mount & Arbeitsbereich · JSON-Konventionen · Tutorial 00 – Geräteliste · Tutorial 07 – Basis & Mount (WebSocket-Version)