Skip to content

Commit 3d3199a

Browse files
committed
TeleopPanel: replace background thread with event loop (#50)
Remove _teleop_loop thread and _sim_lock. Teleop stepping now happens on the main thread via PhysicsEventLoop.tick(). - _activate_teleop: register with event loop instead of spawning thread - _deactivate_teleop: unregister from event loop - _update_status / _on_teleop_error: extracted from old _teleop_loop, called by PhysicsEventLoop.tick() Part of personalrobotics/mj_manipulator#50
1 parent 7105f80 commit 3d3199a

1 file changed

Lines changed: 24 additions & 47 deletions

File tree

src/mj_viser/teleop_panel.py

Lines changed: 24 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,6 @@
2727
from __future__ import annotations
2828

2929
import logging
30-
import threading
31-
import time
3230
from typing import TYPE_CHECKING
3331

3432
import mujoco
@@ -193,10 +191,6 @@ class TeleopPanel(PanelBase):
193191
arm_label: Display label (e.g., "Right Arm").
194192
"""
195193

196-
# Shared lock across all TeleopPanel instances — prevents concurrent
197-
# MuJoCo access when multiple arms are teleoped simultaneously.
198-
_sim_lock = threading.Lock()
199-
200194
def __init__(
201195
self,
202196
arm: Arm,
@@ -207,6 +201,7 @@ def __init__(
207201
arm_label: str = "Arm",
208202
abort_fn: object | None = None,
209203
clear_abort_fn: object | None = None,
204+
event_loop: object | None = None,
210205
):
211206
self._arm = arm
212207
self._controller = controller
@@ -216,6 +211,7 @@ def __init__(
216211
self._arm_label = arm_label
217212
self._abort_fn = abort_fn # callable → bool, checked in teleop loop
218213
self._clear_abort_fn = clear_abort_fn # callable → None, clears abort
214+
self._event_loop = event_loop # PhysicsEventLoop — steps teleop from main thread
219215
self._gizmo = None
220216
self._ghost = None
221217
self._is_teleop_active = False
@@ -337,15 +333,15 @@ def _activate_teleop(self) -> None:
337333
self._activate_btn.name = "Deactivate"
338334
self._activate_btn.color = "red"
339335

340-
# Start background loop that steps the controller + syncs viewer
341-
self._teleop_thread = threading.Thread(
342-
target=self._teleop_loop,
343-
daemon=True,
344-
)
345-
self._teleop_thread.start()
336+
# Register with event loop — teleop stepping happens on the main
337+
# thread via inputhook tick(), not in a background thread.
338+
if self._event_loop is not None:
339+
self._event_loop.register_teleop(self._controller, self)
346340

347341
def _deactivate_teleop(self) -> None:
348-
self._is_teleop_active = False # signals thread to stop
342+
self._is_teleop_active = False
343+
if self._event_loop is not None:
344+
self._event_loop.unregister_teleop(self._controller)
349345
self._controller.deactivate()
350346

351347
self._gizmo.visible = False
@@ -356,40 +352,21 @@ def _deactivate_teleop(self) -> None:
356352
self._activate_btn.color = "green"
357353
self._status_md.content = "⚪ **Idle**"
358354

359-
def _teleop_loop(self) -> None:
360-
"""Background loop: step controller + sync viewer at ~30 Hz."""
361-
dt = 1.0 / 30.0
362-
while self._is_teleop_active and self._controller.is_active:
363-
# Check abort (Stop button / reset)
364-
if self._abort_fn is not None and self._abort_fn():
365-
self._controller.deactivate()
366-
break
367-
t0 = time.monotonic()
368-
try:
369-
from mj_manipulator.teleop import TeleopState
370-
371-
with TeleopPanel._sim_lock:
372-
state = self._controller.step()
373-
if self._viewer is not None:
374-
self._viewer.sync()
375-
# Update status (outside lock — GUI updates are thread-safe)
376-
if state == TeleopState.TRACKING:
377-
self._status_md.content = "🟢 **Tracking**"
378-
elif state == TeleopState.TRACKING_COLLISION:
379-
self._status_md.content = "🔴 **Collision**"
380-
elif state == TeleopState.UNREACHABLE:
381-
self._status_md.content = "🟠 **Unreachable**"
382-
else:
383-
self._status_md.content = "⚪ **Idle**"
384-
except Exception as e:
385-
logger.warning("Teleop step error: %s", e)
386-
break
387-
elapsed = time.monotonic() - t0
388-
sleep = dt - elapsed
389-
if sleep > 0:
390-
time.sleep(sleep)
391-
392-
# Loop exited (abort, error, or deactivate) — reset panel UI
355+
def _update_status(self, state) -> None:
356+
"""Update status indicator. Called by PhysicsEventLoop.tick()."""
357+
from mj_manipulator.teleop import TeleopState
358+
359+
if state == TeleopState.TRACKING:
360+
self._status_md.content = "🟢 **Tracking**"
361+
elif state == TeleopState.TRACKING_COLLISION:
362+
self._status_md.content = "🔴 **Collision**"
363+
elif state == TeleopState.UNREACHABLE:
364+
self._status_md.content = "🟠 **Unreachable**"
365+
else:
366+
self._status_md.content = "⚪ **Idle**"
367+
368+
def _on_teleop_error(self) -> None:
369+
"""Reset panel UI after a teleop error. Called by PhysicsEventLoop."""
393370
self._is_teleop_active = False
394371
self._gizmo.visible = False
395372
if self._ghost is not None:

0 commit comments

Comments
 (0)