Skip to content

Commit 9bfbe1f

Browse files
committed
test: add mock Haply SDK server and align env var names
Port the mock_haply_server.py from PR NVIDIA#268 for integration testing without hardware. Also rename environment variables from HAPLY_WEBSOCKET_HOST/PORT to HAPLY_WS_HOST/PORT for consistency with the shorter naming convention. This incorporates the remaining changes from PR NVIDIA#268 that were not included in the initial SchemaPusher rework commit. Signed-off-by: Vicky <vickybot911@gmail.com>
1 parent fb9ad96 commit 9bfbe1f

2 files changed

Lines changed: 269 additions & 6 deletions

File tree

src/plugins/haply/core/haply_plugin.cpp

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -395,19 +395,18 @@ void HaplyPlugin::initialize(const std::string& collection_id, const std::string
395395
std::cout << "Initializing Haply Plugin..." << std::endl;
396396

397397
// Read WebSocket config from environment
398-
const char* host_env = std::getenv("HAPLY_WEBSOCKET_HOST");
398+
const char* host_env = std::getenv("HAPLY_WS_HOST");
399399
std::string ws_host = host_env ? host_env : "127.0.0.1";
400400
uint16_t ws_port = 10001;
401-
const char* port_env = std::getenv("HAPLY_WEBSOCKET_PORT");
401+
const char* port_env = std::getenv("HAPLY_WS_PORT");
402402
if (port_env)
403403
{
404404
try
405405
{
406406
unsigned long parsed = std::stoul(port_env);
407407
if (parsed == 0 || parsed > 65535)
408408
{
409-
std::cerr << "[Haply] Invalid HAPLY_WEBSOCKET_PORT value: " << port_env << ", using default 10001"
410-
<< std::endl;
409+
std::cerr << "[Haply] Invalid HAPLY_WS_PORT value: " << port_env << ", using default 10001" << std::endl;
411410
}
412411
else
413412
{
@@ -416,8 +415,7 @@ void HaplyPlugin::initialize(const std::string& collection_id, const std::string
416415
}
417416
catch (const std::exception&)
418417
{
419-
std::cerr << "[Haply] Invalid HAPLY_WEBSOCKET_PORT value: " << port_env << ", using default 10001"
420-
<< std::endl;
418+
std::cerr << "[Haply] Invalid HAPLY_WS_PORT value: " << port_env << ", using default 10001" << std::endl;
421419
}
422420
}
423421

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
#!/usr/bin/env python3
2+
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
"""
18+
Mock Haply SDK WebSocket server for integration testing.
19+
20+
Emulates the Haply Inverse Service by streaming synthetic Inverse3 and
21+
VerseGrip device data over WebSocket at ws://localhost:<port>.
22+
23+
Usage:
24+
python3 mock_haply_server.py [--port PORT] [--hz HZ] [--handedness left|right] [--duration SECONDS]
25+
26+
The server sends JSON messages matching the Haply SDK wire format and
27+
accepts force commands back. Device positions follow a Lissajous curve,
28+
orientation rotates smoothly, and buttons cycle on/off periodically.
29+
30+
Useful for:
31+
- Integration testing HaplyHandTrackerPrinter without hardware
32+
- Developing against the Haply plugin locally
33+
- CI smoke tests
34+
"""
35+
36+
import argparse
37+
import asyncio
38+
import json
39+
import math
40+
import signal
41+
import sys
42+
import time
43+
44+
try:
45+
import websockets
46+
import websockets.server
47+
except ImportError:
48+
print("Error: 'websockets' package required. Install with: pip install websockets", file=sys.stderr)
49+
sys.exit(1)
50+
51+
52+
class MockHaplyDevice:
53+
"""Generates synthetic Haply device data."""
54+
55+
def __init__(self, handedness: str = "right"):
56+
self.handedness = handedness
57+
self.inverse3_device_id = "mock-inverse3-001"
58+
self.versegrip_device_id = "mock-versegrip-001"
59+
self.start_time = time.monotonic()
60+
self.frame_count = 0
61+
self.last_force = {"x": 0.0, "y": 0.0, "z": 0.0}
62+
63+
def get_state(self, first_message: bool = False) -> dict:
64+
"""Generate a single frame of mock device data."""
65+
t = time.monotonic() - self.start_time
66+
self.frame_count += 1
67+
68+
# Lissajous curve for position (bounded workspace ~[-0.1, 0.1] meters)
69+
amplitude = 0.08
70+
px = amplitude * math.sin(2.0 * math.pi * 0.3 * t)
71+
py = amplitude * math.sin(2.0 * math.pi * 0.5 * t + math.pi / 4.0)
72+
pz = 0.15 + amplitude * 0.5 * math.sin(2.0 * math.pi * 0.2 * t) # centered ~15cm up
73+
74+
# Velocity (numerical derivative approximation)
75+
dt = 0.005 # 200Hz
76+
vx = amplitude * 2.0 * math.pi * 0.3 * math.cos(2.0 * math.pi * 0.3 * t)
77+
vy = amplitude * 2.0 * math.pi * 0.5 * math.cos(2.0 * math.pi * 0.5 * t + math.pi / 4.0)
78+
vz = amplitude * 0.5 * 2.0 * math.pi * 0.2 * math.cos(2.0 * math.pi * 0.2 * t)
79+
80+
# Smooth quaternion rotation around Y axis
81+
angle = 0.5 * math.sin(2.0 * math.pi * 0.1 * t) # gentle oscillation
82+
qw = math.cos(angle / 2.0)
83+
qx = 0.0
84+
qy = math.sin(angle / 2.0)
85+
qz = 0.0
86+
87+
# Buttons: cycle through patterns every 3 seconds
88+
button_phase = int(t / 3.0) % 4
89+
buttons = {
90+
"button_0": button_phase == 0 or button_phase == 3,
91+
"button_1": button_phase == 1 or button_phase == 3,
92+
"button_2": button_phase == 2,
93+
"button_3": button_phase == 3,
94+
}
95+
96+
# Build Inverse3 device data
97+
inverse3_data = {
98+
"device_id": self.inverse3_device_id,
99+
"state": {
100+
"cursor_position": {"x": round(px, 6), "y": round(py, 6), "z": round(pz, 6)},
101+
"cursor_velocity": {"x": round(vx, 6), "y": round(vy, 6), "z": round(vz, 6)},
102+
},
103+
}
104+
105+
# Include config only in first message
106+
if first_message:
107+
inverse3_data["config"] = {"handedness": self.handedness}
108+
109+
# Build VerseGrip device data
110+
versegrip_data = {
111+
"device_id": self.versegrip_device_id,
112+
"state": {
113+
"buttons": buttons,
114+
"orientation": {
115+
"w": round(qw, 6),
116+
"x": round(qx, 6),
117+
"y": round(qy, 6),
118+
"z": round(qz, 6),
119+
},
120+
},
121+
}
122+
123+
if first_message:
124+
versegrip_data["config"] = {}
125+
126+
return {
127+
"inverse3": [inverse3_data],
128+
"wireless_verse_grip": [versegrip_data],
129+
}
130+
131+
def process_command(self, msg: dict):
132+
"""Process incoming force commands."""
133+
inverse3_cmds = msg.get("inverse3", [])
134+
for cmd in inverse3_cmds:
135+
force_cmd = cmd.get("commands", {}).get("set_cursor_force", {})
136+
values = force_cmd.get("values", {})
137+
if values:
138+
self.last_force = {
139+
"x": values.get("x", 0.0),
140+
"y": values.get("y", 0.0),
141+
"z": values.get("z", 0.0),
142+
}
143+
144+
145+
async def handle_client(websocket, device: MockHaplyDevice, hz: float, verbose: bool):
146+
"""Handle a single WebSocket client connection."""
147+
period = 1.0 / hz
148+
first_message = True
149+
client_addr = websocket.remote_address
150+
151+
print(f"[mock-haply] Client connected: {client_addr}")
152+
153+
try:
154+
while True:
155+
frame_start = asyncio.get_event_loop().time()
156+
157+
# Send device state
158+
state = device.get_state(first_message=first_message)
159+
first_message = False
160+
await websocket.send(json.dumps(state))
161+
162+
if verbose and device.frame_count % int(hz) == 0:
163+
pos = state["inverse3"][0]["state"]["cursor_position"]
164+
print(
165+
f"[mock-haply] frame={device.frame_count} "
166+
f"pos=({pos['x']:.3f}, {pos['y']:.3f}, {pos['z']:.3f}) "
167+
f"force=({device.last_force['x']:.3f}, {device.last_force['y']:.3f}, {device.last_force['z']:.3f})"
168+
)
169+
170+
# Try to receive a force command (non-blocking with short timeout)
171+
try:
172+
raw = await asyncio.wait_for(websocket.recv(), timeout=0.001)
173+
try:
174+
cmd = json.loads(raw)
175+
device.process_command(cmd)
176+
except json.JSONDecodeError:
177+
pass
178+
except asyncio.TimeoutError:
179+
pass
180+
181+
# Sleep for remainder of period
182+
elapsed = asyncio.get_event_loop().time() - frame_start
183+
sleep_time = max(0, period - elapsed)
184+
await asyncio.sleep(sleep_time)
185+
186+
except websockets.exceptions.ConnectionClosed:
187+
print(f"[mock-haply] Client disconnected: {client_addr}")
188+
except Exception as e:
189+
print(f"[mock-haply] Error with client {client_addr}: {e}")
190+
191+
192+
async def run_server(port: int, hz: float, handedness: str, duration: float, verbose: bool):
193+
"""Run the mock Haply WebSocket server."""
194+
device = MockHaplyDevice(handedness=handedness)
195+
196+
print("[mock-haply] Starting mock Haply SDK server")
197+
print(f"[mock-haply] WebSocket: ws://localhost:{port}")
198+
print(f"[mock-haply] Frequency: {hz} Hz")
199+
print(f"[mock-haply] Handedness: {handedness}")
200+
print(f"[mock-haply] Duration: {'infinite' if duration <= 0 else f'{duration}s'}")
201+
print("[mock-haply] Waiting for connections...")
202+
203+
stop_event = asyncio.Event()
204+
205+
# Handle shutdown signals
206+
loop = asyncio.get_running_loop()
207+
for sig in (signal.SIGINT, signal.SIGTERM):
208+
loop.add_signal_handler(sig, stop_event.set)
209+
210+
async with websockets.serve(
211+
lambda ws: handle_client(ws, device, hz, verbose),
212+
"localhost",
213+
port,
214+
):
215+
if duration > 0:
216+
try:
217+
await asyncio.wait_for(stop_event.wait(), timeout=duration)
218+
except asyncio.TimeoutError:
219+
pass
220+
print(f"\n[mock-haply] Duration elapsed ({duration}s). Shutting down.")
221+
else:
222+
await stop_event.wait()
223+
print("\n[mock-haply] Shutting down.")
224+
225+
print(f"[mock-haply] Total frames sent: {device.frame_count}")
226+
227+
228+
def main():
229+
parser = argparse.ArgumentParser(
230+
description="Mock Haply SDK WebSocket server for integration testing",
231+
formatter_class=argparse.RawDescriptionHelpFormatter,
232+
epilog="""
233+
Examples:
234+
# Run at default 200Hz on port 10001
235+
python3 mock_haply_server.py
236+
237+
# Run for 10 seconds then exit (useful for CI)
238+
python3 mock_haply_server.py --duration 10
239+
240+
# Run left-handed at 100Hz with verbose output
241+
python3 mock_haply_server.py --handedness left --hz 100 --verbose
242+
243+
# Use a different port
244+
python3 mock_haply_server.py --port 10002
245+
""",
246+
)
247+
parser.add_argument("--port", type=int, default=10001, help="WebSocket port (default: 10001)")
248+
parser.add_argument("--hz", type=float, default=200.0, help="Update frequency in Hz (default: 200)")
249+
parser.add_argument(
250+
"--handedness", choices=["left", "right"], default="right", help="Device handedness (default: right)"
251+
)
252+
parser.add_argument(
253+
"--duration",
254+
type=float,
255+
default=0,
256+
help="Run for N seconds then exit. 0 = run forever (default: 0)",
257+
)
258+
parser.add_argument("--verbose", "-v", action="store_true", help="Print periodic status updates")
259+
260+
args = parser.parse_args()
261+
asyncio.run(run_server(args.port, args.hz, args.handedness, args.duration, args.verbose))
262+
263+
264+
if __name__ == "__main__":
265+
main()

0 commit comments

Comments
 (0)