gpue commited on
Commit
e2fcf08
·
1 Parent(s): be47e69

Refactor mujoco_server.py to use WebSocket for communication and update README.md for API changes

Browse files

- Replaced Flask-SocketIO with Flask-Sock for WebSocket handling in mujoco_server.py.
- Implemented WebSocket message handling for commands, resets, and camera controls.
- Updated state broadcasting to use JSON format for WebSocket clients.
- Modified the README.md to reflect the new WebSocket API structure and usage instructions.
- Changed HTTP endpoints to include a versioned API prefix for better organization.

Files changed (3) hide show
  1. README.md +62 -49
  2. mujoco_server.py +216 -122
  3. requirements.txt +1 -1
README.md CHANGED
@@ -31,7 +31,7 @@ A unified MuJoCo-based robot simulation platform with web interface for multiple
31
 
32
  ```bash
33
  # Install dependencies
34
- pip install mujoco gymnasium flask flask-socketio opencv-python torch numpy
35
 
36
  # Optional: For PyMPC gait controller
37
  pip install jax jaxlib quadruped-pympc gym-quadruped
@@ -39,7 +39,7 @@ pip install jax jaxlib quadruped-pympc gym-quadruped
39
  # Start the server
40
  python mujoco_server.py
41
 
42
- # Open browser at http://localhost:3004
43
  ```
44
 
45
  ### Docker
@@ -169,7 +169,7 @@ docker run --gpus all -p 3004:3004 \
169
  ┌─────────────────────────────────────────────────────────────────────────┐
170
  │ mujoco_server.py │
171
  │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
172
- │ │ Flask + WSGI │ │SocketIO Handler │ │ Render Thread │ │
173
  │ │ HTTP endpoints │ │ cmd, reset, │ │ 60 FPS loop │ │
174
  │ │ │ │ switch_robot │ │ MJPEG encode │ │
175
  │ └─────────────────┘ └────────┬────────┘ └────────┬────────┘ │
@@ -289,80 +289,93 @@ env = SpotEnv(controller_type='trot')
289
 
290
  ## API
291
 
292
- ### WebSocket Events
293
 
294
- Connect to `ws://localhost:3004/socket.io/` using Socket.IO client.
295
 
296
- #### Client Server
297
 
298
- | Event | Payload | Description |
299
- |-------|---------|-------------|
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  | `command` | `{vx, vy, vyaw}` | Set velocity command |
301
- | `reset` | *(none)* | Reset robot to standing pose |
302
  | `switch_robot` | `{robot}` | Switch active robot |
303
- | `camera` | `{type, ...}` | Camera control |
304
  | `camera_follow` | `{follow}` | Toggle camera follow mode |
305
 
306
- **`command` payload:**
307
  ```json
308
- {
309
- "vx": 0.5, // Forward/backward velocity [-1, 1]
310
- "vy": 0.0, // Left/right strafe velocity [-1, 1]
311
- "vyaw": 0.0 // Turn rate [-1, 1]
312
- }
313
  ```
 
 
 
314
 
315
- **`reset`:** No payload - emit the event name only:
316
- ```javascript
317
- socket.emit('reset'); // No second argument
318
  ```
319
- Resets the robot to its initial standing pose and resets the camera position.
320
 
321
- **`switch_robot` payload:**
322
  ```json
323
- {
324
- "robot": "g1" // "g1" or "spot"
325
- }
326
  ```
 
327
 
328
- **`camera` payload:**
329
  ```json
330
  // Rotate camera
331
- {"type": "rotate", "dx": 10, "dy": 5}
332
 
333
  // Zoom camera
334
- {"type": "zoom", "dz": -50}
335
 
336
  // Pan camera
337
- {"type": "pan", "dx": 10, "dy": 5}
338
 
339
  // Set absolute distance
340
- {"type": "set_distance", "distance": 3.0}
341
  ```
342
 
343
- **`camera_follow` payload:**
344
  ```json
345
- {
346
- "follow": true // true to follow robot, false for fixed camera
347
- }
348
  ```
