ardupilot-methodic-configurator 2.6.1__py3-none-any.whl → 2.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ardupilot-methodic-configurator might be problematic. Click here for more details.

Files changed (107) hide show
  1. ardupilot_methodic_configurator/__init__.py +2 -2
  2. ardupilot_methodic_configurator/__main__.py +34 -1
  3. ardupilot_methodic_configurator/annotate_params.py +49 -14
  4. ardupilot_methodic_configurator/argparse_check_range.py +1 -1
  5. ardupilot_methodic_configurator/backend_filesystem.py +7 -3
  6. ardupilot_methodic_configurator/backend_filesystem_configuration_steps.py +53 -6
  7. ardupilot_methodic_configurator/backend_filesystem_freedesktop.py +275 -0
  8. ardupilot_methodic_configurator/backend_filesystem_json_with_schema.py +3 -3
  9. ardupilot_methodic_configurator/backend_filesystem_program_settings.py +26 -8
  10. ardupilot_methodic_configurator/backend_filesystem_vehicle_components.py +3 -3
  11. ardupilot_methodic_configurator/backend_flightcontroller.py +37 -20
  12. ardupilot_methodic_configurator/backend_flightcontroller_info.py +1 -1
  13. ardupilot_methodic_configurator/backend_internet.py +1 -1
  14. ardupilot_methodic_configurator/backend_mavftp.py +1 -1
  15. ardupilot_methodic_configurator/battery_cell_voltages.py +1 -1
  16. ardupilot_methodic_configurator/common_arguments.py +1 -1
  17. ardupilot_methodic_configurator/configuration_manager.py +561 -121
  18. ardupilot_methodic_configurator/configuration_steps_ArduCopter.json +7 -5
  19. ardupilot_methodic_configurator/configuration_steps_ArduPlane.json +3 -1
  20. ardupilot_methodic_configurator/configuration_steps_Heli.json +3 -1
  21. ardupilot_methodic_configurator/configuration_steps_Rover.json +3 -1
  22. ardupilot_methodic_configurator/configuration_steps_strings.py +5 -3
  23. ardupilot_methodic_configurator/data_model_ardupilot_parameter.py +55 -2
  24. ardupilot_methodic_configurator/data_model_configuration_step.py +96 -46
  25. ardupilot_methodic_configurator/data_model_fc_ids.py +16 -7
  26. ardupilot_methodic_configurator/data_model_motor_test.py +1 -1
  27. ardupilot_methodic_configurator/data_model_par_dict.py +25 -11
  28. ardupilot_methodic_configurator/data_model_software_updates.py +1 -1
  29. ardupilot_methodic_configurator/data_model_template_overview.py +1 -1
  30. ardupilot_methodic_configurator/data_model_vehicle_components.py +1 -1
  31. ardupilot_methodic_configurator/data_model_vehicle_components_base.py +3 -2
  32. ardupilot_methodic_configurator/data_model_vehicle_components_display.py +1 -1
  33. ardupilot_methodic_configurator/data_model_vehicle_components_import.py +2 -1
  34. ardupilot_methodic_configurator/data_model_vehicle_components_json_schema.py +1 -1
  35. ardupilot_methodic_configurator/data_model_vehicle_components_templates.py +1 -1
  36. ardupilot_methodic_configurator/data_model_vehicle_components_validation.py +41 -1
  37. ardupilot_methodic_configurator/data_model_vehicle_project.py +1 -1
  38. ardupilot_methodic_configurator/data_model_vehicle_project_creator.py +1 -1
  39. ardupilot_methodic_configurator/data_model_vehicle_project_opener.py +1 -1
  40. ardupilot_methodic_configurator/extract_param_defaults.py +1 -1
  41. ardupilot_methodic_configurator/frontend_tkinter_autoresize_combobox.py +1 -1
  42. ardupilot_methodic_configurator/frontend_tkinter_base_window.py +6 -2
  43. ardupilot_methodic_configurator/frontend_tkinter_component_editor.py +56 -29
  44. ardupilot_methodic_configurator/frontend_tkinter_component_editor_base.py +18 -13
  45. ardupilot_methodic_configurator/frontend_tkinter_component_template_manager.py +1 -1
  46. ardupilot_methodic_configurator/frontend_tkinter_connection_selection.py +1 -1
  47. ardupilot_methodic_configurator/frontend_tkinter_directory_selection.py +1 -1
  48. ardupilot_methodic_configurator/frontend_tkinter_entry_dynamic.py +1 -1
  49. ardupilot_methodic_configurator/frontend_tkinter_flightcontroller_info.py +1 -1
  50. ardupilot_methodic_configurator/frontend_tkinter_font.py +1 -1
  51. ardupilot_methodic_configurator/frontend_tkinter_motor_test.py +1 -1
  52. ardupilot_methodic_configurator/frontend_tkinter_pair_tuple_combobox.py +7 -1
  53. ardupilot_methodic_configurator/frontend_tkinter_parameter_editor.py +107 -156
  54. ardupilot_methodic_configurator/frontend_tkinter_parameter_editor_documentation_frame.py +24 -58
  55. ardupilot_methodic_configurator/frontend_tkinter_parameter_editor_table.py +24 -56
  56. ardupilot_methodic_configurator/frontend_tkinter_progress_window.py +1 -1
  57. ardupilot_methodic_configurator/frontend_tkinter_project_creator.py +1 -1
  58. ardupilot_methodic_configurator/frontend_tkinter_project_opener.py +1 -1
  59. ardupilot_methodic_configurator/frontend_tkinter_rich_text.py +1 -1
  60. ardupilot_methodic_configurator/frontend_tkinter_scroll_frame.py +1 -1
  61. ardupilot_methodic_configurator/frontend_tkinter_show.py +3 -3
  62. ardupilot_methodic_configurator/frontend_tkinter_software_update.py +1 -1
  63. ardupilot_methodic_configurator/frontend_tkinter_stage_progress.py +19 -29
  64. ardupilot_methodic_configurator/frontend_tkinter_template_overview.py +1 -1
  65. ardupilot_methodic_configurator/frontend_tkinter_usage_popup_window.py +1 -1
  66. ardupilot_methodic_configurator/internationalization.py +1 -1
  67. ardupilot_methodic_configurator/param_pid_adjustment_update.py +43 -39
  68. ardupilot_methodic_configurator/tempcal_imu.py +1 -1
  69. ardupilot_methodic_configurator/vehicle_components.py +1 -1
  70. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/AirCar_v1/14_logging.param +3 -3
  71. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Big_Owl/14_logging.param +3 -3
  72. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Chimera7/14_logging.param +3 -3
  73. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/FETtec-5/14_logging.param +3 -3
  74. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/GazeboIrisWithTargetFollow/14_logging.param +3 -3
  75. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500/14_logging.param +3 -3
  76. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/14_logging.param +3 -3
  77. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X650_LTE/14_logging.param +3 -3
  78. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Hoverit_X11+/14_logging.param +3 -3
  79. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Hoverit_X13/14_logging.param +3 -3
  80. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Marmotte5v2/14_logging.param +3 -3
  81. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/ReadyToSkyZD550/14_logging.param +3 -3
  82. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/05_remote_controller.param +1 -1
  83. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/14_logging.param +3 -3
  84. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/47_position_controller.param +2 -2
  85. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Tarot_X4/14_logging.param +3 -3
  86. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/X11_plus/14_logging.param +3 -3
  87. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/diatone_taycan_mxc/4.3.8-params/14_logging.param +3 -3
  88. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/diatone_taycan_mxc/4.4.4-params/14_logging.param +3 -3
  89. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/diatone_taycan_mxc/4.5.x-params/14_logging.param +3 -3
  90. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/diatone_taycan_mxc/4.6.x-params/14_logging.param +3 -3
  91. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/empty_4.5.x/10_gnss.param +1 -1
  92. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/empty_4.6.x/10_gnss.param +1 -1
  93. {ardupilot_methodic_configurator-2.6.1.dist-info → ardupilot_methodic_configurator-2.7.1.dist-info}/METADATA +12 -7
  94. {ardupilot_methodic_configurator-2.6.1.dist-info → ardupilot_methodic_configurator-2.7.1.dist-info}/RECORD +107 -106
  95. {ardupilot_methodic_configurator-2.6.1.dist-info → ardupilot_methodic_configurator-2.7.1.dist-info}/licenses/credits/CREDITS.md +1 -0
  96. {ardupilot_methodic_configurator-2.6.1.dist-info → ardupilot_methodic_configurator-2.7.1.dist-info}/WHEEL +0 -0
  97. {ardupilot_methodic_configurator-2.6.1.dist-info → ardupilot_methodic_configurator-2.7.1.dist-info}/entry_points.txt +0 -0
  98. {ardupilot_methodic_configurator-2.6.1.dist-info → ardupilot_methodic_configurator-2.7.1.dist-info}/licenses/LICENSE.md +0 -0
  99. {ardupilot_methodic_configurator-2.6.1.dist-info → ardupilot_methodic_configurator-2.7.1.dist-info}/licenses/LICENSES/Apache-2.0.txt +0 -0
  100. {ardupilot_methodic_configurator-2.6.1.dist-info → ardupilot_methodic_configurator-2.7.1.dist-info}/licenses/LICENSES/BSD-3-Clause.txt +0 -0
  101. {ardupilot_methodic_configurator-2.6.1.dist-info → ardupilot_methodic_configurator-2.7.1.dist-info}/licenses/LICENSES/GPL-3.0-or-later.txt +0 -0
  102. {ardupilot_methodic_configurator-2.6.1.dist-info → ardupilot_methodic_configurator-2.7.1.dist-info}/licenses/LICENSES/LGPL-3.0-or-later.txt +0 -0
  103. {ardupilot_methodic_configurator-2.6.1.dist-info → ardupilot_methodic_configurator-2.7.1.dist-info}/licenses/LICENSES/MIT-CMU.txt +0 -0
  104. {ardupilot_methodic_configurator-2.6.1.dist-info → ardupilot_methodic_configurator-2.7.1.dist-info}/licenses/LICENSES/MIT.txt +0 -0
  105. {ardupilot_methodic_configurator-2.6.1.dist-info → ardupilot_methodic_configurator-2.7.1.dist-info}/licenses/LICENSES/MPL-2.0.txt +0 -0
  106. {ardupilot_methodic_configurator-2.6.1.dist-info → ardupilot_methodic_configurator-2.7.1.dist-info}/licenses/LICENSES/PSF-2.0.txt +0 -0
  107. {ardupilot_methodic_configurator-2.6.1.dist-info → ardupilot_methodic_configurator-2.7.1.dist-info}/top_level.txt +0 -0
