From 353463a1d6bd7a156c71d94a8f96f19ab8c44b3a Mon Sep 17 00:00:00 2001 From: Kwashie A <104215256+Kwash67@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:44:54 +0000 Subject: [PATCH 1/9] set up servoOutput page UI --- gcs/package.json | 2 +- gcs/src/components/config/servoOutput.jsx | 298 +++++++++++++++++++ gcs/src/config.jsx | 7 + gcs/src/redux/middleware/socketMiddleware.js | 12 + gcs/src/redux/slices/servoOutputSlice.js | 28 ++ gcs/src/redux/store.js | 2 + radio/app/drone.py | 8 + radio/app/endpoints/params.py | 12 +- radio/app/endpoints/servo.py | 37 +++ radio/app/endpoints/states.py | 1 + 10 files changed, 402 insertions(+), 5 deletions(-) create mode 100644 gcs/src/components/config/servoOutput.jsx create mode 100644 gcs/src/redux/slices/servoOutputSlice.js create mode 100644 radio/app/endpoints/servo.py diff --git a/gcs/package.json b/gcs/package.json index 0d8c04dc7..e5672fec3 100644 --- a/gcs/package.json +++ b/gcs/package.json @@ -36,7 +36,7 @@ "@mantine/tiptap": "^7.17.4", "@reduxjs/toolkit": "^2.2.7", "@robloche/chartjs-plugin-streaming": "^3.1.0", - "@tabler/icons-react": "^3.36.1", + "@tabler/icons-react": "^3.37.1", "@tailwindcss/container-queries": "^0.1.1", "@tiptap/extension-link": "^2.11.6", "@tiptap/pm": "^2.11.6", diff --git a/gcs/src/components/config/servoOutput.jsx b/gcs/src/components/config/servoOutput.jsx new file mode 100644 index 000000000..bab3a0959 --- /dev/null +++ b/gcs/src/components/config/servoOutput.jsx @@ -0,0 +1,298 @@ +// Servo Output Configuration Page Scaffold +import { useEffect, useState } from "react" +import { + Table, + Button, + NumberInput, + Progress, + Checkbox, + Select, + Modal, + Text, +} from "@mantine/core" +import resolveConfig from "tailwindcss/resolveConfig" +import tailwindConfig from "../../../tailwind.config" +const tailwindColors = resolveConfig(tailwindConfig).theme.colors +import { io } from "socket.io-client" +const socket = io() + +// 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 { useSelector, useDispatch } from "react-redux" +import { selectAircraftType } from "../../redux/slices/droneInfoSlice" +import { + selectParams, + selectModifiedParams, + appendModifiedParams, + updateModifiedParamValue, +} from "../../redux/slices/paramsSlice" +import { emitRefreshParams } from "../../redux/slices/paramsSlice" +import { emitSetState } from "../../redux/slices/droneConnectionSlice" +import { selectConnectedToDrone } from "../../redux/slices/droneConnectionSlice" +import { selectServoOutputs } from "../../redux/slices/servoOutputSlice" + +export default function ServoOutput() { + const dispatch = useDispatch() + const [testModalOpen, setTestModalOpen] = useState(false) + const [testServoIdx, setTestServoIdx] = useState(null) + const [testPwm, setTestPwm] = useState(1500) + const aircraftType = useSelector(selectAircraftType) + const params = useSelector(selectParams) + const modifiedParams = useSelector(selectModifiedParams) + const connected = useSelector(selectConnectedToDrone) + const servoOutputs = useSelector(selectServoOutputs) + + // Helper to get param value (modified or current) + function getParamValue(param_id) { + const mod = modifiedParams.find((p) => p.param_id === param_id) + if (mod) return mod.param_value + const orig = params.find((p) => p.param_id === param_id) + return orig ? orig.param_value : "" + } + + // 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 update param value in Redux + function handleParamChange(param_id, value) { + const orig = params.find((p) => p.param_id === param_id) + if (!orig) return + const param = { ...orig } + if (modifiedParams.find((p) => p.param_id === param_id)) { + dispatch(updateModifiedParamValue({ param_id, param_value: value })) + } else { + dispatch( + appendModifiedParams([ + { + param_id, + param_value: value, + param_type: param.param_type, + initial_value: param.param_value, + }, + ]), + ) + } + } + + function handleOpenTestModal(idx) { + setTestServoIdx(idx) + setTestPwm(1500) // Placeholder, as position is not available + setTestModalOpen(true) + } + + function handleSendTestPwm() { + // Send test PWM to backend + if (testServoIdx !== null && servoRows[testServoIdx]) { + const servoNum = servoRows[testServoIdx].number + socket.emit("set_servo_pwm", { servo: servoNum, pwm: testPwm }) + } + setTestModalOpen(false) + } + + const COLOURS = [ + tailwindColors.red[500], + tailwindColors.orange[500], + tailwindColors.yellow[500], + tailwindColors.green[500], + tailwindColors.blue[500], + tailwindColors.indigo[500], + tailwindColors.purple[500], + tailwindColors.pink[500], + ] + + // Build servo rows (1-16) + const servoRows = Array.from({ length: 16 }, (_, i) => { + const num = i + 1 + return { + number: num, + function: getParamValue(`SERVO${num}_FUNCTION`), + min: getParamValue(`SERVO${num}_MIN`), + trim: getParamValue(`SERVO${num}_TRIM`), + max: getParamValue(`SERVO${num}_MAX`), + reverse: + getParamValue(`SERVO${num}_REVERSED`) === 1 || + getParamValue(`SERVO${num}_REVERSED`) === "1", + pwm: servoOutputs[num] || null, // Use true output PWM + } + }) + + useEffect(() => { + if (connected) { + dispatch(emitSetState("config.servo")) + dispatch(emitRefreshParams()) + } + }, [connected, dispatch]) + + useEffect(() => { + console.log("Updated servo outputs:", servoOutputs) + }, [servoOutputs]) + + return ( +
+ {/* Modal for sending test PWM */} + setTestModalOpen(false)} + title={`Test Servo Output #${testServoIdx !== null ? servoRows[testServoIdx]?.number : ""}`} + centered + > + Enter PWM value to send: + + + + + + + + # + Position + Reverse + Function + Min + Trim + Max + Test PWM + + + + {servoRows.map((servo, idx) => { + // Param IDs + const num = servo.number + const fnParam = `SERVO${num}_FUNCTION` + const minParam = `SERVO${num}_MIN` + const trimParam = `SERVO${num}_TRIM` + const maxParam = `SERVO${num}_MAX` + const revParam = `SERVO${num}_REVERSED` + + // Param defs + const fnDef = getParamDef(fnParam) + const minDef = getParamDef(minParam) + const trimDef = getParamDef(trimParam) + const maxDef = getParamDef(maxParam) + const revDef = getParamDef(revParam) + + // Function dropdown options + const fnOptions = fnDef?.Values + ? Object.entries(fnDef.Values).map(([value, label]) => ({ + value, + label: `${value}: ${label}`, + })) + : [] + + return ( + + {num} + + + + + {servo.pwm ? `${servo.pwm}` : "--"} + + + + + + + handleParamChange( + revParam, + event.currentTarget.checked ? 1 : 0, + ) + } + /> + + +
+
+ ) +} diff --git a/gcs/src/config.jsx b/gcs/src/config.jsx index 31bee301e..b4dedbd28 100644 --- a/gcs/src/config.jsx +++ b/gcs/src/config.jsx @@ -28,6 +28,7 @@ import { setActiveTab, } from "./redux/slices/configSlice" import { selectConnectedToDrone } from "./redux/slices/droneConnectionSlice" +import ServoOutput from "./components/config/servoOutput" export default function Config() { const dispatch = useDispatch() @@ -63,6 +64,7 @@ export default function Config() { Motor Test RC Calibration Flight modes + Servo Output FTP @@ -85,6 +87,11 @@ export default function Config() { + +
+ +
+
diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js index ea8534b4d..dccd97a9d 100644 --- a/gcs/src/redux/middleware/socketMiddleware.js +++ b/gcs/src/redux/middleware/socketMiddleware.js @@ -121,6 +121,7 @@ import { updateParamValue, } from "../slices/paramsSlice.js" import { pushMessage, resetMessages } from "../slices/statusTextSlice.js" +import { setServoOutputs } from "../slices/servoOutputSlice" import { handleEmitters } from "./emitters.js" const SocketEvents = Object.freeze({ @@ -298,6 +299,17 @@ const socketMiddleware = (store) => { window.ipcRenderer.invoke("app:update-vibe-status", data) break } + case "SERVO_OUTPUT_RAW": { + // Map: channel number (1-based) -> PWM value + const outputs = {} + for (let i = 1; i <= 16; i++) { + const pwm = msg[`servo${i}_raw`] + if (typeof pwm === "number") outputs[i] = pwm + } + console.log("SERVO_OUTPUT_RAW received:", msg, "Mapped outputs:", outputs) + store.dispatch(setServoOutputs(outputs)) + break + } } } diff --git a/gcs/src/redux/slices/servoOutputSlice.js b/gcs/src/redux/slices/servoOutputSlice.js new file mode 100644 index 000000000..ef33a055c --- /dev/null +++ b/gcs/src/redux/slices/servoOutputSlice.js @@ -0,0 +1,28 @@ +import { createSlice } from "@reduxjs/toolkit" + +// This slice will store the latest SERVO_OUTPUT_RAW values for each output channel +const initialState = { + // Map: channel number (1-based) -> PWM value + outputs: {}, +} + +const servoOutputSlice = createSlice({ + name: "servoOutput", + initialState, + reducers: { + setServoOutputs: (state, action) => { + // action.payload: { [channel]: pwm, ... } + state.outputs = { ...state.outputs, ...action.payload } + }, + resetServoOutputs: (state) => { + state.outputs = {} + }, + }, + selectors: { + selectServoOutputs: (state) => state.outputs, + }, +}) + +export const { setServoOutputs, resetServoOutputs } = servoOutputSlice.actions +export const { selectServoOutputs } = servoOutputSlice.selectors +export default servoOutputSlice diff --git a/gcs/src/redux/store.js b/gcs/src/redux/store.js index 001997fc5..256e71133 100644 --- a/gcs/src/redux/store.js +++ b/gcs/src/redux/store.js @@ -23,6 +23,7 @@ import missionInfoSlice, { setPlannedHomePosition } from "./slices/missionSlice" import paramsSlice from "./slices/paramsSlice" import socketSlice from "./slices/socketSlice" import statusTextSlice from "./slices/statusTextSlice" +import servoOutputSlice from "./slices/servoOutputSlice" const rootReducer = combineSlices( logAnalyserSlice, @@ -36,6 +37,7 @@ const rootReducer = combineSlices( checklistSlice, applicationSlice, ftpSlice, + servoOutputSlice, ) export const store = configureStore({ diff --git a/radio/app/drone.py b/radio/app/drone.py index 24f163c97..2c8a1c691 100644 --- a/radio/app/drone.py +++ b/radio/app/drone.py @@ -213,6 +213,7 @@ def __init__( self.controller_queues: Dict[str, Queue] = {} self.reservation_lock = Lock() self.controller_id = f"Drone_{current_thread().ident}" + self.logged_message_types: Set[str] = set() self.armed = False self.capabilities: Optional[list[str]] = None @@ -651,6 +652,13 @@ def checkForMessages(self) -> None: msg_name = msg.get_type() + # Logging first occurrence of each message type + if msg_name not in self.logged_message_types: + self.logger.info( + f"\033[92mSERVO DEBUG: Received message type: {msg_name}\033[0m" + ) + self.logged_message_types.add(msg_name) + if msg_name == "HEARTBEAT": if ( msg.autopilot == mavutil.mavlink.MAV_AUTOPILOT_INVALID diff --git a/radio/app/endpoints/params.py b/radio/app/endpoints/params.py index 95c692110..44a5cac91 100644 --- a/radio/app/endpoints/params.py +++ b/radio/app/endpoints/params.py @@ -36,11 +36,13 @@ def set_multiple_params(params_list: List[Any]) -> None: Args: params_list: The list of parameters to be setting from the client. """ - validStates = ["params", "config"] + validStates = ["params", "config", "config.servo"] if droneStatus.state not in validStates: socketio.emit( "params_error", - {"message": "You must be on the params screen to save parameters."}, + { + "message": "You must be on the params or servo config screen to save parameters." + }, ) logger.debug(f"Current state: {droneStatus.state}") return @@ -62,10 +64,12 @@ def refresh_params() -> None: """ Refresh all parameters """ - if droneStatus.state != "params": + if droneStatus.state not in ["params", "config.servo"]: socketio.emit( "params_error", - {"message": "You must be on the params screen to refresh the parameters."}, + { + "message": "You must be on the params or servo config screen to refresh the parameters." + }, ) logger.debug(f"Current state: {droneStatus.state}") return diff --git a/radio/app/endpoints/servo.py b/radio/app/endpoints/servo.py new file mode 100644 index 000000000..994a52593 --- /dev/null +++ b/radio/app/endpoints/servo.py @@ -0,0 +1,37 @@ +from app import socketio +import app.droneStatus as droneStatus +from app.utils import notConnectedError +from pymavlink import mavutil + + +@socketio.on("set_servo_pwm") +def set_servo_pwm(data): + """ + Set a servo output to a specific PWM value. + Args: + data: dict with 'servo' (int, 1-based) and 'pwm' (int, microseconds) + """ + if not droneStatus.drone: + return notConnectedError(action="set servo PWM") + + servo = data.get("servo") + pwm = data.get("pwm") + if servo is None or pwm is None: + socketio.emit("servo_test_error", {"message": "Missing servo or pwm value."}) + return + + # ArduPilot expects servo instance (1-based) + droneStatus.drone.master.mav.command_long_send( + droneStatus.drone.target_system, + droneStatus.drone.target_component, + mavutil.mavlink.MAV_CMD_DO_SET_SERVO, + 0, # confirmation + servo, # param1: servo number (1-based) + pwm, # param2: PWM value (microseconds) + 0, + 0, + 0, + 0, + 0, # unused params + ) + socketio.emit("servo_test_success", {"servo": servo, "pwm": pwm}) diff --git a/radio/app/endpoints/states.py b/radio/app/endpoints/states.py index 892892878..43c67f41d 100644 --- a/radio/app/endpoints/states.py +++ b/radio/app/endpoints/states.py @@ -39,6 +39,7 @@ class SetStateType(TypedDict): "RC_CHANNELS", ], "config.rc": ["RC_CHANNELS"], + "config.servo": ["SERVO_OUTPUT_RAW", "RC_CHANNELS"], } From c2c8a6ad9bc5a39e6cc074648e66d75e32b805b6 Mon Sep 17 00:00:00 2001 From: Kwashie A <104215256+Kwash67@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:54:53 +0000 Subject: [PATCH 2/9] set up listeners on servo config --- radio/app/endpoints/states.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/radio/app/endpoints/states.py b/radio/app/endpoints/states.py index 43c67f41d..6272be18b 100644 --- a/radio/app/endpoints/states.py +++ b/radio/app/endpoints/states.py @@ -107,3 +107,10 @@ def set_state(data: SetStateType) -> None: for message in STATES_MESSAGE_LISTENERS["config.rc"]: droneStatus.drone.addMessageListener(message, sendMessage) + elif droneStatus.state == "config.servo": + droneStatus.drone.sendDataStreamRequestMessage( + mavutil.mavlink.MAV_DATA_STREAM_RC_CHANNELS, 4 + ) + + for message in STATES_MESSAGE_LISTENERS["config.servo"]: + droneStatus.drone.addMessageListener(message, sendMessage) From 06b5a9bc453d6a1f5789c8d9183b16175e119b29 Mon Sep 17 00:00:00 2001 From: Kwashie A <104215256+Kwash67@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:37:43 +0000 Subject: [PATCH 3/9] added servo controller for consistency --- gcs/src/components/config/servoOutput.jsx | 159 +++++++------------ gcs/src/redux/middleware/emitters.js | 24 +++ gcs/src/redux/middleware/socketMiddleware.js | 78 +++++++-- gcs/src/redux/slices/configSlice.js | 59 +++++++ gcs/src/redux/slices/servoOutputSlice.js | 28 ---- gcs/src/redux/store.js | 2 - radio/app/controllers/servoController.py | 147 +++++++++++++++++ radio/app/drone.py | 15 +- radio/app/endpoints/__init__.py | 1 + radio/app/endpoints/params.py | 10 +- radio/app/endpoints/servo.py | 121 ++++++++++---- 11 files changed, 461 insertions(+), 183 deletions(-) delete mode 100644 gcs/src/redux/slices/servoOutputSlice.js create mode 100644 radio/app/controllers/servoController.py diff --git a/gcs/src/components/config/servoOutput.jsx b/gcs/src/components/config/servoOutput.jsx index bab3a0959..42625d089 100644 --- a/gcs/src/components/config/servoOutput.jsx +++ b/gcs/src/components/config/servoOutput.jsx @@ -1,20 +1,18 @@ -// Servo Output Configuration Page Scaffold +// Servo Output Configuration Page import { useEffect, useState } from "react" import { Table, Button, NumberInput, - Progress, Checkbox, Select, Modal, Text, + Progress, } from "@mantine/core" import resolveConfig from "tailwindcss/resolveConfig" import tailwindConfig from "../../../tailwind.config" const tailwindColors = resolveConfig(tailwindConfig).theme.colors -import { io } from "socket.io-client" -const socket = io() // Custom components, helpers and data import apmParamDefsCopter from "../../../data/gen_apm_params_def_copter.json" @@ -23,34 +21,26 @@ import apmParamDefsPlane from "../../../data/gen_apm_params_def_plane.json" import { useSelector, useDispatch } from "react-redux" import { selectAircraftType } from "../../redux/slices/droneInfoSlice" import { - selectParams, - selectModifiedParams, - appendModifiedParams, - updateModifiedParamValue, -} from "../../redux/slices/paramsSlice" -import { emitRefreshParams } from "../../redux/slices/paramsSlice" + emitGetServoConfig, + emitSetServoConfigParam, + emitBatchSetServoConfigParams, + selectServoConfig, + selectServoPwmOutputs, + updateServoConfigParam, +} from "../../redux/slices/configSlice" import { emitSetState } from "../../redux/slices/droneConnectionSlice" import { selectConnectedToDrone } from "../../redux/slices/droneConnectionSlice" -import { selectServoOutputs } from "../../redux/slices/servoOutputSlice" export default function ServoOutput() { const dispatch = useDispatch() const [testModalOpen, setTestModalOpen] = useState(false) const [testServoIdx, setTestServoIdx] = useState(null) const [testPwm, setTestPwm] = useState(1500) + const aircraftType = useSelector(selectAircraftType) - const params = useSelector(selectParams) - const modifiedParams = useSelector(selectModifiedParams) + const servoConfig = useSelector(selectServoConfig) + const servoPwmOutputs = useSelector(selectServoPwmOutputs) const connected = useSelector(selectConnectedToDrone) - const servoOutputs = useSelector(selectServoOutputs) - - // Helper to get param value (modified or current) - function getParamValue(param_id) { - const mod = modifiedParams.find((p) => p.param_id === param_id) - if (mod) return mod.param_value - const orig = params.find((p) => p.param_id === param_id) - return orig ? orig.param_value : "" - } // Helper to get paramDef for a given param_id function getParamDef(param_id) { @@ -59,39 +49,24 @@ export default function ServoOutput() { return undefined } - // Helper to update param value in Redux + // Helper to handle param change function handleParamChange(param_id, value) { - const orig = params.find((p) => p.param_id === param_id) - if (!orig) return - const param = { ...orig } - if (modifiedParams.find((p) => p.param_id === param_id)) { - dispatch(updateModifiedParamValue({ param_id, param_value: value })) - } else { - dispatch( - appendModifiedParams([ - { - param_id, - param_value: value, - param_type: param.param_type, - initial_value: param.param_value, - }, - ]), - ) - } + dispatch( + emitSetServoConfigParam({ + param_id, + value: parseInt(value), + }), + ) } - function handleOpenTestModal(idx) { - setTestServoIdx(idx) - setTestPwm(1500) // Placeholder, as position is not available + function handleOpenTestModal(servoNum) { + setTestServoIdx(servoNum) + setTestPwm(1500) setTestModalOpen(true) } function handleSendTestPwm() { - // Send test PWM to backend - if (testServoIdx !== null && servoRows[testServoIdx]) { - const servoNum = servoRows[testServoIdx].number - socket.emit("set_servo_pwm", { servo: servoNum, pwm: testPwm }) - } + // TODO: Implement test servo PWM send setTestModalOpen(false) } @@ -109,44 +84,39 @@ export default function ServoOutput() { // Build servo rows (1-16) const servoRows = Array.from({ length: 16 }, (_, i) => { const num = i + 1 + const config = servoConfig[num] || {} return { number: num, - function: getParamValue(`SERVO${num}_FUNCTION`), - min: getParamValue(`SERVO${num}_MIN`), - trim: getParamValue(`SERVO${num}_TRIM`), - max: getParamValue(`SERVO${num}_MAX`), - reverse: - getParamValue(`SERVO${num}_REVERSED`) === 1 || - getParamValue(`SERVO${num}_REVERSED`) === "1", - pwm: servoOutputs[num] || null, // Use true output PWM + function: config.function, + min: config.min, + trim: config.trim, + max: config.max, + reversed: config.reversed === 1 || config.reversed === "1", + pwm: servoPwmOutputs[num] || 0, } }) useEffect(() => { if (connected) { dispatch(emitSetState("config.servo")) - dispatch(emitRefreshParams()) + dispatch(emitGetServoConfig()) } }, [connected, dispatch]) - useEffect(() => { - console.log("Updated servo outputs:", servoOutputs) - }, [servoOutputs]) - return (
{/* Modal for sending test PWM */} setTestModalOpen(false)} - title={`Test Servo Output #${testServoIdx !== null ? servoRows[testServoIdx]?.number : ""}`} + title={`Test Servo Output #${testServoIdx}`} centered > Enter PWM value to send: