メインコンテンツへスキップ

08. リモートセッション・コンフィギュレーター

別のアプリ、Unity のシーン、Haply Hub のデモなど、別の場所で既に実行中のセッションを再構成します。これは、デバイスに対して HTTP の REST 呼び出しを送信することで行います。このチュートリアルでは WebSocket は使用しません。GET、POST、DELETE のみを使用し、他のアプリがハプティクスの描画を継続している間に、ベース、ワークスペースのプリセット、マウントトランスフォームを変更します。

ユースケース​

  • 実行中のデモをリアルタイムで調整します。 Haply HubのOrbデモを起動してから、2つ目のターミナルでこのチュートリアルを実行してください。基本の並べ替えを切り替えたり、ワークスペースのプリセットを変更したり、マウントトランスフォームを微調整したりすると、デモを停止することなく、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」シーンは、デバイスのワークスペース内に球体を描画します。チュートリアル 08 で基底やプリセットを切り替えたり、マウントトランスフォームを微調整したりすると、「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 呼び出しが1 回だけ送信されます。

シミュレーションでプロファイル名を設定する

プロファイル名のないセッションは、数値の ID でのみ指定できます。この ID は実行のたびに変わります。メインアプリの最初のメッセージで session.configure.profile.name を呼び出すようにすると、--session :my_profile:0 のような安定したセレクタを、すべての実行で再利用できます。詳細は セッション — プロファイル名 を参照してください。

キーバインド​

