From 543b21f490cc5baa9f2432eefa9bac1ea8a3b645 Mon Sep 17 00:00:00 2001 From: Alexandre Abadie Date: Mon, 9 Feb 2026 16:28:36 +0100 Subject: [PATCH 1/7] dotbot: split out qrkey in its own client service --- dotbot/controller.py | 274 +--------------------- dotbot/models.py | 8 +- dotbot/qrkey.py | 341 ++++++++++++++++++++++++++++ dotbot/qrkey_app.py | 118 ++++++++++ dotbot/rest.py | 51 ++++- dotbot/tests/test_controller.py | 7 +- dotbot/tests/test_controller_app.py | 9 +- dotbot/tests/test_server.py | 1 - pyproject.toml | 1 + 9 files changed, 520 insertions(+), 290 deletions(-) create mode 100644 dotbot/qrkey.py create mode 100644 dotbot/qrkey_app.py diff --git a/dotbot/controller.py b/dotbot/controller.py index 2058c935..7bfc3acc 100644 --- a/dotbot/controller.py +++ b/dotbot/controller.py @@ -25,9 +25,6 @@ from dotbot_utils.protocol import Frame, Payload from dotbot_utils.serial_interface import SerialInterfaceException from fastapi import WebSocket -from pydantic import ValidationError -from pydantic.tools import parse_obj_as -from qrkey import QrkeyController, SubscriptionModel, qrkey_settings from dotbot import ( CONTROLLER_ADAPTER_DEFAULT, @@ -56,29 +53,15 @@ DotBotLH2Position, DotBotMapSizeModel, DotBotModel, - DotBotMoveRawCommandModel, DotBotNotificationCommand, DotBotNotificationModel, DotBotNotificationUpdate, DotBotQueryModel, - DotBotReplyModel, - DotBotRequestModel, - DotBotRequestType, - DotBotRgbLedCommandModel, DotBotStatus, - DotBotWaypoints, - DotBotXGOActionCommandModel, ) from dotbot.protocol import ( ApplicationType, - PayloadCommandMoveRaw, - PayloadCommandRgbLed, - PayloadCommandXgoAction, - PayloadGPSPosition, - PayloadGPSWaypoints, PayloadLh2CalibrationHomography, - PayloadLH2Location, - PayloadLH2Waypoints, PayloadType, ) from dotbot.server import api @@ -91,7 +74,6 @@ # ) -CONTROLLERS = {} INACTIVE_DELAY = 5 # seconds LOST_DELAY = 60 # seconds LH2_POSITION_DISTANCE_THRESHOLD = 20 # mm @@ -213,243 +195,6 @@ def __init__(self, settings: ControllerSettings): height=int(settings.map_size.split("x")[1]), ) api.controller = self - self.qrkey = None - - self.subscriptions = [ - SubscriptionModel( - topic="/command/+/+/+/move_raw", callback=self.on_command_move_raw - ), - SubscriptionModel( - topic="/command/+/+/+/rgb_led", callback=self.on_command_rgb_led - ), - SubscriptionModel( - topic="/command/+/+/+/xgo_action", callback=self.on_command_xgo_action - ), - SubscriptionModel( - topic="/command/+/+/+/waypoints", callback=self.on_command_waypoints - ), - SubscriptionModel( - topic="/command/+/+/+/clear_position_history", - callback=self.on_command_clear_position_history, - ), - ] - - def on_command_move_raw(self, topic, payload): - """Called when a move raw command is received.""" - logger = self.logger.bind(command="move_raw", topic=topic) - topic_split = topic.split("/")[2:] - if len(topic_split) != 4 or topic_split[-1] != "move_raw": - logger.warning("Invalid move_raw command topic") - return - _, address, application, _ = topic_split - try: - command = DotBotMoveRawCommandModel(**payload) - except ValidationError as exc: - self.logger.warning(f"Invalid move raw command: {exc.errors()}") - return - logger.bind( - address=address, - application=ApplicationType(int(application)).name, - **command.model_dump(), - ) - if address not in self.dotbots: - logger.warning("DotBot not found") - return - payload = PayloadCommandMoveRaw( - left_x=command.left_x, - left_y=command.left_y, - right_x=command.right_x, - right_y=command.right_y, - ) - logger.info( - "Sending MQTT command", address=address, command=payload.__class__.__name__ - ) - self.send_payload(int(address, 16), payload=payload) - self.dotbots[address].move_raw = command - - def on_command_rgb_led(self, topic, payload): - """Called when an rgb led command is received.""" - logger = self.logger.bind(command="rgb_led", topic=topic) - topic_split = topic.split("/")[2:] - if len(topic_split) != 4 or topic_split[-1] != "rgb_led": - logger.warning("Invalid rgb_led command topic") - return - _, address, application, _ = topic_split - try: - command = DotBotRgbLedCommandModel(**payload) - except ValidationError as exc: - LOGGER.warning(f"Invalid rgb led command: {exc.errors()}") - return - logger = logger.bind( - address=address, - application=ApplicationType(int(application)).name, - **command.model_dump(), - ) - if address not in self.dotbots: - logger.warning("DotBot not found") - return - payload = PayloadCommandRgbLed( - red=command.red, green=command.green, blue=command.blue - ) - logger.info( - "Sending MQTT command", address=address, command=payload.__class__.__name__ - ) - self.send_payload(int(address, 16), payload=payload) - self.dotbots[address].rgb_led = 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.""" - logger = self.logger.bind(command="xgo_action", topic=topic) - topic_split = topic.split("/")[2:] - if len(topic_split) != 4 or topic_split[-1] != "xgo_action": - logger.warning("Invalid xgo_action command topic") - return - _, address, application, _ = topic_split - try: - command = DotBotXGOActionCommandModel(**payload) - except ValidationError as exc: - LOGGER.warning(f"Invalid rgb led command: {exc.errors()}") - return - logger = logger.bind( - address=address, - application=ApplicationType(int(application)).name, - **command.model_dump(), - ) - if address not in self.dotbots: - logger.warning("DotBot not found") - return - payload = PayloadCommandXgoAction(action=command.action) - logger.info( - "Sending MQTT command", address=address, command=payload.__class__.__name__ - ) - self.send_payload(int(address, 16), payload=payload) - - def on_command_waypoints(self, topic, payload): - """Called when a list of waypoints is received.""" - logger = self.logger.bind(command="waypoints", topic=topic) - topic_split = topic.split("/")[2:] - if len(topic_split) != 4 or topic_split[-1] != "waypoints": - logger.warning("Invalid waypoints command topic") - return - _, address, application, _ = topic_split - command = parse_obj_as(DotBotWaypoints, payload) - logger = logger.bind( - address=address, - application=ApplicationType(int(application)).name, - threshold=command.threshold, - length=len(command.waypoints), - ) - if address not in self.dotbots: - logger.warning("DotBot not found") - return - waypoints_list = command.waypoints - if ApplicationType(int(application)) == ApplicationType.SailBot: - if self.dotbots[address].gps_position is not None: - waypoints_list = [ - self.dotbots[address].gps_position - ] + command.waypoints - payload = PayloadGPSWaypoints( - threshold=command.threshold, - count=len(command.waypoints), - waypoints=[ - PayloadGPSPosition( - latitude=int(waypoint.latitude * 1e6), - longitude=int(waypoint.longitude * 1e6), - ) - for waypoint in command.waypoints - ], - ) - else: # DotBot application - if self.dotbots[address].lh2_position is not None: - waypoints_list = [ - self.dotbots[address].lh2_position - ] + command.waypoints - payload = PayloadLH2Waypoints( - threshold=command.threshold, - count=len(command.waypoints), - waypoints=[ - PayloadLH2Location( - pos_x=int(waypoint.x), - pos_y=int(waypoint.y), - pos_z=int(waypoint.z), - ) - for waypoint in command.waypoints - ], - ) - logger.info( - "Sending MQTT command", address=address, command=payload.__class__.__name__ - ) - self.send_payload(int(address, 16), payload=payload) - self.dotbots[address].waypoints = waypoints_list - self.dotbots[address].waypoints_threshold = command.threshold - 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.""" - logger = self.logger.bind(command="clear_position_history", topic=topic) - topic_split = topic.split("/")[2:] - if len(topic_split) != 4 or topic_split[-1] != "clear_position_history": - logger.warning("Invalid clear_position_history command topic") - return - _, address, application, _ = topic_split - logger = logger.bind( - address=address, - application=ApplicationType(int(application)).name, - ) - if address not in self.dotbots: - logger.warning("DotBot not found") - return - logger.info("Notify clear command", address=address) - self.dotbots[address].position_history = [] - self.qrkey.publish( - "/notify", - DotBotNotificationModel(cmd=DotBotNotificationCommand.RELOAD).model_dump( - exclude_none=True - ), - ) - - def on_request(self, payload): - logger = LOGGER.bind(topic="/request") - logger.info("Request received", **payload) - try: - request = DotBotRequestModel(**payload) - except ValidationError as exc: - logger.warning(f"Invalid request: {exc.errors()}") - return - - reply_topic = f"/reply/{request.reply}" - if request.request == DotBotRequestType.DOTBOTS: - logger.info("Publish dotbots") - data = [ - dotbot.model_dump(exclude_none=True) - for dotbot in self.get_dotbots(DotBotQueryModel()) - ] - message = DotBotReplyModel( - request=DotBotRequestType.DOTBOTS, - data=data, - ).model_dump(exclude_none=True) - self.qrkey.publish(reply_topic, message) - elif request.request == DotBotRequestType.MAP_SIZE: - logger.info("Publish map size") - data = self.map_size.model_dump(exclude_none=True) - message = DotBotReplyModel( - request=DotBotRequestType.MAP_SIZE, - data=data, - ).model_dump(exclude_none=True) - self.qrkey.publish(reply_topic, message) - else: - logger.warning("Unsupported request command") async def _open_webbrowser(self): """Wait until the server is ready before opening a web browser.""" @@ -463,18 +208,7 @@ async def _open_webbrowser(self): else: writer.close() break - url = ( - f"http://localhost:{self.settings.controller_http_port}/PyDotBot?" - f"pin={self.qrkey.pin_code}&" - f"mqtt_host={qrkey_settings.mqtt_host}&" - f"mqtt_port={qrkey_settings.mqtt_ws_port}&" - f"mqtt_version={qrkey_settings.mqtt_version}&" - f"mqtt_use_ssl={qrkey_settings.mqtt_use_ssl}" - ) - if qrkey_settings.mqtt_username is not None: - url += f"&mqtt_username={qrkey_settings.mqtt_username}" - if qrkey_settings.mqtt_password is not None: - url += f"&mqtt_password={qrkey_settings.mqtt_password}" + url = f"http://localhost:{self.settings.controller_http_port}/PyDotBot" self.logger.debug("Using frontend URL", url=url) if self.settings.webbrowser is True: self.logger.info("Opening webbrowser", url=url) @@ -712,7 +446,6 @@ async def notify_clients(self, notification): for websocket in self.websockets ] ) - self.qrkey.publish("/notify", notification.model_dump(exclude_none=True)) def send_payload(self, destination: int, payload: Payload): """Sends a command in an HDLC frame over serial.""" @@ -841,13 +574,8 @@ async def _start_adapter(self): async def run(self): """Launch the controller.""" tasks = [] - self.qrkey = QrkeyController(self.on_request, LOGGER, root_topic="/pydotbot") try: tasks = [ - asyncio.create_task( - name="QrKey controller", - coro=self.qrkey.start(subscriptions=self.subscriptions), - ), asyncio.create_task(name="Web server", coro=self.web()), asyncio.create_task(name="Web browser", coro=self._open_webbrowser()), asyncio.create_task( diff --git a/dotbot/models.py b/dotbot/models.py index a1ae70f2..67859e75 100644 --- a/dotbot/models.py +++ b/dotbot/models.py @@ -126,10 +126,10 @@ class DotBotNotificationUpdate(BaseModel): """Update notification model.""" address: str - direction: Optional[int] - wind_angle: Optional[int] - rudder_angle: Optional[int] - sail_angle: Optional[int] + 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 diff --git a/dotbot/qrkey.py b/dotbot/qrkey.py new file mode 100644 index 00000000..5126f6fd --- /dev/null +++ b/dotbot/qrkey.py @@ -0,0 +1,341 @@ +# SPDX-FileCopyrightText: 2026-present Inria +# SPDX-FileCopyrightText: 2026-present Alexandre Abadie +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Interface of the QrKey client.""" + +import asyncio +import json +import os +import threading +import webbrowser +from dataclasses import dataclass + +from pydantic import ValidationError +from pydantic.tools import parse_obj_as +from qrkey import QrkeyController, SubscriptionModel, qrkey_settings +from websockets import exceptions as websockets_exceptions +from websockets.asyncio.client import connect + +from dotbot import CONTROLLER_HTTP_HOSTNAME_DEFAULT, CONTROLLER_HTTP_PORT_DEFAULT +from dotbot.logger import LOGGER +from dotbot.models import ( + DotBotMoveRawCommandModel, + DotBotNotificationCommand, + DotBotNotificationModel, + DotBotReplyModel, + DotBotRequestModel, + DotBotRequestType, + DotBotRgbLedCommandModel, + DotBotWaypoints, + DotBotXGOActionCommandModel, +) +from dotbot.protocol import ApplicationType +from dotbot.rest import RestClient + + +@dataclass +class QrKeyClientSettings: + """Data class that holds QrKey client settings.""" + + http_host: str = CONTROLLER_HTTP_HOSTNAME_DEFAULT + http_port: int = CONTROLLER_HTTP_PORT_DEFAULT + webbrowser: bool = False + verbose: bool = False + log_level: str = "info" + log_output: str = os.path.join(os.getcwd(), "pydotbot-qrkey.log") + + +class AsyncWorker: + def __init__(self): + self.loop = asyncio.new_event_loop() + self.thread = threading.Thread(target=self._run_loop, daemon=True) + self.thread.start() + + def _run_loop(self): + asyncio.set_event_loop(self.loop) + self.loop.run_forever() + + def run(self, coro): + future = asyncio.run_coroutine_threadsafe(coro, self.loop) + return future.result(timeout=1) + + +class QrKeyClient: + """Abstract base class of specific implementations of Dotbot controllers.""" + + def __init__(self, settings: QrKeyClientSettings, client: RestClient): + self.client = client + self.logger = LOGGER.bind(context=__name__) + self.settings: QrKeyClientSettings = settings + self.worker = AsyncWorker() + self.qrkey = None + + self.subscriptions = [ + SubscriptionModel( + topic="/command/+/+/+/move_raw", callback=self.on_command_move_raw + ), + SubscriptionModel( + topic="/command/+/+/+/rgb_led", callback=self.on_command_rgb_led + ), + SubscriptionModel( + topic="/command/+/+/+/xgo_action", callback=self.on_command_xgo_action + ), + SubscriptionModel( + topic="/command/+/+/+/waypoints", callback=self.on_command_waypoints + ), + SubscriptionModel( + topic="/command/+/+/+/clear_position_history", + callback=self.on_command_clear_position_history, + ), + ] + + def on_command_move_raw(self, topic, payload): + """Called when a move raw command is received.""" + logger = self.logger.bind(command="move_raw") + topic_split = topic.split("/")[2:] + if len(topic_split) != 4 or topic_split[-1] != "move_raw": + logger.warning("Invalid move_raw command topic") + return + _, address, application, _ = topic_split + try: + command = DotBotMoveRawCommandModel(**payload) + except ValidationError as exc: + self.logger.warning(f"Invalid move raw command: {exc.errors()}") + return + logger.bind( + address=address, + application=ApplicationType(int(application)).name, + **command.model_dump(), + ) + logger.info( + "Forwarding move_raw command", + address=address, + command=command.__class__.__name__, + ) + self.worker.run( + self.client.send_move_raw_command( + address, ApplicationType(int(application)), command + ) + ) + + def on_command_rgb_led(self, topic, payload): + """Called when an rgb led command is received.""" + logger = self.logger.bind(command="rgb_led") + topic_split = topic.split("/")[2:] + if len(topic_split) != 4 or topic_split[-1] != "rgb_led": + logger.warning("Invalid rgb_led command topic") + return + _, address, application, _ = topic_split + try: + command = DotBotRgbLedCommandModel(**payload) + except ValidationError as exc: + LOGGER.warning(f"Invalid rgb led command: {exc.errors()}") + return + logger = logger.bind( + address=address, + application=ApplicationType(int(application)).name, + **command.model_dump(), + ) + logger.info( + "Forwarding RGB LED command", + address=address, + 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.""" + logger = self.logger.bind(command="xgo_action") + topic_split = topic.split("/")[2:] + if len(topic_split) != 4 or topic_split[-1] != "xgo_action": + logger.warning("Invalid xgo_action command topic") + return + _, address, application, _ = topic_split + try: + command = DotBotXGOActionCommandModel(**payload) + except ValidationError as exc: + logger.warning(f"Invalid xgo_action command: {exc.errors()}") + return + logger = logger.bind( + address=address, + application=ApplicationType(int(application)).name, + **command.model_dump(), + ) + logger.info( + "Forwarding XGO action command", + address=address, + command=command.__class__.__name__, + ) + # TODO: implement xgo action command sending + + def on_command_waypoints(self, topic, payload): + """Called when a list of waypoints is received.""" + logger = self.logger.bind(command="waypoints") + topic_split = topic.split("/")[2:] + if len(topic_split) != 4 or topic_split[-1] != "waypoints": + logger.warning("Invalid waypoints command topic") + return + _, address, application, _ = topic_split + command = parse_obj_as(DotBotWaypoints, payload) + logger = logger.bind( + address=address, + application=ApplicationType(int(application)).name, + threshold=command.threshold, + length=len(command.waypoints), + ) + + logger.info( + "Forwarding waypoints command", + address=address, + command=payload.__class__.__name__, + ) + self.worker.run( + self.client.send_waypoint_command( + 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.""" + logger = self.logger.bind(command="clear_position_history") + topic_split = topic.split("/")[2:] + if len(topic_split) != 4 or topic_split[-1] != "clear_position_history": + logger.warning("Invalid clear_position_history command topic") + return + _, address, application, _ = topic_split + logger = logger.bind( + address=address, + application=ApplicationType(int(application)).name, + ) + 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") + logger.info("Request received", **payload) + try: + request = DotBotRequestModel(**payload) + except ValidationError as exc: + logger.warning(f"Invalid request: {exc.errors()}") + return + + reply_topic = f"/reply/{request.reply}" + if request.request == DotBotRequestType.DOTBOTS: + logger.info("Publish dotbots") + dotbots = self.worker.run(self.client.fetch_dotbots()) + data = [dotbot.model_dump(exclude_none=True) for dotbot in dotbots] + message = DotBotReplyModel( + request=DotBotRequestType.DOTBOTS, + data=data, + ).model_dump(exclude_none=True) + self.qrkey.publish(reply_topic, message) + elif request.request == DotBotRequestType.MAP_SIZE: + logger.info("Publish map size") + area_size = self.worker.run(self.client.fetch_map_size()) + message = DotBotReplyModel( + request=DotBotRequestType.MAP_SIZE, + data=area_size.model_dump(exclude_none=True), + ).model_dump(exclude_none=True) + self.qrkey.publish(reply_topic, message) + else: + logger.warning("Unsupported request command") + + async def _open_webbrowser(self): + """Wait until the server is ready before opening a web browser.""" + while 1: + try: + _, writer = await asyncio.open_connection( + self.settings.http_host, self.settings.http_port + ) + except ConnectionRefusedError: + await asyncio.sleep(0.1) + else: + writer.close() + break + url = ( + f"http://{self.settings.http_host}:{self.settings.http_port}/PyDotBot?" + f"use_qrkey=true&" + f"pin={self.qrkey.pin_code}&" + f"mqtt_host={qrkey_settings.mqtt_host}&" + f"mqtt_port={qrkey_settings.mqtt_ws_port}&" + f"mqtt_version={qrkey_settings.mqtt_version}&" + f"mqtt_use_ssl={qrkey_settings.mqtt_use_ssl}" + ) + if qrkey_settings.mqtt_username is not None: + url += f"&mqtt_username={qrkey_settings.mqtt_username}" + if qrkey_settings.mqtt_password is not None: + url += f"&mqtt_password={qrkey_settings.mqtt_password}" + self.logger.debug("Using frontend URL", url=url) + if self.settings.webbrowser is True: + self.logger.info("Opening webbrowser", url=url) + webbrowser.open(url) + + async def start_ws_client(self): + """Start the WebSocket client to receive commands from the frontend.""" + async with connect( + f"ws://{self.settings.http_host}:{self.settings.http_port}/controller/ws/status", + ) as websocket: + while True: + message = await websocket.recv() + try: + payload = json.loads(message) + except json.JSONDecodeError: + self.logger.warning( + "Received invalid JSON message", message=message + ) + continue + if "cmd" not in payload: + continue + self.qrkey.publish( + "/notify", + DotBotNotificationModel(**payload).model_dump(exclude_none=True), + ) + + async def run(self): + """Launch the controller.""" + tasks = [] + self.qrkey = QrkeyController(self.on_request, LOGGER, root_topic="/pydotbot") + try: + tasks = [ + asyncio.create_task( + name="QrKey controller", + coro=self.qrkey.start(subscriptions=self.subscriptions), + ), + asyncio.create_task( + name="WebSocket client", coro=self.start_ws_client() + ), + asyncio.create_task(name="Web browser", coro=self._open_webbrowser()), + ] + await asyncio.gather(*tasks) + except ConnectionRefusedError as exc: + self.logger.warning(f"Failed to connect to PyDotBot controller: {exc}") + except websockets_exceptions.ConnectionClosedError as exc: + self.logger.warning(f"WebSocket connection closed: {exc}") + except SystemExit: + pass + finally: + self.logger.info("Stopping QrKey client") + for task in tasks: + self.logger.info(f"Cancelling task '{task.get_name()}'") + task.cancel() + self.logger.info("QrKey client stopped") diff --git a/dotbot/qrkey_app.py b/dotbot/qrkey_app.py new file mode 100644 index 00000000..16fa5566 --- /dev/null +++ b/dotbot/qrkey_app.py @@ -0,0 +1,118 @@ +# SPDX-FileCopyrightText: 2022-present Inria +# SPDX-FileCopyrightText: 2022-present Alexandre Abadie +# +# SPDX-License-Identifier: BSD-3-Clause + +#!/usr/bin/env python3 + +"""Main module of the Dotbot controller command line tool.""" + +import asyncio +import sys + +import click +import toml + +from dotbot import ( + CONTROLLER_HTTP_HOSTNAME_DEFAULT, + CONTROLLER_HTTP_PORT_DEFAULT, + pydotbot_version, +) +from dotbot.logger import setup_logging +from dotbot.qrkey import QrKeyClient, QrKeyClientSettings +from dotbot.rest import rest_client + + +@click.command() +@click.option( + "-H", + "--http-host", + type=int, + help=f"Controller HTTP host of the REST API. Defaults to '{CONTROLLER_HTTP_HOSTNAME_DEFAULT}'", +) +@click.option( + "-P", + "--http-port", + type=int, + help=f"Controller HTTP port of the REST API. Defaults to '{CONTROLLER_HTTP_PORT_DEFAULT}'", +) +@click.option( + "-w", + "--webbrowser", + is_flag=True, + help="Open a web browser automatically", +) +@click.option( + "-v", + "--verbose", + is_flag=True, + help="Run in verbose mode (all payloads received are printed in terminal)", +) +@click.option( + "--log-level", + type=click.Choice(["debug", "info", "warning", "error"]), + default="info", + help="Logging level. Defaults to info", +) +@click.option( + "--log-output", + type=click.Path(), + help="Filename where logs are redirected", +) +@click.option( + "--config-path", + type=click.Path(exists=True, dir_okay=False), + help="Path to a .toml configuration file.", +) +def main( + http_host, + http_port, + webbrowser, + verbose, + log_level, + log_output, + config_path, +): # pylint: disable=redefined-builtin,too-many-arguments + """DotBot QrKey client.""" + # welcome sentence + print(f"Welcome to the DotBot QrKey client (version: {pydotbot_version()}).") + + # The priority order is CLI > ConfigFile (optional) > Defaults + cli_args = { + "http_host": http_host, + "http_port": http_port, + "webbrowser": webbrowser, + "verbose": verbose, + "log_level": log_level, + "log_output": log_output, + } + + data = {} + if config_path: + file_data = toml.load(config_path) + data.update(file_data) + + data.update({k: v for k, v in cli_args.items() if v not in (None, False)}) + + client_settings = QrKeyClientSettings(**data) + + setup_logging( + client_settings.log_output, + client_settings.log_level, + ["console", "file"], + ) + try: + loop = asyncio.get_event_loop() + loop.run_until_complete(cli(client_settings)) + except (SystemExit, KeyboardInterrupt): + sys.exit(0) + + +async def cli(settings: QrKeyClientSettings): + async with rest_client(settings.http_host, settings.http_port, False) as client: + qrkey_client = QrKeyClient(settings, client) + await qrkey_client.run() + + +if __name__ == "__main__": + main() # pragma: nocover, pylint: disable=no-value-for-parameter diff --git a/dotbot/rest.py b/dotbot/rest.py index ae1a70cb..b60bff72 100644 --- a/dotbot/rest.py +++ b/dotbot/rest.py @@ -12,7 +12,7 @@ import httpx from dotbot.logger import LOGGER, setup_logging -from dotbot.models import DotBotModel, DotBotQueryModel +from dotbot.models import DotBotMapSizeModel, DotBotModel, DotBotQueryModel from dotbot.protocol import ApplicationType @@ -69,10 +69,36 @@ async def fetch_dotbots( return [DotBotModel(**dotbot) for dotbot in response.json()] return [] + async def fetch_map_size(self) -> DotBotMapSizeModel: + """Fetch DotBot area map size.""" + try: + response = await self._client.get( + f"{self.base_url}/map_size", + headers={ + "Accept": "application/json", + }, + ) + except httpx.ConnectError as exc: + self._logger.warning(f"Failed to fetch map size: {exc}") + else: + if response.status_code != 200: + self._logger.warning( + f"Failed to fetch map size: {response} {response.text}" + ) + raise RuntimeError("Failed to fetch map size") + return DotBotMapSizeModel(**response.json()) + async def _send_command(self, address, application, resource, command): + self._logger.info( + "Sending command", + address=address, + application=application, + resource=resource, + command=command.__class__.__name__, + ) try: response = await self._client.put( - f"{self.base_url}/dotbots" f"/{address}/{application.value}/{resource}", + f"{self.base_url}/dotbots/{address}/{application.value}/{resource}", headers={ "Accept": "application/json", "Content-Type": "application/json", @@ -101,3 +127,24 @@ async def send_rgb_led_command(self, address, command): async def send_waypoint_command(self, address, application, command): """Send an waypoint command to a DotBot.""" await self._send_command(address, application, "waypoints", command) + + async def clear_position_history(self, address): + """Clear the position history of a DotBot.""" + try: + response = await self._client.put( + f"{self.base_url}/dotbots" f"/{address}/positions", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + }, + ) + except httpx.ConnectError as exc: + self._logger.warning(f"Failed to clear positions: {exc}") + return + if response.status_code != 200: + self._logger.error( + "Cannot clear positions", + response=str(response), + status_code=response.status_code, + content=str(response.text), + ) diff --git a/dotbot/tests/test_controller.py b/dotbot/tests/test_controller.py index b8ccf5d9..61987464 100644 --- a/dotbot/tests/test_controller.py +++ b/dotbot/tests/test_controller.py @@ -2,7 +2,7 @@ import asyncio import time -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest from dotbot_utils.hdlc import hdlc_encode @@ -203,7 +203,7 @@ async def start_simulator(): adapter="sailbot-simulator", network_id="0", gw_address="78", - controller_http_port=8000, + controller_http_port=8002, ) controller = Controller(settings) try: @@ -216,8 +216,7 @@ async def start_simulator(): @pytest.mark.filterwarnings("ignore::DeprecationWarning") -@patch("dotbot.controller.QrkeyController.start") -def test_controller_dotbot_simulator(_): +def test_controller_dotbot_simulator(): """Check controller called for dotbot simulator.""" async def start_simulator(): diff --git a/dotbot/tests/test_controller_app.py b/dotbot/tests/test_controller_app.py index 8b43c1bf..479d777c 100644 --- a/dotbot/tests/test_controller_app.py +++ b/dotbot/tests/test_controller_app.py @@ -54,10 +54,9 @@ def test_main_help(): @patch("dotbot_utils.serial_interface.serial.Serial.open") -@patch("dotbot.controller.QrkeyController") @patch("dotbot.version") @patch("dotbot.controller.Controller.run") -def test_main(run, version, _, __): +def test_main(run, version, _): version.return_value = "test" runner = CliRunner() result = runner.invoke(main) @@ -72,9 +71,8 @@ def test_main(run, version, _, __): @patch("dotbot_utils.serial_interface.serial.Serial.open") -@patch("dotbot.controller.QrkeyController") @patch("dotbot.controller.Controller.run") -def test_main_interrupts(run, _, __): +def test_main_interrupts(run, _): runner = CliRunner() run.side_effect = KeyboardInterrupt result = runner.invoke(main) @@ -93,9 +91,8 @@ def test_main_interrupts(run, _, __): @pytest.mark.skipif(sys.platform == "win32", reason="Doesn't work on Windows") @patch("dotbot_utils.serial_interface.serial.Serial.open") -@patch("dotbot.controller.QrkeyController") @patch("dotbot.controller_app.Controller") -def test_main_with_config(controller, _, __, tmp_path): +def test_main_with_config(controller, _, tmp_path): log_file = tmp_path / "logfile.log" config_file = tmp_path / "config.toml" config_file.write_text( diff --git a/dotbot/tests/test_server.py b/dotbot/tests/test_server.py index 35fb71b2..a564fb23 100644 --- a/dotbot/tests/test_server.py +++ b/dotbot/tests/test_server.py @@ -740,7 +740,6 @@ def test_ws_dotbots_commands( api.controller.send_payload.assert_not_called() -@pytest.mark.asyncio def test_ws_invalid_message_validation_error(): api.controller.dotbots = { "4242": DotBotModel( diff --git a/pyproject.toml b/pyproject.toml index 5bef8e5f..50593032 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,7 @@ dotbot-controller = "dotbot.controller_app:main" dotbot-edge-gateway = "dotbot.edge_gateway_app:main" dotbot-keyboard = "dotbot.keyboard:main" dotbot-joystick = "dotbot.joystick:main" +dotbot-qrkey = "dotbot.qrkey_app:main" [tool.ruff] lint.select = ["E", "F"] From 00e6f3d61e2fe84363bbe39024287982a35c3dc6 Mon Sep 17 00:00:00 2001 From: Alexandre Abadie Date: Mon, 9 Feb 2026 16:28:51 +0100 Subject: [PATCH 2/7] dotbot/dotbot_simulator: fix pdr limit --- dotbot/dotbot_simulator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotbot/dotbot_simulator.py b/dotbot/dotbot_simulator.py index a15c6250..756746c1 100644 --- a/dotbot/dotbot_simulator.py +++ b/dotbot/dotbot_simulator.py @@ -285,7 +285,7 @@ def flush(self): pass def _packet_delivered(self): - return random.randint(0, 100) < self.network_pdr + return random.randint(0, 100) <= self.network_pdr def handle_dotbot_frame(self, frame): """Send bytes to the fake serial, similar to the real gateway.""" From 56624a8a6855175fecd0bb6f8a965fd95286b954 Mon Sep 17 00:00:00 2001 From: Alexandre Abadie Date: Tue, 17 Feb 2026 06:54:20 +0100 Subject: [PATCH 3/7] dotbot/frontend: re-add rest support --- dotbot/frontend/src/App.js | 123 ++++----------------------- dotbot/frontend/src/QrKeyApp.js | 122 +++++++++++++++++++++++++++ dotbot/frontend/src/RestApp.js | 136 ++++++++++++++++++++++++++++++ dotbot/frontend/src/utils/rest.js | 49 +++++++++++ 4 files changed, 325 insertions(+), 105 deletions(-) create mode 100644 dotbot/frontend/src/QrKeyApp.js create mode 100644 dotbot/frontend/src/RestApp.js create mode 100644 dotbot/frontend/src/utils/rest.js diff --git a/dotbot/frontend/src/App.js b/dotbot/frontend/src/App.js index e2f5c301..15af30e1 100644 --- a/dotbot/frontend/src/App.js +++ b/dotbot/frontend/src/App.js @@ -1,120 +1,33 @@ import React from 'react'; import { useSearchParams } from 'react-router-dom'; -import { useCallback, useEffect, useState } from "react"; -import { useQrKey } from "qrkey"; -import { gps_distance_threshold, lh2_distance_threshold, NotificationType, RequestType } from "./utils/constants"; -import { gps_distance, lh2_distance } from "./utils/helpers"; +import { useEffect, useState } from "react"; -import DotBots from './DotBots'; -import QrKeyForm from './QrKeyForm'; - -import logger from './utils/logger'; -const log = logger.child({module: 'app'}); +import RestApp from "./RestApp"; +import QrKeyApp from "./QrKeyApp"; const App = () => { - const [searchParams, setSearchParams] = useSearchParams(); - const [message, setMessage] = useState(null); - const [areaSize, setAreaSize] = useState({height: 2000, width: 2000}); - const [dotbots, setDotbots] = useState([]); - - const [ready, clientId, mqttData, setMqttData, publish, publishCommand, sendRequest] = useQrKey({ - rootTopic: process.env.REACT_APP_ROOT_TOPIC, - setQrKeyMessage: setMessage, - searchParams: searchParams, - setSearchParams: setSearchParams, - }); - - const handleMessage = useCallback(() => { - log.info(`Handle received message: ${JSON.stringify(message)}`); - let payload = message.payload; - if (message.topic === `/reply/${clientId}`) { - // Received the list of dotbots - if (payload.request === RequestType.DotBots) { - setDotbots(payload.data); - } else if (payload.request === RequestType.AreaSize) { - setAreaSize(payload.data); - } - } else if (message.topic === `/notify`) { - // Process notifications - if (payload.cmd === NotificationType.Update && dotbots && dotbots.length > 0) { - let dotbotsTmp = dotbots.slice(); - for (let idx = 0; idx < dotbots.length; idx++) { - if (dotbots[idx].address === payload.data.address) { - if (payload.data.direction !== undefined && payload.data.direction !== null) { - dotbotsTmp[idx].direction = payload.data.direction; - } - if (payload.data.lh2_position !== undefined && payload.data.lh2_position !== null) { - const newPosition = { - x: payload.data.lh2_position.x, - y: payload.data.lh2_position.y - }; - console.log('distance threshold:', lh2_distance_threshold, lh2_distance(dotbotsTmp[idx].lh2_position, newPosition)); - if (dotbotsTmp[idx].lh2_position && (dotbotsTmp[idx].position_history.length === 0 || lh2_distance(dotbotsTmp[idx].lh2_position, newPosition) >= lh2_distance_threshold)) { - console.log('Adding to position history'); - dotbotsTmp[idx].position_history.push(newPosition); - } - dotbotsTmp[idx].lh2_position = newPosition; - } - if (payload.data.gps_position !== undefined && payload.data.gps_position !== null) { - const newPosition = { - latitude: payload.data.gps_position.latitude, - longitude: payload.data.gps_position.longitude - }; - if (dotbotsTmp[idx].gps_position !== undefined && dotbotsTmp[idx].gps_position !== null && (dotbotsTmp[idx].position_history.length === 0 || gps_distance(dotbotsTmp[idx].gps_position, newPosition) > gps_distance_threshold)) { - dotbotsTmp[idx].position_history.push(newPosition); - } - dotbotsTmp[idx].gps_position = newPosition; - } - if (payload.data.battery !== undefined) { - dotbotsTmp[idx].battery = payload.data.battery ; - } - setDotbots(dotbotsTmp); - } - } - } else if (payload.cmd === NotificationType.Reload) { - log.info("Reload notification"); - sendRequest({request: RequestType.DotBots, reply: `${clientId}`}); - } - } - setMessage(null); - },[clientId, dotbots, setDotbots, setAreaSize, sendRequest, message, setMessage] - ); - - useEffect(() => { - if (clientId) { - // Ask for the list of dotbots at startup - setTimeout(sendRequest, 100, ({request: RequestType.DotBots, reply: `${clientId}`})); - setTimeout(sendRequest, 200, ({request: RequestType.AreaSize, reply: `${clientId}`})); - } - }, [sendRequest, clientId] - ); + const [useQrKey, setUseQrKey] = useState(false); + const [searchParams] = useSearchParams(); useEffect(() => { - // Process incoming messages if any - if (!message) { - return; + const qrKeyParam = searchParams.get('use_qrkey'); + if (qrKeyParam && qrKeyParam.toLowerCase() === 'true') { + setUseQrKey(true); + localStorage.setItem('use_qrkey', 'true'); + } else if (!qrKeyParam || (qrKeyParam && qrKeyParam.toLowerCase() === 'false')) { + setUseQrKey(false); + localStorage.setItem('use_qrkey', 'false'); + } else if (localStorage.getItem('use_qrkey') === 'true') { + setUseQrKey(true); + } else { + localStorage.setItem('use_qrkey', 'false'); } - handleMessage(message.topic, message.payload); - }, [message, handleMessage] + }, [searchParams, setUseQrKey] ); return ( <> - {mqttData ? -
- -
- : - <> - {ready && } - - } + {useQrKey ? : } ); } diff --git a/dotbot/frontend/src/QrKeyApp.js b/dotbot/frontend/src/QrKeyApp.js new file mode 100644 index 00000000..8c69ad06 --- /dev/null +++ b/dotbot/frontend/src/QrKeyApp.js @@ -0,0 +1,122 @@ +import React from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useCallback, useEffect, useState } from "react"; +import { useQrKey } from "qrkey"; +import { gps_distance_threshold, lh2_distance_threshold, NotificationType, RequestType } from "./utils/constants"; +import { gps_distance, lh2_distance } from "./utils/helpers"; + +import DotBots from './DotBots'; +import QrKeyForm from './QrKeyForm'; + +import logger from './utils/logger'; +const log = logger.child({module: 'app'}); + +const QrKeyApp = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const [message, setMessage] = useState(null); + const [areaSize, setAreaSize] = useState({height: 2000, width: 2000}); + const [dotbots, setDotbots] = useState([]); + + const [ready, clientId, mqttData, setMqttData, publish, publishCommand, sendRequest] = useQrKey({ + rootTopic: process.env.REACT_APP_ROOT_TOPIC, + setQrKeyMessage: setMessage, + searchParams: searchParams, + setSearchParams: setSearchParams, + }); + + const handleMessage = useCallback(() => { + log.info(`Handle received message: ${JSON.stringify(message)}`); + let payload = message.payload; + if (message.topic === `/reply/${clientId}`) { + // Received the list of dotbots + if (payload.request === RequestType.DotBots) { + setDotbots(payload.data); + } else if (payload.request === RequestType.AreaSize) { + setAreaSize(payload.data); + } + } else if (message.topic === `/notify`) { + // Process notifications + if (payload.cmd === NotificationType.Update && dotbots && dotbots.length > 0) { + let dotbotsTmp = dotbots.slice(); + for (let idx = 0; idx < dotbots.length; idx++) { + if (dotbots[idx].address === payload.data.address) { + if (payload.data.direction !== undefined && payload.data.direction !== null) { + dotbotsTmp[idx].direction = payload.data.direction; + } + if (payload.data.lh2_position !== undefined && payload.data.lh2_position !== null) { + const newPosition = { + x: payload.data.lh2_position.x, + y: payload.data.lh2_position.y + }; + console.log('distance threshold:', lh2_distance_threshold, lh2_distance(dotbotsTmp[idx].lh2_position, newPosition)); + if (dotbotsTmp[idx].lh2_position && (dotbotsTmp[idx].position_history.length === 0 || lh2_distance(dotbotsTmp[idx].lh2_position, newPosition) >= lh2_distance_threshold)) { + console.log('Adding to position history'); + dotbotsTmp[idx].position_history.push(newPosition); + } + dotbotsTmp[idx].lh2_position = newPosition; + } + if (payload.data.gps_position !== undefined && payload.data.gps_position !== null) { + const newPosition = { + latitude: payload.data.gps_position.latitude, + longitude: payload.data.gps_position.longitude + }; + if (dotbotsTmp[idx].gps_position !== undefined && dotbotsTmp[idx].gps_position !== null && (dotbotsTmp[idx].position_history.length === 0 || gps_distance(dotbotsTmp[idx].gps_position, newPosition) > gps_distance_threshold)) { + dotbotsTmp[idx].position_history.push(newPosition); + } + dotbotsTmp[idx].gps_position = newPosition; + } + if (payload.data.battery !== undefined) { + dotbotsTmp[idx].battery = payload.data.battery ; + } + setDotbots(dotbotsTmp); + } + } + } else if (payload.cmd === NotificationType.Reload) { + log.info("Reload notification"); + sendRequest({request: RequestType.DotBots, reply: `${clientId}`}); + } + } + setMessage(null); + },[clientId, dotbots, setDotbots, setAreaSize, sendRequest, message, setMessage] + ); + + useEffect(() => { + if (clientId) { + // Ask for the list of dotbots at startup + setTimeout(sendRequest, 100, ({request: RequestType.DotBots, reply: `${clientId}`})); + setTimeout(sendRequest, 200, ({request: RequestType.AreaSize, reply: `${clientId}`})); + } + }, [sendRequest, clientId] + ); + + useEffect(() => { + // Process incoming messages if any + if (!message) { + return; + } + handleMessage(message.topic, message.payload); + }, [message, handleMessage] + ); + + return ( + <> + {mqttData ? +
+ +
+ : + <> + {ready && } + + } + + ); +} + +export default QrKeyApp; diff --git a/dotbot/frontend/src/RestApp.js b/dotbot/frontend/src/RestApp.js new file mode 100644 index 00000000..f83d6f38 --- /dev/null +++ b/dotbot/frontend/src/RestApp.js @@ -0,0 +1,136 @@ +import React from 'react'; +import { useCallback, useEffect, useState } from "react"; +import { gps_distance_threshold, lh2_distance_threshold, NotificationType } from "./utils/constants"; +import { gps_distance, lh2_distance } from "./utils/helpers"; + +import useWebSocket from 'react-use-websocket'; +import { apiFetchDotbots, apiFetchMapSize, apiUpdateMoveRaw, apiUpdateRgbLed, apiUpdateWaypoints, apiClearPositionsHistory } from "./utils/rest"; +import DotBots from './DotBots'; + +import logger from './utils/logger'; +const log = logger.child({module: 'app'}); + +const RestApp = () => { + const [areaSize, setAreaSize] = useState(undefined); + const [dotbots, setDotbots] = useState([]); + + const websocketUrl = `ws://localhost:8000/controller/ws/status`; + + const fetchDotBots = useCallback(async () => { + const data = await apiFetchDotbots().catch(error => console.log(error)); + setDotbots(data); + }, [setDotbots] + ); + + const fetchAreaSize = useCallback(async () => { + const data = await apiFetchMapSize().catch(error => console.log(error)); + setAreaSize(data); + }, [setAreaSize] + ); + + const publishCommand = useCallback((address, application, command, data) => { + if (command === "move_raw") { + return apiUpdateMoveRaw(address, application, data.left_x, data.left_y, data.right_x, data.right_y).catch(error => console.log(error)); + } else if (command === "rgb_led") { + return apiUpdateRgbLed(address, application, data.red, data.green, data.blue).catch(error => console.log(error)); + } else if (command === "waypoints") { + return apiUpdateWaypoints(address, application, data.waypoints, data.threshold).catch(error => console.log(error)); + } else if (command === "clear_positions_history") { + return apiClearPositionsHistory(address).catch(error => console.log(error)); + } + }, []); + + const publish = useCallback((topic, message) => { + log.info(`Publishing message: ${message} to topic: ${topic}`); + }, []); + +const onWsOpen = () => { + log.info('websocket opened'); + fetchDotBots(); + }; + + const onWsMessage = (event) => { + const message = JSON.parse(event.data); + if (message.cmd === NotificationType.Reload) { + fetchDotBots(); + } + if (message.cmd === NotificationType.Update && dotbots && dotbots.length > 0) { + let dotbotsTmp = dotbots.slice(); + for (let idx = 0; idx < dotbots.length; idx++) { + if (dotbots[idx].address === message.data.address) { + if (message.data.direction !== undefined && message.data.direction !== null) { + dotbotsTmp[idx].direction = message.data.direction; + } + if (message.data.wind_angle !== undefined && message.data.wind_angle !== null) { + dotbotsTmp[idx].wind_angle = message.data.wind_angle; + } + if (message.data.rudder_angle !== undefined && message.data.rudder_angle !== null) { + dotbotsTmp[idx].rudder_angle = message.data.rudder_angle; + } + if (message.data.sail_angle !== undefined && message.data.sail_angle !== null) { + dotbotsTmp[idx].sail_angle = message.data.sail_angle; + } + if (message.data.lh2_position !== undefined && message.data.lh2_position !== null) { + const newPosition = { + x: message.data.lh2_position.x, + y: message.data.lh2_position.y + }; + if (dotbotsTmp[idx].lh2_position !== undefined && dotbotsTmp[idx].lh2_position !== null && (dotbotsTmp[idx].position_history.length === 0 || lh2_distance(dotbotsTmp[idx].lh2_position, newPosition) > lh2_distance_threshold)) { + dotbotsTmp[idx].position_history.push(newPosition); + } + dotbotsTmp[idx].lh2_position = newPosition; + } + if (message.data.gps_position !== undefined && message.data.gps_position !== null) { + const newPosition = { + latitude: message.data.gps_position.latitude, + longitude: message.data.gps_position.longitude + }; + if (dotbotsTmp[idx].gps_position !== undefined && dotbotsTmp[idx].gps_position !== null && (dotbotsTmp[idx].position_history.length === 0 || gps_distance(dotbotsTmp[idx].gps_position, newPosition) > gps_distance_threshold)) { + dotbotsTmp[idx].position_history.push(newPosition); + } + dotbotsTmp[idx].gps_position = newPosition; + } + if (payload.data.battery !== undefined) { + dotbotsTmp[idx].battery = payload.data.battery; + } + setDotbots(dotbotsTmp); + } + } + } + }; + + useWebSocket(websocketUrl, { + onOpen: () => onWsOpen(), + onClose: () => log.warn("websocket closed"), + onMessage: (event) => onWsMessage(event), + shouldReconnect: (event) => true, + }); + + useEffect(() => { + if (!dotbots) { + fetchDotBots(); + } + if (!areaSize) { + fetchAreaSize(); + } + }, [dotbots, areaSize, fetchDotBots, fetchAreaSize] + ); + + return ( + <> + {areaSize && +
+ +
+ } + + ); +} + +export default RestApp; diff --git a/dotbot/frontend/src/utils/rest.js b/dotbot/frontend/src/utils/rest.js new file mode 100644 index 00000000..6707f253 --- /dev/null +++ b/dotbot/frontend/src/utils/rest.js @@ -0,0 +1,49 @@ + +import axios from 'axios'; + +export const API_URL = "http://localhost:8000"; + +export const apiFetchDotbots = async () => { + return await axios.get( + `${API_URL}/controller/dotbots`, + ).then(res => res.data); +} + +export const apiFetchMapSize = async () => { + return await axios.get( + `${API_URL}/controller/map_size`, + ).then(res => res.data); +} + +export const apiUpdateMoveRaw = async (address, application, left_x, left_y, right_x, right_y) => { + const command = { left_x: parseInt(left_x), left_y: parseInt(left_y), right_x: parseInt(right_x), right_y: parseInt(right_y) }; + return await axios.put( + `${API_URL}/controller/dotbots/${address}/${application}/move_raw`, + command, + { headers: { 'Content-Type': 'application/json' } } + ); +} + +export const apiUpdateRgbLed = async (address, application, red, green, blue) => { + const command = { red: red, green: green, blue: blue }; + return await axios.put( + `${API_URL}/controller/dotbots/${address}/${application}/rgb_led`, + command, + { headers: { 'Content-Type': 'application/json' } } + ); +} + +export const apiUpdateWaypoints = async (address, application, waypoints, threshold) => { + return await axios.put( + `${API_URL}/controller/dotbots/${address}/${application}/waypoints`, + {threshold: threshold, waypoints: waypoints}, + { headers: { 'Content-Type': 'application/json' } } + ); +} + +export const apiClearPositionsHistory = async (address) => { + return await axios.delete( + `${API_URL}/controller/dotbots/${address}/positions`, + { headers: { 'Content-Type': 'application/json' } } + ); +} From e07da0e67259aca25bae755393129a0c35db9534 Mon Sep 17 00:00:00 2001 From: Alexandre Abadie Date: Tue, 17 Feb 2026 11:00:01 +0100 Subject: [PATCH 4/7] dotbot/frontend: dotbot/server: fix remaining bugs in UI (Rest mode) --- dotbot/frontend/src/DotBotItem.js | 8 ++++---- dotbot/frontend/src/DotBots.js | 32 +++++++++++++++++-------------- dotbot/frontend/src/QrKeyApp.js | 2 +- dotbot/frontend/src/RestApp.js | 20 +++++++++---------- dotbot/frontend/src/index.js | 5 +++-- dotbot/frontend/src/utils/rest.js | 8 ++++++++ dotbot/server.py | 9 +++++++++ 7 files changed, 53 insertions(+), 31 deletions(-) diff --git a/dotbot/frontend/src/DotBotItem.js b/dotbot/frontend/src/DotBotItem.js index 1b794217..d6474a62 100644 --- a/dotbot/frontend/src/DotBotItem.js +++ b/dotbot/frontend/src/DotBotItem.js @@ -4,7 +4,7 @@ import { Joystick } from "./Joystick"; import { dotbotStatuses, dotbotBadgeStatuses } from "./utils/constants"; import logger from './utils/logger'; -const log = logger.child({module: 'dotbot-item'}); +const log = logger.child({module: 'DotBotItem'}); export const DotBotItem = ({dotbot, publishCommand, updateActive, applyWaypoints, clearWaypoints, updateWaypointThreshold, clearPositionsHistory}) => { @@ -100,11 +100,11 @@ export const DotBotItem = ({dotbot, publishCommand, updateActive, applyWaypoints <>
-

- Autonomous mode: +

+
Autonomous navigation
-

+

{`Target threshold: ${dotbot.waypoints_threshold}`}

diff --git a/dotbot/frontend/src/DotBots.js b/dotbot/frontend/src/DotBots.js index a4c9d262..219811bd 100644 --- a/dotbot/frontend/src/DotBots.js +++ b/dotbot/frontend/src/DotBots.js @@ -9,6 +9,8 @@ import { SailBotsMap } from "./SailBotsMap"; import { XGOItem } from "./XGOItem"; import { ApplicationType, inactiveAddress, maxWaypoints, maxPositionHistory } from "./utils/constants"; +import logger from './utils/logger'; +const log = logger.child({module: 'DotBots'}); const DotBots = ({ dotbots, areaSize, updateDotbots, publishCommand, publish }) => { const [ activeDotbot, setActiveDotbot ] = useState(inactiveAddress); @@ -21,6 +23,7 @@ const DotBots = ({ dotbots, areaSize, updateDotbots, publishCommand, publish }) const backspace = useKeyPress("Backspace"); const updateActive = useCallback(async (address) => { + log.info(`Updating active dotbot to ${address}`); setActiveDotbot(address); }, [setActiveDotbot] ); @@ -132,23 +135,24 @@ const DotBots = ({ dotbots, areaSize, updateDotbots, publishCommand, publish }) }; useEffect(() => { - - if (dotbots && control && enter) { - if (activeDotbot !== inactiveAddress) { - for (let idx = 0; idx < dotbots.length; idx++) { - if (dotbots[idx].address === activeDotbot) { - applyWaypoints(activeDotbot, dotbots[idx].application); - break; + if (dotbots && control) { + if (enter) { + if (activeDotbot !== inactiveAddress) { + for (let idx = 0; idx < dotbots.length; idx++) { + if (dotbots[idx].address === activeDotbot) { + applyWaypoints(activeDotbot, dotbots[idx].application); + break; + } } } } - } - if (dotbots && control && backspace) { - if (activeDotbot !== inactiveAddress) { - for (let idx = 0; idx < dotbots.length; idx++) { - if (dotbots[idx].address === activeDotbot) { - clearWaypoints(activeDotbot, dotbots[idx].application); - break; + if (backspace) { + if (activeDotbot !== inactiveAddress) { + for (let idx = 0; idx < dotbots.length; idx++) { + if (dotbots[idx].address === activeDotbot) { + clearWaypoints(activeDotbot, dotbots[idx].application); + break; + } } } } diff --git a/dotbot/frontend/src/QrKeyApp.js b/dotbot/frontend/src/QrKeyApp.js index 8c69ad06..ec66afce 100644 --- a/dotbot/frontend/src/QrKeyApp.js +++ b/dotbot/frontend/src/QrKeyApp.js @@ -9,7 +9,7 @@ import DotBots from './DotBots'; import QrKeyForm from './QrKeyForm'; import logger from './utils/logger'; -const log = logger.child({module: 'app'}); +const log = logger.child({module: 'QrKeyApp'}); const QrKeyApp = () => { const [searchParams, setSearchParams] = useSearchParams(); diff --git a/dotbot/frontend/src/RestApp.js b/dotbot/frontend/src/RestApp.js index f83d6f38..74007635 100644 --- a/dotbot/frontend/src/RestApp.js +++ b/dotbot/frontend/src/RestApp.js @@ -8,7 +8,7 @@ import { apiFetchDotbots, apiFetchMapSize, apiUpdateMoveRaw, apiUpdateRgbLed, ap import DotBots from './DotBots'; import logger from './utils/logger'; -const log = logger.child({module: 'app'}); +const log = logger.child({module: 'RestApp'}); const RestApp = () => { const [areaSize, setAreaSize] = useState(undefined); @@ -28,17 +28,17 @@ const RestApp = () => { }, [setAreaSize] ); - const publishCommand = useCallback((address, application, command, data) => { + const publishCommand = async (address, application, command, data) => { if (command === "move_raw") { - return apiUpdateMoveRaw(address, application, data.left_x, data.left_y, data.right_x, data.right_y).catch(error => console.log(error)); + return await apiUpdateMoveRaw(address, application, data.left_x, data.left_y, data.right_x, data.right_y).catch(error => console.log(error)); } else if (command === "rgb_led") { - return apiUpdateRgbLed(address, application, data.red, data.green, data.blue).catch(error => console.log(error)); + return await apiUpdateRgbLed(address, application, data.red, data.green, data.blue).catch(error => console.log(error)); } else if (command === "waypoints") { - return apiUpdateWaypoints(address, application, data.waypoints, data.threshold).catch(error => console.log(error)); - } else if (command === "clear_positions_history") { - return apiClearPositionsHistory(address).catch(error => console.log(error)); + return await apiUpdateWaypoints(address, application, data.waypoints, data.threshold).catch(error => console.log(error)); + } else if (command === "clear_position_history") { + return await apiClearPositionsHistory(address).catch(error => console.log(error)); } - }, []); + }; const publish = useCallback((topic, message) => { log.info(`Publishing message: ${message} to topic: ${topic}`); @@ -90,8 +90,8 @@ const onWsOpen = () => { } dotbotsTmp[idx].gps_position = newPosition; } - if (payload.data.battery !== undefined) { - dotbotsTmp[idx].battery = payload.data.battery; + if (message.data.battery !== undefined) { + dotbotsTmp[idx].battery = message.data.battery; } setDotbots(dotbotsTmp); } diff --git a/dotbot/frontend/src/index.js b/dotbot/frontend/src/index.js index 852d4f55..3b4b848c 100644 --- a/dotbot/frontend/src/index.js +++ b/dotbot/frontend/src/index.js @@ -5,13 +5,14 @@ import { RouterProvider, } from "react-router-dom"; import App from './App'; -import logger from './utils/logger'; import 'bootstrap/dist/css/bootstrap.css'; import 'bootstrap-icons/font/bootstrap-icons.css'; import 'bootstrap/dist/js/bootstrap.bundle.min'; -logger.info(`Starting dotbot frontend`); +import logger from './utils/logger'; +const log = logger.child({module: 'index'}); +log.info(`Starting dotbot frontend`); const router = createBrowserRouter([ { diff --git a/dotbot/frontend/src/utils/rest.js b/dotbot/frontend/src/utils/rest.js index 6707f253..ae372c33 100644 --- a/dotbot/frontend/src/utils/rest.js +++ b/dotbot/frontend/src/utils/rest.js @@ -1,21 +1,26 @@ import axios from 'axios'; +import logger from './logger'; +const log = logger.child({module: 'Rest'}); export const API_URL = "http://localhost:8000"; export const apiFetchDotbots = async () => { + log.info("Fetching dotbots from API");; return await axios.get( `${API_URL}/controller/dotbots`, ).then(res => res.data); } export const apiFetchMapSize = async () => { + log.info("Fetching map size from API");; return await axios.get( `${API_URL}/controller/map_size`, ).then(res => res.data); } export const apiUpdateMoveRaw = async (address, application, left_x, left_y, right_x, right_y) => { + log.info(`Setting move raw for dotbot ${address} with values (${left_x}, ${left_y}, ${right_x}, ${right_y})`); const command = { left_x: parseInt(left_x), left_y: parseInt(left_y), right_x: parseInt(right_x), right_y: parseInt(right_y) }; return await axios.put( `${API_URL}/controller/dotbots/${address}/${application}/move_raw`, @@ -25,6 +30,7 @@ export const apiUpdateMoveRaw = async (address, application, left_x, left_y, rig } export const apiUpdateRgbLed = async (address, application, red, green, blue) => { + log.info(`Setting RGB LED for dotbot ${address} with values (${red}, ${green}, ${blue})`); const command = { red: red, green: green, blue: blue }; return await axios.put( `${API_URL}/controller/dotbots/${address}/${application}/rgb_led`, @@ -34,6 +40,7 @@ export const apiUpdateRgbLed = async (address, application, red, green, blue) => } export const apiUpdateWaypoints = async (address, application, waypoints, threshold) => { + log.info(`Setting waypoints for dotbot ${address} with command (${JSON.stringify(waypoints)}, ${threshold})`); return await axios.put( `${API_URL}/controller/dotbots/${address}/${application}/waypoints`, {threshold: threshold, waypoints: waypoints}, @@ -42,6 +49,7 @@ export const apiUpdateWaypoints = async (address, application, waypoints, thresh } export const apiClearPositionsHistory = async (address) => { + log.info(`Clearing positions history for dotbot ${address}`); return await axios.delete( `${API_URL}/controller/dotbots/${address}/positions`, { headers: { 'Content-Type': 'application/json' } } diff --git a/dotbot/server.py b/dotbot/server.py index b12e4bff..a3f77b58 100644 --- a/dotbot/server.py +++ b/dotbot/server.py @@ -114,6 +114,9 @@ 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): @@ -140,6 +143,9 @@ async def dotbots_rgb_led( 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) + ) def _dotbots_rgb_led(address: str, command: DotBotRgbLedCommandModel): @@ -226,6 +232,9 @@ async def dotbot_positions_history_clear(address: str): if address not in api.controller.dotbots: 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) + ) @api.get( From 934d063353cbd10d56b49531d187c878412bf447 Mon Sep 17 00:00:00 2001 From: Alexandre Abadie Date: Tue, 17 Feb 2026 11:40:21 +0100 Subject: [PATCH 5/7] pyproject.toml: bump qrkey dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 50593032..57420ee6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ "pygame >= 2.6.1", "pynput >= 1.8.1", "pyserial >= 3.5", - "qrkey >= 0.12.0", + "qrkey >= 0.12.1", "structlog >= 24.4.0", "uvicorn >= 0.32.0", "websockets >= 13.1.0", From 7ceda34bc023c0dac4bf280233aa63832658e79a Mon Sep 17 00:00:00 2001 From: Alexandre Abadie Date: Tue, 17 Feb 2026 13:57:43 +0100 Subject: [PATCH 6/7] dotbot/qrkey_app: add basic tests --- dotbot/__init__.py | 2 +- dotbot/qrkey_app.py | 3 +- dotbot/rest.py | 18 +++---- dotbot/tests/test_qrkey_app.py | 96 ++++++++++++++++++++++++++++++++++ tox.ini | 1 + 5 files changed, 108 insertions(+), 12 deletions(-) create mode 100644 dotbot/tests/test_qrkey_app.py diff --git a/dotbot/__init__.py b/dotbot/__init__.py index 167ea244..691e8546 100644 --- a/dotbot/__init__.py +++ b/dotbot/__init__.py @@ -11,7 +11,7 @@ NETWORK_ID_DEFAULT = "0000" CONTROLLER_HTTP_PROTOCOL_DEFAULT = "http" CONTROLLER_HTTP_HOSTNAME_DEFAULT = "localhost" -CONTROLLER_HTTP_PORT_DEFAULT = "8000" +CONTROLLER_HTTP_PORT_DEFAULT = 8000 CONTROLLER_ADAPTER_DEFAULT = "serial" MQTT_HOST_DEFAULT = "localhost" MQTT_PORT_DEFAULT = 1883 diff --git a/dotbot/qrkey_app.py b/dotbot/qrkey_app.py index 16fa5566..5a9e9898 100644 --- a/dotbot/qrkey_app.py +++ b/dotbot/qrkey_app.py @@ -102,8 +102,7 @@ def main( ["console", "file"], ) try: - loop = asyncio.get_event_loop() - loop.run_until_complete(cli(client_settings)) + asyncio.run(cli(client_settings)) except (SystemExit, KeyboardInterrupt): sys.exit(0) diff --git a/dotbot/rest.py b/dotbot/rest.py index b60bff72..b3e67eb3 100644 --- a/dotbot/rest.py +++ b/dotbot/rest.py @@ -16,15 +16,6 @@ from dotbot.protocol import ApplicationType -@asynccontextmanager -async def rest_client(host, port, https): - client = RestClient(host, port, https) - try: - yield client - finally: - await client.close() - - class RestClient: """Client to interact with the controller REST API.""" @@ -148,3 +139,12 @@ async def clear_position_history(self, address): status_code=response.status_code, content=str(response.text), ) + + +@asynccontextmanager +async def rest_client(host, port, https): + client = RestClient(host, port, https) + try: + yield client + finally: + await client.close() diff --git a/dotbot/tests/test_qrkey_app.py b/dotbot/tests/test_qrkey_app.py new file mode 100644 index 00000000..9c4cdcd7 --- /dev/null +++ b/dotbot/tests/test_qrkey_app.py @@ -0,0 +1,96 @@ +"""Test module for the qrkey client main function.""" + +import asyncio +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from dotbot.qrkey import QrKeyClientSettings +from dotbot.qrkey_app import main + +MAIN_HELP_EXPECTED = """Usage: main [OPTIONS] + + DotBot QrKey client. + +Options: + -H, --http-host INTEGER Controller HTTP host of the REST API. Defaults + to 'localhost' + -P, --http-port INTEGER Controller HTTP port of the REST API. Defaults + to '8000' + -w, --webbrowser Open a web browser automatically + -v, --verbose Run in verbose mode (all payloads received are + printed in terminal) + --log-level [debug|info|warning|error] + Logging level. Defaults to info + --log-output PATH Filename where logs are redirected + --config-path FILE Path to a .toml configuration file. + --help Show this message and exit. +""" + + +def test_main_help(): + runner = CliRunner() + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert result.output == MAIN_HELP_EXPECTED + + +@pytest.fixture +def cli_mock(): + async def fake_cli(settings: QrKeyClientSettings): + asyncio.sleep(0.1) # simulate some async work + return None + + with patch("dotbot.qrkey_app.cli") as cli: + cli.return_value = fake_cli + yield cli + + +def test_main(cli_mock): + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code == 0 + assert "Welcome to the DotBot QrKey client" in result.output + cli_mock.assert_called_once() + args, _ = cli_mock.call_args + assert isinstance(args[0], QrKeyClientSettings) + assert args[0].http_host == "localhost" + assert args[0].http_port == 8000 + assert args[0].webbrowser is False + assert args[0].verbose is False + + +def test_main_interrupts(cli_mock): + runner = CliRunner() + cli_mock.side_effect = KeyboardInterrupt + result = runner.invoke(main, []) + assert result.exit_code == 0 + + cli_mock.side_effect = SystemExit + result = runner.invoke(main, []) + assert result.exit_code == 0 + + +def test_main_config_path(cli_mock, tmp_path): + config_path = tmp_path / "config.toml" + config_path.write_text( + """ +http_host = "testhost" +http_port = 1234 +webbrowser = true +verbose = true +""" + ) + + runner = CliRunner() + result = runner.invoke(main, ["--config-path", str(config_path)]) + assert result.exit_code == 0 + assert "Welcome to the DotBot QrKey client" in result.output + cli_mock.assert_called_once() + args, _ = cli_mock.call_args + assert isinstance(args[0], QrKeyClientSettings) + assert args[0].http_host == "testhost" + assert args[0].http_port == 1234 + assert args[0].webbrowser is True + assert args[0].verbose is True diff --git a/tox.ini b/tox.ini index 86dabc95..e41ce257 100644 --- a/tox.ini +++ b/tox.ini @@ -50,6 +50,7 @@ allowlist_externals= /usr/bin/bash commands= bash -exc "dotbot-controller --help" + bash -exc "dotbot-qrkey --help" [testenv:web] allowlist_externals= From 545dfb1b627831e930f4532cbb0b539bb63ca85e Mon Sep 17 00:00:00 2001 From: Alexandre Abadie Date: Tue, 17 Feb 2026 16:13:24 +0100 Subject: [PATCH 7/7] dotbot/tests: add initial tests for qrkey --- dotbot/tests/test_qrkey.py | 65 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 dotbot/tests/test_qrkey.py diff --git a/dotbot/tests/test_qrkey.py b/dotbot/tests/test_qrkey.py new file mode 100644 index 00000000..6edf1129 --- /dev/null +++ b/dotbot/tests/test_qrkey.py @@ -0,0 +1,65 @@ +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest +from websockets import exceptions as websockets_exceptions + +from dotbot.logger import setup_logging +from dotbot.qrkey import QrKeyClient, QrKeyClientSettings + + +class WebsocketMock: + def __init__(self): + self.send = AsyncMock() + self.recv = AsyncMock() + self.close = AsyncMock() + + async def __aenter__(self, *args, **kwargs): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + + +@pytest.fixture +def client(monkeypatch): + """Create a client instance with mocked websocket and qrkey clients.""" + + async def qrkey_controller_start_mock(*args, **kwargs): + await asyncio.sleep(0.5) # simulate some async work + raise websockets_exceptions.ConnectionClosedError(1000, None) + + qrkey_controller_mock = AsyncMock() + qrkey_controller_mock.start.side_effect = qrkey_controller_start_mock + monkeypatch.setattr( + "dotbot.qrkey.QrkeyController", lambda *args, **kwargs: qrkey_controller_mock + ) + websocket_mock = WebsocketMock() + + async def recv_side_effect(): + await asyncio.sleep(0.1) # simulate some delay in receiving messages + return json.dumps({"cmd": 2, "data": {"address": "test_bot"}}) + + websocket_mock.recv.side_effect = recv_side_effect + monkeypatch.setattr("dotbot.qrkey.connect", lambda *args, **kwargs: websocket_mock) + rest_client = MagicMock() + monkeypatch.setattr("dotbot.qrkey.RestClient", rest_client) + + settings = QrKeyClientSettings( + http_port=8001, + http_host="localhost", + webbrowser=False, + verbose=False, + ) + + _client = QrKeyClient(settings, rest_client) + + yield _client + + +@pytest.mark.asyncio +async def test_qrkey_client_basic(client): + """Test that the QrKeyClient can be instantiated and run without errors.""" + setup_logging(None, "debug", ["console"]) + await client.run()