diff --git a/gcs/src/components/config/radioCalibration.jsx b/gcs/src/components/config/radioCalibration.jsx index 2800b5e2f..06dbf3232 100644 --- a/gcs/src/components/config/radioCalibration.jsx +++ b/gcs/src/components/config/radioCalibration.jsx @@ -5,14 +5,10 @@ */ // Base imports -import { useEffect } from "react" - -// Styling imports -import resolveConfig from "tailwindcss/resolveConfig" -import tailwindConfig from "../../../tailwind.config" +import { useEffect, useMemo } from "react" // Helper javascript files -import { Progress } from "@mantine/core" +import { Checkbox, Progress, Select, Table } from "@mantine/core" import apmParamDefsCopter from "../../../data/gen_apm_params_def_copter.json" import apmParamDefsPlane from "../../../data/gen_apm_params_def_plane.json" @@ -20,20 +16,25 @@ import apmParamDefsPlane from "../../../data/gen_apm_params_def_plane.json" import { useDispatch, useSelector } from "react-redux" import { emitGetRcConfig, - selectRadioChannels, + emitSetRcConfigParam, selectRadioChannelsConfig, + selectRadioPwmChannels, } from "../../redux/slices/configSlice" import { emitSetState, selectConnectedToDrone, } from "../../redux/slices/droneConnectionSlice" -import { selectAircraftType } from "../../redux/slices/droneInfoSlice" +import { selectAircraftTypeString } from "../../redux/slices/droneInfoSlice" -// Tailwind +// Styling imports +import resolveConfig from "tailwindcss/resolveConfig" +import tailwindConfig from "../../../tailwind.config" const tailwindColors = resolveConfig(tailwindConfig).theme.colors + const PWM_MIN = 800 const PWM_MAX = 2200 -const colors = [ + +const COLOURS = [ tailwindColors.red[500], tailwindColors.orange[500], tailwindColors.yellow[500], @@ -52,17 +53,50 @@ function getPercentageValueFromPWM(pwmValue) { export default function RadioCalibration() { const dispatch = useDispatch() const connected = useSelector(selectConnectedToDrone) - const aircraftType = useSelector(selectAircraftType) - const channels = useSelector(selectRadioChannels) + const aircraftTypeString = useSelector(selectAircraftTypeString) + const pwmChannels = useSelector(selectRadioPwmChannels) const channelsConfig = useSelector(selectRadioChannelsConfig) - function getReadableRcOption(option) { - if (option === 0) return null - if (aircraftType === 1) { - return apmParamDefsPlane.RC5_OPTION.Values[`${option}`] ?? option - } else if (aircraftType === 2) { - return apmParamDefsCopter.RC5_OPTION.Values[`${option}`] ?? option - } + const paramDefs = useMemo(() => { + return aircraftTypeString === "Copter" + ? apmParamDefsCopter + : apmParamDefsPlane + }, [aircraftTypeString]) + + const rcSelectOptions = useMemo(() => { + const rcOptions = paramDefs.RC1_OPTION?.Values + if (!rcOptions) return [] + + return Object.entries(rcOptions) + .sort((a, b) => { + // Always put "0" first + if (a[0] === "0") return -1 + if (b[0] === "0") return 1 + // Then sort by label alphabetically + return a[1].localeCompare(b[1]) + }) + .map(([value, label]) => ({ + value: value, + label: label, + })) + }, [paramDefs]) + + function handleRcOptionChange(channel, newOption) { + dispatch( + emitSetRcConfigParam({ + param_id: `RC${channel}_OPTION`, + value: parseInt(newOption), + }), + ) + } + + function handleReversedChange(channel, isReversed) { + dispatch( + emitSetRcConfigParam({ + param_id: `RC${channel}_REVERSED`, + value: isReversed ? 1 : 0, + }), + ) } useEffect(() => { @@ -73,32 +107,60 @@ export default function RadioCalibration() { }, [connected]) return ( -
- - - {Object.keys(channels).map((channel) => ( - - -
- {channel} - {channelsConfig[channel]?.map ?? - getReadableRcOption(channelsConfig[channel]?.option)} - - +
+ + + + Channel + Function/Option + Reversed + PWM Value + + + + {Object.keys(pwmChannels).map((channel) => ( + + + RC{channel} + + + {channelsConfig[channel]?.map ? ( + {channelsConfig[channel].map} + ) : ( +
+ +
) } diff --git a/gcs/src/redux/middleware/emitters.js b/gcs/src/redux/middleware/emitters.js index aa4081378..db55b1b6e 100644 --- a/gcs/src/redux/middleware/emitters.js +++ b/gcs/src/redux/middleware/emitters.js @@ -8,6 +8,7 @@ import { emitSetFlightMode, emitSetGripper, emitSetGripperConfigParam, + emitSetRcConfigParam, emitTestAllMotors, emitTestMotorSequence, emitTestOneMotor, @@ -297,6 +298,15 @@ export function handleEmitters(socket, store, action) { emitter: emitGetRcConfig, callback: () => socket.socket.emit("get_rc_config"), }, + { + emitter: emitSetRcConfigParam, + callback: () => { + socket.socket.emit("set_rc_config_param", { + param_id: action.payload.param_id, + value: action.payload.value, + }) + }, + }, ] for (const { emitter, callback } of emitHandlers) { diff --git a/gcs/src/redux/middleware/socketMiddleware.js b/gcs/src/redux/middleware/socketMiddleware.js index 42afca480..f9aa83210 100644 --- a/gcs/src/redux/middleware/socketMiddleware.js +++ b/gcs/src/redux/middleware/socketMiddleware.js @@ -42,10 +42,11 @@ import { setGetGripperEnabled, setGripperConfig, setNumberOfMotors, - setRadioChannels, + setRadioPwmChannels, setRefreshingFlightModeData, setRefreshingGripperConfigData, setShowMotorTestWarningModal, + updateChannelsConfigParam, updateGripperConfigParam, } from "../slices/configSlice.js" import { @@ -150,6 +151,7 @@ const ConfigSpecificSocketEvents = Object.freeze({ onSetFlightModeResult: "set_flight_mode_result", onFrameTypeConfig: "frame_type_config", onRcConfig: "rc_config", + onSetRcConfigResult: "set_rc_config_result", }) const socketMiddleware = (store) => { @@ -162,7 +164,7 @@ const socketMiddleware = (store) => { chans[i] = msg[`chan${i}_raw`] } - store.dispatch(setRadioChannels(chans)) + store.dispatch(setRadioPwmChannels(chans)) } const incomingMessageHandler = (msg) => { @@ -809,6 +811,23 @@ const socketMiddleware = (store) => { store.dispatch(setChannelsConfig(config)) }) + socket.socket.on( + ConfigSpecificSocketEvents.onSetRcConfigResult, + (msg) => { + if (msg.success) { + showSuccessNotification(msg.message) + store.dispatch( + updateChannelsConfigParam({ + param_id: msg.param_id, + value: msg.value, + }), + ) + } else { + showErrorNotification(msg.message) + } + }, + ) + /* Generic Drone Data */ diff --git a/gcs/src/redux/slices/configSlice.js b/gcs/src/redux/slices/configSlice.js index d6045cded..f8f0efdbd 100644 --- a/gcs/src/redux/slices/configSlice.js +++ b/gcs/src/redux/slices/configSlice.js @@ -23,7 +23,7 @@ const configSlice = createSlice({ frameClass: null, numberOfMotors: 4, showMotorTestWarningModal: true, - radioChannels: { + radioPwmChannels: { 1: 0, 2: 0, 3: 0, @@ -101,14 +101,38 @@ const configSlice = createSlice({ if (action.payload === state.showMotorTestWarningModal) return state.showMotorTestWarningModal = action.payload }, - setRadioChannels: (state, action) => { - if (action.payload === state.radioChannels) return - state.radioChannels = action.payload + setRadioPwmChannels: (state, action) => { + if (action.payload === state.radioPwmChannels) return + state.radioPwmChannels = action.payload }, setChannelsConfig: (state, action) => { if (action.payload === state.radioChannelsConfig) return state.radioChannelsConfig = action.payload }, + updateChannelsConfigParam: (state, action) => { + const { param_id, value } = action.payload + // param_id is like "RC1_OPTION", "RC2_REVERSED", etc. so we need to separate out the channel number + const match = param_id.match(/^RC(\d+)_/)[1] + if (!match) return + + const channelNum = match[1] + + if (!state.radioChannelsConfig[channelNum]) return + + // Get if its an option or reversed parameter + const isOption = param_id.endsWith("_OPTION") + const isReversed = param_id.endsWith("_REVERSED") + if (!isOption && !isReversed) return + + if (isOption) { + // For option, value should be an integer + if (state.radioChannelsConfig[channelNum].option === value) return + state.radioChannelsConfig[channelNum].option = value + } else if (isReversed) { + if (state.radioChannelsConfig[channelNum].reversed === value) return + state.radioChannelsConfig[channelNum].reversed = value + } + }, // Emits emitGetGripperEnabled: () => {}, @@ -123,6 +147,7 @@ const configSlice = createSlice({ emitTestMotorSequence: () => {}, emitTestAllMotors: () => {}, emitGetRcConfig: () => {}, + emitSetRcConfigParam: () => {}, }, selectors: { selectGetGripperEnabled: (state) => state.getGripperEnabled, @@ -139,7 +164,7 @@ const configSlice = createSlice({ selectFrameClass: (state) => state.frameClass, selectNumberOfMotors: (state) => state.numberOfMotors, selectShowMotorTestWarningModal: (state) => state.showMotorTestWarningModal, - selectRadioChannels: (state) => state.radioChannels, + selectRadioPwmChannels: (state) => state.radioPwmChannels, selectRadioChannelsConfig: (state) => state.radioChannelsConfig, }, }) @@ -159,8 +184,9 @@ export const { setFrameClass, setNumberOfMotors, setShowMotorTestWarningModal, - setRadioChannels, + setRadioPwmChannels, setChannelsConfig, + updateChannelsConfigParam, // Emitters emitGetGripperEnabled, @@ -175,6 +201,7 @@ export const { emitTestMotorSequence, emitTestAllMotors, emitGetRcConfig, + emitSetRcConfigParam, } = configSlice.actions export const { @@ -191,7 +218,7 @@ export const { selectFrameClass, selectNumberOfMotors, selectShowMotorTestWarningModal, - selectRadioChannels, + selectRadioPwmChannels, selectRadioChannelsConfig, } = configSlice.selectors diff --git a/radio/app/controllers/gripperController.py b/radio/app/controllers/gripperController.py index d65f09219..a6d11520b 100644 --- a/radio/app/controllers/gripperController.py +++ b/radio/app/controllers/gripperController.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING import serial -from app.customTypes import Response +from app.customTypes import Number, Response from app.utils import commandAccepted from pymavlink import mavutil @@ -158,7 +158,7 @@ def getConfig(self) -> dict: return config - def setGripperParam(self, param_id: str, value: float) -> bool: + def setGripperParam(self, param_id: str, value: Number) -> bool: """ Sets a gripper related parameter on the drone. """ diff --git a/radio/app/controllers/rcController.py b/radio/app/controllers/rcController.py index 221610479..9ad889f0c 100644 --- a/radio/app/controllers/rcController.py +++ b/radio/app/controllers/rcController.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING +from app.customTypes import Number + if TYPE_CHECKING: from app.drone import Drone @@ -16,6 +18,7 @@ def __init__(self, drone: Drone) -> None: """ self.drone = drone self.params: dict = {} + self.param_types: dict = {} self.fetchParams() @@ -33,6 +36,7 @@ def _getAndSetParam( 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 @@ -57,6 +61,7 @@ def _getAndSetCachedParam( ) if fetched_param: params_dict[param_key] = fetched_param.param_value + self.param_types[param_name] = fetched_param.param_type def fetchParams(self) -> None: """ @@ -107,3 +112,11 @@ def getConfig(self) -> dict: self.params[f"RC_{channel_number}"] = channel_params return self.params + + def setConfigParam(self, param_id: str, value: Number) -> bool: + """ + Sets a RC 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/customTypes.py b/radio/app/customTypes.py index b05b08789..cfc851665 100644 --- a/radio/app/customTypes.py +++ b/radio/app/customTypes.py @@ -41,6 +41,11 @@ class SetFlightModeValueAndNumber(TypedDict): flight_mode: int +class SetConfigParam(TypedDict): + param_id: str + value: Number + + class VehicleType(Enum): UNKNOWN = 0 FIXED_WING = 1 diff --git a/radio/app/endpoints/gripper.py b/radio/app/endpoints/gripper.py index 542fccf31..464ad4070 100644 --- a/radio/app/endpoints/gripper.py +++ b/radio/app/endpoints/gripper.py @@ -1,5 +1,6 @@ import app.droneStatus as droneStatus from app import logger, socketio +from app.customTypes import SetConfigParam from app.utils import droneErrorCb @@ -83,7 +84,7 @@ def getGripperConfig() -> None: @socketio.on("set_gripper_config_param") -def setGripperParam(data: dict) -> None: +def setGripperParam(data: SetConfigParam) -> None: """ Sets a gripper parameter based off data passed in, only works when the config page is loaded. """ diff --git a/radio/app/endpoints/rc.py b/radio/app/endpoints/rc.py index 9d7956f3f..15603a238 100644 --- a/radio/app/endpoints/rc.py +++ b/radio/app/endpoints/rc.py @@ -1,5 +1,6 @@ import app.droneStatus as droneStatus from app import logger, socketio +from app.customTypes import SetConfigParam from app.utils import notConnectedError @@ -28,3 +29,47 @@ def getRcConfig() -> None: "rc_config", droneStatus.drone.rcController.params, ) + + +@socketio.on("set_rc_config_param") +def setRcConfigParam(data: SetConfigParam) -> None: + """ + Sets a RC config parameter on the drone. + """ + if droneStatus.state != "config.rc": + socketio.emit( + "params_error", + { + "message": "You must be on the config screen to set RC config parameters." + }, + ) + logger.debug(f"Current state: {droneStatus.state}") + return + + if not droneStatus.drone: + return notConnectedError(action="set a RC 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.rcController.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_rc_config_result", result) diff --git a/radio/app/endpoints/states.py b/radio/app/endpoints/states.py index 0eccecb95..4aec449cd 100644 --- a/radio/app/endpoints/states.py +++ b/radio/app/endpoints/states.py @@ -50,6 +50,7 @@ def set_state(data: SetStateType) -> None: "missions": ["GLOBAL_POSITION_INT", "NAV_CONTROLLER_OUTPUT", "HEARTBEAT"], "graphs": ["VFR_HUD", "ATTITUDE", "SYS_STATUS"], "config.flight_modes": ["RC_CHANNELS", "HEARTBEAT"], + "config.rc": ["RC_CHANNELS", "HEARTBEAT"], } droneStatus.drone.logger.info(f"Changing state to {droneStatus.state}") @@ -98,3 +99,6 @@ def set_state(data: SetStateType) -> None: droneStatus.drone.sendDataStreamRequestMessage( mavutil.mavlink.MAV_DATA_STREAM_RC_CHANNELS, 4 ) + + for message in message_listeners["config.rc"]: + droneStatus.drone.addMessageListener(message, sendMessage) diff --git a/radio/tests/test_states.py b/radio/tests/test_states.py index 81c9c6e9e..8e407b40d 100644 --- a/radio/tests/test_states.py +++ b/radio/tests/test_states.py @@ -46,7 +46,7 @@ def test_setState(socketio_client: SocketIOTestClient, droneStatus) -> None: socketio_client.emit("set_state", {"state": "config.rc"}) assert len(socketio_client.get_received()) == 0 - assert len(droneStatus.drone.message_listeners) == 0 + assert len(droneStatus.drone.message_listeners) == 2 droneStatus.drone.message_listeners = {}