Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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},
},
}


Expand Down Expand Up @@ -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",
Expand All @@ -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",
},
Expand All @@ -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")


Comment on lines +410 to 412
class ComponentDataModelValidation(ComponentDataModelBase):
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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", {}):
Expand Down
63 changes: 63 additions & 0 deletions tests/test_backend_flightcontroller_business_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
55 changes: 52 additions & 3 deletions tests/test_data_model_vehicle_components_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 = {
Expand All @@ -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:
"""
Expand Down
Loading
Loading