349
 
350
- #### Server → Client
351
-
352
- | Event | Payload | Description |
353
- |-------|---------|-------------|
354
- | `state` | `{robot, base_height, ...}` | Robot state (broadcast at render rate) |
355
 
356
- **`state` payload:**
357
  ```json
358
  {
359
- "robot": "spot", // Current robot type
360
- "base_height": 0.46, // Robot base height in meters
361
- "upright": 0.98, // Uprightness metric [0-1] (1 = perfectly upright)
362
- "steps": 1234, // Simulation step count
363
- "vx": 0.5, // Current forward velocity command
364
- "vy": 0.0, // Current strafe velocity command
365
- "vyaw": 0.0 // Current turn rate command
 
 
 
366
  }
367
  ```
368
 
@@ -370,12 +383,12 @@ Resets the robot to its initial standing pose and resets the camera position.
370
 
371
  | Endpoint | Method | Description |
372
  |----------|--------|-------------|
373
- | `/` | GET | Web interface (HTML/JS) |
374
- | `/video_feed` | GET | MJPEG video stream |
375
 
376
  **Video stream usage:**
377
  ```html
378
- <img src="http://localhost:3004/video_feed" />
379
  ```
380
 
381
  ## License
 
31
 
32
  ```bash
33
  # Install dependencies
34
+ pip install mujoco gymnasium flask flask-sock opencv-python torch numpy
35
 
36
  # Optional: For PyMPC gait controller
37
  pip install jax jaxlib quadruped-pympc gym-quadruped
 
39
  # Start the server
40
  python mujoco_server.py
41
 
42
+ # Open browser at http://localhost:3004/nova-sim/api/v1
43
  ```
44
 
45
  ### Docker
 
169
  ┌─────────────────────────────────────────────────────────────────────────┐
170
  │ mujoco_server.py │
171
  │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
172
+ │ │ Flask + WSGI │ │ WebSocket │ │ Render Thread │ │
173
  │ │ HTTP endpoints │ │ cmd, reset, │ │ 60 FPS loop │ │
174
  │ │ │ │ switch_robot │ │ MJPEG encode │ │
175
  │ └─────────────────┘ └────────┬────────┘ └────────┬────────┘ │
 
289
 
290
  ## API
291
 
292
+ All endpoints are prefixed with `/nova-sim/api/v1`.
293
 
294
+ ### WebSocket
295
 
296
+ Connect using standard WebSocket:
297
 
298
+ ```javascript
299
+ const ws = new WebSocket('ws://localhost:3004/nova-sim/api/v1/ws');
300
+
301
+ // Send message
302
+ ws.send(JSON.stringify({type: 'command', data: {vx: 0.5, vy: 0, vyaw: 0}}));
303
+
304
+ // Receive messages
305
+ ws.onmessage = (event) => {
306
+ const msg = JSON.parse(event.data);
307
+ if (msg.type === 'state') {
308
+ console.log(msg.data);
309
+ }
310
+ };
311
+ ```
312
+
313
+ #### Client → Server Messages
314
+
315
+ All messages are JSON with `{type, data}` structure:
316
+
317
+ | Type | Data | Description |
318
+ |------|------|-------------|
319
  | `command` | `{vx, vy, vyaw}` | Set velocity command |
320
+ | `reset` | `{}` | Reset robot to standing pose |
321
  | `switch_robot` | `{robot}` | Switch active robot |
322
+ | `camera` | `{action, ...}` | Camera control |
323
  | `camera_follow` | `{follow}` | Toggle camera follow mode |
324
 
325
+ **`command`:**
326
  ```json
327
+ {"type": "command", "data": {"vx": 0.5, "vy": 0.0, "vyaw": 0.0}}
 
 
 
 
328
  ```
329
+ - `vx`: Forward/backward velocity [-1, 1]
330
+ - `vy`: Left/right strafe velocity [-1, 1]
331
+ - `vyaw`: Turn rate [-1, 1]
332
 
