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 ( +
+ + + + Port + Baud Rate + Protocol + Options + + + + {serialPortRows.map((port) => { + const num = port.number + const protocolParam = `SERIAL${num}_PROTOCOL` + const baudParam = `SERIAL${num}_BAUD` + const optionsParam = `SERIAL${num}_OPTIONS` + + const protocolDef = getParamDef(protocolParam) + const baudDef = getParamDef(baudParam) + const optionsDef = getParamDef(optionsParam) + + const protocolOptions = protocolDef?.Values + ? Object.entries(protocolDef.Values).map(([value, label]) => ({ + value, + label: `${value}: ${label}`, + })) + : [] + + // Baud rate handling + const baudOptions = baudDef?.Values + ? Object.entries(baudDef.Values).map(([value, label]) => ({ + value, + label: `${value}: ${label}`, + })) + : [] + + return ( + + + + SERIAL{num} + + + + +
+ {baudDef?.Description || "Baud rate selection"} +
+ + } + position="top" + multiline + > + handleParamChange(protocolParam, val)} + /> +
+
+ + + {optionsDef?.Description || "Serial port options"} + + } + position="top" + multiline + > +
+ handleParamChange(optionsParam, val)} + options={optionsDef?.Bitmask} + /> +
+
+
+
+ ) + })} +
+
+ + + Note: Changes to the serial port settings will not take effect until the + board is rebooted. + +
+ ) +} diff --git a/gcs/src/config.jsx b/gcs/src/config.jsx index 9b6df1fda..5cae0fe8a 100644 --- a/gcs/src/config.jsx +++ b/gcs/src/config.jsx @@ -23,6 +23,7 @@ import { useDispatch, useSelector } from "react-redux" import Ftp from "./components/config/ftp" import { selectActiveTab, setActiveTab } from "./redux/slices/configSlice" import { selectConnectedToDrone } from "./redux/slices/droneConnectionSlice" +import SerialPorts from "./components/config/serialPorts" import ServoOutput from "./components/config/servoOutput" export default function Config() { @@ -55,6 +56,7 @@ export default function Config() { RC Calibration Flight modes Servo Output + Serial Ports FTP @@ -82,6 +84,11 @@ export default function Config() { + +
+ +
+
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