@@ -5,7 +5,7 @@ Contains state information but no GUI code.
5
5
  Aggregates flight controller and filesystem access in a single interface.
6
6
  Uses exceptions for error handling, the GUI layer will catch and display them.
7
7
 
8
- This file is part of Ardupilot methodic configurator. https://github.com/ArduPilot/MethodicConfigurator
8
+ This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator
9
9
 
10
10
  SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas <amilcar.lucas@iav.de>
11
11
 
@@ -16,10 +16,13 @@ from csv import writer as csv_writer
16
16
  from logging import error as logging_error
17
17
  from logging import info as logging_info
18
18
  from pathlib import Path
19
- from typing import Callable, Optional
19
+ from time import time
20
+ from typing import Callable, Literal, Optional
21
+ from webbrowser import open as webbrowser_open # to open the web documentation
20
22
 
21
23
  from ardupilot_methodic_configurator import _
22
24
  from ardupilot_methodic_configurator.backend_filesystem import LocalFilesystem
25
+ from ardupilot_methodic_configurator.backend_filesystem_configuration_steps import PhaseData
23
26
  from ardupilot_methodic_configurator.backend_flightcontroller import FlightController
24
27
  from ardupilot_methodic_configurator.backend_internet import download_file_from_url
25
28
  from ardupilot_methodic_configurator.data_model_ardupilot_parameter import ArduPilotParameter
@@ -33,6 +36,9 @@ SelectFileCallback = Callable[[str, list[str]], Optional[str]] # (title, filety
33
36
  ShowWarningCallback = Callable[[str, str], None] # (title, message) -> None
34
37
  ShowErrorCallback = Callable[[str, str], None] # (title, message) -> None
35
38
  ShowInfoCallback = Callable[[str, str], None] # (title, message) -> None
39
+ AskRetryCancelCallback = Callable[[str, str], bool] # (title, message) -> bool
40
+ ExperimentChoice = Literal["close", True, False]
41
+ ExperimentChoiceCallback = Callable[[str, str, list[str]], ExperimentChoice]
36
42
 
37
43
 
38
44
  class OperationNotPossibleError(Exception):
@@ -46,57 +52,64 @@ class InvalidParameterNameError(Exception):
46
52
  # pylint: disable=too-many-lines
47
53
 
48
54
 
49
- class ConfigurationManager: # pylint: disable=too-many-public-methods
55
+ class ConfigurationManager: # pylint: disable=too-many-public-methods, too-many-instance-attributes
50
56
  """
51
57
  Manages configuration state, including flight controller and filesystem access.
52
58
 
53
59
  This class aggregates the flight controller and filesystem access to provide a unified interface
54
- for managing configuration state. It holds references to the flight controller and filesystem,
60
+ for managing configuration state. It holds protected references to the flight controller and filesystem,
55
61
  and provides methods to interact with them.
56
62
  """
57
63
 
58
64
  def __init__(self, current_file: str, flight_controller: FlightController, filesystem: LocalFilesystem) -> None:
59
65
  self.current_file = current_file
60
- self.flight_controller = flight_controller
61
- self.filesystem = filesystem
62
- self.config_step_processor = ConfigurationStepProcessor(self.filesystem)
66
+ self._flight_controller = flight_controller
67
+ self._local_filesystem = filesystem
68
+ self._config_step_processor = ConfigurationStepProcessor(self._local_filesystem)
63
69
 
64
- # self.parameters is rebuilt on every repopulate(...) call and only contains the ArduPilotParameter
70
+ # self.current_step_parameters is rebuilt on every repopulate(...) call and only contains the ArduPilotParameter
65
71
  # objects needed for the current table view.
66
- self.parameters: dict[str, ArduPilotParameter] = {}
72
+ self.current_step_parameters: dict[str, ArduPilotParameter] = {}
67
73
 
74
+ # Track parameters added by user (not in original file) or renamed by the system in the current configuration step
75
+ self._added_parameters: set[str] = set()
76
+
77
+ # Track parameters deleted by user (were in original file) or renamed by the system in the current configuration step
78
+ self._deleted_parameters: set[str] = set()
79
+
80
+ self._at_least_one_changed = False
81
+
82
+ self._last_time_asked_to_save: float = 0
83
+
84
+ # frontend_tkinter_parameter_editor_table.py API start
68
85
  @property
69
86
  def connected_vehicle_type(self) -> str:
70
87
  return (
71
- getattr(self.flight_controller.info, "vehicle_type", "")
72
- if hasattr(self.flight_controller, "info") and self.flight_controller.info is not None
88
+ getattr(self._flight_controller.info, "vehicle_type", "")
89
+ if hasattr(self._flight_controller, "info") and self._flight_controller.info is not None
73
90
  else ""
74
91
  )
75
92
 
76
93
  @property
77
94
  def is_fc_connected(self) -> bool:
78
- return self.flight_controller.master is not None
95
+ return self._flight_controller.master is not None
79
96
 
80
97
  @property
81
98
  def fc_parameters(self) -> dict[str, float]:
82
99
  return (
83
- self.flight_controller.fc_parameters
84
- if hasattr(self.flight_controller, "fc_parameters") and self.flight_controller.fc_parameters is not None
100
+ self._flight_controller.fc_parameters
101
+ if hasattr(self._flight_controller, "fc_parameters") and self._flight_controller.fc_parameters is not None
85
102
  else {}
86
103
  )
87
104
 
88
105
  @property
89
106
  def is_mavftp_supported(self) -> bool:
90
107
  return (
91
- getattr(self.flight_controller.info, "is_mavftp_supported", False)
92
- if hasattr(self.flight_controller, "info") and self.flight_controller.info is not None
108
+ getattr(self._flight_controller.info, "is_mavftp_supported", False)
109
+ if hasattr(self._flight_controller, "info") and self._flight_controller.info is not None
93
110
  else False
94
111
  )
95
112
 