333
+ **`reset`:**
334
+ ```json
335
+ {"type": "reset", "data": {}}
336
  ```
 
337
 
338
+ **`switch_robot`:**
339
  ```json
340
+ {"type": "switch_robot", "data": {"robot": "spot"}}
 
 
341
  ```
342
+ - `robot`: `"g1"` or `"spot"`
343
 
344
+ **`camera`:**
345
  ```json
346
  // Rotate camera
347
+ {"type": "camera", "data": {"action": "rotate", "dx": 10, "dy": 5}}
348
 
349
  // Zoom camera
350
+ {"type": "camera", "data": {"action": "zoom", "dz": -50}}
351
 
352
  // Pan camera
353
+ {"type": "camera", "data": {"action": "pan", "dx": 10, "dy": 5}}
354
 
355
  // Set absolute distance
356
+ {"type": "camera", "data": {"action": "set_distance", "distance": 3.0}}
357
  ```
358
 
359
+ **`camera_follow`:**
360
  ```json
361
+ {"type": "camera_follow", "data": {"follow": true}}
 
 
362
  ```
363
 
364
+ #### Server → Client Messages
 
 
 
 
365
 
366
+ **`state`** (broadcast at ~10 Hz):
367
  ```json
368
  {
369
+ "type": "state",
370
+ "data": {
371
+ "robot": "spot",
372
+ "base_height": 0.46,
373
+ "upright": 0.98,
374
+ "steps": 1234,
375
+ "vx": 0.5,
376
+ "vy": 0.0,
377
+ "vyaw": 0.0
378
+ }
379
  }
380
  ```
381
 
 
383
 
384
  | Endpoint | Method | Description |
385
  |----------|--------|-------------|
386
+ | `/nova-sim/api/v1` | GET | Web interface (HTML/JS) |
387
+ | `/nova-sim/api/v1/video_feed` | GET | MJPEG video stream |
388
 
389
  **Video stream usage:**
390
  ```html
391
+ <img src="http://localhost:3004/nova-sim/api/v1/video_feed" />
392
  ```
393
 
394
  ## License
mujoco_server.py CHANGED
@@ -2,11 +2,12 @@ import os
2
  import sys
3
  import time
4
  import threading
 
5
  import cv2
6
  import numpy as np
7
  import mujoco
8
  from flask import Flask, Response, render_template_string, request, jsonify
9
- from flask_socketio import SocketIO, emit
10
 
11
  # Add robots directory to path for imports
12
  _nova_sim_dir = os.path.dirname(os.path.abspath(__file__))
@@ -14,9 +15,12 @@ sys.path.insert(0, os.path.join(_nova_sim_dir, 'robots', 'g1'))
14
  from g1_env import G1Env
15
  sys.path.pop(0)
16
 
 
 
 
17
  app = Flask(__name__)
18
  app.config['SECRET_KEY'] = 'robotsim-secret'
19
- socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading')
20
 
21
  # Detect if running in Docker (check for /.dockerenv or cgroup)
22
  def is_running_in_docker():
@@ -54,6 +58,10 @@ mujoco_lock = threading.Lock()
54
  renderer = None
55
  needs_robot_switch = None # Robot to switch to
56
 
 
 
 
 
57
  # Camera state for orbit controls
58
  cam = mujoco.MjvCamera()
59
  cam.azimuth = 135
@@ -128,7 +136,7 @@ env = init_g1()
128
 
129
 
130
  def broadcast_state():
131
- """Broadcast robot state to all connected clients."""
132
  with mujoco_lock:
133
  if env is None:
134
  return
@@ -140,16 +148,30 @@ def broadcast_state():
140
  base_quat = obs[3:7]
141
  upright = float(base_quat[0] ** 2)
142
 
143
- socketio.emit('state', {
144
- 'robot': current_robot,
145
- 'base_height': base_height,
146
- 'upright': upright,
147
- 'steps': int(steps),
148
- 'vx': float(cmd[0]),
149
- 'vy': float(cmd[1]),
150
- 'vyaw': float(cmd[2])
 
 
 
151
  })
