Georg commited on
Commit
9013e5d
·
1 Parent(s): f6af091

Refactor teleoperation commands to actions in mujoco_server.py and update protocol documentation

Browse files

- Renamed `teleop_command` to `teleop_action` and `command` to `action` for consistency across the codebase.
- Updated message handling to support new action-based velocity control for UR5 robots, including backward compatibility for legacy message types.
- Enhanced the README.md and PROTOCOL_CHANGES.md to reflect the new action structure and provide clear migration guidance for developers.
- Introduced a new `protocol_types.py` file with TypeScript-style type definitions for all protocol messages, improving type safety and IDE support.
- Updated tests to validate the new action message structure and ensure backward compatibility with existing command formats.

Files changed (4) hide show
  1. README.md +39 -25
  2. mujoco_server.py +154 -71
  3. protocol_types.py +456 -0
  4. tests/test_api.py +213 -45
README.md CHANGED
@@ -238,7 +238,7 @@ docker run --gpus all -p 3004:3004 \
238
  - **Joint Mode**: Use J1-J6 sliders to control individual joints
239
  - **Gripper**: Open/Close buttons for Robotiq gripper
240
  - **Home**: Return to home position (joint mode)
241
- - **Keyboard Teleop**: With the UR5 selected, W/A/S/D jogs the tool in the XY plane, R/F nudges along Z, and every keystroke streams a `teleop_command` event that mirrors into the `command` field so the browser panel and any trainer see the same velocity delta.
242
 
243
  ### Common Controls
244
  - **Mouse drag**: Rotate camera
@@ -332,7 +332,7 @@ docker run --gpus all -p 3004:3004 \
332
 
333
  1. **User Input** → Browser captures keyboard/button events
334
  2. **WebSocket** → Commands sent as `{vx, vy, vyaw}` velocity targets
335
- 3. **Controller** → Converts velocity commands to joint position targets
336
  4. **MuJoCo** → Simulates physics at 500Hz (0.002s timestep)
337
  5. **Renderer** → Captures frames at 60 FPS (native) or 30 FPS (Docker)
338
  6. **MJPEG Stream** → Frames encoded as JPEG and streamed to browser
@@ -340,7 +340,7 @@ docker run --gpus all -p 3004:3004 \
340
 
341
  ### Controller Architecture
342
 
343
- **G1 (Humanoid)**: Uses a pre-trained RL policy (PyTorch) that maps observations (body orientation, joint positions/velocities, commands) to joint position targets.
344
 
345
  **Spot (Quadruped)**: Uses gait-based controllers:
346
  - **MPC Gait**: Phase-based trot with feedback balance control
@@ -378,7 +378,7 @@ Connect using standard WebSocket:
378
  const ws = new WebSocket('ws://localhost:3004/nova-sim/api/v1/ws');
379
 
380
  // Send message
381
- ws.send(JSON.stringify({type: 'command', data: {vx: 0.5, vy: 0, vyaw: 0}}));
382
 
383
  // Receive messages
384
  ws.onmessage = (event) => {
@@ -389,7 +389,7 @@ ws.onmessage = (event) => {
389
  };
390
  ```
391
 
392
- Nova-Sim uses `/ws` as the shared control channel for the browser UI, trainers, and any RL clients. Every UI interaction (teleop, camera controls, robot switching) and the trainer handshake/notifications flows through this single socket; the UI `state` messages shown below now also carry the action deltas, integrated reward, and trainer connection status that RL agents need.
393
 
394
  ### HTTP Endpoints
395
 
@@ -398,7 +398,7 @@ Nova-Sim provides a minimal HTTP API for static information:
398
  | Endpoint | Method | Description |
399
  |----------|--------|-------------|
400
  | `/env` | `GET` | Returns static environment information: robot, scene, has_gripper, action_space, observation_space, camera_feeds |
401
- | `/metadata` | `GET` | Returns available robots, scenes, commands, and system configuration |
402
  | `/video_feed` | `GET` | MJPEG video stream of the main camera |
403
  | `/camera/<name>/video_feed` | `GET` | MJPEG video stream of auxiliary cameras (e.g., aux_top, aux_side) |
404
 
@@ -421,18 +421,28 @@ Nova-Sim provides a minimal HTTP API for static information:
421
 
422
  The `/env` endpoint returns scene-specific information including camera feeds available for the current robot/scene configuration.
423
 
424
- All dynamic operations (reset, switching robots, sending commands) are performed via WebSocket messages. Training data (observations, rewards, etc.) come from the `/ws` state stream.
425
 
426
  ### Client → Server WebSocket Messages
427
 
428
- **`command`** - Send velocity commands to locomotion robots:
429
  ```json
430
- {"type": "command", "data": {"vx": 0.5, "vy": 0.0, "vyaw": 0.0}}
431
  ```
 
 
432
  - `vx`: Forward/backward velocity [-1, 1]
433
  - `vy`: Left/right strafe velocity [-1, 1]
434
  - `vyaw`: Turn rate [-1, 1]
435
 
 
 
 
 
 
 
 
 
436
  **`reset`** - Reset the environment:
437
  ```json
438
  {"type": "reset", "data": {"seed": 42}}
@@ -451,12 +461,13 @@ All dynamic operations (reset, switching robots, sending commands) are performed
451
  {"type": "home"}
452
  ```
453
 
454
- **`teleop_command`** - Send Cartesian teleop delta (UR5) or velocity (locomotion):
455
  ```json
456
- {"type": "teleop_command", "data": {"vx": 0.01, "vy": 0.0, "vz": 0.0}}
457
  ```
458
- - `vx`, `vy`, `vz`: Velocity/delta values
459
  - Backward compatible: old `dx`, `dy`, `dz` format is auto-mapped to `vx`, `vy`, `vz`
 
460
 
461
  **`camera`:**
462
  ```json
@@ -532,7 +543,7 @@ For locomotion robots (G1, Spot):
532
  },
533
  "steps": 1234,
534
  "reward": 0.0,
535
- "teleop_command": {"vx": 0.5, "vy": 0.0, "vz": 0.0, "vyaw": 0.0, "gripper": 0.0},
536
  "trainer_connected": true
537
  }
538
  }
@@ -555,7 +566,7 @@ For robot arm (UR5):
555
  "control_mode": "ik",
556
  "steps": 1234,
557
  "reward": -0.25,
558
- "teleop_command": {"vx": 0.02, "vy": 0.0, "vz": 0.0, "vyaw": 0.0, "gripper": 128.0},
559
  "trainer_connected": true,
560
  "nova_api": {
561
  "connected": true,
@@ -571,10 +582,13 @@ For robot arm (UR5):
571
  - `observation`: Contains robot-specific sensor data and state information
572
  - `steps`: Number of simulation steps since last reset
573
  - `reward`: The integrated task reward from the simulator that remote trainers can consume
574
- - `teleop_command`: The canonical velocity/delta stream that drives locomotion or arm movement; the UI and every trainer should read this field as the unified action record. Always present with zero values when idle
575
- - Common fields: `vx` (forward/back or X-axis), `vy` (strafe or Y-axis), `vz` (vertical for UR5), `vyaw` (rotation for locomotion), `gripper` (0-255 for UR5, 0 for others)
576
- - Locomotion robots: Use `vx`, `vy`, `vyaw` (vz and gripper are 0)
577
- - UR5: Use `vx`, `vy`, `vz` for Cartesian movement, `gripper` for gripper control (vyaw is 0)
 
 
 
578
  - `trainer_connected`: Whether a trainer handshake is active on `/ws` (useful for status LEDs)
579
 
580
  *Locomotion observation fields (inside `observation`):*
@@ -603,7 +617,7 @@ For robot arm (UR5):
603
 
604
  Every `/ws` client receives a `state` message roughly every 100 ms. The examples above show the locomotion (`spot`) and arm (`ur5`) payloads; the payload also now includes:
605
 
606
- - `teleop_command`: The latest command/teleoperation stream (`vx`, `vy`, `vz`, `vyaw`) so trainers and the UI read a single canonical action payload. Always present with zero values when idle.
607
  - `reward`: The integrated task reward that trainers can consume without sending a separate `step`.
608
  - `trainer_connected`: Whether a trainer handshake is active on `/ws` (used to update the UI indicator).
609
 
@@ -615,7 +629,7 @@ Trainers announce themselves by sending a `trainer_identity` payload when the so
615
  |----------|--------|-------------|
616
  | `/nova-sim/api/v1` | GET | Web interface (HTML/JS) |
617
  | `/nova-sim/api/v1/env` | GET | Static environment info (robot, scene, spaces, camera feeds) |
618
- | `/nova-sim/api/v1/metadata` | GET | Available robots, scenes, commands, and system configuration |
619
  | `/nova-sim/api/v1/video_feed` | GET | MJPEG video stream (main camera) |
620
  | `/nova-sim/api/v1/camera/<name>/video_feed` | GET | MJPEG video stream (auxiliary cameras) |
621
 
@@ -628,7 +642,7 @@ Trainers announce themselves by sending a `trainer_identity` payload when the so
628
 
629
  ### Metadata & Camera Feeds
630
 
631
- - `GET /nova-sim/api/v1/metadata` returns JSON describing every available robot/scene pair and the supported commands
632
  - `GET /nova-sim/api/v1/env` returns scene-specific camera feeds - the `camera_feeds` array lists all available video streams for the current robot/scene configuration including the main camera and any auxiliary cameras (e.g., `aux_top`, `aux_side`, `aux_flange`)
633
  - `GET /nova-sim/api/v1/camera/<name>/video_feed` streams MJPEG for a specific camera feed
634
  - `pytest tests/` exercises the HTTP metadata/video endpoints, the `/ws` control socket, and every camera feed. Keep Nova-Sim running at `http://localhost:3004` when you run it so the suite can talk to the live server.
@@ -751,7 +765,7 @@ env = UR5Env(
751
 
752
  **Note**: When state streaming is enabled, the simulation becomes a read-only digital twin that displays the real robot's movements. Local target controls and IK computations are ignored since the robot is controlled by external hardware/software.
753
 
754
- **Sending Commands to Robot**: To send motion commands to the real robot through Nova API, use Nova's motion execution endpoints directly (not currently implemented in the web UI). The simulation is designed to visualize robot state, not to command it.
755
 
756
  ### Environment Variables Reference
757
 
@@ -873,10 +887,10 @@ This project uses models from:
873
  - [unitree_mujoco](https://github.com/unitreerobotics/unitree_mujoco) (BSD-3-Clause)
874
  - [unitree_rl_gym](https://github.com/unitreerobotics/unitree_rl_gym)
875
  - [Quadruped-PyMPC](https://github.com/iit-DLSLab/Quadruped-PyMPC) (BSD-3-Clause)
876
- **`teleop_command`:**
877
  ```json
878
- {"type": "teleop_command", "data": {"vx": 0.01, "vy": 0.0, "vz": -0.01}}
879
  ```
880
  - `vx`, `vy`, `vz`: Velocity/delta values for UR5 Cartesian movement (WASD + RF from the UI) or locomotion robot velocity (`vx`, `vy`, `vyaw`)
881
- - These values appear in the `teleop_command` field of each `/ws` `state` broadcast, which is the canonical action stream for both the UI and any RL trainer
882
  - The field always contains zero values when idle (never null)
 
238
  - **Joint Mode**: Use J1-J6 sliders to control individual joints
239
  - **Gripper**: Open/Close buttons for Robotiq gripper
240
  - **Home**: Return to home position (joint mode)
241
+ - **Keyboard Teleop**: With the UR5 selected, W/A/S/D jogs the tool in the XY plane, R/F nudges along Z, and every keystroke streams a `teleop_action` event so the browser panel and any trainer see the same velocity delta.
242
 
243
  ### Common Controls
244
  - **Mouse drag**: Rotate camera
 
332
 
333
  1. **User Input** → Browser captures keyboard/button events
334
  2. **WebSocket** → Commands sent as `{vx, vy, vyaw}` velocity targets
335
+ 3. **Controller** → Converts velocity actions to joint position targets
336
  4. **MuJoCo** → Simulates physics at 500Hz (0.002s timestep)
337
  5. **Renderer** → Captures frames at 60 FPS (native) or 30 FPS (Docker)
338
  6. **MJPEG Stream** → Frames encoded as JPEG and streamed to browser
 
340
 
341
  ### Controller Architecture
342
 
343
+ **G1 (Humanoid)**: Uses a pre-trained RL policy (PyTorch) that maps observations (body orientation, joint positions/velocities, actions) to joint position targets.
344
 
345
  **Spot (Quadruped)**: Uses gait-based controllers:
346
  - **MPC Gait**: Phase-based trot with feedback balance control
 
378
  const ws = new WebSocket('ws://localhost:3004/nova-sim/api/v1/ws');
379
 
380
  // Send message
381
+ ws.send(JSON.stringify({type: 'action', data: {vx: 0.5, vy: 0, vyaw: 0}}));
382
 
383
  // Receive messages
384
  ws.onmessage = (event) => {
 
389
  };
390
  ```
391
 
392
+ Nova-Sim uses `/ws` as the shared control channel for the browser UI, trainers, and any RL clients. Every UI interaction (teleop, camera controls, robot switching) and the trainer handshake/notifications flows through this single socket; the UI `state` messages shown below now also carry the action velocities, integrated reward, and trainer connection status that RL agents need.
393
 
394
  ### HTTP Endpoints
395
 
 
398
  | Endpoint | Method | Description |
399
  |----------|--------|-------------|
400
  | `/env` | `GET` | Returns static environment information: robot, scene, has_gripper, action_space, observation_space, camera_feeds |
401
+ | `/metadata` | `GET` | Returns available robots, scenes, actions, and system configuration |
402
  | `/video_feed` | `GET` | MJPEG video stream of the main camera |
403
  | `/camera/<name>/video_feed` | `GET` | MJPEG video stream of auxiliary cameras (e.g., aux_top, aux_side) |
404
 
 
421
 
422
  The `/env` endpoint returns scene-specific information including camera feeds available for the current robot/scene configuration.
423
 
424
+ All dynamic operations (reset, switching robots, sending actions) are performed via WebSocket messages. Training data (observations, rewards, etc.) come from the `/ws` state stream.
425
 
426
  ### Client → Server WebSocket Messages
427
 
428
+ **`action`** - Send velocity actions to all robots:
429
  ```json
430
+ {"type": "action", "data": {"vx": 0.5, "vy": 0.0, "vyaw": 0.0}}
431
  ```
432
+
433
+ For locomotion robots (G1, Spot):
434
  - `vx`: Forward/backward velocity [-1, 1]
435
  - `vy`: Left/right strafe velocity [-1, 1]
436
  - `vyaw`: Turn rate [-1, 1]
437
 
438
+ For robot arms (UR5):
439
+ - `vx`, `vy`, `vz`: Cartesian translation velocities (m/s)
440
+ - `vrx`, `vry`, `vrz`: Cartesian rotation velocities (rad/s)
441
+ - `j1`-`j6`: Joint velocities (rad/s)
442
+ - `gripper`: Gripper position [0-255]
443
+
444
+ **Note:** Old message type `command` is still accepted for backward compatibility.
445
+
446
  **`reset`** - Reset the environment:
447
  ```json
448
  {"type": "reset", "data": {"seed": 42}}
 
461
  {"type": "home"}
462
  ```
463
 
464
+ **`teleop_action`** - Send teleoperation action (primarily for UR5 keyboard control):
465
  ```json
466
+ {"type": "teleop_action", "data": {"vx": 0.01, "vy": 0.0, "vz": 0.0}}
467
  ```
468
+ - For UR5: `vx`, `vy`, `vz` represent Cartesian velocity deltas (m/s)
469
  - Backward compatible: old `dx`, `dy`, `dz` format is auto-mapped to `vx`, `vy`, `vz`
470
+ - **Note:** Old message type `teleop_command` is still accepted for backward compatibility.
471
 
472
  **`camera`:**
473
  ```json
 
543
  },
544
  "steps": 1234,
545
  "reward": 0.0,
546
+ "teleop_action": {"vx": 0.5, "vy": 0.0, "vz": 0.0, "vyaw": 0.0, "vrx": 0.0, "vry": 0.0, "vrz": 0.0, "j1": 0.0, "j2": 0.0, "j3": 0.0, "j4": 0.0, "j5": 0.0, "j6": 0.0, "gripper": 0.0},
547
  "trainer_connected": true
548
  }
549
  }
 
566
  "control_mode": "ik",
567
  "steps": 1234,
568
  "reward": -0.25,
569
+ "teleop_action": {"vx": 0.02, "vy": 0.0, "vz": 0.0, "vyaw": 0.0, "vrx": 0.0, "vry": 0.0, "vrz": 0.0, "j1": 0.0, "j2": 0.0, "j3": 0.0, "j4": 0.0, "j5": 0.0, "j6": 0.0, "gripper": 128.0},
570
  "trainer_connected": true,
571
  "nova_api": {
572
  "connected": true,
 
582
  - `observation`: Contains robot-specific sensor data and state information
583
  - `steps`: Number of simulation steps since last reset
584
  - `reward`: The integrated task reward from the simulator that remote trainers can consume
585
+ - `teleop_action`: The canonical action/velocity stream that drives locomotion or arm movement; the UI and every trainer should read this field as the unified action record. Always present with zero values when idle
586
+ - Cartesian velocities: `vx` (forward/back or X-axis), `vy` (strafe or Y-axis), `vz` (vertical for UR5), `vyaw` (rotation for locomotion)
587
+ - Cartesian rotation velocities (UR5 only): `vrx`, `vry`, `vrz` (rad/s)
588
+ - Joint velocities (UR5 only): `j1`, `j2`, `j3`, `j4`, `j5`, `j6` (rad/s)
589
+ - Gripper: `gripper` (0-255 for UR5, 0 for others)
590
+ - Locomotion robots: Use `vx`, `vy`, `vyaw` (other fields are 0)
591
+ - UR5: Use `vx`/`vy`/`vz` for Cartesian translation, `vrx`/`vry`/`vrz` for rotation, `j1`-`j6` for joint velocities, and `gripper` for gripper control
592
  - `trainer_connected`: Whether a trainer handshake is active on `/ws` (useful for status LEDs)
593
 
594
  *Locomotion observation fields (inside `observation`):*
 
617
 
618
  Every `/ws` client receives a `state` message roughly every 100 ms. The examples above show the locomotion (`spot`) and arm (`ur5`) payloads; the payload also now includes:
619
 
620
+ - `teleop_action`: The latest action/teleoperation stream (includes `vx`, `vy`, `vz`, `vyaw`, `vrx`, `vry`, `vrz`, `j1`-`j6`, `gripper`) so trainers and the UI read a single canonical action payload. Always present with zero values when idle.
621
  - `reward`: The integrated task reward that trainers can consume without sending a separate `step`.
622
  - `trainer_connected`: Whether a trainer handshake is active on `/ws` (used to update the UI indicator).
623
 
 
629
  |----------|--------|-------------|
630
  | `/nova-sim/api/v1` | GET | Web interface (HTML/JS) |
631
  | `/nova-sim/api/v1/env` | GET | Static environment info (robot, scene, spaces, camera feeds) |
632
+ | `/nova-sim/api/v1/metadata` | GET | Available robots, scenes, actions, and system configuration |
633
  | `/nova-sim/api/v1/video_feed` | GET | MJPEG video stream (main camera) |
634
  | `/nova-sim/api/v1/camera/<name>/video_feed` | GET | MJPEG video stream (auxiliary cameras) |
635
 
 
642
 
643
  ### Metadata & Camera Feeds
644
 
645
+ - `GET /nova-sim/api/v1/metadata` returns JSON describing every available robot/scene pair and the supported actions
646
  - `GET /nova-sim/api/v1/env` returns scene-specific camera feeds - the `camera_feeds` array lists all available video streams for the current robot/scene configuration including the main camera and any auxiliary cameras (e.g., `aux_top`, `aux_side`, `aux_flange`)
647
  - `GET /nova-sim/api/v1/camera/<name>/video_feed` streams MJPEG for a specific camera feed
648
  - `pytest tests/` exercises the HTTP metadata/video endpoints, the `/ws` control socket, and every camera feed. Keep Nova-Sim running at `http://localhost:3004` when you run it so the suite can talk to the live server.
 
765
 
766
  **Note**: When state streaming is enabled, the simulation becomes a read-only digital twin that displays the real robot's movements. Local target controls and IK computations are ignored since the robot is controlled by external hardware/software.
767
 
768
+ **Sending Actions to Robot**: To send motion actions to the real robot through Nova API, use Nova's motion execution endpoints directly (not currently implemented in the web UI). The simulation is designed to visualize robot state, not to control it.
769
 
770
  ### Environment Variables Reference
771
 
 
887
  - [unitree_mujoco](https://github.com/unitreerobotics/unitree_mujoco) (BSD-3-Clause)
888
  - [unitree_rl_gym](https://github.com/unitreerobotics/unitree_rl_gym)
889
  - [Quadruped-PyMPC](https://github.com/iit-DLSLab/Quadruped-PyMPC) (BSD-3-Clause)
890
+ **`teleop_action`:**
891
  ```json
892
+ {"type": "teleop_action", "data": {"vx": 0.01, "vy": 0.0, "vz": -0.01}}
893
  ```
894
  - `vx`, `vy`, `vz`: Velocity/delta values for UR5 Cartesian movement (WASD + RF from the UI) or locomotion robot velocity (`vx`, `vy`, `vyaw`)
895
+ - These values appear in the `teleop_action` field of each `/ws` `state` broadcast, which is the canonical action stream for both the UI and any RL trainer
896
  - The field always contains zero values when idle (never null)
mujoco_server.py CHANGED
@@ -100,9 +100,9 @@ episode_control_state = {
100
  }
101
  episode_control_lock = threading.Lock()
102
 
103
- # Latest teleoperation command (for trainer state)
104
  # Initialize with zero values instead of None so it's always a dict
105
- last_teleop_command: dict[str, Any] = {
106
  "vx": 0.0,
107
  "vy": 0.0,
108
  "vz": 0.0,
@@ -216,13 +216,13 @@ DEFAULT_SCENES = {
216
  "spot": "scene",
217
  }
218
 
219
- AVAILABLE_COMMANDS = [
220
- "command",
221
  "reset",
222
  "switch_robot",
223
  "camera",
224
  "camera_follow",
225
- "teleop_command",
226
  "start_jog",
227
  "stop_jog",
228
  "arm_target",
@@ -531,19 +531,19 @@ def switch_robot(robot_type, scene_name=None):
531
  current_scene = active_scene
532
  env.reset()
533
 
534
- # Initialize gripper value in teleop_command for UR5
535
  with teleop_lock:
536
  if robot_type in ("ur5", "ur5_t_push"):
537
  has_gripper = getattr(env, "has_gripper", False)
538
  if has_gripper:
539
  # Get current gripper value from env
540
  gripper_val = getattr(env, "get_gripper", lambda: 128)()
541
- last_teleop_command["gripper"] = float(gripper_val)
542
  else:
543
- last_teleop_command["gripper"] = 0.0
544
  else:
545
  # Locomotion robots don't have grippers
546
- last_teleop_command["gripper"] = 0.0
547
 
548
  # Create new renderer
549
  renderer = mujoco.Renderer(env.model, height=env.height, width=env.width)
@@ -571,7 +571,7 @@ def broadcast_state():
571
  "vz": 0.0,
572
  }
573
  with teleop_lock:
574
- teleop_snapshot = last_teleop_command.copy()
575
  # Debug: print non-zero jogging values
576
  non_zero = {k: v for k, v in teleop_snapshot.items() if v != 0.0}
577
  if non_zero:
@@ -628,7 +628,7 @@ def broadcast_state():
628
  'control_mode': control_mode,
629
  'steps': int(steps),
630
  'reward': reward_value,
631
- 'teleop_command': teleop_snapshot,
632
  'nova_api': {
633
  'connected': nova_connected,
634
  'state_streaming': nova_state_streaming,
@@ -655,7 +655,7 @@ def broadcast_state():
655
  },
656
  'steps': int(steps),
657
  'reward': reward_value,
658
- 'teleop_command': teleop_snapshot,
659
  'trainer_connected': trainer_connected
660
  }
661
  })
@@ -669,8 +669,8 @@ def broadcast_state():
669
  # Print actual JSON being sent
670
  import json as json_module
671
  parsed = json_module.loads(state_msg)
672
- actual_teleop = parsed.get('data', {}).get('teleop_command', {})
673
- print(f"[Broadcast] Actual JSON teleop_command j3 = {actual_teleop.get('j3', 'MISSING')}")
674
  for ws in ws_clients:
675
  try:
676
  ws.send(state_msg)
@@ -963,7 +963,7 @@ def generate_overlay_frames(name: str):
963
 
964
  def handle_ws_message(ws, data):
965
  """Handle incoming WebSocket message."""
966
- global needs_robot_switch, camera_follow, last_teleop_command
967
 
968
  msg_type = data.get('type')
969
  print(f"[WS] Received message type: {msg_type}")
@@ -972,21 +972,104 @@ def handle_ws_message(ws, data):
972
  _handle_trainer_message(ws, data)
973
  return
974
 
 
975
  if msg_type == 'command':
 
 
 
 
 
 
 
976
  payload = data.get('data', {})
977
  vx = payload.get('vx', 0.0)
978
  vy = payload.get('vy', 0.0)
 
979
  vyaw = payload.get('vyaw', 0.0)
 
 
 
 
 
 
 
 
 
 
 
980
  with mujoco_lock:
981
  if env is not None:
982
- env.set_command(vx, vy, vyaw)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
983
  with teleop_lock:
984
- # Update locomotion velocities, preserve other fields
985
- last_teleop_command["vx"] = float(vx)
986
- last_teleop_command["vy"] = float(vy)
987
- last_teleop_command["vz"] = 0.0
988
- last_teleop_command["vyaw"] = float(vyaw)
989
- # Note: Don't clear jogging velocities here - they should only be cleared by stop_jog
 
 
 
 
 
 
 
 
 
 
990
 
991
  elif msg_type == 'reset':
992
  payload = data.get('data', {})
@@ -1197,7 +1280,7 @@ def handle_ws_message(ws, data):
1197
  payload = data.get('data', {})
1198
  camera_follow = payload.get('follow', True)
1199
 
1200
- elif msg_type == 'teleop_command':
1201
  payload = data.get('data', {})
1202
  # Accept both old format (dx/dy/dz) and new format (vx/vy/vz)
1203
  vx = float(payload.get('vx', payload.get('dx', 0.0)))
@@ -1220,19 +1303,19 @@ def handle_ws_message(ws, data):
1220
 
1221
  with teleop_lock:
1222
  # Update UR5 Cartesian velocities, preserve other fields
1223
- last_teleop_command["vx"] = vx
1224
- last_teleop_command["vy"] = vy
1225
- last_teleop_command["vz"] = vz
1226
- last_teleop_command["vyaw"] = 0.0
1227
  # Clear jogging velocities when using teleop (jogging and teleop are mutually exclusive)
1228
  for j in range(1, 7):
1229
- last_teleop_command[f"j{j}"] = 0.0
1230
- last_teleop_command["vrx"] = 0.0
1231
- last_teleop_command["vry"] = 0.0
1232
- last_teleop_command["vrz"] = 0.0
1233
 
1234
  broadcast_to_trainer(
1235
- "teleop_command",
1236
  {
1237
  "robot": current_robot,
1238
  "scene": getattr(env, "scene_name", None) if env is not None else None,
@@ -1270,9 +1353,9 @@ def handle_ws_message(ws, data):
1270
  with mujoco_lock:
1271
  if env is not None and current_robot in ("ur5", "ur5_t_push"):
1272
  env.set_gripper(value)
1273
- # Update teleop_command with gripper value
1274
  with teleop_lock:
1275
- last_teleop_command["gripper"] = float(value)
1276
 
1277
  elif msg_type == 'control_mode':
1278
  payload = data.get('data', {})
@@ -1324,7 +1407,7 @@ def handle_ws_message(ws, data):
1324
  if not success:
1325
  print(f"[Server] Failed to start jog: {jog_type}, {jog_params}")
1326
 
1327
- # Update teleop_command to reflect jogging state
1328
  with teleop_lock:
1329
  if jog_type == 'joint':
1330
  # Joint jogging: {"j1": vel, "j2": vel, ...}
@@ -1337,16 +1420,16 @@ def handle_ws_message(ws, data):
1337
 
1338
  # Reset all joint velocities to 0
1339
  for j in range(1, 7):
1340
- last_teleop_command[f"j{j}"] = 0.0
1341
  # Set active joint velocity
1342
- last_teleop_command[f"j{joint}"] = float(vel_value)
1343
  # Clear Cartesian velocities
1344
- last_teleop_command["vx"] = 0.0
1345
- last_teleop_command["vy"] = 0.0
1346
- last_teleop_command["vz"] = 0.0
1347
- last_teleop_command["vrx"] = 0.0
1348
- last_teleop_command["vry"] = 0.0
1349
- last_teleop_command["vrz"] = 0.0
1350
 
1351
  elif jog_type == 'cartesian_translation':
1352
  # Cartesian translation: {"vx": vel, "vy": vel, "vz": vel}
@@ -1356,17 +1439,17 @@ def handle_ws_message(ws, data):
1356
  vel_value = velocity if direction == '+' else -velocity
1357
 
1358
  # Reset Cartesian velocities
1359
- last_teleop_command["vx"] = 0.0
1360
- last_teleop_command["vy"] = 0.0
1361
- last_teleop_command["vz"] = 0.0
1362
- last_teleop_command["vrx"] = 0.0
1363
- last_teleop_command["vry"] = 0.0
1364
- last_teleop_command["vrz"] = 0.0
1365
  # Set active axis velocity
1366
- last_teleop_command[f"v{axis}"] = float(vel_value)
1367
  # Clear joint velocities
1368
  for j in range(1, 7):
1369
- last_teleop_command[f"j{j}"] = 0.0
1370
 
1371
  elif jog_type == 'cartesian_rotation':
1372
  # Cartesian rotation: {"vrx": vel, "vry": vel, "vrz": vel}
@@ -1376,33 +1459,33 @@ def handle_ws_message(ws, data):
1376
  vel_value = velocity if direction == '+' else -velocity
1377
 
1378
  # Reset Cartesian velocities
1379
- last_teleop_command["vx"] = 0.0
1380
- last_teleop_command["vy"] = 0.0
1381
- last_teleop_command["vz"] = 0.0
1382
- last_teleop_command["vrx"] = 0.0
1383
- last_teleop_command["vry"] = 0.0
1384
- last_teleop_command["vrz"] = 0.0
1385
  # Set active rotation velocity
1386
- last_teleop_command[f"vr{axis}"] = float(vel_value)
1387
  # Clear joint velocities
1388
  for j in range(1, 7):
1389
- last_teleop_command[f"j{j}"] = 0.0
1390
 
1391
  elif msg_type == 'stop_jog':
1392
  with mujoco_lock:
1393
  if env is not None and current_robot in ("ur5", "ur5_t_push"):
1394
  env.stop_jog()
1395
 
1396
- # Clear all jogging velocities in teleop_command
1397
  with teleop_lock:
1398
- last_teleop_command["vx"] = 0.0
1399
- last_teleop_command["vy"] = 0.0
1400
- last_teleop_command["vz"] = 0.0
1401
- last_teleop_command["vrx"] = 0.0
1402
- last_teleop_command["vry"] = 0.0
1403
- last_teleop_command["vrz"] = 0.0
1404
  for j in range(1, 7):
1405
- last_teleop_command[f"j{j}"] = 0.0
1406
 
1407
  elif msg_type == 'homing':
1408
  with mujoco_lock:
@@ -2858,7 +2941,7 @@ def index():
2858
  }
2859
 
2860
  // Update teleop command display - only show non-zero values
2861
- const armTeleop = data.teleop_command;
2862
  const armTeleopDisplayEl = document.getElementById('arm_teleop_display');
2863
  if (armTeleop && armTeleopDisplayEl) {
2864
  const parts = [];
@@ -2913,7 +2996,7 @@ def index():
2913
  }
2914
 
2915
  // Update teleop command display - only show non-zero values
2916
- const locoTeleop = data.teleop_command || {};
2917
  const locoTeleopDisplayEl = document.getElementById('loco_teleop_display');
2918
  if (locoTeleopDisplayEl) {
2919
  const parts = [];
@@ -3428,7 +3511,7 @@ def index():
3428
  }
3429
 
3430
  lastTeleopCommand = {dx, dy, dz};
3431
- send('teleop_command', {dx, dy, dz});
3432
  if (teleopStatus) {
3433
  teleopStatus.innerText = `UI teleop → dx: ${dx.toFixed(3)} m, dy: ${dy.toFixed(3)} m, dz: ${dz.toFixed(3)} m`;
3434
  }
@@ -3670,7 +3753,7 @@ def metadata():
3670
  pass
3671
  response = {
3672
  "robots": robots_meta,
3673
- "commands": AVAILABLE_COMMANDS,
3674
  "nova_api": {
3675
  "preconfigured": NOVA_CREDENTIALS_PRESENT,
3676
  },
 
100
  }
101
  episode_control_lock = threading.Lock()
102
 
103
+ # Latest teleoperation action (for trainer state)
104
  # Initialize with zero values instead of None so it's always a dict
105
+ last_teleop_action: dict[str, Any] = {
106
  "vx": 0.0,
107
  "vy": 0.0,
108
  "vz": 0.0,
 
216
  "spot": "scene",
217
  }
218
 
219
+ AVAILABLE_ACTIONS = [
220
+ "action",
221
  "reset",
222
  "switch_robot",
223
  "camera",
224
  "camera_follow",
225
+ "teleop_action",
226
  "start_jog",
227
  "stop_jog",
228
  "arm_target",
 
531
  current_scene = active_scene
532
  env.reset()
533
 
534
+ # Initialize gripper value in teleop_action for UR5
535
  with teleop_lock:
536
  if robot_type in ("ur5", "ur5_t_push"):
537
  has_gripper = getattr(env, "has_gripper", False)
538
  if has_gripper:
539
  # Get current gripper value from env
540
  gripper_val = getattr(env, "get_gripper", lambda: 128)()
541
+ last_teleop_action["gripper"] = float(gripper_val)
542
  else:
543
+ last_teleop_action["gripper"] = 0.0
544
  else:
545
  # Locomotion robots don't have grippers
546
+ last_teleop_action["gripper"] = 0.0
547
 
548
  # Create new renderer
549
  renderer = mujoco.Renderer(env.model, height=env.height, width=env.width)
 
571
  "vz": 0.0,
572
  }
573
  with teleop_lock:
574
+ teleop_snapshot = last_teleop_action.copy()
575
  # Debug: print non-zero jogging values
576
  non_zero = {k: v for k, v in teleop_snapshot.items() if v != 0.0}
577
  if non_zero:
 
628
  'control_mode': control_mode,
629
  'steps': int(steps),
630
  'reward': reward_value,
631
+ 'teleop_action': teleop_snapshot,
632
  'nova_api': {
633
  'connected': nova_connected,
634
  'state_streaming': nova_state_streaming,
 
655
  },
656
  'steps': int(steps),
657
  'reward': reward_value,
658
+ 'teleop_action': teleop_snapshot,
659
  'trainer_connected': trainer_connected
660
  }
661
  })
 
669
  # Print actual JSON being sent
670
  import json as json_module
671
  parsed = json_module.loads(state_msg)
672
+ actual_teleop = parsed.get('data', {}).get('teleop_action', {})
673
+ print(f"[Broadcast] Actual JSON teleop_action j3 = {actual_teleop.get('j3', 'MISSING')}")
674
  for ws in ws_clients:
675
  try:
676
  ws.send(state_msg)
 
963
 
964
  def handle_ws_message(ws, data):
965
  """Handle incoming WebSocket message."""
966
+ global needs_robot_switch, camera_follow, last_teleop_action
967
 
968
  msg_type = data.get('type')
969
  print(f"[WS] Received message type: {msg_type}")
 
972
  _handle_trainer_message(ws, data)
973
  return
974
 
975
+ # Backward compatibility: map old message types to new ones
976
  if msg_type == 'command':
977
+ msg_type = 'action'
978
+ data['type'] = 'action'
979
+ elif msg_type == 'teleop_command':
980
+ msg_type = 'teleop_action'
981
+ data['type'] = 'teleop_action'
982
+
983
+ if msg_type == 'action':
984
  payload = data.get('data', {})
985
  vx = payload.get('vx', 0.0)
986
  vy = payload.get('vy', 0.0)
987
+ vz = payload.get('vz', 0.0)
988
  vyaw = payload.get('vyaw', 0.0)
989
+ vrx = payload.get('vrx', 0.0)
990
+ vry = payload.get('vry', 0.0)
991
+ vrz = payload.get('vrz', 0.0)
992
+ j1 = payload.get('j1', 0.0)
993
+ j2 = payload.get('j2', 0.0)
994
+ j3 = payload.get('j3', 0.0)
995
+ j4 = payload.get('j4', 0.0)
996
+ j5 = payload.get('j5', 0.0)
997
+ j6 = payload.get('j6', 0.0)
998
+ gripper = payload.get('gripper', None)
999
+
1000
  with mujoco_lock:
1001
  if env is not None:
1002
+ # For UR5: translate velocity actions to jogging commands
1003
+ if current_robot in ("ur5", "ur5_t_push"):
1004
+ # Handle gripper if specified
1005
+ if gripper is not None:
1006
+ env.set_gripper(float(gripper))
1007
+
1008
+ # Check which type of velocity is active (mutually exclusive)
1009
+ joint_velocities = [j1, j2, j3, j4, j5, j6]
1010
+ cartesian_translation = [vx, vy, vz]
1011
+ cartesian_rotation = [vrx, vry, vrz]
1012
+
1013
+ # Find which velocity mode is active
1014
+ active_joint = None
1015
+ for i, vel in enumerate(joint_velocities):
1016
+ if abs(vel) > 0.001: # Threshold to ignore noise
1017
+ active_joint = (i + 1, vel)
1018
+ break
1019
+
1020
+ active_translation = None
1021
+ for i, (axis, vel) in enumerate(zip(['x', 'y', 'z'], cartesian_translation)):
1022
+ if abs(vel) > 0.001:
1023
+ active_translation = (axis, vel)
1024
+ break
1025
+
1026
+ active_rotation = None
1027
+ for i, (axis, vel) in enumerate(zip(['x', 'y', 'z'], cartesian_rotation)):
1028
+ if abs(vel) > 0.001:
1029
+ active_rotation = (axis, vel)
1030
+ break
1031
+
1032
+ # Apply the appropriate jogging mode
1033
+ if active_joint:
1034
+ joint, velocity = active_joint
1035
+ direction = '+' if velocity > 0 else '-'
1036
+ env.start_jog('joint', joint=joint, direction=direction, velocity=abs(velocity))
1037
+ elif active_translation:
1038
+ axis, velocity = active_translation
1039
+ direction = '+' if velocity > 0 else '-'
1040
+ # Convert m/s to mm/s
1041
+ velocity_mm_s = abs(velocity) * 1000.0
1042
+ env.start_jog('cartesian_translation', axis=axis, direction=direction,
1043
+ velocity=velocity_mm_s, tcp_id='Flange', coord_system_id='world')
1044
+ elif active_rotation:
1045
+ axis, velocity = active_rotation
1046
+ direction = '+' if velocity > 0 else '-'
1047
+ env.start_jog('cartesian_rotation', axis=axis, direction=direction,
1048
+ velocity=abs(velocity), tcp_id='Flange', coord_system_id='world')
1049
+ else:
1050
+ # No active velocity - stop jogging
1051
+ env.stop_jog()
1052
+ else:
1053
+ # For locomotion robots: use vx, vy, vyaw
1054
+ env.set_command(vx, vy, vyaw)
1055
+
1056
  with teleop_lock:
1057
+ # Update all velocity fields in teleop_action
1058
+ last_teleop_action["vx"] = float(vx)
1059
+ last_teleop_action["vy"] = float(vy)
1060
+ last_teleop_action["vz"] = float(vz)
1061
+ last_teleop_action["vyaw"] = float(vyaw)
1062
+ last_teleop_action["vrx"] = float(vrx)
1063
+ last_teleop_action["vry"] = float(vry)
1064
+ last_teleop_action["vrz"] = float(vrz)
1065
+ last_teleop_action["j1"] = float(j1)
1066
+ last_teleop_action["j2"] = float(j2)
1067
+ last_teleop_action["j3"] = float(j3)
1068
+ last_teleop_action["j4"] = float(j4)
1069
+ last_teleop_action["j5"] = float(j5)
1070
+ last_teleop_action["j6"] = float(j6)
1071
+ if gripper is not None:
1072
+ last_teleop_action["gripper"] = float(gripper)
1073
 
1074
  elif msg_type == 'reset':
1075
  payload = data.get('data', {})
 
1280
  payload = data.get('data', {})
1281
  camera_follow = payload.get('follow', True)
1282
 
1283
+ elif msg_type == 'teleop_action':
1284
  payload = data.get('data', {})
1285
  # Accept both old format (dx/dy/dz) and new format (vx/vy/vz)
1286
  vx = float(payload.get('vx', payload.get('dx', 0.0)))
 
1303
 
1304
  with teleop_lock:
1305
  # Update UR5 Cartesian velocities, preserve other fields
1306
+ last_teleop_action["vx"] = vx
1307
+ last_teleop_action["vy"] = vy
1308
+ last_teleop_action["vz"] = vz
1309
+ last_teleop_action["vyaw"] = 0.0
1310
  # Clear jogging velocities when using teleop (jogging and teleop are mutually exclusive)
1311
  for j in range(1, 7):
1312
+ last_teleop_action[f"j{j}"] = 0.0
1313
+ last_teleop_action["vrx"] = 0.0
1314
+ last_teleop_action["vry"] = 0.0
1315
+ last_teleop_action["vrz"] = 0.0
1316
 
1317
  broadcast_to_trainer(
1318
+ "action_update",
1319
  {
1320
  "robot": current_robot,
1321
  "scene": getattr(env, "scene_name", None) if env is not None else None,
 
1353
  with mujoco_lock:
1354
  if env is not None and current_robot in ("ur5", "ur5_t_push"):
1355
  env.set_gripper(value)
1356
+ # Update teleop_action with gripper value
1357
  with teleop_lock:
1358
+ last_teleop_action["gripper"] = float(value)
1359
 
1360
  elif msg_type == 'control_mode':
1361
  payload = data.get('data', {})
 
1407
  if not success:
1408
  print(f"[Server] Failed to start jog: {jog_type}, {jog_params}")
1409
 
1410
+ # Update teleop_action to reflect jogging state
1411
  with teleop_lock:
1412
  if jog_type == 'joint':
1413
  # Joint jogging: {"j1": vel, "j2": vel, ...}
 
1420
 
1421
  # Reset all joint velocities to 0
1422
  for j in range(1, 7):
1423
+ last_teleop_action[f"j{j}"] = 0.0
1424
  # Set active joint velocity
1425
+ last_teleop_action[f"j{joint}"] = float(vel_value)
1426
  # Clear Cartesian velocities
1427
+ last_teleop_action["vx"] = 0.0
1428
+ last_teleop_action["vy"] = 0.0
1429
+ last_teleop_action["vz"] = 0.0
1430
+ last_teleop_action["vrx"] = 0.0
1431
+ last_teleop_action["vry"] = 0.0
1432
+ last_teleop_action["vrz"] = 0.0
1433
 
1434
  elif jog_type == 'cartesian_translation':
1435
  # Cartesian translation: {"vx": vel, "vy": vel, "vz": vel}
 
1439
  vel_value = velocity if direction == '+' else -velocity
1440
 
1441
  # Reset Cartesian velocities
1442
+ last_teleop_action["vx"] = 0.0
1443
+ last_teleop_action["vy"] = 0.0
1444
+ last_teleop_action["vz"] = 0.0
1445
+ last_teleop_action["vrx"] = 0.0
1446
+ last_teleop_action["vry"] = 0.0
1447
+ last_teleop_action["vrz"] = 0.0
1448
  # Set active axis velocity
1449
+ last_teleop_action[f"v{axis}"] = float(vel_value)
1450
  # Clear joint velocities
1451
  for j in range(1, 7):
1452
+ last_teleop_action[f"j{j}"] = 0.0
1453
 
1454
  elif jog_type == 'cartesian_rotation':
1455
  # Cartesian rotation: {"vrx": vel, "vry": vel, "vrz": vel}
 
1459
  vel_value = velocity if direction == '+' else -velocity
1460
 
1461
  # Reset Cartesian velocities
1462
+ last_teleop_action["vx"] = 0.0
1463
+ last_teleop_action["vy"] = 0.0
1464
+ last_teleop_action["vz"] = 0.0
1465
+ last_teleop_action["vrx"] = 0.0
1466
+ last_teleop_action["vry"] = 0.0
1467
+ last_teleop_action["vrz"] = 0.0
1468
  # Set active rotation velocity
1469
+ last_teleop_action[f"vr{axis}"] = float(vel_value)
1470
  # Clear joint velocities
1471
  for j in range(1, 7):
1472
+ last_teleop_action[f"j{j}"] = 0.0
1473
 
1474
  elif msg_type == 'stop_jog':
1475
  with mujoco_lock:
1476
  if env is not None and current_robot in ("ur5", "ur5_t_push"):
1477
  env.stop_jog()
1478
 
1479
+ # Clear all jogging velocities in teleop_action
1480
  with teleop_lock:
1481
+ last_teleop_action["vx"] = 0.0
1482
+ last_teleop_action["vy"] = 0.0
1483
+ last_teleop_action["vz"] = 0.0
1484
+ last_teleop_action["vrx"] = 0.0
1485
+ last_teleop_action["vry"] = 0.0
1486
+ last_teleop_action["vrz"] = 0.0
1487
  for j in range(1, 7):
1488
+ last_teleop_action[f"j{j}"] = 0.0
1489
 
1490
  elif msg_type == 'homing':
1491
  with mujoco_lock:
 
2941
  }
2942
 
2943
  // Update teleop command display - only show non-zero values
2944
+ const armTeleop = data.teleop_action;
2945
  const armTeleopDisplayEl = document.getElementById('arm_teleop_display');
2946
  if (armTeleop && armTeleopDisplayEl) {
2947
  const parts = [];
 
2996
  }
2997
 
2998
  // Update teleop command display - only show non-zero values
2999
+ const locoTeleop = data.teleop_action || {};
3000
  const locoTeleopDisplayEl = document.getElementById('loco_teleop_display');
3001
  if (locoTeleopDisplayEl) {
3002
  const parts = [];
 
3511
  }
3512
 
3513
  lastTeleopCommand = {dx, dy, dz};
3514
+ send('teleop_action', {dx, dy, dz});
3515
  if (teleopStatus) {
3516
  teleopStatus.innerText = `UI teleop → dx: ${dx.toFixed(3)} m, dy: ${dy.toFixed(3)} m, dz: ${dz.toFixed(3)} m`;
3517
  }
 
3753
  pass
3754
  response = {
3755
  "robots": robots_meta,
3756
+ "actions": AVAILABLE_ACTIONS,
3757
  "nova_api": {
3758
  "preconfigured": NOVA_CREDENTIALS_PRESENT,
3759
  },
protocol_types.py ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Protocol type definitions for Nova-Sim WebSocket API.
3
+
4
+ This module defines the structure of all WebSocket messages exchanged between
5
+ clients and the server, including both request and response messages.
6
+ """
7
+
8
+ from typing import TypedDict, Literal, Optional, Union, List
9
+
10
+
11
+ # ============================================================================
12
+ # Common Types
13
+ # ============================================================================
14
+
15
+ RobotType = Literal["g1", "spot", "ur5", "ur5_t_push"]
16
+ SceneType = Optional[str]
17
+ ControlMode = Literal["ik", "joint"]
18
+
19
+
20
+ # ============================================================================
21
+ # Action Messages (Client -> Server)
22
+ # ============================================================================
23
+
24
+ class ActionData(TypedDict, total=False):
25
+ """Velocity-based action commands for robot control.
26
+
27
+ For locomotion robots (G1, Spot):
28
+ - vx: Forward/backward velocity [-1, 1]
29
+ - vy: Left/right strafe velocity [-1, 1]
30
+ - vyaw: Turn rate [-1, 1]
31
+
32
+ For robot arms (UR5):
33
+ - vx, vy, vz: Cartesian translation velocities (m/s)
34
+ - vrx, vry, vrz: Cartesian rotation velocities (rad/s)
35
+ - j1-j6: Joint velocities (rad/s)
36
+ - gripper: Gripper position [0-255]
37
+ """
38
+ # Cartesian translation velocities (m/s for UR5, normalized for locomotion)
39
+ vx: float
40
+ vy: float
41
+ vz: float
42
+
43
+ # Rotation velocity (rad/s for UR5, normalized for locomotion)
44
+ vyaw: float
45
+
46
+ # Cartesian rotation velocities (rad/s, UR5 only)
47
+ vrx: float
48
+ vry: float
49
+ vrz: float
50
+
51
+ # Joint velocities (rad/s, UR5 only)
52
+ j1: float
53
+ j2: float
54
+ j3: float
55
+ j4: float
56
+ j5: float
57
+ j6: float
58
+
59
+ # Gripper position (0=open, 255=closed, UR5 only)
60
+ gripper: float
61
+
62
+
63
+ class ActionMessage(TypedDict):
64
+ """Action command message for all robots."""
65
+ type: Literal["action"]
66
+ data: ActionData
67
+
68
+
69
+ class TeleopActionData(TypedDict, total=False):
70
+ """Teleoperation action data (backward compatible with old teleop_command)."""
71
+ vx: float
72
+ vy: float
73
+ vz: float
74
+ # Backward compatibility: accept old dx/dy/dz format
75
+ dx: float
76
+ dy: float
77
+ dz: float
78
+
79
+
80
+ class TeleopActionMessage(TypedDict):
81
+ """Teleoperation action message (UR5 keyboard control)."""
82
+ type: Literal["teleop_action"]
83
+ data: TeleopActionData
84
+
85
+
86
+ # ============================================================================
87
+ # Other Client -> Server Messages
88
+ # ============================================================================
89
+
90
+ class ResetData(TypedDict, total=False):
91
+ """Reset environment data."""
92
+ seed: Optional[int]
93
+
94
+
95
+ class ResetMessage(TypedDict):
96
+ """Reset environment message."""
97
+ type: Literal["reset"]
98
+ data: ResetData
99
+
100
+
101
+ class SwitchRobotData(TypedDict):
102
+ """Switch robot data."""
103
+ robot: RobotType
104
+ scene: Optional[str]
105
+
106
+
107
+ class SwitchRobotMessage(TypedDict):
108
+ """Switch robot message."""
109
+ type: Literal["switch_robot"]
110
+ data: SwitchRobotData
111
+
112
+
113
+ class HomeMessage(TypedDict):
114
+ """Home robot message (UR5 only)."""
115
+ type: Literal["home"]
116
+
117
+
118
+ class StopHomeMessage(TypedDict):
119
+ """Stop homing sequence message (UR5 only)."""
120
+ type: Literal["stop_home"]
121
+
122
+
123
+ class CameraRotateData(TypedDict):
124
+ """Camera rotation data."""
125
+ action: Literal["rotate"]
126
+ dx: float
127
+ dy: float
128
+
129
+
130
+ class CameraZoomData(TypedDict):
131
+ """Camera zoom data."""
132
+ action: Literal["zoom"]
133
+ dz: float
134
+
135
+
136
+ class CameraPanData(TypedDict):
137
+ """Camera pan data."""
138
+ action: Literal["pan"]
139
+ dx: float
140
+ dy: float
141
+
142
+
143
+ class CameraSetDistanceData(TypedDict):
144
+ """Camera set distance data."""
145
+ action: Literal["set_distance"]
146
+ distance: float
147
+
148
+
149
+ CameraData = Union[CameraRotateData, CameraZoomData, CameraPanData, CameraSetDistanceData]
150
+
151
+
152
+ class CameraMessage(TypedDict):
153
+ """Camera control message."""
154
+ type: Literal["camera"]
155
+ data: CameraData
156
+
157
+
158
+ class CameraFollowData(TypedDict):
159
+ """Camera follow mode data."""
160
+ follow: bool
161
+
162
+
163
+ class CameraFollowMessage(TypedDict):
164
+ """Camera follow mode message."""
165
+ type: Literal["camera_follow"]
166
+ data: CameraFollowData
167
+
168
+
169
+ # ============================================================================
170
+ # UR5-Specific Client -> Server Messages
171
+ # ============================================================================
172
+
173
+ class ArmTargetData(TypedDict):
174
+ """Arm target position data (IK mode)."""
175
+ x: float
176
+ y: float
177
+ z: float
178
+
179
+
180
+ class ArmTargetMessage(TypedDict):
181
+ """Set arm target position message (UR5, IK mode)."""
182
+ type: Literal["arm_target"]
183
+ data: ArmTargetData
184
+
185
+
186
+ class ArmOrientationData(TypedDict):
187
+ """Arm target orientation data (IK mode)."""
188
+ roll: float
189
+ pitch: float
190
+ yaw: float
191
+
192
+
193
+ class ArmOrientationMessage(TypedDict):
194
+ """Set arm target orientation message (UR5, IK mode)."""
195
+ type: Literal["arm_orientation"]
196
+ data: ArmOrientationData
197
+
198
+
199
+ class UseOrientationData(TypedDict):
200
+ """Toggle orientation control data."""
201
+ enabled: bool
202
+
203
+
204
+ class UseOrientationMessage(TypedDict):
205
+ """Toggle orientation control message (UR5)."""
206
+ type: Literal["use_orientation"]
207
+ data: UseOrientationData
208
+
209
+
210
+ class JointPositionsData(TypedDict):
211
+ """Joint positions data (joint mode)."""
212
+ positions: List[float] # Array of 6 joint angles in radians
213
+
214
+
215
+ class JointPositionsMessage(TypedDict):
216
+ """Set joint positions message (UR5, joint mode)."""
217
+ type: Literal["joint_positions"]
218
+ data: JointPositionsData
219
+
220
+
221
+ class ControlModeData(TypedDict):
222
+ """Control mode data."""
223
+ mode: ControlMode
224
+
225
+
226
+ class ControlModeMessage(TypedDict):
227
+ """Set control mode message (UR5)."""
228
+ type: Literal["control_mode"]
229
+ data: ControlModeData
230
+
231
+
232
+ class GripperData(TypedDict):
233
+ """Gripper control data."""
234
+ action: Literal["open", "close"]
235
+ value: Optional[int] # 0-255, only used if action is not "open" or "close"
236
+
237
+
238
+ class GripperMessage(TypedDict):
239
+ """Gripper control message (UR5)."""
240
+ type: Literal["gripper"]
241
+ data: GripperData
242
+
243
+
244
+ class NovaModeSetting(TypedDict):
245
+ """Nova API mode settings."""
246
+ state_streaming: bool
247
+ ik: bool
248
+
249
+
250
+ class SetNovaModeData(TypedDict):
251
+ """Set Nova API mode data."""
252
+ enabled: Optional[bool] # Legacy: enable/disable all Nova features
253
+ # New granular settings:
254
+ state_streaming: Optional[bool]
255
+ ik: Optional[bool]
256
+
257
+
258
+ class SetNovaModeMessage(TypedDict):
259
+ """Set Nova API mode message (UR5)."""
260
+ type: Literal["set_nova_mode"]
261
+ data: SetNovaModeData
262
+
263
+
264
+ # ============================================================================
265
+ # Trainer Messages (Client -> Server)
266
+ # ============================================================================
267
+
268
+ class TrainerIdentityData(TypedDict):
269
+ """Trainer identity data."""
270
+ trainer_id: str
271
+
272
+
273
+ class TrainerIdentityMessage(TypedDict):
274
+ """Trainer identity handshake message."""
275
+ type: Literal["trainer_identity"]
276
+ data: TrainerIdentityData
277
+
278
+
279
+ class TrainerNotificationData(TypedDict, total=False):
280
+ """Trainer notification data."""
281
+ message: str
282
+ level: Literal["info", "warning", "error"]
283
+
284
+
285
+ class TrainerNotificationMessage(TypedDict):
286
+ """Trainer notification message."""
287
+ type: Literal["notification"]
288
+ data: TrainerNotificationData
289
+
290
+
291
+ class EpisodeControlData(TypedDict):
292
+ """Episode control data."""
293
+ action: Literal["terminate", "truncate"]
294
+
295
+
296
+ class EpisodeControlMessage(TypedDict):
297
+ """Episode control message (trainer only)."""
298
+ type: Literal["episode_control"]
299
+ data: EpisodeControlData
300
+
301
+
302
+ # ============================================================================
303
+ # Server -> Client Messages
304
+ # ============================================================================
305
+
306
+ class Position(TypedDict):
307
+ """3D position."""
308
+ x: float
309
+ y: float
310
+ z: float
311
+
312
+
313
+ class Quaternion(TypedDict):
314
+ """Quaternion orientation."""
315
+ w: float
316
+ x: float
317
+ y: float
318
+ z: float
319
+
320
+
321
+ class EulerAngles(TypedDict):
322
+ """Euler angles orientation."""
323
+ roll: float
324
+ pitch: float
325
+ yaw: float
326
+
327
+
328
+ class LocomotionObservation(TypedDict):
329
+ """Observation data for locomotion robots (G1, Spot)."""
330
+ position: Position
331
+ orientation: Quaternion
332
+
333
+
334
+ class UR5Observation(TypedDict):
335
+ """Observation data for UR5 robot arm."""
336
+ end_effector: Position
337
+ ee_orientation: Quaternion
338
+ ee_target: Position
339
+ ee_target_orientation: EulerAngles
340
+ gripper: int # 0-255
341
+ joint_positions: List[float] # 6 joint angles
342
+ joint_targets: List[float] # 6 target joint angles
343
+
344
+
345
+ Observation = Union[LocomotionObservation, UR5Observation]
346
+
347
+
348
+ class NovaApiStatus(TypedDict):
349
+ """Nova API integration status."""
350
+ connected: bool
351
+ state_streaming: bool
352
+ ik: bool
353
+
354
+
355
+ class StateData(TypedDict, total=False):
356
+ """State broadcast data."""
357
+ observation: Observation
358
+ steps: int
359
+ reward: float
360
+ teleop_action: ActionData # Current action/velocity commands
361
+ trainer_connected: bool
362
+
363
+ # UR5-specific fields
364
+ control_mode: ControlMode
365
+ nova_api: NovaApiStatus
366
+
367
+
368
+ class StateMessage(TypedDict):
369
+ """State broadcast message."""
370
+ type: Literal["state"]
371
+ data: StateData
372
+
373
+
374
+ class TrainerStatusData(TypedDict):
375
+ """Trainer connection status data."""
376
+ connected: bool
377
+ trainer_id: Optional[str]
378
+
379
+
380
+ class TrainerStatusMessage(TypedDict):
381
+ """Trainer status broadcast message (to UI clients)."""
382
+ type: Literal["trainer_status"]
383
+ data: TrainerStatusData
384
+
385
+
386
+ class TrainerNotificationBroadcast(TypedDict):
387
+ """Trainer notification broadcast (to UI clients)."""
388
+ type: Literal["trainer_notification"]
389
+ data: TrainerNotificationData
390
+
391
+
392
+ # ============================================================================
393
+ # HTTP Response Types
394
+ # ============================================================================
395
+
396
+ class CameraFeed(TypedDict):
397
+ """Camera feed information."""
398
+ name: str
399
+ label: str
400
+ url: str
401
+
402
+
403
+ class EnvResponse(TypedDict, total=False):
404
+ """GET /env response."""
405
+ robot: RobotType
406
+ scene: str
407
+ has_gripper: bool
408
+ control_mode: ControlMode
409
+ action_space: dict # Gym space serialization
410
+ observation_space: dict # Gym space serialization
411
+ camera_feeds: List[CameraFeed]
412
+ home_pose: List[float] # Joint angles for home position
413
+
414
+
415
+ class CommandInfo(TypedDict):
416
+ """Command metadata."""
417
+ name: str
418
+ description: str
419
+
420
+
421
+ class MetadataResponse(TypedDict):
422
+ """GET /metadata response."""
423
+ robots: List[str]
424
+ commands: List[CommandInfo]
425
+
426
+
427
+ # ============================================================================
428
+ # Union Types for All Messages
429
+ # ============================================================================
430
+
431
+ ClientMessage = Union[
432
+ ActionMessage,
433
+ TeleopActionMessage,
434
+ ResetMessage,
435
+ SwitchRobotMessage,
436
+ HomeMessage,
437
+ StopHomeMessage,
438
+ CameraMessage,
439
+ CameraFollowMessage,
440
+ ArmTargetMessage,
441
+ ArmOrientationMessage,
442
+ UseOrientationMessage,
443
+ JointPositionsMessage,
444
+ ControlModeMessage,
445
+ GripperMessage,
446
+ SetNovaModeMessage,
447
+ TrainerIdentityMessage,
448
+ TrainerNotificationMessage,
449
+ EpisodeControlMessage,
450
+ ]
451
+
452
+ ServerMessage = Union[
453
+ StateMessage,
454
+ TrainerStatusMessage,
455
+ TrainerNotificationBroadcast,
456
+ ]
tests/test_api.py CHANGED
@@ -38,7 +38,7 @@ class TestHTTPEndpoints:
38
 
39
  data = response.json()
40
  assert "robots" in data
41
- assert "commands" in data
42
  # camera_feeds, current_selection moved to /env endpoint
43
  assert "camera_feeds" not in data
44
  assert "overlay_camera_presets" not in data
@@ -225,8 +225,8 @@ class TestWebSocketMessages:
225
  tolerance = 0.01
226
  assert final_distance < tolerance, f"Robot did not reach home position: distance={final_distance:.4f}, tolerance={tolerance}"
227
 
228
- def test_teleop_command_message(self, check_server):
229
- """Test teleop_command WebSocket message with new vx/vy/vz format."""
230
  with connect(WS_URL, timeout=10) as ws:
231
  # Ensure we're on UR5
232
  ws.send(json.dumps({
@@ -235,28 +235,28 @@ class TestWebSocketMessages:
235
  }))
236
  time.sleep(2)
237
 
238
- # Send teleop command with new format
239
  ws.send(json.dumps({
240
- "type": "teleop_command",
241
  "data": {"vx": 0.01, "vy": 0.0, "vz": 0.0}
242
  }))
243
 
244
- # Receive state and check teleop_command
245
  msg = ws.recv(timeout=2)
246
  data = json.loads(msg)
247
  assert data.get("type") == "state"
248
  state_data = data.get("data", {})
249
- teleop = state_data.get("teleop_command")
250
 
251
- # Should always have teleop_command (never null)
252
  assert teleop is not None
253
  assert isinstance(teleop, dict)
254
  assert "vx" in teleop
255
  assert "vy" in teleop
256
  assert "vz" in teleop
257
 
258
- def test_command_message(self, check_server):
259
- """Test command WebSocket message for locomotion robots."""
260
  with connect(WS_URL, timeout=10) as ws:
261
  # Switch to Spot
262
  ws.send(json.dumps({
@@ -265,9 +265,9 @@ class TestWebSocketMessages:
265
  }))
266
  time.sleep(2)
267
 
268
- # Send command
269
  ws.send(json.dumps({
270
- "type": "command",
271
  "data": {"vx": 0.5, "vy": 0.0, "vyaw": 0.0}
272
  }))
273
 
@@ -276,9 +276,9 @@ class TestWebSocketMessages:
276
  data = json.loads(msg)
277
  assert data.get("type") == "state"
278
  state_data = data.get("data", {})
279
- teleop = state_data.get("teleop_command")
280
 
281
- # Should have teleop_command with values
282
  assert teleop is not None
283
  assert isinstance(teleop, dict)
284
 
@@ -309,8 +309,8 @@ class TestStateStructure:
309
  assert "scene" not in state_data, "scene should not be in state stream"
310
  assert "has_gripper" not in state_data, "has_gripper should not be in state stream"
311
 
312
- def test_state_always_has_teleop_command(self, check_server):
313
- """Verify state always contains teleop_command (never null)."""
314
  with connect(WS_URL, timeout=10) as ws:
315
  # Get multiple state messages
316
  for _ in range(3):
@@ -318,11 +318,11 @@ class TestStateStructure:
318
  data = json.loads(msg)
319
  if data.get("type") == "state":
320
  state_data = data.get("data", {})
321
- teleop = state_data.get("teleop_command")
322
 
323
  # Should never be None
324
- assert teleop is not None, "teleop_command should never be null"
325
- assert isinstance(teleop, dict), "teleop_command should be a dict"
326
 
327
  # Should have expected keys
328
  assert "vx" in teleop
@@ -358,7 +358,7 @@ class TestStateStructure:
358
  assert "control_mode" in state_data
359
  assert "steps" in state_data
360
  assert "reward" in state_data
361
- assert "teleop_command" in state_data
362
  assert "trainer_connected" in state_data
363
  # Verify old fields are not in state root
364
  assert "target" not in state_data
@@ -388,7 +388,7 @@ class TestStateStructure:
388
  assert "orientation" in obs
389
  assert "steps" in state_data
390
  assert "reward" in state_data
391
- assert "teleop_command" in state_data
392
  assert "trainer_connected" in state_data
393
  # Verify old fields are removed
394
  assert "base_height" not in state_data
@@ -411,15 +411,57 @@ class TestBackwardCompatibility:
411
  }))
412
  time.sleep(2)
413
 
414
- # Send teleop command with old format (dx/dy/dz)
415
  ws.send(json.dumps({
416
- "type": "teleop_command",
417
  "data": {"dx": 0.01, "dy": 0.0, "dz": 0.0}
418
  }))
419
 
420
  # Should still work - receive state
421
  msg = ws.recv(timeout=2)
422
- data = json.loads(msg) # Fixed: was json.dumps, should be json.loads
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  assert data.get("type") == "state"
424
 
425
 
@@ -452,7 +494,7 @@ class TestRobotMovement:
452
  # Send multiple teleop commands to move in +X direction
453
  for _ in range(5):
454
  ws.send(json.dumps({
455
- "type": "teleop_command",
456
  "data": {"vx": 0.02, "vy": 0.0, "vz": 0.0}
457
  }))
458
  time.sleep(0.1)
@@ -471,8 +513,8 @@ class TestRobotMovement:
471
  target_x = target.get("x", 0)
472
  assert target_x > initial_x, f"Expected target X to increase from {initial_x}, got {target_x}"
473
 
474
- def test_spot_command_moves_robot(self, check_server):
475
- """Test Spot responds to command messages and position changes."""
476
  with connect(WS_URL, timeout=10) as ws:
477
  # Switch to Spot
478
  ws.send(json.dumps({
@@ -494,10 +536,10 @@ class TestRobotMovement:
494
  initial_pos = obs.get("position", {})
495
  initial_x = initial_pos.get("x", 0)
496
 
497
- # Send multiple commands to move forward
498
  for _ in range(10):
499
  ws.send(json.dumps({
500
- "type": "command",
501
  "data": {"vx": 0.5, "vy": 0.0, "vyaw": 0.0}
502
  }))
503
  time.sleep(0.1)
@@ -514,8 +556,8 @@ class TestRobotMovement:
514
  # Locomotion robots may move slowly or fall, just verify some movement occurred
515
  assert abs(new_x - initial_x) > 0.001, f"Expected some X movement from {initial_x}, got {new_x}"
516
 
517
- def test_g1_command_moves_robot(self, check_server):
518
- """Test G1 responds to command messages and position changes."""
519
  with connect(WS_URL, timeout=10) as ws:
520
  # Switch to G1
521
  ws.send(json.dumps({
@@ -537,10 +579,10 @@ class TestRobotMovement:
537
  initial_pos = obs.get("position", {})
538
  initial_x = initial_pos.get("x", 0)
539
 
540
- # Send multiple commands to move forward
541
  for _ in range(10):
542
  ws.send(json.dumps({
543
- "type": "command",
544
  "data": {"vx": 0.5, "vy": 0.0, "vyaw": 0.0}
545
  }))
546
  time.sleep(0.1)
@@ -594,8 +636,65 @@ class TestRobotMovement:
594
  data = json.loads(msg)
595
  assert data.get("type") == "state"
596
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
597
  def test_ur5_joint_jog_teleop(self, check_server):
598
- """Test joint jogging updates teleop_command with joint velocities."""
599
  with connect(WS_URL, timeout=10) as ws:
600
  # Switch to UR5
601
  ws.send(json.dumps({
@@ -618,7 +717,7 @@ class TestRobotMovement:
618
  }))
619
  time.sleep(0.2)
620
 
621
- # Start joint jog on joint 3 in positive direction
622
  ws.send(json.dumps({
623
  "type": "start_jog",
624
  "data": {
@@ -631,22 +730,22 @@ class TestRobotMovement:
631
  }
632
  }))
633
 
634
- # Get state and verify teleop_command has joint velocities
635
  # Receive multiple messages to ensure we get a fresh state after the jog command
636
  time.sleep(0.3)
637
  for i in range(3):
638
  msg = ws.recv(timeout=2)
639
  data = json.loads(msg)
640
- teleop = data.get("data", {}).get("teleop_command", {})
641
  obs = data.get("data", {}).get("observation", {})
642
  has_ee = 'end_effector' in obs # UR5 has end_effector, G1/Spot don't
643
  print(f"\nDEBUG message {i+1}: robot={'UR5' if has_ee else 'LOCO'}, teleop j3 = {teleop.get('j3', 'MISSING')}")
644
 
645
  state_data = data.get("data", {})
646
- teleop = state_data.get("teleop_command", {})
647
 
648
  # Debug: print actual values received
649
- print(f"\nDEBUG: Final teleop_command = {teleop}")
650
 
651
  # Joint 3 should have velocity 0.5, others should be 0
652
  assert teleop.get("j3", 0) == 0.5, f"Expected j3=0.5, got {teleop.get('j3')}, full teleop={teleop}"
@@ -665,13 +764,82 @@ class TestRobotMovement:
665
  msg = ws.recv(timeout=2)
666
  data = json.loads(msg)
667
  state_data = data.get("data", {})
668
- teleop = state_data.get("teleop_command", {})
669
 
670
  assert teleop.get("j3", 1) == 0.0
671
  assert teleop.get("vx", 1) == 0.0
672
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
673
  def test_ur5_cartesian_jog_teleop(self, check_server):
674
- """Test Cartesian jogging updates teleop_command with Cartesian velocities."""
675
  with connect(WS_URL, timeout=10) as ws:
676
  # Switch to UR5
677
  ws.send(json.dumps({
@@ -709,14 +877,14 @@ class TestRobotMovement:
709
  }
710
  }))
711
 
712
- # Get state and verify teleop_command has vx velocity
713
  # Receive multiple messages to ensure we get a fresh state after the jog command
714
  time.sleep(0.3)
715
  for _ in range(3):
716
  msg = ws.recv(timeout=2)
717
  data = json.loads(msg)
718
  state_data = data.get("data", {})
719
- teleop = state_data.get("teleop_command", {})
720
 
721
  # vx should have velocity 0.05 m/s (50 mm/s), others should be 0
722
  assert abs(teleop.get("vx", 0) - 0.05) < 0.01, f"Expected vx near 0.05, got {teleop.get('vx')}"
@@ -747,14 +915,14 @@ class TestRobotMovement:
747
  }
748
  }))
749
 
750
- # Get state and verify teleop_command has vrz velocity
751
  # Receive multiple messages to ensure we get a fresh state after the jog command
752
  time.sleep(0.3)
753
  for _ in range(3):
754
  msg = ws.recv(timeout=2)
755
  data = json.loads(msg)
756
  state_data = data.get("data", {})
757
- teleop = state_data.get("teleop_command", {})
758
 
759
  # vrz should have velocity -0.3 rad/s, others should be 0
760
  assert teleop.get("vrz", 0) == -0.3, f"Expected vrz=-0.3, got {teleop.get('vrz')}"
 
38
 
39
  data = response.json()
40
  assert "robots" in data
41
+ assert "actions" in data
42
  # camera_feeds, current_selection moved to /env endpoint
43
  assert "camera_feeds" not in data
44
  assert "overlay_camera_presets" not in data
 
225
  tolerance = 0.01
226
  assert final_distance < tolerance, f"Robot did not reach home position: distance={final_distance:.4f}, tolerance={tolerance}"
227
 
228
+ def test_teleop_action_message(self, check_server):
229
+ """Test teleop_action WebSocket message with vx/vy/vz format."""
230
  with connect(WS_URL, timeout=10) as ws:
231
  # Ensure we're on UR5
232
  ws.send(json.dumps({
 
235
  }))
236
  time.sleep(2)
237
 
238
+ # Send teleop action with new format
239
  ws.send(json.dumps({
240
+ "type": "teleop_action",
241
  "data": {"vx": 0.01, "vy": 0.0, "vz": 0.0}
242
  }))
243
 
244
+ # Receive state and check teleop_action
245
  msg = ws.recv(timeout=2)
246
  data = json.loads(msg)
247
  assert data.get("type") == "state"
248
  state_data = data.get("data", {})
249
+ teleop = state_data.get("teleop_action")
250
 
251
+ # Should always have teleop_action (never null)
252
  assert teleop is not None
253
  assert isinstance(teleop, dict)
254
  assert "vx" in teleop
255
  assert "vy" in teleop
256
  assert "vz" in teleop
257
 
258
+ def test_action_message(self, check_server):
259
+ """Test action WebSocket message for locomotion robots."""
260
  with connect(WS_URL, timeout=10) as ws:
261
  # Switch to Spot
262
  ws.send(json.dumps({
 
265
  }))
266
  time.sleep(2)
267
 
268
+ # Send action
269
  ws.send(json.dumps({
270
+ "type": "action",
271
  "data": {"vx": 0.5, "vy": 0.0, "vyaw": 0.0}
272
  }))
273
 
 
276
  data = json.loads(msg)
277
  assert data.get("type") == "state"
278
  state_data = data.get("data", {})
279
+ teleop = state_data.get("teleop_action")
280
 
281
+ # Should have teleop_action with values
282
  assert teleop is not None
283
  assert isinstance(teleop, dict)
284
 
 
309
  assert "scene" not in state_data, "scene should not be in state stream"
310
  assert "has_gripper" not in state_data, "has_gripper should not be in state stream"
311
 
312
+ def test_state_always_has_teleop_action(self, check_server):
313
+ """Verify state always contains teleop_action (never null)."""
314
  with connect(WS_URL, timeout=10) as ws:
315
  # Get multiple state messages
316
  for _ in range(3):
 
318
  data = json.loads(msg)
319
  if data.get("type") == "state":
320
  state_data = data.get("data", {})
321
+ teleop = state_data.get("teleop_action")
322
 
323
  # Should never be None
324
+ assert teleop is not None, "teleop_action should never be null"
325
+ assert isinstance(teleop, dict), "teleop_action should be a dict"
326
 
327
  # Should have expected keys
328
  assert "vx" in teleop
 
358
  assert "control_mode" in state_data
359
  assert "steps" in state_data
360
  assert "reward" in state_data
361
+ assert "teleop_action" in state_data
362
  assert "trainer_connected" in state_data
363
  # Verify old fields are not in state root
364
  assert "target" not in state_data
 
388
  assert "orientation" in obs
389
  assert "steps" in state_data
390
  assert "reward" in state_data
391
+ assert "teleop_action" in state_data
392
  assert "trainer_connected" in state_data
393
  # Verify old fields are removed
394
  assert "base_height" not in state_data
 
411
  }))
412
  time.sleep(2)
413
 
414
+ # Send teleop action with old format (dx/dy/dz)
415
  ws.send(json.dumps({
416
+ "type": "teleop_action",
417
  "data": {"dx": 0.01, "dy": 0.0, "dz": 0.0}
418
  }))
419
 
420
  # Should still work - receive state
421
  msg = ws.recv(timeout=2)
422
+ data = json.loads(msg)
423
+ assert data.get("type") == "state"
424
+
425
+ def test_old_command_type_accepted(self, check_server):
426
+ """Test that old 'command' message type is still accepted."""
427
+ with connect(WS_URL, timeout=10) as ws:
428
+ # Switch to Spot
429
+ ws.send(json.dumps({
430
+ "type": "switch_robot",
431
+ "data": {"robot": "spot"}
432
+ }))
433
+ time.sleep(2)
434
+
435
+ # Send command with old message type
436
+ ws.send(json.dumps({
437
+ "type": "command",
438
+ "data": {"vx": 0.5, "vy": 0.0, "vyaw": 0.0}
439
+ }))
440
+
441
+ # Should still work - receive state
442
+ msg = ws.recv(timeout=2)
443
+ data = json.loads(msg)
444
+ assert data.get("type") == "state"
445
+
446
+ def test_old_teleop_command_type_accepted(self, check_server):
447
+ """Test that old 'teleop_command' message type is still accepted."""
448
+ with connect(WS_URL, timeout=10) as ws:
449
+ # Switch to UR5
450
+ ws.send(json.dumps({
451
+ "type": "switch_robot",
452
+ "data": {"robot": "ur5"}
453
+ }))
454
+ time.sleep(2)
455
+
456
+ # Send teleop with old message type
457
+ ws.send(json.dumps({
458
+ "type": "teleop_command",
459
+ "data": {"vx": 0.01, "vy": 0.0, "vz": 0.0}
460
+ }))
461
+
462
+ # Should still work - receive state
463
+ msg = ws.recv(timeout=2)
464
+ data = json.loads(msg)
465
  assert data.get("type") == "state"
466
 
467
 
 
494
  # Send multiple teleop commands to move in +X direction
495
  for _ in range(5):
496
  ws.send(json.dumps({
497
+ "type": "teleop_action",
498
  "data": {"vx": 0.02, "vy": 0.0, "vz": 0.0}
499
  }))
500
  time.sleep(0.1)
 
513
  target_x = target.get("x", 0)
514
  assert target_x > initial_x, f"Expected target X to increase from {initial_x}, got {target_x}"
515
 
516
+ def test_spot_action_moves_robot(self, check_server):
517
+ """Test Spot responds to action messages and position changes."""
518
  with connect(WS_URL, timeout=10) as ws:
519
  # Switch to Spot
520
  ws.send(json.dumps({
 
536
  initial_pos = obs.get("position", {})
537
  initial_x = initial_pos.get("x", 0)
538
 
539
+ # Send multiple actions to move forward
540
  for _ in range(10):
541
  ws.send(json.dumps({
542
+ "type": "action",
543
  "data": {"vx": 0.5, "vy": 0.0, "vyaw": 0.0}
544
  }))
545
  time.sleep(0.1)
 
556
  # Locomotion robots may move slowly or fall, just verify some movement occurred
557
  assert abs(new_x - initial_x) > 0.001, f"Expected some X movement from {initial_x}, got {new_x}"
558
 
559
+ def test_g1_action_moves_robot(self, check_server):
560
+ """Test G1 responds to action messages and position changes."""
561
  with connect(WS_URL, timeout=10) as ws:
562
  # Switch to G1
563
  ws.send(json.dumps({
 
579
  initial_pos = obs.get("position", {})
580
  initial_x = initial_pos.get("x", 0)
581
 
582
+ # Send multiple actions to move forward
583
  for _ in range(10):
584
  ws.send(json.dumps({
585
+ "type": "action",
586
  "data": {"vx": 0.5, "vy": 0.0, "vyaw": 0.0}
587
  }))
588
  time.sleep(0.1)
 
636
  data = json.loads(msg)
637
  assert data.get("type") == "state"
638
 
639
+ def test_ur5_action_with_joint_velocity(self, check_server):
640
+ """Test UR5 responds to action messages with joint velocities."""
641
+ with connect(WS_URL, timeout=10) as ws:
642
+ # Switch to UR5
643
+ ws.send(json.dumps({
644
+ "type": "switch_robot",
645
+ "data": {"robot": "ur5"}
646
+ }))
647
+ time.sleep(2)
648
+
649
+ # Reset to clear any previous state
650
+ ws.send(json.dumps({"type": "reset"}))
651
+ time.sleep(0.5)
652
+
653
+ # Send action with joint 3 velocity set
654
+ ws.send(json.dumps({
655
+ "type": "action",
656
+ "data": {
657
+ "vx": 0.0, "vy": 0.0, "vz": 0.0,
658
+ "vrx": 0.0, "vry": 0.0, "vrz": 0.0,
659
+ "j1": 0.0, "j2": 0.0, "j3": 0.5, "j4": 0.0, "j5": 0.0, "j6": 0.0,
660
+ "gripper": 0.0
661
+ }
662
+ }))
663
+
664
+ # Get state and verify teleop_action has joint velocity
665
+ time.sleep(0.3)
666
+ for _ in range(3):
667
+ msg = ws.recv(timeout=2)
668
+ data = json.loads(msg)
669
+ state_data = data.get("data", {})
670
+ teleop = state_data.get("teleop_action", {})
671
+
672
+ # Joint 3 should have velocity 0.5
673
+ assert abs(teleop.get("j3", 0) - 0.5) < 0.01, f"Expected j3=0.5, got {teleop.get('j3')}"
674
+
675
+ # Stop by sending zero velocities
676
+ ws.send(json.dumps({
677
+ "type": "action",
678
+ "data": {
679
+ "vx": 0.0, "vy": 0.0, "vz": 0.0,
680
+ "vrx": 0.0, "vry": 0.0, "vrz": 0.0,
681
+ "j1": 0.0, "j2": 0.0, "j3": 0.0, "j4": 0.0, "j5": 0.0, "j6": 0.0,
682
+ "gripper": 0.0
683
+ }
684
+ }))
685
+
686
+ time.sleep(0.3)
687
+ for _ in range(3):
688
+ msg = ws.recv(timeout=2)
689
+ data = json.loads(msg)
690
+ state_data = data.get("data", {})
691
+ teleop = state_data.get("teleop_action", {})
692
+
693
+ # All velocities should be zero
694
+ assert abs(teleop.get("j3", 1)) < 0.01
695
+
696
  def test_ur5_joint_jog_teleop(self, check_server):
697
+ """Test joint jogging with start_jog updates teleop_action with joint velocities."""
698
  with connect(WS_URL, timeout=10) as ws:
699
  # Switch to UR5
700
  ws.send(json.dumps({
 
717
  }))
718
  time.sleep(0.2)
719
 
720
+ # Start joint jog on joint 3 in positive direction (legacy start_jog still supported)
721
  ws.send(json.dumps({
722
  "type": "start_jog",
723
  "data": {
 
730
  }
731
  }))
732
 
733
+ # Get state and verify teleop_action has joint velocities
734
  # Receive multiple messages to ensure we get a fresh state after the jog command
735
  time.sleep(0.3)
736
  for i in range(3):
737
  msg = ws.recv(timeout=2)
738
  data = json.loads(msg)
739
+ teleop = data.get("data", {}).get("teleop_action", {})
740
  obs = data.get("data", {}).get("observation", {})
741
  has_ee = 'end_effector' in obs # UR5 has end_effector, G1/Spot don't
742
  print(f"\nDEBUG message {i+1}: robot={'UR5' if has_ee else 'LOCO'}, teleop j3 = {teleop.get('j3', 'MISSING')}")
743
 
744
  state_data = data.get("data", {})
745
+ teleop = state_data.get("teleop_action", {})
746
 
747
  # Debug: print actual values received
748
+ print(f"\nDEBUG: Final teleop_action = {teleop}")
749
 
750
  # Joint 3 should have velocity 0.5, others should be 0
751
  assert teleop.get("j3", 0) == 0.5, f"Expected j3=0.5, got {teleop.get('j3')}, full teleop={teleop}"
 
764
  msg = ws.recv(timeout=2)
765
  data = json.loads(msg)
766
  state_data = data.get("data", {})
767
+ teleop = state_data.get("teleop_action", {})
768
 
769
  assert teleop.get("j3", 1) == 0.0
770
  assert teleop.get("vx", 1) == 0.0
771
 
772
+ def test_ur5_action_with_cartesian_velocity(self, check_server):
773
+ """Test UR5 responds to action messages with Cartesian velocities."""
774
+ with connect(WS_URL, timeout=10) as ws:
775
+ # Switch to UR5
776
+ ws.send(json.dumps({
777
+ "type": "switch_robot",
778
+ "data": {"robot": "ur5"}
779
+ }))
780
+ time.sleep(2)
781
+
782
+ # Reset to clear any previous state
783
+ ws.send(json.dumps({"type": "reset"}))
784
+ time.sleep(0.5)
785
+
786
+ # Send action with vx velocity set (50 mm/s = 0.05 m/s)
787
+ ws.send(json.dumps({
788
+ "type": "action",
789
+ "data": {
790
+ "vx": 0.05, "vy": 0.0, "vz": 0.0,
791
+ "vrx": 0.0, "vry": 0.0, "vrz": 0.0,
792
+ "j1": 0.0, "j2": 0.0, "j3": 0.0, "j4": 0.0, "j5": 0.0, "j6": 0.0,
793
+ "gripper": 0.0
794
+ }
795
+ }))
796
+
797
+ # Get state and verify teleop_action has vx velocity
798
+ time.sleep(0.3)
799
+ for _ in range(3):
800
+ msg = ws.recv(timeout=2)
801
+ data = json.loads(msg)
802
+ state_data = data.get("data", {})
803
+ teleop = state_data.get("teleop_action", {})
804
+
805
+ # vx should be 0.05 m/s
806
+ assert abs(teleop.get("vx", 0) - 0.05) < 0.01, f"Expected vx=0.05, got {teleop.get('vx')}"
807
+
808
+ # Send action with rotation velocity
809
+ ws.send(json.dumps({
810
+ "type": "action",
811
+ "data": {
812
+ "vx": 0.0, "vy": 0.0, "vz": 0.0,
813
+ "vrx": 0.0, "vry": 0.0, "vrz": -0.3,
814
+ "j1": 0.0, "j2": 0.0, "j3": 0.0, "j4": 0.0, "j5": 0.0, "j6": 0.0,
815
+ "gripper": 0.0
816
+ }
817
+ }))
818
+
819
+ # Get state and verify teleop_action has vrz velocity
820
+ time.sleep(0.3)
821
+ for _ in range(3):
822
+ msg = ws.recv(timeout=2)
823
+ data = json.loads(msg)
824
+ state_data = data.get("data", {})
825
+ teleop = state_data.get("teleop_action", {})
826
+
827
+ # vrz should be -0.3 rad/s
828
+ assert abs(teleop.get("vrz", 0) + 0.3) < 0.01, f"Expected vrz=-0.3, got {teleop.get('vrz')}"
829
+
830
+ # Stop by sending zero velocities
831
+ ws.send(json.dumps({
832
+ "type": "action",
833
+ "data": {
834
+ "vx": 0.0, "vy": 0.0, "vz": 0.0,
835
+ "vrx": 0.0, "vry": 0.0, "vrz": 0.0,
836
+ "j1": 0.0, "j2": 0.0, "j3": 0.0, "j4": 0.0, "j5": 0.0, "j6": 0.0,
837
+ "gripper": 0.0
838
+ }
839
+ }))
840
+
841
  def test_ur5_cartesian_jog_teleop(self, check_server):
842
+ """Test Cartesian jogging with start_jog updates teleop_action with Cartesian velocities."""
843
  with connect(WS_URL, timeout=10) as ws:
844
  # Switch to UR5
845
  ws.send(json.dumps({
 
877
  }
878
  }))
879
 
880
+ # Get state and verify teleop_action has vx velocity
881
  # Receive multiple messages to ensure we get a fresh state after the jog command
882
  time.sleep(0.3)
883
  for _ in range(3):
884
  msg = ws.recv(timeout=2)
885
  data = json.loads(msg)
886
  state_data = data.get("data", {})
887
+ teleop = state_data.get("teleop_action", {})
888
 
889
  # vx should have velocity 0.05 m/s (50 mm/s), others should be 0
890
  assert abs(teleop.get("vx", 0) - 0.05) < 0.01, f"Expected vx near 0.05, got {teleop.get('vx')}"
 
915
  }
916
  }))
917
 
918
+ # Get state and verify teleop_action has vrz velocity
919
  # Receive multiple messages to ensure we get a fresh state after the jog command
920
  time.sleep(0.3)
921
  for _ in range(3):
922
  msg = ws.recv(timeout=2)
923
  data = json.loads(msg)
924
  state_data = data.get("data", {})
925
+ teleop = state_data.get("teleop_action", {})
926
 
927
  # vrz should have velocity -0.3 rad/s, others should be 0
928
  assert teleop.get("vrz", 0) == -0.3, f"Expected vrz=-0.3, got {teleop.get('vrz')}"