96
- @property
97
- def current_file_parameters(self) -> ParDict:
98
- return self.filesystem.file_parameters.get(self.current_file, ParDict())
99
-
100
113
  def handle_imu_temperature_calibration_workflow( # pylint: disable=too-many-arguments, too-many-positional-arguments
101
114
  self,
102
115
  selected_file: str,
@@ -126,7 +139,9 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
126
139
 
127
140
  """
128
141
  # Check if IMU temperature calibration should be offered for this file
129
- tempcal_imu_result_param_filename, tempcal_imu_result_param_fullpath = self.filesystem.tempcal_imu_result_param_tuple()
142
+ tempcal_imu_result_param_filename, tempcal_imu_result_param_fullpath = (
143
+ self._local_filesystem.tempcal_imu_result_param_tuple()
144
+ )
130
145
  if selected_file != tempcal_imu_result_param_filename:
131
146
  return False
132
147
 
@@ -161,19 +176,19 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
161
176
  log_parm=False,
162
177
  online=False,
163
178
  tclr=False,
164
- figpath=self.filesystem.vehicle_dir,
179
+ figpath=self._local_filesystem.vehicle_dir,
165
180
  progress_callback=progress_callback,
166
181
  )
167
182
 
168
183
  try:
169
184
  # Reload parameter files after calibration
170
- self.filesystem.file_parameters = self.filesystem.read_params_from_files()
185
+ self._local_filesystem.file_parameters = self._local_filesystem.read_params_from_files()
171
186
  return True
172
187
  except SystemExit as exp:
173
188
  show_error(_("Fatal error reading parameter files"), f"{exp}")
174
189
  raise
175
190
 
176
- def should_copy_fc_values_to_file(self, selected_file: str) -> tuple[bool, Optional[dict], Optional[str]]:
191
+ def _should_copy_fc_values_to_file(self, selected_file: str) -> tuple[bool, Optional[dict], Optional[str]]:
177
192
  """
178
193
  Check if flight controller values should be copied to the specified file.
179
194
 
@@ -186,18 +201,18 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
186
201
  auto_changed_by contains the tool name that requires external changes.
187
202
 
188
203
  """
189
- auto_changed_by = self.filesystem.auto_changed_by(selected_file)
190
- if auto_changed_by and self.flight_controller.fc_parameters:
204
+ auto_changed_by = self._local_filesystem.auto_changed_by(selected_file)
205
+ if auto_changed_by and self._flight_controller.fc_parameters:
191
206
  # Filter relevant FC parameters for this file
192
207
  relevant_fc_params = {
193
208
  key: value
194
- for key, value in self.flight_controller.fc_parameters.items()
195
- if key in self.filesystem.file_parameters[selected_file]
209
+ for key, value in self._flight_controller.fc_parameters.items()
210
+ if key in self.current_step_parameters
196
211
  }
197
212
  return True, relevant_fc_params, auto_changed_by
198
213
  return False, None, auto_changed_by
199
214
 
200
- def copy_fc_values_to_file(self, selected_file: str, relevant_fc_params: dict) -> bool:
215
+ def _copy_fc_values_to_file(self, selected_file: str, relevant_fc_params: dict) -> bool:
201
216
  """
202
217
  Copy FC values to the specified file.
203
218
 
@@ -209,10 +224,78 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
209
224
  bool: True if parameters were copied successfully.
210
225
 
211
226
  """
212
- params_copied = self.filesystem.copy_fc_values_to_file(selected_file, relevant_fc_params)
227
+ params_copied = self._local_filesystem.copy_fc_values_to_file(selected_file, relevant_fc_params)
213
228
  return bool(params_copied)
214
229
 
215
- def get_file_jump_options(self, selected_file: str) -> dict[str, str]:
230
+ def handle_copy_fc_values_workflow(
231
+ self,
232
+ selected_file: str,
233
+ ask_user_choice: ExperimentChoiceCallback,
234
+ show_info: ShowInfoCallback,
235
+ ) -> ExperimentChoice:
236
+ """
237
+ Handle the complete workflow for copying FC values to file with user interaction.
238
+
239
+ Args:
240
+ selected_file: The configuration file to potentially update.
241
+ ask_user_choice: Callback to ask user for choice (Yes/No/Close).
242
+ show_info: Callback to show information messages.
243
+
244
+ Returns:
245
+ ExperimentChoice: "close" if user chose to close, True if copied, False if no copy.
246
+
247
+ """
248
+ should_copy, relevant_fc_params, auto_changed_by = self._should_copy_fc_values_to_file(selected_file)
249
+ if should_copy and relevant_fc_params and auto_changed_by:
250
+ msg = _(
251
+ "This configuration step requires external changes by: {auto_changed_by}\n\n"
252
+ "The external tool experiment procedure is described in the tuning guide.\n\n"
253
+ "Choose an option:\n"
254
+ "* CLOSE - Close the application and go perform the experiment\n"
255
+ "* YES - Copy current FC values to {selected_file} (if you've already completed the experiment)\n"
256
+ "* NO - Continue without copying values (if you haven't performed the experiment yet,"
257
+ " but know what you are doing)"
258
+ ).format(auto_changed_by=auto_changed_by, selected_file=selected_file)
259
+
260
+ user_choice = ask_user_choice(_("Update file with values from FC?"), msg, [_("Close"), _("Yes"), _("No")])
261
+
262
+ if user_choice is True: # Yes option
263
+ params_copied = self._copy_fc_values_to_file(selected_file, relevant_fc_params)
264
+ if params_copied:
265
+ show_info(
266
+ _("Parameters copied"),
267
+ _("FC values have been copied to {selected_file}").format(selected_file=selected_file),
268
+ )
269
+ return user_choice
270
+ return False
271
+
272
+ def handle_file_jump_workflow(
273
+ self,
274
+ selected_file: str,
275
+ gui_complexity: str,
276
+ ask_user_confirmation: AskConfirmationCallback,
277
+ ) -> str:
278
+ """
279
+ Handle the complete workflow for file jumping with user interaction.
280
+
281
+ Args:
282
+ selected_file: The current configuration file.
283
+ gui_complexity: The GUI complexity setting ("simple" or other).
284
+ ask_user_confirmation: Callback to ask user for confirmation.
285
+
286
+ Returns:
287
+ str: The destination file to jump to, or the original file if no jump.
288
+
289
+ """
290
+ jump_options = self._get_file_jump_options(selected_file)
291
+ for dest_file, msg in jump_options.items():
292
+ if gui_complexity == "simple" or ask_user_confirmation(
293
+ _("Skip some steps?"), _(msg) if msg else _("Skip to {dest_file}?").format(dest_file=dest_file)
294
+ ):
295
+ return dest_file
296
+ return selected_file
297
+
298
+ def _get_file_jump_options(self, selected_file: str) -> dict[str, str]:
216
299
  """
217
300
  Get available file jump options for the selected file.
218
301
 
@@ -223,13 +306,45 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
223
306
  dict: Dictionary mapping destination files to their messages.
224
307
 
225
308
  """
226
- return self.filesystem.jump_possible(selected_file)
309
+ return self._local_filesystem.jump_possible(selected_file)
310
+
311
+ def handle_write_changes_workflow(
312
+ self,
313
+ annotate_params_into_files: bool,
314
+ ask_user_confirmation: AskConfirmationCallback,
315
+ ) -> bool:
316
+ """
317
+ Handle the workflow for writing changes to intermediate parameter file.
318
+
319
+ Args:
320
+ at_least_one_param_edited: Whether any parameters have been edited.
321
+ annotate_params_into_files: Whether to annotate documentation into files.
322
+ ask_user_confirmation: Callback to ask user for confirmation.
323
+
324
+ Returns:
325
+ bool: True if changes were written, False otherwise.
326
+
327
+ """
328
+ elapsed_since_last_ask = time() - self._last_time_asked_to_save
329
+ # if annotate parameters into files is true, we always need to write to file, because
330
+ # the parameter metadata might have changed, or not be present in the file.
331
+ # In that situation, avoid asking multiple times to write the file, by checking the time last asked
332
+ # But only if annotate_params_into_files is True
333
+ if self._has_unsaved_changes() or (annotate_params_into_files and elapsed_since_last_ask > 1.0):
334
+ msg = _("Do you want to write the changes to the {current_filename} file?").format(
335
+ current_filename=self.current_file
336
+ )
337
+ if ask_user_confirmation(_("One or more parameters have been edited"), msg):
338
+ self._export_current_file(annotate_doc=annotate_params_into_files)
339
+ self._last_time_asked_to_save = time()
340
+ return True
341
+ return False
227
342
 
228
343
  def should_download_file_from_url_workflow(
229
344
  self,
230
345
  selected_file: str,
231
- ask_confirmation: Callable[[str, str], bool],
232
- show_error: Callable[[str, str], None],
346
+ ask_confirmation: AskConfirmationCallback,
347
+ show_error: ShowErrorCallback,
233
348
  ) -> bool:
234
349
  """
235
350
  Handle file download workflow with injected GUI callbacks.
@@ -246,11 +361,11 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
246
361
  bool: True if download was successful or not needed, False if download failed.
247
362
 
248
363
  """
249
- url, local_filename = self.filesystem.get_download_url_and_local_filename(selected_file)
364
+ url, local_filename = self._local_filesystem.get_download_url_and_local_filename(selected_file)
250
365
  if not url or not local_filename:
251
366
  return True # No download required
252
367
 
253
- if self.filesystem.vehicle_configuration_file_exists(local_filename):
368
+ if self._local_filesystem.vehicle_configuration_file_exists(local_filename):
254
369
  return True # File already exists in the vehicle directory, no need to download it
255
370
 
256
371
  # Ask user for confirmation
@@ -291,16 +406,16 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
291
406
  bool: True if upload was successful or not needed, False if upload failed.
292
407
 
293
408
  """
294
- local_filename, remote_filename = self.filesystem.get_upload_local_and_remote_filenames(selected_file)
409
+ local_filename, remote_filename = self._local_filesystem.get_upload_local_and_remote_filenames(selected_file)
295
410
  if not local_filename or not remote_filename:
296
411
  return True # No upload required
297
412
 
298
- if not self.filesystem.vehicle_configuration_file_exists(local_filename):
413
+ if not self._local_filesystem.vehicle_configuration_file_exists(local_filename):
299
414
  error_msg = _("Local file {local_filename} does not exist")
300
415
  show_error(_("Will not upload any file"), error_msg.format(local_filename=local_filename))
301
416
  return False
302
417
 
303
- if self.flight_controller.master is None:
418
+ if self._flight_controller.master is None:
304
419
  show_warning(_("Will not upload any file"), _("No flight controller connection"))
305
420
  return False
306
421
 
@@ -312,7 +427,7 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
312
427
  return True # User declined upload
313
428
 
314
429
  # Attempt upload
315
- if not self.flight_controller.upload_file(local_filename, remote_filename, progress_callback):
430
+ if not self._flight_controller.upload_file(local_filename, remote_filename, progress_callback):
316
431
  error_msg = _("Failed to upload {local_filename} to {remote_filename}, please upload it manually")
317
432
  show_error(_("Upload failed"), error_msg.format(local_filename=local_filename, remote_filename=remote_filename))
318
433
  return False
@@ -331,18 +446,18 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
331
446
 
332
447
  """
333
448
  # Download all parameters from the flight controller
334
- fc_parameters, param_default_values = self.flight_controller.download_params(
449
+ fc_parameters, param_default_values = self._flight_controller.download_params(
335
450
  progress_callback,
336
- Path(self.filesystem.vehicle_dir) / "complete.param",
337
- Path(self.filesystem.vehicle_dir) / "00_default.param",
451
+ Path(self._local_filesystem.vehicle_dir) / "complete.param",
452
+ Path(self._local_filesystem.vehicle_dir) / "00_default.param",
338
453
  )
339
454
 
340
455
  # Update the flight controller parameters
341
- self.flight_controller.fc_parameters = fc_parameters
456
+ self._flight_controller.fc_parameters = fc_parameters
342
457
 
343
458
  # Write default values to file if available
344
459
  if param_default_values:
345
- self.filesystem.write_param_default_values_to_file(param_default_values)
460
+ self._local_filesystem.write_param_default_values_to_file(param_default_values)
346
461
 
347
462
  return fc_parameters, param_default_values
348
463
 
@@ -373,17 +488,17 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
373
488
  # Write each selected parameter to the flight controller
374
489
  for param_name, param in selected_params.items():
375
490
  try:
376
- if param_name not in self.flight_controller.fc_parameters or not is_within_tolerance(
377
- self.flight_controller.fc_parameters[param_name], param.value
491
+ if param_name not in self._flight_controller.fc_parameters or not is_within_tolerance(
492
+ self._flight_controller.fc_parameters[param_name], param.value
378
493
  ):
379
- param_metadata = self.filesystem.doc_dict.get(param_name, None)
494
+ param_metadata = self._local_filesystem.doc_dict.get(param_name, None)
380
495
  if param_metadata and param_metadata.get("RebootRequired", False):
381
- self.flight_controller.set_param(param_name, float(param.value))
382
- if param_name in self.flight_controller.fc_parameters:
496
+ self._flight_controller.set_param(param_name, float(param.value))
497
+ if param_name in self._flight_controller.fc_parameters:
383
498
  logging_info(
384
499
  _("Parameter %s changed from %f to %f, reset required"),
385
500
  param_name,
386
- self.flight_controller.fc_parameters[param_name],
501
+ self._flight_controller.fc_parameters[param_name],
387
502
  param.value,
388
503
  )
389
504
  else:
@@ -391,12 +506,12 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
391
506
  reset_required = True
392
507
  # Check if any of the selected parameters have a _TYPE, _EN, or _ENABLE suffix
393
508
  elif param_name.endswith(("_TYPE", "_EN", "_ENABLE", "SID_AXIS")):
394
- self.flight_controller.set_param(param_name, float(param.value))
395
- if param_name in self.flight_controller.fc_parameters:
509
+ self._flight_controller.set_param(param_name, float(param.value))
510
+ if param_name in self._flight_controller.fc_parameters:
396
511
  logging_info(
397
512
  _("Parameter %s changed from %f to %f, possible reset required"),
398
513
  param_name,
399
- self.flight_controller.fc_parameters[param_name],
514
+ self._flight_controller.fc_parameters[param_name],
400
515
  param.value,
401
516
  )
402
517
  else:
@@ -423,10 +538,13 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
423
538
  int: Extra sleep time in seconds.
424
539
 
425
540
  """
426
- current_file_params: ParDict = self.filesystem.file_parameters.get(self.current_file, ParDict())
427
- filesystem_boot_delay = current_file_params.get("BRD_BOOT_DELAY", Par(0.0))
428
- flightcontroller_boot_delay = self.flight_controller.fc_parameters.get("BRD_BOOT_DELAY", 0)
429
- return int(max(filesystem_boot_delay.value, flightcontroller_boot_delay) // 1000 + 1) # round up
541
+ param_boot_delay = (
542
+ self.current_step_parameters["BRD_BOOT_DELAY"].get_new_value()
543
+ if "BRD_BOOT_DELAY" in self.current_step_parameters
544
+ else 0.0
545
+ )
546
+ flightcontroller_boot_delay = self._flight_controller.fc_parameters.get("BRD_BOOT_DELAY", 0)
547
+ return int(max(param_boot_delay, flightcontroller_boot_delay) // 1000 + 1) # round up
430
548
 
431
549
  def _reset_and_reconnect_flight_controller(
432
550
  self, progress_callback: Optional[Callable] = None, sleep_time: Optional[int] = None
@@ -446,7 +564,7 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
446
564
  sleep_time = self._calculate_reset_time()
447
565
 
448
566
  # Call reset_and_reconnect with a callback to update the reset progress bar and the progress message
449
- return self.flight_controller.reset_and_reconnect(progress_callback, None, int(sleep_time))
567
+ return self._flight_controller.reset_and_reconnect(progress_callback, None, int(sleep_time))
450
568
 
451
569
  def reset_and_reconnect_workflow( # pylint: disable=too-many-arguments, too-many-positional-arguments
452
570
  self,
@@ -492,7 +610,7 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
492
610
 
493
611
  return True # No reset needed
494
612
 
495
- def upload_selected_parameters_workflow(self, selected_params: dict, show_error: Callable[[str, str], None]) -> int:
613
+ def _upload_parameters_to_fc(self, selected_params: dict, show_error: Callable[[str, str], None]) -> int:
496
614
  """
497
615
  Upload selected parameters to flight controller.
498
616
 
@@ -510,15 +628,15 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
510
628
 
511
629
  for param_name, param in selected_params.items():
512
630
  try:
513
- self.flight_controller.set_param(param_name, param.value)
514
- if param_name not in self.flight_controller.fc_parameters or not is_within_tolerance(
515
- self.flight_controller.fc_parameters[param_name], param.value
631
+ self._flight_controller.set_param(param_name, param.value)
632
+ if param_name not in self._flight_controller.fc_parameters or not is_within_tolerance(
633
+ self._flight_controller.fc_parameters[param_name], param.value
516
634
  ):
517
- if param_name in self.flight_controller.fc_parameters:
635
+ if param_name in self._flight_controller.fc_parameters:
518
636
  logging_info(
519
637
  _("Parameter %s changed from %f to %f"),
520
638
  param_name,
521
- self.flight_controller.fc_parameters[param_name],
639
+ self._flight_controller.fc_parameters[param_name],
522
640
  param.value,
523
641
  )
524
642
  else:
@@ -593,7 +711,7 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
593
711
  "39_autotune_roll_pitch_results.param",
594
712
  ]
595
713
 
596
- report_file_path = Path(getattr(self.filesystem, "vehicle_dir", ".")) / "tuning_report.csv"
714
+ report_file_path = Path(getattr(self._local_filesystem, "vehicle_dir", ".")) / "tuning_report.csv"
597
715
 
598
716
  # Write a CSV with a header ("param", <list of files>) and one row per parameter.
599
717
  with open(report_file_path, "w", newline="", encoding="utf-8") as file:
@@ -605,34 +723,107 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
605
723
  for param_file in report_files:
606
724
  try:
607
725
  if param_file == "00_default.param":
608
- value = str(self.filesystem.param_default_dict[param_name].value)
726
+ value = str(self._local_filesystem.param_default_dict[param_name].value)
609
727
  else:
610
- value = str(self.filesystem.file_parameters[param_file][param_name].value)
728
+ value = str(self._local_filesystem.file_parameters[param_file][param_name].value)
611
729
  except (KeyError, ValueError):
612
730
  # On any unexpected structure, leave the value empty (don't crash)
613
731
  value = ""
614
732
  row.append(value)
615
733
  writer.writerow(row)
616
734
 
617
- def validate_uploaded_parameters(self, selected_params: dict) -> list[str]:
735
+ def upload_selected_params_workflow( # pylint: disable=too-many-arguments, too-many-positional-arguments
736
+ self,
737
+ selected_params: dict,
738
+ ask_confirmation: AskConfirmationCallback,
739
+ ask_retry_cancel: AskRetryCancelCallback,
740
+ show_error: ShowErrorCallback,
741
+ progress_callback_for_reset: Optional[Callable] = None,
742
+ progress_callback_for_download: Optional[Callable] = None,
743
+ ) -> None:
744
+ """
745
+ Complete workflow for uploading selected parameters, including reset, upload, validation, and retry.
746
+
747
+ Args:
748
+ selected_params: Dictionary of parameters to upload.
749
+ ask_confirmation: Callback to ask user for confirmation.
750
+ ask_retry_cancel: Callback to ask user to retry or cancel on upload error.
751
+ show_error: Callback to show error messages.
752
+ progress_callback_for_reset: Optional callback for reset progress.
753
+ progress_callback_for_download: Optional callback for download progress.
754
+
755
+ """
756
+ logging_info(
757
+ _("Uploading %d selected %s parameters to flight controller..."),
758
+ len(selected_params),
759
+ self.current_file,
760
+ )
761
+
762
+ # Upload parameters that require reset
763
+ reset_happened = self.upload_parameters_that_require_reset_workflow(
764
+ selected_params,
765
+ ask_confirmation,
766
+ show_error,
767
+ progress_callback_for_reset,
768
+ )
769
+
770
+ # Upload the selected parameters
771
+ nr_changed = self._upload_parameters_to_fc(selected_params, show_error)
772
+
773
+ if reset_happened or nr_changed > 0:
774
+ self._at_least_one_changed = True
775
+
776
+ if self._at_least_one_changed:
777
+ # Re-download all parameters to validate
778
+ self.download_flight_controller_parameters(progress_callback_for_download)
779
+ param_upload_error = self._validate_uploaded_parameters(selected_params)
780
+
781
+ if param_upload_error:
782
+ if ask_retry_cancel(
783
+ _("Parameter upload error"),
784
+ _("Failed to upload the following parameters to the flight controller:\n")
785
+ + f"{(', ').join(param_upload_error)}",
786
+ ):
787
+ # Retry the entire workflow
788
+ self.upload_selected_params_workflow(
789
+ selected_params,
790
+ ask_confirmation,
791
+ ask_retry_cancel,
792
+ show_error,
793
+ progress_callback_for_reset,
794
+ progress_callback_for_download,
795
+ )
796
+ # If not retrying, continue without success message
797
+ else:
798
+ logging_info(_("All parameters uploaded to the flight controller successfully"))
799
+
800
+ self._export_fc_params_missing_or_different()
801
+
802
+ self._write_current_file()
803
+ self._at_least_one_changed = False
804
+
805
+ # frontend_tkinter_parameter_editor_table.py API end
806
+
807
+ # frontend_tkinter_parameter_editor.py API start
808
+ def _validate_uploaded_parameters(self, selected_params: dict) -> list[str]:
618
809
  logging_info(_("Re-downloaded all parameters from the flight controller"))
619
810
 
620
811
  # Validate that the read parameters are the same as the ones in the current_file
621
812
  param_upload_error = []
622
813
  for param_name, param in selected_params.items():
623
814
  if (
624
- param_name in self.flight_controller.fc_parameters
815
+ param_name in self._flight_controller.fc_parameters
625
816
  and param is not None
626
- and not is_within_tolerance(self.flight_controller.fc_parameters[param_name], float(param.value))
817
+ and not is_within_tolerance(self._flight_controller.fc_parameters[param_name], float(param.value))
627
818
  ):
628
819
  logging_error(
629
820
  _("Parameter %s upload to the flight controller failed. Expected: %f, Actual: %f"),
630
821
  param_name,
631
822
  param.value,
632
- self.flight_controller.fc_parameters[param_name],
823
+ self._flight_controller.fc_parameters[param_name],
633
824
  )
634
825
  param_upload_error.append(param_name)
635
- if param_name not in self.flight_controller.fc_parameters:
826
+ if param_name not in self._flight_controller.fc_parameters:
636
827
  logging_error(
637
828
  _("Parameter %s upload to the flight controller failed. Expected: %f, Actual: N/A"),
638
829
  param_name,
@@ -650,18 +841,20 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
650
841
 
651
842
  """
652
843
  # Create FC parameters dictionary
653
- fc_parameters = ParDict.from_fc_parameters(self.flight_controller.fc_parameters)
844
+ fc_parameters = ParDict.from_fc_parameters(self._flight_controller.fc_parameters)
654
845
 
655
846
  # Early exit if no FC parameters available
656
847
  if len(fc_parameters) == 0:
657
848
  return fc_parameters
658
849
 
659
850
  # Remove default parameters from FC parameters if default file exists
660
- fc_parameters.remove_if_value_is_similar(self.filesystem.param_default_dict, is_within_tolerance)
851
+ fc_parameters.remove_if_value_is_similar(self._local_filesystem.param_default_dict, is_within_tolerance)
661
852
 
662
853
  # Filter out read-only parameters efficiently - only check params that exist in fc_parameters
663
854
  readonly_params_to_remove = [
664
- param_name for param_name in fc_parameters if self.filesystem.doc_dict.get(param_name, {}).get("ReadOnly", False)
855
+ param_name
856
+ for param_name in fc_parameters
857
+ if self._local_filesystem.doc_dict.get(param_name, {}).get("ReadOnly", False)
665
858
  ]
666
859
  for param_name in readonly_params_to_remove:
667
860
  del fc_parameters[param_name]
@@ -681,13 +874,13 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
681
874
  last_filename: Last configuration file to process (inclusive).
682
875
 
683
876
  """
684
- if not self.flight_controller.fc_parameters:
877
+ if not self._flight_controller.fc_parameters:
685
878
  return
686
879
 
687
880
  # Create the compounded state of all parameters stored in the AMC .param files
688
881
  compound = ParDict()
689
882
  first_config_step_filename = None
690
- for file_name, file_params in self.filesystem.file_parameters.items():
883
+ for file_name, file_params in self._local_filesystem.file_parameters.items():
691
884
  if file_name != "00_default.param":
692
885
  if first_config_step_filename is None:
693
886
  first_config_step_filename = file_name
@@ -722,7 +915,7 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
722
915
  first_name_without_ext = first_config_step_filename.rsplit(".", 1)[0] if first_config_step_filename else "unknown"
723
916
  # the last filename already has the .param extension
724
917
  filename = f"fc_params_missing_or_different_in_the_amc_param_files_{first_name_without_ext}_to_{last_filename}"
725
- self.filesystem.export_to_param(params_missing_in_the_amc_param_files, filename, annotate_doc=False)
918
+ self._local_filesystem.export_to_param(params_missing_in_the_amc_param_files, filename, annotate_doc=False)
726
919
  logging_info(
727
920
  _("Exported %d FC parameters missing or different in AMC files to %s"),
728
921
  len(params_missing_in_the_amc_param_files),
@@ -731,10 +924,10 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
731
924
  else:
732
925
  logging_info(_("No FC parameters are missing or different from AMC parameter files"))
733
926
 
734
- def export_fc_params_missing_or_different(self) -> None:
927
+ def _export_fc_params_missing_or_different(self) -> None:
735
928
  non_default_non_read_only_fc_params = self._get_non_default_non_read_only_fc_params()
736
929
 
737
- last_config_step_filename = list(self.filesystem.file_parameters.keys())[-1]
930
+ last_config_step_filename = list(self._local_filesystem.file_parameters.keys())[-1]
738
931
  # Export FC parameters that are missing or different from AMC parameter files
739
932
  self._export_fc_params_missing_or_different_in_amc_files(non_default_non_read_only_fc_params, self.current_file)
740
933
  self._export_fc_params_missing_or_different_in_amc_files(
@@ -759,7 +952,7 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
759
952
  run_in_thread: Callback to run the download in a thread (optional).
760
953
 
761
954
  """
762
- if self.flight_controller.master is None:
955
+ if self._flight_controller.master is None:
763
956
  show_error(_("Error"), _("No flight controller connected"))
764
957
  return
765
958
 
@@ -771,26 +964,29 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
771
964
  if not filename:
772
965
  return
773
966
 
774
- success = self.flight_controller.download_last_flight_log(filename, progress_callback)
967
+ success = self._flight_controller.download_last_flight_log(filename, progress_callback)
775
968
  if success:
776
969
  show_info(_("Success"), _("Flight log downloaded successfully to:\n%s") % filename)
777
970
  else:
778
971
  show_error(_("Error"), _("Failed to download flight log. Check the console for details."))
779
972
 
780
- def is_configuration_step_optional(self, file_name: str, threshold_pct: int = 20) -> bool:
973
+ def is_configuration_step_optional(self, file_name: Optional[str] = None, threshold_pct: int = 20) -> bool:
781
974
  """
782
975
  Check if the configuration step for the given file is optional.
783
976
 
784
977
  Args:
785
- file_name: Name of the configuration file to check.
978
+ file_name: Name of the configuration file to check, defaults to self.current_file.
786
979
  threshold_pct: Threshold percentage below which the step is considered optional.
787
980
 
788
981
  Returns:
789
982
  bool: True if the configuration step is optional, False if mandatory.
790
983
 
791
984
  """
985
+ if file_name is None:
986
+ file_name = self.current_file
987
+
792
988
  # Check if the configuration step for the given file is optional
793
- mandatory_text, _mandatory_url = self.filesystem.get_documentation_text_and_url(file_name, "mandatory")
989
+ mandatory_text, _mandatory_url = self._local_filesystem.get_documentation_text_and_url(file_name, "mandatory")
794
990
  # Extract percentage from mandatory_text like "80% mandatory (20% optional)"
795
991
  percentage = 0
796
992
  if mandatory_text:
@@ -801,21 +997,24 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
801
997
 
802
998
  return percentage <= threshold_pct
803
999
 
804
- def get_next_non_optional_file(self, current_file: str) -> Optional[str]:
1000
+ def get_next_non_optional_file(self, current_file: Optional[str] = None) -> Optional[str]:
805
1001
  """
806
1002
  Get the next non-optional configuration file in sequence.
807
1003
 
808
1004
  Args:
809
- current_file: The current parameter file being processed.
1005
+ current_file: The current parameter file being processed, defaults to self.current_file.
810
1006
 
811
1007
  Returns:
812
1008
  Optional[str]: Next non-optional file name, or None if at the end.
813
1009
 
814
1010
  """
815
- files = list(self.filesystem.file_parameters.keys())
1011
+ files = list(self._local_filesystem.file_parameters.keys())
816
1012
  if not files:
817
1013
  return None
818
1014
 
1015
+ if current_file is None:
1016
+ current_file = self.current_file
1017
+
819
1018
  try:
820
1019
  next_file_index = files.index(current_file) + 1
821
1020
 
@@ -843,14 +1042,14 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
843
1042
 
844
1043
  """
845
1044
  # Get annotated FC parameters
846
- annotated_fc_parameters = self.filesystem.annotate_intermediate_comments_to_param_dict(
847
- self.flight_controller.fc_parameters
1045
+ annotated_fc_parameters = self._local_filesystem.annotate_intermediate_comments_to_param_dict(
1046
+ self._flight_controller.fc_parameters
848
1047
  )
849
1048
  if not annotated_fc_parameters:
850
1049
  return {}
851
1050
 
852
1051
  # Categorize parameters using filesystem logic
853
- categorized = self.filesystem.categorize_parameters(annotated_fc_parameters)
1052
+ categorized = self._local_filesystem.categorize_parameters(annotated_fc_parameters)
854
1053
  if not categorized or len(categorized) != 3:
855
1054
  # Return empty dict if categorization fails or returns empty tuple
856
1055
  return {}
@@ -1009,13 +1208,13 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
1009
1208
  return False
1010
1209
 
1011
1210
  # If file exists, ask user for confirmation
1012
- if self.filesystem.vehicle_configuration_file_exists(filename):
1211
+ if self._local_filesystem.vehicle_configuration_file_exists(filename):
1013
1212
  msg = _("{} file already exists.\nDo you want to overwrite it?")
1014
1213
  should_write_file = ask_confirmation(_("Overwrite existing file"), msg.format(filename))
1015
1214
 
1016
1215
  # Write the file using if confirmed and has parameters
1017
1216
  if should_write_file:
1018
- self.filesystem.export_to_param(param_dict, filename, annotate_doc)
1217
+ self._local_filesystem.export_to_param(param_dict, filename, annotate_doc)
1019
1218
  logging_info(_("Summary file %s written"), filename)
1020
1219
 
1021
1220
  return should_write_file
@@ -1042,14 +1241,14 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
1042
1241
  should_write_file = True # Default to writing new files
1043
1242
 
1044
1243
  # If file exists, ask user for confirmation
1045
- if self.filesystem.zip_file_exists():
1046
- zip_file_path = self.filesystem.zip_file_path()
1244
+ if self._local_filesystem.zip_file_exists():
1245
+ zip_file_path = self._local_filesystem.zip_file_path()
1047
1246
  msg = _("{} file already exists.\nDo you want to overwrite it?")
1048
1247
  should_write_file = ask_confirmation(_("Overwrite existing file"), msg.format(zip_file_path))
1049
1248
 
1050
1249
  if should_write_file:
1051
- self.filesystem.zip_files(files_to_zip)
1052
- zip_file_path = self.filesystem.zip_file_path()
1250
+ self._local_filesystem.zip_files(files_to_zip)
1251
+ zip_file_path = self._local_filesystem.zip_file_path()
1053
1252
  msg = _(
1054
1253
  "All relevant files have been zipped into the \n"
1055
1254
  "{zip_file_path} file.\n\nYou can now upload this file to the ArduPilot Methodic\n"
@@ -1061,18 +1260,75 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
1061
1260
 
1062
1261
  def repopulate_configuration_step_parameters(
1063
1262
  self,
1064
- ) -> tuple[bool, list[tuple[str, str]], list[tuple[str, str]]]:
1263
+ ) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
1065
1264
  """
1066
1265
  Process the configuration step for the current file and update the self.parameters.
1067
1266
 
1068
1267
  Returns:
1069
- tuple: (config_step_edited, ui_errors, ui_infos)
1268
+ tuple: (ui_errors, ui_infos)
1070
1269
 
1071
1270
  """
1072
- self.parameters, config_step_edited, ui_errors, ui_infos = self.config_step_processor.process_configuration_step(
1073
- self.current_file, self.fc_parameters
1271
+ # Reset tracking sets when navigating to new file
1272
+ self._added_parameters.clear()
1273
+ self._deleted_parameters.clear()
1274
+
1275
+ # Process configuration step and get operations to apply
1276
+ self.current_step_parameters, ui_errors, ui_infos, duplicates_to_remove, renames_to_apply, derived_params = (
1277
+ self._config_step_processor.process_configuration_step(self.current_file, self.fc_parameters)
1074
1278
  )
1075
- return config_step_edited, ui_errors, ui_infos
1279
+
1280
+ # Apply derived parameters to domain model using specialized setters
1281
+ for param_name, derived_par in derived_params.items():
1282
+ if param_name in self.current_step_parameters:
1283
+ # Update existing forced/derived parameter with new value using dedicated setter
1284
+ # The setter methods will raise ValueError for invalid parameters (not forced/derived, readonly, etc.)
1285
+ try:
1286
+ self.current_step_parameters[param_name].set_forced_or_derived_value(float(derived_par.value))
1287
+ if derived_par.comment:
1288
+ self.current_step_parameters[param_name].set_forced_or_derived_change_reason(derived_par.comment)
1289
+ except (ValueError, TypeError) as e:
1290
+ logging_error(
1291
+ _("Failed to apply derived parameter %s: %s"),
1292
+ param_name,
1293
+ str(e),
1294
+ )
1295
+ else:
1296
+ # Parameter in derived_params but not in self.parameters - this is unexpected
1297
+ logging_error(
1298
+ _("Derived parameter %s not found in current parameters, skipping"),
1299
+ param_name,
1300
+ )
1301
+
1302
+ # Apply rename operations to domain model using add/delete tracking
1303
+ for old_name in duplicates_to_remove:
1304
+ # Mark duplicate as deleted
1305
+ if old_name in self._local_filesystem.file_parameters.get(self.current_file, ParDict()):
1306
+ self._deleted_parameters.add(old_name)
1307
+ # Remove from domain model
1308
+ if old_name in self.current_step_parameters:
1309
+ del self.current_step_parameters[old_name]
1310
+
1311
+ for old_name, new_name in renames_to_apply:
1312
+ # Get the parameter value from the original file
1313
+ original_params = self._local_filesystem.file_parameters.get(self.current_file, ParDict())
1314
+ if old_name in original_params:
1315
+ # Mark old parameter as deleted
1316
+ self._deleted_parameters.add(old_name)
1317
+
1318
+ # Create new parameter with renamed name
1319
+ old_par = original_params[old_name]
1320
+ self.current_step_parameters[new_name] = self._config_step_processor.create_ardupilot_parameter(
1321
+ new_name, old_par, self.current_file, self.fc_parameters
1322
+ )
1323
+
1324
+ # Mark new parameter as added
1325
+ self._added_parameters.add(new_name)
1326
+
1327
+ # Remove old parameter from domain model
1328
+ if old_name in self.current_step_parameters:
1329
+ del self.current_step_parameters[old_name]
1330
+
1331
+ return ui_errors, ui_infos
1076
1332
 
1077
1333
  def get_different_parameters(self) -> dict[str, ArduPilotParameter]:
1078
1334
  """
@@ -1082,7 +1338,7 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
1082
1338
  Dictionary of parameters that are different from FC
1083
1339
 
1084
1340
  """
1085
- return self.config_step_processor.filter_different_parameters(self.parameters)
1341
+ return self._config_step_processor.filter_different_parameters(self.current_step_parameters)
1086
1342
 
1087
1343
  def delete_parameter_from_current_file(self, param_name: str) -> None:
1088
1344
  """
@@ -1092,18 +1348,30 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
1092
1348
  param_name: The name of the parameter to delete
1093
1349
 
1094
1350
  """
1095
- del self.current_file_parameters[param_name]
1096
- if param_name in self.parameters:
1097
- del self.parameters[param_name]
1351
+ # If parameter was in original file, mark as deleted
1352
+ if param_name in self._local_filesystem.file_parameters.get(self.current_file, ParDict()):
1353
+ self._deleted_parameters.add(param_name)
1354
+
1355
+ # If it was previously added in this session, remove from added set
1356
+ self._added_parameters.discard(param_name)
1357
+
1358
+ # Remove from runtime state
1359
+ if param_name in self.current_step_parameters:
1360
+ del self.current_step_parameters[param_name]
1098
1361
 
1099
1362
  def get_possible_add_param_names(self) -> list[str]:
1100
1363
  """Return a sorted list of possible parameter names to add, or raise OperationNotPossibleError if not possible."""
1101
- param_dict = self.filesystem.doc_dict or self.fc_parameters
1364
+ param_dict = self._local_filesystem.doc_dict or self.fc_parameters
1102
1365
  if not param_dict:
1103
1366
  raise OperationNotPossibleError(
1104
1367
  _("No apm.pdef.xml file and no FC connected. Not possible autocomplete parameter names.")
1105
1368
  )
1106
- possible_add_param_names = [param_name for param_name in param_dict if param_name not in self.current_file_parameters]
1369
+
1370
+ # Build set of currently active parameters from domain model
1371
+ active_params = set(self.current_step_parameters.keys())
1372
+
1373
+ # Find parameters that aren't currently active
1374
+ possible_add_param_names = [param_name for param_name in param_dict if param_name not in active_params]
1107
1375
  possible_add_param_names.sort()
1108
1376
  return possible_add_param_names
1109
1377
 
@@ -1119,34 +1387,206 @@ class ConfigurationManager: # pylint: disable=too-many-public-methods
1119
1387
  if not param_name:
1120
1388
  raise InvalidParameterNameError(_("Parameter name can not be empty."))
1121
1389
 
1122
- if param_name in self.current_file_parameters:
1390
+ # Check if parameter already exists (in original file, added, or not deleted)
1391
+ original_file_params = self._local_filesystem.file_parameters.get(self.current_file, ParDict())
1392
+ is_in_original = param_name in original_file_params
1393
+ is_already_added = param_name in self._added_parameters
1394
+ is_deleted = param_name in self._deleted_parameters
1395
+
1396
+ if (is_in_original and not is_deleted) or is_already_added:
1123
1397
  raise InvalidParameterNameError(_("Parameter already exists, edit it instead"))
1124
1398
 
1125
1399
  fc_parameters = self.fc_parameters
1126
1400
  if fc_parameters:
1127
1401
  if param_name in fc_parameters:
1128
- self.current_file_parameters[param_name] = Par(fc_parameters[param_name], "")
1129
- self.parameters[param_name] = self.config_step_processor.create_ardupilot_parameter(
1130
- param_name, self.current_file_parameters[param_name], self.current_file, fc_parameters
1402
+ # Create the parameter in domain model
1403
+ par = Par(fc_parameters[param_name], "")
1404
+ self.current_step_parameters[param_name] = self._config_step_processor.create_ardupilot_parameter(
1405
+ param_name, par, self.current_file, fc_parameters
1131
1406
  )
1407
+
1408
+ # Track addition
1409
+ if not is_in_original:
1410
+ self._added_parameters.add(param_name)
1411
+ # If was previously deleted, remove from deleted set
1412
+ self._deleted_parameters.discard(param_name)
1413
+
1132
1414
  return True
1133
1415
  raise InvalidParameterNameError(_("Parameter name not found in the flight controller."))
1134
1416
 
1135
- if self.filesystem.doc_dict:
1136
- if param_name in self.filesystem.doc_dict:
1137
- self.current_file_parameters[param_name] = Par(
1138
- self.filesystem.param_default_dict.get(param_name, Par(0, "")).value, ""
1139
- )
1140
- self.parameters[param_name] = self.config_step_processor.create_ardupilot_parameter(
1141
- param_name, self.current_file_parameters[param_name], self.current_file, fc_parameters
1417
+ if self._local_filesystem.doc_dict:
1418
+ if param_name in self._local_filesystem.doc_dict:
1419
+ # Create the parameter in domain model
1420
+ par = Par(self._local_filesystem.param_default_dict.get(param_name, Par(0, "")).value, "")
1421
+ self.current_step_parameters[param_name] = self._config_step_processor.create_ardupilot_parameter(
1422
+ param_name, par, self.current_file, fc_parameters
1142
1423
  )
1424
+
1425
+ # Track addition
1426
+ if not is_in_original:
1427
+ self._added_parameters.add(param_name)
1428
+ # If was previously deleted, remove from deleted set
1429
+ self._deleted_parameters.discard(param_name)
1430
+
1143
1431
  return True
1144
1432
  raise InvalidParameterNameError(
1145
1433
  _("'{param_name}' not found in the apm.pdef.xml file.").format(param_name=param_name)
1146
1434
  )
1147
1435
 
1148
- if not fc_parameters and not self.filesystem.doc_dict:
1436
+ if not fc_parameters and not self._local_filesystem.doc_dict:
1149
1437
  raise OperationNotPossibleError(
1150
1438
  _("Can not add parameter when no FC is connected and no apm.pdef.xml file exists.")
1151
1439
  )
1152
1440
  return False
1441
+
1442
+ def get_parameters_as_par_dict(self, param_names: Optional[list[str]] = None) -> ParDict:
1443
+ """
1444
+ Extract Par objects from ArduPilotParameter domain models.
1445
+
1446
+ This method converts the domain model objects to data transfer objects (Par)
1447
+ that can be used for file operations or flight controller uploads.
1448
+
1449
+ Args:
1450
+ param_names: Optional list of parameter names to include.
1451
+ If None, includes all parameters.
1452
+
1453
+ Returns:
1454
+ ParDict containing Par objects with current values and change reasons
1455
+
1456
+ """
1457
+ if param_names is None:
1458
+ param_names = list(self.current_step_parameters.keys())
1459
+
1460
+ return ParDict(
1461
+ {
1462
+ name: Par(self.current_step_parameters[name].get_new_value(), self.current_step_parameters[name].change_reason)
1463
+ for name in param_names
1464
+ if name in self.current_step_parameters
1465
+ }
1466
+ )
1467
+
1468
+ def _has_unsaved_changes(self) -> bool:
1469
+ """
1470
+ Check if any changes have been made that need to be saved.
1471
+
1472
+ This includes:
1473
+ - User edits to parameter values
1474
+ - Derived parameter changes (tracked via is_dirty)
1475
+ - Forced parameter changes (tracked via is_dirty)
1476
+ - Connection renaming changes (tracked via _added_parameters and _deleted_parameters)
1477
+ - Parameter additions
1478
+ - Parameter deletions
1479
+
1480
+ Returns:
1481
+ True if there are unsaved changes, False otherwise
1482
+
1483
+ """
1484
+ # Check for structural changes (additions/deletions, including from renames)
1485
+ if self._added_parameters or self._deleted_parameters:
1486
+ return True
1487
+
1488
+ # Check individual parameter edits (value or comment changes)
1489
+ return any(param.is_dirty for param in self.current_step_parameters.values())
1490
+
1491
+ def get_last_configuration_step_number(self) -> Optional[int]:
1492
+ if self._local_filesystem.configuration_phases:
1493
+ # Get the first two characters of the last configuration step filename
1494
+ last_step_filename = next(reversed(self._local_filesystem.file_parameters.keys()))
1495
+ return int(last_step_filename[:2]) + 1 if len(last_step_filename) >= 2 else 1
1496
+ return None
1497
+
1498
+ def get_sorted_phases_with_end_and_weight(self, last_step_nr: int) -> dict[str, PhaseData]:
1499
+ return self._local_filesystem.get_sorted_phases_with_end_and_weight(last_step_nr)
1500
+
1501
+ def get_vehicle_directory(self) -> str:
1502
+ return self._local_filesystem.vehicle_dir
1503
+
1504
+ def parameter_files(self) -> list[str]:
1505
+ return list(self._local_filesystem.file_parameters.keys())
1506
+
1507
+ def parameter_documentation_available(self) -> bool:
1508
+ return bool(self._local_filesystem.doc_dict)
1509
+
1510
+ def configuration_phases(self) -> dict[str, PhaseData]:
1511
+ return self._local_filesystem.configuration_phases
1512
+
1513
+ def _write_current_file(self) -> None:
1514
+ self._local_filesystem.write_last_uploaded_filename(self.current_file)
1515
+
1516
+ def _export_current_file(self, annotate_doc: bool) -> None:
1517
+ # Convert domain model parameters to Par objects for export
1518
+ export_params = self.get_parameters_as_par_dict()
1519
+
1520
+ # Export to file
1521
+ self._local_filesystem.export_to_param(export_params, self.current_file, annotate_doc)
1522
+
1523
+ # Update the filesystem's file_parameters to match what was saved
1524
+ self._local_filesystem.file_parameters[self.current_file] = export_params
1525
+
1526
+ self._added_parameters.clear()
1527
+ self._deleted_parameters.clear()
1528
+ # copy parameters new values to their _values_on_file
1529
+ for param in self.current_step_parameters.values():
1530
+ param.copy_new_value_to_file()
1531
+
1532
+ def open_documentation_in_browser(self, filename: str) -> None:
1533
+ _blog_text, blog_url = self.get_documentation_text_and_url("blog", filename)
1534
+ _wiki_text, wiki_url = self.get_documentation_text_and_url("wiki", filename)
1535
+ _external_tool_text, external_tool_url = self.get_documentation_text_and_url("external_tool", filename)
1536
+ if wiki_url:
1537
+ webbrowser_open(url=wiki_url, new=0, autoraise=False)
1538
+ if external_tool_url:
1539
+ webbrowser_open(url=external_tool_url, new=0, autoraise=False)
1540
+ if blog_url:
1541
+ webbrowser_open(url=blog_url, new=0, autoraise=True)
1542
+
1543
+ # frontend_tkinter_parameter_editor.py API end
1544
+
1545
+ # frontend_tkinter_parameter_editor_documentation_frame.py API start
1546
+ def get_documentation_text_and_url(self, key: str, filename: Optional[str] = None) -> tuple[str, str]:
1547
+ if filename is None:
1548
+ filename = self.current_file
1549
+ return self._local_filesystem.get_documentation_text_and_url(filename, key)
1550
+
1551
+ def get_why_why_now_tooltip(self) -> str:
1552
+ why_tooltip_text = self._local_filesystem.get_seq_tooltip_text(self.current_file, "why")
1553
+ why_now_tooltip_text = self._local_filesystem.get_seq_tooltip_text(self.current_file, "why_now")
1554
+ tooltip_text = ""
1555
+ if why_tooltip_text:
1556
+ tooltip_text += _("Why: ") + _(why_tooltip_text) + "\n"
1557
+ if why_now_tooltip_text:
1558
+ tooltip_text += _("Why now: ") + _(why_now_tooltip_text)
1559
+ return tooltip_text
1560
+
1561
+ def get_documentation_frame_title(self) -> str:
1562
+ if self.current_file:
1563
+ title = _("{current_file} Documentation")
1564
+ return title.format(current_file=self.current_file)
1565
+ return _("Documentation")
1566
+
1567
+ def parse_mandatory_level_percentage(self, text: str) -> tuple[int, str]:
1568
+ """
1569
+ Parse and validate the mandatory level percentage from text.
1570
+
1571
+ Args:
1572
+ text: The text containing the mandatory level information
1573
+
1574
+ Returns:
1575
+ tuple: (percentage_value, tooltip_text)
1576
+ percentage_value: 0-100 for valid percentage, 0 for invalid
1577
+ tooltip_text: Formatted tooltip text
1578
+
1579
+ """
1580
+ current_file = self.current_file or ""
1581
+ try:
1582
+ # Extract up to 3 digits from the start of the mandatory text
1583
+ percentage = int("".join([c for c in text[:3] if c.isdigit()]))
1584
+ if 0 <= percentage <= 100:
1585
+ tooltip = _("This configuration step ({current_file} intermediate parameter file) is {percentage}% mandatory")
1586
+ return percentage, tooltip.format(current_file=current_file, percentage=percentage)
1587
+ raise ValueError
1588
+ except ValueError:
1589
+ tooltip = _("Mandatory level not available for this configuration step ({current_file})")
1590
+ return 0, tooltip.format(current_file=current_file)
1591
+
1592
+ # frontend_tkinter_parameter_editor_documentation_frame.py API end