From 2b50f736807d6e49c84cfb4e9e91b3a3e9e6eca2 Mon Sep 17 00:00:00 2001 From: Alexandre Abadie Date: Mon, 16 Feb 2026 09:17:15 +0100 Subject: [PATCH 1/5] dotbot/dotbot_simulator: rework threading --- doc/conf.py | 1 + dotbot/dotbot_simulator.py | 143 +++++++++++++++++++++---------------- 2 files changed, 81 insertions(+), 63 deletions(-) diff --git a/doc/conf.py b/doc/conf.py index 2a0ac4e..88e624b 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -45,6 +45,7 @@ ("py:class", r"dotbot.models.Annotated"), ("py:class", r"Query"), ("py:class", r"PydanticUndefined"), + ("py:class", r"queue.Queue"), ] # -- Options for HTML output ------------------------------------------------- diff --git a/dotbot/dotbot_simulator.py b/dotbot/dotbot_simulator.py index 756746c..87228e0 100644 --- a/dotbot/dotbot_simulator.py +++ b/dotbot/dotbot_simulator.py @@ -6,6 +6,7 @@ """Dotbot simulator for the DotBot project.""" +import queue import random import threading import time @@ -76,11 +77,10 @@ class InitStateToml(BaseModel): network: SimulatedNetworkSettings = SimulatedNetworkSettings() -class DotBotSimulator(threading.Thread): +class DotBotSimulator: """Simulator class for the dotbot.""" - def __init__(self, settings: SimulatedDotBotSettings): - super().__init__(daemon=True) + def __init__(self, settings: SimulatedDotBotSettings, tx_queue: queue.Queue): self.address = settings.address.lower() self.pos_x = settings.pos_x self.pos_y = settings.pos_y @@ -97,10 +97,18 @@ def __init__(self, settings: SimulatedDotBotSettings): self.waypoints = [] self.waypoint_index = 0 + self._lock = threading.Lock() + self.tx_queue = tx_queue + self.queue = queue.Queue() + self.advertise_thread = threading.Thread(target=self.advertise, daemon=True) + self.rx_thread = threading.Thread(target=self.rx_frame, daemon=True) + self.main_thread = threading.Thread(target=self.run, daemon=True) self.controller_mode: DotBotSimulatorMode = DotBotSimulatorMode.MANUAL self.logger = LOGGER.bind(context=__name__, address=self.address) - self.running = True - self.start() + self._stop_event = threading.Event() + self.rx_thread.start() + self.advertise_thread.start() + self.main_thread.start() @property def header(self): @@ -191,94 +199,103 @@ def update(self, dt: float): def advertise(self): """Send an advertisement message to the gateway.""" - payload = Frame( - header=self.header, - packet=Packet.from_payload( - PayloadDotBotAdvertisement( - calibrated=self.calibrated, - direction=int(self.theta * 180 / pi + 90), - pos_x=int(self.pos_x) if self.pos_x >= 0 else 0, - pos_y=int(self.pos_y) if self.pos_y >= 0 else 0, - pos_z=0, - battery=battery_discharge_model(self.time_elapsed_s), - ) - ), - ) - return payload + while self._stop_event.is_set() is False: + payload = Frame( + header=self.header, + packet=Packet.from_payload( + PayloadDotBotAdvertisement( + calibrated=self.calibrated, + direction=int(self.theta * 180 / pi + 90), + pos_x=int(self.pos_x) if self.pos_x >= 0 else 0, + pos_y=int(self.pos_y) if self.pos_y >= 0 else 0, + pos_z=0, + battery=battery_discharge_model(self.time_elapsed_s), + ) + ), + ) + self.tx_queue.put(payload) + time.sleep(0.5) - def handle_frame(self, frame: Frame): + def rx_frame(self): """Decode the serial input received from the gateway.""" - if self.address == hex(frame.header.destination)[2:]: - if frame.payload_type == PayloadType.CMD_MOVE_RAW: - self.controller_mode = DotBotSimulatorMode.MANUAL - self.v_left = frame.packet.payload.left_y - self.v_right = frame.packet.payload.right_y - - if self.v_left > 127: - self.v_left = self.v_left - 256 - if self.v_right > 127: - self.v_right = self.v_right - 256 - - elif frame.payload_type == PayloadType.LH2_WAYPOINTS: - self.v_left = 0 - self.v_right = 0 - self.controller_mode = DotBotSimulatorMode.MANUAL - self.waypoint_threshold = frame.packet.payload.threshold - self.waypoints = frame.packet.payload.waypoints - if self.waypoints: - self.controller_mode = DotBotSimulatorMode.AUTOMATIC - - def stop(self): - self.logger.info("Stopping DotBot simulator...") - self.running = False - self.join() + while self._stop_event.is_set() is False: + frame = self.queue.get() + if frame is None: + break + with self._lock: + if self.address == hex(frame.header.destination)[2:]: + if frame.payload_type == PayloadType.CMD_MOVE_RAW: + self.controller_mode = DotBotSimulatorMode.MANUAL + self.v_left = frame.packet.payload.left_y + self.v_right = frame.packet.payload.right_y + + if self.v_left > 127: + self.v_left = self.v_left - 256 + if self.v_right > 127: + self.v_right = self.v_right - 256 + + elif frame.payload_type == PayloadType.LH2_WAYPOINTS: + self.v_left = 0 + self.v_right = 0 + self.controller_mode = DotBotSimulatorMode.MANUAL + self.waypoint_threshold = frame.packet.payload.threshold + self.waypoints = frame.packet.payload.waypoints + if self.waypoints: + self.controller_mode = DotBotSimulatorMode.AUTOMATIC def run(self): """Update the state of the dotbot simulator.""" - while self.running is True: - self.update(0.1) + while self._stop_event.is_set() is False: + with self._lock: + self.update(0.1) + + def stop(self): + self.logger.info(f"Stopping DotBot {self.address} simulator...") + self._stop_event.set() + self.queue.put(None) # unblock the rx_thread if waiting on the queue + self.advertise_thread.join() + self.rx_thread.join() + self.main_thread.join() -class DotBotSimulatorCommunicationInterface(threading.Thread): +class DotBotSimulatorCommunicationInterface: """Bidirectional serial interface to control simulated robots""" def __init__(self, on_frame_received: Callable, simulator_init_state_path: str): + self.queue = queue.Queue() self.on_frame_received = on_frame_received - self.running = True - super().__init__(daemon=True) + self._stp_event = threading.Event() + self.main_thread = threading.Thread(target=self.run, daemon=True) init_state = InitStateToml(**toml.load(simulator_init_state_path)) self.network_pdr = init_state.network.pdr self.dotbots = [ DotBotSimulator( settings=dotbot_settings, + tx_queue=self.queue, ) for dotbot_settings in init_state.dotbots ] - self.start() + self.main_thread.start() self.logger = LOGGER.bind(context=__name__) self.logger.info("DotBot Simulation Started") def run(self): """Listen continuously at each byte received on the fake serial interface.""" - for dotbot in self.dotbots: - self.on_frame_received(dotbot.advertise()) - time.sleep(0.1) - - while self.running: - for dotbot in self.dotbots: - self.handle_dotbot_frame(dotbot.advertise()) - time.sleep( - 0.5 - PayloadDotBotAdvertisement().size * len(self.dotbots) * 0.000001 - ) + while self._stp_event.is_set() is False: + frame = self.queue.get() + if frame is None: + break + self.handle_dotbot_frame(frame) def stop(self): self.logger.info("Stopping DotBot Simulation...") - self.running = False + self._stp_event.set() + self.queue.put(None) # unblock the run thread if waiting on the queue for dotbot in self.dotbots: dotbot.stop() - self.join() + self.main_thread.join() def flush(self): """Flush fake serial output.""" @@ -304,4 +321,4 @@ def write(self, bytes_): f"Packet to DotBot {dotbot.address} lost in simulation" ) continue - dotbot.handle_frame(Frame.from_bytes(bytes_)) + dotbot.queue.put(Frame.from_bytes(bytes_)) From b3f8a6088b3d1a4b89ccbba91519cde207eff998 Mon Sep 17 00:00:00 2001 From: Alexandre Abadie Date: Fri, 20 Feb 2026 16:52:30 +0100 Subject: [PATCH 2/5] dotbot/dotbot_simulator: use put_notwait when filling the thread queues --- dotbot/dotbot_simulator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dotbot/dotbot_simulator.py b/dotbot/dotbot_simulator.py index 87228e0..05c4082 100644 --- a/dotbot/dotbot_simulator.py +++ b/dotbot/dotbot_simulator.py @@ -213,7 +213,7 @@ def advertise(self): ) ), ) - self.tx_queue.put(payload) + self.tx_queue.put_nowait(payload) time.sleep(0.5) def rx_frame(self): @@ -253,7 +253,7 @@ def run(self): def stop(self): self.logger.info(f"Stopping DotBot {self.address} simulator...") self._stop_event.set() - self.queue.put(None) # unblock the rx_thread if waiting on the queue + self.queue.put_nowait(None) # unblock the rx_thread if waiting on the queue self.advertise_thread.join() self.rx_thread.join() self.main_thread.join() @@ -292,7 +292,7 @@ def run(self): def stop(self): self.logger.info("Stopping DotBot Simulation...") self._stp_event.set() - self.queue.put(None) # unblock the run thread if waiting on the queue + self.queue.put_nowait(None) # unblock the run thread if waiting on the queue for dotbot in self.dotbots: dotbot.stop() self.main_thread.join() @@ -321,4 +321,4 @@ def write(self, bytes_): f"Packet to DotBot {dotbot.address} lost in simulation" ) continue - dotbot.queue.put(Frame.from_bytes(bytes_)) + dotbot.queue.put_nowait(Frame.from_bytes(bytes_)) From 6cafab9a92d1c5f2a03d5b90a0b35411c63f53c2 Mon Sep 17 00:00:00 2001 From: Alexandre Abadie Date: Fri, 20 Feb 2026 16:53:09 +0100 Subject: [PATCH 3/5] dotbot/controller: better catch exception on websocket send errors --- dotbot/controller.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/dotbot/controller.py b/dotbot/controller.py index 7bfc3ac..3577dab 100644 --- a/dotbot/controller.py +++ b/dotbot/controller.py @@ -19,6 +19,7 @@ from pathlib import Path from typing import Dict, List +import starlette import serial import uvicorn import websockets @@ -432,8 +433,17 @@ async def _ws_send_safe(self, websocket: WebSocket, msg: str): """Safely send a message to a websocket client.""" try: await websocket.send_text(msg) - except websockets.exceptions.ConnectionClosedError: - await asyncio.sleep(0.1) + except ( + websockets.exceptions.ConnectionClosedError, + RuntimeError, + starlette.websockets.WebSocketDisconnect, + ) as exc: + self.logger.warning( + "Failed to send message to websocket client", + error=str(exc), + ) + if websocket in self.websockets: + self.websockets.remove(websocket) async def notify_clients(self, notification): """Send a message to all clients connected.""" From d0e75aae239057c0c5173ae1cbac0120eac4a4e2 Mon Sep 17 00:00:00 2001 From: Alexandre Abadie Date: Fri, 20 Feb 2026 16:53:50 +0100 Subject: [PATCH 4/5] dotbot: frontend: rework client notification strategy --- dotbot/controller.py | 6 ++- dotbot/frontend/src/DotBots.js | 11 +++-- dotbot/frontend/src/QrKeyApp.js | 27 +++++++++++ dotbot/frontend/src/RestApp.js | 27 +++++++++++ dotbot/frontend/src/utils/constants.js | 1 + dotbot/models.py | 65 ++++++++++++++------------ dotbot/qrkey.py | 19 -------- dotbot/server.py | 37 +++++++++------ 8 files changed, 125 insertions(+), 68 deletions(-) diff --git a/dotbot/controller.py b/dotbot/controller.py index 3577dab..e30b37c 100644 --- a/dotbot/controller.py +++ b/dotbot/controller.py @@ -19,8 +19,8 @@ from pathlib import Path from typing import Dict, List -import starlette import serial +import starlette import uvicorn import websockets from dotbot_utils.protocol import Frame, Payload @@ -295,7 +295,7 @@ def handle_received_frame( else: # reload if a new dotbot comes in logger.info("New robot") - notification_cmd = DotBotNotificationCommand.RELOAD + notification_cmd = DotBotNotificationCommand.NEW_DOTBOT if frame.packet.payload_type == PayloadType.ADVERTISEMENT: logger = logger.bind( @@ -426,6 +426,8 @@ def handle_received_frame( if self.settings.verbose is True: print(frame) self.dotbots.update({dotbot.address: dotbot}) + if notification_cmd != DotBotNotificationCommand.NEW_DOTBOT: + notification.data = DotBotModel(**dotbot.model_dump(exclude_none=True)) if notification_cmd != DotBotNotificationCommand.NONE: asyncio.create_task(self.notify_clients(notification)) diff --git a/dotbot/frontend/src/DotBots.js b/dotbot/frontend/src/DotBots.js index 219811b..ec85651 100644 --- a/dotbot/frontend/src/DotBots.js +++ b/dotbot/frontend/src/DotBots.js @@ -164,6 +164,9 @@ const DotBots = ({ dotbots, areaSize, updateDotbots, publishCommand, publish }) ]); let needDotBotMap = dotbots.filter(dotbot => dotbot.application === ApplicationType.DotBot).some((dotbot) => dotbot.calibrated > 0x00); + let dotbotCount = dotbots.filter(dotbot => dotbot.application === ApplicationType.DotBot).length; + let sailbotCount = dotbots.filter(dotbot => dotbot.application === ApplicationType.SailBot).length; + let xgoCount = dotbots.filter(dotbot => dotbot.application === ApplicationType.XGO).length; return ( <> @@ -185,11 +188,11 @@ const DotBots = ({ dotbots, areaSize, updateDotbots, publishCommand, publish })
{dotbots && dotbots.length > 0 && ( <> - {dotbots.filter(dotbot => dotbot.application === ApplicationType.DotBot).length > 0 && + {dotbotCount > 0 &&
-
Available DotBots
+
Available DotBots ({`${dotbotCount}`})
{dotbots @@ -247,7 +250,7 @@ const DotBots = ({ dotbots, areaSize, updateDotbots, publishCommand, publish }) }
} - {dotbots.filter(dotbot => dotbot.application === ApplicationType.SailBot).length > 0 && + {sailbotCount > 0 &&
@@ -297,7 +300,7 @@ const DotBots = ({ dotbots, areaSize, updateDotbots, publishCommand, publish })
} - {dotbots.filter(dotbot => dotbot.application === ApplicationType.XGO).length > 0 && + {xgoCount > 0 &&
diff --git a/dotbot/frontend/src/QrKeyApp.js b/dotbot/frontend/src/QrKeyApp.js index ec66afc..319a556 100644 --- a/dotbot/frontend/src/QrKeyApp.js +++ b/dotbot/frontend/src/QrKeyApp.js @@ -36,6 +36,11 @@ const QrKeyApp = () => { } } else if (message.topic === `/notify`) { // Process notifications + if (message.cmd === NotificationType.NewDotBot) { + let dotbotsTmp = dotbots.slice(); + dotbotsTmp.push(message.data); + setDotbots(dotbotsTmp); + } if (payload.cmd === NotificationType.Update && dotbots && dotbots.length > 0) { let dotbotsTmp = dotbots.slice(); for (let idx = 0; idx < dotbots.length; idx++) { @@ -43,6 +48,18 @@ const QrKeyApp = () => { if (payload.data.direction !== undefined && payload.data.direction !== null) { dotbotsTmp[idx].direction = payload.data.direction; } + if (payload.data.rgb_led !== undefined) { + if (dotbotsTmp[idx].rgb_led === undefined) { + dotbotsTmp[idx].rgb_led = { + red: 0, + green: 0, + blue: 0 + } + } + dotbotsTmp[idx].rgb_led.red = payload.data.rgb_led.red; + dotbotsTmp[idx].rgb_led.green = payload.data.rgb_led.green; + dotbotsTmp[idx].rgb_led.blue = payload.data.rgb_led.blue; + } if (payload.data.lh2_position !== undefined && payload.data.lh2_position !== null) { const newPosition = { x: payload.data.lh2_position.x, @@ -55,6 +72,9 @@ const QrKeyApp = () => { } dotbotsTmp[idx].lh2_position = newPosition; } + if (payload.data.lh2_waypoints !== undefined) { + dotbotsTmp[idx].lh2_waypoints = payload.data.lh2_waypoints; + } if (payload.data.gps_position !== undefined && payload.data.gps_position !== null) { const newPosition = { latitude: payload.data.gps_position.latitude, @@ -65,10 +85,17 @@ const QrKeyApp = () => { } dotbotsTmp[idx].gps_position = newPosition; } + if (payload.data.gps_waypoints !== undefined) { + dotbotsTmp[idx].gps_waypoints = payload.data.gps_waypoints; + } + if (payload.data.position_history !== undefined) { + dotbotsTmp[idx].position_history = payload.data.position_history; + } if (payload.data.battery !== undefined) { dotbotsTmp[idx].battery = payload.data.battery ; } setDotbots(dotbotsTmp); + break; } } } else if (payload.cmd === NotificationType.Reload) { diff --git a/dotbot/frontend/src/RestApp.js b/dotbot/frontend/src/RestApp.js index 7400763..7e7b2b0 100644 --- a/dotbot/frontend/src/RestApp.js +++ b/dotbot/frontend/src/RestApp.js @@ -54,6 +54,11 @@ const onWsOpen = () => { if (message.cmd === NotificationType.Reload) { fetchDotBots(); } + if (message.cmd === NotificationType.NewDotBot) { + let dotbotsTmp = dotbots.slice(); + dotbotsTmp.push(message.data); + setDotbots(dotbotsTmp); + } if (message.cmd === NotificationType.Update && dotbots && dotbots.length > 0) { let dotbotsTmp = dotbots.slice(); for (let idx = 0; idx < dotbots.length; idx++) { @@ -61,6 +66,18 @@ const onWsOpen = () => { if (message.data.direction !== undefined && message.data.direction !== null) { dotbotsTmp[idx].direction = message.data.direction; } + if (message.data.rgb_led !== undefined) { + if (dotbotsTmp[idx].rgb_led === undefined) { + dotbotsTmp[idx].rgb_led = { + red: 0, + green: 0, + blue: 0 + } + } + dotbotsTmp[idx].rgb_led.red = message.data.rgb_led.red; + dotbotsTmp[idx].rgb_led.green = message.data.rgb_led.green; + dotbotsTmp[idx].rgb_led.blue = message.data.rgb_led.blue; + } if (message.data.wind_angle !== undefined && message.data.wind_angle !== null) { dotbotsTmp[idx].wind_angle = message.data.wind_angle; } @@ -80,6 +97,9 @@ const onWsOpen = () => { } dotbotsTmp[idx].lh2_position = newPosition; } + if (message.data.lh2_waypoints !== undefined) { + dotbotsTmp[idx].lh2_waypoints = message.data.lh2_waypoints; + } if (message.data.gps_position !== undefined && message.data.gps_position !== null) { const newPosition = { latitude: message.data.gps_position.latitude, @@ -90,10 +110,17 @@ const onWsOpen = () => { } dotbotsTmp[idx].gps_position = newPosition; } + if (message.data.gps_waypoints !== undefined) { + dotbotsTmp[idx].gps_waypoints = message.data.gps_waypoints; + } + if (message.data.position_history !== undefined) { + dotbotsTmp[idx].position_history = message.data.position_history; + } if (message.data.battery !== undefined) { dotbotsTmp[idx].battery = message.data.battery; } setDotbots(dotbotsTmp); + break; } } } diff --git a/dotbot/frontend/src/utils/constants.js b/dotbot/frontend/src/utils/constants.js index e47dba4..7e15d6a 100644 --- a/dotbot/frontend/src/utils/constants.js +++ b/dotbot/frontend/src/utils/constants.js @@ -15,6 +15,7 @@ export const NotificationType = { Reload: 1, Update: 2, PinCodeUpdate: 3, + NewDotBot: 4, }; export const RequestType = { diff --git a/dotbot/models.py b/dotbot/models.py index 67859e7..1378a6d 100644 --- a/dotbot/models.py +++ b/dotbot/models.py @@ -113,36 +113,6 @@ class DotBotQueryModel(BaseModel): min_position_y: Optional[float] = None -class DotBotNotificationCommand(IntEnum): - """Notification command of a DotBot.""" - - NONE: int = 0 - RELOAD: int = 1 - UPDATE: int = 2 - PIN_CODE_UPDATE: int = 3 - - -class DotBotNotificationUpdate(BaseModel): - """Update notification model.""" - - address: str - direction: Optional[int] = None - wind_angle: Optional[int] = None - rudder_angle: Optional[int] = None - sail_angle: Optional[int] = None - lh2_position: Optional[DotBotLH2Position] = None - gps_position: Optional[DotBotGPSPosition] = None - battery: Optional[float] = None - - -class DotBotNotificationModel(BaseModel): - """Model class used to send controller notifications.""" - - cmd: DotBotNotificationCommand - data: Optional[DotBotNotificationUpdate] = None - pin_code: Optional[int] = None - - class DotBotRequestType(IntEnum): """Request received from MQTT client.""" @@ -188,6 +158,41 @@ class DotBotModel(BaseModel): battery: float = 3.0 # Voltage in Volts +class DotBotNotificationCommand(IntEnum): + """Notification command of a DotBot.""" + + NONE: int = 0 + RELOAD: int = 1 + UPDATE: int = 2 + PIN_CODE_UPDATE: int = 3 + NEW_DOTBOT: int = 4 + + +class DotBotNotificationUpdate(BaseModel): + """Update notification model.""" + + address: str + direction: Optional[int] = None + wind_angle: Optional[int] = None + rudder_angle: Optional[int] = None + sail_angle: Optional[int] = None + lh2_position: Optional[DotBotLH2Position] = None + gps_position: Optional[DotBotGPSPosition] = None + battery: Optional[float] = None + rgb_led: Optional[DotBotRgbLedCommandModel] = None + lh2_waypoints: Optional[List[DotBotLH2Position]] = None + gps_waypoints: Optional[List[DotBotGPSPosition]] = None + position_history: Optional[List[Union[DotBotLH2Position, DotBotGPSPosition]]] = None + + +class DotBotNotificationModel(BaseModel): + """Model class used to send controller notifications.""" + + cmd: DotBotNotificationCommand + data: Optional[Union[DotBotNotificationUpdate, DotBotModel]] = None + pin_code: Optional[int] = None + + class WSBase(BaseModel): cmd: str address: str diff --git a/dotbot/qrkey.py b/dotbot/qrkey.py index 5126f6f..7df19c9 100644 --- a/dotbot/qrkey.py +++ b/dotbot/qrkey.py @@ -22,7 +22,6 @@ from dotbot.logger import LOGGER from dotbot.models import ( DotBotMoveRawCommandModel, - DotBotNotificationCommand, DotBotNotificationModel, DotBotReplyModel, DotBotRequestModel, @@ -144,12 +143,6 @@ def on_command_rgb_led(self, topic, payload): command=command.__class__.__name__, ) self.worker.run(self.client.send_rgb_led_command(address, command)) - self.qrkey.publish( - "/notify", - DotBotNotificationModel(cmd=DotBotNotificationCommand.RELOAD).model_dump( - exclude_none=True - ), - ) def on_command_xgo_action(self, topic, payload): """Called when an rgb led command is received.""" @@ -202,12 +195,6 @@ def on_command_waypoints(self, topic, payload): address, ApplicationType(int(application)), command ) ) - self.qrkey.publish( - "/notify", - DotBotNotificationModel(cmd=DotBotNotificationCommand.RELOAD).model_dump( - exclude_none=True - ), - ) def on_command_clear_position_history(self, topic, _): """Called when a clear position history command is received.""" @@ -223,12 +210,6 @@ def on_command_clear_position_history(self, topic, _): ) logger.info("Notify clear command", address=address) self.worker.run(self.client.clear_position_history(address)) - self.qrkey.publish( - "/notify", - DotBotNotificationModel(cmd=DotBotNotificationCommand.RELOAD).model_dump( - exclude_none=True - ), - ) def on_request(self, payload): logger = LOGGER.bind(topic="/request") diff --git a/dotbot/server.py b/dotbot/server.py index a3f77b5..d677a7c 100644 --- a/dotbot/server.py +++ b/dotbot/server.py @@ -31,6 +31,7 @@ DotBotMoveRawCommandModel, DotBotNotificationCommand, DotBotNotificationModel, + DotBotNotificationUpdate, DotBotQueryModel, DotBotRgbLedCommandModel, DotBotWaypoints, @@ -114,9 +115,6 @@ async def dotbots_move_raw( raise HTTPException(status_code=404, detail="No matching dotbot found") _dotbots_move_raw(address=address, command=command) - await api.controller.notify_clients( - DotBotNotificationModel(cmd=DotBotNotificationCommand.RELOAD) - ) def _dotbots_move_raw(address: str, command: DotBotMoveRawCommandModel): @@ -141,19 +139,20 @@ async def dotbots_rgb_led( """Set the current active DotBot.""" if address not in api.controller.dotbots: raise HTTPException(status_code=404, detail="No matching dotbot found") - - _dotbots_rgb_led(address=address, command=command) - await api.controller.notify_clients( - DotBotNotificationModel(cmd=DotBotNotificationCommand.RELOAD) - ) + await _dotbots_rgb_led(address=address, command=command) -def _dotbots_rgb_led(address: str, command: DotBotRgbLedCommandModel): +async def _dotbots_rgb_led(address: str, command: DotBotRgbLedCommandModel): payload = PayloadCommandRgbLed( red=command.red, green=command.green, blue=command.blue ) api.controller.send_payload(int(address, 16), payload) api.controller.dotbots[address].rgb_led = command + notification = DotBotNotificationModel( + cmd=DotBotNotificationCommand.UPDATE, + data=DotBotNotificationUpdate(address=address, rgb_led=command), + ) + await api.controller.notify_clients(notification) @api.put( @@ -197,6 +196,10 @@ async def _dotbots_waypoints( for waypoint in waypoints.waypoints ], ) + update_data = DotBotNotificationUpdate( + address=address, + gps_waypoints=waypoints_list, + ) else: # DotBot application if api.controller.dotbots[address].lh2_position is not None: waypoints_list = [ @@ -214,12 +217,17 @@ async def _dotbots_waypoints( for waypoint in waypoints.waypoints ], ) + update_data = DotBotNotificationUpdate( + address=address, + lh2_waypoints=waypoints_list, + ) api.controller.dotbots[address].waypoints = waypoints_list api.controller.dotbots[address].waypoints_threshold = waypoints.threshold api.controller.send_payload(int(address, 16), payload) - await api.controller.notify_clients( - DotBotNotificationModel(cmd=DotBotNotificationCommand.RELOAD) + notification = DotBotNotificationModel( + cmd=DotBotNotificationCommand.UPDATE, data=update_data ) + await api.controller.notify_clients(notification) @api.delete( @@ -233,7 +241,10 @@ async def dotbot_positions_history_clear(address: str): raise HTTPException(status_code=404, detail="No matching dotbot found") api.controller.dotbots[address].position_history = [] await api.controller.notify_clients( - DotBotNotificationModel(cmd=DotBotNotificationCommand.RELOAD) + DotBotNotificationModel( + cmd=DotBotNotificationCommand.UPDATE, + data=DotBotNotificationUpdate(address=address, position_history=[]), + ) ) @@ -313,7 +324,7 @@ async def ws_dotbots(websocket: WebSocket): continue if isinstance(msg, WSRgbLed): - _dotbots_rgb_led( + await _dotbots_rgb_led( address=msg.address, command=msg.data, ) From eca08abb0fd7ef349faf8d7278378670ce523f31 Mon Sep 17 00:00:00 2001 From: Alexandre Abadie Date: Mon, 23 Feb 2026 16:01:15 +0100 Subject: [PATCH 5/5] dotbot/dotbot_simulator: speed-up simulator shutdown --- dotbot/dotbot_simulator.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/dotbot/dotbot_simulator.py b/dotbot/dotbot_simulator.py index 05c4082..212003b 100644 --- a/dotbot/dotbot_simulator.py +++ b/dotbot/dotbot_simulator.py @@ -9,7 +9,6 @@ import queue import random import threading -import time from binascii import hexlify from dataclasses import dataclass from enum import Enum @@ -32,6 +31,9 @@ INITIAL_BATTERY_VOLTAGE = 3000 # mV MAX_BATTERY_DURATION = 300 # 5 minutes +ADVERTISEMENT_INTERVAL_S = 0.5 +SIMULATOR_UPDATE_INTERVAL_S = 0.1 + def battery_discharge_model(time_elapsed_s: float) -> int: """Simple battery discharge model.""" @@ -195,7 +197,6 @@ def update(self, dt: float): "DotBot simulator update", x=self.pos_x, y=self.pos_y, theta=self.theta ) self.time_elapsed_s += dt - time.sleep(dt) def advertise(self): """Send an advertisement message to the gateway.""" @@ -214,7 +215,9 @@ def advertise(self): ), ) self.tx_queue.put_nowait(payload) - time.sleep(0.5) + is_stopped = self._stop_event.wait(ADVERTISEMENT_INTERVAL_S) + if is_stopped: + break def rx_frame(self): """Decode the serial input received from the gateway.""" @@ -246,9 +249,12 @@ def rx_frame(self): def run(self): """Update the state of the dotbot simulator.""" - while self._stop_event.is_set() is False: + while True: with self._lock: - self.update(0.1) + self.update(SIMULATOR_UPDATE_INTERVAL_S) + is_stopped = self._stop_event.wait(SIMULATOR_UPDATE_INTERVAL_S) + if is_stopped: + break def stop(self): self.logger.info(f"Stopping DotBot {self.address} simulator...")