152
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
  def simulation_loop():
155
  global running, latest_frame, camera_follow, renderer, needs_robot_switch, env
@@ -238,14 +260,120 @@ def generate_frames():
238
  time.sleep(0.04)
239
 
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  @app.route('/')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  def index():
243
  return render_template_string("""
244
  <!DOCTYPE html>
245
  <html>
246
  <head>
247
- <title>Robot Simulator - MuJoCo</title>
248
- <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.6.1/socket.io.min.js"></script>
249
  <style>
250
  body, html {
251
  margin: 0; padding: 0; width: 100%; height: 100%;
@@ -331,10 +459,10 @@ def index():
331
  </head>
332
  <body>
333
  <div class="video-container" id="viewport">
334
- <img src="/video_feed" draggable="false">
335
  </div>
336
 
337
- <div class="connection-status" id="conn_status">Connected</div>
338
 
339
  <div class="overlay">
340
  <h2 id="robot_title">Unitree G1 Humanoid</h2>
@@ -404,7 +532,12 @@ def index():
404
  </div>
405
 
406
  <script>
407
- const socket = io();
 
 
 
 
 
408
  const connStatus = document.getElementById('conn_status');
409
  const robotSelect = document.getElementById('robot_select');
410
  const robotTitle = document.getElementById('robot_title');
@@ -420,29 +553,63 @@ def index():
420
  'spot': 'Boston Dynamics Spot'
421
  };
422
 
423
- socket.on('connect', () => {
424
- connStatus.textContent = 'Connected';
425
- connStatus.classList.remove('disconnected');
426
- });
427
-
428
- socket.on('disconnect', () => {
429
- connStatus.textContent = 'Disconnected';
430
- connStatus.classList.add('disconnected');
431
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
 
433
- socket.on('state', (data) => {
434
- heightVal.innerText = data.base_height.toFixed(2);
435
- uprightVal.innerText = data.upright.toFixed(2);
436
- stepVal.innerText = data.steps;
437
- cmdVx.innerText = data.vx.toFixed(1);
438
- cmdVy.innerText = data.vy.toFixed(1);
439
- cmdYaw.innerText = data.vyaw.toFixed(1);
440
-
441
- if (data.robot && robotSelect.value !== data.robot) {
442
- robotSelect.value = data.robot;
443
- updateRobotUI(data.robot);
444
  }
445
- });
446
 
447
  function updateRobotUI(robot) {
448
  robotTitle.innerText = robotTitles[robot] || robot;
@@ -451,7 +618,7 @@ def index():
451
 
452
  function switchRobot() {
453
  const robot = robotSelect.value;
454
- socket.emit('switch_robot', {robot});
455
  updateRobotUI(robot);
456
  }
457
 
@@ -468,16 +635,16 @@ def index():
468
  let keysPressed = new Set();
469
 
470
  function resetEnv() {
471
- socket.emit('reset');
472
  }
473
 
474
  function setCmd(vx, vy, vyaw) {
475
- socket.emit('command', {vx, vy, vyaw});
476
  }
477
 
478
  function setCameraFollow() {
479
  const follow = document.getElementById('cam_follow').checked;
480
- socket.emit('camera_follow', {follow});
481
  }
482
 
483
  function updateCmdFromKeys() {
@@ -510,7 +677,7 @@ def index():
510
 
511
  camDist.oninput = () => {
512
  camDistVal.innerText = parseFloat(camDist.value).toFixed(1);
513
- socket.emit('camera', {type: 'set_distance', distance: parseFloat(camDist.value)});
514
  };
515
 
516
  let isDragging = false;
@@ -534,101 +701,28 @@ def index():
534
  const dy = e.clientY - lastY;
535
  lastX = e.clientX;
536
  lastY = e.clientY;
537
- socket.emit('camera', {type: 'rotate', dx, dy});
538
  }
539
  };
540
 
541
  viewport.onwheel = (e) => {
542
  e.preventDefault();
543
- socket.emit('camera', {type: 'zoom', dz: e.deltaY});
544
  };
 
 
 
545
  </script>
546
  </body>
547
  </html>
548
  """)
