Georg commited on
Commit
e9e2294
·
1 Parent(s): 2425670

Implement blocking homing endpoint and refactor homing logic in mujoco_server.py

Browse files

- Introduced a new blocking API endpoint (`/homing`) for UR5 that pauses jogging commands while executing homing, enhancing control during the homing process.
- Refactored the homing logic to remove the need for continuous home messages, improving efficiency and reducing potential drift.
- Updated the frontend to utilize the new blocking endpoint, simplifying the user interaction for homing.
- Removed deprecated home and stop_home message types from protocol_types.py to streamline the codebase.
- Enhanced tests to validate the new blocking homing functionality and ensure proper behavior during homing operations.
- Updated README.md to document the new homing endpoint and its usage.

README.md CHANGED
@@ -422,6 +422,7 @@ Nova-Sim provides a minimal HTTP API for static information:
422
  | `/video_feed` | `GET` | MJPEG video stream of the main camera |
423
  | `/camera/<name>/video_feed` | `GET` | MJPEG video stream of auxiliary cameras (added via `/cameras`) |
424
  | `/cameras` | `POST` | Register a new auxiliary camera for the current robot/scene |
 
425
 
426
  **Example `/env` response:**
427
  ```json
@@ -478,6 +479,14 @@ Notes:
478
  - Cameras are scoped to the current robot + scene and show up in `/env` under `camera_feeds`.
479
  - The UI listens for a `state` message containing `camera_event` and refreshes tiles automatically.
480
 
 
 
 
 
 
 
 
 
481
  ### Client → Server WebSocket Messages
482
 
483
  **`action`** - Send velocity actions to all robots:
@@ -511,16 +520,6 @@ For robot arms (UR5):
511
  - `robot`: Required. One of: `"g1"`, `"spot"`, `"ur5"`, `"ur5_t_push"`
512
  - `scene`: Optional scene name (see `/metadata` for available scenes)
513
 
514
- **`home`** - Move robot to home position (UR5 only):
515
- ```json
516
- {"type": "home"}
517
- ```
518
-
519
- **`stop_home`** - Stop homing sequence (UR5 only):
520
- ```json
521
- {"type": "stop_home"}
522
- ```
523
-
524
  **`teleop_action`** - Send teleoperation action (primarily for UR5 keyboard control):
525
  ```json
526
  {"type": "teleop_action", "data": {"vx": 0.01, "vy": 0.0, "vz": 0.0}}
 
422
  | `/video_feed` | `GET` | MJPEG video stream of the main camera |
423
  | `/camera/<name>/video_feed` | `GET` | MJPEG video stream of auxiliary cameras (added via `/cameras`) |
424
  | `/cameras` | `POST` | Register a new auxiliary camera for the current robot/scene |
425
+ | `/homing` | `GET` | **Blocking** homing for UR5 via Nova jogging (pauses joggers while executing) |
426
 
427
  **Example `/env` response:**
428
  ```json
 
479
  - Cameras are scoped to the current robot + scene and show up in `/env` under `camera_feeds`.
480
  - The UI listens for a `state` message containing `camera_event` and refreshes tiles automatically.
481
 
482
+ **Blocking homing (`GET /homing`)**
483
+ ```
484
+ /nova-sim/api/v1/homing?timeout_s=30&tolerance=0.01&poll_interval_s=0.1
485
+ ```
486
+ Notes:
487
+ - This endpoint is **blocking**: it returns only after the robot reaches `home_pose` or a timeout occurs.
488
+ - While homing is active, jogging commands are paused (incoming jog/teleop actions are ignored).
489
+
490
  ### Client → Server WebSocket Messages
491
 
492
  **`action`** - Send velocity actions to all robots:
 
520
  - `robot`: Required. One of: `"g1"`, `"spot"`, `"ur5"`, `"ur5_t_push"`
521
  - `scene`: Optional scene name (see `/metadata` for available scenes)
522
 
 
 
 
 
 
 
 
 
 
 
523
  **`teleop_action`** - Send teleoperation action (primarily for UR5 keyboard control):
524
  ```json
525
  {"type": "teleop_action", "data": {"vx": 0.01, "vy": 0.0, "vz": 0.0}}
frontend/index.html CHANGED
@@ -1078,9 +1078,7 @@
1078
 
1079
  <button class="danger" style="width: 100%; margin-top: 15px;" onclick="resetEnv()">Reset
1080
  Environment</button>
1081
- <button id="homeBtn" style="width: 100%; margin-top: 10px; display: none;" onmousedown="startHoming()"
1082
- onmouseup="stopHoming()" onmouseleave="stopHoming()" ontouchstart="startHoming()"
1083
- ontouchend="stopHoming()">🏠 Move to Home</button>
1084
  </div>
1085
  </div>
1086
 
@@ -2129,10 +2127,18 @@
2129
  }
2130
 
2131
  // Homing functionality for UR5
2132
- let homingInterval = null;
2133
- let homingJoints = []; // Joints being homed
2134
 
2135
- function startHoming() {
 
 
 
 
 
 
 
 
 
2136
  if (currentRobot !== 'ur5' && currentRobot !== 'ur5_t_push') {
2137
  return;
2138
  }
@@ -2142,24 +2148,30 @@
2142
  return;
2143
  }
2144
 
2145
- // Send home messages continuously while button is pressed
2146
- send('home', {});
 
2147
 
2148
- // Send home message every 100ms while button is pressed
2149
- if (homingInterval) clearInterval(homingInterval);
2150
- homingInterval = setInterval(() => {
2151
- send('home', {});
2152
- }, 100);
2153
- }
2154
 
