Skip to content

Commit e9443de

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 e9443de

7 files changed

Lines changed: 454 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: 7 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

@@ -184,14 +184,14 @@ def process_fc_parameters(
184184
fw_type = str(self.get_component_value(("Flight Controller", "Firmware", "Type")) or "")
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")
187+
self._verify_dict_is_uptodate(doc, get_frame_class_as_protocol_dict(fw_type), "FRAME_CLASS", "values")
187188

188-
# Process frame information if FRAME_CLASS is present in FC parameters
189-
if "FRAME_CLASS" in fc_parameters:
189+
# Process frame information if FRAME_CLASS or Q_FRAME_CLASS is present in FC parameters
190+
if "FRAME_CLASS" in fc_parameters or "Q_FRAME_CLASS" in fc_parameters:
190191
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-
)
192+
frame_class_entry = get_frame_class_as_protocol_dict(fw_type).get(str(frame_class))
193+
frame_class_label = frame_class_entry.get("protocol") if isinstance(frame_class_entry, dict) else "Undefined"
194+
self.set_component_value(("Frame", "Specifications", "Frame class"), frame_class_label)
195195

196196
# Process parameters in sequence
197197
self._set_gnss_type_from_fc_parameters(fc_parameters)

ardupilot_methodic_configurator/data_model_vehicle_components_validation.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,19 @@ 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
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+
},
286299
}
287300

288301

@@ -316,6 +329,8 @@ def get_esc_connection_sub_dict(
316329
"65536": {"type": RC_PORTS + SERIAL_PORTS, "protocol": "MAVRadio"}, # Bit 16
317330
}
318331

332+
# When adding new entries here, make sure to also update the self._verify_dict_is_uptodate() calls
333+
# inside the process_fc_parameters() method in the data_model_vehicle_components_import.py file
319334
FRAME_CLASS_DICT: dict[str, dict[int, str]] = {
320335
"ArduCopter": {
321336
0: "Undefined",
@@ -338,7 +353,11 @@ def get_esc_connection_sub_dict(
338353
17: "Dynamic Scripting Matrix",
339354
},
340355
"Heli": {
356+
0: "Undefined",
341357
6: "Heli",
358+
8: "SingleCopter",
359+
9: "CoaxCopter",
360+
10: "BiCopter",
342361
11: "Heli_Dual",
343362
13: "HeliQuad",
344363
},
@@ -348,13 +367,41 @@ def get_esc_connection_sub_dict(
348367
2: "Boat",
349368
3: "BalanceBot",
350369
},
351-
"ArduPlane": {},
370+
# ArduPlane does not have a FRAME_CLASS parameter, it uses Q_FRAME_CLASS instead for the same purpose.
371+
# We added it here to unify and simplify the GUI as they serve the same purpose of defining the number of motors users
372+
# used in active hover propulsion.
373+
"ArduPlane": {
374+
0: "Undefined",
375+
1: "Quad",
376+
2: "Hexa",
377+
3: "Octa",
378+
4: "OctaQuad",
379+
5: "Y6",
380+
7: "Tri",
381+
10: "Single/Dual",
382+
12: "DodecaHexa",
383+
14: "Deca",
384+
15: "Scripting Matrix",
385+
17: "Dynamic Scripting Matrix",
386+
},
352387
}
353388

354389

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"])
390+
def get_frame_class_as_protocol_dict(vehicle_type: str) -> dict[str, dict[str, str]]:
391+
"""
392+
Return the vehicle-type-specific frame class mapping from FRAME_CLASS_DICT.
393+
394+
Each entry is shaped as a documentation-compatible protocol dictionary so
395+
_verify_dict_is_uptodate() can handle FRAME_CLASS metadata unchanged.
396+
"""
397+
return {
398+
str(frame_class): {"protocol": frame_class_name}
399+
for frame_class, frame_class_name in FRAME_CLASS_DICT.get(vehicle_type, FRAME_CLASS_DICT["ArduCopter"]).items()
400+
}
401+
402+
403+
def get_frame_class_valid_tuple(vehicle_type: str) -> tuple[str, ...]:
404+
return tuple(v for v in FRAME_CLASS_DICT.get(vehicle_type, FRAME_CLASS_DICT["ArduCopter"]).values() if v != "Undefined")
358405

359406

360407
class ComponentDataModelValidation(ComponentDataModelBase):
@@ -387,6 +434,9 @@ def set_component_value(self, path: ComponentPath, value: Union[ComponentData, C
387434
("Battery", "Specifications", vtype), BatteryCell.recommended_cell_voltage(value, vtype)
388435
)
389436

437+
if path == ("Flight Controller", "Firmware", "Type") and isinstance(value, str):
438+
self._possible_choices[("Frame", "Specifications", "Frame class")] = get_frame_class_valid_tuple(value)
439+
390440
# Update possible choices for protocol fields when connection type changes
391441
self._update_possible_choices_for_path(path, value)
392442

@@ -538,6 +588,7 @@ def get_connection_types(conn_dict: dict) -> tuple[str, ...]:
538588
("GNSS Receiver", "FC Connection", "Type"): ("None", *SERIAL_PORTS, *CAN_PORTS),
539589
("GNSS Receiver", "FC Connection", "Protocol"): get_all_protocols(GNSS_RECEIVER_CONNECTION),
540590
("Battery", "Specifications", "Chemistry"): BatteryCell.chemistries(),
591+
("Frame", "Specifications", "Frame class"): get_frame_class_valid_tuple(fw_type),
541592
}
542593
for component in ["RC Receiver", "Telemetry", "Battery Monitor", "ESC", "GNSS Receiver"]:
543594
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)