Skip to content

Commit 7e3b5cc

Browse files
committed
feat(vehicle-components): extend FRAME_CLASS support to ArduPlane VTOL and Heli variants
- Fall back to Q_FRAME_CLASS/Q_FRAME_TYPE in get_frame_info() for ArduPlane VTOL support - Populate ArduPlane entry in FRAME_CLASS_DICT (mirrors Q_FRAME_CLASS values) - Add missing Heli frame classes (Undefined, SingleCopter, CoaxCopter, BiCopter) - Rename get_frame_class_sub_dict to get_frame_class_as_protocol_dict and change return type to dict[str, dict[str, str]] for _verify_dict_is_uptodate() compatibility - Add get_frame_class_valid_tuple() to exclude "Undefined" from GUI choices - Add ArduPlane to ESC_CONNECTION_DICT (Q_M_PWM_TYPE protocol mapping) - Validate FRAME_CLASS against allowed values in component editor - Update tests and bump verify call count from 5 to 6
1 parent 4f29eba commit 7e3b5cc

7 files changed

Lines changed: 469 additions & 35 deletions

ardupilot_methodic_configurator/backend_flightcontroller_business_logic.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ def get_frame_info(fc_parameters: dict[str, float]) -> tuple[int, int]:
8585
(1, 1)
8686
8787
"""
88-
frame_class = int(fc_parameters.get("FRAME_CLASS", 1)) # Default to QUAD
89-
frame_type = int(fc_parameters.get("FRAME_TYPE", 1)) # Default to X
88+
frame_class = int(fc_parameters.get("FRAME_CLASS", fc_parameters.get("Q_FRAME_CLASS", 1))) # Default to QUAD
89+
frame_type = int(fc_parameters.get("FRAME_TYPE", fc_parameters.get("Q_FRAME_TYPE", 1))) # Default to X
9090
return (frame_class, frame_type)
9191

9292

ardupilot_methodic_configurator/data_model_vehicle_components_import.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
SERVO_FUNCTION_ESC_CONTROL,
3838
ComponentDataModelValidation,
3939
get_esc_connection_sub_dict,
40-
get_frame_class_sub_dict,
40+
get_frame_class_as_protocol_dict,
4141
)
4242

4343

@@ -185,13 +185,18 @@ def process_fc_parameters(
185185
self._verify_dict_is_uptodate(doc, get_esc_connection_sub_dict(fw_type), "MOT_PWM_TYPE", "values")
186186
self._verify_dict_is_uptodate(doc, RC_PROTOCOLS_DICT, "RC_PROTOCOLS", "Bitmask")
187187

188-
# Process frame information if FRAME_CLASS is present in FC parameters
189-
if "FRAME_CLASS" in fc_parameters:
188+
# Compute frame class dict once for both verification and label lookup
189+
frame_class_dict = get_frame_class_as_protocol_dict(fw_type)
190+
# ArduPlane uses Q_FRAME_CLASS in documentation; other types use FRAME_CLASS
191+
frame_class_doc_key = "Q_FRAME_CLASS" if fw_type == "ArduPlane" else "FRAME_CLASS"
192+
self._verify_dict_is_uptodate(doc, frame_class_dict, frame_class_doc_key, "values")
193+
194+
# Process frame information if FRAME_CLASS or Q_FRAME_CLASS is present in FC parameters
195+
if "FRAME_CLASS" in fc_parameters or "Q_FRAME_CLASS" in fc_parameters:
190196
frame_class, _ = get_frame_info(fc_parameters)
191-
self.set_component_value(
192-
("Frame", "Specifications", "Frame class"),
193-
get_frame_class_sub_dict(fw_type).get(frame_class, "Undefined"),
194-
)
197+
frame_class_entry = frame_class_dict.get(str(frame_class))
198+
frame_class_label = frame_class_entry.get("protocol") if isinstance(frame_class_entry, dict) else "Undefined"
199+
self.set_component_value(("Frame", "Specifications", "Frame class"), frame_class_label)
195200

196201
# Process parameters in sequence
197202
self._set_gnss_type_from_fc_parameters(fc_parameters)

ardupilot_methodic_configurator/data_model_vehicle_components_validation.py

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,20 @@ def get_connection_type_tuples_with_labels(connection_types: tuple[str, ...]) ->
283283
"102": {"type": SERIAL_PORTS, "protocol": "CoDevESC", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SAME},
284284
"200": {"type": CAN_PORTS, "protocol": "DroneCAN", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SAME},
285285
},
286+
# ArduPlane uses Q_M_PWM_TYPE instead of MOT_PWM_TYPE and supports DroneCAN for ESCs
287+
"ArduPlane": {
288+
"0": {"type": PWM_OUT_PORTS, "protocol": "Normal", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SCRIPTING_ONLY},
289+
"1": {"type": PWM_OUT_PORTS, "protocol": "OneShot", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SERIAL_ONLY},
290+
"2": {"type": PWM_OUT_PORTS, "protocol": "OneShot125", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SERIAL_ONLY},
291+
"3": {"type": PWM_OUT_PORTS, "protocol": "Brushed", "ESC_to_FC": ESC_TO_FC_TELEMETRY_NONE},
292+
"4": {"type": PWM_OUT_PORTS, "protocol": "DShot150", "ESC_to_FC": ESC_TO_FC_TELEMETRY_DSHOT},
293+
"5": {"type": PWM_OUT_PORTS, "protocol": "DShot300", "ESC_to_FC": ESC_TO_FC_TELEMETRY_DSHOT},
294+
"6": {"type": PWM_OUT_PORTS, "protocol": "DShot600", "ESC_to_FC": ESC_TO_FC_TELEMETRY_DSHOT},
295+
"7": {"type": PWM_OUT_PORTS, "protocol": "DShot1200", "ESC_to_FC": ESC_TO_FC_TELEMETRY_DSHOT},
296+
"8": {"type": PWM_OUT_PORTS, "protocol": "PWMRange", "ESC_to_FC": ESC_TO_FC_TELEMETRY_NONE},
297+
"9": {"type": PWM_OUT_PORTS, "protocol": "PWMAngle", "ESC_to_FC": ESC_TO_FC_TELEMETRY_NONE},
298+
"200": {"type": CAN_PORTS, "protocol": "DroneCAN", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SAME},
299+
},
286300
}
287301

288302

@@ -316,6 +330,8 @@ def get_esc_connection_sub_dict(
316330
"65536": {"type": RC_PORTS + SERIAL_PORTS, "protocol": "MAVRadio"}, # Bit 16
317331
}
318332

333+
# When adding new entries here, make sure to also update the self._verify_dict_is_uptodate() calls
334+
# inside the process_fc_parameters() method in the data_model_vehicle_components_import.py file
319335
FRAME_CLASS_DICT: dict[str, dict[int, str]] = {
320336
"ArduCopter": {
321337
0: "Undefined",
@@ -338,7 +354,11 @@ def get_esc_connection_sub_dict(
338354
17: "Dynamic Scripting Matrix",
339355
},
340356
"Heli": {
357+
0: "Undefined",
341358
6: "Heli",
359+
8: "SingleCopter",
360+
9: "CoaxCopter",
361+
10: "BiCopter",
342362
11: "Heli_Dual",
343363
13: "HeliQuad",
344364
},
@@ -348,13 +368,46 @@ def get_esc_connection_sub_dict(
348368
2: "Boat",
349369
3: "BalanceBot",
350370
},
351-
"ArduPlane": {},
371+
# ArduPlane does not have a FRAME_CLASS parameter, it uses Q_FRAME_CLASS instead for the same purpose.
372+
# We added it here to unify and simplify the GUI as they serve the same purpose of defining the number of motors users
373+
# used in active hover propulsion.
374+
"ArduPlane": {
375+
0: "Undefined",
376+
1: "Quad",
377+
2: "Hexa",
378+
3: "Octa",
379+
4: "OctaQuad",
380+
5: "Y6",
381+
7: "Tri",
382+
10: "Single/Dual",
383+
12: "DodecaHexa",
384+
14: "Deca",
385+
15: "Scripting Matrix",
386+
17: "Dynamic Scripting Matrix",
387+
},
352388
}
353389

354390

355-
def get_frame_class_sub_dict(vehicle_type: str) -> dict[int, str]:
356-
"""Return the vehicle-type-specific frame class mapping from FRAME_CLASS_DICT."""
357-
return FRAME_CLASS_DICT.get(vehicle_type, FRAME_CLASS_DICT["ArduCopter"])
391+
def get_frame_class_as_protocol_dict(vehicle_type: str) -> dict[str, dict[str, str]]:
392+
"""
393+
Return the vehicle-type-specific frame class mapping from FRAME_CLASS_DICT.
394+
395+
Each entry is shaped as a documentation-compatible protocol dictionary so
396+
_verify_dict_is_uptodate() can handle FRAME_CLASS metadata unchanged.
397+
"""
398+
return {
399+
str(frame_class): {"protocol": frame_class_name}
400+
for frame_class, frame_class_name in FRAME_CLASS_DICT.get(vehicle_type, FRAME_CLASS_DICT["ArduCopter"]).items()
401+
}
402+
403+
404+
def get_frame_class_valid_tuple(vehicle_type: str) -> tuple[str, ...]:
405+
"""
406+
Return the valid frame-class labels for the given vehicle type, excluding "Undefined".
407+
408+
"Undefined" is not a valid user selection and should not appear in the combobox.
409+
"""
410+
return tuple(v for v in FRAME_CLASS_DICT.get(vehicle_type, FRAME_CLASS_DICT["ArduCopter"]).values() if v != "Undefined")
358411

359412

360413
class ComponentDataModelValidation(ComponentDataModelBase):
@@ -387,6 +440,9 @@ def set_component_value(self, path: ComponentPath, value: Union[ComponentData, C
387440
("Battery", "Specifications", vtype), BatteryCell.recommended_cell_voltage(value, vtype)
388441
)
389442

443+
if path == ("Flight Controller", "Firmware", "Type") and isinstance(value, str):
444+
self._possible_choices[("Frame", "Specifications", "Frame class")] = get_frame_class_valid_tuple(value)
445+
390446
# Update possible choices for protocol fields when connection type changes
391447
self._update_possible_choices_for_path(path, value)
392448

@@ -538,6 +594,7 @@ def get_connection_types(conn_dict: dict) -> tuple[str, ...]:
538594
("GNSS Receiver", "FC Connection", "Type"): ("None", *SERIAL_PORTS, *CAN_PORTS),
539595
("GNSS Receiver", "FC Connection", "Protocol"): get_all_protocols(GNSS_RECEIVER_CONNECTION),
540596
("Battery", "Specifications", "Chemistry"): BatteryCell.chemistries(),
597+
("Frame", "Specifications", "Frame class"): get_frame_class_valid_tuple(fw_type),
541598
}
542599
for component in ["RC Receiver", "Telemetry", "Battery Monitor", "ESC", "GNSS Receiver"]:
543600
if component not in self._data.get("Components", {}):

tests/test_backend_flightcontroller_business_logic.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,69 @@ def test_with_default_values(self) -> None:
131131
assert frame_class == 1 # Default QUAD
132132
assert frame_type == 1 # Default X
133133

134+
def test_vtol_plane_uses_q_frame_class_when_frame_class_absent(self) -> None:
135+
"""
136+
ArduPlane VTOL frame class is read from Q_FRAME_CLASS when FRAME_CLASS is absent.
137+
138+
GIVEN: FC parameters with Q_FRAME_CLASS set (ArduPlane VTOL) but no FRAME_CLASS
139+
WHEN: Extracting frame info
140+
THEN: Q_FRAME_CLASS value is used as the frame class
141+
"""
142+
params = {"Q_FRAME_CLASS": 2.0, "Q_FRAME_TYPE": 1.0}
143+
frame_class, frame_type = get_frame_info(params)
144+
assert frame_class == 2 # Hexa from Q_FRAME_CLASS
145+
assert frame_type == 1 # X from Q_FRAME_TYPE
146+
147+
def test_vtol_plane_uses_q_frame_type_when_frame_type_absent(self) -> None:
148+
"""
149+
ArduPlane VTOL frame type is read from Q_FRAME_TYPE when FRAME_TYPE is absent.
150+
151+
GIVEN: FC parameters with Q_FRAME_TYPE set but no FRAME_TYPE
152+
WHEN: Extracting frame info
153+
THEN: Q_FRAME_TYPE value is used as the frame type
154+
"""
155+
params = {"FRAME_CLASS": 1.0, "Q_FRAME_TYPE": 3.0}
156+
frame_class, frame_type = get_frame_info(params)
157+
assert frame_class == 1
158+
assert frame_type == 3 # H from Q_FRAME_TYPE fallback
159+
160+
def test_frame_class_takes_priority_over_q_frame_class_when_both_present(self) -> None:
161+
"""
162+
FRAME_CLASS takes precedence over Q_FRAME_CLASS when both are present.
163+
164+
GIVEN: FC parameters with both FRAME_CLASS and Q_FRAME_CLASS
165+
WHEN: Extracting frame info
166+
THEN: FRAME_CLASS is used and Q_FRAME_CLASS is ignored
167+
"""
168+
params = {"FRAME_CLASS": 3.0, "Q_FRAME_CLASS": 1.0, "FRAME_TYPE": 1.0}
169+
frame_class, _ = get_frame_info(params)
170+
assert frame_class == 3 # Octa from FRAME_CLASS, not Quad from Q_FRAME_CLASS
171+
172+
def test_frame_type_takes_priority_over_q_frame_type_when_both_present(self) -> None:
173+
"""
174+
FRAME_TYPE takes precedence over Q_FRAME_TYPE when both are present.
175+
176+
GIVEN: FC parameters with both FRAME_TYPE and Q_FRAME_TYPE
177+
WHEN: Extracting frame info
178+
THEN: FRAME_TYPE is used and Q_FRAME_TYPE is ignored
179+
"""
180+
params = {"FRAME_CLASS": 1.0, "FRAME_TYPE": 2.0, "Q_FRAME_TYPE": 5.0}
181+
_, frame_type = get_frame_info(params)
182+
assert frame_type == 2 # V from FRAME_TYPE, not A-Tail from Q_FRAME_TYPE
183+
184+
def test_defaults_to_1_when_all_frame_params_absent(self) -> None:
185+
"""
186+
Both frame class and type default to 1 when neither standard nor Q parameters exist.
187+
188+
GIVEN: FC parameters with no FRAME_CLASS, Q_FRAME_CLASS, FRAME_TYPE, or Q_FRAME_TYPE
189+
WHEN: Extracting frame info
190+
THEN: Both values default to 1 (Quad, X)
191+
"""
192+
params = {"BATT_MONITOR": 4.0}
193+
frame_class, frame_type = get_frame_info(params)
194+
assert frame_class == 1
195+
assert frame_type == 1
196+
134197

135198
class TestBatteryVoltageValidation:
136199
"""Test battery voltage validation logic."""

tests/test_data_model_vehicle_components_import.py

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,55 @@ def test_user_can_import_frame_class_from_fc(self, realistic_model) -> None:
320320
frame_class = realistic_model.get_component_value(("Frame", "Specifications", "Frame class"))
321321
assert frame_class == "Octa"
322322

323+
def test_user_can_import_frame_class_from_q_frame_class_for_vtol_plane(self, realistic_model) -> None:
324+
"""
325+
ArduPlane VTOL frame class is imported from Q_FRAME_CLASS when FRAME_CLASS is absent.
326+
327+
GIVEN: FC parameters with Q_FRAME_CLASS set to 2 (Hexa) but no FRAME_CLASS key
328+
WHEN: Processing FC parameters for an ArduPlane firmware model
329+
THEN: Frame class in the component data should be set to 'Hexa'
330+
"""
331+
realistic_model.set_component_value(("Flight Controller", "Firmware", "Type"), "ArduPlane")
332+
fc_parameters = {"Q_FRAME_CLASS": 2, "Q_FRAME_TYPE": 1}
333+
334+
with patch.object(realistic_model, "_verify_dict_is_uptodate", return_value=True):
335+
realistic_model.process_fc_parameters(fc_parameters, {})
336+
337+
frame_class = realistic_model.get_component_value(("Frame", "Specifications", "Frame class"))
338+
assert frame_class == "Hexa"
339+
340+
def test_frame_class_takes_priority_over_q_frame_class_during_import(self, realistic_model) -> None:
341+
"""
342+
FRAME_CLASS takes precedence over Q_FRAME_CLASS when both are present in FC parameters.
343+
344+
GIVEN: FC parameters containing both FRAME_CLASS (3=Octa) and Q_FRAME_CLASS (1=Quad)
345+
WHEN: Processing FC parameters
346+
THEN: Frame class is set from FRAME_CLASS (Octa), not Q_FRAME_CLASS (Quad)
347+
"""
348+
fc_parameters = {"FRAME_CLASS": 3, "Q_FRAME_CLASS": 1}
349+
350+
with patch.object(realistic_model, "_verify_dict_is_uptodate", return_value=True):
351+
realistic_model.process_fc_parameters(fc_parameters, {})
352+
353+
frame_class = realistic_model.get_component_value(("Frame", "Specifications", "Frame class"))
354+
assert frame_class == "Octa"
355+
356+
def test_frame_class_set_to_undefined_when_code_not_in_dict(self, realistic_model) -> None:
357+
"""
358+
Frame class is set to 'Undefined' when the numeric code is not in the vehicle's dict.
359+
360+
GIVEN: FC parameters with a FRAME_CLASS code that has no label for the current firmware type
361+
WHEN: Processing FC parameters
362+
THEN: Frame class component value is set to 'Undefined'
363+
"""
364+
fc_parameters = {"FRAME_CLASS": 999} # Unknown code
365+
366+
with patch.object(realistic_model, "_verify_dict_is_uptodate", return_value=True):
367+
realistic_model.process_fc_parameters(fc_parameters, {})
368+
369+
frame_class = realistic_model.get_component_value(("Frame", "Specifications", "Frame class"))
370+
assert frame_class == "Undefined"
371+
323372
def test_user_can_import_esc_connection_and_telemetry_from_serial_fc(self, realistic_model) -> None:
324373
"""
325374
Import ESC serial config into FC->ESC Connection and ESC->FC Telemetry.
@@ -1110,7 +1159,7 @@ def test_system_attempts_verification_for_all_dictionaries(self, realistic_model
11101159
11111160
GIVEN: Flight controller parameters and documentation that fails verification
11121161
WHEN: Processing FC parameters
1113-
THEN: Verification should be attempted for all 5 dictionaries
1162+
THEN: Verification should be attempted for all 6 dictionaries
11141163
AND: Processing should continue despite verification failures
11151164
"""
11161165
fc_parameters = {
@@ -1127,8 +1176,8 @@ def test_system_attempts_verification_for_all_dictionaries(self, realistic_model
11271176
with patch.object(realistic_model, "_verify_dict_is_uptodate", return_value=False) as mock_verify:
11281177
realistic_model.process_fc_parameters(fc_parameters, doc)
11291178

1130-
# Should call verification 5 times (once for each dictionary)
1131-
assert mock_verify.call_count == 5
1179+
# Should call verification 6 times (once for each dictionary)
1180+
assert mock_verify.call_count == 6
11321181

11331182
def test_system_correctly_validates_rc_protocol_power_of_two(self, realistic_model) -> None:
11341183
"""

0 commit comments

Comments
 (0)