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:
diff --git a/gcs/src/redux/middleware/emitters.js b/gcs/src/redux/middleware/emitters.js
index 295224961..df1d549c6 100644
--- a/gcs/src/redux/middleware/emitters.js
+++ b/gcs/src/redux/middleware/emitters.js
@@ -1,17 +1,20 @@
import { showErrorNotification } from "../../helpers/notification"
import {
emitBatchSetRcConfigParams,
+ emitBatchSetServoConfigParams,
emitGetFlightModeConfig,
emitGetFrameConfig,
emitGetGripperConfig,
emitGetGripperEnabled,
emitGetRcConfig,
+ emitGetServoConfig,
emitRefreshFlightModeData,
emitSetFlightMode,
emitSetFlightModeChannel,
emitSetGripper,
emitSetGripperConfigParam,
emitSetRcConfigParam,
+ emitSetServoConfigParam,
emitTestAllMotors,
emitTestMotorSequence,
emitTestOneMotor,
@@ -398,6 +401,27 @@ export function handleEmitters(socket, store, action) {
})
},
},
+ {
+ emitter: emitGetServoConfig,
+ callback: () => socket.socket.emit("get_servo_config"),
+ },
+ {
+ emitter: emitSetServoConfigParam,
+ callback: () => {
+ socket.socket.emit("set_servo_config_param", {
+ param_id: action.payload.param_id,
+ value: action.payload.value,
+ })
+ },
+ },
+ {
+ emitter: emitBatchSetServoConfigParams,
+ callback: () => {
+ socket.socket.emit("batch_set_servo_config_params", {
+ params: action.payload.params,
+ })
+ },
+ },
{
emitter: emitListFiles,
callback: () => {
diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js
index dccd97a9d..694f8137d 100644
--- a/gcs/src/redux/middleware/socketMiddleware.js
+++ b/gcs/src/redux/middleware/socketMiddleware.js
@@ -49,8 +49,11 @@ import {
setRefreshingFlightModeData,
setRefreshingGripperConfigData,
setShowMotorTestWarningModal,
+ setServoConfig,
+ setServoPwmOutputs,
updateChannelsConfigParam,
updateGripperConfigParam,
+ updateServoConfigParam,
} from "../slices/configSlice.js"
import {
appendToGpsTrack,
@@ -121,7 +124,6 @@ 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({
@@ -183,6 +185,9 @@ const ConfigSpecificSocketEvents = Object.freeze({
onRcConfig: "rc_config",
onSetRcConfigResult: "set_rc_config_result",
onBatchSetRcConfigResult: "batch_set_rc_config_result",
+ onServoConfig: "servo_config",
+ onSetServoConfigResult: "set_servo_config_result",
+ onBatchSetServoConfigResult: "batch_set_servo_config_result",
})
const FtpSpecificSocketEvents = Object.freeze({
@@ -205,6 +210,16 @@ const socketMiddleware = (store) => {
store.dispatch(setRadioPwmChannels(chans))
}
+ function handleServoOutputRaw(msg) {
+ const outputs = {}
+ for (let i = 1; i <= 16; i++) {
+ const pwm = msg[`servo${i}_raw`]
+ if (typeof pwm === "number") outputs[i] = pwm
+ }
+
+ store.dispatch(setServoPwmOutputs(outputs))
+ }
+
const incomingMessageHandler = (msg) => {
switch (msg.mavpackettype) {
case "VFR_HUD":
@@ -299,17 +314,9 @@ 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))
+ case "SERVO_OUTPUT_RAW":
+ handleServoOutputRaw(msg)
break
- }
}
}
@@ -1147,6 +1154,55 @@ const socketMiddleware = (store) => {
},
)
+ socket.socket.on(ConfigSpecificSocketEvents.onServoConfig, (msg) => {
+ const config = {}
+
+ for (let i = 1; i < 17; i++) {
+ config[i] = msg[`SERVO_${i}`]
+ }
+
+ store.dispatch(setServoConfig(config))
+ })
+
+ socket.socket.on(
+ ConfigSpecificSocketEvents.onSetServoConfigResult,
+ (msg) => {
+ if (msg.success) {
+ showSuccessNotification(msg.message)
+ store.dispatch(
+ updateServoConfigParam({
+ param_id: msg.param_id,
+ value: msg.value,
+ }),
+ )
+ } else {
+ showErrorNotification(msg.message)
+ }
+ },
+ )
+
+ socket.socket.on(
+ ConfigSpecificSocketEvents.onBatchSetServoConfigResult,
+ (msg) => {
+ if (msg.success) {
+ showSuccessNotification(msg.message)
+ } else {
+ showErrorNotification(msg.message)
+ }
+
+ if (msg.data?.length > 0) {
+ for (const successfullySetParam of msg.data) {
+ store.dispatch(
+ updateServoConfigParam({
+ 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 96bd8242e..fc54b5f3b 100644
--- a/gcs/src/redux/slices/configSlice.js
+++ b/gcs/src/redux/slices/configSlice.js
@@ -44,6 +44,25 @@ const configSlice = createSlice({
},
radioChannelsConfig: {},
radioCalibrationModalOpen: false,
+ servoPwmOutputs: {
+ 1: 0,
+ 2: 0,
+ 3: 0,
+ 4: 0,
+ 5: 0,
+ 6: 0,
+ 7: 0,
+ 8: 0,
+ 9: 0,
+ 10: 0,
+ 11: 0,
+ 12: 0,
+ 13: 0,
+ 14: 0,
+ 15: 0,
+ 16: 0,
+ },
+ servoConfig: {},
},
reducers: {
setActiveTab: (state, action) => {
@@ -145,6 +164,33 @@ const configSlice = createSlice({
setRadioCalibrationModalOpen: (state, action) => {
state.radioCalibrationModalOpen = action.payload
},
+ setServoPwmOutputs: (state, action) => {
+ if (action.payload === state.servoPwmOutputs) return
+ const updatedOutputs = {}
+ for (const servo in action.payload) {
+ if (action.payload[servo] !== state.servoPwmOutputs[servo]) {
+ updatedOutputs[servo] = action.payload[servo]
+ }
+ }
+ if (Object.keys(updatedOutputs).length === 0) return
+ state.servoPwmOutputs = { ...state.servoPwmOutputs, ...updatedOutputs }
+ },
+ setServoConfig: (state, action) => {
+ if (action.payload === state.servoConfig) return
+ state.servoConfig = action.payload
+ },
+ updateServoConfigParam: (state, action) => {
+ const { param_id, value } = action.payload
+ const match = param_id.match(/^SERVO(\d+)_(.+)$/)
+ if (!match) return
+ const servoNum = match[1]
+ const paramType = match[2].toLowerCase()
+ if (!state.servoConfig[servoNum]) return
+ const validParamTypes = ["function", "reversed", "min", "max", "trim"]
+ if (!validParamTypes.includes(paramType)) return
+ if (state.servoConfig[servoNum][paramType] === value) return
+ state.servoConfig[servoNum][paramType] = value
+ },
// Emits
emitGetGripperEnabled: () => {},
@@ -162,6 +208,9 @@ const configSlice = createSlice({
emitGetRcConfig: () => {},
emitSetRcConfigParam: () => {},
emitBatchSetRcConfigParams: () => {},
+ emitGetServoConfig: () => {},
+ emitSetServoConfigParam: () => {},
+ emitBatchSetServoConfigParams: () => {},
},
selectors: {
selectActiveTab: (state) => state.activeTab,
@@ -182,6 +231,8 @@ const configSlice = createSlice({
selectRadioPwmChannels: (state) => state.radioPwmChannels,
selectRadioChannelsConfig: (state) => state.radioChannelsConfig,
selectRadioCalibrationModalOpen: (state) => state.radioCalibrationModalOpen,
+ selectServoPwmOutputs: (state) => state.servoPwmOutputs,
+ selectServoConfig: (state) => state.servoConfig,
},
})
@@ -205,6 +256,9 @@ export const {
setChannelsConfig,
updateChannelsConfigParam,
setRadioCalibrationModalOpen,
+ setServoPwmOutputs,
+ setServoConfig,
+ updateServoConfigParam,
emitGetGripperEnabled,
emitGetGripperConfig,
@@ -221,6 +275,9 @@ export const {
emitGetRcConfig,
emitSetRcConfigParam,
emitBatchSetRcConfigParams,
+ emitGetServoConfig,
+ emitSetServoConfigParam,
+ emitBatchSetServoConfigParams,
} = configSlice.actions
export const {
@@ -241,6 +298,8 @@ export const {
selectRadioPwmChannels,
selectRadioChannelsConfig,
selectRadioCalibrationModalOpen,
+ selectServoPwmOutputs,
+ selectServoConfig,
} = configSlice.selectors
export default configSlice
diff --git a/gcs/src/redux/slices/servoOutputSlice.js b/gcs/src/redux/slices/servoOutputSlice.js
deleted file mode 100644
index ef33a055c..000000000
--- a/gcs/src/redux/slices/servoOutputSlice.js
+++ /dev/null
@@ -1,28 +0,0 @@
-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 256e71133..001997fc5 100644
--- a/gcs/src/redux/store.js
+++ b/gcs/src/redux/store.js
@@ -23,7 +23,6 @@ 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,
@@ -37,7 +36,6 @@ const rootReducer = combineSlices(
checklistSlice,
applicationSlice,
ftpSlice,
- servoOutputSlice,
)
export const store = configureStore({
diff --git a/radio/app/controllers/servoController.py b/radio/app/controllers/servoController.py
new file mode 100644
index 000000000..044f89846
--- /dev/null
+++ b/radio/app/controllers/servoController.py
@@ -0,0 +1,147 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from app.customTypes import Number, Response, SetConfigParam
+
+if TYPE_CHECKING:
+ from app.drone import Drone
+
+
+class ServoController:
+ def __init__(self, drone: Drone) -> None:
+ """
+ The Servo controller handles all servo 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")
+ else:
+ self.drone.logger.warning(
+ f"Param {param_name} not found in cache, fetching from drone"
+ )
+ fetched_param = self.drone.paramsController.getSingleParam(param_name).get(
+ "data"
+ )
+ if fetched_param:
+ params_dict[param_key] = fetched_param.param_value
+ self.param_types[param_name] = fetched_param.param_type
+
+ def fetchParams(self) -> None:
+ """
+ Fetches the servo parameters from the drone.
+ """
+ self.drone.logger.debug("Fetching servo parameters")
+
+ for servo_number in range(1, 17):
+ servo_params = self.params.get(f"SERVO_{servo_number}", {})
+
+ self._getAndSetParam(
+ servo_params, "function", f"SERVO{servo_number}_FUNCTION"
+ )
+ self._getAndSetParam(servo_params, "min", f"SERVO{servo_number}_MIN")
+ self._getAndSetParam(servo_params, "trim", f"SERVO{servo_number}_TRIM")
+ self._getAndSetParam(servo_params, "max", f"SERVO{servo_number}_MAX")
+ self._getAndSetParam(
+ servo_params, "reversed", f"SERVO{servo_number}_REVERSED"
+ )
+
+ self.params[f"SERVO_{servo_number}"] = servo_params
+
+ def getConfig(self) -> dict:
+ """
+ Returns the servo configuration with the cached parameters.
+
+ Returns:
+ dict: The servo configuration
+ """
+ for servo_number in range(1, 17):
+ servo_params = self.params.get(f"SERVO_{servo_number}", {})
+
+ self._getAndSetCachedParam(
+ servo_params, "function", f"SERVO{servo_number}_FUNCTION"
+ )
+ self._getAndSetCachedParam(servo_params, "min", f"SERVO{servo_number}_MIN")
+ self._getAndSetCachedParam(servo_params, "trim", f"SERVO{servo_number}_TRIM")
+ self._getAndSetCachedParam(servo_params, "max", f"SERVO{servo_number}_MAX")
+ self._getAndSetCachedParam(
+ servo_params, "reversed", f"SERVO{servo_number}_REVERSED"
+ )
+
+ self.params[f"SERVO_{servo_number}"] = servo_params
+
+ return self.params
+
+ def setConfigParam(self, param_id: str, value: Number) -> bool:
+ """
+ Sets a servo configuration related parameter on the drone.
+ """
+ param_type = self.param_types.get(param_id)
+
+ return self.drone.paramsController.setParam(param_id, value, param_type)
+
+ def batchSetConfigParams(self, params: list[SetConfigParam]) -> Response:
+ """
+ Sets multiple servo configuration related parameters on the drone.
+ """
+ param_set_failures = []
+ param_set_successes = []
+ for item in params:
+ param_id = item.get("param_id")
+ value = item.get("value")
+ if param_id and value is not None:
+ if not self.setConfigParam(param_id, value):
+ param_set_failures.append(param_id)
+ else:
+ param_set_successes.append({"param_id": param_id, "value": value})
+
+ if len(param_set_failures) == 0:
+ return {
+ "success": True,
+ "message": f"Set {len(param_set_successes)} parameters successfully.",
+ "data": param_set_successes,
+ }
+
+ # Even though the batch operation may fail, some params may get set
+ # successfully
+ return {
+ "success": False,
+ "message": f"Failed to set {len(param_set_failures)} parameters: {param_set_failures}",
+ "data": param_set_successes,
+ }
diff --git a/radio/app/drone.py b/radio/app/drone.py
index 2c8a1c691..807a2aa82 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.servoController import ServoController
from app.customTypes import Number, Response, VehicleType
from app.utils import (
commandAccepted,
@@ -121,6 +122,7 @@ 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 nav controller",
"Setting up the FTP controller",
"Connection complete",
@@ -213,7 +215,6 @@ 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
@@ -297,9 +298,12 @@ def setupControllers(self) -> None:
self.rcController = RcController(self)
self.sendConnectionStatusUpdate(11)
- self.navController = NavController(self)
+ self.servoController = ServoController(self)
self.sendConnectionStatusUpdate(12)
+ self.navController = NavController(self)
+
+ self.sendConnectionStatusUpdate(13)
self.ftpController = FtpController(self)
def sendConnectionStatusUpdate(self, msg_index):
@@ -652,13 +656,6 @@ 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/__init__.py b/radio/app/endpoints/__init__.py
index 76ed1417b..a0e14533b 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 servo as servo
from . import states as states
endpoints = Blueprint("endpoints", __name__)
diff --git a/radio/app/endpoints/params.py b/radio/app/endpoints/params.py
index 44a5cac91..7aae9f93b 100644
--- a/radio/app/endpoints/params.py
+++ b/radio/app/endpoints/params.py
@@ -36,12 +36,12 @@ 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", "config.servo"]
+ validStates = ["params", "config"]
if droneStatus.state not in validStates:
socketio.emit(
"params_error",
{
- "message": "You must be on the params or servo config screen to save parameters."
+ "message": "You must be on the params screen to save parameters."
},
)
logger.debug(f"Current state: {droneStatus.state}")
@@ -64,12 +64,10 @@ def refresh_params() -> None:
"""
Refresh all parameters
"""
- if droneStatus.state not in ["params", "config.servo"]:
+ if droneStatus.state != "params":
socketio.emit(
"params_error",
- {
- "message": "You must be on the params or servo config screen to refresh the parameters."
- },
+ {"message": "You must be on the params 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
index 994a52593..c49a341fc 100644
--- a/radio/app/endpoints/servo.py
+++ b/radio/app/endpoints/servo.py
@@ -1,37 +1,104 @@
-from app import socketio
import app.droneStatus as droneStatus
+from app import logger, socketio
+from app.customTypes import BatchSetConfigParams, SetConfigParam
from app.utils import notConnectedError
-from pymavlink import mavutil
+@socketio.on("get_servo_config")
+def getServoConfig() -> None:
+ """
+ Sends the servo config to the frontend, only works when the config page is loaded.
+ """
+ if droneStatus.state != "config.servo":
+ socketio.emit(
+ "params_error",
+ {
+ "message": "You must be on the servo config screen to access the servo config."
+ },
+ )
+ logger.debug(f"Current state: {droneStatus.state}")
+ return
+
+ if not droneStatus.drone:
+ return notConnectedError(action="get the servo config")
+
+ servo_config = droneStatus.drone.servoController.getConfig()
+
+ socketio.emit(
+ "servo_config",
+ servo_config,
+ )
+
+
+@socketio.on("set_servo_config_param")
+def setServoConfigParam(data: SetConfigParam) -> None:
+ """
+ Sets a servo config parameter on the drone.
+ """
+ if droneStatus.state != "config.servo":
+ socketio.emit(
+ "params_error",
+ {
+ "message": "You must be on the servo config screen to set servo config parameters."
+ },
+ )
+ logger.debug(f"Current state: {droneStatus.state}")
+ return
+
+ if not droneStatus.drone:
+ return notConnectedError(action="set a servo 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
-@socketio.on("set_servo_pwm")
-def set_servo_pwm(data):
+ success = droneStatus.drone.servoController.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_servo_config_result", result)
+
+
+@socketio.on("batch_set_servo_config_params")
+def batchSetServoConfigParams(data: BatchSetConfigParams) -> None:
"""
- Set a servo output to a specific PWM value.
- Args:
- data: dict with 'servo' (int, 1-based) and 'pwm' (int, microseconds)
+ Sets multiple servo config parameters on the drone.
"""
+ if droneStatus.state != "config.servo":
+ socketio.emit(
+ "params_error",
+ {
+ "message": "You must be on the servo config screen to set servo config parameters."
+ },
+ )
+ logger.debug(f"Current state: {droneStatus.state}")
+ return
+
if not droneStatus.drone:
- return notConnectedError(action="set servo PWM")
+ return notConnectedError(action="set multiple servo config parameters")
- 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."})
+ params = data.get("params", [])
+ if not params:
+ socketio.emit(
+ "batch_set_servo_config_result",
+ {"success": True, "message": "No parameters specified."},
+ )
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})
+ result = droneStatus.drone.servoController.batchSetConfigParams(params)
+
+ socketio.emit("batch_set_servo_config_result", result)
From 5a7b47e8d9e352e6288aa297816e7067692a438c Mon Sep 17 00:00:00 2001
From: Kwashie A <104215256+Kwash67@users.noreply.github.com>
Date: Thu, 26 Feb 2026 22:00:48 +0000
Subject: [PATCH 4/9] completed servo config
---
gcs/src/components/config/servoOutput.jsx | 88 +++++++++++---------
gcs/src/redux/middleware/emitters.js | 10 +++
gcs/src/redux/middleware/socketMiddleware.js | 12 +++
gcs/src/redux/slices/configSlice.js | 2 +
radio/app/controllers/servoController.py | 4 +-
radio/app/drone.py | 14 +++-
radio/app/endpoints/servo.py | 26 ++++++
7 files changed, 115 insertions(+), 41 deletions(-)
diff --git a/gcs/src/components/config/servoOutput.jsx b/gcs/src/components/config/servoOutput.jsx
index 42625d089..29f7dabd0 100644
--- a/gcs/src/components/config/servoOutput.jsx
+++ b/gcs/src/components/config/servoOutput.jsx
@@ -12,7 +12,6 @@ import {
} from "@mantine/core"
import resolveConfig from "tailwindcss/resolveConfig"
import tailwindConfig from "../../../tailwind.config"
-const tailwindColors = resolveConfig(tailwindConfig).theme.colors
// Custom components, helpers and data
import apmParamDefsCopter from "../../../data/gen_apm_params_def_copter.json"
@@ -24,12 +23,35 @@ import {
emitGetServoConfig,
emitSetServoConfigParam,
emitBatchSetServoConfigParams,
+ emitTestServoPwm,
selectServoConfig,
selectServoPwmOutputs,
updateServoConfigParam,
} from "../../redux/slices/configSlice"
import { emitSetState } from "../../redux/slices/droneConnectionSlice"
import { selectConnectedToDrone } from "../../redux/slices/droneConnectionSlice"
+const tailwindColors = resolveConfig(tailwindConfig).theme.colors
+
+const PWM_MIN = 1000
+const PWM_MAX = 2000
+
+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],
+]
+
+function getPercentageValueFromPWM(pwmValue) {
+ // Normalise the PWM value into a percentage value
+ if (pwmValue == 0) return 0 // Handle case where PWM value is not available
+
+ return ((pwmValue - PWM_MIN) / (PWM_MAX - PWM_MIN)) * 100
+}
export default function ServoOutput() {
const dispatch = useDispatch()
@@ -61,26 +83,21 @@ export default function ServoOutput() {
function handleOpenTestModal(servoNum) {
setTestServoIdx(servoNum)
- setTestPwm(1500)
+ setTestPwm(servoPwmOutputs[servoNum] || 1500)
setTestModalOpen(true)
}
function handleSendTestPwm() {
- // TODO: Implement test servo PWM send
+ if (testPwm < PWM_MIN || testPwm > PWM_MAX) return
+ dispatch(
+ emitTestServoPwm({
+ servo_instance: testServoIdx,
+ pwm_value: 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
@@ -115,11 +132,16 @@ export default function ServoOutput() {
Enter PWM value to send:
PWM_MAX ? `Value must be between ${PWM_MIN} and ${PWM_MAX}` : null}
/>
-
@@ -158,28 +180,18 @@ export default function ServoOutput() {
}))
: []
- const pwmPercentage =
- servo.pwm && servo.min && servo.max
- ? Math.max(
- 0,
- Math.min(
- 100,
- ((servo.pwm - servo.min) / (servo.max - servo.min)) * 100,
- ),
- )
- : 0
-
return (
{num}
-
+
- {servo.pwm || "--"}
+ {servo.pwm}
@@ -209,8 +221,8 @@ export default function ServoOutput() {
handleParamChange(minParam, val)}
@@ -219,10 +231,8 @@ export default function ServoOutput() {
handleParamChange(trimParam, val)}
@@ -231,8 +241,8 @@ export default function ServoOutput() {
handleParamChange(maxParam, val)}
diff --git a/gcs/src/redux/middleware/emitters.js b/gcs/src/redux/middleware/emitters.js
index df1d549c6..9ac953e22 100644
--- a/gcs/src/redux/middleware/emitters.js
+++ b/gcs/src/redux/middleware/emitters.js
@@ -18,6 +18,7 @@ import {
emitTestAllMotors,
emitTestMotorSequence,
emitTestOneMotor,
+ emitTestServoPwm,
setRefreshingGripperConfigData,
} from "../slices/configSlice"
import {
@@ -422,6 +423,15 @@ export function handleEmitters(socket, store, action) {
})
},
},
+ {
+ emitter: emitTestServoPwm,
+ callback: () => {
+ socket.socket.emit("test_servo_pwm", {
+ servo_instance: action.payload.servo_instance,
+ pwm_value: action.payload.pwm_value,
+ })
+ },
+ },
{
emitter: emitListFiles,
callback: () => {
diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js
index 694f8137d..4820ab77a 100644
--- a/gcs/src/redux/middleware/socketMiddleware.js
+++ b/gcs/src/redux/middleware/socketMiddleware.js
@@ -188,6 +188,7 @@ const ConfigSpecificSocketEvents = Object.freeze({
onServoConfig: "servo_config",
onSetServoConfigResult: "set_servo_config_result",
onBatchSetServoConfigResult: "batch_set_servo_config_result",
+ onTestServoPwmResult: "test_servo_result",
})
const FtpSpecificSocketEvents = Object.freeze({
@@ -1203,6 +1204,17 @@ const socketMiddleware = (store) => {
},
)
+ socket.socket.on(
+ ConfigSpecificSocketEvents.onTestServoPwmResult,
+ (msg) => {
+ if (msg.success) {
+ showSuccessNotification(msg.message)
+ } else {
+ showErrorNotification(msg.message)
+ }
+ },
+ )
+
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 fc54b5f3b..b08eda49c 100644
--- a/gcs/src/redux/slices/configSlice.js
+++ b/gcs/src/redux/slices/configSlice.js
@@ -211,6 +211,7 @@ const configSlice = createSlice({
emitGetServoConfig: () => {},
emitSetServoConfigParam: () => {},
emitBatchSetServoConfigParams: () => {},
+ emitTestServoPwm: () => {},
},
selectors: {
selectActiveTab: (state) => state.activeTab,
@@ -278,6 +279,7 @@ export const {
emitGetServoConfig,
emitSetServoConfigParam,
emitBatchSetServoConfigParams,
+ emitTestServoPwm,
} = configSlice.actions
export const {
diff --git a/radio/app/controllers/servoController.py b/radio/app/controllers/servoController.py
index 044f89846..602ae5c86 100644
--- a/radio/app/controllers/servoController.py
+++ b/radio/app/controllers/servoController.py
@@ -98,7 +98,9 @@ def getConfig(self) -> dict:
servo_params, "function", f"SERVO{servo_number}_FUNCTION"
)
self._getAndSetCachedParam(servo_params, "min", f"SERVO{servo_number}_MIN")
- self._getAndSetCachedParam(servo_params, "trim", f"SERVO{servo_number}_TRIM")
+ self._getAndSetCachedParam(
+ servo_params, "trim", f"SERVO{servo_number}_TRIM"
+ )
self._getAndSetCachedParam(servo_params, "max", f"SERVO{servo_number}_MAX")
self._getAndSetCachedParam(
servo_params, "reversed", f"SERVO{servo_number}_REVERSED"
diff --git a/radio/app/drone.py b/radio/app/drone.py
index 807a2aa82..cf0d4b3d8 100644
--- a/radio/app/drone.py
+++ b/radio/app/drone.py
@@ -1015,9 +1015,21 @@ def setServo(self, servo_instance: int, pwm_value: int) -> Response:
self.logger.error(
f"Failed to set servo {servo_instance} to {pwm_value}"
)
+ error_message = f"Failed to set servo {servo_instance} to {pwm_value}"
+ error_code = response.result if response else None
+
+ # Map specific error codes to user-friendly messages
+ if error_code == 4: # MAV_RESULT_FAILED
+ error_message = f"Channel {servo_instance} is already in use"
+ elif error_code == 3: # MAV_RESULT_UNSUPPORTED
+ error_message = f"Servo {servo_instance} is not supported"
+ elif error_code == 2: # MAV_RESULT_DENIED
+ error_message = f"Permission denied to set servo {servo_instance}"
+
return {
"success": False,
- "message": f"Failed to set servo {servo_instance} to {pwm_value}",
+ "message": error_message,
+ "error_code": error_code,
}
except serial.serialutil.SerialException:
diff --git a/radio/app/endpoints/servo.py b/radio/app/endpoints/servo.py
index c49a341fc..47e34019f 100644
--- a/radio/app/endpoints/servo.py
+++ b/radio/app/endpoints/servo.py
@@ -102,3 +102,29 @@ def batchSetServoConfigParams(data: BatchSetConfigParams) -> None:
result = droneStatus.drone.servoController.batchSetConfigParams(params)
socketio.emit("batch_set_servo_config_result", result)
+
+
+@socketio.on("test_servo_pwm")
+def testServoPwm(data: dict) -> None:
+ """
+ Sends a test PWM value to a servo.
+ """
+ if not droneStatus.drone:
+ return notConnectedError(action="test servo PWM")
+
+ servo_instance = data.get("servo_instance", None)
+ pwm_value = data.get("pwm_value", None)
+
+ if servo_instance is None or pwm_value is None:
+ socketio.emit(
+ "test_servo_result",
+ {
+ "success": False,
+ "message": "Servo instance and PWM value must be specified.",
+ },
+ )
+ return
+
+ result = droneStatus.drone.setServo(servo_instance, pwm_value)
+
+ socketio.emit("test_servo_result", result)
From 6f488e24f812da9930c9175bf0f9e6a3b6bb6044 Mon Sep 17 00:00:00 2001
From: Kwashie A <104215256+Kwash67@users.noreply.github.com>
Date: Thu, 26 Feb 2026 22:05:11 +0000
Subject: [PATCH 5/9] removing extra key in response
---
radio/app/drone.py | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/radio/app/drone.py b/radio/app/drone.py
index 350856ba3..e4657213d 100644
--- a/radio/app/drone.py
+++ b/radio/app/drone.py
@@ -1013,8 +1013,7 @@ def setServo(self, servo_instance: int, pwm_value: int) -> Response:
return {
"success": False,
- "message": error_message,
- "error_code": error_code,
+ "message": error_message
}
except serial.serialutil.SerialException:
From ff935f985939cdf6bdfead13dcc91c4e186b88e0 Mon Sep 17 00:00:00 2001
From: Kwashie A <104215256+Kwash67@users.noreply.github.com>
Date: Thu, 26 Feb 2026 22:07:55 +0000
Subject: [PATCH 6/9] formatting
---
gcs/src/components/config/servoOutput.jsx | 38 +++++++++++++++++------
1 file changed, 28 insertions(+), 10 deletions(-)
diff --git a/gcs/src/components/config/servoOutput.jsx b/gcs/src/components/config/servoOutput.jsx
index 29f7dabd0..8d9d6e13c 100644
--- a/gcs/src/components/config/servoOutput.jsx
+++ b/gcs/src/components/config/servoOutput.jsx
@@ -133,11 +133,15 @@ export default function ServoOutput() {
PWM_MAX ? `Value must be between ${PWM_MIN} and ${PWM_MAX}` : null}
+ error={
+ testPwm < PWM_MIN || testPwm > PWM_MAX
+ ? `Value must be between ${PWM_MIN} and ${PWM_MAX}`
+ : null
+ }
/>
- PWM_MAX}
fullWidth
@@ -221,8 +225,12 @@ export default function ServoOutput() {
handleParamChange(minParam, val)}
@@ -231,8 +239,14 @@ export default function ServoOutput() {
handleParamChange(trimParam, val)}
@@ -241,8 +255,12 @@ export default function ServoOutput() {
handleParamChange(maxParam, val)}
From af173049b42d4ae9f47dc8604df316afbcba8215 Mon Sep 17 00:00:00 2001
From: Kwashie A <104215256+Kwash67@users.noreply.github.com>
Date: Thu, 26 Feb 2026 22:09:07 +0000
Subject: [PATCH 7/9] removing unused var
---
gcs/src/components/config/servoOutput.jsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/gcs/src/components/config/servoOutput.jsx b/gcs/src/components/config/servoOutput.jsx
index 8d9d6e13c..54886fa4c 100644
--- a/gcs/src/components/config/servoOutput.jsx
+++ b/gcs/src/components/config/servoOutput.jsx
@@ -26,7 +26,6 @@ import {
emitTestServoPwm,
selectServoConfig,
selectServoPwmOutputs,
- updateServoConfigParam,
} from "../../redux/slices/configSlice"
import { emitSetState } from "../../redux/slices/droneConnectionSlice"
import { selectConnectedToDrone } from "../../redux/slices/droneConnectionSlice"
From 5592eb4e47a34e08a276d9a1f2812fa9133a1b55 Mon Sep 17 00:00:00 2001
From: Kwashie A <104215256+Kwash67@users.noreply.github.com>
Date: Thu, 26 Feb 2026 22:12:51 +0000
Subject: [PATCH 8/9] removing unused var
---
gcs/src/components/config/servoOutput.jsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/gcs/src/components/config/servoOutput.jsx b/gcs/src/components/config/servoOutput.jsx
index 54886fa4c..19c1fe5be 100644
--- a/gcs/src/components/config/servoOutput.jsx
+++ b/gcs/src/components/config/servoOutput.jsx
@@ -22,7 +22,6 @@ import { selectAircraftType } from "../../redux/slices/droneInfoSlice"
import {
emitGetServoConfig,
emitSetServoConfigParam,
- emitBatchSetServoConfigParams,
emitTestServoPwm,
selectServoConfig,
selectServoPwmOutputs,
From 9f7cce5e0775489cbe4b8ae3c0b8d421d1b3f2c5 Mon Sep 17 00:00:00 2001
From: Kwashie A <104215256+Kwash67@users.noreply.github.com>
Date: Fri, 27 Feb 2026 21:47:13 +0000
Subject: [PATCH 9/9] completed requested changes
---
gcs/src/components/config/servoOutput.jsx | 24 ++++-----
radio/app/controllers/servoController.py | 61 ++++++++++++++++++++++
radio/app/customTypes.py | 5 ++
radio/app/drone.py | 62 -----------------------
radio/app/endpoints/servo.py | 7 +--
radio/app/endpoints/states.py | 4 +-
6 files changed, 84 insertions(+), 79 deletions(-)
diff --git a/gcs/src/components/config/servoOutput.jsx b/gcs/src/components/config/servoOutput.jsx
index 19c1fe5be..4f0a7ab73 100644
--- a/gcs/src/components/config/servoOutput.jsx
+++ b/gcs/src/components/config/servoOutput.jsx
@@ -53,15 +53,15 @@ function getPercentageValueFromPWM(pwmValue) {
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 servoConfig = useSelector(selectServoConfig)
const servoPwmOutputs = useSelector(selectServoPwmOutputs)
const connected = useSelector(selectConnectedToDrone)
+ const [testModalOpen, setTestModalOpen] = useState(false)
+ const [testServoIdx, setTestServoIdx] = useState(null)
+ const [testPwm, setTestPwm] = useState(1500)
+
// Helper to get paramDef for a given param_id
function getParamDef(param_id) {
if (aircraftType === 1) return apmParamDefsPlane[param_id]
@@ -81,7 +81,7 @@ export default function ServoOutput() {
function handleOpenTestModal(servoNum) {
setTestServoIdx(servoNum)
- setTestPwm(servoPwmOutputs[servoNum] || 1500)
+ setTestPwm(1500)
setTestModalOpen(true)
}
@@ -148,16 +148,16 @@ export default function ServoOutput() {
-
+
#
Position
Reversed
Function
- Min
- Trim
- Max
+ Min
+ Trim
+ Max
Test
@@ -230,7 +230,7 @@ export default function ServoOutput() {
minDef?.Range?.high ? Number(minDef.Range.high) : PWM_MAX
}
size="xs"
- style={{ width: "60px" }}
+ className="w-[3.75rem]"
onChange={(val) => handleParamChange(minParam, val)}
/>
@@ -246,7 +246,7 @@ export default function ServoOutput() {
: PWM_MAX
}
size="xs"
- style={{ width: "60px" }}
+ className="w-[3.75rem]"
onChange={(val) => handleParamChange(trimParam, val)}
/>
@@ -260,7 +260,7 @@ export default function ServoOutput() {
maxDef?.Range?.high ? Number(maxDef.Range.high) : PWM_MAX
}
size="xs"
- style={{ width: "60px" }}
+ className="w-[3.75rem]"
onChange={(val) => handleParamChange(maxParam, val)}
/>
diff --git a/radio/app/controllers/servoController.py b/radio/app/controllers/servoController.py
index 602ae5c86..14037c9b5 100644
--- a/radio/app/controllers/servoController.py
+++ b/radio/app/controllers/servoController.py
@@ -2,7 +2,11 @@
from typing import TYPE_CHECKING
+import serial
+from pymavlink import mavutil
+
from app.customTypes import Number, Response, SetConfigParam
+from app.utils import commandAccepted
if TYPE_CHECKING:
from app.drone import Drone
@@ -147,3 +151,60 @@ def batchSetConfigParams(self, params: list[SetConfigParam]) -> Response:
"message": f"Failed to set {len(param_set_failures)} parameters: {param_set_failures}",
"data": param_set_successes,
}
+
+ def setServo(self, servo_instance: int, pwm_value: int) -> Response:
+ """Set a servo to a specific PWM value.
+
+ Args:
+ servo_instance (int): The number of the servo to set
+ pwm_value (int): The PWM value to set the servo to
+
+ Returns:
+ Response: The response from the servo set command
+ """
+ if not self.drone.reserve_message_type("COMMAND_ACK", self.drone.controller_id):
+ return {
+ "success": False,
+ "message": "Could not reserve COMMAND_ACK messages",
+ }
+
+ try:
+ self.drone.sendCommand(
+ mavutil.mavlink.MAV_CMD_DO_SET_SERVO,
+ param1=servo_instance, # Servo instance number
+ param2=pwm_value, # PWM value
+ )
+
+ response = self.drone.wait_for_message(
+ "COMMAND_ACK",
+ self.drone.controller_id,
+ condition_func=lambda msg: msg.command
+ == mavutil.mavlink.MAV_CMD_DO_SET_SERVO,
+ )
+
+ if commandAccepted(response, mavutil.mavlink.MAV_CMD_DO_SET_SERVO):
+ return {"success": True, "message": f"Setting servo to {pwm_value}"}
+ else:
+ self.drone.logger.error(
+ f"Failed to set servo {servo_instance} to {pwm_value}"
+ )
+ error_message = f"Failed to set servo {servo_instance} to {pwm_value}"
+ error_code = response.result if response else None
+
+ # Map specific error codes to user-friendly messages
+ if error_code == 4: # MAV_RESULT_FAILED
+ error_message = f"Channel {servo_instance} is already in use"
+ elif error_code == 3: # MAV_RESULT_UNSUPPORTED
+ error_message = f"Servo {servo_instance} is not supported"
+ elif error_code == 2: # MAV_RESULT_DENIED
+ error_message = f"Permission denied to set servo {servo_instance}"
+
+ return {"success": False, "message": error_message}
+
+ except serial.serialutil.SerialException:
+ return {
+ "success": False,
+ "message": "Setting servo failed, serial exception",
+ }
+ finally:
+ self.drone.release_message_type("COMMAND_ACK", self.drone.controller_id)
diff --git a/radio/app/customTypes.py b/radio/app/customTypes.py
index 659fb26cb..657c253e9 100644
--- a/radio/app/customTypes.py
+++ b/radio/app/customTypes.py
@@ -50,6 +50,11 @@ class BatchSetConfigParams(TypedDict):
params: list[SetConfigParam]
+class TestServoPwm(TypedDict):
+ servo_instance: int
+ pwm_value: int
+
+
class VehicleType(Enum):
UNKNOWN = 0
FIXED_WING = 1
diff --git a/radio/app/drone.py b/radio/app/drone.py
index e4657213d..9058500c1 100644
--- a/radio/app/drone.py
+++ b/radio/app/drone.py
@@ -962,68 +962,6 @@ def rebootAutopilot(self) -> bool:
self.close()
return True
- # TODO: Move this out into a controller
- @sendingCommandLock
- def setServo(self, servo_instance: int, pwm_value: int) -> Response:
- """Set a servo to a specific PWM value.
-
- Args:
- servo_instance (int): The number of the servo to set
- pwm_value (int): The PWM value to set the servo to
-
- Returns:
- Response: The response from the servo set command
- """
- if not self.reserve_message_type("COMMAND_ACK", self.controller_id):
- return {
- "success": False,
- "message": "Could not reserve COMMAND_ACK messages",
- }
-
- try:
- self.sendCommand(
- mavutil.mavlink.MAV_CMD_DO_SET_SERVO,
- param1=servo_instance, # Servo instance number
- param2=pwm_value, # PWM value
- )
-
- response = self.wait_for_message(
- "COMMAND_ACK",
- self.controller_id,
- condition_func=lambda msg: msg.command
- == mavutil.mavlink.MAV_CMD_DO_SET_SERVO,
- )
-
- if commandAccepted(response, mavutil.mavlink.MAV_CMD_DO_SET_SERVO):
- return {"success": True, "message": f"Setting servo to {pwm_value}"}
- else:
- self.logger.error(
- f"Failed to set servo {servo_instance} to {pwm_value}"
- )
- error_message = f"Failed to set servo {servo_instance} to {pwm_value}"
- error_code = response.result if response else None
-
- # Map specific error codes to user-friendly messages
- if error_code == 4: # MAV_RESULT_FAILED
- error_message = f"Channel {servo_instance} is already in use"
- elif error_code == 3: # MAV_RESULT_UNSUPPORTED
- error_message = f"Servo {servo_instance} is not supported"
- elif error_code == 2: # MAV_RESULT_DENIED
- error_message = f"Permission denied to set servo {servo_instance}"
-
- return {
- "success": False,
- "message": error_message
- }
-
- except serial.serialutil.SerialException:
- return {
- "success": False,
- "message": "Setting servo failed, serial exception",
- }
- finally:
- self.release_message_type("COMMAND_ACK", self.controller_id)
-
def sendCommand(
self,
message: int,
diff --git a/radio/app/endpoints/servo.py b/radio/app/endpoints/servo.py
index 47e34019f..8df97b856 100644
--- a/radio/app/endpoints/servo.py
+++ b/radio/app/endpoints/servo.py
@@ -1,8 +1,9 @@
import app.droneStatus as droneStatus
from app import logger, socketio
-from app.customTypes import BatchSetConfigParams, SetConfigParam
+from app.customTypes import BatchSetConfigParams, SetConfigParam, TestServoPwm
from app.utils import notConnectedError
+
@socketio.on("get_servo_config")
def getServoConfig() -> None:
"""
@@ -105,7 +106,7 @@ def batchSetServoConfigParams(data: BatchSetConfigParams) -> None:
@socketio.on("test_servo_pwm")
-def testServoPwm(data: dict) -> None:
+def testServoPwm(data: TestServoPwm) -> None:
"""
Sends a test PWM value to a servo.
"""
@@ -125,6 +126,6 @@ def testServoPwm(data: dict) -> None:
)
return
- result = droneStatus.drone.setServo(servo_instance, pwm_value)
+ result = droneStatus.drone.servoController.setServo(servo_instance, pwm_value)
socketio.emit("test_servo_result", result)
diff --git a/radio/app/endpoints/states.py b/radio/app/endpoints/states.py
index 6272be18b..d72415f6e 100644
--- a/radio/app/endpoints/states.py
+++ b/radio/app/endpoints/states.py
@@ -39,7 +39,7 @@ class SetStateType(TypedDict):
"RC_CHANNELS",
],
"config.rc": ["RC_CHANNELS"],
- "config.servo": ["SERVO_OUTPUT_RAW", "RC_CHANNELS"],
+ "config.servo": ["SERVO_OUTPUT_RAW"],
}
@@ -111,6 +111,6 @@ def set_state(data: SetStateType) -> None:
droneStatus.drone.sendDataStreamRequestMessage(
mavutil.mavlink.MAV_DATA_STREAM_RC_CHANNELS, 4
)
-
+
for message in STATES_MESSAGE_LISTENERS["config.servo"]:
droneStatus.drone.addMessageListener(message, sendMessage)