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.
- README.md +39 -25
- mujoco_server.py +154 -71
- protocol_types.py +456 -0
- 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 `
|
| 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
|
| 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,
|
| 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: '
|
| 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
|
| 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,
|
| 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
|
| 425 |
|
| 426 |
### Client → Server WebSocket Messages
|
| 427 |
|
| 428 |
-
**`
|
| 429 |
```json
|
| 430 |
-
{"type": "
|
| 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 |
-
**`
|
| 455 |
```json
|
| 456 |
-
{"type": "
|
| 457 |
```
|
| 458 |
-
- `vx`, `vy`, `vz`
|
| 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 |
-
"
|
| 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 |
-
"
|
| 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 |
-
- `
|
| 575 |
-
-
|
| 576 |
-
-
|
| 577 |
-
- UR5:
|
|
|
|
|
|
|
|
|
|
| 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 |
-
- `
|
| 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,
|
| 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
|
| 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
|
| 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 |
-
**`
|
| 877 |
```json
|
| 878 |
-
{"type": "
|
| 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 `
|
| 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
|
| 104 |
# Initialize with zero values instead of None so it's always a dict
|
| 105 |
-
|
| 106 |
"vx": 0.0,
|
| 107 |
"vy": 0.0,
|
| 108 |
"vz": 0.0,
|
|
@@ -216,13 +216,13 @@ DEFAULT_SCENES = {
|
|
| 216 |
"spot": "scene",
|
| 217 |
}
|
| 218 |
|
| 219 |
-
|
| 220 |
-
"
|
| 221 |
"reset",
|
| 222 |
"switch_robot",
|
| 223 |
"camera",
|
| 224 |
"camera_follow",
|
| 225 |
-
"
|
| 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
|
| 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 |
-
|
| 542 |
else:
|
| 543 |
-
|
| 544 |
else:
|
| 545 |
# Locomotion robots don't have grippers
|
| 546 |
-
|
| 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 =
|
| 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 |
-
'
|
| 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 |
-
'
|
| 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('
|
| 673 |
-
print(f"[Broadcast] Actual JSON
|
| 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,
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 983 |
with teleop_lock:
|
| 984 |
-
# Update
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 == '
|
| 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 |
-
|
| 1224 |
-
|
| 1225 |
-
|
| 1226 |
-
|
| 1227 |
# Clear jogging velocities when using teleop (jogging and teleop are mutually exclusive)
|
| 1228 |
for j in range(1, 7):
|
| 1229 |
-
|
| 1230 |
-
|
| 1231 |
-
|
| 1232 |
-
|
| 1233 |
|
| 1234 |
broadcast_to_trainer(
|
| 1235 |
-
"
|
| 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
|
| 1274 |
with teleop_lock:
|
| 1275 |
-
|
| 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
|
| 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 |
-
|
| 1341 |
# Set active joint velocity
|
| 1342 |
-
|
| 1343 |
# Clear Cartesian velocities
|
| 1344 |
-
|
| 1345 |
-
|
| 1346 |
-
|
| 1347 |
-
|
| 1348 |
-
|
| 1349 |
-
|
| 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 |
-
|
| 1360 |
-
|
| 1361 |
-
|
| 1362 |
-
|
| 1363 |
-
|
| 1364 |
-
|
| 1365 |
# Set active axis velocity
|
| 1366 |
-
|
| 1367 |
# Clear joint velocities
|
| 1368 |
for j in range(1, 7):
|
| 1369 |
-
|
| 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 |
-
|
| 1380 |
-
|
| 1381 |
-
|
| 1382 |
-
|
| 1383 |
-
|
| 1384 |
-
|
| 1385 |
# Set active rotation velocity
|
| 1386 |
-
|
| 1387 |
# Clear joint velocities
|
| 1388 |
for j in range(1, 7):
|
| 1389 |
-
|
| 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
|
| 1397 |
with teleop_lock:
|
| 1398 |
-
|
| 1399 |
-
|
| 1400 |
-
|
| 1401 |
-
|
| 1402 |
-
|
| 1403 |
-
|
| 1404 |
for j in range(1, 7):
|
| 1405 |
-
|
| 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.
|
| 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.
|
| 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('
|
| 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 |
-
"
|
| 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 "
|
| 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
|
| 229 |
-
"""Test
|
| 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
|
| 239 |
ws.send(json.dumps({
|
| 240 |
-
"type": "
|
| 241 |
"data": {"vx": 0.01, "vy": 0.0, "vz": 0.0}
|
| 242 |
}))
|
| 243 |
|
| 244 |
-
# Receive state and check
|
| 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("
|
| 250 |
|
| 251 |
-
# Should always have
|
| 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
|
| 259 |
-
"""Test
|
| 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
|
| 269 |
ws.send(json.dumps({
|
| 270 |
-
"type": "
|
| 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("
|
| 280 |
|
| 281 |
-
# Should have
|
| 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
|
| 313 |
-
"""Verify state always contains
|
| 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("
|
| 322 |
|
| 323 |
# Should never be None
|
| 324 |
-
assert teleop is not None, "
|
| 325 |
-
assert isinstance(teleop, 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 "
|
| 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 "
|
| 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
|
| 415 |
ws.send(json.dumps({
|
| 416 |
-
"type": "
|
| 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 |
|
|
@@ -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": "
|
| 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
|
| 475 |
-
"""Test Spot responds to
|
| 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
|
| 498 |
for _ in range(10):
|
| 499 |
ws.send(json.dumps({
|
| 500 |
-
"type": "
|
| 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
|
| 518 |
-
"""Test G1 responds to
|
| 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
|
| 541 |
for _ in range(10):
|
| 542 |
ws.send(json.dumps({
|
| 543 |
-
"type": "
|
| 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
|
| 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
|
| 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("
|
| 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("
|
| 647 |
|
| 648 |
# Debug: print actual values received
|
| 649 |
-
print(f"\nDEBUG: Final
|
| 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("
|
| 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
|
| 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
|
| 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("
|
| 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
|
| 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("
|
| 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')}"
|