-
Notifications
You must be signed in to change notification settings - Fork 5
Fix/e stop #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix/e stop #10
Changes from 6 commits
1b1e92f
98cee04
985c1c7
ab6fa23
23e022e
8d815e3
4a61d88
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -119,6 +119,7 @@ def __init__( | |
| self._preset: None | str = None | ||
| self._strict_processing = strict_processing | ||
| self._strict_drone_match = strict_drone_match | ||
| self._active_swarm: Any | None = None | ||
| if set(self.songs) & set(self.presets): | ||
| raise ValueError("Songs and presets must have unique names") | ||
|
|
||
|
|
@@ -299,6 +300,7 @@ def deploy(self, drone_ids: list[int] | None = None) -> bool: | |
| return False | ||
|
|
||
| swarm = DroneSwarm(self.choreographer.drones, lighthouse=self.settings["lighthouse"]) | ||
| self._active_swarm = swarm | ||
| logger.info("Swarm connected...") | ||
|
|
||
| # generate references | ||
|
|
@@ -378,14 +380,25 @@ def deploy(self, drone_ids: list[int] | None = None) -> bool: | |
| ) | ||
| self.music_manager.stop() | ||
| swarm.goto(final_pos_dict, duration=2.0) # Transition from ideal point to hover pos | ||
| if self.settings["land_on_docks"]: # Commented out for demo | ||
| if self.settings["land_on_docks"]: # Commented out for demo | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Extra space from linting. The comment should be removed though |
||
| swarm.goto(final_pos_dict, duration=3.0) # Hovering | ||
| swarm.land(duration=1.5) # Landing | ||
| finally: | ||
| swarm.close() | ||
| try: | ||
| swarm.close() | ||
| finally: | ||
| self._active_swarm = None | ||
| logger.info("Deployment successful") | ||
| return True | ||
|
|
||
| def emergency_stop_active_swarm(self) -> None: | ||
| """Emergency-stop the currently active deployment swarm, if one exists.""" | ||
| swarm = self._active_swarm | ||
| if swarm is None: | ||
| raise RuntimeError("No active deployment swarm to emergency stop.") | ||
| swarm.emergency_stop() | ||
| self.music_manager.stop() | ||
|
|
||
|
ratheron marked this conversation as resolved.
|
||
| def load_preset(self, preset_id: str) -> str: | ||
| """Load a preset response. | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,10 @@ | |
| _CommanderLevel = Literal["low", "high"] | ||
|
|
||
|
|
||
| class EmergencyStopActive(RuntimeError): | ||
| """Raised when a motion command is issued after the emergency-stop latch is set.""" | ||
|
|
||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we need this? |
||
| class DroneSwarm: | ||
| """Connects, configures, and commands a Crazyflie swarm with cflib2.""" | ||
|
|
||
|
|
@@ -75,6 +79,7 @@ def __init__( | |
| self._estimator_stop_event: Event | None = None | ||
| self._estimator_future: Future[None] | None = None | ||
| self._closed = False | ||
| self._estop = threading.Event() | ||
|
|
||
| if not lighthouse: | ||
| from drone_estimators.ros_nodes.ros2_connector import ROSConnector | ||
|
|
@@ -125,24 +130,27 @@ def is_active(self, uri: str) -> bool: | |
|
|
||
| def takeoff(self, height: float = 1.5, duration: float = 3.0): | ||
| """Take off the drones to a given height over a given duration.""" | ||
| self._ensure_not_estopped() | ||
|
|
||
| async def _takeoff(uri: str) -> None: | ||
| cf = self._cf(uri) | ||
| await self._change_commander_level(uri, "high") | ||
| await cf.high_level_commander().take_off(height, None, duration, None) | ||
| await asyncio.sleep(duration) | ||
| await self._estop_guarded_sleep(uri, duration) | ||
|
|
||
| self._run(self._parallel_by_uri("Taking off", self.uris, _takeoff, timeout=duration + 1.0)) | ||
|
|
||
| def land(self, height: float = 0.0, duration: float = 3.0): | ||
| """Land the drones at a given height over a given duration.""" | ||
| self._ensure_not_estopped() | ||
|
|
||
| async def _land(uri: str) -> None: | ||
| cf = self._cf(uri) | ||
| await self._change_commander_level(uri, "high") | ||
| high_level_commander = cf.high_level_commander() | ||
| await high_level_commander.land(height, None, duration, None) | ||
| await asyncio.sleep(duration) | ||
| if await self._estop_guarded_sleep(uri, duration): | ||
| return | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are no continuous commands here -> we should be able to do a regular sleep in this thread. This is also true for all other sleeps |
||
| await high_level_commander.stop(None) | ||
|
|
||
| self._run(self._parallel_by_uri("Landing", self.uris, _land, timeout=duration + 1.0)) | ||
|
|
@@ -154,6 +162,7 @@ def goto(self, target: dict[str, list], duration: float = 3.0): | |
| target: Position+Yaw references in the form {'uri1': [target], ...}. | ||
| duration: Duration of the motion in seconds. | ||
| """ | ||
| self._ensure_not_estopped() | ||
| self._validate_required_uris("pos", target) | ||
| for uri, setpoint in target.items(): | ||
| if len(setpoint) != 4: | ||
|
|
@@ -165,7 +174,7 @@ async def _goto(uri: str) -> None: | |
| await cf.high_level_commander().go_to( | ||
| *target[uri], duration, relative=False, linear=True, group_mask=None | ||
| ) | ||
| await asyncio.sleep(duration) | ||
| await self._estop_guarded_sleep(uri, duration) | ||
|
|
||
| self._run(self._parallel_by_uri("Goto", self.uris, _goto, timeout=duration + 1.0)) | ||
|
|
||
|
|
@@ -175,6 +184,7 @@ def setpoint(self, target: dict[str, list]): | |
| Args: | ||
| target: Position+Yaw references in the form {'uri1': [target], ...}. | ||
| """ | ||
| self._ensure_not_estopped() | ||
| self._validate_required_uris("pos", target) | ||
| for uri, setpoint in target.items(): | ||
| if len(setpoint) != 4: | ||
|
|
@@ -202,6 +212,7 @@ def execute_choreography( | |
| color_top: Top deck color cues in the form {uri: {time: wrgb}}. | ||
| color_bot: Bottom deck color cues in the form {uri: {time: wrgb}}. | ||
| """ | ||
| self._ensure_not_estopped() | ||
| self._validate_required_uris("choreography", choreography) | ||
| if not color_top and not color_bot: | ||
| logger.warning("No colors provided for choreography.") | ||
|
|
@@ -262,13 +273,27 @@ async def _set_param(uri: str) -> None: | |
| ) | ||
|
|
||
| def emergency_stop(self, uri: str | None = None): | ||
| """Send an emergency stop signal to one URI or all drones (default).""" | ||
| """Send an emergency stop signal to one URI or all drones (default). | ||
|
|
||
| Sets a latch that halts the choreography stream and blocks further motion | ||
| commands so fresh setpoints cannot override the motor cut. | ||
| """ | ||
| self._estop.set() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't really matter what other threads do. Not sure if we need this |
||
| if uri is None: | ||
| uris = self.uris | ||
| else: | ||
| self._validate_known_uris("uri", {uri: None}) | ||
| uris = [uri] | ||
| self._run(self._parallel_by_uri("Emergency stop", uris, self._emergency_stop, timeout=0.5)) | ||
| # In-flight loop coroutines self-cut on the latch; this also sends the packet directly | ||
| # to cover idle windows. Bounded so the caller never blocks on a loop that has stopped. | ||
| coro = self._parallel_by_uri("Emergency stop", uris, self._emergency_stop, timeout=0.5) | ||
| try: | ||
| if self._loop_thread is not None or self._loop.is_running(): | ||
| asyncio.run_coroutine_threadsafe(coro, self._loop).result(timeout=1.0) | ||
| else: | ||
| self._loop.run_until_complete(coro) | ||
| except Exception as exc: | ||
| logger.error(f"Emergency stop encountered errors: {exc}") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isn't this essentially the same as just doing run? |
||
|
|
||
| def reset(self): | ||
| """Reset all active drones.""" | ||
|
|
@@ -345,9 +370,32 @@ async def _close() -> None: | |
| if self.ros_connector is not None: | ||
| self.ros_connector.close() | ||
|
|
||
| def _ensure_not_estopped(self) -> None: | ||
| """Block forward-motion commands once the emergency-stop latch is set.""" | ||
| if self._estop.is_set(): | ||
| raise EmergencyStopActive("Swarm is emergency-stopped; motion commands are blocked.") | ||
|
|
||
| async def _estop_guarded_sleep(self, uri: str, duration: float) -> bool: | ||
| """Wait up to ``duration``, cutting ``uri``'s motors from the loop if the latch trips. | ||
|
|
||
| Returns True if the latch tripped (motors cut), False if it slept the full duration. | ||
| """ | ||
| loop = asyncio.get_running_loop() | ||
| deadline = loop.time() + duration | ||
| while (remaining := deadline - loop.time()) > 0: | ||
| if self._estop.is_set(): | ||
| await self._emergency_stop(uri) | ||
| return True | ||
| await asyncio.sleep(min(0.02, remaining)) | ||
| return False | ||
|
|
||
| def _run(self, coroutine: Awaitable[Any]) -> Any: | ||
| """Run a cflib2 coroutine on the swarm event loop.""" | ||
| if self._loop_thread is not None: | ||
| """Run a cflib2 coroutine on the swarm event loop. | ||
|
|
||
| Dispatches cross-thread when the loop runs in another thread or is already running, | ||
| so emergency stops from the request or signal-handler threads still reach the swarm. | ||
| """ | ||
| if self._loop_thread is not None or self._loop.is_running(): | ||
| return asyncio.run_coroutine_threadsafe(coroutine, self._loop).result() | ||
| return self._loop.run_until_complete(coroutine) | ||
|
|
||
|
|
@@ -606,6 +654,9 @@ async def _stream_reference( | |
| t_col = -np.inf | ||
|
|
||
| while (t_cur := asyncio.get_running_loop().time() - start_time) < duration: | ||
| if self._estop.is_set(): | ||
| await self._emergency_stop(uri) | ||
| return | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the only point where checking for the estop makes sense, since the commands might overwrite the estop. However, emergency stop sets the estop, so calling emergency stop when estop is set doesn't make sense? Further, I think sending a command won't overwrite an estop, so we should be able to remove this. |
||
| await commander.send_setpoint_position(*reference(t_cur)) | ||
|
|
||
| if t_cur - t_col >= color_period: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
| import os | ||
| import sys | ||
| from pathlib import Path | ||
| from types import FrameType | ||
|
|
||
| import fire | ||
| import uvicorn | ||
|
|
@@ -52,8 +53,24 @@ def main( | |
| use_motion_primitives=use_motion_primitives, | ||
| ) | ||
| ) | ||
| server = uvicorn.Server(uvicorn.Config(app, host=host, port=port)) | ||
| # uvicorn captures SIGINT/SIGTERM itself (Server.capture_signals installs self.handle_exit) | ||
| # and only starts a graceful shutdown, which waits on the open events WebSocket while the | ||
| # deploy thread keeps flying. Wrap handle_exit so the first Ctrl+C cuts the motors before | ||
| # uvicorn begins draining. capture_signals installs whatever self.handle_exit is at call time. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should only have the button. This CTRL+C handling seems like unnecessary overhead, since we are inside the frontend only anyway |
||
| uvicorn_handle_exit = server.handle_exit | ||
|
|
||
| def handle_exit(sig: int, frame: FrameType | None) -> None: | ||
| logging.warning("Ctrl+C received: emergency-stopping all active swarms.") | ||
| try: | ||
| app.state.store.emergency_stop_all() | ||
| except Exception: | ||
| logging.exception("Emergency stop during shutdown failed") | ||
| uvicorn_handle_exit(sig, frame) | ||
|
|
||
| server.handle_exit = handle_exit | ||
| try: | ||
| uvicorn.run(app, host=host, port=port) | ||
| server.run() | ||
| finally: | ||
| shutdown_ollama_generation() | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.