diff --git a/TUNING_GUIDE_ArduCopter.md b/TUNING_GUIDE_ArduCopter.md index 91ecb8c20..462c9efde 100644 --- a/TUNING_GUIDE_ArduCopter.md +++ b/TUNING_GUIDE_ArduCopter.md @@ -268,7 +268,7 @@ The `ESC->FC Telemetry` type and protocol describe the return path: | Connection Type | Protocol | Notes | | --------------- | -------- | ----- | | `None` | `None` | No ESC telemetry | -| same as FC->ESC | `BDShot` | BDShot only on Main Out and/or AIO, without serial port backup channel | +| same as FC->ESC | `BDShotOnly` | BDShot only on Main Out and/or AIO, without serial port backup channel | | serial port | `ESC Telemetry` | DShot or BDShot serial telemetry backup channel | | serial port | `FETtecOneWire` | Bidirectional FETtec protocol on the same wire | | serial port | `Scripting` | For T-Motor/Hobbywing Datalink v2 serial telemetry | @@ -278,7 +278,7 @@ The `ESC->FC Telemetry` type and protocol describe the return path: When using BDShot only on `Main Out` (1-8) and/or `Aux I/O` (9-14) connection, without serial port backup channel: -![ESC telemetry BDshot only](images/blog/component_editor_esc_telem_main_out_aio.png) +![ESC telemetry BDShotOnly](images/blog/component_editor_esc_telem_main_out_aio.png) When using DShot or BDShot with a serial port backup channel: diff --git a/ardupilot_methodic_configurator/data_model_parameter_editor.py b/ardupilot_methodic_configurator/data_model_parameter_editor.py index 69a69b5d5..6a8c0b4ac 100644 --- a/ardupilot_methodic_configurator/data_model_parameter_editor.py +++ b/ardupilot_methodic_configurator/data_model_parameter_editor.py @@ -1024,7 +1024,7 @@ def upload_selected_params_workflow( # pylint: disable=too-many-arguments, too- # If reset happened, fc_parameters cache was cleared during disconnect/reconnect # Re-download parameters now so _upload_parameters_to_fc has valid cache for comparison if reset_happened: - self.download_flight_controller_parameters(lambda cb=progress_callback_for_download: cb) + self.download_flight_controller_parameters(lambda cb=progress_callback_for_download: cb) # type: ignore[misc] # Upload remaining parameters (excluding those already uploaded in reset workflow) remaining_params = {k: v for k, v in selected_params.items() if k not in already_uploaded_params} @@ -1039,7 +1039,7 @@ def upload_selected_params_workflow( # pylint: disable=too-many-arguments, too- if self._at_least_one_changed: # Re-download all parameters to validate # Note: Passing the callback directly, not the factory, since we already got it - self.download_flight_controller_parameters(lambda cb=progress_callback_for_download: cb) + self.download_flight_controller_parameters(lambda cb=progress_callback_for_download: cb) # type: ignore[misc] param_upload_error = self._validate_uploaded_parameters(selected_params) if param_upload_error: diff --git a/ardupilot_methodic_configurator/data_model_vehicle_components_import.py b/ardupilot_methodic_configurator/data_model_vehicle_components_import.py index b4cfda74a..a40753ccc 100644 --- a/ardupilot_methodic_configurator/data_model_vehicle_components_import.py +++ b/ardupilot_methodic_configurator/data_model_vehicle_components_import.py @@ -26,15 +26,16 @@ from ardupilot_methodic_configurator.data_model_vehicle_components_validation import ( BATT_MONITOR_CONNECTION, CAN_PORTS, - ESC_TELEMETRY_PROTOCOLS, + ESC_TELEMETRY_ONLY_PROTOCOLS, GNSS_RECEIVER_CONNECTION, I2C_PORTS, + PWM_OUT_PORTS, RC_PROTOCOLS_DICT, SERIAL_PORTS, SERIAL_PROTOCOLS_DICT, SERVO_FUNCTION_ESC_CONTROL, ComponentDataModelValidation, - get_mot_pwm_type_sub_dict, + get_esc_connection_sub_dict, ) @@ -179,7 +180,7 @@ def process_fc_parameters( elif "GPS_TYPE" in doc: self._verify_dict_is_uptodate(doc, GNSS_RECEIVER_CONNECTION, "GPS_TYPE", "values") fw_type = str(self.get_component_value(("Flight Controller", "Firmware", "Type")) or "") - self._verify_dict_is_uptodate(doc, get_mot_pwm_type_sub_dict(fw_type), "MOT_PWM_TYPE", "values") + 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 parameters in sequence @@ -330,7 +331,7 @@ def _process_serial_components(self, fc_parameters: dict) -> bool: # pylint: di self.set_component_value(("GNSS Receiver", "FC Connection", "Type"), serial) gnss += 1 elif component == "ESC": - if protocol in ESC_TELEMETRY_PROTOCOLS: + if protocol in ESC_TELEMETRY_ONLY_PROTOCOLS: # Serial ESC->FC telemetry only (DShot with UART feedback, or Hobbywing Datalink v2). # FC->ESC connection is still PWM/DShot; _set_esc_type_from_fc_parameters handles it. # Do NOT increment esc so that function is still called. @@ -372,13 +373,13 @@ def _set_esc_type_from_fc_parameters(self, fc_parameters: dict[str, float], doc: protocol = doc["MOT_PWM_TYPE"]["values"].get(str(mot_pwm_type), "") if protocol: self.set_component_value(("ESC", "FC->ESC Connection", "Protocol"), protocol) - # Fallback to MOT_PWM_TYPE_DICT if doc is not available + # Fallback to ESC_CONNECTION_DICT if doc is not available else: - mot_pwm_sub = get_mot_pwm_type_sub_dict( + esc_conn_sub = get_esc_connection_sub_dict( str(self.get_component_value(("Flight Controller", "Firmware", "Type")) or "") ) - if str(mot_pwm_type) in mot_pwm_sub: - protocol = str(mot_pwm_sub[str(mot_pwm_type)]["protocol"]) + if str(mot_pwm_type) in esc_conn_sub: + protocol = str(esc_conn_sub[str(mot_pwm_type)]["protocol"]) self.set_component_value(("ESC", "FC->ESC Connection", "Protocol"), protocol) # Set ESC->FC Telemetry: DShot supports BDShot telemetry on the same PWM wire. @@ -388,9 +389,9 @@ def _set_esc_type_from_fc_parameters(self, fc_parameters: dict[str, float], doc: current_telemetry_type = self.get_component_value(("ESC", "ESC->FC Telemetry", "Type")) if protocol and protocol.startswith("DShot"): if current_telemetry_type not in SERIAL_PORTS and current_telemetry_type not in CAN_PORTS: - # No dedicated serial/CAN telemetry port detected; fall back to BDShot on the PWM wire + # No dedicated serial/CAN telemetry port detected; fall back to BDShotOnly on the PWM wire self.set_component_value(("ESC", "ESC->FC Telemetry", "Type"), esc_conn_type) - self.set_component_value(("ESC", "ESC->FC Telemetry", "Protocol"), "BDShot") + self.set_component_value(("ESC", "ESC->FC Telemetry", "Protocol"), "BDShotOnly") elif not current_telemetry_type or ( current_telemetry_type not in SERIAL_PORTS and current_telemetry_type not in CAN_PORTS ): @@ -688,10 +689,19 @@ def _set_motor_poles_from_fc_parameters(self, fc_parameters: dict[str, float]) - poles = 0.0 if "MOT_PWM_TYPE" in fc_parameters: mot_pwm_type_str = str(int(fc_parameters["MOT_PWM_TYPE"])) - mot_pwm_sub = get_mot_pwm_type_sub_dict( + esc_conn_sub = get_esc_connection_sub_dict( str(self.get_component_value(("Flight Controller", "Firmware", "Type")) or "") ) - if mot_pwm_type_str in mot_pwm_sub and mot_pwm_sub[mot_pwm_type_str].get("is_dshot", False): + entry = esc_conn_sub.get(mot_pwm_type_str) + esc_to_fc = entry.get("ESC_to_FC") if entry is not None else None + is_dshot = ( + entry is not None + and isinstance(esc_to_fc, dict) + and ("same_as_FC_to_ESC",) in esc_to_fc + and isinstance(entry.get("type"), tuple) + and entry["type"] == PWM_OUT_PORTS + ) + if is_dshot: if fc_parameters.get("SERVO_BLH_POLES"): # DShot ESCs poles = fc_parameters["SERVO_BLH_POLES"] elif fc_parameters.get("SERVO_FTW_MASK") and fc_parameters.get("SERVO_FTW_POLES"): diff --git a/ardupilot_methodic_configurator/data_model_vehicle_components_validation.py b/ardupilot_methodic_configurator/data_model_vehicle_components_validation.py index 796cf8a76..6dc761810 100644 --- a/ardupilot_methodic_configurator/data_model_vehicle_components_validation.py +++ b/ardupilot_methodic_configurator/data_model_vehicle_components_validation.py @@ -13,7 +13,7 @@ import operator from logging import error as logging_error from types import MappingProxyType -from typing import Any, Optional, Union +from typing import Any, Optional, Union, cast from ardupilot_methodic_configurator import _ from ardupilot_methodic_configurator.backend_filesystem_vehicle_components import VehicleComponents @@ -25,6 +25,8 @@ ComponentValue, ) +# pylint: disable=too-many-lines + # Port definitions ANALOG_PORTS: tuple[str, ...] = ("Analog",) SERIAL_PORTS: tuple[str, ...] = ("SERIAL1", "SERIAL2", "SERIAL3", "SERIAL4", "SERIAL5", "SERIAL6", "SERIAL7", "SERIAL8") @@ -147,8 +149,16 @@ def get_connection_type_tuples_with_labels(connection_types: tuple[str, ...]) -> "49": {"type": SERIAL_PORTS, "protocol": "i-BUS Telemetry", "component": None}, } -# ESC protocol constants -ESC_TELEMETRY_PROTOCOLS = {"ESC Telemetry", "Scripting"} # Serial telemetry-only protocols +# Serial telemetry-only protocols +ESC_TELEMETRY_ONLY_PROTOCOLS: frozenset[str] = frozenset({"ESC Telemetry", "Scripting"}) + +# Protocols where FC->ESC and ESC->FC Telemetry share the same SERIAL port. +# The ESC->FC Telemetry protocol is implicitly determined by (and must match) the FC->ESC Connection protocol. +ESC_SERIAL_SAME_PORT_PROTOCOLS: tuple[str, ...] = tuple( + str(v["protocol"]) + for v in SERIAL_PROTOCOLS_DICT.values() + if v.get("component") == "ESC" and v["protocol"] not in ESC_TELEMETRY_ONLY_PROTOCOLS +) BATT_MONITOR_CONNECTION: dict[str, dict[str, Union[tuple[str, ...], str]]] = { "0": {"type": ("None",), "protocol": "Disabled"}, @@ -208,50 +218,79 @@ def get_connection_type_tuples_with_labels(connection_types: tuple[str, ...]) -> "26": {"type": SERIAL_PORTS, "protocol": "Septentrio-DualAntenna(SBF)"}, } -MOT_PWM_TYPE_DICT: dict[str, dict[str, dict[str, Union[tuple[str, ...], str, bool]]]] = { +# Key: tuple of possible ESC->FC telemetry connection types, or a sentinel such as +# ("same_as_FC_to_ESC",); value: telemetry protocol string for that connection choice. +EscToFcTelemetryDict = dict[tuple[str, ...], str] +ESC_TO_FC_TELEMETRY_NONE: EscToFcTelemetryDict = {("None",): "None"} +ESC_TO_FC_TELEMETRY_SCRIPTING_ONLY: EscToFcTelemetryDict = {("None",): "None", SERIAL_PORTS: "Scripting"} +ESC_TO_FC_TELEMETRY_SERIAL_ONLY: EscToFcTelemetryDict = {("None",): "None", SERIAL_PORTS: "ESC Telemetry"} +ESC_TO_FC_TELEMETRY_DSHOT: EscToFcTelemetryDict = { + ("None",): "None", + ("same_as_FC_to_ESC",): "BDShotOnly", + SERIAL_PORTS: "ESC Telemetry", +} +ESC_TO_FC_TELEMETRY_SAME: EscToFcTelemetryDict = {("same_as_FC_to_ESC",): "same_as_FC_to_ESC"} + +# FC->ESC Connection types and protocols determine the possible ESC->FC Telemetry protocols, +# with some variations by vehicle type. +ESC_CONNECTION_DICT: dict[str, dict[str, dict[str, Union[tuple[str, ...], str, EscToFcTelemetryDict]]]] = { "ArduCopter": { - "0": {"type": PWM_OUT_PORTS, "protocol": "Normal", "is_dshot": False}, - "1": {"type": PWM_OUT_PORTS, "protocol": "OneShot", "is_dshot": True}, - "2": {"type": PWM_OUT_PORTS, "protocol": "OneShot125", "is_dshot": True}, - "3": {"type": PWM_OUT_PORTS, "protocol": "Brushed", "is_dshot": False}, - "4": {"type": PWM_OUT_PORTS, "protocol": "DShot150", "is_dshot": True}, - "5": {"type": PWM_OUT_PORTS, "protocol": "DShot300", "is_dshot": True}, - "6": {"type": PWM_OUT_PORTS, "protocol": "DShot600", "is_dshot": True}, - "7": {"type": PWM_OUT_PORTS, "protocol": "DShot1200", "is_dshot": True}, - "8": {"type": PWM_OUT_PORTS, "protocol": "PWMRange", "is_dshot": False}, - "9": {"type": PWM_OUT_PORTS, "protocol": "PWMAngle", "is_dshot": False}, + "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}, + "100": {"type": SERIAL_PORTS, "protocol": "FETtecOneWire", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SAME}, + "101": {"type": SERIAL_PORTS, "protocol": "Torqeedo", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SAME}, + "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}, }, "Heli": { - "0": {"type": PWM_OUT_PORTS, "protocol": "Normal", "is_dshot": False}, - "1": {"type": PWM_OUT_PORTS, "protocol": "OneShot", "is_dshot": True}, - "2": {"type": PWM_OUT_PORTS, "protocol": "OneShot125", "is_dshot": True}, - "3": {"type": PWM_OUT_PORTS, "protocol": "Brushed", "is_dshot": False}, - "4": {"type": PWM_OUT_PORTS, "protocol": "DShot150", "is_dshot": True}, - "5": {"type": PWM_OUT_PORTS, "protocol": "DShot300", "is_dshot": True}, - "6": {"type": PWM_OUT_PORTS, "protocol": "DShot600", "is_dshot": True}, - "7": {"type": PWM_OUT_PORTS, "protocol": "DShot1200", "is_dshot": True}, - "8": {"type": PWM_OUT_PORTS, "protocol": "PWMRange", "is_dshot": False}, - "9": {"type": PWM_OUT_PORTS, "protocol": "PWMAngle", "is_dshot": False}, + "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}, + "100": {"type": SERIAL_PORTS, "protocol": "FETtecOneWire", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SAME}, + "101": {"type": SERIAL_PORTS, "protocol": "Torqeedo", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SAME}, + "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}, }, "Rover": { - "0": {"type": PWM_OUT_PORTS, "protocol": "Normal", "is_dshot": False}, - "1": {"type": PWM_OUT_PORTS, "protocol": "OneShot", "is_dshot": True}, - "2": {"type": PWM_OUT_PORTS, "protocol": "OneShot125", "is_dshot": True}, - "3": {"type": PWM_OUT_PORTS, "protocol": "BrushedWithRelay", "is_dshot": False}, - "4": {"type": PWM_OUT_PORTS, "protocol": "BrushedBiPolar", "is_dshot": False}, - "5": {"type": PWM_OUT_PORTS, "protocol": "DShot150", "is_dshot": True}, - "6": {"type": PWM_OUT_PORTS, "protocol": "DShot300", "is_dshot": True}, - "7": {"type": PWM_OUT_PORTS, "protocol": "DShot600", "is_dshot": True}, - "8": {"type": PWM_OUT_PORTS, "protocol": "DShot1200", "is_dshot": True}, - "9": {"type": PWM_OUT_PORTS, "protocol": "PWMRange", "is_dshot": False}, - "10": {"type": PWM_OUT_PORTS, "protocol": "PWMAngle", "is_dshot": False}, + "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": "BrushedWithRelay", "ESC_to_FC": ESC_TO_FC_TELEMETRY_NONE}, + "4": {"type": PWM_OUT_PORTS, "protocol": "BrushedBiPolar", "ESC_to_FC": ESC_TO_FC_TELEMETRY_NONE}, + "5": {"type": PWM_OUT_PORTS, "protocol": "DShot150", "ESC_to_FC": ESC_TO_FC_TELEMETRY_DSHOT}, + "6": {"type": PWM_OUT_PORTS, "protocol": "DShot300", "ESC_to_FC": ESC_TO_FC_TELEMETRY_DSHOT}, + "7": {"type": PWM_OUT_PORTS, "protocol": "DShot600", "ESC_to_FC": ESC_TO_FC_TELEMETRY_DSHOT}, + "8": {"type": PWM_OUT_PORTS, "protocol": "DShot1200", "ESC_to_FC": ESC_TO_FC_TELEMETRY_DSHOT}, + "9": {"type": PWM_OUT_PORTS, "protocol": "PWMRange", "ESC_to_FC": ESC_TO_FC_TELEMETRY_NONE}, + "10": {"type": PWM_OUT_PORTS, "protocol": "PWMAngle", "ESC_to_FC": ESC_TO_FC_TELEMETRY_NONE}, + "100": {"type": SERIAL_PORTS, "protocol": "FETtecOneWire", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SAME}, + "101": {"type": SERIAL_PORTS, "protocol": "Torqeedo", "ESC_to_FC": ESC_TO_FC_TELEMETRY_SAME}, + "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}, }, } -def get_mot_pwm_type_sub_dict(vehicle_type: str) -> dict[str, dict[str, Union[tuple[str, ...], str, bool]]]: - """Return the vehicle-type-specific entry sub-dict from MOT_PWM_TYPE_DICT.""" - return MOT_PWM_TYPE_DICT.get(vehicle_type, MOT_PWM_TYPE_DICT["ArduCopter"]) +def get_esc_connection_sub_dict( + vehicle_type: str, +) -> dict[str, dict[str, Union[tuple[str, ...], str, EscToFcTelemetryDict]]]: + """Return the vehicle-type-specific entry sub-dict from ESC_CONNECTION_DICT.""" + return ESC_CONNECTION_DICT.get(vehicle_type, ESC_CONNECTION_DICT["ArduCopter"]) # RC_PROTOCOLS is a bitmask parameter, so keys are actual bitmask values (2^bit_position) @@ -277,23 +316,6 @@ def get_mot_pwm_type_sub_dict(vehicle_type: str) -> dict[str, dict[str, Union[tu "65536": {"type": RC_PORTS + SERIAL_PORTS, "protocol": "MAVRadio"}, # Bit 16 } -# ESC->FC telemetry connections -ESC_TELEMETRY_DICT: dict[str, dict[str, Union[tuple[str, ...], str]]] = { - "0": {"type": ("None",), "protocol": "None"}, - # On DShot: FC->ESC is either Main Out or AIO; and ESC->FC Telemetry is serial - # On BDShot: FC->ESC is either Main Out or AIO; and ESC->FC Telemetry is also Main Out or AIO - # but there is an optional backup serial telemetry channel - "1": {"type": SERIAL_PORTS, "protocol": "ESC Telemetry"}, - # On BShot if the optional serial ESC->FC backup telemetry is unused, choose this - "2": {"type": PWM_OUT_PORTS, "protocol": "BDShot"}, - # The same CAN connection is used for both FC->ESC and ESC->FC telemetry - "3": {"type": CAN_PORTS, "protocol": "DroneCAN"}, - # The same serial connection is used for both FC->ESC and ESC->FC telemetry - "4": {"type": SERIAL_PORTS, "protocol": "FETtecOneWire"}, - # On T-Motor/Hobbywing Datalink v2: FC->ESC is either Main Out or AIO; and serial telemetry is received via scripting - "5": {"type": SERIAL_PORTS, "protocol": "Scripting"}, -} - class ComponentDataModelValidation(ComponentDataModelBase): """ @@ -328,6 +350,64 @@ def set_component_value(self, path: ComponentPath, value: Union[ComponentData, C # Update possible choices for protocol fields when connection type changes self._update_possible_choices_for_path(path, value) + def _get_current_esc_to_fc_dict(self) -> "EscToFcTelemetryDict": + """Return the ESC_to_FC dict for the currently selected FC->ESC Type + Protocol entry.""" + fw_type = str(self.get_component_value(("Flight Controller", "Firmware", "Type")) or "") + fc_esc_conn_type = str(self.get_component_value(("ESC", "FC->ESC Connection", "Type")) or "") + fc_esc_protocol = str(self.get_component_value(("ESC", "FC->ESC Connection", "Protocol")) or "") + for entry in get_esc_connection_sub_dict(fw_type).values(): + if fc_esc_conn_type in entry.get("type", ()) and entry.get("protocol") == fc_esc_protocol: + return cast("EscToFcTelemetryDict", entry.get("ESC_to_FC", {})) + return {} + + def get_valid_esc_telemetry_types(self) -> tuple[str, ...]: + """ + Return valid ESC->FC Telemetry Type values for the current FC->ESC connection protocol. + + Uses the ESC_CONNECTION_DICT directly (via _get_current_esc_to_fc_dict) so it does not + depend on _mot_pwm_types being populated from a doc file. This makes it safe to call from + the GUI cascade code regardless of whether a parameter-definition file has been loaded. + """ + esc_to_fc_dict = self._get_current_esc_to_fc_dict() + fc_esc_conn_type = str(self.get_component_value(("ESC", "FC->ESC Connection", "Type")) or "") + valid: list[str] = [] + for port_key in esc_to_fc_dict: + if port_key == ("same_as_FC_to_ESC",): + if fc_esc_conn_type not in valid: + valid.append(fc_esc_conn_type) + else: + for port in port_key: + if port not in valid: + valid.append(port) + return tuple(valid) if valid else ("None",) + + def is_esc_telemetry_type_mirrored(self) -> bool: + """ + Return True when the ESC->FC Telemetry Type combobox should be greyed-out. + + True ONLY when the ESC_to_FC dict has EXACTLY ONE key: ("same_as_FC_to_ESC",). + This covers fully-mirrored entries like ESC_TO_FC_TELEMETRY_SAME (CAN/DroneCAN and + SERIAL same-port protocols FETtecOneWire, Torqeedo, CoDevESC). + + False for entries with multiple type keys like ESC_TO_FC_TELEMETRY_DSHOT which has + ("None",), ("same_as_FC_to_ESC",), and SERIAL_PORTS — these are NOT fully mirrored. + """ + esc_to_fc_dict = self._get_current_esc_to_fc_dict() + # Type is mirrored only if the sole key is ("same_as_FC_to_ESC",) + return list(esc_to_fc_dict) == [("same_as_FC_to_ESC",)] + + def is_esc_telemetry_protocol_mirrored(self) -> bool: + """ + Return True when the ESC->FC Telemetry Protocol combobox should be greyed-out. + + True when the matching ESC_CONNECTION_DICT entry has "same_as_FC_to_ESC" as a value, + meaning the telemetry protocol is forced to match the FC->ESC protocol exactly. + This covers only fully-mirrored entries (ESC_TO_FC_TELEMETRY_SAME: CAN/DroneCAN and + SERIAL same-port protocols FETtecOneWire, Torqeedo, CoDevESC). BDShot entries have + ("same_as_FC_to_ESC",) as a key but "BDShotOnly" as the value — not mirrored here. + """ + return "same_as_FC_to_ESC" in self._get_current_esc_to_fc_dict().values() + def is_valid_component_data(self) -> bool: """ Validate the component data structure. @@ -350,7 +430,11 @@ def get_all_protocols(param_dict: dict) -> tuple[str, ...]: fallbacks: dict[str, tuple[str, ...]] = { "RC_PROTOCOLS": get_all_protocols(RC_PROTOCOLS_DICT), "BATT_MONITOR": get_all_protocols(BATT_MONITOR_CONNECTION), - "MOT_PWM_TYPE": get_all_protocols(get_mot_pwm_type_sub_dict(fw_type)), + "MOT_PWM_TYPE": tuple( + str(v["protocol"]) + for v in get_esc_connection_sub_dict(fw_type).values() + if isinstance(v.get("type"), tuple) and v["type"] == PWM_OUT_PORTS + ), "GPS_TYPE": get_all_protocols(GNSS_RECEIVER_CONNECTION), "GPS1_TYPE": get_all_protocols(GNSS_RECEIVER_CONNECTION), # GPS_TYPE was renamed to GPS1_TYPE in 4.6 } @@ -381,6 +465,9 @@ def get_connection_types(conn_dict: dict) -> tuple[str, ...]: ) ) + # Always initialize from the fallback so that PWM protocol choices are available + # even when no parameter-definition file (apm.pdef.xml) has been loaded (e.g. in tests). + self._mot_pwm_types = fallbacks["MOT_PWM_TYPE"] if "MOT_PWM_TYPE" in doc_dict: self._mot_pwm_types = get_combobox_values("MOT_PWM_TYPE") if "Q_M_PWM_TYPE" in doc_dict: @@ -400,7 +487,13 @@ def get_connection_types(conn_dict: dict) -> tuple[str, ...]: ("ESC", "FC->ESC Connection", "Protocol"): self._mot_pwm_types, ("ESC", "ESC->FC Telemetry", "Type"): ("None", *PWM_OUT_PORTS, *SERIAL_PORTS, *CAN_PORTS), ("ESC", "ESC->FC Telemetry", "Protocol"): tuple( - dict.fromkeys(str(v["protocol"]) for v in ESC_TELEMETRY_DICT.values()) + dict.fromkeys( + telem_protocol + for entry in get_esc_connection_sub_dict(fw_type).values() + if isinstance(entry.get("ESC_to_FC"), dict) + for telem_protocol in cast("EscToFcTelemetryDict", entry["ESC_to_FC"]).values() + if telem_protocol != "same_as_FC_to_ESC" and telem_protocol not in ESC_SERIAL_SAME_PORT_PROTOCOLS + ) ), ("GNSS Receiver", "FC Connection", "Type"): ("None", *SERIAL_PORTS, *CAN_PORTS), ("GNSS Receiver", "FC Connection", "Protocol"): get_all_protocols(GNSS_RECEIVER_CONNECTION), @@ -425,89 +518,269 @@ def get_connection_types(conn_dict: dict) -> tuple[str, ...]: self.get_component_value((component, "FC Connection", "Type")), ) - def _update_possible_choices_for_path( # pylint: disable=too-many-branches - self, path: ComponentPath, value: Union[ComponentData, ComponentValue, None] - ) -> None: - """Update _possible_choices when connection type values that affect protocol choices are changed.""" - if len(path) < 3 or path[2] != "Type" or not isinstance(value, str): - return + def _update_esc_fc_connection_choices(self, value: str, protocol_path: ComponentPath) -> None: + """Update FC->ESC Connection Protocol and cascade ESC->FC Telemetry choices when connection Type changes.""" + # Update FC->ESC Protocol choices based on connection type + if value == "None": + self._possible_choices[protocol_path] = ("None",) + elif value in CAN_PORTS: + self._possible_choices[protocol_path] = self._get_esc_connection_protocols_for_type(value) or ("None",) + elif value in SERIAL_PORTS: + self._possible_choices[protocol_path] = ESC_SERIAL_SAME_PORT_PROTOCOLS + else: + # For PWM outputs, use motor PWM types + self._possible_choices[protocol_path] = self._mot_pwm_types - component_name = path[0] - section = path[1] + # Cascade-update ESC->FC Telemetry Type and Protocol choices + telemetry_type_path: ComponentPath = ("ESC", "ESC->FC Telemetry", "Type") + telemetry_protocol_path: ComponentPath = ("ESC", "ESC->FC Telemetry", "Protocol") + telemetry_types, telemetry_protocols = self._compute_esc_telemetry_choices(value) + self._possible_choices[telemetry_type_path] = telemetry_types + self._possible_choices[telemetry_protocol_path] = telemetry_protocols - if section not in ("FC Connection", "FC->ESC Connection", "ESC->FC Telemetry"): - return + def _compute_telem_serial_protocols( + self, + telem_type_value: str, + esc_sub: dict[str, dict[str, Union[tuple[str, ...], str, "EscToFcTelemetryDict"]]], + ) -> tuple[str, ...]: + """ + Compute ESC->FC Telemetry Protocol choices when Telemetry Type is a SERIAL port. - protocol_path: ComponentPath = (component_name, section, "Protocol") + When FC->ESC Type is also SERIAL (same-port mirror mode), returns the matching same-port + protocol. When FC->ESC Type is PWM/None (independent back-channel), restricts to the + protocols allowed by the specific current FC->ESC Protocol (e.g., "Normal" only allows + "Scripting", not "ESC Telemetry" which belongs to DShot/OneShot protocols). + """ + fc_esc_type = str(self.get_component_value(("ESC", "FC->ESC Connection", "Type")) or "") + if fc_esc_type in SERIAL_PORTS: + fc_esc_protocol = str(self.get_component_value(("ESC", "FC->ESC Connection", "Protocol")) or "") + return (fc_esc_protocol,) if fc_esc_protocol in ESC_SERIAL_SAME_PORT_PROTOCOLS else ("None",) + # PWM / None: restrict to protocols supported by the currently selected FC->ESC Protocol. + fc_esc_protocol = str(self.get_component_value(("ESC", "FC->ESC Connection", "Protocol")) or "") + # Find the ESC_to_FC dict for the current protocol entry. + esc_to_fc: EscToFcTelemetryDict = {("None",): "None"} + for entry in esc_sub.values(): + if entry.get("protocol") == fc_esc_protocol: + raw = entry.get("ESC_to_FC", {("None",): "None"}) + esc_to_fc = cast("EscToFcTelemetryDict", raw) + break + return tuple( + dict.fromkeys( + telem_protocol + for port_key, telem_protocol in esc_to_fc.items() + if isinstance(port_key, tuple) + and telem_type_value in port_key + and telem_protocol not in ESC_SERIAL_SAME_PORT_PROTOCOLS + and telem_protocol != "same_as_FC_to_ESC" + ) + ) - # Calculate the new possible choices for the corresponding protocol field - if component_name == "RC Receiver": - # Filter RC protocols based on the selected connection type - if value == "None": - new_choices: tuple[str, ...] = ("None",) - else: - # For any connection type, find protocols that support it - new_choices = tuple(str(v["protocol"]) for v in RC_PROTOCOLS_DICT.values() if value in v["type"]) - self._possible_choices[protocol_path] = new_choices + @staticmethod + def _collect_telem_choices_from_esc_to_fc( + esc_to_fc: "EscToFcTelemetryDict", fc_esc_conn_type: str + ) -> tuple[tuple[str, ...], tuple[str, ...]]: + """ + Collect ESC->FC Telemetry Type and Protocol choices from a single ESC_to_FC mapping. - elif component_name == "Telemetry": - if value == "None": - self._possible_choices[protocol_path] = ("None",) + Shared helper used by both _compute_esc_telemetry_choices_union_pwm and + _compute_esc_telemetry_choices_pwm to avoid duplicating the accumulation loop. + """ + collected_types: list[str] = ["None"] + collected_protocols: list[str] = ["None"] + for port_key, telem_protocol in esc_to_fc.items(): + if telem_protocol == "same_as_FC_to_ESC" or telem_protocol in ESC_SERIAL_SAME_PORT_PROTOCOLS: + continue + if port_key == ("None",): + pass # "None" already seeded above + elif port_key == ("same_as_FC_to_ESC",): + if fc_esc_conn_type not in collected_types: + collected_types.append(fc_esc_conn_type) else: - # For non-None telemetry connections, use the standard telemetry protocols - self._possible_choices[protocol_path] = tuple( - str(v["protocol"]) for v in SERIAL_PROTOCOLS_DICT.values() if v["component"] == "Telemetry" - ) + for p in port_key: + if p not in collected_types: + collected_types.append(p) + if telem_protocol not in collected_protocols: + collected_protocols.append(telem_protocol) + return tuple(collected_types), tuple(collected_protocols) + + def _compute_esc_telemetry_choices_union_pwm(self, fc_esc_conn_type: str) -> tuple[tuple[str, ...], tuple[str, ...]]: + """ + Compute ESC->FC Telemetry choices as the union across all PWM protocol options. - elif component_name == "Battery Monitor": - if value == "None": - self._possible_choices[protocol_path] = ("None",) - return + Used when FC->ESC Protocol is stale (e.g., transitioning from CAN) so no single + protocol is authoritative yet. Shows every back-channel type/protocol that any + PWM protocol supports, giving the user the broadest valid choice set. + """ + fw_type = str(self.get_component_value(("Flight Controller", "Firmware", "Type")) or "") + esc_sub = get_esc_connection_sub_dict(fw_type) + union_types: list[str] = ["None"] + union_protocols: list[str] = ["None"] + for entry in esc_sub.values(): + if entry.get("type") != PWM_OUT_PORTS: + continue + esc_to_fc = cast("EscToFcTelemetryDict", entry.get("ESC_to_FC", {("None",): "None"})) + types, protocols = self._collect_telem_choices_from_esc_to_fc(esc_to_fc, fc_esc_conn_type) + for t in types: + if t not in union_types: + union_types.append(t) + for p in protocols: + if p not in union_protocols: + union_protocols.append(p) + return tuple(union_types), tuple(union_protocols) + + def _compute_esc_telemetry_choices_pwm( + self, fc_esc_conn_type: str, current_protocol: str + ) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Compute ESC->FC Telemetry choices when FC->ESC Connection is a PWM output.""" + fw_type = str(self.get_component_value(("Flight Controller", "Firmware", "Type")) or "") + esc_sub = get_esc_connection_sub_dict(fw_type) + esc_to_fc: EscToFcTelemetryDict = {("None",): "None"} + for entry in esc_sub.values(): + if entry.get("protocol") == current_protocol: + esc_to_fc = cast("EscToFcTelemetryDict", entry.get("ESC_to_FC", {("None",): "None"})) + break + return self._collect_telem_choices_from_esc_to_fc(esc_to_fc, fc_esc_conn_type) + + def _compute_esc_telemetry_choices(self, fc_esc_conn_type: str) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Compute valid ESC->FC Telemetry Type and Protocol choices for the given FC->ESC Connection Type.""" + if fc_esc_conn_type in CAN_PORTS: + return (fc_esc_conn_type,), self._get_esc_connection_protocols_for_type(fc_esc_conn_type) or ("None",) + if fc_esc_conn_type in SERIAL_PORTS: + # The ESC->FC Telemetry uses the same SERIAL port as the FC->ESC connection. + # Only the matching same-port protocol is valid (FETtecOneWire, Torqeedo, CoDevESC). + # This is a mirrored setting and cannot be disabled separately. + current_protocol = str(self.get_component_value(("ESC", "FC->ESC Connection", "Protocol")) or "") + if current_protocol in ESC_SERIAL_SAME_PORT_PROTOCOLS: + return (fc_esc_conn_type,), (current_protocol,) + return (fc_esc_conn_type,), ("None",) + if fc_esc_conn_type == "None": + return ("None",), ("None",) + # PWM + current_protocol = str(self.get_component_value(("ESC", "FC->ESC Connection", "Protocol")) or "") + if current_protocol not in self._mot_pwm_types: + # Protocol is stale (e.g., transitioning from CAN/SERIAL) — compute the union of + # all possible PWM back-channel options so the user sees the full valid choice set. + return self._compute_esc_telemetry_choices_union_pwm(fc_esc_conn_type) + return self._compute_esc_telemetry_choices_pwm(fc_esc_conn_type, current_protocol) + + def _update_rc_receiver_protocol_choices(self, value: str, protocol_path: ComponentPath) -> None: + """Update RC Receiver protocol choices based on the selected connection type.""" + if value == "None": + self._possible_choices[protocol_path] = ("None",) + else: + self._possible_choices[protocol_path] = tuple( + str(v["protocol"]) for v in RC_PROTOCOLS_DICT.values() if value in v["type"] + ) - # Find protocols available for the selected connection type - batt_available_protocols: list[str] = [] - for conn_dict in BATT_MONITOR_CONNECTION.values(): - conn_type = conn_dict["type"] - if isinstance(conn_type, tuple) and value in conn_type: - batt_available_protocols.append(str(conn_dict["protocol"])) + def _update_telemetry_protocol_choices(self, value: str, protocol_path: ComponentPath) -> None: + """Update Telemetry protocol choices based on the selected connection type.""" + if value == "None": + self._possible_choices[protocol_path] = ("None",) + else: + self._possible_choices[protocol_path] = tuple( + str(v["protocol"]) for v in SERIAL_PROTOCOLS_DICT.values() if v["component"] == "Telemetry" + ) - self._possible_choices[protocol_path] = tuple(batt_available_protocols) if batt_available_protocols else ("None",) + def _update_battery_monitor_protocol_choices(self, value: str, protocol_path: ComponentPath) -> None: + """Update Battery Monitor protocol choices based on the selected connection type.""" + if value == "None": + self._possible_choices[protocol_path] = ("None",) + return + batt_available_protocols: list[str] = [ + str(conn_dict["protocol"]) + for conn_dict in BATT_MONITOR_CONNECTION.values() + if isinstance(conn_dict["type"], tuple) and value in conn_dict["type"] + ] + self._possible_choices[protocol_path] = tuple(batt_available_protocols) if batt_available_protocols else ("None",) + + def _get_esc_connection_protocols_for_type(self, connection_type: str) -> tuple[str, ...]: + """Return ESC FC->ESC protocols supported by the given connection type for the active vehicle.""" + fw_type = str(self.get_component_value(("Flight Controller", "Firmware", "Type")) or "") + return tuple( + dict.fromkeys( + str(entry["protocol"]) + for entry in get_esc_connection_sub_dict(fw_type).values() + if isinstance(entry.get("type"), tuple) and connection_type in entry["type"] + ) + ) - elif component_name == "ESC": - if section == "ESC->FC Telemetry": - if value == "None": - self._possible_choices[protocol_path] = ("None",) - else: - self._possible_choices[protocol_path] = tuple( - str(v["protocol"]) - for v in ESC_TELEMETRY_DICT.values() - if isinstance(v["type"], tuple) and value in v["type"] - ) or ("None",) - elif value == "None": - self._possible_choices[protocol_path] = ("None",) - elif value in CAN_PORTS: - self._possible_choices[protocol_path] = ("DroneCAN",) - elif value in SERIAL_PORTS: - self._possible_choices[protocol_path] = tuple( - str(v["protocol"]) for v in SERIAL_PROTOCOLS_DICT.values() if v["component"] == "ESC" + def _update_esc_telemetry_type_protocol_choices(self, value: str, protocol_path: ComponentPath) -> None: + """Update ESC->FC Telemetry protocol choices based on the selected telemetry type.""" + if value == "None": + self._possible_choices[protocol_path] = ("None",) + return + if value in CAN_PORTS: + self._possible_choices[protocol_path] = self._get_esc_connection_protocols_for_type(value) or ("None",) + return + fw_type = str(self.get_component_value(("Flight Controller", "Firmware", "Type")) or "") + esc_sub = get_esc_connection_sub_dict(fw_type) + if value in PWM_OUT_PORTS: + # BDShot back-channel: collect protocols from ("same_as_FC_to_ESC",) keys + valid_protocols: tuple[str, ...] = tuple( + dict.fromkeys( + telem_protocol + for entry in esc_sub.values() + if isinstance(entry.get("ESC_to_FC"), dict) + for port_key, telem_protocol in cast("EscToFcTelemetryDict", entry["ESC_to_FC"]).items() + if port_key == ("same_as_FC_to_ESC",) and telem_protocol != "same_as_FC_to_ESC" ) - else: - # For PWM outputs, use motor PWM types - self._possible_choices[protocol_path] = self._mot_pwm_types + ) + else: + valid_protocols = self._compute_telem_serial_protocols(value, esc_sub) + self._possible_choices[protocol_path] = valid_protocols or ("None",) - elif component_name == "GNSS Receiver": - if value == "None": - self._possible_choices[protocol_path] = ("None",) - return + def _update_gnss_receiver_protocol_choices(self, value: str, protocol_path: ComponentPath) -> None: + """Update GNSS Receiver protocol choices based on the selected connection type.""" + if value == "None": + self._possible_choices[protocol_path] = ("None",) + return + gnss_available_protocols: list[str] = [ + str(conn_dict["protocol"]) + for conn_dict in GNSS_RECEIVER_CONNECTION.values() + if isinstance(conn_dict["type"], tuple) and value in conn_dict["type"] + ] + self._possible_choices[protocol_path] = tuple(gnss_available_protocols) if gnss_available_protocols else ("None",) + + def _update_possible_choices_for_path( + self, path: ComponentPath, value: Union[ComponentData, ComponentValue, None] + ) -> None: + """Update _possible_choices when connection type values that affect protocol choices are changed.""" + if len(path) < 3 or not isinstance(value, str): + return + + component_name = path[0] + section = path[1] + + # When ESC FC->ESC Connection Protocol changes, cascade-update ESC->FC Telemetry Type choices. + # This ensures the Type widget entries are always consistent with the selected FC->ESC Protocol + # (e.g., switching from Brushed to DShot expands the type options to include BDShot/SERIAL ports). + if path[2] == "Protocol" and component_name == "ESC" and section == "FC->ESC Connection": + fc_esc_type = str(self.get_component_value(("ESC", "FC->ESC Connection", "Type")) or "") + telem_types, telem_protocols = self._compute_esc_telemetry_choices(fc_esc_type) + self._possible_choices[("ESC", "ESC->FC Telemetry", "Type")] = telem_types + self._possible_choices[("ESC", "ESC->FC Telemetry", "Protocol")] = telem_protocols + return + + if path[2] != "Type": + return - # Find protocols available for the selected connection type - gnss_available_protocols: list[str] = [] - for conn_dict in GNSS_RECEIVER_CONNECTION.values(): - conn_type = conn_dict["type"] - if isinstance(conn_type, tuple) and value in conn_type: - gnss_available_protocols.append(str(conn_dict["protocol"])) + if section not in ("FC Connection", "FC->ESC Connection", "ESC->FC Telemetry"): + return - self._possible_choices[protocol_path] = tuple(gnss_available_protocols) if gnss_available_protocols else ("None",) + protocol_path: ComponentPath = (component_name, section, "Protocol") + updater = { + "RC Receiver": self._update_rc_receiver_protocol_choices, + "Telemetry": self._update_telemetry_protocol_choices, + "Battery Monitor": self._update_battery_monitor_protocol_choices, + "GNSS Receiver": self._update_gnss_receiver_protocol_choices, + }.get(component_name) + if updater is not None: + updater(value, protocol_path) + elif component_name == "ESC": + if section == "ESC->FC Telemetry": + self._update_esc_telemetry_type_protocol_choices(value, protocol_path) + else: # section == "FC->ESC Connection" + self._update_esc_fc_connection_choices(value, protocol_path) def _validate_tow_limits(self, value: str, path: ComponentPath) -> tuple[str, Optional[float]]: """Validate takeoff weight min/max cross-constraints.""" diff --git a/ardupilot_methodic_configurator/frontend_tkinter_component_editor.py b/ardupilot_methodic_configurator/frontend_tkinter_component_editor.py index eda5018af..8f9b74ccd 100755 --- a/ardupilot_methodic_configurator/frontend_tkinter_component_editor.py +++ b/ardupilot_methodic_configurator/frontend_tkinter_component_editor.py @@ -96,16 +96,167 @@ def set_mcu_series(self, mcu: str) -> None: if mcu.upper() in ("STM32F4XX", "STM32F7XX", "STM32H7XX"): self.data_model.schema.modify_schema_for_mcu_series(is_optional=True) + def populate_frames(self) -> None: + """Populate frames and then apply initial mirror state for ESC->FC Telemetry comboboxes.""" + super().populate_frames() + self._set_esc_telemetry_combobox_mirror_state() + def update_component_protocol_combobox_entries(self, component_path: ComponentPath, connection_type: str) -> str: """Updates the Protocol combobox entries based on the selected component connection Type.""" self.data_model.set_component_value(component_path, connection_type) # when the connection Type changes, we need to update the Protocol combobox entries protocol_path: ComponentPath = (component_path[0], component_path[1], "Protocol") - return self.update_protocol_combobox_entries( + err_msg = self.update_protocol_combobox_entries( self.data_model.get_combobox_values_for_path(protocol_path), protocol_path ) + # When FC->ESC Connection Type changes, also cascade-update ESC->FC Telemetry comboboxes + # (the data model already computed the new choices in _update_possible_choices_for_path) + if component_path == ("ESC", "FC->ESC Connection", "Type"): + telemetry_type_path: ComponentPath = ("ESC", "ESC->FC Telemetry", "Type") + telemetry_protocol_path: ComponentPath = ("ESC", "ESC->FC Telemetry", "Protocol") + # Capture both sets of choices BEFORE updating the Type combobox. + # Updating the Type may auto-select a new value and trigger set_component_value, which + # calls _update_possible_choices_for_path and overwrites the protocol choices that + # _update_esc_fc_connection_choices already computed correctly. + telemetry_type_choices = self.data_model.get_combobox_values_for_path(telemetry_type_path) + telemetry_protocol_choices = self.data_model.get_combobox_values_for_path(telemetry_protocol_path) + err_msg += self.update_protocol_combobox_entries(telemetry_type_choices, telemetry_type_path) + err_msg += self.update_protocol_combobox_entries(telemetry_protocol_choices, telemetry_protocol_path) + # For same-port connections (CAN, SERIAL with same-port protocol) mirror FC->ESC Protocol + # to ESC->FC Telemetry Protocol. For PWM or SERIAL with back-channel protocols the + # telemetry comboboxes remain independently editable. + # _set_esc_telemetry_combobox_mirror_state reads the current FC->ESC Protocol from the + # data model, but it was set above by set_component_value(connection_type) which triggered + # _update_esc_fc_connection_choices — the protocol may have been auto-selected. + self._set_esc_telemetry_combobox_mirror_state() + if self.data_model.is_esc_telemetry_type_mirrored(): + current_fc_esc_protocol = str( + self.data_model.get_component_value(("ESC", "FC->ESC Connection", "Protocol")) or "" + ) + self._on_esc_fc_protocol_changed(current_fc_esc_protocol) + + return err_msg + + def _set_esc_telemetry_combobox_mirror_state(self) -> None: + """ + Mirror (grey-out) or unmirror ESC->FC Telemetry comboboxes according to the data model. + + The mirror rules are protocol-dependent, not based solely on FC->ESC Connection Type: + - Telemetry Type mirroring is determined by `is_esc_telemetry_type_mirrored()`. + In practice, CAN setups are mirrored, while SERIAL is mirrored only for + same-port/shared-port protocols; PWM/DShot cases are not fully mirrored. + - Telemetry Protocol mirroring is determined by `is_esc_telemetry_protocol_mirrored()`. + CAN is mirrored, and SERIAL is mirrored only when the selected FC->ESC + protocol uses the same port for telemetry. + """ + for telem_path, mirrored in ( + (("ESC", "ESC->FC Telemetry", "Type"), self.data_model.is_esc_telemetry_type_mirrored()), + (("ESC", "ESC->FC Telemetry", "Protocol"), self.data_model.is_esc_telemetry_protocol_mirrored()), + ): + widget = self.entry_widgets.get(telem_path) + if isinstance(widget, PairTupleCombobox): + widget.configure(state="disabled" if mirrored else "normal") + + def _on_esc_fc_protocol_mirrored(self, new_protocol: str) -> None: + """Handle mirrored telemetry protocol: FC->ESC Protocol mirrors to ESC->FC Telemetry Protocol.""" + telem_protocol_path: ComponentPath = ("ESC", "ESC->FC Telemetry", "Protocol") + self.data_model.set_component_value(telem_protocol_path, new_protocol) + widget = self.entry_widgets.get(telem_protocol_path) + if isinstance(widget, PairTupleCombobox): + self.set_combobox_entries_preserving_width(widget, [(new_protocol, new_protocol)], new_protocol) + widget.update_idletasks() + self._set_esc_telemetry_combobox_mirror_state() + + def _on_esc_fc_protocol_invalid_type(self, valid_telem_types: tuple[str, ...]) -> None: + """Reset both telemetry Type and Protocol to 'None' when current Type is no longer valid.""" + telem_type_path: ComponentPath = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path: ComponentPath = ("ESC", "ESC->FC Telemetry", "Protocol") + self.data_model.set_component_value(telem_type_path, "None") + self.data_model.set_component_value(telem_protocol_path, "None") + telem_type_widget = self.entry_widgets.get(telem_type_path) + if isinstance(telem_type_widget, PairTupleCombobox): + type_tuples = get_connection_type_tuples_with_labels(valid_telem_types) + self.set_combobox_entries_preserving_width(telem_type_widget, type_tuples, "None") + telem_type_widget.update_idletasks() + telem_protocol_widget = self.entry_widgets.get(telem_protocol_path) + if isinstance(telem_protocol_widget, PairTupleCombobox): + valid_telem_protocols = self.data_model.get_combobox_values_for_path(telem_protocol_path) + protocol_tuples = [(p, p) for p in valid_telem_protocols] + self.set_combobox_entries_preserving_width(telem_protocol_widget, protocol_tuples, "None") + telem_protocol_widget.update_idletasks() + + def _on_esc_fc_protocol_recompute(self, current_telem_type: str) -> None: + """Recompute telemetry type AND protocol choices and validate current selection.""" + telem_type_path: ComponentPath = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path: ComponentPath = ("ESC", "ESC->FC Telemetry", "Protocol") + # Update the Type widget entries to reflect valid telem types for the current FC->ESC Protocol. + # get_combobox_values_for_path returns the updated telem type choices set by the data model's + # Protocol-change handler in _update_possible_choices_for_path. + valid_telem_types = self.data_model.get_combobox_values_for_path(telem_type_path) + telem_type_widget = self.entry_widgets.get(telem_type_path) + if isinstance(telem_type_widget, PairTupleCombobox): + type_tuples = get_connection_type_tuples_with_labels(valid_telem_types) + self.set_combobox_entries_preserving_width(telem_type_widget, type_tuples, current_telem_type) + telem_type_widget.update_idletasks() + self.data_model.set_component_value(telem_type_path, current_telem_type) + new_choices = self.data_model.get_combobox_values_for_path(telem_protocol_path) + widget = self.entry_widgets.get(telem_protocol_path) + if not isinstance(widget, PairTupleCombobox): + return + current_selection = widget.get_selected_key() or str(self.data_model.get_component_value(telem_protocol_path) or "") + if not current_selection and "None" in new_choices: + current_selection = "None" + protocol_tuples = [(p, p) for p in new_choices] + if current_selection and current_selection not in new_choices: + if len(new_choices) == 1: + new_selection = new_choices[0] + self.set_combobox_entries_preserving_width(widget, protocol_tuples, new_selection) + self.data_model.set_component_value(telem_protocol_path, new_selection) + else: + self.set_combobox_entries_preserving_width(widget, protocol_tuples, None) + component: str = " > ".join(telem_protocol_path) + err_msg = _( + "On {component} the selected\nprotocol '{current_selection}' " + "is not available for the selected connection Type." + ) + show_error_message(_("Error"), err_msg.format(component=component, current_selection=current_selection)) + widget.configure(style="comb_input_invalid.TCombobox") + else: + if not current_selection and len(new_choices) == 1: + current_selection = new_choices[0] + self.data_model.set_component_value(telem_protocol_path, current_selection) + self.set_combobox_entries_preserving_width(widget, protocol_tuples, current_selection) + widget.update_idletasks() + + def _on_esc_fc_protocol_changed(self, new_protocol: str) -> None: + """ + Handle FC->ESC protocol changes. + + When ``is_esc_telemetry_protocol_mirrored()`` is true, mirror the FC->ESC + Protocol into ESC->FC Telemetry. Otherwise, recompute and validate the + available ESC->FC telemetry Type/Protocol choices for the selected + connection and ensure the telemetry comboboxes are enabled. + """ + # Persist the new protocol into the data model immediately. <> fires + # before persists the value via _validate_combobox, so the cascade + # computations below would otherwise read the *previous* protocol and produce stale results. + fc_esc_protocol_path: ComponentPath = ("ESC", "FC->ESC Connection", "Protocol") + self.data_model.set_component_value(fc_esc_protocol_path, new_protocol) + if self.data_model.is_esc_telemetry_protocol_mirrored(): + self._on_esc_fc_protocol_mirrored(new_protocol) + else: + telem_type_path: ComponentPath = ("ESC", "ESC->FC Telemetry", "Type") + valid_telem_types = self.data_model.get_valid_esc_telemetry_types() + current_telem_type = str(self.data_model.get_component_value(telem_type_path) or "") + if current_telem_type not in valid_telem_types: + self._on_esc_fc_protocol_invalid_type(valid_telem_types) + else: + self._on_esc_fc_protocol_recompute(current_telem_type) + # Ensure both ESC->FC Telemetry comboboxes are enabled (not greyed). + self._set_esc_telemetry_combobox_mirror_state() + def update_protocol_combobox_entries(self, protocols: tuple[str, ...], protocol_path: ComponentPath) -> str: err_msg = "" if protocol_path in self.entry_widgets: @@ -113,22 +264,41 @@ def update_protocol_combobox_entries(self, protocols: tuple[str, ...], protocol_ # Only update if this is actually a PairTupleCombobox (protocol comboboxes should be) if isinstance(protocol_combobox, PairTupleCombobox): # Rebuild the (key, display) pairs for PairTupleCombobox - protocol_tuples = [(p, p) for p in protocols] + if protocol_path == ("ESC", "ESC->FC Telemetry", "Type"): + protocol_tuples = get_connection_type_tuples_with_labels(protocols) + else: + protocol_tuples = [(p, p) for p in protocols] # Get current selection and validate it against new protocols current_selection = protocol_combobox.get_selected_key() + if len(protocols) == 1 and not current_selection: + current_selection = protocols[0] + self.data_model.set_component_value(protocol_path, current_selection) if current_selection and current_selection not in protocols: - # Current selection is not valid for new protocols, clear it - invalid_selection = current_selection - current_selection = None - component: str = " > ".join(protocol_path) - err_msg = _( - "On {component} the selected\nprotocol '{invalid_selection}' " - "is not available for the selected connection Type." - ) - err_msg = err_msg.format(component=component, invalid_selection=invalid_selection) - - # Update the combobox entries using set_entries_tuple with validated selection - protocol_combobox.set_entries_tuple(protocol_tuples, current_selection) + if len(protocols) == 1: + # Only one option available — auto-select it silently + current_selection = protocols[0] + self.data_model.set_component_value(protocol_path, current_selection) + else: + # Current selection is not valid for new protocols, clear it + invalid_selection = current_selection + current_selection = None + if "None" in protocols: + self.data_model.set_component_value(protocol_path, "None") + component: str = " > ".join(protocol_path) + err_msg = _( + "On {component} the selected\nprotocol '{invalid_selection}' " + "is not available for the selected connection Type." + ) + err_msg = err_msg.format(component=component, invalid_selection=invalid_selection) + + # Keep ESC->FC Telemetry combobox width stable while repopulating choices. + if protocol_path in ( + ("ESC", "ESC->FC Telemetry", "Type"), + ("ESC", "ESC->FC Telemetry", "Protocol"), + ): + self.set_combobox_entries_preserving_width(protocol_combobox, protocol_tuples, current_selection) + else: + protocol_combobox.set_entries_tuple(protocol_tuples, current_selection) if err_msg: show_error_message(_("Error"), err_msg) @@ -224,6 +394,15 @@ def combined_focus_out(event: tk.Event) -> None: lambda _event: self.update_component_protocol_combobox_entries(path, cb.get_selected_key() or ""), ) + # When FC->ESC Connection Protocol changes on a SERIAL/CAN connection, mirror it to + # ESC->FC Telemetry Protocol and keep it mirrored; on PWM it stays user-selectable. + if path == ("ESC", "FC->ESC Connection", "Protocol"): + cb.bind( + "<>", + lambda _event: self._on_esc_fc_protocol_changed(cb.get_selected_key() or ""), + add="+", + ) + # When battery chemistry changes, the max, low and crit voltages will change to the # recommended values for the new chemistry, so we need to update the UI if path == ("Battery", "Specifications", "Chemistry"): diff --git a/ardupilot_methodic_configurator/frontend_tkinter_component_editor_base.py b/ardupilot_methodic_configurator/frontend_tkinter_component_editor_base.py index b50529f1c..174cc2dc2 100755 --- a/ardupilot_methodic_configurator/frontend_tkinter_component_editor_base.py +++ b/ardupilot_methodic_configurator/frontend_tkinter_component_editor_base.py @@ -12,6 +12,7 @@ import tkinter as tk from argparse import ArgumentParser, Namespace +from contextlib import suppress # from logging import debug as logging_debug from logging import basicConfig as logging_basicConfig @@ -330,6 +331,22 @@ def _update_widget_value(self, path: ComponentPath, value: str) -> None: entry.insert(0, value) entry.config(state="disabled") + @staticmethod + def set_combobox_entries_preserving_width( + combobox: PairTupleCombobox, entries: list[tuple[str, str]], selection: Optional[str] + ) -> None: + """Set combobox entries while preserving the currently configured widget width.""" + width: Optional[int] = None + try: + width = int(combobox.cget("width")) + except (AttributeError, TypeError, ValueError, tk.TclError): + # Test doubles may not have a live Tk backend; skip width preservation in that case. + width = None + combobox.set_entries_tuple(entries, selection) + if width is not None: + with suppress(AttributeError, tk.TclError): + combobox.config(width=width) + def populate_frames(self) -> None: """Populates the ScrollFrame with widgets based on the JSON data.""" components = self.data_model.get_all_components() diff --git a/ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X650_LTE/vehicle_components.json b/ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X650_LTE/vehicle_components.json index fc2211782..de0c92163 100644 --- a/ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X650_LTE/vehicle_components.json +++ b/ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X650_LTE/vehicle_components.json @@ -83,7 +83,7 @@ }, "ESC->FC Telemetry": { "Type": "Main Out", - "Protocol": "BDShot" + "Protocol": "BDShotOnly" }, "Notes": "" }, diff --git a/ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Marmotte5v2/vehicle_components.json b/ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Marmotte5v2/vehicle_components.json index 282c01023..1b469a159 100644 --- a/ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Marmotte5v2/vehicle_components.json +++ b/ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Marmotte5v2/vehicle_components.json @@ -83,7 +83,7 @@ }, "ESC->FC Telemetry": { "Type": "Main Out", - "Protocol": "BDShot" + "Protocol": "BDShotOnly" }, "Notes": "Runs BDshot600 on Main out and no serial telemetry backup channel" }, diff --git a/tests/gui_frontend_tkinter_component_editor.py b/tests/gui_frontend_tkinter_component_editor.py new file mode 100755 index 000000000..2865c481c --- /dev/null +++ b/tests/gui_frontend_tkinter_component_editor.py @@ -0,0 +1,1791 @@ +#!/usr/bin/env python3 + +""" +GUI tests for the ComponentEditorWindow using PyAutoGUI. + +This module contains automated GUI tests for the Tkinter-based component editor. +Tests verify that ESC connection type cascade updates work correctly through +real GUI comboboxes visible on screen. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2024-2026 Amilcar do Carmo Lucas + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +import json +import tempfile +from collections.abc import Generator +from pathlib import Path +from unittest.mock import patch + +import pyautogui +import pytest + +from ardupilot_methodic_configurator.backend_filesystem import LocalFilesystem +from ardupilot_methodic_configurator.frontend_tkinter_component_editor import ComponentEditorWindow +from ardupilot_methodic_configurator.frontend_tkinter_pair_tuple_combobox import PairTupleCombobox + +# pylint: disable=redefined-outer-name, too-many-lines + +_VEHICLE_COMPONENTS_WITH_ESC = { + "Format version": 1, + "Components": { + "Flight Controller": { + "Product": {"Manufacturer": "Matek", "Model": "H743 SLIM", "URL": "", "Version": ""}, + "Firmware": {"Type": "ArduCopter", "Version": "4.6.0"}, + "Specifications": {"MCU Series": "STM32H7xx"}, + "Notes": "", + }, + "Frame": { + "Product": {"Manufacturer": "Diatone", "Model": "Taycan MXC", "URL": "", "Version": ""}, + "Specifications": {"TOW min Kg": 0.5, "TOW max Kg": 1.0}, + "Notes": "", + }, + "Battery Monitor": { + "Product": {"Manufacturer": "Matek", "Model": "H743 SLIM", "URL": "", "Version": ""}, + "Firmware": {"Type": "ArduCopter", "Version": "4.6.x"}, + "FC Connection": {"Type": "Analog", "Protocol": "Analog Voltage and Current"}, + "Notes": "", + }, + "Battery": { + "Product": {"Manufacturer": "SLS", "Model": "X-Cube 1800mAh 4S", "URL": "", "Version": ""}, + "Specifications": { + "Chemistry": "Lipo", + "Volt per cell max": 4.2, + "Volt per cell arm": 3.8, + "Volt per cell low": 3.5, + "Volt per cell crit": 3.3, + "Volt per cell min": 3.1, + "Number of cells": 4, + "Capacity mAh": 1800, + }, + "Notes": "", + }, + "ESC": { + "Product": {"Manufacturer": "Mamba System", "Model": "F45_128k 4in1 ESC", "URL": "", "Version": "1"}, + "Firmware": {"Type": "BLHeli32", "Version": "32.10"}, + # Start with CAN2 so that all three ESC protocol fields are created as PairTupleComboboxes. + # The test then cascades to CAN1 to verify the cascade update. + "FC->ESC Connection": {"Type": "CAN2", "Protocol": "DroneCAN"}, + "ESC->FC Telemetry": {"Type": "CAN2", "Protocol": "DroneCAN"}, + "Notes": "", + }, + "Motors": { + "Product": {"Manufacturer": "T-Motor", "Model": "T-Motor 15507 3800kv", "URL": "", "Version": ""}, + "Specifications": {"Poles": 14}, + "Notes": "", + }, + "Propellers": { + "Product": {"Manufacturer": "HQProp", "Model": 'CineWhoop 3"', "URL": "", "Version": ""}, + "Specifications": {"Diameter_inches": 3}, + "Notes": "", + }, + "GNSS Receiver": { + "Product": {"Manufacturer": "Holybro", "Model": "H-RTK F9P Helical", "URL": "", "Version": "1"}, + "FC Connection": {"Type": "SERIAL1", "Protocol": "UBLOX"}, + "Notes": "", + }, + "RC Receiver": { + "Product": {"Manufacturer": "TBS", "Model": "TBS Crossfire Nano RX", "URL": "", "Version": ""}, + "FC Connection": {"Type": "SERIAL2", "Protocol": "CRSF"}, + "Notes": "", + }, + "Telemetry": { + "Product": {"Manufacturer": "HolyBro", "Model": "SiK Telemetry Radio V3", "URL": "", "Version": ""}, + "FC Connection": {"Type": "SERIAL3", "Protocol": "MAVLink2"}, + "Notes": "", + }, + }, +} + + +@pytest.fixture(scope="class") +def temp_vehicle_dir_with_esc() -> Generator[str, None, None]: + """Create a temporary directory with vehicle_components.json that includes full ESC data.""" + with tempfile.TemporaryDirectory() as temp_dir: + components_file = Path(temp_dir) / "vehicle_components.json" + components_file.write_text(json.dumps(_VEHICLE_COMPONENTS_WITH_ESC, indent=2), encoding="utf-8") + yield temp_dir + + +@pytest.fixture(scope="class") +def component_editor_window(temp_vehicle_dir_with_esc) -> Generator[ComponentEditorWindow, None, None]: + """ + Create a real ComponentEditorWindow with ESC comboboxes for GUI testing. + + GIVEN: A temporary vehicle directory with ESC FC->ESC Connection and ESC->FC Telemetry fields + WHEN: The ComponentEditorWindow is initialised and frames are populated + THEN: Real PairTupleCombobox widgets exist for all ESC connection paths + """ + filesystem = LocalFilesystem( + vehicle_dir=temp_vehicle_dir_with_esc, + vehicle_type="ArduCopter", + fw_version="", + allow_editing_template_files=True, + save_component_to_system_templates=False, + ) + + with patch( + "ardupilot_methodic_configurator.frontend_tkinter_usage_popup_window.UsagePopupWindow.should_display", + return_value=False, + ): + editor = ComponentEditorWindow("1.0.0", filesystem, {}) + editor.populate_frames() + editor.root.update_idletasks() + editor.root.update() + + yield editor + + editor.root.destroy() + + +class TestESCConnectionCascadeBehavior: + """ + GUI tests for ESC connection type cascade behaviour in ComponentEditorWindow. + + Validates that selecting a FC->ESC Connection Type correctly cascades to + update the FC->ESC Protocol, ESC->FC Telemetry Type, and ESC->FC Telemetry + Protocol comboboxes in the live GUI. + """ + + def test_user_selects_can1_fc_esc_type_cascades_to_dronecan( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + Selecting CAN1 as FC->ESC Connection Type cascades all related fields to DroneCAN. + + GIVEN: The component editor is open and ESC comboboxes are rendered on screen + WHEN: The user selects "CAN1" as the FC->ESC Connection Type + THEN: FC->ESC Connection Protocol becomes "DroneCAN" + AND: ESC->FC Telemetry Type becomes "CAN1" + AND: ESC->FC Telemetry Protocol becomes "DroneCAN" + AND: ESC->FC Telemetry Type and Protocol comboboxes are disabled (mirrored) + AND: PyAutoGUI can capture the updated window on screen + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + # Verify required comboboxes exist before acting + assert fc_esc_type_path in editor.entry_widgets, "FC->ESC Connection Type combobox not found" + assert fc_esc_protocol_path in editor.entry_widgets, "FC->ESC Connection Protocol combobox not found" + assert telem_type_path in editor.entry_widgets, "ESC->FC Telemetry Type combobox not found" + assert telem_protocol_path in editor.entry_widgets, "ESC->FC Telemetry Protocol combobox not found" + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox), "FC->ESC Connection Type widget must be a PairTupleCombobox" + + # Act: select "CAN1" programmatically (same as user clicking the combobox and choosing CAN1). + # Patch show_error_message to suppress any "invalid selection" popups caused by the CAN2→CAN1 + # transition (where the old telemetry port "CAN2" is no longer in the new choices). + # This is expected behaviour in cascade updates and is a known UX limitation, not a test failure. + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "CAN1", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert: FC->ESC Connection Protocol → "DroneCAN" + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + assert fc_esc_protocol_cb.get_selected_key() == "DroneCAN", ( + f"Expected FC->ESC Protocol to be 'DroneCAN', got '{fc_esc_protocol_cb.get_selected_key()}'" + ) + + # Assert: ESC->FC Telemetry Type available choices restricted to ("CAN1",) only. + # The data model correctly computed the cascade; the single-option restriction IS the + # "change to CAN1" — only CAN1 is available as ESC->FC Telemetry Type when FC->ESC is CAN1. + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + assert editor.data_model.get_combobox_values_for_path(telem_type_path) == ("CAN1",), ( + f"Expected ESC->FC Telemetry Type data model choices to be ('CAN1',), " + f"got {editor.data_model.get_combobox_values_for_path(telem_type_path)}" + ) + assert telem_type_cb.list_keys == ["CAN1"], ( + f"Expected ESC->FC Telemetry Type combobox choices to be ['CAN1'], got {telem_type_cb.list_keys}" + ) + + # Assert: ESC->FC Telemetry Protocol → "DroneCAN" + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + assert telem_protocol_cb.get_selected_key() == "DroneCAN", ( + f"Expected ESC->FC Telemetry Protocol to be 'DroneCAN', got '{telem_protocol_cb.get_selected_key()}'" + ) + + # Assert: ESC->FC Telemetry comboboxes are disabled (mirrored for CAN connections) + assert str(telem_type_cb.cget("state")) == "disabled", "ESC->FC Telemetry Type should be disabled for CAN" + assert str(telem_protocol_cb.cget("state")) == "disabled", "ESC->FC Telemetry Protocol should be disabled for CAN" + + # Assert: PyAutoGUI can capture the screen with the window visible + editor.root.deiconify() + editor.root.lift() + editor.root.update_idletasks() + editor.root.update() + + screenshot = pyautogui.screenshot() + assert screenshot is not None + assert screenshot.size[0] > 0 + assert screenshot.size[1] > 0 + + def test_user_switches_serial_port_keeps_same_port_protocol( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + Switching FC->ESC Type between SERIAL ports keeps the same-port protocol without error. + + GIVEN: FC->ESC Connection Type is navigated to SERIAL5 with CoDevESC protocol + WHEN: The user changes FC->ESC Connection Type to SERIAL8 + THEN: No error dialog is shown + AND: ESC->FC Telemetry Type is silently updated to SERIAL8 + AND: ESC->FC Telemetry Protocol stays CoDevESC (the only valid same-port protocol) + AND: Both ESC->FC Telemetry comboboxes remain disabled (SERIAL mirrors FC->ESC) + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + + # Arrange: navigate to SERIAL5 / CoDevESC starting state (suppress any cascade errors). + # Start from CAN2 (fixture default) → switch to SERIAL5 → pick CoDevESC protocol. + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "SERIAL5", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + # Select CoDevESC as the FC->ESC protocol (unlocks specific same-port mapping). + # Also sync the data model directly — the <> event on a protocol + # combobox only triggers _on_esc_fc_protocol_changed (which mirrors to telemetry) but + # does NOT update the data model for FC->ESC Protocol itself. We need the data model + # in sync so that the subsequent SERIAL8 cascade reads "CoDevESC" when computing + # telemetry protocol choices. + editor.data_model.set_component_value(fc_esc_protocol_path, "CoDevESC") + fc_esc_protocol_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_protocol_path)], + "CoDevESC", + ) + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Verify pre-conditions for the main act + assert fc_esc_type_cb.get_selected_key() == "SERIAL5" + assert fc_esc_protocol_cb.get_selected_key() == "CoDevESC" + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + assert telem_type_cb.get_selected_key() == "SERIAL5" + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + assert telem_protocol_cb.get_selected_key() == "CoDevESC" + + # Act: switch FC->ESC Connection Type to SERIAL8 + error_was_shown = False + + def record_error(*_args: object, **_kwargs: object) -> None: + nonlocal error_was_shown + error_was_shown = True + + with patch( + "ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message", + side_effect=record_error, + ): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "SERIAL8", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert: no error dialog was triggered + assert not error_was_shown, "No error dialog should appear when switching between SERIAL ports with a valid protocol" + + # Assert: FC->ESC Protocol unchanged (CoDevESC valid for any SERIAL port) + assert fc_esc_protocol_cb.get_selected_key() == "CoDevESC" + + # Assert: ESC->FC Telemetry Type silently updated to SERIAL8 + assert telem_type_cb.get_selected_key() == "SERIAL8", ( + f"Expected ESC->FC Telemetry Type to be 'SERIAL8', got '{telem_type_cb.get_selected_key()}'" + ) + + # Assert: ESC->FC Telemetry Protocol remains CoDevESC — not cleared, no error + assert telem_protocol_cb.get_selected_key() == "CoDevESC", ( + f"Expected ESC->FC Telemetry Protocol to remain 'CoDevESC', got '{telem_protocol_cb.get_selected_key()}'" + ) + + # Assert: ESC->FC Telemetry comboboxes remain disabled (SERIAL mirrors FC->ESC) + assert str(telem_type_cb.cget("state")) == "disabled", "ESC->FC Telemetry Type should be disabled for SERIAL" + assert str(telem_protocol_cb.cget("state")) == "disabled", "ESC->FC Telemetry Protocol should be disabled for SERIAL" + + def test_user_selects_serial_telemetry_type_with_normal_pwm_excludes_esc_telemetry_protocol( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + ESC->FC Telemetry Type SERIAL1 with FC->ESC Normal offers only Scripting, not ESC Telemetry. + + GIVEN: FC->ESC Connection Type is "Main Out" with Protocol "Normal" + WHEN: The user selects ESC->FC Telemetry Type = "SERIAL1" + THEN: ESC->FC Telemetry Protocol choices contain only "Scripting" + AND: "ESC Telemetry" is NOT offered (it belongs to DShot/OneShot, not Normal) + AND: No error dialog is shown + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + + # Arrange: switch FC->ESC Type to "Main Out" (PWM), suppress cascade errors. + # Also directly set the data model Protocol to "Normal" — the FC->ESC Protocol widget + # is empty in test context (no apm.pdef.xml doc), but the data model value is what + # _compute_telem_serial_protocols reads when computing ESC->FC Telemetry choices. + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "Main Out", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + editor.data_model.set_component_value(fc_esc_protocol_path, "Normal") + + # Verify: ESC->FC Telemetry comboboxes are enabled (unmirored for PWM) + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + assert str(telem_type_cb.cget("state")) != "disabled", "ESC->FC Telemetry Type should be enabled for PWM" + + # Act: select ESC->FC Telemetry Type = "SERIAL1" + error_was_shown = False + + def record_error(*_args: object, **_kwargs: object) -> None: + nonlocal error_was_shown + error_was_shown = True + + with patch( + "ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message", + side_effect=record_error, + ): + telem_type_choices = editor.data_model.get_combobox_values_for_path(telem_type_path) + assert "SERIAL1" in telem_type_choices, ( + f"SERIAL1 should be available in ESC->FC Telemetry choices: {telem_type_choices}" + ) + telem_type_cb.set_entries_tuple([(k, k) for k in telem_type_choices], "SERIAL1") + telem_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert: no error dialog + assert not error_was_shown, "No error dialog should appear when selecting SERIAL1 with a valid Normal protocol" + + # Assert: Protocol choices contain only "Scripting" — not "ESC Telemetry" + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + available_protocols = editor.data_model.get_combobox_values_for_path(telem_protocol_path) + assert "Scripting" in available_protocols, ( + f"'Scripting' should be available for Normal/SERIAL1, got: {available_protocols}" + ) + assert "ESC Telemetry" not in available_protocols, ( + f"'ESC Telemetry' must NOT be available for Normal/SERIAL1 (only DShot/OneShot), got: {available_protocols}" + ) + + def test_user_selects_serial_telemetry_type_with_dshot_shows_esc_telemetry_not_scripting( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + ESC->FC Telemetry Type SERIAL1 with FC->ESC DShot600 offers only ESC Telemetry, not Scripting. + + GIVEN: FC->ESC Connection Type is "Main Out" with Protocol "DShot600" + WHEN: The user selects ESC->FC Telemetry Type = "SERIAL1" + THEN: ESC->FC Telemetry Protocol choices contain "ESC Telemetry" + AND: "Scripting" is NOT offered (it belongs to Normal, not DShot) + AND: No error dialog is shown + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + + # Arrange: ensure FC->ESC Type is "Main Out" with Protocol "DShot600". + # Re-apply "Main Out" to be safe (previous test may have left it there already), + # then override the data model Protocol to "DShot600". + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "Main Out", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + editor.data_model.set_component_value(fc_esc_protocol_path, "DShot600") + + # Verify: ESC->FC Telemetry Type combobox is enabled for PWM + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + assert str(telem_type_cb.cget("state")) != "disabled", "ESC->FC Telemetry Type should be enabled for PWM" + + # Act: select ESC->FC Telemetry Type = "SERIAL1" + error_was_shown = False + + def record_error(*_args: object, **_kwargs: object) -> None: + nonlocal error_was_shown + error_was_shown = True + + with patch( + "ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message", + side_effect=record_error, + ): + telem_type_choices = editor.data_model.get_combobox_values_for_path(telem_type_path) + assert "SERIAL1" in telem_type_choices, ( + f"SERIAL1 should be available in ESC->FC Telemetry choices: {telem_type_choices}" + ) + telem_type_cb.set_entries_tuple([(k, k) for k in telem_type_choices], "SERIAL1") + telem_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert: no error dialog + assert not error_was_shown, "No error dialog should appear when selecting SERIAL1 with a valid DShot protocol" + + # Assert: Protocol choices contain "ESC Telemetry" — not "Scripting" + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + available_protocols = editor.data_model.get_combobox_values_for_path(telem_protocol_path) + assert "ESC Telemetry" in available_protocols, ( + f"'ESC Telemetry' should be available for DShot600/SERIAL1, got: {available_protocols}" + ) + assert "Scripting" not in available_protocols, ( + f"'Scripting' must NOT be available for DShot600/SERIAL1 (only Normal), got: {available_protocols}" + ) + + def test_user_changes_fc_esc_protocol_to_oneshot_autoselects_esc_telemetry_protocol( # pylint: disable=too-many-locals + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + Changing FC->ESC Protocol to OneShot auto-selects the single valid ESC->FC Telemetry Protocol. + + GIVEN: FC->ESC Connection is (Main Out, Normal) and ESC->FC Telemetry is (SERIAL5, Scripting) + WHEN: The user changes FC->ESC Connection Protocol from "Normal" to "OneShot" + THEN: No error dialog appears — there is exactly one valid option ("ESC Telemetry") + AND: ESC->FC Telemetry Protocol is automatically set to "ESC Telemetry" + AND: The new telemetry protocol choices contain "ESC Telemetry" but not "Scripting" + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + + # Arrange: set FC->ESC = (Main Out, Normal), ESC->FC Telemetry = (SERIAL5, Scripting). + # Suppress errors during setup — we only care about errors triggered by the act step. + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + # Step 1: switch FC->ESC Type to "Main Out" (PWM) + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "Main Out", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Step 2: set FC->ESC Protocol = "Normal" in the data model and widget. + editor.data_model.set_component_value(fc_esc_protocol_path, "Normal") + fc_esc_protocol_cb.set_entries_tuple([("Normal", "Normal")], "Normal") + + # Step 3: switch ESC->FC Telemetry Type to SERIAL5. + telem_type_choices = editor.data_model.get_combobox_values_for_path(telem_type_path) + telem_type_cb.set_entries_tuple([(k, k) for k in telem_type_choices], "SERIAL5") + telem_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Step 4: set ESC->FC Telemetry Protocol = "Scripting" (the only choice for Normal/SERIAL5). + editor.data_model.set_component_value(telem_protocol_path, "Scripting") + telem_protocol_cb.set_entries_tuple([("Scripting", "Scripting")], "Scripting") + + # Verify pre-conditions + assert fc_esc_type_cb.get_selected_key() == "Main Out" + assert fc_esc_protocol_cb.get_selected_key() == "Normal" + assert telem_type_cb.get_selected_key() == "SERIAL5" + assert telem_protocol_cb.get_selected_key() == "Scripting" + assert str(telem_protocol_cb.cget("state")) != "disabled", "ESC->FC Telemetry Protocol must be editable for PWM" + + # Act: user changes FC->ESC Protocol from "Normal" to "OneShot". + error_was_shown = False + + def record_error(*_args: object, **_kwargs: object) -> None: + nonlocal error_was_shown + error_was_shown = True + + with patch( + "ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message", + side_effect=record_error, + ): + editor.data_model.set_component_value(fc_esc_protocol_path, "OneShot") + fc_esc_protocol_cb.set_entries_tuple([("OneShot", "OneShot")], "OneShot") + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert: no error dialog — only one valid option, so it is auto-selected silently + assert not error_was_shown, "No error dialog should appear when there is exactly one valid option to auto-select" + + # Assert: ESC->FC Telemetry Protocol is automatically set to "ESC Telemetry" + assert telem_protocol_cb.get_selected_key() == "ESC Telemetry", ( + f"Expected ESC->FC Telemetry Protocol to be auto-selected as 'ESC Telemetry', " + f"got '{telem_protocol_cb.get_selected_key()}'" + ) + + # Assert: data model also reflects the auto-selected value + assert editor.data_model.get_component_value(telem_protocol_path) == "ESC Telemetry", ( + "Data model ESC->FC Telemetry Protocol should also be updated to 'ESC Telemetry'" + ) + + # Assert: new protocol choices contain "ESC Telemetry" (correct for OneShot/SERIAL5) + new_choices = editor.data_model.get_combobox_values_for_path(telem_protocol_path) + assert "ESC Telemetry" in new_choices, ( + f"'ESC Telemetry' should now be available for OneShot/SERIAL5, got: {new_choices}" + ) + assert "Scripting" not in new_choices, f"'Scripting' must NOT be available for OneShot/SERIAL5, got: {new_choices}" + + def test_pwm_fc_esc_leaves_telemetry_comboboxes_enabled( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + PWM FC->ESC connection leaves ESC->FC Telemetry comboboxes editable (not greyed). + + GIVEN: FC->ESC Connection Type is "Main Out" (PWM protocol, not same_as_FC_to_ESC) + WHEN: The component editor is displayed + THEN: ESC->FC Telemetry Type combobox is NOT disabled + AND: ESC->FC Telemetry Protocol combobox is NOT disabled + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + + # Arrange: switch to "Main Out" (a PWM port — back-channel is independent, not mirrored) + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "Main Out", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert: both ESC->FC Telemetry comboboxes are editable + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + + assert str(telem_type_cb.cget("state")) != "disabled", ( + "ESC->FC Telemetry Type must NOT be disabled for PWM FC->ESC connection" + ) + assert str(telem_protocol_cb.cget("state")) != "disabled", ( + "ESC->FC Telemetry Protocol must NOT be disabled for PWM FC->ESC connection" + ) + + def test_serial_non_same_port_protocol_leaves_telemetry_comboboxes_enabled( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + SERIAL FC->ESC with a non-same-port protocol (e.g. Normal) leaves telemetry comboboxes editable. + + GIVEN: FC->ESC Connection Type is "SERIAL5" with Protocol "Normal" + (Normal is NOT a same_as_FC_to_ESC protocol — back-channel is independent) + WHEN: The component editor is displayed + THEN: ESC->FC Telemetry Type combobox is NOT disabled + AND: ESC->FC Telemetry Protocol combobox is NOT disabled + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + + # Arrange: switch FC->ESC Type to SERIAL5 with Protocol "Normal" + # (Normal is a PWM-style protocol carried over SERIAL — not same_as_FC_to_ESC) + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "SERIAL5", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Set FC->ESC Protocol to "Normal" (not a same-port protocol) + editor.data_model.set_component_value(fc_esc_protocol_path, "Normal") + # Re-trigger mirror-state evaluation by firing _on_esc_fc_protocol_changed + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + fc_esc_protocol_cb.set_entries_tuple([("Normal", "Normal")], "Normal") + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert: both ESC->FC Telemetry comboboxes are NOT disabled + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + + assert str(telem_type_cb.cget("state")) != "disabled", ( + "ESC->FC Telemetry Type must NOT be disabled for SERIAL5/Normal (back-channel is independent)" + ) + assert str(telem_protocol_cb.cget("state")) != "disabled", ( + "ESC->FC Telemetry Protocol must NOT be disabled for SERIAL5/Normal (back-channel is independent)" + ) + + def test_serial_same_port_protocol_disables_telemetry_comboboxes( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + SERIAL FC->ESC with a same-port protocol (e.g. FETtecOneWire) disables telemetry comboboxes. + + GIVEN: FC->ESC Connection Type is "SERIAL5" with Protocol "FETtecOneWire" + (FETtecOneWire IS a same_as_FC_to_ESC protocol — telemetry mirrors FC->ESC) + WHEN: The component editor is displayed + THEN: ESC->FC Telemetry Type combobox IS disabled + AND: ESC->FC Telemetry Protocol combobox IS disabled + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + + # Arrange: switch FC->ESC to SERIAL5 / FETtecOneWire (a same-port protocol) + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "SERIAL5", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + editor.data_model.set_component_value(fc_esc_protocol_path, "FETtecOneWire") + fc_esc_protocol_cb.set_entries_tuple([("FETtecOneWire", "FETtecOneWire")], "FETtecOneWire") + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert: both ESC->FC Telemetry comboboxes ARE disabled (mirrored) + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + + assert str(telem_type_cb.cget("state")) == "disabled", ( + "ESC->FC Telemetry Type MUST be disabled for SERIAL5/FETtecOneWire (same_as_FC_to_ESC)" + ) + assert str(telem_protocol_cb.cget("state")) == "disabled", ( + "ESC->FC Telemetry Protocol MUST be disabled for SERIAL5/FETtecOneWire (same_as_FC_to_ESC)" + ) + + def test_changing_fc_esc_protocol_to_brushed_resets_both_telemetry_type_and_protocol( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + Changing FC->ESC Protocol to Brushed resets both ESC->FC Telemetry Type and Protocol to "None". + + Brushed uses ESC_TO_FC_TELEMETRY_NONE — no back-channel is possible. When the user + was previously using a DShot protocol with a dedicated serial telemetry port, both the + Type (e.g. SERIAL3) and the Protocol (e.g. ESC Telemetry) must be invalidated and + auto-reset to "None" when the FC->ESC protocol changes to Brushed. + + GIVEN: FC->ESC Connection is (Main Out, DShot150) + AND: ESC->FC Telemetry is (SERIAL3, ESC Telemetry) + WHEN: The user changes the FC->ESC Protocol to "Brushed" + THEN: ESC->FC Telemetry Type is immediately reset to "None" + AND: ESC->FC Telemetry Protocol is immediately reset to "None" + AND: Both data model values are updated to "None" + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + + # Arrange: set FC->ESC = (Main Out, DShot150), ESC->FC Telemetry = (SERIAL3, ESC Telemetry). + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "Main Out", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + editor.data_model.set_component_value(fc_esc_protocol_path, "DShot150") + fc_esc_protocol_cb.set_entries_tuple([("DShot150", "DShot150")], "DShot150") + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + telem_type_choices = editor.data_model.get_combobox_values_for_path(telem_type_path) + assert "SERIAL3" in telem_type_choices, f"SERIAL3 should be available for DShot150: {telem_type_choices}" + telem_type_cb.set_entries_tuple([(k, k) for k in telem_type_choices], "SERIAL3") + telem_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + telem_protocol_choices = editor.data_model.get_combobox_values_for_path(telem_protocol_path) + assert "ESC Telemetry" in telem_protocol_choices, ( + f"ESC Telemetry should be available for DShot150/SERIAL3: {telem_protocol_choices}" + ) + editor.data_model.set_component_value(telem_protocol_path, "ESC Telemetry") + telem_protocol_cb.set_entries_tuple([("ESC Telemetry", "ESC Telemetry")], "ESC Telemetry") + + # Verify pre-conditions + assert fc_esc_type_cb.get_selected_key() == "Main Out" + assert fc_esc_protocol_cb.get_selected_key() == "DShot150" + assert telem_type_cb.get_selected_key() == "SERIAL3" + assert telem_protocol_cb.get_selected_key() == "ESC Telemetry" + + # Act: user changes FC->ESC Protocol to "Brushed" (no back-channel possible). + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + editor.data_model.set_component_value(fc_esc_protocol_path, "Brushed") + fc_esc_protocol_cb.set_entries_tuple([("Brushed", "Brushed")], "Brushed") + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert: ESC->FC Telemetry Type is reset to "None" + assert telem_type_cb.get_selected_key() == "None", ( + f"ESC->FC Telemetry Type must be reset to 'None' when FC->ESC Protocol is Brushed " + f"(no back-channel possible), got '{telem_type_cb.get_selected_key()}'" + ) + + # Assert: ESC->FC Telemetry Protocol is reset to "None" + assert telem_protocol_cb.get_selected_key() == "None", ( + f"ESC->FC Telemetry Protocol must be reset to 'None' when FC->ESC Protocol is Brushed " + f"(no back-channel possible), got '{telem_protocol_cb.get_selected_key()}'" + ) + + # Assert: data model reflects the reset values + assert editor.data_model.get_component_value(telem_type_path) == "None", ( + "Data model ESC->FC Telemetry Type must be 'None' after switching to Brushed" + ) + assert editor.data_model.get_component_value(telem_protocol_path) == "None", ( + "Data model ESC->FC Telemetry Protocol must be 'None' after switching to Brushed" + ) + + def test_changing_fc_esc_protocol_to_dshot_leaves_telemetry_type_editable( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + Changing FC->ESC Protocol to DShot150 leaves ESC->FC Telemetry Type editable (not greyed). + + DShot protocols have ESC_TO_FC_TELEMETRY_DSHOT with three valid type choices: + ("None",), ("same_as_FC_to_ESC",), and SERIAL_PORTS. The ESC->FC Telemetry Type + should be enabled and offer all three type options. (It is NOT fully mirrored — + only the Protocol field mirrors when a specific type is selected.) + + GIVEN: FC->ESC Connection is (Main Out, Brushed) and ESC->FC Telemetry is (None, None) + WHEN: The user changes the FC->ESC Protocol to "DShot150" + THEN: ESC->FC Telemetry Type combobox IS enabled (not greyed out) + AND: ESC->FC Telemetry Protocol combobox IS enabled (not greyed out) + AND: ESC->FC Telemetry Type choices include all valid options + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + + # Arrange: set FC->ESC = (Main Out, Brushed), ESC->FC Telemetry = (None, None). + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "Main Out", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + editor.data_model.set_component_value(fc_esc_protocol_path, "Brushed") + fc_esc_protocol_cb.set_entries_tuple([("Brushed", "Brushed")], "Brushed") + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + editor.data_model.set_component_value(telem_type_path, "None") + telem_type_cb.set_entries_tuple([("None", "None")], "None") + + editor.data_model.set_component_value(telem_protocol_path, "None") + telem_protocol_cb.set_entries_tuple([("None", "None")], "None") + + # Verify pre-conditions + assert fc_esc_type_cb.get_selected_key() == "Main Out" + assert fc_esc_protocol_cb.get_selected_key() == "Brushed" + assert telem_type_cb.get_selected_key() == "None" + assert telem_protocol_cb.get_selected_key() == "None" + + # Act: user changes FC->ESC Protocol from "Brushed" to "DShot150". + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + editor.data_model.set_component_value(fc_esc_protocol_path, "DShot150") + fc_esc_protocol_cb.set_entries_tuple([("DShot150", "DShot150")], "DShot150") + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert: ESC->FC Telemetry Type combobox IS enabled (not greyed out) + # DShot has multiple type choices (None, BDShotOnly same-port, SERIAL) so it is NOT fully mirrored + assert str(telem_type_cb.cget("state")) != "disabled", ( + "ESC->FC Telemetry Type MUST be enabled for DShot150 (has multiple type choices: " + "None, same_as_FC_to_ESC, SERIAL_PORTS)" + ) + + # Assert: ESC->FC Telemetry Protocol combobox IS enabled (not greyed out) + # Until the user selects a specific Type, the Protocol should also be editable + assert str(telem_protocol_cb.cget("state")) != "disabled", ( + "ESC->FC Telemetry Protocol MUST be enabled for DShot150 when Type is not mirrored" + ) + + def test_fc_esc_protocol_change_takes_effect_immediately_not_on_next_selection( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + Changing FC->ESC Protocol takes effect immediately on <>, not one selection later. + + Regression test for the bug where <> fires before persists + the new value to the data model, causing the cascade to read the *previous* protocol and + produce stale results — so the first selection appeared to do nothing and the intended effect + only appeared on the next selection. + + GIVEN: FC->ESC Connection is (Main Out, Normal) and ESC->FC Telemetry is (SERIAL5, Scripting) + AND: The data model still holds "Normal" for FC->ESC Protocol (not yet persisted by ) + WHEN: The FC->ESC Protocol widget shows "OneShot" and <> fires + WITHOUT first updating the data model (replicating the real Tkinter event order) + THEN: ESC->FC Telemetry Protocol is immediately auto-selected as "ESC Telemetry" + AND: The data model is also updated to "ESC Telemetry" in the same event + AND: No second selection is needed to trigger the change + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + + # Arrange: set FC->ESC = (Main Out, Normal), ESC->FC Telemetry = (SERIAL5, Scripting). + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "Main Out", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + editor.data_model.set_component_value(fc_esc_protocol_path, "Normal") + fc_esc_protocol_cb.set_entries_tuple([("Normal", "Normal")], "Normal") + + telem_type_choices = editor.data_model.get_combobox_values_for_path(telem_type_path) + telem_type_cb.set_entries_tuple([(k, k) for k in telem_type_choices], "SERIAL5") + telem_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + editor.data_model.set_component_value(telem_protocol_path, "Scripting") + telem_protocol_cb.set_entries_tuple([("Scripting", "Scripting")], "Scripting") + + # Verify pre-conditions + assert fc_esc_type_cb.get_selected_key() == "Main Out" + assert fc_esc_protocol_cb.get_selected_key() == "Normal" + assert telem_type_cb.get_selected_key() == "SERIAL5" + assert telem_protocol_cb.get_selected_key() == "Scripting" + + # Act: simulate the real Tkinter event order — the widget shows "OneShot" but the data + # model has NOT been updated yet (<> fires before , which + # is what _validate_combobox uses to persist the value to the data model). + # This is the exact condition that triggered the regression. + fc_esc_protocol_cb.set_entries_tuple([("Normal", "Normal"), ("OneShot", "OneShot")], "OneShot") + # Data model intentionally NOT updated here — it still holds "Normal" + assert editor.data_model.get_component_value(fc_esc_protocol_path) == "Normal", ( + "Pre-condition: data model must still hold 'Normal' to replicate the regression scenario" + ) + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert: ESC->FC Telemetry Protocol is immediately "ESC Telemetry" — no second selection needed. + assert telem_protocol_cb.get_selected_key() == "ESC Telemetry", ( + f"ESC->FC Telemetry Protocol must be auto-selected as 'ESC Telemetry' immediately on the " + f"first <> event, got '{telem_protocol_cb.get_selected_key()}'. " + f"If this is still 'Scripting', the regression has returned: the cascade is reading the " + f"stale data model value instead of the protocol passed to _on_esc_fc_protocol_changed." + ) + + # Assert: data model also reflects the auto-selected value + assert editor.data_model.get_component_value(telem_protocol_path) == "ESC Telemetry", ( + "Data model ESC->FC Telemetry Protocol must be updated to 'ESC Telemetry' in the same event" + ) + + def test_pwm_main_out_fc_esc_type_change_cascades_protocol_and_telemetry( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + Changing FC->ESC Type between PWM ports keeps protocol options and leaves telemetry editable. + + GIVEN: FC->ESC Connection is set to "Main Out" with Normal protocol + WHEN: The user changes FC->ESC Connection Type to "AIO" + THEN: FC->ESC Protocol options remain non-empty (same set for all PWM_OUT_PORTS) + AND: ESC->FC Telemetry comboboxes are NOT disabled (Normal/PWM has no telemetry mirroring) + AND: PyAutoGUI can capture the updated window on screen + """ + editor = component_editor_window + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + + # Arrange: Set FC->ESC to Main Out/Normal + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "Main Out", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + editor.data_model.set_component_value(fc_esc_protocol_path, "Normal") + + # Act: Change FC->ESC Type to AIO + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "AIO", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert cascade results + assert fc_esc_type_cb.get_selected_key() == "AIO" + # For Normal/PWM, the telemetry comboboxes must NOT be disabled (independent back-channel). + # Only CAN/DroneCAN and SERIAL same-port protocols (FETtecOneWire, CoDevESC, etc.) mirror. + assert str(telem_type_cb.cget("state")) != "disabled", ( + "ESC->FC Telemetry Type must NOT be disabled for AIO/Normal (PWM has no telemetry mirroring)" + ) + assert str(telem_protocol_cb.cget("state")) != "disabled", ( + "ESC->FC Telemetry Protocol must NOT be disabled for AIO/Normal (PWM has no telemetry mirroring)" + ) + # FC->ESC Protocol combobox must have entries (validates that _mot_pwm_types is populated) + assert len(fc_esc_protocol_cb.list_keys) > 0, ( + "FC->ESC Protocol combobox must have entries for PWM type; got empty list — _mot_pwm_types may not be initialised" + ) + + # Assert PyAutoGUI can capture the screen + editor.root.deiconify() + editor.root.lift() + editor.root.update_idletasks() + screenshot = pyautogui.screenshot() + assert screenshot.size[0] > 0 + + def test_changing_fc_esc_protocol_from_brushed_to_dshot300_expands_telemetry_type_options( # noqa: PLR0915 # pylint: disable=too-many-locals, too-many-statements + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + Changing FC->ESC Protocol from Brushed to DShot300 expands ESC->FC Telemetry Type choices. + + DShot300 uses ESC_TO_FC_TELEMETRY_DSHOT which supports three back-channel categories: + "None" (no telemetry), the FC->ESC port itself via BDShot, and any SERIAL port via + ESC Telemetry. Brushed has no back-channel (ESC_TO_FC_TELEMETRY_NONE = only "None"). + + GIVEN: FC->ESC Connection is ("Main Out", "Brushed") and ESC->FC Telemetry is ("None", "None") + WHEN: The user changes FC->ESC Connection Protocol from "Brushed" to "DShot300" + THEN: No error dialog is shown (current telemetry "None" is valid for DShot300) + AND: ESC->FC Telemetry Type combobox is NOT disabled (DShot has an independent back-channel) + AND: ESC->FC Telemetry Protocol combobox is NOT disabled + AND: ESC->FC Telemetry Type choices now include "Main Out" and SERIAL ports (not just "None") + AND: ESC->FC Telemetry Type selection remains "None" (still valid — no forced reset) + AND: ESC->FC Telemetry Type widget entries include "Main Out" and "SERIAL2" (not just "None") + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + + # Arrange: FC->ESC = (Main Out, Brushed), ESC->FC Telemetry = (None, None) + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "Main Out", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + editor.data_model.set_component_value(fc_esc_protocol_path, "Brushed") + fc_esc_protocol_cb.set_entries_tuple([("Brushed", "Brushed")], "Brushed") + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Verify pre-conditions + assert fc_esc_type_cb.get_selected_key() == "Main Out" + assert fc_esc_protocol_cb.get_selected_key() == "Brushed" + assert telem_type_cb.get_selected_key() == "None", ( + f"Pre-condition: ESC->FC Telemetry Type should be 'None', got '{telem_type_cb.get_selected_key()}'" + ) + assert telem_protocol_cb.get_selected_key() == "None", ( + f"Pre-condition: ESC->FC Telemetry Protocol should be 'None', got '{telem_protocol_cb.get_selected_key()}'" + ) + brushed_valid_types = editor.data_model.get_valid_esc_telemetry_types() + assert brushed_valid_types == ("None",), ( + f"Pre-condition: Brushed should allow only ('None',) as telemetry types, got {brushed_valid_types}" + ) + + # Act: change FC->ESC Protocol from "Brushed" to "DShot300" + error_was_shown = False + + def record_error(*_args: object, **_kwargs: object) -> None: + nonlocal error_was_shown + error_was_shown = True + + with patch( + "ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message", + side_effect=record_error, + ): + editor.data_model.set_component_value(fc_esc_protocol_path, "DShot300") + fc_esc_protocol_cb.set_entries_tuple([("DShot300", "DShot300")], "DShot300") + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert: no error dialog — current telemetry "None" is valid for DShot300 + assert not error_was_shown, "No error dialog should appear: ESC->FC Telemetry 'None' is valid for DShot300" + + # Assert: FC->ESC Protocol selection unchanged + assert fc_esc_protocol_cb.get_selected_key() == "DShot300" + + # Assert: both ESC->FC Telemetry comboboxes are enabled (DShot has no full telemetry mirroring) + assert str(telem_type_cb.cget("state")) != "disabled", ( + "ESC->FC Telemetry Type must NOT be disabled for DShot300 (independent back-channel)" + ) + assert str(telem_protocol_cb.cget("state")) != "disabled", ( + "ESC->FC Telemetry Protocol must NOT be disabled for DShot300" + ) + + # Assert: ESC->FC Telemetry Type choices are now expanded (DShot allows BDShot and SERIAL) + dshot_valid_types = editor.data_model.get_valid_esc_telemetry_types() + assert "Main Out" in dshot_valid_types, ( + f"DShot300 should offer 'Main Out' (BDShot back-channel) as telemetry type, got {dshot_valid_types}" + ) + assert "SERIAL1" in dshot_valid_types, ( + f"DShot300 should offer SERIAL ports as telemetry types, got {dshot_valid_types}" + ) + assert "None" in dshot_valid_types, f"DShot300 should still offer 'None' as telemetry type, got {dshot_valid_types}" + + # Assert: current ESC->FC Telemetry Type selection stays "None" (no forced reset needed) + assert telem_type_cb.get_selected_key() == "None", ( + f"ESC->FC Telemetry Type should remain 'None' (still valid for DShot300), got '{telem_type_cb.get_selected_key()}'" + ) + + # Assert: current ESC->FC Telemetry Protocol stays "None" + assert telem_protocol_cb.get_selected_key() == "None", ( + f"ESC->FC Telemetry Protocol should remain 'None', got '{telem_protocol_cb.get_selected_key()}'" + ) + + # Assert: the Type widget's dropdown entries are now expanded (not just ["None"]). + # This is the KEY check — the widget itself must reflect the new valid set, not stale Brushed options. + assert "Main Out" in telem_type_cb.list_keys, ( + f"Type widget entries must include 'Main Out' (BDShot) after DShot300, got {telem_type_cb.list_keys}" + ) + assert "SERIAL2" in telem_type_cb.list_keys, ( + f"Type widget entries must include SERIAL ports after DShot300, got {telem_type_cb.list_keys}" + ) + assert telem_type_cb.list_keys != ["None"], "Type widget must offer more than just 'None' when DShot300 is selected" + + def test_pwm_main_out_fc_esc_protocol_change_cascades_to_telemetry_protocol( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """Changing FC->ESC Protocol on Main Out cascades to ESC->FC Telemetry Protocol.""" + editor = component_editor_window + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + + # Arrange + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "Main Out", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + editor.data_model.set_component_value(fc_esc_protocol_path, "Normal") + + # Act + fc_esc_protocol_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_protocol_path)], + "DShot600", + ) + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert + assert fc_esc_protocol_cb.get_selected_key() == "DShot600" + assert telem_protocol_cb.get_selected_key() in editor.data_model.get_combobox_values_for_path(telem_protocol_path) + + def test_pwm_main_out_telem_type_change_cascades_to_telemetry_protocol( # pylint: disable=too-many-locals + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + Changing ESC->FC Telemetry Type on SERIAL cascades to ESC->FC Telemetry Protocol. + + GIVEN: FC->ESC Connection is "Main Out" with "DShot" protocol + AND: ESC->FC Telemetry Type is "SERIAL1" + WHEN: The user changes ESC->FC Telemetry Type to another SERIAL port (e.g., "SERIAL5") + THEN: ESC->FC Telemetry Protocol options are recalculated for the new SERIAL port + AND: ESC->FC Telemetry Protocol remains a valid option + AND: Data model is updated to reflect the cascaded changes + AND: PyAutoGUI can capture the updated window on screen + """ + editor = component_editor_window + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + + # Arrange: Set FC->ESC to Main Out/DShot + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "Main Out", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + editor.data_model.set_component_value(fc_esc_protocol_path, "DShot") + fc_esc_protocol_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_protocol_path)], + "DShot", + ) + + # Get available telemetry types and select an alternative SERIAL port + telem_types = editor.data_model.get_combobox_values_for_path(telem_type_path) + serial_ports = [t for t in telem_types if t.startswith("SERIAL")] + if len(serial_ports) < 2: + pytest.skip("Not enough SERIAL ports available for cascade test") + + alt_port = serial_ports[1] # Use second SERIAL port as alternative + + # Act: Change ESC->FC Telemetry Type to alternative SERIAL port + telem_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(telem_type_path)], + alt_port, + ) + telem_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert: Telemetry Type changed + assert telem_type_cb.get_selected_key() == alt_port + # Assert: Telemetry Protocol is still valid for the new type + assert telem_protocol_cb.get_selected_key() in editor.data_model.get_combobox_values_for_path(telem_protocol_path) + # Assert: Data model is in sync + assert editor.data_model.get_component_value(telem_type_path) == alt_port + + # Assert PyAutoGUI can capture the screen + editor.root.deiconify() + editor.root.lift() + editor.root.update_idletasks() + screenshot = pyautogui.screenshot() + assert screenshot.size[0] > 0 + + # ------------------------------------------------------------------------- + # Cascade rule 1: FC->ESC Type change → Protocol + Telem Type + Telem Protocol + # ------------------------------------------------------------------------- + + def test_cascade_1_fc_esc_type_change_from_can_to_pwm_updates_all_downstream_widget_entries( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + Cascade rule 1: FC->ESC Type change updates Protocol, Telem Type, and Telem Protocol entries. + + Mirroring is applied only after all downstream option lists are populated. + + GIVEN: FC->ESC Connection is ("CAN1", "DroneCAN") and ESC->FC Telemetry mirrors it + WHEN: The user changes FC->ESC Connection Type to "Main Out" (PWM) + THEN: FC->ESC Protocol widget entries list all valid PWM protocols + AND: ESC->FC Telemetry Type widget entries list valid types for the auto-selected PWM protocol + AND: Both ESC->FC Telemetry comboboxes are enabled (NOT disabled — PWM is not mirrored) + AND: All changes are immediately visible without any further user interaction + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + + # Arrange: FC->ESC = (CAN1, DroneCAN) — fully mirrored initial state + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "CAN1", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Verify: CAN state (fully mirrored, disabled) + assert fc_esc_protocol_cb.list_keys == ["DroneCAN"], ( + f"Pre-condition: CAN should restrict Protocol to ['DroneCAN'], got {fc_esc_protocol_cb.list_keys}" + ) + assert telem_type_cb.list_keys == ["CAN1"], ( + f"Pre-condition: CAN should restrict Telem Type to ['CAN1'], got {telem_type_cb.list_keys}" + ) + assert str(telem_type_cb.cget("state")) == "disabled", "Pre-condition: Telem Type should be disabled for CAN" + + # Act: change FC->ESC Type to "Main Out" (PWM) + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "Main Out", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert cascade rule 1: FC->ESC Protocol widget entries updated + assert len(fc_esc_protocol_cb.list_keys) > 1, ( + f"FC->ESC Protocol should offer multiple PWM options after 'Main Out' type, got {fc_esc_protocol_cb.list_keys}" + ) + # Normal, Brushed, DShot* and other PWM protocols should be available + assert any(k in fc_esc_protocol_cb.list_keys for k in ("Normal", "Brushed", "DShot150")), ( + f"FC->ESC Protocol must include common PWM protocols, got {fc_esc_protocol_cb.list_keys}" + ) + + # Assert cascade rule 1: ESC->FC Telemetry Type widget entries updated + # The auto-selected PWM protocol determines which telem types are valid. + assert len(telem_type_cb.list_keys) >= 1, ( + f"Telem Type widget must have at least one option after PWM type change, got {telem_type_cb.list_keys}" + ) + assert "None" in telem_type_cb.list_keys, "'None' must always be an option in Telem Type widget" + + # Assert cascade rule 1: mirroring is NOT applied for PWM (it was applied last, after options) + assert str(telem_type_cb.cget("state")) != "disabled", ( + "ESC->FC Telemetry Type must be enabled after switching to PWM 'Main Out' " + "(mirroring only for CAN/SERIAL same-port)" + ) + assert str(telem_protocol_cb.cget("state")) != "disabled", ( + "ESC->FC Telemetry Protocol must be enabled after switching to PWM 'Main Out'" + ) + + # ------------------------------------------------------------------------- + # Cascade rule 2: FC->ESC Protocol change → Telem Type + Telem Protocol + # ------------------------------------------------------------------------- + + def test_cascade_2_fc_esc_protocol_change_to_dshot150_expands_telem_type_widget_entries( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + Cascade rule 2 expanding: Brushed to DShot150 Protocol change expands Telem Type entries. + + Changing FC->ESC Protocol from Brushed to DShot150 immediately expands the + ESC->FC Telemetry Type widget entries to include BDShot and SERIAL options. + + GIVEN: FC->ESC Connection is ("Main Out", "Brushed") and ESC->FC Telemetry is ("None", "None") + WHEN: The user changes FC->ESC Protocol to "DShot150" + THEN: ESC->FC Telemetry Type widget entries include "Main Out" (BDShot back-channel) + AND: ESC->FC Telemetry Type widget entries include SERIAL ports (dedicated ESC telemetry) + AND: ESC->FC Telemetry Type current selection stays "None" (no forced reset) + AND: Both ESC->FC Telemetry comboboxes remain enabled (DShot is not fully mirrored) + AND: No error dialog is shown (current "None" is still valid) + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + + # Arrange: FC->ESC = (Main Out, Brushed), ESC->FC = (None, None) + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "Main Out", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + editor.data_model.set_component_value(fc_esc_protocol_path, "Brushed") + fc_esc_protocol_cb.set_entries_tuple([("Brushed", "Brushed")], "Brushed") + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Verify pre-conditions + assert fc_esc_protocol_cb.get_selected_key() == "Brushed" + assert telem_type_cb.get_selected_key() == "None" + assert telem_type_cb.list_keys == ["None"], ( + f"Pre-condition: Brushed should restrict Telem Type widget to ['None'], got {telem_type_cb.list_keys}" + ) + + # Act: change FC->ESC Protocol to DShot150 + error_was_shown = False + + def record_error(*_args: object, **_kwargs: object) -> None: + nonlocal error_was_shown + error_was_shown = True + + with patch( + "ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message", + side_effect=record_error, + ): + editor.data_model.set_component_value(fc_esc_protocol_path, "DShot150") + fc_esc_protocol_cb.set_entries_tuple([("DShot150", "DShot150")], "DShot150") + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert cascade rule 2: no error (current "None" telem type is still valid for DShot150) + assert not error_was_shown, "No error dialog should appear: 'None' is valid for DShot150" + + # Assert cascade rule 2: Telem Type widget entries expanded immediately + assert "Main Out" in telem_type_cb.list_keys, ( + f"Telem Type widget must include 'Main Out' (BDShot) after DShot150, got {telem_type_cb.list_keys}" + ) + assert "SERIAL1" in telem_type_cb.list_keys, ( + f"Telem Type widget must include SERIAL ports after DShot150, got {telem_type_cb.list_keys}" + ) + assert telem_type_cb.list_keys != ["None"], "Telem Type widget must offer more than 'None' for DShot150" + + # Assert cascade rule 2: current selection unchanged (no forced reset) + assert telem_type_cb.get_selected_key() == "None", ( + f"Telem Type selection must remain 'None' (still valid), got '{telem_type_cb.get_selected_key()}'" + ) + assert telem_protocol_cb.get_selected_key() == "None", ( + f"Telem Protocol selection must remain 'None', got '{telem_protocol_cb.get_selected_key()}'" + ) + + # Assert: mirroring applied after options — both comboboxes are enabled (not mirrored for DShot) + assert str(telem_type_cb.cget("state")) != "disabled", "Telem Type must be enabled after DShot150 (not fully mirrored)" + assert str(telem_protocol_cb.cget("state")) != "disabled", "Telem Protocol must be enabled after DShot150" + + def test_cascade_2_fc_esc_protocol_change_to_brushed_restricts_telem_type_widget_to_none( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + Cascade rule 2 restricting: DShot300 to Brushed Protocol change restricts Telem Type to None. + + Changing from DShot300 to Brushed immediately restricts the ESC->FC Telemetry Type + widget entries to only "None". + + GIVEN: FC->ESC Connection is ("Main Out", "DShot300") and ESC->FC Telemetry is ("None", "None") + WHEN: The user changes FC->ESC Protocol to "Brushed" + THEN: ESC->FC Telemetry Type widget entries shrink to ["None"] only + AND: ESC->FC Telemetry Protocol widget entries shrink to ["None"] only + AND: Both selections stay "None" (still valid — no error needed) + AND: No error dialog is shown + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + + # Arrange: FC->ESC = (Main Out, DShot300), ESC->FC = (None, None) + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "Main Out", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + editor.data_model.set_component_value(fc_esc_protocol_path, "DShot300") + fc_esc_protocol_cb.set_entries_tuple([("DShot300", "DShot300")], "DShot300") + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Verify pre-conditions: DShot300 should have expanded Telem Type options + assert "Main Out" in telem_type_cb.list_keys, ( + f"Pre-condition: DShot300 should expand Telem Type widget, got {telem_type_cb.list_keys}" + ) + assert telem_type_cb.get_selected_key() == "None" + + # Act: change FC->ESC Protocol to Brushed + error_was_shown = False + + def record_error(*_args: object, **_kwargs: object) -> None: + nonlocal error_was_shown + error_was_shown = True + + with patch( + "ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message", + side_effect=record_error, + ): + editor.data_model.set_component_value(fc_esc_protocol_path, "Brushed") + fc_esc_protocol_cb.set_entries_tuple([("Brushed", "Brushed")], "Brushed") + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert cascade rule 2: no error (current "None" is still valid for Brushed) + assert not error_was_shown, "No error dialog should appear: 'None' is valid for Brushed" + + # Assert cascade rule 2: Telem Type widget entries restricted to ["None"] + assert telem_type_cb.list_keys == ["None"], ( + f"Telem Type widget must be restricted to ['None'] for Brushed, got {telem_type_cb.list_keys}" + ) + + # Assert cascade rule 2: Telem Protocol widget entries restricted to ["None"] + assert telem_protocol_cb.list_keys == ["None"], ( + f"Telem Protocol widget must be restricted to ['None'] for Brushed, got {telem_protocol_cb.list_keys}" + ) + + # Assert: selections remain "None" (valid, no forced reset needed) + assert telem_type_cb.get_selected_key() == "None" + assert telem_protocol_cb.get_selected_key() == "None" + + # ------------------------------------------------------------------------- + # Cascade rule 3: ESC->FC Telemetry Type change → Telem Protocol + # ------------------------------------------------------------------------- + + def test_cascade_3_telem_type_change_from_none_to_main_out_shows_bdshot_protocol_in_widget( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + Cascade rule 3: Telem Type change from None to Main Out updates Protocol widget to BDShotOnly. + + Changing ESC->FC Telemetry Type from "None" to "Main Out" (BDShot back-channel) + immediately updates the Telem Protocol widget to show only "BDShotOnly". + + GIVEN: FC->ESC Connection is ("Main Out", "DShot300") and ESC->FC Telemetry is ("None", "None") + WHEN: The user changes ESC->FC Telemetry Type to "Main Out" + THEN: ESC->FC Telemetry Protocol widget entries contain only "BDShotOnly" + AND: "ESC Telemetry" is NOT offered (that is for SERIAL back-channel, not BDShot) + AND: "None" is NOT offered (back-channel is now active) + AND: ESC->FC Telemetry Protocol is auto-selected to "BDShotOnly" (single valid option) + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + + # Arrange: FC->ESC = (Main Out, DShot300), ESC->FC = (None, None) + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "Main Out", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + editor.data_model.set_component_value(fc_esc_protocol_path, "DShot300") + fc_esc_protocol_cb.set_entries_tuple([("DShot300", "DShot300")], "DShot300") + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Verify pre-conditions: telem type widget must offer "Main Out" (DShot BDShot option) + assert "Main Out" in telem_type_cb.list_keys, ( + f"Pre-condition: DShot300 must offer 'Main Out' in Telem Type widget, got {telem_type_cb.list_keys}" + ) + assert telem_type_cb.get_selected_key() == "None" + + # Act: change Telem Type to "Main Out" (BDShot back-channel) + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + telem_type_cb.set_entries_tuple([(k, k) for k in telem_type_cb.list_keys], "Main Out") + telem_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert cascade rule 3: Telem Protocol widget entries updated to BDShot only + assert telem_type_cb.get_selected_key() == "Main Out" + assert "BDShotOnly" in telem_protocol_cb.list_keys, ( + f"BDShot back-channel must offer 'BDShotOnly' protocol, got {telem_protocol_cb.list_keys}" + ) + assert "ESC Telemetry" not in telem_protocol_cb.list_keys, ( + f"'ESC Telemetry' must NOT appear for 'Main Out' BDShot (it belongs to SERIAL), got {telem_protocol_cb.list_keys}" + ) + # With only one valid option, it should be auto-selected + assert telem_protocol_cb.get_selected_key() == "BDShotOnly", ( + f"Protocol must be auto-selected to 'BDShotOnly' (only option), got '{telem_protocol_cb.get_selected_key()}'" + ) + + def test_cascade_3_telem_type_change_from_none_to_serial_updates_protocol_widget( + self, + component_editor_window: ComponentEditorWindow, + gui_test_environment, # pylint: disable=unused-argument + ) -> None: + """ + Cascade rule 3: Telem Type change to SERIAL updates Protocol widget for active FC->ESC Protocol. + + Changing ESC->FC Telemetry Type to a SERIAL port immediately updates the + Telem Protocol widget to show the correct protocol for the active FC->ESC Protocol. + + For DShot: SERIAL type → "ESC Telemetry" (dedicated back-channel). + For Normal: SERIAL type → "Scripting" (scripting-based back-channel). + + GIVEN: FC->ESC Connection is ("Main Out", "DShot150") and ESC->FC Telemetry is ("None", "None") + WHEN: The user changes ESC->FC Telemetry Type to "SERIAL4" + THEN: ESC->FC Telemetry Protocol widget contains "ESC Telemetry" + AND: "Scripting" is NOT offered (that belongs to Normal, not DShot) + AND: The widget's list_keys is updated (not just the data model) + """ + editor = component_editor_window + + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_protocol_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_type_cb = editor.entry_widgets[fc_esc_type_path] + assert isinstance(fc_esc_type_cb, PairTupleCombobox) + fc_esc_protocol_cb = editor.entry_widgets[fc_esc_protocol_path] + assert isinstance(fc_esc_protocol_cb, PairTupleCombobox) + telem_type_cb = editor.entry_widgets[telem_type_path] + assert isinstance(telem_type_cb, PairTupleCombobox) + telem_protocol_cb = editor.entry_widgets[telem_protocol_path] + assert isinstance(telem_protocol_cb, PairTupleCombobox) + + # Arrange: FC->ESC = (Main Out, DShot150), ESC->FC = (None, None) + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + fc_esc_type_cb.set_entries_tuple( + [(k, k) for k in editor.data_model.get_combobox_values_for_path(fc_esc_type_path)], + "Main Out", + ) + fc_esc_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + editor.data_model.set_component_value(fc_esc_protocol_path, "DShot150") + fc_esc_protocol_cb.set_entries_tuple([("DShot150", "DShot150")], "DShot150") + fc_esc_protocol_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Verify pre-conditions + assert fc_esc_protocol_cb.get_selected_key() == "DShot150" + assert "SERIAL4" in telem_type_cb.list_keys, ( + f"Pre-condition: DShot150 must offer SERIAL ports in Telem Type widget, got {telem_type_cb.list_keys}" + ) + + # Act: change Telem Type to SERIAL4 + with patch("ardupilot_methodic_configurator.frontend_tkinter_component_editor.show_error_message"): + telem_type_cb.set_entries_tuple([(k, k) for k in telem_type_cb.list_keys], "SERIAL4") + telem_type_cb.event_generate("<>") + editor.root.update_idletasks() + editor.root.update() + + # Assert cascade rule 3: Telem Protocol widget entries updated + assert telem_type_cb.get_selected_key() == "SERIAL4" + assert "ESC Telemetry" in telem_protocol_cb.list_keys, ( + f"DShot150/SERIAL4 must offer 'ESC Telemetry' in Protocol widget, got {telem_protocol_cb.list_keys}" + ) + assert "Scripting" not in telem_protocol_cb.list_keys, ( + f"'Scripting' must NOT appear for DShot150 (it belongs to Normal), got {telem_protocol_cb.list_keys}" + ) + assert "BDShotOnly" not in telem_protocol_cb.list_keys, ( + f"'BDShotOnly' must NOT appear for SERIAL4 (it is a PWM back-channel, not SERIAL), " + f"got {telem_protocol_cb.list_keys}" + ) diff --git a/tests/test_data_model_vehicle_components_base.py b/tests/test_data_model_vehicle_components_base.py index e029d40d3..98b89bf32 100755 --- a/tests/test_data_model_vehicle_components_base.py +++ b/tests/test_data_model_vehicle_components_base.py @@ -1668,7 +1668,7 @@ def test_user_sees_notes_last_across_all_components_simultaneously(self, model_i "ESC": { "Product": {"Manufacturer": "Mamba"}, "FC->ESC Connection": {"Type": "Main Out", "Protocol": "DShot600"}, - "ESC->FC Telemetry": {"Type": "Main Out", "Protocol": "BDShot"}, + "ESC->FC Telemetry": {"Type": "Main Out", "Protocol": "BDShotOnly"}, "Notes": "esc notes", }, "Motors": { diff --git a/tests/test_data_model_vehicle_components_import.py b/tests/test_data_model_vehicle_components_import.py index 318daa114..17c300df2 100755 --- a/tests/test_data_model_vehicle_components_import.py +++ b/tests/test_data_model_vehicle_components_import.py @@ -255,8 +255,8 @@ def test_system_handles_invalid_serial_protocol_value(self, realistic_model) -> assert result is False @patch( - "ardupilot_methodic_configurator.data_model_vehicle_components_import.get_mot_pwm_type_sub_dict", - lambda *_: {"6": {"protocol": "DShot600"}}, + "ardupilot_methodic_configurator.data_model_vehicle_components_import.get_esc_connection_sub_dict", + lambda *_: {"6": {"protocol": "DShot600", "ESC_to_FC": {}, "type": ("Main Out", "AIO")}}, ) def test_system_imports_esc_on_main_outputs(self, realistic_model) -> None: """ @@ -281,8 +281,10 @@ def test_system_imports_esc_on_main_outputs(self, realistic_model) -> None: assert esc_protocol == "DShot600" @patch( - "ardupilot_methodic_configurator.data_model_vehicle_components_import.get_mot_pwm_type_sub_dict", - lambda *_: {"6": {"protocol": "DShot600"}}, + "ardupilot_methodic_configurator.data_model_vehicle_components_import.get_esc_connection_sub_dict", + lambda *_: { + "6": {"protocol": "DShot600", "ESC_to_FC": {("same_as_FC_to_ESC",): "BDShotOnly"}, "type": ("Main Out", "AIO")} + }, ) def test_system_imports_esc_aio_configuration(self, realistic_model) -> None: """ @@ -479,16 +481,18 @@ def test_system_skips_disabled_serial_ports(self, realistic_model) -> None: def test_system_falls_back_to_mot_pwm_dict_when_doc_empty(self, realistic_model) -> None: """ - System falls back to MOT_PWM_TYPE_DICT when documentation is empty. + System falls back to ESC_CONNECTION_DICT when documentation is empty. - GIVEN: Empty documentation but MOT_PWM_TYPE_DICT available + GIVEN: Empty documentation but ESC_CONNECTION_DICT available WHEN: Importing ESC configuration - THEN: MOT_PWM_TYPE_DICT should be used as fallback + THEN: ESC_CONNECTION_DICT should be used as fallback AND: ESC protocol should be correctly identified """ with patch( - "ardupilot_methodic_configurator.data_model_vehicle_components_import.get_mot_pwm_type_sub_dict", - return_value={"6": {"protocol": "DShot600"}}, + "ardupilot_methodic_configurator.data_model_vehicle_components_import.get_esc_connection_sub_dict", + return_value={ + "6": {"protocol": "DShot600", "ESC_to_FC": {("same_as_FC_to_ESC",): "BDShotOnly"}, "type": ("Main Out", "AIO")} + }, ): fc_parameters = {"MOT_PWM_TYPE": 6} doc: dict[str, Any] = {} # Empty doc should trigger fallback @@ -502,7 +506,7 @@ def test_system_handles_esc_protocol_not_found(self, realistic_model) -> None: """ System handles ESC protocol not found in either documentation or dictionary. - GIVEN: MOT_PWM_TYPE not in documentation or MOT_PWM_TYPE_DICT + GIVEN: MOT_PWM_TYPE not in documentation or ESC_CONNECTION_DICT WHEN: Importing ESC configuration THEN: System should handle gracefully without setting protocol """ @@ -912,8 +916,10 @@ def test_system_handles_dshot_without_poles_parameter(self, realistic_model) -> THEN: System should handle gracefully without setting poles """ with patch( - "ardupilot_methodic_configurator.data_model_vehicle_components_import.get_mot_pwm_type_sub_dict", - lambda *_: {"6": {"protocol": "DShot600", "is_dshot": True}}, + "ardupilot_methodic_configurator.data_model_vehicle_components_import.get_esc_connection_sub_dict", + lambda *_: { + "6": {"protocol": "DShot600", "type": ("Main Out", "AIO"), "ESC_to_FC": {("same_as_FC_to_ESC",): "BDShotOnly"}} + }, ): fc_parameters = { "MOT_PWM_TYPE": 6, @@ -2080,12 +2086,12 @@ def test_system_skips_setting_esc_protocol_when_doc_value_is_absent(self, basic_ def test_system_uses_mot_pwm_type_dict_fallback_when_doc_has_no_mot_pwm_type(self, basic_model) -> None: """ - _set_esc_type_from_fc_parameters falls back to MOT_PWM_TYPE_DICT when doc lacks values. + _set_esc_type_from_fc_parameters falls back to ESC_CONNECTION_DICT when doc lacks values. GIVEN: doc has no MOT_PWM_TYPE entry (empty) - AND: fc_parameters has MOT_PWM_TYPE=0 (which IS in MOT_PWM_TYPE_DICT as 'Normal') + AND: fc_parameters has MOT_PWM_TYPE=0 (which IS in ESC_CONNECTION_DICT as 'Normal') WHEN: _set_esc_type_from_fc_parameters is called - THEN: Protocol is set to 'Normal' via the elif fallback (line 335 exercised) + THEN: Protocol is set to 'Normal' via the elif fallback """ fc_parameters: dict[str, Any] = {"MOT_PWM_TYPE": 0} doc: dict[str, Any] = {} # no MOT_PWM_TYPE in doc @@ -2230,9 +2236,9 @@ def test_system_leaves_esc_protocol_unchanged_when_type_not_in_doc_or_dict(self, _set_esc_type_from_fc_parameters leaves ESC protocol unchanged when mot_pwm_type is unknown. GIVEN: doc is empty (no MOT_PWM_TYPE key) - AND: fc_parameters has MOT_PWM_TYPE=999 (not in MOT_PWM_TYPE_DICT either) + AND: fc_parameters has MOT_PWM_TYPE=999 (not in ESC_CONNECTION_DICT either) WHEN: _set_esc_type_from_fc_parameters is called - THEN: Neither the doc branch nor the dict fallback sets the protocol (line 335->exit exercised) + THEN: Neither the doc branch nor the dict fallback sets the protocol AND: The function completes without error """ fc_parameters: dict[str, Any] = {"MOT_PWM_TYPE": 999} # not in dict diff --git a/tests/test_frontend_tkinter_component_editor.py b/tests/test_frontend_tkinter_component_editor.py index d528d0f58..c53bff48a 100755 --- a/tests/test_frontend_tkinter_component_editor.py +++ b/tests/test_frontend_tkinter_component_editor.py @@ -23,11 +23,14 @@ setup_common_editor_mocks, ) -from ardupilot_methodic_configurator.data_model_vehicle_components_validation import BATTERY_CELL_VOLTAGE_PATHS +from ardupilot_methodic_configurator.data_model_vehicle_components_validation import ( + BATTERY_CELL_VOLTAGE_PATHS, + get_connection_type_tuples_with_labels, +) from ardupilot_methodic_configurator.frontend_tkinter_component_editor import ComponentEditorWindow from ardupilot_methodic_configurator.frontend_tkinter_pair_tuple_combobox import PairTupleCombobox -# pylint: disable=protected-access,redefined-outer-name +# pylint: disable=protected-access,redefined-outer-name,too-many-lines @pytest.fixture @@ -57,6 +60,24 @@ def editor_with_mocked_root() -> ComponentEditorWindow: yield editor +class _FakePairTupleCombobox(PairTupleCombobox): # pylint: disable=too-many-ancestors + """Lightweight PairTupleCombobox-compatible test double.""" + + def __init__(self, selected_key: str) -> None: # pylint: disable=super-init-not-called + self._selected_key = selected_key + self.list_keys: list[str] = [selected_key] if selected_key else [] + self.configure = MagicMock() + self.update_idletasks = MagicMock() + self.set_entries_tuple = MagicMock(side_effect=self._set_entries_tuple) + + def _set_entries_tuple(self, entries: list[tuple[str, str]], selection: str = "") -> None: + self.list_keys = [key for key, _ in entries] + self._selected_key = selection or "" + + def get_selected_key(self) -> str: + return self._selected_key + + class TestComponentEditorWindow: # pylint: disable=too-many-public-methods """Test cases for ComponentEditorWindow class.""" @@ -831,3 +852,169 @@ def mock_set_entries_tuple(entries, selection) -> None: # If SBUS is valid for RCin/SBUS, no error should occur mock_show_error.assert_not_called() assert result == "" + + def test_esc_fc_telemetry_type_widget_refreshes_to_same_serial_port_when_fc_esc_connection_changes_to_serial( + self, editor_with_mocked_root + ) -> None: + """ + ESC->FC Telemetry Type widget is refreshed to the new serial port when FC->ESC Connection type changes. + + GIVEN: ESC FC->ESC Connection Type combobox is changed to a SERIAL port (e.g. SERIAL1) + WHEN: update_component_protocol_combobox_entries is called with the new SERIAL port + THEN: The ESC->FC Telemetry Type combobox is updated to display only that same SERIAL port + AND: The displayed selection in the widget matches the SERIAL port that was chosen + """ + editor = editor_with_mocked_root + serial_port = "SERIAL1" + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_proto_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_proto_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_protocol = "FETtecOneWire" + + # GIVEN: Data model cascade returns SERIAL1 as the only valid Telemetry Type option + # (what _update_esc_fc_connection_choices produces when FC->ESC is SERIAL) + def combobox_values_for_path(path) -> tuple: + return { + fc_esc_proto_path: (fc_esc_protocol,), + telem_type_path: (serial_port,), + telem_proto_path: ("None",), + }.get(path, ()) + + editor.data_model.get_combobox_values_for_path.side_effect = combobox_values_for_path + editor.data_model.get_component_value = MagicMock( + side_effect=lambda path: fc_esc_protocol if path == fc_esc_proto_path else serial_port + ) + + # GIVEN: PairTupleCombobox-compatible test doubles are registered for all three paths + fc_esc_proto_widget = _FakePairTupleCombobox(fc_esc_protocol) + telem_type_widget = _FakePairTupleCombobox(serial_port) + telem_proto_widget = _FakePairTupleCombobox("None") + + editor.entry_widgets[fc_esc_proto_path] = fc_esc_proto_widget + editor.entry_widgets[telem_type_path] = telem_type_widget + editor.entry_widgets[telem_proto_path] = telem_proto_widget + + # WHEN: User changes FC->ESC Connection Type to SERIAL1 + editor.update_component_protocol_combobox_entries(fc_esc_type_path, serial_port) + + # THEN: ESC->FC Telemetry Type widget is refreshed with only the matching SERIAL port + telem_type_widget.set_entries_tuple.assert_called_once_with( + get_connection_type_tuples_with_labels((serial_port,)), serial_port + ) + + def test_esc_fc_telemetry_comboboxes_are_disabled_when_fc_esc_connection_type_is_serial( + self, editor_with_mocked_root + ) -> None: + """ + ESC->FC Telemetry comboboxes are greyed-out (disabled) when FC->ESC Connection type is SERIAL. + + GIVEN: ESC FC->ESC Connection Type is changed to a SERIAL port + WHEN: update_component_protocol_combobox_entries is called + THEN: Both ESC->FC Telemetry Type and Protocol comboboxes are set to state="disabled" + AND: The user cannot independently change the Telemetry port (it is locked to the FC->ESC port) + """ + editor = editor_with_mocked_root + serial_port = "SERIAL2" + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_proto_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_proto_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + fc_esc_protocol = "FETtecOneWire" + + # GIVEN: Data model cascade returns the SERIAL port for both Telemetry fields + def combobox_values_for_path(path) -> tuple: + return { + fc_esc_proto_path: (fc_esc_protocol,), + telem_type_path: (serial_port,), + telem_proto_path: ("None",), + }.get(path, ()) + + editor.data_model.get_combobox_values_for_path.side_effect = combobox_values_for_path + editor.data_model.get_component_value = MagicMock( + side_effect=lambda path: fc_esc_protocol if path == fc_esc_proto_path else serial_port + ) + + # GIVEN: PairTupleCombobox-compatible test doubles registered for the telemetry paths + fc_esc_proto_widget = _FakePairTupleCombobox(fc_esc_protocol) + telem_type_widget = _FakePairTupleCombobox(serial_port) + telem_proto_widget = _FakePairTupleCombobox("None") + + editor.entry_widgets[fc_esc_proto_path] = fc_esc_proto_widget + editor.entry_widgets[telem_type_path] = telem_type_widget + editor.entry_widgets[telem_proto_path] = telem_proto_widget + + # WHEN: User changes FC->ESC Connection Type to a SERIAL port + editor.update_component_protocol_combobox_entries(fc_esc_type_path, serial_port) + + # THEN: Both ESC->FC Telemetry comboboxes are disabled (user cannot change them independently) + telem_type_widget.configure.assert_any_call(state="disabled") + telem_proto_widget.configure.assert_any_call(state="disabled") + + def test_esc_fc_telemetry_protocol_mirrors_fc_esc_protocol_when_type_changes_to_serial( + self, editor_with_mocked_root + ) -> None: + """ + ESC->FC Telemetry Protocol is mirrored to FC->ESC Protocol when FC->ESC Type changes to SERIAL. + + GIVEN: FC->ESC Protocol is already set to "FETtecOneWire" + WHEN: FC->ESC Connection Type is changed to SERIAL1 + THEN: ESC->FC Telemetry Protocol widget is updated to display "FETtecOneWire" (not "None") + AND: The widget selection is set to the same protocol, not left stale + """ + editor = editor_with_mocked_root + serial_port = "SERIAL1" + fc_esc_protocol = "FETtecOneWire" + fc_esc_type_path = ("ESC", "FC->ESC Connection", "Type") + fc_esc_proto_path = ("ESC", "FC->ESC Connection", "Protocol") + telem_type_path = ("ESC", "ESC->FC Telemetry", "Type") + telem_proto_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + # GIVEN: Data model returns the correct cascaded values after the Type change + def combobox_values_for_path(path) -> tuple: + return { + fc_esc_proto_path: (fc_esc_protocol,), + telem_type_path: (serial_port,), + telem_proto_path: ("None", fc_esc_protocol), + }.get(path, ()) + + editor.data_model.get_combobox_values_for_path.side_effect = combobox_values_for_path + # _on_esc_fc_protocol_changed reads the FC->ESC Connection Type and Protocol from data model + editor.data_model.get_component_value = MagicMock( + side_effect=lambda path: fc_esc_protocol if path == fc_esc_proto_path else serial_port + ) + + fc_esc_proto_widget = _FakePairTupleCombobox(fc_esc_protocol) + telem_type_widget = _FakePairTupleCombobox(serial_port) + + # ESC->FC Protocol was previously "None" — this is the stale value the bug leaves + telem_proto_widget = _FakePairTupleCombobox("None") + + editor.entry_widgets[fc_esc_proto_path] = fc_esc_proto_widget + editor.entry_widgets[telem_type_path] = telem_type_widget + editor.entry_widgets[telem_proto_path] = telem_proto_widget + + # WHEN: User changes FC->ESC Connection Type to SERIAL1 + editor.update_component_protocol_combobox_entries(fc_esc_type_path, serial_port) + + # THEN: ESC->FC Telemetry Protocol widget is set to the same protocol as FC->ESC + telem_proto_widget.set_entries_tuple.assert_called_with([(fc_esc_protocol, fc_esc_protocol)], fc_esc_protocol) + + def test_update_protocol_combobox_entries_autoselects_only_option_when_selection_is_empty( + self, editor_with_mocked_root + ) -> None: + """Single-option protocol comboboxes auto-select their sole value even when no current selection exists.""" + editor = editor_with_mocked_root + protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + protocol_widget = _FakePairTupleCombobox("") + + editor.entry_widgets[protocol_path] = protocol_widget + + result = editor.update_protocol_combobox_entries(("DroneCAN",), protocol_path) + + assert result == "" + editor.data_model.set_component_value.assert_any_call(protocol_path, "DroneCAN") + assert protocol_widget.list_keys == ["DroneCAN"] + assert protocol_widget.get_selected_key() == "DroneCAN" diff --git a/tests/test_frontend_tkinter_component_editor_integration.py b/tests/test_frontend_tkinter_component_editor_integration.py index 9639425db..e9d60789a 100755 --- a/tests/test_frontend_tkinter_component_editor_integration.py +++ b/tests/test_frontend_tkinter_component_editor_integration.py @@ -293,6 +293,7 @@ def test_editor_initialization_process(self, temp_vehicle_dir, root) -> None: patch.object(ComponentEditorWindow, "_create_save_frame") as mock_save_frame, patch("tkinter.PhotoImage"), patch("PIL.ImageTk.PhotoImage"), + patch("tkinter.Tk", return_value=root), ): # Create the editor with real filesystem and data model editor = ComponentEditorWindow("1.0.0", filesystem, {}) @@ -344,6 +345,7 @@ def test_editor_with_real_data_model(self, temp_vehicle_dir, root) -> None: patch.object(ComponentEditorWindow, "_initialize_ui"), patch("tkinter.PhotoImage"), patch("PIL.ImageTk.PhotoImage"), + patch("tkinter.Tk", return_value=root), ): # Initialize with existing data_model using dependency injection editor = ComponentEditorWindow("1.0.0", filesystem, {}) diff --git a/tests/unit_data_model_vehicle_components_validation.py b/tests/unit_data_model_vehicle_components_validation.py index a6d2a7326..3693a41df 100755 --- a/tests/unit_data_model_vehicle_components_validation.py +++ b/tests/unit_data_model_vehicle_components_validation.py @@ -22,12 +22,13 @@ SERIAL_BUS_LABELS, SERIAL_DISPLAY_TO_KEY, ComponentDataModelValidation, + get_esc_connection_sub_dict, ) # pylint: disable=protected-access -class TestValidationInternals: +class TestValidationInternals: # pylint: disable=too-many-public-methods """Unit tests for ComponentDataModelValidation internal implementation.""" @pytest.fixture @@ -99,11 +100,18 @@ def test_update_possible_choices_for_esc_can(self, realistic_model) -> None: """Test _update_possible_choices_for_path with ESC CAN connection.""" model = realistic_model model.init_possible_choices({"MOT_PWM_TYPE": {"values": {"0": "Normal", "6": "DShot600"}}}) + expected_protocols = tuple( + dict.fromkeys( + str(entry["protocol"]) + for entry in get_esc_connection_sub_dict("ArduCopter").values() + if isinstance(entry.get("type"), tuple) and "CAN1" in entry["type"] + ) + ) model._update_possible_choices_for_path(("ESC", "FC->ESC Connection", "Type"), "CAN1") protocol_choices = model._possible_choices.get(("ESC", "FC->ESC Connection", "Protocol"), ()) - assert protocol_choices == ("DroneCAN",) + assert protocol_choices == expected_protocols def test_update_possible_choices_for_esc_pwm(self, realistic_model) -> None: """Test _update_possible_choices_for_path with ESC PWM connection.""" # pylint: disable=duplicate-code # Common connection test pattern @@ -116,6 +124,24 @@ def test_update_possible_choices_for_esc_pwm(self, realistic_model) -> None: assert len(protocol_choices) > 0 # pylint: enable=duplicate-code + def test_esc_telemetry_type_can_uses_derived_protocols(self, realistic_model) -> None: + """Test CAN telemetry types use the protocols derived from the ESC connection table.""" + model = realistic_model + model.init_possible_choices({}) + expected_protocols = tuple( + dict.fromkeys( + str(entry["protocol"]) + for entry in get_esc_connection_sub_dict("ArduCopter").values() + if isinstance(entry.get("type"), tuple) and "CAN1" in entry["type"] + ) + ) + + protocol_path = ("ESC", "ESC->FC Telemetry", "Protocol") + + model._update_esc_telemetry_type_protocol_choices("CAN1", protocol_path) + + assert model._possible_choices[protocol_path] == expected_protocols + def test_update_possible_choices_for_gnss_none(self, realistic_model) -> None: """Test _update_possible_choices_for_path with GNSS Receiver None connection.""" model = realistic_model diff --git a/tests/unit_data_model_vehicle_components_validation_constants.py b/tests/unit_data_model_vehicle_components_validation_constants.py index cff893a7e..321ce1909 100755 --- a/tests/unit_data_model_vehicle_components_validation_constants.py +++ b/tests/unit_data_model_vehicle_components_validation_constants.py @@ -18,10 +18,11 @@ BATTERY_CELL_VOLTAGE_PATHS, BATTERY_CELL_VOLTAGE_TYPES, CAN_PORTS, + ESC_CONNECTION_DICT, + ESC_SERIAL_SAME_PORT_PROTOCOLS, FC_CONNECTION_TYPE_PATHS, GNSS_RECEIVER_CONNECTION, I2C_PORTS, - MOT_PWM_TYPE_DICT, OTHER_PORTS, PWM_IN_PORTS, PWM_OUT_PORTS, @@ -265,27 +266,27 @@ def test_gnss_receiver_connection_structure(self) -> None: assert key in GNSS_RECEIVER_CONNECTION assert GNSS_RECEIVER_CONNECTION[key]["protocol"] == protocol - def test_mot_pwm_type_dict_structure(self) -> None: - """Test MOT_PWM_TYPE_DICT structure and data types.""" + def test_esc_connection_dict_structure(self) -> None: + """Test ESC_CONNECTION_DICT structure and data types (replaces deleted MOT_PWM_TYPE_DICT).""" # Should be a dict of vehicle-type sub-dicts - assert isinstance(MOT_PWM_TYPE_DICT, dict) - assert len(MOT_PWM_TYPE_DICT) > 0 + assert isinstance(ESC_CONNECTION_DICT, dict) + assert len(ESC_CONNECTION_DICT) > 0 # Top-level keys are vehicle type strings (e.g. "ArduCopter", "Rover") - for vtype, sub_dict in MOT_PWM_TYPE_DICT.items(): + for vtype, sub_dict in ESC_CONNECTION_DICT.items(): assert isinstance(vtype, str), f"Vehicle type key '{vtype}' is not a string" assert isinstance(sub_dict, dict), f"Sub-dict for '{vtype}' is not a dict" - # Inner keys should be integer strings representing PWM type numbers + # Inner keys should be integer strings representing protocol numbers for key in sub_dict: assert isinstance(key, str) try: int(key) except ValueError: - pytest.fail(f"Key '{key}' in MOT_PWM_TYPE_DICT['{vtype}'] is not a valid integer string") + pytest.fail(f"Key '{key}' in ESC_CONNECTION_DICT['{vtype}'] is not a valid integer string") # Values should be dicts with specific structure - required_fields = {"type", "protocol", "is_dshot"} + required_fields = {"type", "protocol", "ESC_to_FC"} for key, value in sub_dict.items(): assert isinstance(value, dict), f"Value for key '{key}' in '{vtype}' is not a dict" assert set(value.keys()) == required_fields, f"Value for key '{key}' in '{vtype}' has incorrect fields" @@ -293,21 +294,14 @@ def test_mot_pwm_type_dict_structure(self) -> None: # Check field types assert isinstance(value["type"], tuple), f"'type' field for key '{key}' in '{vtype}' is not a tuple" assert isinstance(value["protocol"], str), f"'protocol' field for key '{key}' in '{vtype}' is not a string" - assert isinstance(value["is_dshot"], bool), f"'is_dshot' field for key '{key}' in '{vtype}' is not a boolean" - - # Type should reference PWM output ports - assert value["type"] == PWM_OUT_PORTS, f"'type' for key '{key}' in '{vtype}' does not reference PWM_OUT_PORTS" + assert isinstance(value["ESC_to_FC"], dict), f"'ESC_to_FC' field for key '{key}' in '{vtype}' is not a dict" # Verify expected PWM types exist in the ArduCopter sub-dict - expected_pwm_types = { - "0": {"protocol": "Normal", "is_dshot": False}, - "6": {"protocol": "DShot600", "is_dshot": True}, - } - copter_sub = MOT_PWM_TYPE_DICT["ArduCopter"] - for key, expected_data in expected_pwm_types.items(): - assert key in copter_sub - assert copter_sub[key]["protocol"] == expected_data["protocol"] - assert copter_sub[key]["is_dshot"] == expected_data["is_dshot"] + copter_sub = ESC_CONNECTION_DICT["ArduCopter"] + assert "0" in copter_sub + assert copter_sub["0"]["protocol"] == "Normal" + assert "6" in copter_sub + assert copter_sub["6"]["protocol"] == "DShot600" def test_rc_protocols_dict_structure(self) -> None: """Test RC_PROTOCOLS_DICT structure and data types.""" @@ -425,11 +419,11 @@ def test_protocol_number_ranges(self) -> None: gps_num = int(key) assert 0 <= gps_num <= 50, f"GPS type number {gps_num} is out of expected range" - # Motor PWM type numbers should be reasonable (typically 0-10) - for sub_dict in MOT_PWM_TYPE_DICT.values(): + # Motor PWM type numbers should be reasonable (typically -1 to 200) + for sub_dict in ESC_CONNECTION_DICT.values(): for key in sub_dict: pwm_num = int(key) - assert 0 <= pwm_num <= 20, f"Motor PWM type number {pwm_num} is out of expected range" + assert -1 <= pwm_num <= 200, f"ESC connection key {pwm_num} is out of expected range" # RC protocol numbers should be reasonable bit positions (typically 0-15) for key in RC_PROTOCOLS_DICT: @@ -451,14 +445,14 @@ def test_protocol_names_not_empty(self) -> None: assert isinstance(protocol_name, str), f"Protocol name for key '{key}' in {dict_name} is not a string" assert len(protocol_name.strip()) > 0, f"Protocol name for key '{key}' in {dict_name} is empty or whitespace" - for vtype, sub_dict in MOT_PWM_TYPE_DICT.items(): + for vtype, sub_dict in ESC_CONNECTION_DICT.items(): for key, value in sub_dict.items(): protocol_name = value["protocol"] assert isinstance(protocol_name, str), ( - f"Protocol name for key '{key}' in MOT_PWM_TYPE_DICT['{vtype}'] is not a string" + f"Protocol name for key '{key}' in ESC_CONNECTION_DICT['{vtype}'] is not a string" ) assert len(protocol_name.strip()) > 0, ( - f"Protocol name for key '{key}' in MOT_PWM_TYPE_DICT['{vtype}'] is empty" + f"Protocol name for key '{key}' in ESC_CONNECTION_DICT['{vtype}'] is empty" ) def test_no_protocol_duplicates_within_dict(self) -> None: @@ -484,14 +478,14 @@ def test_no_protocol_duplicates_within_dict(self) -> None: f"Too many duplicate protocol names in {dict_name}: {len(unique_names)}/{len(protocol_names)} unique" ) - # Check uniqueness within each vehicle-type sub-dict of MOT_PWM_TYPE_DICT - for vtype, sub_dict in MOT_PWM_TYPE_DICT.items(): + # Check uniqueness within each vehicle-type sub-dict of ESC_CONNECTION_DICT + for vtype, sub_dict in ESC_CONNECTION_DICT.items(): protocol_names = [value["protocol"] for value in sub_dict.values()] unique_names = set(protocol_names) - assert len(unique_names) > 0, f"No protocols found in MOT_PWM_TYPE_DICT['{vtype}']" + assert len(unique_names) > 0, f"No protocols found in ESC_CONNECTION_DICT['{vtype}']" uniqueness_ratio = len(unique_names) / len(protocol_names) assert uniqueness_ratio >= 0.8, ( - f"Too many duplicate protocol names in MOT_PWM_TYPE_DICT['{vtype}']: " + f"Too many duplicate protocol names in ESC_CONNECTION_DICT['{vtype}']: " f"{len(unique_names)}/{len(protocol_names)} unique" ) @@ -542,3 +536,23 @@ def test_get_connection_type_tuples_with_labels_function(self) -> None: # Test with empty tuple result = get_connection_type_tuples_with_labels(()) assert not result + + def test_esc_serial_same_port_protocols_contents(self) -> None: + """ + Assert the exact contents of ESC_SERIAL_SAME_PORT_PROTOCOLS. + + This guards against silent omissions when SERIAL_PROTOCOLS_DICT is extended + without updating the 'component' annotation required for derivation. + """ + expected = {"FETtecOneWire", "Torqeedo", "CoDevESC"} + assert set(ESC_SERIAL_SAME_PORT_PROTOCOLS) == expected, ( + f"ESC_SERIAL_SAME_PORT_PROTOCOLS has changed: got {set(ESC_SERIAL_SAME_PORT_PROTOCOLS)!r}, " + 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 + for vtype, sub_dict in ESC_CONNECTION_DICT.items(): + vehicle_protocols = {entry.get("protocol") for entry in sub_dict.values()} + for protocol in ESC_SERIAL_SAME_PORT_PROTOCOLS: + assert protocol in vehicle_protocols, ( + f"Protocol '{protocol}' from ESC_SERIAL_SAME_PORT_PROTOCOLS not found in ESC_CONNECTION_DICT['{vtype}']" + )