diff --git a/dotbot/__init__.py b/dotbot/__init__.py
index 167ea24..691e854 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/controller.py b/dotbot/controller.py
index 2058c93..7bfc3ac 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/dotbot_simulator.py b/dotbot/dotbot_simulator.py
index a15c625..756746c 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."""
diff --git a/dotbot/frontend/src/App.js b/dotbot/frontend/src/App.js
index e2f5c30..15af30e 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/DotBotItem.js b/dotbot/frontend/src/DotBotItem.js
index 1b79421..d6474a6 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 a4c9d26..219811b 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
new file mode 100644
index 0000000..ec66afc
--- /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: 'QrKeyApp'});
+
+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 0000000..7400763
--- /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: 'RestApp'});
+
+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 = async (address, application, command, data) => {
+ if (command === "move_raw") {
+ 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 await apiUpdateRgbLed(address, application, data.red, data.green, data.blue).catch(error => console.log(error));
+ } else if (command === "waypoints") {
+ 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}`);
+ }, []);
+
+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 (message.data.battery !== undefined) {
+ dotbotsTmp[idx].battery = message.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/index.js b/dotbot/frontend/src/index.js
index 852d4f5..3b4b848 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
new file mode 100644
index 0000000..ae372c3
--- /dev/null
+++ b/dotbot/frontend/src/utils/rest.js
@@ -0,0 +1,57 @@
+
+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`,
+ command,
+ { headers: { 'Content-Type': 'application/json' } }
+ );
+}
+
+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`,
+ command,
+ { headers: { 'Content-Type': 'application/json' } }
+ );
+}
+
+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},
+ { headers: { 'Content-Type': 'application/json' } }
+ );
+}
+
+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/models.py b/dotbot/models.py
index a1ae70f..67859e7 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 0000000..5126f6f
--- /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 0000000..5a9e989
--- /dev/null
+++ b/dotbot/qrkey_app.py
@@ -0,0 +1,117 @@
+# 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:
+ asyncio.run(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 ae1a70c..b3e67eb 100644
--- a/dotbot/rest.py
+++ b/dotbot/rest.py
@@ -12,19 +12,10 @@
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
-@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."""
@@ -69,10 +60,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 +118,33 @@ 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),
+ )
+
+
+@asynccontextmanager
+async def rest_client(host, port, https):
+ client = RestClient(host, port, https)
+ try:
+ yield client
+ finally:
+ await client.close()
diff --git a/dotbot/server.py b/dotbot/server.py
index b12e4bf..a3f77b5 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(
diff --git a/dotbot/tests/test_controller.py b/dotbot/tests/test_controller.py
index b8ccf5d..6198746 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 8b43c1b..479d777 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_qrkey.py b/dotbot/tests/test_qrkey.py
new file mode 100644
index 0000000..6edf112
--- /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()
diff --git a/dotbot/tests/test_qrkey_app.py b/dotbot/tests/test_qrkey_app.py
new file mode 100644
index 0000000..9c4cdcd
--- /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/dotbot/tests/test_server.py b/dotbot/tests/test_server.py
index 35fb71b..a564fb2 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 5bef8e5..57420ee 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",
@@ -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"]
diff --git a/tox.ini b/tox.ini
index 86dabc9..e41ce25 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=