2155
- function stopHoming() {
2156
- // Stop the home message interval
2157
- if (homingInterval) {
2158
- clearInterval(homingInterval);
2159
- homingInterval = null;
 
 
 
 
 
 
 
 
 
 
 
2160
  }
2161
- // Send stop_home message to backend
2162
- send('stop_home', {});
2163
  }
2164
 
2165
  function setCmd(vx, vy, vyaw) {
 
1078
 
1079
  <button class="danger" style="width: 100%; margin-top: 15px;" onclick="resetEnv()">Reset
1080
  Environment</button>
1081
+ <button id="homeBtn" style="width: 100%; margin-top: 10px; display: none;" onclick="startHoming()">🏠 Move to Home</button>
 
 
1082
  </div>
1083
  </div>
1084
 
 
2127
  }
2128
 
2129
  // Homing functionality for UR5
2130
+ let homingInProgress = false;
 
2131
 
2132
+ function setHomeButtonState(inProgress) {
2133
+ const homeBtn = document.getElementById('homeBtn');
2134
+ if (!homeBtn) {
2135
+ return;
2136
+ }
2137
+ homeBtn.disabled = inProgress;
2138
+ homeBtn.textContent = inProgress ? '🏠 Homing...' : '🏠 Move to Home';
2139
+ }
2140
+
2141
+ async function startHoming() {
2142
  if (currentRobot !== 'ur5' && currentRobot !== 'ur5_t_push') {
2143
  return;
2144
  }
 
2148
  return;
2149
  }
2150
 
2151
+ if (homingInProgress) {
2152
+ return;
2153
+ }
2154
 
2155
+ homingInProgress = true;
2156
+ setHomeButtonState(true);
 
 
 
 
2157
 
2158
+ const params = new URLSearchParams({
2159
+ timeout_s: '30',
2160
+ tolerance: '0.01',
2161
+ poll_interval_s: '0.1'
2162
+ });
2163
+ try {
2164
+ const response = await fetch(`${API_PREFIX}/homing?${params.toString()}`);
2165
+ const payload = await response.json();
2166
+ if (!response.ok) {
2167
+ console.warn('Homing failed:', payload);
2168
+ }
2169
+ } catch (err) {
2170
+ console.warn('Homing request failed:', err);
2171
+ } finally {
2172
+ homingInProgress = false;
2173
+ setHomeButtonState(false);
2174
  }
 
 
2175
  }
2176
 
