본문으로 건너뛰기

08. 원격 세션 구성기

다른 곳에서 이미 실행 중인 세션(다른 앱, Unity 씬, Haply Hub 데모 등)을 재구성합니다. 이 튜토리얼은 WebSocket를 열지 않으며, 디바이스에 HTTP REST 호출(GET, POST, DELETE)만 보냅니다. 다른 앱이 햅틱 렌더링을 계속하는 동안 basis, 워크스페이스 프리셋, 마운트 transform을 변경할 수 있습니다.

사용 사례​

  • 실행 중인 데모 실시간 조정. Haply Hub Orb 데모를 시작한 다음, 두 번째 터미널에서 이 튜토리얼을 실행하여 basis 순열을 바꾸거나 워크스페이스 프리셋을 변경하거나 마운트 transform을 미세 조정합니다. 데모를 멈추지 않아도 Orb의 좌표 프레임이 즉시 전환됩니다.
  • 사용자별 워크스페이스 캘리브레이션. 주 머신에서 햅틱 씬을 계속 실행하고, 같은 네트워크의 운영자가 mount 오프셋 / 회전 / 스케일을 전송하도록 합니다. 그러면 가상 워크스페이스가 사용자의 책상과 맞춰집니다.
  • 디바이스 선택 옵션 메뉴. 같은 HTTP 헬퍼를 사용해 GET /devices(튜토리얼 00 참고)를 질의하면 디바이스 목록을 열거하고 대화형 메뉴를 만들 수 있습니다. 디바이스를 선택한 뒤 재구성하며, 세션의 WebSocket는 건드리지 않습니다. 이 튜토리얼은 /sessions를 질의하고 *inverse/0을 하드코딩하지만, /devices 기반 선택기로 전환하는 것은 로컬 변경으로 가능합니다.
  • 스크립트 기반 재구성. 세션 녹화가 시작되기 전에 사전 단계(basis + preset + mount 설정)를 자동화합니다. 설정을 모든 클라이언트에 포함시킬 필요가 없습니다.

사전 요구 사항​

튜토리얼 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나 프리셋을 순환 변경하거나 마운트 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과 같은 고정 셀렉터를 재사용할 수 있습니다. 세션 — 프로필 이름을 참조하십시오.

키 바인딩​

키동작
Bbasis 순열 순환
P워크스페이스 프리셋 순환
W / E / R마운트 편집 모드 선택 — 위치 (mm) / 회전 (°) / 배율 (%)
← / →현재 모드에서 −X / +X 방향으로 한 단계 이동
↑ / ↓현재 모드에서 +Y / −Y 방향으로 한 단계 이동
Page Up / Page Down현재 모드에서 +Z / −Z 방향으로 한 단계 이동
= / -세 축을 동시에 균일하게 ± 배율 조정 (항상 사용 가능)
Deletebasis + preset + mount에 DELETE 적용 — 장치 기본값으로 되돌리기
H도움말 표시
Esc종료 (Ctrl+C도 동작)

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는 HTTP keep-alive를 위해 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> 한 번 호출. 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만 사용합니다.

대상 장치를 바꾸려면 문자열 하나만 변경하면 됩니다 — --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번째 왼손잡이 inverse”이며, “0번째 inverse이고, 아마 왼쪽인 것”이 아닙니다. 자세한 내용은 선택자 — 인덱싱 이전에 클래스 필터 해석을 참조하십시오.

대상 장치가 없으면 튜토리얼 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​

세 개의 키는 요청 형식은 같지만 본문 스키마가 서로 다릅니다. 모든 POST 요청은 결과 값을 data에 담아 200을 반환하며, 세션 또는 장치 선택자가 일치하는 항목이 없으면 404를 반환합니다.

Basis​

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 초기화 — 세 번의 호출​

reset은 설정 키마다 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 내재(intrinsic) 오일러 삼중항으로 저장합니다 (X축 기준 pitch, Z축 기준 yaw, Y축 기준 roll — 모두 도 단위). 그리고 모든 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 내재(intrinsic) 방식(q = q_z * q_y * q_x)입니다. 즉, 먼저 X축을 중심으로 피치(pitch)를 적용하고, 다음으로 Y축을 중심으로 롤(roll)을, 마지막으로 Z축을 중심으로 요(yaw)를 적용합니다.

튜토리얼은 상태 표시줄마다 유도된 쿼터니언을 오일러 각 값과 함께 출력하므로, 장치가 회전하기 전에 합성 결과를 검증할 수 있습니다. 로컬 오일러 상태는 세션에 이미 저장된 값과 관계없이 (0, 0, 0)에서 시작합니다. 첫 번째 mount POST가 기존 값을 덮어씁니다.

입력 모델 (간략히)​

HTTP 연결 구조가 핵심이며, 키보드 UX는 부차적입니다. 다음 두 가지 의도적인 단순화 방식을 사용합니다.

  • Python 구현에서는 keyboard 패키지를 사용합니다. 크로스 플랫폼이며 키를 누르고 있을 때의 반복 입력을 기본적으로 처리합니다. 방향키, Page Up / Page Down, = / - 키를 누르고 있는 동안 마운트 축이 이동하며, B 및 P 키는 상승 에지(rising edge)에서 기저와 프리셋을 순환합니다.
  • 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 버전)