549
 
550
 
551
- @app.route('/video_feed')
552
  def video_feed():
553
  return Response(generate_frames(),
554
  mimetype='multipart/x-mixed-replace; boundary=frame')
555
 
556
 
557
- @socketio.on('connect')
558
- def handle_connect():
559
- print('Client connected')
560
-
561
-
562
- @socketio.on('disconnect')
563
- def handle_disconnect():
564
- print('Client disconnected')
565
-
566
-
567
- @socketio.on('switch_robot')
568
- def handle_switch_robot(data):
569
- """Handle robot switch request from client."""
570
- global needs_robot_switch
571
- robot = data.get('robot', 'g1')
572
- print(f"Robot switch requested: {robot}")
573
- needs_robot_switch = robot
574
-
575
-
576
- @socketio.on('command')
577
- def handle_command(data):
578
- """Handle velocity command from client."""
579
- vx = data.get('vx', 0.0)
580
- vy = data.get('vy', 0.0)
581
- vyaw = data.get('vyaw', 0.0)
582
- with mujoco_lock:
583
- if env is not None:
584
- env.set_command(vx, vy, vyaw)
585
-
586
-
587
- @socketio.on('reset')
588
- def handle_reset():
589
- """Handle reset request from client."""
590
- global camera_follow
591
- with mujoco_lock:
592
- if env is not None:
593
- env.reset()
594
- cam.azimuth = 135
595
- cam.elevation = -20
596
- if current_robot == "g1":
597
- cam.distance = 3.0
598
- cam.lookat = np.array([0.0, 0.0, 0.8])
599
- else:
600
- cam.distance = 2.5
601
- cam.lookat = np.array([0.0, 0.0, 0.4])
602
-
603
-
604
- @socketio.on('camera')
605
- def handle_camera(data):
606
- """Handle camera control from client."""
607
- a = cam.azimuth * np.pi / 180.0
608
- e = cam.elevation * np.pi / 180.0
609
-
610
- if data['type'] == 'rotate':
611
- cam.azimuth -= data['dx'] * 0.5
612
- cam.elevation -= data['dy'] * 0.5
613
- cam.elevation = np.clip(cam.elevation, -89, 89)
614
- elif data['type'] == 'zoom':
615
- cam.distance += data['dz'] * 0.01
616
- cam.distance = max(0.5, min(20.0, cam.distance))
617
- elif data['type'] == 'set_distance':
618
- cam.distance = data['distance']
619
- elif data['type'] == 'pan':
620
- right = np.array([np.sin(a), -np.cos(a), 0])
621
- up = np.array([-np.cos(a) * np.sin(e), -np.sin(a) * np.sin(e), np.cos(e)])
622
- scale = cam.distance * 0.002
623
- cam.lookat -= (data['dx'] * right - data['dy'] * up) * scale
624
-
625
-
626
- @socketio.on('camera_follow')
627
- def handle_camera_follow(data):
628
- """Handle camera follow toggle from client."""
629
- global camera_follow
630
- camera_follow = data.get('follow', True)
631
-
632
-
633
  if __name__ == '__main__':
634
- socketio.run(app, host='0.0.0.0', port=3004, debug=False, allow_unsafe_werkzeug=True)
 
2
  import sys
3
  import time
4
  import threading
5
+ import json
6
  import cv2
7
  import numpy as np
8
  import mujoco
9
  from flask import Flask, Response, render_template_string, request, jsonify
10
+ from flask_sock import Sock
11
 
12
  # Add robots directory to path for imports
13
  _nova_sim_dir = os.path.dirname(os.path.abspath(__file__))
 
15
  from g1_env import G1Env
16
  sys.path.pop(0)
17
 
18
+ # API prefix for all endpoints
19
+ API_PREFIX = '/nova-sim/api/v1'
20
+
21
  app = Flask(__name__)
22
  app.config['SECRET_KEY'] = 'robotsim-secret'
