diff --git a/ardupilot_methodic_configurator/configuration_steps_ArduPlane.json b/ardupilot_methodic_configurator/configuration_steps_ArduPlane.json index 69a70204d..b3620698f 100644 --- a/ardupilot_methodic_configurator/configuration_steps_ArduPlane.json +++ b/ardupilot_methodic_configurator/configuration_steps_ArduPlane.json @@ -594,7 +594,16 @@ "autoimport_nondefault_regexp": ["BRD_BOOT_DELAY", "GPS.*", "GNSS.*"], "derived_parameters": { "GPS_TYPE": { "if": "Version(vehicle_components['Flight Controller']['Firmware']['Version'].split(' ')[0]) < Version('4.6')", "New Value": "vehicle_components['GNSS Receiver']['FC Connection']['Protocol']", "Change Reason": "Defined in component editor" }, - "GPS1_TYPE": { "if": "Version(vehicle_components['Flight Controller']['Firmware']['Version'].split(' ')[0]) >= Version('4.6')","New Value": "vehicle_components['GNSS Receiver']['FC Connection']['Protocol']", "Change Reason": "Defined in component editor" } + "GPS1_TYPE": { "if": "Version(vehicle_components['Flight Controller']['Firmware']['Version'].split(' ')[0]) >= Version('4.6')","New Value": "vehicle_components['GNSS Receiver']['FC Connection']['Protocol']", "Change Reason": "Defined in component editor" }, + "SERIAL1_PROTOCOL": { "if": "vehicle_components['GNSS Receiver']['FC Connection']['Type'] == 'SERIAL1' and vehicle_components['GNSS Receiver']['FC Connection']['Protocol'] in ['AUTO', 'uBlox', 'NMEA', 'SiRF', 'SwiftNav', 'Septentrio(SBF)', 'Trimble(GSOF)', 'ERB', 'NOVA', 'HemisphereNMEA', 'uBlox-MovingBaseline-Base', 'uBlox-MovingBaseline-Rover', 'AllyStar', 'UnicoreNMEA', 'UnicoreMovingBaselineNMEA', 'Septentrio-DualAntenna(SBF)']", "New Value": "5", "Change Reason": "Physical GNSS receiver requires GPS serial transport" }, + "SERIAL2_PROTOCOL": { "if": "vehicle_components['GNSS Receiver']['FC Connection']['Type'] == 'SERIAL2' and vehicle_components['GNSS Receiver']['FC Connection']['Protocol'] in ['AUTO', 'uBlox', 'NMEA', 'SiRF', 'SwiftNav', 'Septentrio(SBF)', 'Trimble(GSOF)', 'ERB', 'NOVA', 'HemisphereNMEA', 'uBlox-MovingBaseline-Base', 'uBlox-MovingBaseline-Rover', 'AllyStar', 'UnicoreNMEA', 'UnicoreMovingBaselineNMEA', 'Septentrio-DualAntenna(SBF)']", "New Value": "5", "Change Reason": "Physical GNSS receiver requires GPS serial transport" }, + "SERIAL3_PROTOCOL": { "if": "vehicle_components['GNSS Receiver']['FC Connection']['Type'] == 'SERIAL3' and vehicle_components['GNSS Receiver']['FC Connection']['Protocol'] in ['AUTO', 'uBlox', 'NMEA', 'SiRF', 'SwiftNav', 'Septentrio(SBF)', 'Trimble(GSOF)', 'ERB', 'NOVA', 'HemisphereNMEA', 'uBlox-MovingBaseline-Base', 'uBlox-MovingBaseline-Rover', 'AllyStar', 'UnicoreNMEA', 'UnicoreMovingBaselineNMEA', 'Septentrio-DualAntenna(SBF)']", "New Value": "5", "Change Reason": "Physical GNSS receiver requires GPS serial transport" }, + "SERIAL4_PROTOCOL": { "if": "vehicle_components['GNSS Receiver']['FC Connection']['Type'] == 'SERIAL4' and vehicle_components['GNSS Receiver']['FC Connection']['Protocol'] in ['AUTO', 'uBlox', 'NMEA', 'SiRF', 'SwiftNav', 'Septentrio(SBF)', 'Trimble(GSOF)', 'ERB', 'NOVA', 'HemisphereNMEA', 'uBlox-MovingBaseline-Base', 'uBlox-MovingBaseline-Rover', 'AllyStar', 'UnicoreNMEA', 'UnicoreMovingBaselineNMEA', 'Septentrio-DualAntenna(SBF)']", "New Value": "5", "Change Reason": "Physical GNSS receiver requires GPS serial transport" }, + "SERIAL5_PROTOCOL": { "if": "vehicle_components['GNSS Receiver']['FC Connection']['Type'] == 'SERIAL5' and vehicle_components['GNSS Receiver']['FC Connection']['Protocol'] in ['AUTO', 'uBlox', 'NMEA', 'SiRF', 'SwiftNav', 'Septentrio(SBF)', 'Trimble(GSOF)', 'ERB', 'NOVA', 'HemisphereNMEA', 'uBlox-MovingBaseline-Base', 'uBlox-MovingBaseline-Rover', 'AllyStar', 'UnicoreNMEA', 'UnicoreMovingBaselineNMEA', 'Septentrio-DualAntenna(SBF)']", "New Value": "5", "Change Reason": "Physical GNSS receiver requires GPS serial transport" }, + "SERIAL6_PROTOCOL": { "if": "vehicle_components['GNSS Receiver']['FC Connection']['Type'] == 'SERIAL6' and vehicle_components['GNSS Receiver']['FC Connection']['Protocol'] in ['AUTO', 'uBlox', 'NMEA', 'SiRF', 'SwiftNav', 'Septentrio(SBF)', 'Trimble(GSOF)', 'ERB', 'NOVA', 'HemisphereNMEA', 'uBlox-MovingBaseline-Base', 'uBlox-MovingBaseline-Rover', 'AllyStar', 'UnicoreNMEA', 'UnicoreMovingBaselineNMEA', 'Septentrio-DualAntenna(SBF)']", "New Value": "5", "Change Reason": "Physical GNSS receiver requires GPS serial transport" }, + "SERIAL7_PROTOCOL": { "if": "vehicle_components['GNSS Receiver']['FC Connection']['Type'] == 'SERIAL7' and vehicle_components['GNSS Receiver']['FC Connection']['Protocol'] in ['AUTO', 'uBlox', 'NMEA', 'SiRF', 'SwiftNav', 'Septentrio(SBF)', 'Trimble(GSOF)', 'ERB', 'NOVA', 'HemisphereNMEA', 'uBlox-MovingBaseline-Base', 'uBlox-MovingBaseline-Rover', 'AllyStar', 'UnicoreNMEA', 'UnicoreMovingBaselineNMEA', 'Septentrio-DualAntenna(SBF)']", "New Value": "5", "Change Reason": "Physical GNSS receiver requires GPS serial transport" }, + "SERIAL8_PROTOCOL": { "if": "vehicle_components['GNSS Receiver']['FC Connection']['Type'] == 'SERIAL8' and vehicle_components['GNSS Receiver']['FC Connection']['Protocol'] in ['AUTO', 'uBlox', 'NMEA', 'SiRF', 'SwiftNav', 'Septentrio(SBF)', 'Trimble(GSOF)', 'ERB', 'NOVA', 'HemisphereNMEA', 'uBlox-MovingBaseline-Base', 'uBlox-MovingBaseline-Rover', 'AllyStar', 'UnicoreNMEA', 'UnicoreMovingBaselineNMEA', 'Septentrio-DualAntenna(SBF)']", "New Value": "5", "Change Reason": "Physical GNSS receiver requires GPS serial transport" }, + "SERIAL9_PROTOCOL": { "if": "vehicle_components['GNSS Receiver']['FC Connection']['Type'] == 'SERIAL9' and vehicle_components['GNSS Receiver']['FC Connection']['Protocol'] in ['AUTO', 'uBlox', 'NMEA', 'SiRF', 'SwiftNav', 'Septentrio(SBF)', 'Trimble(GSOF)', 'ERB', 'NOVA', 'HemisphereNMEA', 'uBlox-MovingBaseline-Base', 'uBlox-MovingBaseline-Rover', 'AllyStar', 'UnicoreNMEA', 'UnicoreMovingBaselineNMEA', 'Septentrio-DualAntenna(SBF)']", "New Value": "5", "Change Reason": "Physical GNSS receiver requires GPS serial transport" } }, "rename_connection": "vehicle_components['GNSS Receiver']['FC Connection']['Type']", "old_filenames": [], diff --git a/ardupilot_methodic_configurator/configuration_steps_strings.py b/ardupilot_methodic_configurator/configuration_steps_strings.py index aabbe2e0e..6b6061051 100644 --- a/ardupilot_methodic_configurator/configuration_steps_strings.py +++ b/ardupilot_methodic_configurator/configuration_steps_strings.py @@ -424,6 +424,7 @@ def configuration_steps_strings() -> None: # noqa: PLR0915 # pylint: disable=to _config_steps_strings = _("PID notch filters require batch logging, not raw logging") _config_steps_strings = _("PID notch filters require batch pre- and post- filters logging") _config_steps_strings = _("Perform all arming checks. If you have a problem fix its source. Do NOT change this") + _config_steps_strings = _("Physical GNSS receiver requires GPS serial transport") _config_steps_strings = _("Pitch") _config_steps_strings = _("Pitch and Roll") _config_steps_strings = _("Q_M_SPIN_ARM plus at least 0.03") diff --git a/ardupilot_methodic_configurator/data_model_parameter_editor.py b/ardupilot_methodic_configurator/data_model_parameter_editor.py index 70d5e9907..bca5f46ad 100644 --- a/ardupilot_methodic_configurator/data_model_parameter_editor.py +++ b/ardupilot_methodic_configurator/data_model_parameter_editor.py @@ -58,8 +58,8 @@ from ardupilot_methodic_configurator.plugins.plugin_factory import PluginModelContext, plugin_factory from ardupilot_methodic_configurator.tempcal_imu import IMUfit -# GNSS MAVLink protocol default value (ArduPilot GPS_TYPE value 5 = MAVLink GPS) -_GNSS_MAVLINK_PROTOCOL_VALUE = "5" +# ArduPilot SERIALn_PROTOCOL value 5 selects GPS serial transport, not a GPS1_TYPE driver. +_GNSS_SERIAL_GPS_PROTOCOL_VALUE = "5" # The rename_connection expression that identifies a GNSS-via-MAVLink step _GNSS_FC_CONNECTION_RENAME_EXPR = "vehicle_components['GNSS Receiver']['FC Connection']['Type']" @@ -2806,9 +2806,13 @@ def refresh_current_step_computed_parameters(self) -> None: self._refresh_current_step_connection_renames(step_info, variables) def _apply_gnss_protocol_value(self, target_name: str) -> None: - """Set the GNSS MAVLink protocol value on a newly renamed _PROTOCOL parameter, ignoring no-op updates.""" + """Apply the legacy GNSS serial value only when no step rule owns the destination.""" + parameter = self.current_step_parameters[target_name] + step_info = self._local_filesystem.configuration_steps.get(self.current_file, {}) + if parameter.is_forced or parameter.is_derived or target_name in step_info.get("derived_parameters", {}): + return with contextlib.suppress(ParameterUnchangedError): - self.current_step_parameters[target_name].set_new_value(_GNSS_MAVLINK_PROTOCOL_VALUE, ignore_out_of_range=True) + parameter.set_new_value(_GNSS_SERIAL_GPS_PROTOCOL_VALUE, ignore_out_of_range=True) def _refresh_current_step_connection_renames(self, step_info: dict, variables: dict) -> None: # pylint: disable=too-many-branches """ diff --git a/ardupilot_methodic_configurator/vehicle_templates/ArduPlane/empty_4.7.x/18_gnss.param b/ardupilot_methodic_configurator/vehicle_templates/ArduPlane/empty_4.7.x/18_gnss.param index bd586d9c7..93e492160 100644 --- a/ardupilot_methodic_configurator/vehicle_templates/ArduPlane/empty_4.7.x/18_gnss.param +++ b/ardupilot_methodic_configurator/vehicle_templates/ArduPlane/empty_4.7.x/18_gnss.param @@ -4,4 +4,3 @@ GPS1_POS_X,0 GPS1_POS_Y,0 GPS1_POS_Z,0 GPS1_TYPE,1 # Defined in component editor -SERIAL3_PROTOCOL,5 diff --git a/ardupilot_methodic_configurator/vehicle_templates/ArduPlane/normal_plane/18_gnss.param b/ardupilot_methodic_configurator/vehicle_templates/ArduPlane/normal_plane/18_gnss.param index ab66937c3..9adc71639 100644 --- a/ardupilot_methodic_configurator/vehicle_templates/ArduPlane/normal_plane/18_gnss.param +++ b/ardupilot_methodic_configurator/vehicle_templates/ArduPlane/normal_plane/18_gnss.param @@ -4,4 +4,3 @@ GPS_POS1_X,0 GPS_POS1_Y,0 GPS_POS1_Z,0 GPS_TYPE,1 # Defined in component editor -SERIAL3_PROTOCOL,5 diff --git a/tests/test_arduplane_gnss_serial_default.py b/tests/test_arduplane_gnss_serial_default.py new file mode 100755 index 000000000..cd5674417 --- /dev/null +++ b/tests/test_arduplane_gnss_serial_default.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 + +""" +Regression tests for ArduPlane GNSS serial transport selection. + +This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator + +SPDX-FileCopyrightText: 2026 ArduPilot Methodic Configurator contributors + +SPDX-License-Identifier: GPL-3.0-or-later +""" + +import json +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from ardupilot_methodic_configurator.backend_filesystem_configuration_steps import ConfigurationSteps +from ardupilot_methodic_configurator.data_model_configuration_step import ConfigurationStepProcessor +from ardupilot_methodic_configurator.data_model_par_dict import Par, ParDict +from ardupilot_methodic_configurator.data_model_parameter_editor import ParameterEditor + +ROOT = Path(__file__).resolve().parents[1] / "ardupilot_methodic_configurator" +STEP = "18_gnss.param" +TEMPLATES = ("empty_4.7.x", "normal_plane") +GPS_DRIVERS = { + "None": 0, + "AUTO": 1, + "uBlox": 2, + "NMEA": 5, + "HIL": 7, + "DroneCAN": 9, + "MAVLink": 14, + "MSP": 19, + "ExternalAHRS": 21, +} + + +def gnss_filesystem(connection: str, driver: str, version: str, saved: ParDict) -> MagicMock: + """Use the real Plane step calculations with a project and component selection.""" + step = json.loads((ROOT / "configuration_steps_ArduPlane.json").read_text(encoding="utf-8"))["steps"][STEP] + calculations = ConfigurationSteps("", "ArduPlane") + filesystem = MagicMock() + filesystem.file_parameters = {STEP: saved} + filesystem.configuration_steps = {STEP: step} + filesystem.forced_parameters = calculations.forced_parameters + filesystem.derived_parameters = calculations.derived_parameters + filesystem.add_parameters = calculations.add_parameters + filesystem.param_default_dict = {"GPS1_GNSS_MODE": Par(0), "GPS1_TYPE": Par(1)} + filesystem.doc_dict = { + "GPS_TYPE": {"values": {str(number): name for name, number in GPS_DRIVERS.items()}}, + "GPS1_TYPE": {"values": {str(number): name for name, number in GPS_DRIVERS.items()}}, + } + filesystem.get_eval_variables.return_value = { + "vehicle_components": { + "Flight Controller": {"Firmware": {"Version": version}}, + "GNSS Receiver": {"FC Connection": {"Type": connection, "Protocol": driver}}, + }, + "doc_dict": filesystem.doc_dict, + } + filesystem.compute_forced_and_derived_parameters.side_effect = calculations.compute_forced_and_derived_parameters + filesystem.compute_parameters.side_effect = calculations.compute_parameters + filesystem.compute_deletions.side_effect = calculations.compute_deletions + filesystem.compute_add_parameters.side_effect = calculations.compute_add_parameters + return filesystem + + +@pytest.mark.parametrize("template", TEMPLATES) +def test_generic_plane_gnss_template_does_not_choose_serial3(template: str) -> None: + """The Plane templates keep all other GNSS settings but no UART default.""" + parameters = ParDict.from_file(str(ROOT / "vehicle_templates" / "ArduPlane" / template / STEP)) + assert "SERIAL3_PROTOCOL" not in parameters + assert "BRD_SAFETY_DEFLT" in parameters + assert {name for name in parameters if name.startswith("SERIAL")} == set() + + +@pytest.mark.parametrize("template_case", [("empty_4.7.x", "4.7.2", "GPS1_TYPE"), ("normal_plane", "4.5.7", "GPS_TYPE")]) +@pytest.mark.parametrize( + "gnss_case", + [ + ("None", "None", None), + ("SERIAL3", "AUTO", "SERIAL3_PROTOCOL"), + ("SERIAL2", "uBlox", "SERIAL2_PROTOCOL"), + ("SERIAL4", "NMEA", "SERIAL4_PROTOCOL"), + ("CAN1", "DroneCAN", None), + ("SERIAL3", "MAVLink", None), + ("SERIAL3", "MSP", None), + ("SERIAL3", "HIL", None), + ("SERIAL3", "ExternalAHRS", None), + ], +) +def test_gnss_step_derives_driver_and_only_physical_gps_serial_transport( + template_case: tuple[str, str, str], gnss_case: tuple[str, str, str | None] +) -> None: + """Physical serial GNSS gets transport 5 while other positioning paths do not.""" + template, version, gps_parameter = template_case + saved = ParDict.from_file(str(ROOT / "vehicle_templates" / "ArduPlane" / template / STEP)) + filesystem = gnss_filesystem(gnss_case[0], gnss_case[1], version, saved) + _parameters, errors, _infos, _duplicates, _renames, derived, _imported = ConfigurationStepProcessor( + filesystem + ).process_configuration_step(STEP, {}) + + assert not errors + assert derived[gps_parameter].value == GPS_DRIVERS[gnss_case[1]] + serial = {name: param.value for name, param in derived.items() if name.startswith("SERIAL")} + assert serial == ({gnss_case[2]: 5} if gnss_case[2] else {}) + + +def test_saved_serial_setting_and_eligible_fc_import_are_preserved() -> None: + """Saved port settings survive and the existing GNSS import rule still applies.""" + saved = ParDict({"SERIAL2_PROTOCOL": Par(5, "saved GPS port"), "SERIAL2_BAUD": Par(115, "saved baud")}) + filesystem = gnss_filesystem("SERIAL2", "uBlox", "4.7.2", saved) + parameters, errors, _infos, duplicates, renames, derived, imported = ConfigurationStepProcessor( + filesystem + ).process_configuration_step( + STEP, {"SERIAL2_PROTOCOL": 5, "SERIAL2_BAUD": 115, "GPS1_GNSS_MODE": 3, "SERIAL3_PROTOCOL": 2} + ) + + assert not errors + assert not duplicates + assert not renames + assert parameters["SERIAL2_PROTOCOL"].get_new_value() == 5 + assert parameters["SERIAL2_BAUD"].get_new_value() == 115 + assert parameters["SERIAL2_BAUD"].change_reason == "saved baud" + assert derived["SERIAL2_PROTOCOL"].value == 5 + assert imported == {"GPS1_GNSS_MODE"} + assert parameters["GPS1_GNSS_MODE"].get_new_value() == 3 + assert "SERIAL3_PROTOCOL" not in parameters + + +def test_existing_gnss_connection_rename_moves_saved_baud() -> None: + """A project baud setting follows the selected port through existing rename logic.""" + filesystem = gnss_filesystem("SERIAL4", "uBlox", "4.7.2", ParDict({"SERIAL3_BAUD": Par(115)})) + _parameters, errors, _infos, _duplicates, renames, derived, _imported = ConfigurationStepProcessor( + filesystem + ).process_configuration_step(STEP, {}) + + assert not errors + assert renames == [("SERIAL3_BAUD", "SERIAL4_BAUD")] + assert derived["SERIAL4_PROTOCOL"].value == 5 + + +def test_editor_displays_derived_gnss_serial_protocol() -> None: + """A newly selected physical UART protocol appears in the editor table model.""" + saved = ParDict.from_file(str(ROOT / "vehicle_templates" / "ArduPlane" / "empty_4.7.x" / STEP)) + filesystem = gnss_filesystem("SERIAL2", "uBlox", "4.7.2", saved) + flight_controller = MagicMock() + flight_controller.fc_parameters = {} + editor = ParameterEditor(STEP, flight_controller, filesystem) + + errors, _infos = editor._repopulate_configuration_step_parameters() # pylint: disable=protected-access + + assert not errors + assert editor.current_step_parameters["SERIAL2_PROTOCOL"].get_new_value() == 5 + assert "SERIAL2_PROTOCOL" in editor._added_parameters # pylint: disable=protected-access diff --git a/tests/test_data_model_parameter_editor.py b/tests/test_data_model_parameter_editor.py index 726d1963c..c0a4f48f7 100755 --- a/tests/test_data_model_parameter_editor.py +++ b/tests/test_data_model_parameter_editor.py @@ -35,7 +35,7 @@ ParameterEditor, ParameterValueUpdateStatus, ) -from ardupilot_methodic_configurator.data_model_safe_evaluator import ConfigurationStepEvalError +from ardupilot_methodic_configurator.data_model_safe_evaluator import ConfigurationStepEvalError, safe_evaluate from ardupilot_methodic_configurator.plugins.frontend_tkinter_helpers import refresh_parameter_editor_after_calibration from ardupilot_methodic_configurator.plugins.plugin_constants import ( PLUGIN_AHRS_ORIENTATION, @@ -5506,6 +5506,101 @@ class TestRefreshConnectionRenames: # ------------------------------------------------------------------ helpers + @staticmethod + def _setup_f17_gnss_step(parameter_editor: ParameterEditor, driver: str, port: str) -> dict[str, str]: + """Load F17's real serial rules with mutable inline component selections.""" + steps_path = Path(__file__).parents[1] / "ardupilot_methodic_configurator" / "configuration_steps_ArduPlane.json" + step_info = json.loads(steps_path.read_text(encoding="utf-8"))["steps"]["18_gnss.param"] + connection = {"Type": port, "Protocol": driver} + components = {"GNSS Receiver": {"FC Connection": connection}} + parameter_editor.current_file = "18_gnss.param" + parameter_editor._config_step_processor.variables = {"vehicle_components": components} + parameter_editor._local_filesystem.configuration_steps = {"18_gnss.param": step_info} + parameter_editor._local_filesystem.derived_parameters = {} + + def compute(_filename: str, _step: dict, _kind: str, variables: dict) -> None: + derived = ParDict() + for name, rule in step_info["derived_parameters"].items(): + if name.startswith("SERIAL") and safe_evaluate(rule["if"], variables): + derived[name] = Par(float(rule["New Value"]), rule["Change Reason"]) + parameter_editor._local_filesystem.derived_parameters = {"18_gnss.param": derived} + + parameter_editor._local_filesystem.compute_parameters.side_effect = compute + return connection + + def test_saved_physical_gnss_protocol_moves_to_new_port_without_exception(self, parameter_editor: ParameterEditor) -> None: + """GIVEN saved SERIAL3 GPS transport, WHEN uBlox moves to SERIAL4, THEN export only SERIAL4=5.""" + self._setup_f17_gnss_step(parameter_editor, "uBlox", "SERIAL4") + saved = Par(5.0, "saved GNSS transport") + parameter_editor._local_filesystem.file_parameters = {"18_gnss.param": ParDict({"SERIAL3_PROTOCOL": saved})} + parameter_editor.current_step_parameters = {"SERIAL3_PROTOCOL": ArduPilotParameter("SERIAL3_PROTOCOL", saved)} + + parameter_editor.refresh_current_step_computed_parameters() + + assert set(parameter_editor.current_step_parameters) == {"SERIAL4_PROTOCOL"} + assert parameter_editor.current_step_parameters["SERIAL4_PROTOCOL"].get_new_value() == 5.0 + assert parameter_editor._added_parameters == {"SERIAL4_PROTOCOL"} + assert parameter_editor._deleted_parameters == {"SERIAL3_PROTOCOL"} + parameter_editor._export_current_file(annotate_doc=False) + exported = parameter_editor._local_filesystem.export_to_param.call_args.args[0] + assert set(exported) == {"SERIAL4_PROTOCOL"} + assert exported["SERIAL4_PROTOCOL"].value == 5.0 + + @pytest.mark.parametrize("driver", ["MAVLink", "MSP", "ExternalAHRS"]) + def test_special_gnss_transport_keeps_saved_serial_value(self, parameter_editor: ParameterEditor, driver: str) -> None: + """GIVEN a special GNSS driver, WHEN its port moves, THEN serial protocol 5 is not imposed.""" + self._setup_f17_gnss_step(parameter_editor, driver, "SERIAL4") + saved = Par(42.0, "special transport") + parameter_editor._local_filesystem.file_parameters = {"18_gnss.param": ParDict({"SERIAL3_PROTOCOL": saved})} + parameter_editor.current_step_parameters = {"SERIAL3_PROTOCOL": ArduPilotParameter("SERIAL3_PROTOCOL", saved)} + + parameter_editor.refresh_current_step_computed_parameters() + + assert set(parameter_editor.current_step_parameters) == {"SERIAL4_PROTOCOL"} + assert parameter_editor.current_step_parameters["SERIAL4_PROTOCOL"].get_new_value() == 42.0 + assert not parameter_editor.current_step_parameters["SERIAL4_PROTOCOL"].is_derived + + @pytest.mark.parametrize("origin", ["forced", "derived"]) + def test_gnss_rename_helper_does_not_edit_managed_destination( + self, parameter_editor: ParameterEditor, origin: str + ) -> None: + """GIVEN a forced or derived destination, WHEN renamed, THEN ordinary editing is skipped.""" + managed = Par(5.0, "managed") + kwargs = {"forced_par": managed} if origin == "forced" else {"derived_par": managed} + destination = ArduPilotParameter("SERIAL4_PROTOCOL", Par(3.0), **kwargs) + parameter_editor.current_step_parameters = {"SERIAL4_PROTOCOL": destination} + + with patch.object(destination, "set_new_value") as ordinary_edit: + parameter_editor._apply_gnss_protocol_value("SERIAL4_PROTOCOL") + + ordinary_edit.assert_not_called() + assert destination.get_new_value() == 5.0 + + def test_repeated_physical_gnss_port_changes_keep_one_final_rename(self, parameter_editor: ParameterEditor) -> None: + """GIVEN repeated inline changes, WHEN exported, THEN only the final serial assignment remains.""" + connection = self._setup_f17_gnss_step(parameter_editor, "uBlox", "SERIAL4") + saved = Par(5.0, "saved GNSS transport") + unrelated = ArduPilotParameter("OTHER", Par(1.0)) + unrelated.set_new_value("2") + parameter_editor._local_filesystem.file_parameters = {"18_gnss.param": ParDict({"SERIAL3_PROTOCOL": saved})} + parameter_editor.current_step_parameters = { + "SERIAL3_PROTOCOL": ArduPilotParameter("SERIAL3_PROTOCOL", saved), + "OTHER": unrelated, + } + + for port in ("SERIAL4", "SERIAL5", "SERIAL4", "SERIAL4"): + connection["Type"] = port + parameter_editor.refresh_current_step_computed_parameters() + assert set(parameter_editor.current_step_parameters) == {"OTHER", f"{port}_PROTOCOL"} + + assert parameter_editor._connection_renames == {"SERIAL3_PROTOCOL": "SERIAL4_PROTOCOL"} + assert parameter_editor._added_parameters == {"SERIAL4_PROTOCOL"} + assert parameter_editor._deleted_parameters == {"SERIAL3_PROTOCOL"} + assert unrelated.get_new_value() == 2.0 + parameter_editor._export_current_file(annotate_doc=False) + exported = parameter_editor._local_filesystem.export_to_param.call_args.args[0] + assert set(exported) == {"OTHER", "SERIAL4_PROTOCOL"} + def _setup_rename_step(self, parameter_editor: ParameterEditor, step_file: str, rename_expr: str) -> None: """Configure the editor so the given step file has a rename_connection entry.""" parameter_editor.current_file = step_file @@ -7162,10 +7257,13 @@ def test_gnss_protocol_value_update_tolerates_an_unchanged_value(self, parameter THEN: No exception escapes """ param = MagicMock() + param.is_forced = False + param.is_derived = False param.set_new_value.side_effect = ParameterUnchangedError("unchanged") parameter_editor.current_step_parameters = {"SERIAL1_PROTOCOL": param} parameter_editor._apply_gnss_protocol_value("SERIAL1_PROTOCOL") + param.set_new_value.assert_called_once_with("5", ignore_out_of_range=True) class TestFlightControllerUploadReporting: