diff --git a/gcs/src/components/config/serialPorts.jsx b/gcs/src/components/config/serialPorts.jsx
new file mode 100644
index 000000000..1e2ab6c09
--- /dev/null
+++ b/gcs/src/components/config/serialPorts.jsx
@@ -0,0 +1,249 @@
+// Serial Ports Configuration Page
+import { useEffect, useMemo } from "react"
+import {
+ Table,
+ Select,
+ MultiSelect,
+ ScrollArea,
+ Text,
+ Tooltip,
+} from "@mantine/core"
+import { useListState } from "@mantine/hooks"
+
+// Custom components, helpers and data
+import apmParamDefsCopter from "../../../data/gen_apm_params_def_copter.json"
+import apmParamDefsPlane from "../../../data/gen_apm_params_def_plane.json"
+import { dec2bin } from "../../helpers/dataFormatters"
+
+import { useSelector, useDispatch } from "react-redux"
+import { selectAircraftType } from "../../redux/slices/droneInfoSlice"
+import {
+ emitGetSerialPortsConfig,
+ emitSetSerialPortConfigParam,
+ selectSerialPortsConfig,
+} from "../../redux/slices/configSlice"
+import { emitSetState } from "../../redux/slices/droneConnectionSlice"
+import { selectConnectedToDrone } from "../../redux/slices/droneConnectionSlice"
+
+// Bitmask Select component for OPTIONS field.
+function OptionsBitmaskSelect({ value, onChange, options }) {
+ const [selected, selectedHandler] = useListState([])
+
+ useEffect(() => {
+ parseBitmask(value)
+ }, [value])
+
+ function parseBitmask(bitmaskToParse) {
+ const binaryString = dec2bin(bitmaskToParse)
+ const selectedArray = []
+
+ binaryString
+ .split("")
+ .reverse()
+ .forEach((bit, index) => {
+ if (bit === "1") {
+ selectedArray.push(`${index}`)
+ }
+ })
+
+ selectedHandler.setState(selectedArray)
+ }
+
+ function createBitmask(value) {
+ const initialValue = 0
+ const bitmask = value.reduce(
+ (accumulator, currentValue) => accumulator + 2 ** parseInt(currentValue),
+ initialValue,
+ )
+ selectedHandler.setState(value)
+ onChange(bitmask)
+ }
+
+ const data = useMemo(
+ () =>
+ options
+ ? Object.keys(options).map((key) => ({
+ value: `${key}`,
+ label: `${options[key]}`,
+ }))
+ : [],
+ [options],
+ )
+
+ return (
+
+
+
+ )
+}
+
+export default function SerialPorts() {
+ const dispatch = useDispatch()
+ const aircraftType = useSelector(selectAircraftType)
+ const serialPortsConfig = useSelector(selectSerialPortsConfig)
+ const connected = useSelector(selectConnectedToDrone)
+
+ // Helper to get paramDef for a given param_id
+ function getParamDef(param_id) {
+ if (aircraftType === 1) return apmParamDefsPlane[param_id]
+ if (aircraftType === 2) return apmParamDefsCopter[param_id]
+ return undefined
+ }
+
+ // Helper to handle param change
+ function handleParamChange(param_id, value) {
+ // Guard against null/undefined values from Mantine components
+ if (value === null || value === undefined) {
+ return
+ }
+ dispatch(
+ emitSetSerialPortConfigParam({
+ param_id,
+ value: parseInt(value),
+ }),
+ )
+ }
+
+ // Build serial port rows
+ const serialPortRows = useMemo(
+ () =>
+ Object.entries(serialPortsConfig).map(([key, config]) => ({
+ number: Number(key.split("_")[1]),
+ protocol: config.protocol,
+ baud: config.baud,
+ options: config.options,
+ })),
+ [serialPortsConfig],
+ )
+
+ useEffect(() => {
+ if (connected) {
+ dispatch(emitSetState("config.serial_ports"))
+ dispatch(emitGetSerialPortsConfig())
+ }
+ }, [connected, dispatch])
+
+ return (
+
diff --git a/gcs/src/helpers/dataFormatters.js b/gcs/src/helpers/dataFormatters.js
index 5d7d8e45c..c82cdd32e 100644
--- a/gcs/src/helpers/dataFormatters.js
+++ b/gcs/src/helpers/dataFormatters.js
@@ -13,6 +13,10 @@ export function coordToInt(val) {
return parseInt(val * 1e7)
}
+export function dec2bin(dec) {
+ return (dec >>> 0).toString(2)
+}
+
export const dataFormatters = {
"ATTITUDE.pitch": radToDeg,
"ATTITUDE.roll": radToDeg,
diff --git a/gcs/src/redux/middleware/emitters.js b/gcs/src/redux/middleware/emitters.js
index 3752213bd..bdfb286a1 100644
--- a/gcs/src/redux/middleware/emitters.js
+++ b/gcs/src/redux/middleware/emitters.js
@@ -7,6 +7,7 @@ import {
emitGetGripperConfig,
emitGetGripperEnabled,
emitGetRcConfig,
+ emitGetSerialPortsConfig,
emitGetServoConfig,
emitRefreshFlightModeData,
emitSetFlightMode,
@@ -16,6 +17,7 @@ import {
emitSetGripperDisabled,
emitSetGripperEnabled,
emitSetRcConfigParam,
+ emitSetSerialPortConfigParam,
emitSetServoConfigParam,
emitTestAllMotors,
emitTestMotorSequence,
@@ -481,6 +483,19 @@ export function handleEmitters(socket, store, action) {
})
},
},
+ {
+ emitter: emitGetSerialPortsConfig,
+ callback: () => socket.socket.emit("get_serial_ports_config"),
+ },
+ {
+ emitter: emitSetSerialPortConfigParam,
+ callback: () => {
+ socket.socket.emit("set_serial_port_config_param", {
+ param_id: action.payload.param_id,
+ value: action.payload.value,
+ })
+ },
+ },
{
emitter: emitListFiles,
callback: () => {
diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js
index 7c3f9f595..e998fe427 100644
--- a/gcs/src/redux/middleware/socketMiddleware.js
+++ b/gcs/src/redux/middleware/socketMiddleware.js
@@ -63,11 +63,13 @@ import {
setRadioPwmChannels,
setRefreshingFlightModeData,
setRefreshingGripperConfigData,
+ setSerialPortsConfig,
setServoConfig,
setServoPwmOutputs,
setShowMotorTestWarningModal,
updateChannelsConfigParam,
updateGripperConfigParam,
+ updateSerialPortConfigParam,
updateServoConfigParam,
} from "../slices/configSlice.js"
import {
@@ -212,6 +214,9 @@ const ConfigSpecificSocketEvents = Object.freeze({
onSetServoConfigResult: "set_servo_config_result",
onBatchSetServoConfigResult: "batch_set_servo_config_result",
onTestServoPwmResult: "test_servo_result",
+ onSerialPortsConfig: "serial_ports_config",
+ onSetSerialPortConfigResult: "set_serial_port_config_result",
+ onBatchSetSerialPortConfigResult: "batch_set_serial_port_config_result",
})
const FtpSpecificSocketEvents = Object.freeze({
@@ -1393,6 +1398,52 @@ const socketMiddleware = (store) => {
},
)
+ socket.socket.on(
+ ConfigSpecificSocketEvents.onSerialPortsConfig,
+ (msg) => {
+ store.dispatch(setSerialPortsConfig(msg))
+ },
+ )
+
+ socket.socket.on(
+ ConfigSpecificSocketEvents.onSetSerialPortConfigResult,
+ (msg) => {
+ if (msg.success) {
+ showSuccessNotification(msg.message)
+ store.dispatch(
+ updateSerialPortConfigParam({
+ param_id: msg.param_id,
+ value: msg.value,
+ }),
+ )
+ } else {
+ showErrorNotification(msg.message)
+ }
+ },
+ )
+
+ socket.socket.on(
+ ConfigSpecificSocketEvents.onBatchSetSerialPortConfigResult,
+ (msg) => {
+ if (msg.success) {
+ showSuccessNotification(msg.message)
+ } else {
+ showErrorNotification(msg.message)
+ }
+
+ if (msg.data?.length > 0) {
+ for (const successfullySetParam of msg.data) {
+ store.dispatch(
+ updateSerialPortConfigParam({
+ param_id: successfullySetParam.param_id,
+ value: successfullySetParam.value,
+ }),
+ )
+ }
+ }
+ },
+ )
+
socket.socket.on(FtpSpecificSocketEvents.onListFilesResult, (msg) => {
store.dispatch(setLoadingListFiles(false))
if (msg.success) {
diff --git a/gcs/src/redux/slices/configSlice.js b/gcs/src/redux/slices/configSlice.js
index 32f71d0db..4957c563f 100644
--- a/gcs/src/redux/slices/configSlice.js
+++ b/gcs/src/redux/slices/configSlice.js
@@ -63,6 +63,7 @@ const configSlice = createSlice({
16: 0,
},
servoConfig: {},
+ serialPortsConfig: {},
},
reducers: {
setActiveTab: (state, action) => {
@@ -191,6 +192,26 @@ const configSlice = createSlice({
if (state.servoConfig[servoNum][paramType] === value) return
state.servoConfig[servoNum][paramType] = value
},
+ setSerialPortsConfig: (state, action) => {
+ if (action.payload === state.serialPortsConfig) return
+ state.serialPortsConfig = action.payload
+ },
+ updateSerialPortConfigParam: (state, action) => {
+ const { param_id, value } = action.payload
+ // Handle SERIALn params
+ const serialMatch = param_id.match(/^SERIAL(\d+)_(.+)$/)
+ if (serialMatch) {
+ const portKey = `SERIAL_${serialMatch[1]}`
+ const paramType = serialMatch[2].toLowerCase()
+ if (!state.serialPortsConfig[portKey]) {
+ state.serialPortsConfig[portKey] = {}
+ }
+ const validParamTypes = ["protocol", "baud", "options"]
+ if (!validParamTypes.includes(paramType)) return
+ if (state.serialPortsConfig[portKey][paramType] === value) return
+ state.serialPortsConfig[portKey][paramType] = value
+ }
+ },
// Emits
emitGetGripperEnabled: () => {},
@@ -214,6 +235,9 @@ const configSlice = createSlice({
emitSetServoConfigParam: () => {},
emitBatchSetServoConfigParams: () => {},
emitTestServoPwm: () => {},
+ emitGetSerialPortsConfig: () => {},
+ emitSetSerialPortConfigParam: () => {},
+ emitBatchSetSerialPortConfigParams: () => {},
},
selectors: {
selectActiveTab: (state) => state.activeTab,
@@ -236,6 +260,7 @@ const configSlice = createSlice({
selectRadioCalibrationModalOpen: (state) => state.radioCalibrationModalOpen,
selectServoPwmOutputs: (state) => state.servoPwmOutputs,
selectServoConfig: (state) => state.servoConfig,
+ selectSerialPortsConfig: (state) => state.serialPortsConfig,
},
})
@@ -262,6 +287,8 @@ export const {
setServoPwmOutputs,
setServoConfig,
updateServoConfigParam,
+ setSerialPortsConfig,
+ updateSerialPortConfigParam,
emitGetGripperEnabled,
emitSetGripperEnabled,
@@ -284,6 +311,9 @@ export const {
emitSetServoConfigParam,
emitBatchSetServoConfigParams,
emitTestServoPwm,
+ emitGetSerialPortsConfig,
+ emitSetSerialPortConfigParam,
+ emitBatchSetSerialPortConfigParams,
} = configSlice.actions
export const {
@@ -306,6 +336,7 @@ export const {
selectRadioCalibrationModalOpen,
selectServoPwmOutputs,
selectServoConfig,
+ selectSerialPortsConfig,
} = configSlice.selectors
export default configSlice
diff --git a/radio/app/controllers/serialPortsController.py b/radio/app/controllers/serialPortsController.py
new file mode 100644
index 000000000..d0c1b3420
--- /dev/null
+++ b/radio/app/controllers/serialPortsController.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from app.customTypes import Number
+
+if TYPE_CHECKING:
+ from app.drone import Drone
+
+
+class SerialPortsController:
+ def __init__(self, drone: Drone) -> None:
+ """
+ The Serial Ports controller handles all serial port config related actions.
+
+ Args:
+ drone (Drone): The main drone object
+ """
+ self.drone = drone
+ self.params: dict = {}
+ self.param_types: dict = {}
+
+ self.fetchParams()
+
+ def _getAndSetParam(
+ self, params_dict: dict, param_key: str, param_name: str
+ ) -> None:
+ """
+ Gets and set the value of a parameter inside a dictionary.
+
+ Args:
+ params_dict (dict): The dictionary to store the parameters
+ param_key (str): The key for the parameter within the dictionary
+ param_name (str): The name of the parameter
+ """
+ param = self.drone.paramsController.getSingleParam(param_name).get("data")
+ if param:
+ params_dict[param_key] = param.param_value
+ self.param_types[param_name] = param.param_type
+
+ def _getAndSetCachedParam(
+ self, params_dict: dict, param_key: str, param_name: str
+ ) -> None:
+ """
+ Gets and set the value of a cached parameter inside a dictionary.
+
+ Args:
+ params_dict (dict): The dictionary to store the parameters
+ param_key (str): The key for the parameter within the dictionary
+ param_name (str): The name of the parameter
+ """
+ cached_param = self.drone.paramsController.getCachedParam(param_name)
+ if cached_param:
+ params_dict[param_key] = cached_param.get("param_value")
+ # Store param_type for setConfigParam
+ param_type = cached_param.get("param_type")
+ if param_type is not None:
+ self.param_types[param_name] = param_type
+ # Don't try to fetch from drone - param may not exist on this firmware
+
+ def fetchParams(self) -> None:
+ """
+ Fetches the serial port parameters from the drone.
+ Tries SERIAL1-9 and only stores ports that exist.
+ """
+ self.drone.logger.debug("Fetching serial port parameters")
+
+ for port_number in range(1, 10):
+ port_params = self.params.get(f"SERIAL_{port_number}", {})
+
+ self._getAndSetParam(
+ port_params, "protocol", f"SERIAL{port_number}_PROTOCOL"
+ )
+
+ # if get protocol times out, skip the others. They dont exist
+ if "protocol" not in port_params:
+ continue
+
+ self._getAndSetParam(port_params, "baud", f"SERIAL{port_number}_BAUD")
+ self._getAndSetParam(port_params, "options", f"SERIAL{port_number}_OPTIONS")
+
+ self.params[f"SERIAL_{port_number}"] = port_params
+
+ def getConfig(self) -> dict:
+ """
+ Returns the serial port configuration with the cached parameters.
+
+ Returns:
+ dict: The serial port configuration
+ """
+ for port_key, port_params in self.params.items():
+ port_number = port_key.split("_")[1]
+
+ self._getAndSetCachedParam(
+ port_params, "protocol", f"SERIAL{port_number}_PROTOCOL"
+ )
+ self._getAndSetCachedParam(port_params, "baud", f"SERIAL{port_number}_BAUD")
+ self._getAndSetCachedParam(
+ port_params, "options", f"SERIAL{port_number}_OPTIONS"
+ )
+
+ return self.params
+
+ def setConfigParam(self, param_id: str, value: Number) -> bool:
+ """
+ Sets a serial port configuration related parameter on the drone.
+ """
+ param_type = self.param_types.get(param_id)
+
+ return self.drone.paramsController.setParam(param_id, value, param_type)
diff --git a/radio/app/drone.py b/radio/app/drone.py
index 57e6f7e72..868351eb4 100644
--- a/radio/app/drone.py
+++ b/radio/app/drone.py
@@ -24,6 +24,7 @@
from app.controllers.navController import NavController
from app.controllers.paramsController import ParamsController
from app.controllers.rcController import RcController
+from app.controllers.serialPortsController import SerialPortsController
from app.controllers.servoController import ServoController
from app.customTypes import Number, Response, VehicleType
from app.utils import (
@@ -116,7 +117,8 @@ def __init__(
"Setting up the mission controller",
"Setting up the frame controller",
"Setting up the RC controller",
- "Setting up the Servo Controller",
+ "Setting up the Servo controller",
+ "Setting up the Serial Ports controller",
"Setting up the nav controller",
"Setting up the FTP controller",
"Connection complete",
@@ -307,11 +309,17 @@ def setupControllers(self) -> None:
self.servoController = ServoController(self)
self.sendConnectionStatusUpdate(12)
- self.navController = NavController(self)
+ self.serialPortsController = SerialPortsController(self)
self.sendConnectionStatusUpdate(13)
+ self.navController = NavController(self)
+
+ self.sendConnectionStatusUpdate(14)
self.ftpController = FtpController(self)
+ # Final phase: connection complete
+ self.sendConnectionStatusUpdate(15)
+
def sendConnectionStatusUpdate(self, msg_index):
total_msgs = len(self.connection_phases)
if msg_index < 0 or msg_index >= total_msgs:
diff --git a/radio/app/endpoints/__init__.py b/radio/app/endpoints/__init__.py
index 5f8b74e17..1a8e240f7 100644
--- a/radio/app/endpoints/__init__.py
+++ b/radio/app/endpoints/__init__.py
@@ -13,6 +13,7 @@
from . import nav as nav
from . import params as params
from . import rc as rc
+from . import serialPorts as serialPorts
from . import servo as servo
from . import simulation as simulation
from . import states as states
diff --git a/radio/app/endpoints/serialPorts.py b/radio/app/endpoints/serialPorts.py
new file mode 100644
index 000000000..aa6033116
--- /dev/null
+++ b/radio/app/endpoints/serialPorts.py
@@ -0,0 +1,75 @@
+import app.droneStatus as droneStatus
+from app import logger, socketio
+from app.customTypes import SetConfigParam
+from app.utils import notConnectedError
+
+
+@socketio.on("get_serial_ports_config")
+def getSerialPortsConfig() -> None:
+ """
+ Sends the serial ports config to the frontend, only works when
+ the serial ports config screen is loaded.
+ """
+ if droneStatus.state != "config.serial_ports":
+ socketio.emit(
+ "params_error",
+ {
+ "message": "You must be on the serial ports config screen to access the serial ports config."
+ },
+ )
+ logger.debug(f"Current state: {droneStatus.state}")
+ return
+
+ if not droneStatus.drone:
+ return notConnectedError(action="get the serial ports config")
+
+ serial_ports_config = droneStatus.drone.serialPortsController.getConfig()
+
+ socketio.emit(
+ "serial_ports_config",
+ serial_ports_config,
+ )
+
+
+@socketio.on("set_serial_port_config_param")
+def setSerialPortConfigParam(data: SetConfigParam) -> None:
+ """
+ Sets a serial port config parameter on the drone.
+ """
+ if droneStatus.state != "config.serial_ports":
+ socketio.emit(
+ "params_error",
+ {
+ "message": "You must be on the serial ports config screen to set serial port config parameters."
+ },
+ )
+ logger.debug(f"Current state: {droneStatus.state}")
+ return
+
+ if not droneStatus.drone:
+ return notConnectedError(action="set a serial port config parameter")
+
+ param_id = data.get("param_id", None)
+ value = data.get("value", None)
+
+ if param_id is None or value is None:
+ socketio.emit(
+ "params_error",
+ {"message": "Param ID and value must be specified."},
+ )
+ return
+
+ success = droneStatus.drone.serialPortsController.setConfigParam(param_id, value)
+ if success:
+ result = {
+ "success": True,
+ "message": f"Parameter {param_id} successfully set to {value}.",
+ "param_id": param_id,
+ "value": value,
+ }
+ else:
+ result = {
+ "success": False,
+ "message": f"Failed to set parameter {param_id} to {value}.",
+ }
+ socketio.emit("set_serial_port_config_result", result)
diff --git a/radio/tests/test_autopilot.py b/radio/tests/test_autopilot.py
index 50536afc4..b95a7120c 100644
--- a/radio/tests/test_autopilot.py
+++ b/radio/tests/test_autopilot.py
@@ -2,7 +2,7 @@
from flask_socketio.test_client import SocketIOTestClient
-@pytest.mark.timeout(30)
+@pytest.mark.timeout(40)
def test_reboot_success(socketio_client: SocketIOTestClient):
"""
Tests if the autopilot has been rebooted
diff --git a/radio/tests/test_comPorts.py b/radio/tests/test_comPorts.py
index 5e7c320e8..b42410c9c 100644
--- a/radio/tests/test_comPorts.py
+++ b/radio/tests/test_comPorts.py
@@ -1,11 +1,10 @@
import sys
import pytest
-from app import droneStatus
from app.drone import Drone
+from flask_socketio.test_client import SocketIOTestClient
from serial.tools import list_ports
-from . import socketio_client
from .conftest import setupDrone
from .helpers import send_and_receive
@@ -17,6 +16,8 @@ def run_once_after_all_tests():
"""
Saves the valid connection string then ensures that the drone connection is established again after the tests have run
"""
+ from app import droneStatus
+
assert droneStatus.drone is not None
global VALID_DRONE_PORT
VALID_DRONE_PORT = droneStatus.drone.port
@@ -42,7 +43,7 @@ def get_comport_name(port):
return port_name
-def test_getComPort() -> None:
+def test_getComPort(droneStatus) -> None:
# TODO: we should automate different OS environments for our unit tests maybe?
assert (
send_and_receive("get_com_ports")
@@ -64,7 +65,7 @@ def test_connectToDrone_badType() -> None:
}
-def test_connectToDrone_badPort() -> None:
+def test_connectToDrone_badPort(socketio_client: SocketIOTestClient) -> None:
# Failure on no port specified
assert send_and_receive("connect_to_drone", {"connectionType": "serial"}) == {
"message": "COM port not specified."
@@ -96,7 +97,9 @@ def test_connectToDrone_badPort() -> None:
}
-def test_connectToDrone_validConnection() -> None:
+def test_connectToDrone_validConnection(
+ socketio_client: SocketIOTestClient, droneStatus
+) -> None:
global VALID_DRONE_PORT
# If network connection then do network tests else do serial tests
@@ -163,7 +166,7 @@ def test_connectToDrone_badBaud() -> None:
) == {"message": "Expected integer value for baud, received str."}
-def test_disconnectFromDrone() -> None:
+def test_disconnectFromDrone(socketio_client: SocketIOTestClient) -> None:
global VALID_DRONE_PORT
# If network connection then do network tests else do serial tests
diff --git a/radio/tests/test_serialPorts.py b/radio/tests/test_serialPorts.py
new file mode 100644
index 000000000..351da4cf2
--- /dev/null
+++ b/radio/tests/test_serialPorts.py
@@ -0,0 +1,141 @@
+from flask_socketio.test_client import SocketIOTestClient
+
+from .helpers import NoDrone, send_and_receive
+
+
+def test_getSerialPortsConfig_wrongState(
+ socketio_client: SocketIOTestClient, droneStatus
+):
+ """Should return error when not on the serial ports config screen."""
+ droneStatus.state = "dashboard"
+ result = send_and_receive("get_serial_ports_config")
+ assert result == {
+ "message": "You must be on the serial ports config screen to access the serial ports config."
+ }
+
+
+def test_getSerialPortsConfig_noDrone(socketio_client: SocketIOTestClient, droneStatus):
+ """Should return error when no drone is connected."""
+ droneStatus.state = "config.serial_ports"
+ with NoDrone():
+ result = send_and_receive("get_serial_ports_config")
+ assert "message" in result
+ assert (
+ "connected" in result["message"].lower()
+ or "drone" in result["message"].lower()
+ )
+
+
+def test_getSerialPortsConfig_success(socketio_client: SocketIOTestClient, droneStatus):
+ """Should return serial port config when on the correct screen and connected."""
+ droneStatus.state = "config.serial_ports"
+ socketio_client.emit("get_serial_ports_config")
+ received = socketio_client.get_received()
+
+ assert len(received) >= 1
+ config_event = next(
+ (r for r in received if r["name"] == "serial_ports_config"), None
+ )
+ assert config_event is not None
+
+ config = config_event["args"][0]
+ assert isinstance(config, dict)
+
+ # Each port entry should have protocol, baud, options
+ for port_key, port_data in config.items():
+ assert "protocol" in port_data
+ assert "baud" in port_data
+ assert "options" in port_data
+
+
+def test_setSerialPortConfigParam_wrongState(
+ socketio_client: SocketIOTestClient, droneStatus
+):
+ """Should return error when not on the serial ports config screen."""
+ droneStatus.state = "dashboard"
+ result = send_and_receive(
+ "set_serial_port_config_param",
+ {"param_id": "SERIAL1_PROTOCOL", "value": 2},
+ )
+ assert result == {
+ "message": "You must be on the serial ports config screen to set serial port config parameters."
+ }
+
+
+def test_setSerialPortConfigParam_noDrone(
+ socketio_client: SocketIOTestClient, droneStatus
+):
+ """Should return error when no drone is connected."""
+ droneStatus.state = "config.serial_ports"
+ with NoDrone():
+ result = send_and_receive(
+ "set_serial_port_config_param",
+ {"param_id": "SERIAL1_PROTOCOL", "value": 2},
+ )
+ assert "message" in result
+ assert (
+ "connected" in result["message"].lower()
+ or "drone" in result["message"].lower()
+ )
+
+
+def test_setSerialPortConfigParam_missingParamId(
+ socketio_client: SocketIOTestClient, droneStatus
+):
+ """Should return error when param_id is missing."""
+ droneStatus.state = "config.serial_ports"
+ result = send_and_receive(
+ "set_serial_port_config_param",
+ {"value": 2},
+ )
+ assert result == {"message": "Param ID and value must be specified."}
+
+
+def test_setSerialPortConfigParam_missingValue(
+ socketio_client: SocketIOTestClient, droneStatus
+):
+ """Should return error when value is missing."""
+ droneStatus.state = "config.serial_ports"
+ result = send_and_receive(
+ "set_serial_port_config_param",
+ {"param_id": "SERIAL1_PROTOCOL"},
+ )
+ assert result == {"message": "Param ID and value must be specified."}
+
+
+def test_setSerialPortConfigParam_success(
+ socketio_client: SocketIOTestClient, droneStatus
+):
+ """Should successfully set a serial port param and return confirmation."""
+ droneStatus.state = "config.serial_ports"
+
+ # Get current config to find a valid param to set
+ socketio_client.emit("get_serial_ports_config")
+ received = socketio_client.get_received()
+ config_event = next(
+ (r for r in received if r["name"] == "serial_ports_config"), None
+ )
+ assert config_event is not None
+
+ config = config_event["args"][0]
+ # Pick the first port's protocol to set back to its current value (safe/idempotent)
+ first_port_key = next(iter(config))
+ port_number = first_port_key.split("_")[1]
+ param_id = f"SERIAL{port_number}_PROTOCOL"
+ current_value = config[first_port_key]["protocol"]
+
+ socketio_client.emit(
+ "set_serial_port_config_param",
+ {"param_id": param_id, "value": current_value},
+ )
+ received = socketio_client.get_received()
+
+ result_event = next(
+ (r for r in received if r["name"] == "set_serial_port_config_result"), None
+ )
+ assert result_event is not None
+
+ result = result_event["args"][0]
+ assert result["success"] is True
+ assert result["param_id"] == param_id
+ assert result["value"] == current_value