23
+ sock = Sock(app)
24
 
25
  # Detect if running in Docker (check for /.dockerenv or cgroup)
26
  def is_running_in_docker():
 
58
  renderer = None
59
  needs_robot_switch = None # Robot to switch to
60
 
61
+ # WebSocket clients
62
+ ws_clients = set()
63
+ ws_clients_lock = threading.Lock()
64
+
65
  # Camera state for orbit controls
66
  cam = mujoco.MjvCamera()
67
  cam.azimuth = 135
 
136
 
137
 
138
  def broadcast_state():
139
+ """Broadcast robot state to all connected WebSocket clients."""
140
  with mujoco_lock:
141
  if env is None:
142
  return
 
148
  base_quat = obs[3:7]
149
  upright = float(base_quat[0] ** 2)
150
 
151
+ state_msg = json.dumps({
152
+ 'type': 'state',
153
+ 'data': {
154
+ 'robot': current_robot,
155
+ 'base_height': base_height,
156
+ 'upright': upright,
157
+ 'steps': int(steps),
158
+ 'vx': float(cmd[0]),
159
+ 'vy': float(cmd[1]),
160
+ 'vyaw': float(cmd[2])
161
+ }
162
  })
163
 
164
+ # Send to all connected clients
165
+ with ws_clients_lock:
166
+ dead_clients = set()
167
+ for ws in ws_clients:
168
+ try:
169
+ ws.send(state_msg)
170
+ except:
171
+ dead_clients.add(ws)
172
+ # Remove dead clients
173
+ ws_clients.difference_update(dead_clients)
174
+
175
 
176
  def simulation_loop():
177
  global running, latest_frame, camera_follow, renderer, needs_robot_switch, env
 
260
  time.sleep(0.04)
261
 
262
 
263
+ def handle_ws_message(data):
264
+ """Handle incoming WebSocket message."""
265
+ global needs_robot_switch, camera_follow
266
+
267
+ msg_type = data.get('type')
268
+
269
+ if msg_type == 'command':
270
+ payload = data.get('data', {})
271
+ vx = payload.get('vx', 0.0)
272
+ vy = payload.get('vy', 0.0)
273
+ vyaw = payload.get('vyaw', 0.0)
274
+ with mujoco_lock:
275
+ if env is not None:
276
+ env.set_command(vx, vy, vyaw)
277
+
278
+ elif msg_type == 'reset':
279
+ with mujoco_lock:
280
+ if env is not None:
281
+ env.reset()
282
+ cam.azimuth = 135
283
+ cam.elevation = -20
284
+ if current_robot == "g1":
285
+ cam.distance = 3.0
286
+ cam.lookat = np.array([0.0, 0.0, 0.8])
287
+ else:
288
+ cam.distance = 2.5
289
+ cam.lookat = np.array([0.0, 0.0, 0.4])
290
+
291
+ elif msg_type == 'switch_robot':
292
+ payload = data.get('data', {})
293
+ robot = payload.get('robot', 'g1')
294
+ print(f"Robot switch requested: {robot}")
295
+ needs_robot_switch = robot
296
+
297
+ elif msg_type == 'camera':
298
+ payload = data.get('data', {})
299
+ a = cam.azimuth * np.pi / 180.0
300
+ e = cam.elevation * np.pi / 180.0
301
+
302
+ cam_type = payload.get('action')
303
+ if cam_type == 'rotate':
304
+ cam.azimuth -= payload.get('dx', 0) * 0.5
305
+ cam.elevation -= payload.get('dy', 0) * 0.5
306
+ cam.elevation = np.clip(cam.elevation, -89, 89)
307
+ elif cam_type == 'zoom':
308
+ cam.distance += payload.get('dz', 0) * 0.01
309
+ cam.distance = max(0.5, min(20.0, cam.distance))
310
+ elif cam_type == 'set_distance':
311
+ cam.distance = payload.get('distance', 3.0)
312
+ elif cam_type == 'pan':
313
+ right = np.array([np.sin(a), -np.cos(a), 0])
314
+ up = np.array([-np.cos(a) * np.sin(e), -np.sin(a) * np.sin(e), np.cos(e)])
315
+ scale = cam.distance * 0.002
316
+ cam.lookat -= (payload.get('dx', 0) * right - payload.get('dy', 0) * up) * scale
317
+
318
+ elif msg_type == 'camera_follow':
319
+ payload = data.get('data', {})
320
+ camera_follow = payload.get('follow', True)
321
+
322
+
323
+ @sock.route(f'{API_PREFIX}/ws')
324
+ def websocket_handler(ws):
325
+ """Handle WebSocket connections."""
326
+ print('WebSocket client connected')
327
+
328
+ # Register client
329
+ with ws_clients_lock:
330
+ ws_clients.add(ws)
331
+
332
+ try:
333
+ while True:
334
+ message = ws.receive()
335
+ if message is None:
336
+ break
337
+ try:
338
+ data = json.loads(message)
339
+ handle_ws_message(data)
340
+ except json.JSONDecodeError:
341
+ print(f"Invalid JSON received: {message}")
342
+ except Exception as e:
343
+ print(f"Error handling message: {e}")
344
+ except:
345
+ pass
346
+ finally:
347
+ # Unregister client
348
+ with ws_clients_lock:
349
+ ws_clients.discard(ws)
350
+ print('WebSocket client disconnected')
351
+
352
+
353
+ # Redirect root to UI
354
  @app.route('/')
