From 7e3b5cc59d3332cb5230cee4bb170e58735af0c9 Mon Sep 17 00:00:00 2001 From: "Dr.-Ing. Amilcar do Carmo Lucas" Date: Sun, 3 May 2026 20:00:24 +0200 Subject: [PATCH] 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 --- ...backend_flightcontroller_business_logic.py | 4 +- .../data_model_vehicle_components_import.py | 19 +- ...ata_model_vehicle_components_validation.py | 65 ++++++- ...backend_flightcontroller_business_logic.py | 63 +++++++ ...st_data_model_vehicle_components_import.py | 55 +++++- ...ata_model_vehicle_components_validation.py | 120 ++++++++++++ ...vehicle_components_validation_constants.py | 178 ++++++++++++++++-- 7 files changed, 469 insertions(+), 35 deletions(-) diff --git a/ardupilot_methodic_configurator/backend_flightcontroller_business_logic.py b/ardupilot_methodic_configurator/backend_flightcontroller_business_logic.py index bef38809e..4944525a6 100644 --- a/ardupilot_methodic_configurator/backend_flightcontroller_business_logic.py +++ b/ardupilot_methodic_configurator/backend_flightcontroller_business_logic.py @@ -85,8 +85,8 @@ def get_frame_info(fc_parameters: dict[str, float]) -> tuple[int, int]: (1, 1) """ - frame_class = int(fc_parameters.get("FRAME_CLASS", 1)) # Default to QUAD - frame_type = int(fc_parameters.get("FRAME_TYPE", 1)) # Default to X + frame_class = int(fc_parameters.get("FRAME_CLASS", fc_parameters.get("Q_FRAME_CLASS", 1))) # Default to QUAD + frame_type = int(fc_parameters.get("FRAME_TYPE", fc_parameters.get("Q_FRAME_TYPE", 1))) # Default to X return (frame_class, frame_type) diff --git a/ardupilot_methodic_configurator/data_model_vehicle_components_import.py b/ardupilot_methodic_configurator/data_model_vehicle_components_import.py index e0e20c32c..94acd1511 100644 --- a/ardupilot_methodic_configurator/data_model_vehicle_components_import.py +++ b/ardupilot_methodic_configurator/data_model_vehicle_components_import.py @@ -37,7 +37,7 @@ SERVO_FUNCTION_ESC_CONTROL, ComponentDataModelValidation, get_esc_connection_sub_dict, - get_frame_class_sub_dict, + get_frame_class_as_protocol_dict, ) @@ -185,13 +185,18 @@ def process_fc_parameters( self._verify_dict_is_uptodate(doc, get_esc_connection_sub_dict(fw_type), "MOT_PWM_TYPE", "values") self._verify_dict_is_uptodate(doc, RC_PROTOCOLS_DICT, "RC_PROTOCOLS", "Bitmask") - # Process frame information if FRAME_CLASS is present in FC parameters - if "FRAME_CLASS" in fc_parameters: + # Compute frame class dict once for both verification and label lookup + frame_class_dict = get_frame_class_as_protocol_dict(fw_type) + # ArduPlane uses Q_FRAME_CLASS in documentation; other types use FRAME_CLASS + frame_class_doc_key = "Q_FRAME_CLASS" if fw_type == "ArduPlane" else "FRAME_CLASS" + self._verify_dict_is_uptodate(doc, frame_class_dict, frame_class_doc_key, "values") + + # Process frame information if FRAME_CLASS or Q_FRAME_CLASS is present in FC parameters + if "FRAME_CLASS" in fc_parameters or "Q_FRAME_CLASS" in fc_parameters: frame_class, _ = get_frame_info(fc_parameters) - self.set_component_value( - ("Frame", "Specifications", "Frame class"), - get_frame_class_sub_dict(fw_type).get(frame_class, "Undefined"), - ) + frame_class_entry = frame_class_dict.get(str(frame_class)) + frame_class_label = frame_class_entry.get("protocol") if isinstance(frame_class_entry, dict) else "Undefined" + self.set_component_value(("Frame", "Specifications", "Frame class"), frame_class_label) # Process parameters in sequence self._set_gnss_type_from_fc_parameters(fc_parameters) diff --git a/ardupilot_methodic_configurator/data_model_vehicle_components_validation.py b/ardupilot_methodic_configurator/data_model_vehicle_components_validation.py index 2da8a591f..59d51baba 100644 --- a/ardupilot_methodic_configurator/data_model_vehicle_components_validation.py +++ b/ardupilot_methodic_configurator/data_model_vehicle_components_validation.py @@ -283,6 +283,20 @@ def get_connection_type_tuples_with_labels(connection_types: tuple[str, ...]) -> "102": {"type": SERIAL_PORTS, "protocol": "CoDevESC", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SAME}, "200": {"type": CAN_PORTS, "protocol": "DroneCAN", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SAME}, }, + # ArduPlane uses Q_M_PWM_TYPE instead of MOT_PWM_TYPE and supports DroneCAN for ESCs + "ArduPlane": { + "0": {"type": PWM_OUT_PORTS, "protocol": "Normal", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SCRIPTING_ONLY}, + "1": {"type": PWM_OUT_PORTS, "protocol": "OneShot", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SERIAL_ONLY}, + "2": {"type": PWM_OUT_PORTS, "protocol": "OneShot125", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SERIAL_ONLY}, + "3": {"type": PWM_OUT_PORTS, "protocol": "Brushed", "ESC_to_FC": ESC_TO_FC_TELEMETRY_NONE}, + "4": {"type": PWM_OUT_PORTS, "protocol": "DShot150", "ESC_to_FC": ESC_TO_FC_TELEMETRY_DSHOT}, + "5": {"type": PWM_OUT_PORTS, "protocol": "DShot300", "ESC_to_FC": ESC_TO_FC_TELEMETRY_DSHOT}, + "6": {"type": PWM_OUT_PORTS, "protocol": "DShot600", "ESC_to_FC": ESC_TO_FC_TELEMETRY_DSHOT}, + "7": {"type": PWM_OUT_PORTS, "protocol": "DShot1200", "ESC_to_FC": ESC_TO_FC_TELEMETRY_DSHOT}, + "8": {"type": PWM_OUT_PORTS, "protocol": "PWMRange", "ESC_to_FC": ESC_TO_FC_TELEMETRY_NONE}, + "9": {"type": PWM_OUT_PORTS, "protocol": "PWMAngle", "ESC_to_FC": ESC_TO_FC_TELEMETRY_NONE}, + "200": {"type": CAN_PORTS, "protocol": "DroneCAN", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SAME}, + }, } @@ -316,6 +330,8 @@ def get_esc_connection_sub_dict( "65536": {"type": RC_PORTS + SERIAL_PORTS, "protocol": "MAVRadio"}, # Bit 16 } +# When adding new entries here, make sure to also update the self._verify_dict_is_uptodate() calls +# inside the process_fc_parameters() method in the data_model_vehicle_components_import.py file FRAME_CLASS_DICT: dict[str, dict[int, str]] = { "ArduCopter": { 0: "Undefined", @@ -338,7 +354,11 @@ def get_esc_connection_sub_dict( 17: "Dynamic Scripting Matrix", }, "Heli": { + 0: "Undefined", 6: "Heli", + 8: "SingleCopter", + 9: "CoaxCopter", + 10: "BiCopter", 11: "Heli_Dual", 13: "HeliQuad", }, @@ -348,13 +368,46 @@ def get_esc_connection_sub_dict( 2: "Boat", 3: "BalanceBot", }, - "ArduPlane": {}, + # ArduPlane does not have a FRAME_CLASS parameter, it uses Q_FRAME_CLASS instead for the same purpose. + # We added it here to unify and simplify the GUI as they serve the same purpose of defining the number of motors users + # used in active hover propulsion. + "ArduPlane": { + 0: "Undefined", + 1: "Quad", + 2: "Hexa", + 3: "Octa", + 4: "OctaQuad", + 5: "Y6", + 7: "Tri", + 10: "Single/Dual", + 12: "DodecaHexa", + 14: "Deca", + 15: "Scripting Matrix", + 17: "Dynamic Scripting Matrix", + }, } -def get_frame_class_sub_dict(vehicle_type: str) -> dict[int, str]: - """Return the vehicle-type-specific frame class mapping from FRAME_CLASS_DICT.""" - return FRAME_CLASS_DICT.get(vehicle_type, FRAME_CLASS_DICT["ArduCopter"]) +def get_frame_class_as_protocol_dict(vehicle_type: str) -> dict[str, dict[str, str]]: + """ + Return the vehicle-type-specific frame class mapping from FRAME_CLASS_DICT. + + Each entry is shaped as a documentation-compatible protocol dictionary so + _verify_dict_is_uptodate() can handle FRAME_CLASS metadata unchanged. + """ + return { + str(frame_class): {"protocol": frame_class_name} + for frame_class, frame_class_name in FRAME_CLASS_DICT.get(vehicle_type, FRAME_CLASS_DICT["ArduCopter"]).items() + } + + +def get_frame_class_valid_tuple(vehicle_type: str) -> tuple[str, ...]: + """ + Return the valid frame-class labels for the given vehicle type, excluding "Undefined". + + "Undefined" is not a valid user selection and should not appear in the combobox. + """ + return tuple(v for v in FRAME_CLASS_DICT.get(vehicle_type, FRAME_CLASS_DICT["ArduCopter"]).values() if v != "Undefined") class ComponentDataModelValidation(ComponentDataModelBase): @@ -387,6 +440,9 @@ def set_component_value(self, path: ComponentPath, value: Union[ComponentData, C ("Battery", "Specifications", vtype), BatteryCell.recommended_cell_voltage(value, vtype) ) + if path == ("Flight Controller", "Firmware", "Type") and isinstance(value, str): + self._possible_choices[("Frame", "Specifications", "Frame class")] = get_frame_class_valid_tuple(value) + # Update possible choices for protocol fields when connection type changes self._update_possible_choices_for_path(path, value) @@ -538,6 +594,7 @@ def get_connection_types(conn_dict: dict) -> tuple[str, ...]: ("GNSS Receiver", "FC Connection", "Type"): ("None", *SERIAL_PORTS, *CAN_PORTS), ("GNSS Receiver", "FC Connection", "Protocol"): get_all_protocols(GNSS_RECEIVER_CONNECTION), ("Battery", "Specifications", "Chemistry"): BatteryCell.chemistries(), + ("Frame", "Specifications", "Frame class"): get_frame_class_valid_tuple(fw_type), } for component in ["RC Receiver", "Telemetry", "Battery Monitor", "ESC", "GNSS Receiver"]: if component not in self._data.get("Components", {}): diff --git a/tests/test_backend_flightcontroller_business_logic.py b/tests/test_backend_flightcontroller_business_logic.py index 4c8277b8d..1f09305ed 100755 --- a/tests/test_backend_flightcontroller_business_logic.py +++ b/tests/test_backend_flightcontroller_business_logic.py @@ -131,6 +131,69 @@ def test_with_default_values(self) -> None: assert frame_class == 1 # Default QUAD assert frame_type == 1 # Default X + def test_vtol_plane_uses_q_frame_class_when_frame_class_absent(self) -> None: + """ + ArduPlane VTOL frame class is read from Q_FRAME_CLASS when FRAME_CLASS is absent. + + GIVEN: FC parameters with Q_FRAME_CLASS set (ArduPlane VTOL) but no FRAME_CLASS + WHEN: Extracting frame info + THEN: Q_FRAME_CLASS value is used as the frame class + """ + params = {"Q_FRAME_CLASS": 2.0, "Q_FRAME_TYPE": 1.0} + frame_class, frame_type = get_frame_info(params) + assert frame_class == 2 # Hexa from Q_FRAME_CLASS + assert frame_type == 1 # X from Q_FRAME_TYPE + + def test_vtol_plane_uses_q_frame_type_when_frame_type_absent(self) -> None: + """ + ArduPlane VTOL frame type is read from Q_FRAME_TYPE when FRAME_TYPE is absent. + + GIVEN: FC parameters with Q_FRAME_TYPE set but no FRAME_TYPE + WHEN: Extracting frame info + THEN: Q_FRAME_TYPE value is used as the frame type + """ + params = {"FRAME_CLASS": 1.0, "Q_FRAME_TYPE": 3.0} + frame_class, frame_type = get_frame_info(params) + assert frame_class == 1 + assert frame_type == 3 # H from Q_FRAME_TYPE fallback + + def test_frame_class_takes_priority_over_q_frame_class_when_both_present(self) -> None: + """ + FRAME_CLASS takes precedence over Q_FRAME_CLASS when both are present. + + GIVEN: FC parameters with both FRAME_CLASS and Q_FRAME_CLASS + WHEN: Extracting frame info + THEN: FRAME_CLASS is used and Q_FRAME_CLASS is ignored + """ + params = {"FRAME_CLASS": 3.0, "Q_FRAME_CLASS": 1.0, "FRAME_TYPE": 1.0} + frame_class, _ = get_frame_info(params) + assert frame_class == 3 # Octa from FRAME_CLASS, not Quad from Q_FRAME_CLASS + + def test_frame_type_takes_priority_over_q_frame_type_when_both_present(self) -> None: + """ + FRAME_TYPE takes precedence over Q_FRAME_TYPE when both are present. + + GIVEN: FC parameters with both FRAME_TYPE and Q_FRAME_TYPE + WHEN: Extracting frame info + THEN: FRAME_TYPE is used and Q_FRAME_TYPE is ignored + """ + params = {"FRAME_CLASS": 1.0, "FRAME_TYPE": 2.0, "Q_FRAME_TYPE": 5.0} + _, frame_type = get_frame_info(params) + assert frame_type == 2 # V from FRAME_TYPE, not A-Tail from Q_FRAME_TYPE + + def test_defaults_to_1_when_all_frame_params_absent(self) -> None: + """ + Both frame class and type default to 1 when neither standard nor Q parameters exist. + + GIVEN: FC parameters with no FRAME_CLASS, Q_FRAME_CLASS, FRAME_TYPE, or Q_FRAME_TYPE + WHEN: Extracting frame info + THEN: Both values default to 1 (Quad, X) + """ + params = {"BATT_MONITOR": 4.0} + frame_class, frame_type = get_frame_info(params) + assert frame_class == 1 + assert frame_type == 1 + class TestBatteryVoltageValidation: """Test battery voltage validation logic.""" diff --git a/tests/test_data_model_vehicle_components_import.py b/tests/test_data_model_vehicle_components_import.py index 4c7208fb2..a58790212 100755 --- a/tests/test_data_model_vehicle_components_import.py +++ b/tests/test_data_model_vehicle_components_import.py @@ -320,6 +320,55 @@ def test_user_can_import_frame_class_from_fc(self, realistic_model) -> None: frame_class = realistic_model.get_component_value(("Frame", "Specifications", "Frame class")) assert frame_class == "Octa" + def test_user_can_import_frame_class_from_q_frame_class_for_vtol_plane(self, realistic_model) -> None: + """ + ArduPlane VTOL frame class is imported from Q_FRAME_CLASS when FRAME_CLASS is absent. + + GIVEN: FC parameters with Q_FRAME_CLASS set to 2 (Hexa) but no FRAME_CLASS key + WHEN: Processing FC parameters for an ArduPlane firmware model + THEN: Frame class in the component data should be set to 'Hexa' + """ + realistic_model.set_component_value(("Flight Controller", "Firmware", "Type"), "ArduPlane") + fc_parameters = {"Q_FRAME_CLASS": 2, "Q_FRAME_TYPE": 1} + + with patch.object(realistic_model, "_verify_dict_is_uptodate", return_value=True): + realistic_model.process_fc_parameters(fc_parameters, {}) + + frame_class = realistic_model.get_component_value(("Frame", "Specifications", "Frame class")) + assert frame_class == "Hexa" + + def test_frame_class_takes_priority_over_q_frame_class_during_import(self, realistic_model) -> None: + """ + FRAME_CLASS takes precedence over Q_FRAME_CLASS when both are present in FC parameters. + + GIVEN: FC parameters containing both FRAME_CLASS (3=Octa) and Q_FRAME_CLASS (1=Quad) + WHEN: Processing FC parameters + THEN: Frame class is set from FRAME_CLASS (Octa), not Q_FRAME_CLASS (Quad) + """ + fc_parameters = {"FRAME_CLASS": 3, "Q_FRAME_CLASS": 1} + + with patch.object(realistic_model, "_verify_dict_is_uptodate", return_value=True): + realistic_model.process_fc_parameters(fc_parameters, {}) + + frame_class = realistic_model.get_component_value(("Frame", "Specifications", "Frame class")) + assert frame_class == "Octa" + + def test_frame_class_set_to_undefined_when_code_not_in_dict(self, realistic_model) -> None: + """ + Frame class is set to 'Undefined' when the numeric code is not in the vehicle's dict. + + GIVEN: FC parameters with a FRAME_CLASS code that has no label for the current firmware type + WHEN: Processing FC parameters + THEN: Frame class component value is set to 'Undefined' + """ + fc_parameters = {"FRAME_CLASS": 999} # Unknown code + + with patch.object(realistic_model, "_verify_dict_is_uptodate", return_value=True): + realistic_model.process_fc_parameters(fc_parameters, {}) + + frame_class = realistic_model.get_component_value(("Frame", "Specifications", "Frame class")) + assert frame_class == "Undefined" + def test_user_can_import_esc_connection_and_telemetry_from_serial_fc(self, realistic_model) -> None: """ 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 GIVEN: Flight controller parameters and documentation that fails verification WHEN: Processing FC parameters - THEN: Verification should be attempted for all 5 dictionaries + THEN: Verification should be attempted for all 6 dictionaries AND: Processing should continue despite verification failures """ fc_parameters = { @@ -1127,8 +1176,8 @@ def test_system_attempts_verification_for_all_dictionaries(self, realistic_model with patch.object(realistic_model, "_verify_dict_is_uptodate", return_value=False) as mock_verify: realistic_model.process_fc_parameters(fc_parameters, doc) - # Should call verification 5 times (once for each dictionary) - assert mock_verify.call_count == 5 + # Should call verification 6 times (once for each dictionary) + assert mock_verify.call_count == 6 def test_system_correctly_validates_rc_protocol_power_of_two(self, realistic_model) -> None: """ diff --git a/tests/test_data_model_vehicle_components_validation.py b/tests/test_data_model_vehicle_components_validation.py index 6557ed804..c987673e6 100755 --- a/tests/test_data_model_vehicle_components_validation.py +++ b/tests/test_data_model_vehicle_components_validation.py @@ -736,6 +736,27 @@ def test_validate_all_data_invalid_combobox_values(self, realistic_model) -> Non assert len(errors) > 0 assert "Invalid value" in errors[0] + def test_validate_all_data_rejects_undefined_frame_class(self, realistic_model) -> None: + """ + Test validate_all_data rejects Undefined frame class selections. + + GIVEN: A realistic model with Frame class choices initialized + WHEN: Validating a Frame class value of Undefined + THEN: Validation should fail and report an invalid value error + """ + model = realistic_model + model.init_possible_choices({}) + + invalid_entries = { + ("Frame", "Specifications", "Frame class"): "Undefined", + } + + is_valid, errors = model.validate_all_data(invalid_entries) + assert is_valid is False + assert len(errors) == 1 + assert "Invalid value" in errors[0] + assert "Allowed values" in errors[0] + def test_validate_all_data_duplicate_connections(self, realistic_model) -> None: """ Test validate_all_data with duplicate FC connections. @@ -1536,6 +1557,11 @@ def test_low_voltage_validation_requires_below_arm_not_max(self, realistic_model class TestComponentDataModelValidationUncoveredBranches: """Tests targeting previously uncovered branches in ComponentDataModelValidation.""" + @pytest.fixture + def empty_model(self) -> ComponentDataModelValidation: + """Create an empty model.""" + return ComponentDataModelFixtures.create_empty_model(ComponentDataModelValidation) + @pytest.fixture def basic_model(self) -> ComponentDataModelValidation: """Create a basic model.""" @@ -1610,6 +1636,100 @@ def test_system_restricts_esc_protocols_to_none_for_none_connection(self, basic_ assert basic_model._possible_choices[("ESC", "FC Connection", "Protocol")] == ("None",) + # ------------------------------------------------------------------ + # Frame class choices - set_component_value firmware type side effect + # ------------------------------------------------------------------ + def test_set_component_value_firmware_type_updates_frame_class_choices(self, empty_model) -> None: + """ + Setting firmware type updates the Frame class possible choices. + + GIVEN: An empty model with no firmware type set + WHEN: Setting the firmware type to 'ArduCopter' + THEN: Frame class choices must be populated with ArduCopter-specific values + AND: 'Undefined' must not be in the valid choices + """ + model = empty_model + model.init_possible_choices({}) + + model.set_component_value(("Flight Controller", "Firmware", "Type"), "ArduCopter") + + frame_choices = model._possible_choices.get(("Frame", "Specifications", "Frame class"), ()) + assert len(frame_choices) > 0 + assert "Quad" in frame_choices + assert "Hexa" in frame_choices + assert "Undefined" not in frame_choices + + def test_set_component_value_firmware_type_heli_gives_heli_specific_choices(self, empty_model) -> None: + """ + Setting firmware type to Heli populates helicopter-specific frame class choices. + + GIVEN: An empty model + WHEN: Setting the firmware type to 'Heli' + THEN: Frame class choices contain Heli-specific classes (Heli, Heli_Dual, HeliQuad) + AND: Multirotor-only classes (Hexa, Octa) must not be present + """ + model = empty_model + model.init_possible_choices({}) + + model.set_component_value(("Flight Controller", "Firmware", "Type"), "Heli") + + frame_choices = model._possible_choices.get(("Frame", "Specifications", "Frame class"), ()) + assert "Heli" in frame_choices + assert "Heli_Dual" in frame_choices + assert "HeliQuad" in frame_choices + assert "Hexa" not in frame_choices + assert "Octa" not in frame_choices + + def test_set_component_value_non_firmware_type_does_not_update_frame_class_choices(self, empty_model) -> None: + """ + Setting a non-firmware-type path does not alter Frame class choices. + + GIVEN: An empty model with frame class choices already initialised + WHEN: Setting an unrelated value (TOW min Kg) + THEN: Frame class choices remain unchanged + """ + model = empty_model + model.init_possible_choices({}) + model.set_component_value(("Flight Controller", "Firmware", "Type"), "ArduCopter") + choices_before = model._possible_choices.get(("Frame", "Specifications", "Frame class"), ()) + + model.set_component_value(("Frame", "Specifications", "TOW min Kg"), 0.5) + + choices_after = model._possible_choices.get(("Frame", "Specifications", "Frame class"), ()) + assert choices_after == choices_before + + def test_init_possible_choices_includes_frame_class_for_default_firmware_type(self, empty_model) -> None: + """ + init_possible_choices populates Frame class choices based on firmware type. + + GIVEN: An empty model with no data (empty firmware type → fallback to ArduCopter) + WHEN: Calling init_possible_choices with an empty doc dict + THEN: Frame class choices must be present and non-empty + AND: 'Undefined' must not be a valid choice + """ + model = empty_model + model.init_possible_choices({}) + + frame_choices = model._possible_choices.get(("Frame", "Specifications", "Frame class"), ()) + assert isinstance(frame_choices, tuple) + assert len(frame_choices) > 0 + assert "Undefined" not in frame_choices + + def test_init_possible_choices_frame_class_reflects_stored_firmware_type(self, realistic_model) -> None: + """ + init_possible_choices derives Frame class choices from the stored firmware type. + + GIVEN: A realistic model whose firmware type is already set to 'ArduCopter' + WHEN: Calling init_possible_choices + THEN: Frame class choices match ArduCopter-specific values (Quad, Hexa, etc.) + """ + model = realistic_model + model.init_possible_choices({}) + + frame_choices = model._possible_choices.get(("Frame", "Specifications", "Frame class"), ()) + assert "Quad" in frame_choices + assert "Undefined" not in frame_choices + def test_system_sets_esc_protocol_to_dronecan_for_can_connection(self, basic_model) -> None: """ _update_possible_choices_for_path sets ESC protocol to ('DroneCAN',) for a CAN port. diff --git a/tests/unit_data_model_vehicle_components_validation_constants.py b/tests/unit_data_model_vehicle_components_validation_constants.py index 4bd640640..b70f28a5a 100755 --- a/tests/unit_data_model_vehicle_components_validation_constants.py +++ b/tests/unit_data_model_vehicle_components_validation_constants.py @@ -34,7 +34,8 @@ SERIAL_PROTOCOLS_DICT, SPI_PORTS, get_connection_type_tuples_with_labels, - get_frame_class_sub_dict, + get_frame_class_as_protocol_dict, + get_frame_class_valid_tuple, ) @@ -552,7 +553,11 @@ def test_esc_serial_same_port_protocols_contents(self) -> None: f"expected {expected!r}. Update this test and verify SERIAL_PROTOCOLS_DICT 'component' annotations." ) # Verify all protocols in ESC_SERIAL_SAME_PORT_PROTOCOLS are present in every vehicle sub-dict + # that supports serial ESC connections. ArduPlane (Q_M_PWM_TYPE) only supports PWM/DShot/CAN. + vehicle_types_without_serial_esc = {"ArduPlane"} for vtype, sub_dict in ESC_CONNECTION_DICT.items(): + if vtype in vehicle_types_without_serial_esc: + continue vehicle_protocols = {entry.get("protocol") for entry in sub_dict.values()} for protocol in ESC_SERIAL_SAME_PORT_PROTOCOLS: assert protocol in vehicle_protocols, ( @@ -561,7 +566,7 @@ def test_esc_serial_same_port_protocols_contents(self) -> None: class TestFrameClassDict: - """Tests for FRAME_CLASS_DICT and get_frame_class_sub_dict.""" + """Tests for FRAME_CLASS_DICT and get_frame_class_as_protocol_dict.""" def test_frame_class_dict_structure(self) -> None: """FRAME_CLASS_DICT is keyed by vehicle type with int->str sub-dicts.""" @@ -601,6 +606,14 @@ def test_heli_frame_class_values(self) -> None: assert 1 not in sub, "Quad should not be in Heli FRAME_CLASS_DICT" assert 2 not in sub, "Hexa should not be in Heli FRAME_CLASS_DICT" + def test_heli_frame_class_includes_coax_and_single_variants(self) -> None: + """Heli sub-dict includes newly added helicopter frame variants.""" + sub = FRAME_CLASS_DICT["Heli"] + assert sub[0] == "Undefined" + assert sub[8] == "SingleCopter" + assert sub[9] == "CoaxCopter" + assert sub[10] == "BiCopter" + def test_rover_frame_class_values(self) -> None: """Rover sub-dict uses Rover-specific frame class values.""" sub = FRAME_CLASS_DICT["Rover"] @@ -610,26 +623,39 @@ def test_rover_frame_class_values(self) -> None: # Multirotor classes must not appear in Rover assert 4 not in sub, "OctaQuad should not be in Rover FRAME_CLASS_DICT" - def test_arduplane_frame_class_is_empty(self) -> None: - """ArduPlane has no FRAME_CLASS parameter, so its sub-dict must be empty.""" - assert not FRAME_CLASS_DICT["ArduPlane"] + def test_arduplane_frame_class_values(self) -> None: + """ArduPlane sub-dict contains plane-specific hover-capable frame classes.""" + sub = FRAME_CLASS_DICT["ArduPlane"] + assert sub[1] == "Quad" + assert sub[2] == "Hexa" + assert sub[10] == "Single/Dual" + assert sub[12] == "DodecaHexa" + + def test_get_frame_class_as_protocol_dict_known_vehicle_types(self) -> None: + """get_frame_class_as_protocol_dict returns the correct protocol-shaped sub-dict for known vehicle types.""" + result = get_frame_class_as_protocol_dict("ArduCopter") + assert result["1"] == {"protocol": "Quad"} + assert result["2"] == {"protocol": "Hexa"} + assert result["3"] == {"protocol": "Octa"} - def test_get_frame_class_sub_dict_known_vehicle_types(self) -> None: - """get_frame_class_sub_dict returns the correct sub-dict for known vehicle types.""" - assert get_frame_class_sub_dict("ArduCopter") is FRAME_CLASS_DICT["ArduCopter"] - assert get_frame_class_sub_dict("Heli") is FRAME_CLASS_DICT["Heli"] - assert get_frame_class_sub_dict("Rover") is FRAME_CLASS_DICT["Rover"] - assert get_frame_class_sub_dict("ArduPlane") is FRAME_CLASS_DICT["ArduPlane"] + result = get_frame_class_as_protocol_dict("Heli") + assert result["6"] == {"protocol": "Heli"} - def test_get_frame_class_sub_dict_unknown_type_falls_back_to_arducopter(self) -> None: - """get_frame_class_sub_dict falls back to ArduCopter for unknown vehicle types.""" - result = get_frame_class_sub_dict("UnknownVehicle") - assert result is FRAME_CLASS_DICT["ArduCopter"] + result = get_frame_class_as_protocol_dict("Rover") + assert result["1"] == {"protocol": "Rover"} - def test_get_frame_class_sub_dict_empty_string_falls_back_to_arducopter(self) -> None: - """get_frame_class_sub_dict falls back to ArduCopter when fw_type is empty.""" - result = get_frame_class_sub_dict("") - assert result is FRAME_CLASS_DICT["ArduCopter"] + result = get_frame_class_as_protocol_dict("ArduPlane") + assert result["1"] == {"protocol": "Quad"} + + def test_get_frame_class_as_protocol_dict_unknown_type_falls_back_to_arducopter(self) -> None: + """get_frame_class_as_protocol_dict falls back to ArduCopter for unknown vehicle types.""" + result = get_frame_class_as_protocol_dict("UnknownVehicle") + assert result["1"] == {"protocol": "Quad"} + + def test_get_frame_class_as_protocol_dict_empty_string_falls_back_to_arducopter(self) -> None: + """get_frame_class_as_protocol_dict falls back to ArduCopter when fw_type is empty.""" + result = get_frame_class_as_protocol_dict("") + assert result["1"] == {"protocol": "Quad"} def test_frame_class_values_are_unique_per_vehicle_type(self) -> None: """Within each vehicle type, frame class names must be unique.""" @@ -638,3 +664,117 @@ def test_frame_class_values_are_unique_per_vehicle_type(self) -> None: continue names = list(sub_dict.values()) assert len(names) == len(set(names)), f"Duplicate frame class names found in '{vtype}'" + + def test_get_frame_class_valid_tuple_excludes_undefined_for_all_vehicle_types(self) -> None: + """ + get_frame_class_valid_tuple excludes 'Undefined' for every vehicle type. + + GIVEN: All vehicle types in FRAME_CLASS_DICT + WHEN: Calling get_frame_class_valid_tuple for each type + THEN: The returned tuple must not contain 'Undefined' (invalid user selection) + """ + for vtype in FRAME_CLASS_DICT: + result = get_frame_class_valid_tuple(vtype) + assert "Undefined" not in result, ( + f"'Undefined' must not appear in valid tuple for '{vtype}' (not a valid user selection)" + ) + + def test_get_frame_class_valid_tuple_returns_tuple_of_strings(self) -> None: + """ + get_frame_class_valid_tuple returns a tuple of non-empty strings. + + GIVEN: A known vehicle type (ArduCopter) + WHEN: Calling get_frame_class_valid_tuple + THEN: A non-empty tuple of strings is returned, containing expected frame class names + """ + result = get_frame_class_valid_tuple("ArduCopter") + assert isinstance(result, tuple) + assert len(result) > 0 + assert all(isinstance(name, str) for name in result) + assert "Quad" in result + assert "Hexa" in result + + def test_get_frame_class_valid_tuple_falls_back_to_arducopter_for_unknown_type(self) -> None: + """ + get_frame_class_valid_tuple falls back to ArduCopter choices for unknown vehicle types. + + GIVEN: An unrecognised vehicle type string + WHEN: Calling get_frame_class_valid_tuple + THEN: The same result as for 'ArduCopter' is returned + """ + unknown = get_frame_class_valid_tuple("UnknownVehicle") + arducopter = get_frame_class_valid_tuple("ArduCopter") + assert unknown == arducopter + + def test_get_frame_class_valid_tuple_heli_contains_heli_classes(self) -> None: + """ + get_frame_class_valid_tuple for Heli contains Heli-specific choices. + + GIVEN: 'Heli' vehicle type + WHEN: Calling get_frame_class_valid_tuple + THEN: Result contains Heli, Heli_Dual, HeliQuad but not Quad or Hexa + """ + result = get_frame_class_valid_tuple("Heli") + assert "Heli" in result + assert "Heli_Dual" in result + assert "HeliQuad" in result + assert "Quad" not in result + assert "Hexa" not in result + + +class TestArduPlaneEscConnectionDict: + """Tests for the ArduPlane entry in ESC_CONNECTION_DICT.""" + + def test_arduplane_esc_connection_dict_exists(self) -> None: + """ + ArduPlane has an entry in ESC_CONNECTION_DICT. + + GIVEN: The ESC_CONNECTION_DICT constant + WHEN: Checking for the ArduPlane key + THEN: The key must exist and its value must be a non-empty dict + """ + assert "ArduPlane" in ESC_CONNECTION_DICT + assert len(ESC_CONNECTION_DICT["ArduPlane"]) > 0 + + def test_arduplane_esc_connection_dict_contains_dshot_protocols(self) -> None: + """ + ArduPlane ESC dict includes DShot protocol entries. + + GIVEN: The ArduPlane sub-dict in ESC_CONNECTION_DICT + WHEN: Collecting protocol names + THEN: DShot600 and DShot300 must be present (VTOL motors support DShot) + """ + protocols = {entry["protocol"] for entry in ESC_CONNECTION_DICT["ArduPlane"].values()} + assert "DShot600" in protocols + assert "DShot300" in protocols + assert "Normal" in protocols + + def test_arduplane_esc_connection_dict_excludes_serial_esc_protocols(self) -> None: + """ + ArduPlane ESC dict does not contain serial-only ESC protocols. + + GIVEN: The ArduPlane sub-dict in ESC_CONNECTION_DICT + WHEN: Collecting protocol names + THEN: FETtecOneWire, Torqeedo, and CoDevESC must NOT be present + (ArduPlane VTOL uses Q_M_PWM_TYPE which does not support these) + """ + protocols = {entry["protocol"] for entry in ESC_CONNECTION_DICT["ArduPlane"].values()} + assert "FETtecOneWire" not in protocols + assert "Torqeedo" not in protocols + assert "CoDevESC" not in protocols + # But DroneCAN (CAN protocol) must be present + assert "DroneCAN" in protocols + + def test_arduplane_esc_connection_dict_entry_shape_matches_other_vehicles(self) -> None: + """ + Each entry in the ArduPlane ESC dict has the same keys as other vehicle entries. + + GIVEN: The ArduPlane sub-dict and the ArduCopter sub-dict in ESC_CONNECTION_DICT + WHEN: Comparing the key sets of individual entries + THEN: Every ArduPlane entry must have the same set of keys as ArduCopter entries + """ + arducopter_keys = {frozenset(entry.keys()) for entry in ESC_CONNECTION_DICT["ArduCopter"].values()} + for code, entry in ESC_CONNECTION_DICT["ArduPlane"].items(): + assert frozenset(entry.keys()) in arducopter_keys, ( + f"ArduPlane ESC entry '{code}' has unexpected keys: {set(entry.keys())}" + )