キー操作
Bbasis の順列を順番に切り替える
Pワークスペースプリセットを順番に切り替える
W / E / Rマウント編集モードを選択 — 位置 (mm) / 回転 (°) / スケール (%)
← / →現在のモードで −X / +X 方向に移動する
↑ / ↓現在のモードで +Y / −Y 方向に移動する
Page Up / Page Down現在のモードで +Z / −Z 方向に移動する
= / -3軸すべてを一括して均一に拡大/縮小 (±)(常時利用可能)
DeleteDELETE で、basis・preset・mount をリセットし、デバイスをデフォルトの状態に戻す
Hヘルプを表示
Esc終了(Ctrl+C (でも可)

HTTPメソッド — GET、POST、DELETE​

このチュートリアルでは、HTTP メソッドのうち 3 種類のみを使用します。すべての呼び出しは、標準の JSON エンベロープ を返します(成功した場合は {"ok": true, "data": {...}}、失敗した場合は {"ok": false, "error": "..."})。また、ステータスコードは次の3種類のいずれかです: 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 ヘルパー​

上記の3つのメソッドを軽くラップし、チュートリアルの残りの部分を本体のロジックとして読み取れるようにしたものです:

Python では、HTTP キープアライブのために requests.Session() を使用しています(リクエストごとのレイテンシを約 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}"

セッションの検出 — GET /sessions​

--session の指定の有無によって処理が分岐します:

  • --session SELECTOR を指定した場合 → GET /sessions/<SELECTOR> を1回実行します。200 その場合はその結果を使用し、404 その場合はエラーで終了します。
  • フラグなし → GET /sessions(一覧)を実行 → プロファイル名を指定してセッションを表示 → インデックスの入力を求める → 最終的なセレクタを構築します(利用可能な場合は :profile:0 を優先し、それ以外は #id (フォールバックとして)。

SELECTOR は セレクター — セッションセレクター で定義されているすべての形式を受け付けます: :profile:instance、#id、:-1、:0、プロファイル名のみ、または co.haply.hub::*:0 のような プロファイル名のワイルドカード パターンです。このチュートリアルでは、文字列をそのまま渡し、解析はサービス側で行われます。

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

デバイスセレクター — *inverse/0​

設定に関するすべての呼び出しは、デバイス単位で範囲が指定されます。このチュートリアルでは、ファミリーのワイルドカード + インデックスによるセレクタを使用します:

/*inverse/0/config/<key>
  • *inverse は、Inverseファミリーに属する任意のデバイス(inverse3、inverse3x、minverse)に一致します。このチュートリアルは、具体的なモデルに関係なく、そのまま動作します。
  • 0 は、そのファミリー内の 0 から始まるインデックスです。このチュートリアルでは、常に最初の Inverse のみを操作します。

ターゲットの変更は、文字列を1つ変えるだけで済みます。--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番目の逆で、おそらく左にあるもの」ではありません。詳しくは セレクタ — インデックス作成の前にクラスフィルターが解決される を参照してください。

対象デバイスが見つからない場合、チュートリアル 08 は終了します

チュートリアルは起動時に 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​

3つのキーは、リクエストの形式は同じですが、ボディのスキーマが異なります。すべてのPOSTリクエストは、結果の値を data を含む 200 を返します。セッション/デバイスのセレクタに一致するものがない場合は 404 を返します。

基礎​

POST /*inverse/0/config/basis?session=:my_profile:0
Content-Type: application/json

{"permutation": "XZY"}

回答: {"ok": true, "data": {"permutation": "XZY"}}

def post_basis():
perm, _ = BASIS_OPTIONS[basis_index]
api_post(session_url("/inverse3/0/config/basis"), {"permutation": perm})

プリセット​

POST /*inverse/0/config/preset?session=:my_profile:0
Content-Type: application/json

{"preset": "arm_front_centered"}

回答: {"ok": true, "data": {"preset": "arm_front_centered"}}

def post_preset():
preset = PRESET_OPTIONS[preset_index]
api_post(session_url("/inverse3/0/config/preset"), {"preset": preset})

マウント​

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 をそのまま返します。

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)
mount と preset は互いに排他的です

一方を POST すると、デバイス上でもう一方がクリアされます。このチュートリアルでは、この動作を明示的に追跡していません。各 POST は独立しており、競合はサーバー側で解決されます。WebSocket 側から見た同じルールの説明については、チュートリアル 07を参照してください。

DELETE によるリセット — 3回の呼び出し​

reset は、設定キーごとに1回ずつ DELETE を発行します。各呼び出しでは、デフォルト値になった値を data を含む 200 を返します。

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"))

マウントの回転の合成​

transform.rotation は、通信時には単位クォータニオンです。このチュートリアルでは、回転を Z-Y-X 固有オイラー角の3つ組(X 軸周りのピッチ、Z 軸周りのヨー、Y 軸周りのロール — すべて度単位)として保持し、POST のたびにクォータニオンを再合成します。

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,
}
クォータニオンの規約

単位ハミルトン四元数、右手系、スカラーファースト(w)です。これは、本サービス内の他の部分と同様の利用規約です。詳しくは quaternion を参照してください。合成順序はZ-Y-X 固有(q = q_z * q_y * q_x)です:まずX軸周りのピッチを適用し、次にY軸周りのロールを、最後にZ軸周りのヨーを適用します。

チュートリアルでは、ステータス行ごとに導出されたクォータニオンをオイラー角の3つ組とともに表示するため、デバイスが回転する前に合成結果を確認できます。ローカルのオイラー状態は、セッションにすでに保持されている値にかかわらず (0, 0, 0) から始まります。最初の mount POST これにより、それまでの値が上書きされます。

入力モデル(概要)​

HTTPの配線が肝心であり、キーボードのUXは二次的なものです。意図的に簡略化した点が2つあります:

  • Python は keyboard このパッケージを使用しています。クロスプラットフォームに対応しており、キーの長押しによる繰り返し入力をネイティブに処理します。矢印キー、Page Up / Page Down、= / - は、押している間、マウント軸を移動させます。B と P 立ち上がりエッジで、基底とプリセットを順に切り替えます。
  • C++ は std::getline(std::cin, ...) という簡潔なトークン文法(x+20、sx-5、u+10)を使用しています。継続的な微調整には使いにくいものの、各プラットフォームのコンソールAPIを #ifdef で分岐させる必要がないため、移植性があります。

ソース​

SDKインストーラーに同梱

チュートリアル08も、SDKと一緒にローカルにインストールされています。サービスのインストールディレクトリの下にある tutorials/08-haply-inverse-http-remote-config/ ご確認ください。

関連: セッション — リモートコントロール·セレクター·デバイス構成·ベースの置換·マウントとワークスペース·JSON の規約·チュートリアル00 — デバイス一覧·チュートリアル07 — ベースとマウント(WebSocket 版)