355
+ def root_redirect():
356
+ return render_template_string("""
357
+ <!DOCTYPE html>
358
+ <html>
359
+ <head>
360
+ <meta http-equiv="refresh" content="0; url='""" + API_PREFIX + """'" />
361
+ </head>
362
+ <body>
363
+ <p>Redirecting to <a href=\"""" + API_PREFIX + """\">""" + API_PREFIX + """</a>...</p>
364
+ </body>
365
+ </html>
366
+ """)
367
+
368
+
369
+ @app.route(API_PREFIX)
370
+ @app.route(f'{API_PREFIX}/')
371
  def index():
372
  return render_template_string("""
373
  <!DOCTYPE html>
374
  <html>
375
  <head>
376
+ <title>Nova Sim - Robot Simulator</title>
 
377
  <style>
378
  body, html {
379
  margin: 0; padding: 0; width: 100%; height: 100%;
 
459
  </head>
460
  <body>
461
  <div class="video-container" id="viewport">
462
+ <img src=\"""" + API_PREFIX + """/video_feed" draggable="false">
463
  </div>
464
 
465
+ <div class="connection-status" id="conn_status">Connecting...</div>
466
 
467
  <div class="overlay">
468
  <h2 id="robot_title">Unitree G1 Humanoid</h2>
 
532
  </div>
533
 
534
  <script>
535
+ const API_PREFIX = '""" + API_PREFIX + """';
536
+ const WS_URL = (window.location.protocol === 'https:' ? 'wss://' : 'ws://') +
537
+ window.location.host + API_PREFIX + '/ws';
538
+
539
+ let ws = null;
540
+ let reconnectTimer = null;
541
  const connStatus = document.getElementById('conn_status');
542
  const robotSelect = document.getElementById('robot_select');
543
  const robotTitle = document.getElementById('robot_title');
 
553
  'spot': 'Boston Dynamics Spot'
554
  };
555
 
556
+ function connect() {
557
+ ws = new WebSocket(WS_URL);
558
+
559
+ ws.onopen = () => {
560
+ connStatus.textContent = 'Connected';
561
+ connStatus.classList.remove('disconnected');
562
+ if (reconnectTimer) {
563
+ clearInterval(reconnectTimer);
564
+ reconnectTimer = null;
565
+ }
566
+ };
567
+
568
+ ws.onclose = () => {
569
+ connStatus.textContent = 'Disconnected';
570
+ connStatus.classList.add('disconnected');
571
+ // Auto-reconnect
572
+ if (!reconnectTimer) {
573
+ reconnectTimer = setInterval(() => {
574
+ if (ws.readyState === WebSocket.CLOSED) {
575
+ connect();
576
+ }
577
+ }, 2000);
578
+ }
579
+ };
580
+
581
+ ws.onerror = (err) => {
582
+ console.error('WebSocket error:', err);
583
+ };
584
+
585
+ ws.onmessage = (event) => {
586
+ try {
587
+ const msg = JSON.parse(event.data);
588
+ if (msg.type === 'state') {
589
+ const data = msg.data;
590
+ heightVal.innerText = data.base_height.toFixed(2);
591
+ uprightVal.innerText = data.upright.toFixed(2);
592
+ stepVal.innerText = data.steps;
593
+ cmdVx.innerText = data.vx.toFixed(1);
594
+ cmdVy.innerText = data.vy.toFixed(1);
595
+ cmdYaw.innerText = data.vyaw.toFixed(1);
596
+
597
+ if (data.robot && robotSelect.value !== data.robot) {
598
+ robotSelect.value = data.robot;
599
+ updateRobotUI(data.robot);
600
+ }
601
+ }
602
+ } catch (e) {
603
+ console.error('Error parsing message:', e);
604
+ }
605
+ };
606
+ }
607
 
608
+ function send(type, data = {}) {
609
+ if (ws && ws.readyState === WebSocket.OPEN) {
610
+ ws.send(JSON.stringify({type, data}));
 
 
 
 
 
 
 
 
611
  }
612
+ }
613
 
614
  function updateRobotUI(robot) {
615
  robotTitle.innerText = robotTitles[robot] || robot;
 
618
 
619
  function switchRobot() {
620
  const robot = robotSelect.value;
621
+ send('switch_robot', {robot});
622
  updateRobotUI(robot);
623
  }
624
 
 
635
  let keysPressed = new Set();
636
 
637
  function resetEnv() {
638
+ send('reset');
639
  }
640
 
641
  function setCmd(vx, vy, vyaw) {
642
+ send('command', {vx, vy, vyaw});
643
  }
644
 
645
  function setCameraFollow() {
646
  const follow = document.getElementById('cam_follow').checked;
647
+ send('camera_follow', {follow});
648
  }
649
 
650
  function updateCmdFromKeys() {
 
677
 
678
  camDist.oninput = () => {
679
  camDistVal.innerText = parseFloat(camDist.value).toFixed(1);
680
+ send('camera', {action: 'set_distance', distance: parseFloat(camDist.value)});
681
  };
682
 
683
  let isDragging = false;
 
701
  const dy = e.clientY - lastY;
702
  lastX = e.clientX;
703
  lastY = e.clientY;
704
+ send('camera', {action: 'rotate', dx, dy});
705
  }
706
  };
707
 
708
  viewport.onwheel = (e) => {
709
  e.preventDefault();
710
+ send('camera', {action: 'zoom', dz: e.deltaY});
711
  };
712
+
713
+ // Connect on load
714
+ connect();
715
  </script>
716
  </body>
717
  </html>
718
  """)
719
 
720
 
721
+ @app.route(f'{API_PREFIX}/video_feed')
722
  def video_feed():
723
  return Response(generate_frames(),
724
  mimetype='multipart/x-mixed-replace; boundary=frame')
725
 
726
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
727
  if __name__ == '__main__':
728
+ app.run(host='0.0.0.0', port=3004, debug=False, threaded=True)
requirements.txt CHANGED
@@ -1,7 +1,7 @@
1
  mujoco>=3.1.3
2
  gymnasium>=0.29.0
3
  flask>=3.0.0
4
- flask-socketio>=5.3.0
5
  opencv-python>=4.8.0
6
  torch>=2.0.0
7
  numpy>=1.24.0
 
1
  mujoco>=3.1.3
2
  gymnasium>=0.29.0
3
  flask>=3.0.0
4
+ flask-sock>=0.7.0
5
  opencv-python>=4.8.0
6
  torch>=2.0.0
7
  numpy>=1.24.0