2177
  function setCmd(vx, vy, vyaw) {
mujoco_server.py CHANGED
@@ -145,6 +145,8 @@ homing_state = {
145
  "saved_velocity": None, # Store velocity to restore after homing
146
  }
147
  homing_lock = threading.Lock()
 
 
148
 
149
  # WebSocket clients
150
  ws_clients = set()
@@ -237,8 +239,6 @@ AVAILABLE_ACTIONS = [
237
  "camera_follow",
238
  "toggle_target_visibility",
239
  "teleop_action",
240
- "home",
241
- "stop_home",
242
  "arm_target",
243
  "gripper",
244
  "control_mode",
@@ -1207,19 +1207,6 @@ def simulation_loop():
1207
  if env is None:
1208
  return
1209
 
1210
- # Check for homing timeout (prevent drift when home messages stop)
1211
- with homing_lock:
1212
- if homing_state.get("is_jogging", False):
1213
- last_home_time = homing_state.get("last_home_time", 0)
1214
- timeout = 0.5 # 500ms timeout - if no home message for this long, stop
1215
- if time.time() - last_home_time > timeout:
1216
- stop_jog = getattr(env, "stop_jog", None)
1217
- if callable(stop_jog):
1218
- stop_jog()
1219
- homing_state["is_jogging"] = False
1220
- homing_state["active"] = False
1221
- print("[Homing] Stopped jogging due to timeout (no home messages)")
1222
-
1223
  sim_dt = env.model.opt.timestep
1224
 
1225
  # Run physics steps
@@ -1408,6 +1395,137 @@ def generate_overlay_frames(name: str):
1408
  time.sleep(0.04)
1409
 
1410
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1411
  def handle_ws_message(ws, data):
1412
  """Handle incoming WebSocket message."""
1413
  global needs_robot_switch, camera_follow, t_target_visible, last_teleop_action
@@ -1443,6 +1561,13 @@ def handle_ws_message(ws, data):
1443
  j6 = payload.get('j6', 0.0)
1444
  gripper = payload.get('gripper', None)
1445
 
 
 
 
 
 
 
 
1446
  with mujoco_lock:
1447
  if env is not None:
1448
  # For UR5: translate velocity actions to jogging commands
@@ -1510,155 +1635,6 @@ def handle_ws_message(ws, data):
1510
  scene = payload.get('scene')
1511
  _schedule_robot_switch(robot, scene)
1512
 
1513
- elif msg_type == 'home':
1514
- # Multi-axis homing with Nova API v2
1515
- # UI sends home messages continuously while button is pressed
1516
- # When messages stop, jogging should stop to prevent drift
1517
- with mujoco_lock, homing_lock:
1518
- # Update last home message timestamp
1519
- homing_state["last_home_time"] = time.time()
1520
- if env is not None and current_robot in ("ur5", "ur5_t_push"):
1521
- home_pose = getattr(env, "home_pose", None)
1522
- get_joints = getattr(env, "get_joint_positions", None)
1523
- start_multi_joint_jog = getattr(env, "start_multi_joint_jog", None)
1524
- stop_jog = getattr(env, "stop_jog", None)
1525
-
1526
- if home_pose is not None and callable(get_joints) and callable(start_multi_joint_jog) and callable(stop_jog):
1527
- if hasattr(home_pose, "tolist"):
1528
- target_positions = home_pose.tolist()
1529
- else:
1530
- target_positions = list(home_pose)
1531
-
1532
- current_positions = get_joints()
1533
- tolerance = 0.01 # rad (~0.57 degrees)
1534
-
1535
- # Calculate errors for all joints
1536
- errors = []
1537
- for joint_idx in range(min(len(target_positions), len(current_positions))):
1538
- current = current_positions[joint_idx]
1539
- target = target_positions[joint_idx]
1540
- # Compute shortest angular distance (no wrapping needed for UR5)
1541
- # UR5 joints don't wrap around - they have limited ranges
1542
- error = target - current
1543
- errors.append(error)
1544
-
1545
- # Check if any joint needs homing
1546
- max_abs_error = max(abs(e) for e in errors)
1547
- if max_abs_error < tolerance:
1548
- # All joints at home - stop
1549
- if homing_state.get("is_jogging", False):
1550
- stop_jog()
1551
- homing_state["is_jogging"] = False
1552
- homing_state["active"] = False
1553
- homing_state.pop("previous_errors", None)
1554
- print("[Homing] Completed - all joints at target")
1555
- return
1556
-
1557
- # Initialize previous errors tracking if needed
1558
- if "previous_errors" not in homing_state:
1559
- homing_state["previous_errors"] = [0.0] * len(errors)
1560
-
1561
- # Calculate proportional velocities for all joints
1562
- base_velocity = 0.15 # rad/s - slower to prevent overshoot
1563
- velocities = []
1564
-
1565
- # Tighter tolerance for stopping individual joints
1566
- stop_tolerance = tolerance * 0.5 # Half the main tolerance
1567
-
1568
- for joint_idx, error in enumerate(errors):
1569
- abs_error = abs(error)
1570
-
1571
- # Check for overshoot (sign change)
1572
- prev_error = homing_state["previous_errors"][joint_idx]
1573
- if prev_error != 0 and ((prev_error > 0 and error < 0) or (prev_error < 0 and error > 0)):
1574
- # Overshoot detected - stop this joint completely
1575
- velocities.append(0.0)
1576
- continue
1577
-
1578
- if abs_error < stop_tolerance:
1579
- # Close enough - stop this joint
1580
- velocities.append(0.0)
1581
- else:
1582
- # Proportional control: scale velocity by distance relative to max error
1583
- # This ensures all joints reach home at approximately the same time
1584
- if max_abs_error > 0:
1585
- velocity = base_velocity * (abs_error / max_abs_error)
1586
- else:
1587
- velocity = 0.0
1588
-
1589
- # Apply additional damping when very close to target
1590
- # Gradually reduce velocity as we approach the target
1591
- damping_zone = tolerance * 3.0 # Start damping at 3x tolerance
1592
- if abs_error < damping_zone:
1593
- # Quadratic damping: velocity reduces rapidly near target
1594
- damping_factor = (abs_error / damping_zone) ** 2
1595
- velocity *= damping_factor
1596
-
1597
- # Minimum velocity threshold - below this, just stop
1598
- min_velocity = 0.02 # rad/s
1599
- if velocity < min_velocity:
1600
- velocities.append(0.0)
1601
- else:
1602
- # Apply velocity in correct direction
1603
- if error > 0:
1604
- velocities.append(velocity)
1605
- else:
1606
- velocities.append(-velocity)
1607
-
1608
- # Store current errors for overshoot detection
1609
- homing_state["previous_errors"] = errors
1610
-
1611
- # Check if all velocities are zero (all joints at target or stopped due to overshoot)
1612
- if all(abs(v) < 0.001 for v in velocities):
1613
- # All joints stopped - end homing
1614
- if homing_state.get("is_jogging", False):
1615
- stop_jog()
1616
- homing_state["is_jogging"] = False
1617
- homing_state["active"] = False
1618
- homing_state.pop("previous_errors", None)
1619
- homing_state.pop("current_velocities", None)
1620
- print("[Homing] All joints reached target or stopped")
1621
- return
1622
-
1623
- # Start multi-axis jogging with calculated velocities
1624
- current_velocities = homing_state.get("current_velocities")
1625
-
1626
- # Only restart if velocities changed significantly (avoid unnecessary restarts)
1627
- velocities_changed = current_velocities is None or any(
1628
- abs(v1 - v2) > 0.01 for v1, v2 in zip(velocities, current_velocities)
1629
- )
1630
-
1631
- if velocities_changed:
1632
- if homing_state.get("is_jogging", False):
1633
- stop_jog()
1634
-
1635
- start_multi_joint_jog(velocities)
1636
- homing_state["is_jogging"] = True
1637
- homing_state["current_velocities"] = velocities
1638
- homing_state["active"] = True
1639
-
1640
- elif env is not None:
1641
- # Instant homing for non-UR5 robots
1642
- home_pose = getattr(env, "home_pose", None)
1643
- set_joints = getattr(env, "set_joint_positions", None)
1644
- if home_pose is not None and callable(set_joints):
1645
- if hasattr(home_pose, "tolist"):
1646
- positions = home_pose.tolist()
1647
- else:
1648
- positions = list(home_pose)
1649
- set_joints(positions)
1650
-
1651
- elif msg_type == 'stop_home':
1652
- # Stop homing when button is released
1653
- with mujoco_lock, homing_lock:
1654
- if homing_state["active"]:
1655
- if env is not None and current_robot in ("ur5", "ur5_t_push"):
1656
- stop_jog = getattr(env, "stop_jog", None)
1657
- if callable(stop_jog) and homing_state["is_jogging"]:
1658
- stop_jog()
1659
- homing_state["active"] = False
1660
- homing_state["is_jogging"] = False
1661
-
1662
  elif msg_type == 'camera':
1663
  payload = data.get('data', {})
1664
  a = cam.azimuth * np.pi / 180.0
@@ -1696,6 +1672,9 @@ def handle_ws_message(ws, data):
1696
  vz = float(payload.get('vz', payload.get('dz', 0.0)))
1697
  timestamp = time.time()
1698
 
 
 
 
1699
  with mujoco_lock:
1700
  if env is not None and current_robot in ("ur5", "ur5_t_push"):
1701
  # Check if all velocities are zero - if so, stop any active jogging
@@ -2313,6 +2292,112 @@ def get_env_info():
2313
  return jsonify(response)
2314
 
2315
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2316
 
2317
 
2318
  if __name__ == '__main__':
 
145
  "saved_velocity": None, # Store velocity to restore after homing
146
  }
147
  homing_lock = threading.Lock()
148
+ jogging_pause_lock = threading.Lock()
149
+ jogging_paused = False
150
 
151
  # WebSocket clients
152
  ws_clients = set()
 
239
  "camera_follow",
240
  "toggle_target_visibility",
241
  "teleop_action",
 
 
242
  "arm_target",
243
  "gripper",
244
  "control_mode",
 
1207
  if env is None:
1208
  return
1209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1210
  sim_dt = env.model.opt.timestep
1211
 
1212
  # Run physics steps
 
1395
  time.sleep(0.04)
1396
 
1397
 
1398
+ def _set_jogging_paused(paused: bool) -> bool:
1399
+ """Pause or resume jogging commands; returns the previous pause state."""
1400
+ global jogging_paused
1401
+ with jogging_pause_lock:
1402
+ previous = jogging_paused
1403
+ jogging_paused = paused
1404
+ return previous
1405
+
1406
+
1407
+ def _is_jogging_paused() -> bool:
1408
+ with jogging_pause_lock:
1409
+ return jogging_paused
1410
+
1411
+
1412
+ def _step_multi_axis_homing_locked(tolerance: float = 0.01) -> dict:
1413
+ """Run one homing control step (expects mujoco_lock + homing_lock held)."""
1414
+ homing_state["last_home_time"] = time.time()
1415
+
1416
+ if env is None or current_robot not in ("ur5", "ur5_t_push"):
1417
+ return {"status": "unsupported"}
1418
+
1419
+ home_pose = getattr(env, "home_pose", None)
1420
+ get_joints = getattr(env, "get_joint_positions", None)
1421
+ start_multi_joint_jog = getattr(env, "start_multi_joint_jog", None)
1422
+ stop_jog = getattr(env, "stop_jog", None)
1423
+
1424
+ if home_pose is None or not callable(get_joints) or not callable(start_multi_joint_jog) or not callable(stop_jog):
1425
+ return {"status": "unsupported"}
1426
+
1427
+ if hasattr(home_pose, "tolist"):
1428
+ target_positions = home_pose.tolist()
1429
+ else:
1430
+ target_positions = list(home_pose)
1431
+
1432
+ current_positions = get_joints()
1433
+
1434
+ # Calculate errors for all joints
1435
+ errors = []
1436
+ for joint_idx in range(min(len(target_positions), len(current_positions))):
1437
+ current = current_positions[joint_idx]
1438
+ target = target_positions[joint_idx]
1439
+ error = target - current
1440
+ errors.append(error)
1441
+
1442
+ max_abs_error = max(abs(e) for e in errors) if errors else 0.0
1443
+ if max_abs_error < tolerance:
1444
+ if homing_state.get("is_jogging", False):
1445
+ stop_jog()
1446
+ homing_state["is_jogging"] = False
1447
+ homing_state["active"] = False
1448
+ homing_state.pop("previous_errors", None)
1449
+ homing_state.pop("current_velocities", None)
1450
+ print("[Homing] Completed - all joints at target")
1451
+ return {
1452
+ "status": "done",
1453
+ "errors": errors,
1454
+ "max_abs_error": max_abs_error,
1455
+ }
1456
+
1457
+ if "previous_errors" not in homing_state:
1458
+ homing_state["previous_errors"] = [0.0] * len(errors)
1459
+
1460
+ base_velocity = 0.15 # rad/s - slower to prevent overshoot
1461
+ velocities = []
1462
+ stop_tolerance = tolerance * 0.5
1463
+
1464
+ for joint_idx, error in enumerate(errors):
1465
+ abs_error = abs(error)
1466
+
1467
+ prev_error = homing_state["previous_errors"][joint_idx]
1468
+ if prev_error != 0 and ((prev_error > 0 and error < 0) or (prev_error < 0 and error > 0)):
1469
+ velocities.append(0.0)
1470
+ continue
1471
+
1472
+ if abs_error < stop_tolerance:
1473
+ velocities.append(0.0)
1474
+ else:
1475
+ if max_abs_error > 0:
1476
+ velocity = base_velocity * (abs_error / max_abs_error)
1477
+ else:
1478
+ velocity = 0.0
1479
+
1480
+ damping_zone = tolerance * 3.0
1481
+ if abs_error < damping_zone:
1482
+ damping_factor = (abs_error / damping_zone) ** 2
1483
+ velocity *= damping_factor
1484
+
1485
+ min_velocity = 0.02
1486
+ if velocity < min_velocity:
1487
+ velocities.append(0.0)
1488
+ else:
1489
+ velocities.append(velocity if error > 0 else -velocity)
1490
+
1491
+ homing_state["previous_errors"] = errors
1492
+
1493
+ if all(abs(v) < 0.001 for v in velocities):
1494
+ if homing_state.get("is_jogging", False):
1495
+ stop_jog()
1496
+ homing_state["is_jogging"] = False
1497
+ homing_state["active"] = False
1498
+ homing_state.pop("previous_errors", None)
1499
+ homing_state.pop("current_velocities", None)
1500
+ print("[Homing] All joints reached target or stopped")
1501
+ return {
1502
+ "status": "done",
1503
+ "errors": errors,
1504
+ "max_abs_error": max_abs_error,
1505
+ }
1506
+
1507
+ current_velocities = homing_state.get("current_velocities")
1508
+ velocities_changed = current_velocities is None or any(
1509
+ abs(v1 - v2) > 0.01 for v1, v2 in zip(velocities, current_velocities)
1510
+ )
1511
+
1512
+ if velocities_changed:
1513
+ if homing_state.get("is_jogging", False):
1514
+ stop_jog()
1515
+
1516
+ start_multi_joint_jog(velocities)
1517
+ homing_state["is_jogging"] = True
1518
+ homing_state["current_velocities"] = velocities
1519
+ homing_state["active"] = True
1520
+
1521
+ return {
1522
+ "status": "running",
1523
+ "errors": errors,
1524
+ "max_abs_error": max_abs_error,
1525
+ "velocities": velocities,
1526
+ }
1527
+
1528
+
1529
  def handle_ws_message(ws, data):
1530
  """Handle incoming WebSocket message."""
1531
  global needs_robot_switch, camera_follow, t_target_visible, last_teleop_action
 
1561
  j6 = payload.get('j6', 0.0)
1562
  gripper = payload.get('gripper', None)
1563
 
1564
+ if current_robot in ("ur5", "ur5_t_push") and _is_jogging_paused():
1565
+ if gripper is not None:
1566
+ with mujoco_lock:
1567
+ if env is not None:
1568
+ env.set_gripper(float(gripper))
1569
+ return
1570
+
1571
  with mujoco_lock:
1572
  if env is not None:
1573
  # For UR5: translate velocity actions to jogging commands
 
1635
  scene = payload.get('scene')
1636
  _schedule_robot_switch(robot, scene)
1637
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1638
  elif msg_type == 'camera':
1639
  payload = data.get('data', {})
1640
  a = cam.azimuth * np.pi / 180.0
 
1672
  vz = float(payload.get('vz', payload.get('dz', 0.0)))
1673
  timestamp = time.time()
1674
 
1675
+ if current_robot in ("ur5", "ur5_t_push") and _is_jogging_paused():
1676
+ return
1677
+
1678
  with mujoco_lock:
1679
  if env is not None and current_robot in ("ur5", "ur5_t_push"):
1680
  # Check if all velocities are zero - if so, stop any active jogging
 
2292
  return jsonify(response)
2293
 
2294
 
2295
+ @app.route(f'{API_PREFIX}/homing', methods=['GET'])
2296
+ @_env_request
2297
+ def home_blocking():
2298
+ timeout_s = float(request.args.get("timeout_s", 30.0))
2299
+ tolerance = float(request.args.get("tolerance", 0.01))
2300
+ poll_interval = float(request.args.get("poll_interval_s", 0.1))
2301
+
2302
+ if timeout_s <= 0:
2303
+ return jsonify({"error": "timeout_s must be > 0"}), 400
2304
+ if tolerance <= 0:
2305
+ return jsonify({"error": "tolerance must be > 0"}), 400
2306
+ if poll_interval <= 0:
2307
+ return jsonify({"error": "poll_interval_s must be > 0"}), 400
2308
+
2309
+ with mujoco_lock:
2310
+ if env is None:
2311
+ return jsonify({"error": "no environment initialized"}), 503
2312
+
2313
+ if current_robot not in ("ur5", "ur5_t_push"):
2314
+ with mujoco_lock:
2315
+ home_pose = getattr(env, "home_pose", None)
2316
+ set_joints = getattr(env, "set_joint_positions", None)
2317
+ if home_pose is None or not callable(set_joints):
2318
+ return jsonify({"error": "homing not supported for current robot"}), 400
2319
+ positions = home_pose.tolist() if hasattr(home_pose, "tolist") else list(home_pose)
2320
+ set_joints(positions)
2321
+ return jsonify({"status": "completed", "robot": current_robot, "homed": True})
2322
+
2323
+ with mujoco_lock, homing_lock:
2324
+ if homing_state.get("active"):
2325
+ return jsonify({"error": "homing already active"}), 409
2326
+ home_pose = getattr(env, "home_pose", None)
2327
+ get_joints = getattr(env, "get_joint_positions", None)
2328
+ start_multi_joint_jog = getattr(env, "start_multi_joint_jog", None)
2329
+ stop_jog = getattr(env, "stop_jog", None)
2330
+ if home_pose is None or not callable(get_joints) or not callable(start_multi_joint_jog) or not callable(stop_jog):
2331
+ return jsonify({"error": "Nova homing not supported for current robot"}), 400
2332
+ homing_state["active"] = True
2333
+
2334
+ previous_pause = _set_jogging_paused(True)
2335
+ start_time = time.time()
2336
+ status = "running"
2337
+ result_snapshot = {}
2338
+
2339
+ try:
2340
+ with mujoco_lock:
2341
+ stop_jog = getattr(env, "stop_jog", None)
2342
+ if callable(stop_jog):
2343
+ stop_jog()
2344
+
2345
+ while True:
2346
+ with mujoco_lock, homing_lock:
2347
+ result_snapshot = _step_multi_axis_homing_locked(tolerance=tolerance)
2348
+
2349
+ if result_snapshot.get("status") == "done":
2350
+ status = "completed"
2351
+ break
2352
+
2353
+ if time.time() - start_time >= timeout_s:
2354
+ status = "timeout"
2355
+ break
2356
+
2357
+ time.sleep(poll_interval)
2358
+ finally:
2359
+ with mujoco_lock, homing_lock:
2360
+ stop_jog = getattr(env, "stop_jog", None)
2361
+ if callable(stop_jog) and homing_state.get("is_jogging", False):
2362
+ stop_jog()
2363
+ homing_state["active"] = False
2364
+ homing_state["is_jogging"] = False
2365
+ homing_state.pop("previous_errors", None)
2366
+ homing_state.pop("current_velocities", None)
2367
+ _set_jogging_paused(previous_pause)
2368
+
2369
+ with mujoco_lock:
2370
+ home_pose = getattr(env, "home_pose", None)
2371
+ get_joints = getattr(env, "get_joint_positions", None)
2372
+ final_positions = get_joints() if callable(get_joints) else []
2373
+
2374
+ home_pose_list = (
2375
+ home_pose.tolist()
2376
+ if home_pose is not None and hasattr(home_pose, "tolist")
2377
+ else (list(home_pose) if home_pose is not None else None)
2378
+ )
2379
+
2380
+ final_errors = None
2381
+ max_abs_error = None
2382
+ if home_pose_list is not None and final_positions is not None and len(final_positions) > 0:
2383
+ final_errors = [float(t - c) for t, c in zip(home_pose_list, final_positions)]
2384
+ max_abs_error = max(abs(e) for e in final_errors)
2385
+
2386
+ final_positions_list = [float(v) for v in final_positions] if final_positions is not None and len(final_positions) > 0 else None
2387
+ response = {
2388
+ "status": status,
2389
+ "robot": current_robot,
2390
+ "homed": status == "completed",
2391
+ "duration_s": time.time() - start_time,
2392
+ "home_pose": home_pose_list,
2393
+ "final_positions": final_positions_list,
2394
+ "final_errors": final_errors,
2395
+ "max_abs_error": max_abs_error,
2396
+ "jogging_paused": _is_jogging_paused(),
2397
+ }
2398
+ return jsonify(response)
2399
+
2400
+
2401
 
2402
 
2403
  if __name__ == '__main__':
protocol_types.py CHANGED
@@ -110,16 +110,6 @@ class SwitchRobotMessage(TypedDict):
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"]
@@ -459,8 +449,6 @@ ClientMessage = Union[
459
  TeleopActionMessage,
460
  ResetMessage,
461
  SwitchRobotMessage,
462
- HomeMessage,
463
- StopHomeMessage,
464
  CameraMessage,
465
  CameraFollowMessage,
466
  ArmTargetMessage,
 
110
  data: SwitchRobotData
111
 
112
 
 
 
 
 
 
 
 
 
 
 
113
  class CameraRotateData(TypedDict):
114
  """Camera rotation data."""
115
  action: Literal["rotate"]
 
449
  TeleopActionMessage,
450
  ResetMessage,
451
  SwitchRobotMessage,
 
 
452
  CameraMessage,
453
  CameraFollowMessage,
454
  ArmTargetMessage,
tests/test_api.py CHANGED
@@ -178,17 +178,13 @@ class TestWebSocketMessages:
178
  print(f"\nInitial joints: {initial_joints}")
179
  print(f"Home pose: {home_pose}")
180
 
181
- # Send multiple home messages to move robot incrementally
182
- # Each message moves 5% closer, so ~20 messages should get very close
183
- for i in range(20):
184
- ws.send(json.dumps({"type": "home"}))
185
- time.sleep(0.1) # 100ms between messages
186
-
187
- # Read state messages to keep connection alive
188
- try:
189
- ws.recv(timeout=0.05)
190
- except Exception:
191
- pass
192
 
193
  # Wait for final state messages
194
  final_state = None
 
178
  print(f"\nInitial joints: {initial_joints}")
179
  print(f"Home pose: {home_pose}")
180
 
181
+ # Call blocking homing endpoint
182
+ homing_resp = requests.get(
183
+ f"{BASE_URL}/homing",
184
+ params={"timeout_s": 10, "tolerance": 0.05, "poll_interval_s": 0.1},
185
+ timeout=15,
186
+ )
187
+ homing_resp.raise_for_status()
 
 
 
 
188
 
189
  # Wait for final state messages
190
  final_state = None
tests/test_client.py CHANGED
@@ -54,9 +54,19 @@ class NovaSimTestClient:
54
  self.ws.send(json.dumps(msg))
55
  self._receive_state()
56
 
57
- def send_home(self):
58
- """Send a home message."""
59
- self.send_message({"type": "home"})
 
 
 
 
 
 
 
 
 
 
60
 
61
  def get_joint_positions(self):
62
  """Get current joint positions from latest state."""
 
54
  self.ws.send(json.dumps(msg))
55
  self._receive_state()
56
 
57
+ def home_blocking(self, timeout_s: float = 30.0, tolerance: float = 0.01, poll_interval_s: float = 0.1):
58
+ """Invoke the blocking homing endpoint."""
59
+ resp = requests.get(
60
+ f"{self.base_url}/homing",
61
+ params={
62
+ "timeout_s": timeout_s,
63
+ "tolerance": tolerance,
64
+ "poll_interval_s": poll_interval_s,
65
+ },
66
+ timeout=max(5.0, timeout_s + 5.0),
67
+ )
68
+ resp.raise_for_status()
69
+ return resp.json()
70
 
71
  def get_joint_positions(self):
72
  """Get current joint positions from latest state."""
tests/test_homing.py CHANGED
@@ -18,7 +18,7 @@ def test_homing():
18
  print("=" * 70)
19
  print("\nThis test verifies:")
20
  print(" 1. Robot position can be read from state stream")
21
- print(" 2. Sending 'home' messages triggers multi-axis homing")
22
  print(" 3. All joints move simultaneously toward home_pose")
23
  print(" 4. Robot reaches home within acceptable tolerance")
24
  print(" 5. Multi-axis jogging is faster than sequential")
@@ -51,33 +51,16 @@ def test_homing():
51
  print(f" ℹ Robot already near home position")
52
  print(f" Note: In digital twin mode, robot position comes from real hardware")
53
 
54
- # Start homing by sending home messages continuously
55
  print(f"\n[3] Starting multi-axis homing (Nova API v2)...")
56
  print(" All joints will move simultaneously toward home")
57
- print(" Sending home messages for 30 seconds (increased for large movements)...")
58
 
59
  home_start_time = time.time()
60
- message_count = 0
61
  joints_before = initial_joints.copy()
62
-
63
- # Send home messages for 30 seconds (increased from 10 for large joint movements)
64
- while time.time() - home_start_time < 30.0:
65
- client.send_home()
66
- message_count += 1
67
- time.sleep(0.1)
68
-
69
- # Check progress every second
70
- if message_count % 10 == 0:
71
- current_joints = client.get_joint_positions()
72
- current_errors = [abs(actual - target) for actual, target in zip(current_joints, expected_home)]
73
- current_max_error = max(current_errors)
74
- elapsed = time.time() - home_start_time
75
- print(f" t={elapsed:.1f}s: max_error={math.degrees(current_max_error):.2f}° ({message_count} messages)")
76
-
77
- # Check if reached home
78
- if current_max_error < 0.05:
79
- print(f"\n✓ Homing completed in {elapsed:.2f}s!")
80
- break
81
 
82
  homing_time = time.time() - home_start_time
83
 
@@ -162,12 +145,11 @@ def test_homing():
162
 
163
 
164
  def test_drift_prevention():
165
- """Test that robot stops moving when home messages cease."""
166
  print("\n" + "=" * 70)
167
  print("Drift Prevention Test")
168
  print("=" * 70)
169
- print("\nThis test verifies that the robot stops moving when home")
170
- print("messages stop, preventing drift.\n")
171
 
172
  client = NovaSimTestClient("http://localhost:3004/nova-sim/api/v1")
173
 
@@ -181,20 +163,14 @@ def test_drift_prevention():
181
  print(f"\n[1] Initial joint positions:")
182
  print(f" [{', '.join(f'{j:7.4f}' for j in initial_joints)}]")
183
 
184
- # Send home messages for 2 seconds (not enough to fully home)
185
- print(f"\n[2] Sending home messages for 2 seconds...")
186
- duration = 2.0
187
- rate_hz = 10
188
- num_messages = int(duration * rate_hz)
189
-
190
- for i in range(num_messages):
191
- client.send_home()
192
- time.sleep(1.0 / rate_hz)
193
 
194
  # Get position right after stopping
195
  time.sleep(0.1)
196
  position_after_homing = client.get_joint_positions()
197
- print(f"\n[3] Position immediately after stopping home messages:")
198
  print(f" [{', '.join(f'{j:7.4f}' for j in position_after_homing)}]")
199
 
200
  # Wait 2 seconds and check if robot drifted
@@ -202,7 +178,7 @@ def test_drift_prevention():
202
  time.sleep(2.0)
203
 
204
  final_position = client.get_joint_positions()
205
- print(f"\n[5] Position 2 seconds after stopping:")
206
  print(f" [{', '.join(f'{j:7.4f}' for j in final_position)}]")
207
 
208
  # Calculate drift
@@ -228,9 +204,8 @@ def test_drift_prevention():
228
  print(f"✓ NO DRIFT - Robot stopped properly (drift < {drift_threshold:.4f} rad)")
229
  success = True
230
  else:
231
- print(f"✗ DRIFT DETECTED - Robot kept moving after home messages stopped!")
232
- print(f" This indicates the jogging command wasn't stopped.")
233
- print(f" Check the timeout mechanism in the simulation loop.")
234
  success = False
235
  print("=" * 70)
236
 
@@ -295,42 +270,9 @@ def test_overshoot_detection():
295
  print(f" [{', '.join(f'{j:7.4f}' for j in moved_joints)}]")
296
  print(f" Joint 1 error from home: {abs(moved_joints[0] - home_joints[0]):.4f} rad")
297
 
298
- # Now home and monitor for overshoots
299
- print(f"\n[4] Starting homing (monitoring for overshoots)...")
300
- print(f" Sending home messages for 15 seconds...")
301
-
302
- duration = 15.0
303
- rate_hz = 10
304
- num_messages = int(duration * rate_hz)
305
-
306
- prev_j1_error = None
307
- overshoot_count = 0
308
-
309
- for i in range(num_messages):
310
- # Send home message
311
- client.send_home()
312
- time.sleep(1.0 / rate_hz)
313
-
314
- # Check position every second
315
- if (i + 1) % 10 == 0:
316
- current_joints = client.get_joint_positions()
317
- j1_error = current_joints[0] - home_joints[0]
318
-
319
- # Check for overshoot (sign change)
320
- if prev_j1_error is not None:
321
- if (prev_j1_error > 0 and j1_error < 0) or (prev_j1_error < 0 and j1_error > 0):
322
- overshoot_count += 1
323
- print(f" t={(i+1)/10:.1f}s: ⚠️ OVERSHOOT #{overshoot_count} detected!")
324
- print(f" prev_error={prev_j1_error:+.4f} -> current_error={j1_error:+.4f}")
325
-
326
- # Check if close to home
327
- if abs(j1_error) < 0.01:
328
- print(f" t={(i+1)/10:.1f}s: ✓ Reached home (error={abs(j1_error):.4f} rad)")
329
- break
330
- else:
331
- print(f" t={(i+1)/10:.1f}s: j1_error={j1_error:+.4f} rad ({i+1} messages)")
332
-
333
- prev_j1_error = j1_error
334
 
335
  # Get final position
336
  time.sleep(0.5)
@@ -349,7 +291,6 @@ def test_overshoot_detection():
349
  print(f" {status} Joint {i+1}: error={error:.4f} rad ({math.degrees(error):.2f}°)")
350
 
351
  print(f"\n[7] Results:")
352
- print(f" Total overshoots detected: {overshoot_count}")
353
  print(f" Max final error: {max_error:.4f} rad ({math.degrees(max_error):.2f}°)")
354
 
355
  print("\n" + "=" * 70)
@@ -360,10 +301,7 @@ def test_overshoot_detection():
360
  print(f"⚠ Homing incomplete (max error = {max_error:.4f} rad)")
361
  success = False
362
 
363
- if overshoot_count > 0:
364
- print(f"ℹ Overshoot detection working ({overshoot_count} overshoots detected and corrected)")
365
- else:
366
- print(f"✓ No overshoots needed (smooth convergence)")
367
  print("=" * 70)
368
 
369
  client.close()
 
18
  print("=" * 70)
19
  print("\nThis test verifies:")
20
  print(" 1. Robot position can be read from state stream")
21
+ print(" 2. Calling the blocking homing endpoint triggers multi-axis homing")
22
  print(" 3. All joints move simultaneously toward home_pose")
23
  print(" 4. Robot reaches home within acceptable tolerance")
24
  print(" 5. Multi-axis jogging is faster than sequential")
 
51
  print(f" ℹ Robot already near home position")
52
  print(f" Note: In digital twin mode, robot position comes from real hardware")
53
 
54
+ # Start homing using the blocking endpoint
55
  print(f"\n[3] Starting multi-axis homing (Nova API v2)...")
56
  print(" All joints will move simultaneously toward home")
57
+ print(" Calling /homing with a 30 second timeout...")
58
 
59
  home_start_time = time.time()
 
60
  joints_before = initial_joints.copy()
61
+ home_result = client.home_blocking(timeout_s=30.0, tolerance=0.01, poll_interval_s=0.1)
62
+ elapsed = time.time() - home_start_time
63
+ print(f" Homing result: {home_result.get('status')} in {elapsed:.2f}s")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
  homing_time = time.time() - home_start_time
66
 
 
145
 
146
 
147
  def test_drift_prevention():
148
+ """Test that robot doesn't drift after blocking homing completes."""
149
  print("\n" + "=" * 70)
150
  print("Drift Prevention Test")
151
  print("=" * 70)
152
+ print("\nThis test verifies that the robot stops moving after blocking homing.\n")
 
153
 
154
  client = NovaSimTestClient("http://localhost:3004/nova-sim/api/v1")
155
 
 
163
  print(f"\n[1] Initial joint positions:")
164
  print(f" [{', '.join(f'{j:7.4f}' for j in initial_joints)}]")
165
 
166
+ # Run homing
167
+ print(f"\n[2] Calling blocking homing endpoint...")
168
+ client.home_blocking(timeout_s=10.0, tolerance=0.05, poll_interval_s=0.1)
 
 
 
 
 
 
169
 
170
  # Get position right after stopping
171
  time.sleep(0.1)
172
  position_after_homing = client.get_joint_positions()
173
+ print(f"\n[3] Position immediately after homing:")
174
  print(f" [{', '.join(f'{j:7.4f}' for j in position_after_homing)}]")
175
 
176
  # Wait 2 seconds and check if robot drifted
 
178
  time.sleep(2.0)
179
 
180
  final_position = client.get_joint_positions()
181
+ print(f"\n[5] Position 2 seconds after homing:")
182
  print(f" [{', '.join(f'{j:7.4f}' for j in final_position)}]")
183
 
184
  # Calculate drift
 
204
  print(f"✓ NO DRIFT - Robot stopped properly (drift < {drift_threshold:.4f} rad)")
205
  success = True
206
  else:
207
+ print(f"✗ DRIFT DETECTED - Robot kept moving after homing!")
208
+ print(f" Check server logs for jogging stop behavior.")
 
209
  success = False
210
  print("=" * 70)
211
 
 
270
  print(f" [{', '.join(f'{j:7.4f}' for j in moved_joints)}]")
271
  print(f" Joint 1 error from home: {abs(moved_joints[0] - home_joints[0]):.4f} rad")
272
 
273
+ # Now home using the blocking endpoint
274
+ print(f"\n[4] Starting homing...")
275
+ client.home_blocking(timeout_s=15.0, tolerance=0.01, poll_interval_s=0.1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
 
277
  # Get final position
278
  time.sleep(0.5)
 
291
  print(f" {status} Joint {i+1}: error={error:.4f} rad ({math.degrees(error):.2f}°)")
292
 
293
  print(f"\n[7] Results:")
 
294
  print(f" Max final error: {max_error:.4f} rad ({math.degrees(max_error):.2f}°)")
295
 
296
  print("\n" + "=" * 70)
 
301
  print(f"⚠ Homing incomplete (max error = {max_error:.4f} rad)")
302
  success = False
303
 
304
+ print(f"✓ Homing completed via blocking endpoint")
 
 
 
305
  print("=" * 70)
306
 
307
  client.close()