ardupilot-methodic-configurator 2.5.0__py3-none-any.whl → 2.6.0__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 (101) hide show
  1. ardupilot_methodic_configurator/__init__.py +1 -1
  2. ardupilot_methodic_configurator/backend_filesystem_program_settings.py +15 -20
  3. ardupilot_methodic_configurator/configuration_manager.py +113 -1
  4. ardupilot_methodic_configurator/data_model_configuration_step.py +40 -3
  5. ardupilot_methodic_configurator/frontend_tkinter_base_window.py +4 -0
  6. ardupilot_methodic_configurator/frontend_tkinter_component_editor_base.py +1 -1
  7. ardupilot_methodic_configurator/frontend_tkinter_motor_test.py +853 -0
  8. ardupilot_methodic_configurator/frontend_tkinter_parameter_editor.py +8 -12
  9. ardupilot_methodic_configurator/frontend_tkinter_parameter_editor_table.py +78 -108
  10. ardupilot_methodic_configurator/frontend_tkinter_usage_popup_window.py +11 -6
  11. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500/07_esc.param +1 -1
  12. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500/42_system_id_roll.param +5 -3
  13. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500/43_system_id_pitch.param +3 -3
  14. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500/44_system_id_yaw.param +1 -1
  15. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500/vehicle.jpg +0 -0
  16. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500/vehicle_components.json +3 -4
  17. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/05_remote_controller.param +7 -7
  18. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/06_telemetry.param +2 -0
  19. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/07_esc.param +6 -6
  20. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/10_gnss.param +4 -4
  21. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/13_general_configuration.param +5 -5
  22. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/15_motor.param +1 -1
  23. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/16_pid_adjustment.param +10 -10
  24. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/18_notch_filter_setup.param +3 -3
  25. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/20_throttle_controller.param +1 -1
  26. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/22_quick_tune_setup.param +1 -1
  27. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/26_quick_tune_setup.param +2 -2
  28. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/30_autotune_roll_setup.param +1 -0
  29. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/34_autotune_yaw_setup.param +1 -1
  30. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/37_autotune_yawd_results.param +1 -1
  31. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/vehicle.jpg +0 -0
  32. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/00_default.param +1352 -0
  33. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/02_imu_temperature_calibration_setup.param +8 -0
  34. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/03_imu_temperature_calibration_results.param +42 -0
  35. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/04_board_orientation.param +4 -0
  36. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/05_remote_controller.param +13 -0
  37. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/06_telemetry.param +4 -0
  38. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/07_esc.param +43 -0
  39. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/08_batt1.param +15 -0
  40. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/10_gnss.param +11 -0
  41. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/11_initial_atc.param +18 -0
  42. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/12_mp_setup_mandatory_hardware.param +99 -0
  43. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/13_general_configuration.param +17 -0
  44. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/14_logging.param +6 -0
  45. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/15_motor.param +4 -0
  46. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/16_pid_adjustment.param +13 -0
  47. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/17_remote_id.param +4 -0
  48. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/18_notch_filter_setup.param +10 -0
  49. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/19_notch_filter_results.param +7 -0
  50. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/20_throttle_controller.param +5 -0
  51. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/21_ekf_config.param +2 -0
  52. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/22_quick_tune_setup.param +14 -0
  53. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/23_quick_tune_results.param +10 -0
  54. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/24_inflight_magnetometer_fit_setup.param +8 -0
  55. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/24_inflight_magnetometer_fit_setup.pdef.xml +57 -0
  56. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/25_inflight_magnetometer_fit_results.param +15 -0
  57. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/26_quick_tune_setup.param +14 -0
  58. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/27_quick_tune_results.param +10 -0
  59. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/28_evaluate_the_aircraft_tune_ff_disable.param +4 -0
  60. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/29_evaluate_the_aircraft_tune_ff_enable.param +1 -0
  61. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/30_autotune_roll_setup.param +2 -0
  62. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/31_autotune_roll_results.param +5 -0
  63. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/32_autotune_pitch_setup.param +2 -0
  64. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/33_autotune_pitch_results.param +5 -0
  65. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/34_autotune_yaw_setup.param +3 -0
  66. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/35_autotune_yaw_results.param +5 -0
  67. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/36_autotune_yawd_setup.param +3 -0
  68. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/37_autotune_yawd_results.param +5 -0
  69. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/38_autotune_roll_pitch_retune_setup.param +2 -0
  70. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/39_autotune_roll_pitch_retune_results.param +10 -0
  71. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/40_windspeed_estimation.param +5 -0
  72. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/41_barometer_compensation.param +7 -0
  73. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/42_system_id_roll.param +21 -0
  74. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/43_system_id_pitch.param +10 -0
  75. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/44_system_id_yaw.param +10 -0
  76. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/45_system_id_thrust.param +10 -0
  77. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/46_analytical_pid_optimization.param +4 -0
  78. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/47_position_controller.param +13 -0
  79. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/48_guided_operation.param +4 -0
  80. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/49_precision_land.param +27 -0
  81. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/50_optical_flow_setup.param +19 -0
  82. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/51_optical_flow_results.param +3 -0
  83. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/52_use_optical_flow_instead_of_gnss.param +8 -0
  84. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/53_everyday_use.param +7 -0
  85. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/vehicle.jpg +0 -0
  86. ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/vehicle_components.json +188 -0
  87. {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.0.dist-info}/METADATA +74 -134
  88. {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.0.dist-info}/RECORD +101 -43
  89. {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.0.dist-info}/WHEEL +0 -0
  90. {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.0.dist-info}/entry_points.txt +0 -0
  91. {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.0.dist-info}/licenses/LICENSE.md +0 -0
  92. {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.0.dist-info}/licenses/LICENSES/Apache-2.0.txt +0 -0
  93. {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.0.dist-info}/licenses/LICENSES/BSD-3-Clause.txt +0 -0
  94. {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.0.dist-info}/licenses/LICENSES/GPL-3.0-or-later.txt +0 -0
  95. {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.0.dist-info}/licenses/LICENSES/LGPL-3.0-or-later.txt +0 -0
  96. {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.0.dist-info}/licenses/LICENSES/MIT-CMU.txt +0 -0
  97. {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.0.dist-info}/licenses/LICENSES/MIT.txt +0 -0
  98. {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.0.dist-info}/licenses/LICENSES/MPL-2.0.txt +0 -0
  99. {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.0.dist-info}/licenses/LICENSES/PSF-2.0.txt +0 -0
  100. {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.0.dist-info}/licenses/credits/CREDITS.md +0 -0
  101. {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,853 @@
1
+ """
2
+ GUI for motor test functionality.
3
+
4
+ This file implements the Tkinter frontend for the motor test sub-application following
5
+ the Model-View separation pattern defined in ARCHITECTURE_motor_test.md.
6
+
7
+ The MotorTestView class provides:
8
+ - Safety warnings and parameter configuration controls
9
+ - Frame type selection and motor diagram display
10
+ - Individual and sequential motor testing controls
11
+ - Real-time battery status monitoring
12
+ - SVG diagram rendering
13
+
14
+ MotorTestWindow class provides a standalone window for development/testing.
15
+
16
+ This file is part of Ardupilot methodic configurator. https://github.com/ArduPilot/MethodicConfigurator
17
+
18
+ SPDX-FileCopyrightText: 2024-2025 Amilcar do Carmo Lucas <amilcar.lucas@iav.de>
19
+
20
+ SPDX-License-Identifier: GPL-3.0-or-later
21
+ """
22
+
23
+ import time
24
+ import tkinter as tk
25
+ from argparse import ArgumentParser, Namespace
26
+ from functools import partial
27
+ from logging import debug as logging_debug
28
+ from logging import error as logging_error
29
+ from logging import info as logging_info
30
+ from logging import warning as logging_warning
31
+ from tkinter import Frame, Label, ttk
32
+ from tkinter.messagebox import askyesno, showerror, showwarning
33
+ from tkinter.simpledialog import askfloat
34
+ from typing import Callable, Optional, Union
35
+
36
+ from ardupilot_methodic_configurator import _
37
+ from ardupilot_methodic_configurator.__main__ import (
38
+ ApplicationState,
39
+ initialize_flight_controller_and_filesystem,
40
+ setup_logging,
41
+ )
42
+ from ardupilot_methodic_configurator.common_arguments import add_common_arguments
43
+ from ardupilot_methodic_configurator.data_model_motor_test import (
44
+ DURATION_S_MAX,
45
+ DURATION_S_MIN,
46
+ THROTTLE_PCT_MAX,
47
+ THROTTLE_PCT_MIN,
48
+ FrameConfigurationError,
49
+ MotorTestDataModel,
50
+ MotorTestExecutionError,
51
+ MotorTestSafetyError,
52
+ ParameterError,
53
+ ValidationError,
54
+ )
55
+ from ardupilot_methodic_configurator.frontend_tkinter_base_window import (
56
+ BaseWindow,
57
+ )
58
+ from ardupilot_methodic_configurator.frontend_tkinter_pair_tuple_combobox import PairTupleCombobox
59
+ from ardupilot_methodic_configurator.frontend_tkinter_progress_window import ProgressWindow
60
+ from ardupilot_methodic_configurator.frontend_tkinter_scroll_frame import ScrollFrame
61
+
62
+
63
+ class DelayedProgressCallback: # pylint: disable=too-few-public-methods
64
+ """A callback wrapper that delays the first progress update by a specified time."""
65
+
66
+ def __init__(self, original_callback: Callable[[int, int], None], delay_seconds: float) -> None:
67
+ """
68
+ Initialize the delayed callback.
69
+
70
+ Args:
71
+ original_callback: The original callback function to wrap
72
+ delay_seconds: Time in seconds to delay before showing progress
73
+
74
+ """
75
+ self.original_callback = original_callback
76
+ self.delay_seconds = delay_seconds
77
+ self.first_call_time: Optional[float] = None
78
+
79
+ def __call__(self, current: int, total: int) -> None:
80
+ """Execute the callback with delay logic."""
81
+ if self.first_call_time is None:
82
+ self.first_call_time = time.time()
83
+
84
+ # Only call the original callback if enough time has passed since the first call
85
+ elapsed_time = time.time() - self.first_call_time
86
+ if elapsed_time >= self.delay_seconds:
87
+ self.original_callback(current, total)
88
+
89
+
90
+ class MotorTestView(Frame): # pylint: disable=too-many-instance-attributes
91
+ """GUI for motor test functionality."""
92
+
93
+ def __init__(
94
+ self,
95
+ parent: Union[tk.Frame, ttk.Frame],
96
+ model: MotorTestDataModel,
97
+ base_window: BaseWindow,
98
+ ) -> None:
99
+ super().__init__(parent)
100
+ self.parent = parent
101
+ self.model = model
102
+ self.base_window = base_window
103
+ self.root_window = base_window.root # Keep for compatibility
104
+
105
+ # Define attributes
106
+ self.throttle_spinbox: ttk.Spinbox
107
+ self.duration_spinbox: ttk.Spinbox
108
+ self.frame_type_combobox: PairTupleCombobox
109
+ self.motor_buttons: list[ttk.Button] = []
110
+ self.motor_status_labels: list[ttk.Label] = [] # Status labels for visual feedback
111
+ self.detected_comboboxes: list[ttk.Combobox] = []
112
+ self.diagram_label: ttk.Label
113
+ self.batt_voltage_label: ttk.Label
114
+ self.batt_current_label: ttk.Label
115
+ # Store image reference (PNG or other format)
116
+ self._current_diagram_image: Optional[tk.PhotoImage] = None
117
+ self._first_motor_test = True # Track if this is the first motor test
118
+ self._frame_options_loaded = False # Track if frame options have been loaded
119
+ self._diagrams_path = "" # Cache diagram path for performance
120
+ self._diagram_needs_update = True # Track if diagram needs to be updated
121
+ self._content_frame: Optional[ttk.Frame] = None # Store reference to content frame for widget searches
122
+
123
+ self._create_widgets()
124
+
125
+ # Try to refresh frame configuration from flight controller
126
+ if not self.model.refresh_from_flight_controller():
127
+ logging_warning(_("Could not refresh frame configuration from flight controller, using defaults"))
128
+
129
+ self._update_view()
130
+
131
+ # Setup keyboard shortcuts for critical functions
132
+ self._setup_keyboard_shortcuts()
133
+
134
+ def _create_widgets(self) -> None: # pylint: disable=too-many-statements # noqa: PLR0915
135
+ """Create and place widgets in the frame."""
136
+ # Main frame
137
+ main_frame = ScrollFrame(self.parent)
138
+ main_frame.pack(fill="both", expand=True)
139
+ content_frame = main_frame.view_port
140
+ self._content_frame = content_frame # Store reference for later use
141
+
142
+ # --- Safety Warnings ---
143
+ warning_frame = ttk.LabelFrame(content_frame, text=_("Safety Warnings"))
144
+ warning_frame.pack(padx=10, pady=10, fill="x")
145
+ Label(
146
+ warning_frame,
147
+ text=_("PROPELLERS MUST BE REMOVED before proceeding!"),
148
+ fg="red",
149
+ font=("TkDefaultFont", 10, "bold"),
150
+ ).pack(pady=5)
151
+ ttk.Label(
152
+ warning_frame,
153
+ text=_("Ensure the vehicle is properly secured and cannot move."),
154
+ ).pack(pady=5)
155
+
156
+ # --- 1. Frame Configuration ---
157
+ config_frame = ttk.LabelFrame(content_frame, text=_("1. Frame Configuration"))
158
+ config_frame.pack(padx=10, pady=5, fill="x")
159
+
160
+ # Frame Type
161
+ frame_type_frame = ttk.Frame(config_frame)
162
+ frame_type_frame.pack(fill="x", pady=5)
163
+ ttk.Label(frame_type_frame, text=_("Frame Type:")).pack(side="left", padx=5)
164
+
165
+ # Create PairTupleCombobox with frame type pairs
166
+ frame_type_pairs = self.model.get_frame_type_pairs()
167
+ current_selection = self.model.get_current_frame_selection_key() if frame_type_pairs else None
168
+
169
+ self.frame_type_combobox = PairTupleCombobox(
170
+ frame_type_frame, frame_type_pairs, current_selection, "Frame Type", state="readonly"
171
+ )
172
+ self.frame_type_combobox.pack(side="left", padx=5, expand=True, fill="x")
173
+ self.frame_type_combobox.bind("<<ComboboxSelected>>", self._on_frame_type_change, add="+")
174
+
175
+ self.diagram_label = ttk.Label(config_frame, text=_("Loading diagram..."), anchor="center")
176
+ self.diagram_label.pack(pady=10)
177
+
178
+ # --- 2. Motor Order/Direction Configuration ---
179
+ testing_frame = ttk.LabelFrame(content_frame, text=_("2. Motor Order/Direction Configuration"))
180
+ testing_frame.pack(padx=10, pady=5, fill="x")
181
+
182
+ controls_frame = ttk.Frame(testing_frame)
183
+ controls_frame.pack(fill="x", pady=5)
184
+
185
+ ttk.Label(controls_frame, text=_("Throttle:")).pack(side="left", padx=4)
186
+ self.throttle_spinbox = ttk.Spinbox(
187
+ controls_frame, from_=THROTTLE_PCT_MIN, to=THROTTLE_PCT_MAX, increment=1, width=3, command=self._on_throttle_change
188
+ )
189
+ self.throttle_spinbox.pack(side="left", padx=2)
190
+ # Bind events to capture manual text entry completion
191
+ self.throttle_spinbox.bind("<Return>", lambda _: self._on_throttle_change())
192
+ self.throttle_spinbox.bind("<FocusOut>", lambda _: self._on_throttle_change())
193
+ ttk.Label(controls_frame, text="%").pack(side="left", padx=2)
194
+
195
+ ttk.Label(controls_frame, text=_("Duration:")).pack(side="left", padx=4)
196
+ self.duration_spinbox = ttk.Spinbox(
197
+ controls_frame, from_=DURATION_S_MIN, to=DURATION_S_MAX, increment=0.5, width=4, command=self._on_duration_change
198
+ )
199
+ self.duration_spinbox.pack(side="left", padx=2)
200
+ # Bind events to capture manual text entry completion
201
+ self.duration_spinbox.bind("<Return>", lambda _: self._on_duration_change())
202
+ self.duration_spinbox.bind("<FocusOut>", lambda _: self._on_duration_change())
203
+ ttk.Label(controls_frame, text="s").pack(side="left", padx=2)
204
+
205
+ self.batt_voltage_label = ttk.Label(controls_frame, text=_("Voltage: N/A"))
206
+ self.batt_voltage_label.pack(side="left", padx=10)
207
+ self.batt_current_label = ttk.Label(controls_frame, text=_("Current: N/A"))
208
+ self.batt_current_label.pack(side="left", padx=10)
209
+
210
+ motor_grid = ttk.Frame(testing_frame)
211
+ motor_grid.pack(pady=10)
212
+ self._create_motor_buttons(motor_grid)
213
+
214
+ # --- Test Controls ---
215
+ test_controls_frame = ttk.Frame(testing_frame)
216
+ test_controls_frame.pack(pady=10)
217
+ ttk.Button(test_controls_frame, text=_("Test All"), command=self._test_all_motors).pack(side="left", padx=5)
218
+ ttk.Button(
219
+ test_controls_frame,
220
+ text=_("Test in Sequence"),
221
+ command=self._test_motors_in_sequence,
222
+ ).pack(side="left", padx=5)
223
+ ttk.Button(test_controls_frame, text=_("Stop All Motors"), command=self._stop_all_motors).pack(side="right", padx=5)
224
+
225
+ # --- 3. Arm and Min Throttle Configuration ---
226
+ motor_params_frame = ttk.LabelFrame(content_frame, text=_("3. Arm and Min Throttle Configuration"))
227
+ motor_params_frame.pack(padx=10, pady=5, fill="x")
228
+
229
+ button_frame = ttk.Frame(motor_params_frame)
230
+ button_frame.pack(fill="x", pady=5)
231
+ ttk.Button(
232
+ button_frame,
233
+ text=_("Set Motor Spin Arm"),
234
+ command=self._set_motor_spin_arm,
235
+ ).pack(side="left", padx=5)
236
+ ttk.Button(
237
+ button_frame,
238
+ text=_("Set Motor Spin Min"),
239
+ command=self._set_motor_spin_min,
240
+ ).pack(side="left", padx=5)
241
+
242
+ def _create_motor_buttons(self, parent: Union[Frame, ttk.Frame]) -> None:
243
+ """Create the motor test buttons and detection comboboxes."""
244
+ motor_labels = self.model.motor_labels
245
+ motor_numbers = self.model.motor_numbers
246
+ motor_directions = self.model.motor_directions
247
+ for i in range(self.model.motor_count):
248
+ motor_number = motor_numbers[i]
249
+
250
+ motor_frame = ttk.Frame(parent)
251
+ motor_frame.grid(row=i // 4, column=i % 4, padx=10, pady=5)
252
+
253
+ def make_test_command(test_sequence_nr: int, motor_output_nr: int) -> Callable[[], None]:
254
+ return lambda: self._test_motor(test_sequence_nr, motor_output_nr)
255
+
256
+ button = ttk.Button(
257
+ motor_frame,
258
+ text=_("Test Motor %(label)s") % {"label": motor_labels[i]},
259
+ command=make_test_command(i, motor_number),
260
+ )
261
+ button.pack()
262
+ self.motor_buttons.append(button)
263
+
264
+ # Show motor number and expected direction
265
+ motor_text = _("Motor %(num)d %(dir)s") % {"num": motor_number, "dir": motor_directions[i]}
266
+ info_label = ttk.Label(motor_frame, text=motor_text)
267
+ info_label.pack()
268
+
269
+ ttk.Label(motor_frame, text=_("Detected:")).pack()
270
+ combo = ttk.Combobox(motor_frame, values=self.model.motor_labels, width=5)
271
+ combo.pack()
272
+ self.detected_comboboxes.append(combo)
273
+
274
+ # Add status label for visual feedback
275
+ status_label = ttk.Label(motor_frame, text=_("Ready"), foreground="blue")
276
+ status_label.pack()
277
+ self.motor_status_labels.append(status_label)
278
+
279
+ def _update_view(self) -> None:
280
+ """Update the view with data from the model."""
281
+ # Update diagram only when needed (not every second)
282
+ if self._diagram_needs_update:
283
+ self._update_diagram()
284
+ self._diagram_needs_update = False
285
+
286
+ self._update_motor_buttons_layout()
287
+ self._update_battery_status()
288
+ self._update_spinbox_values()
289
+ self.parent.after(1000, self._update_view) # Schedule periodic update
290
+
291
+ def _update_spinbox_values(self) -> None:
292
+ """Update spinbox values from the data model only if not currently being edited."""
293
+ try:
294
+ # Only update if the spinbox doesn't have focus (user is not editing)
295
+ if self.throttle_spinbox.focus_get() != self.throttle_spinbox:
296
+ throttle_pct = self.model.get_test_throttle_pct()
297
+ current_value = self.throttle_spinbox.get()
298
+ # Only update if the value has actually changed to avoid unnecessary updates
299
+ if current_value != str(throttle_pct):
300
+ self.throttle_spinbox.delete(0, "end")
301
+ self.throttle_spinbox.insert(0, str(throttle_pct))
302
+
303
+ # Only update if the spinbox doesn't have focus (user is not editing)
304
+ if self.duration_spinbox.focus_get() != self.duration_spinbox:
305
+ duration = self.model.get_test_duration_s()
306
+ current_value = self.duration_spinbox.get()
307
+ # Only update if the value has actually changed to avoid unnecessary updates
308
+ if current_value != str(duration):
309
+ self.duration_spinbox.delete(0, "end")
310
+ self.duration_spinbox.insert(0, str(duration))
311
+ except KeyError:
312
+ pass
313
+
314
+ def _update_motor_buttons_layout(self) -> None:
315
+ """Re-create motor buttons if motor count changes."""
316
+ current_count = len(self.motor_buttons)
317
+ required_count = self.model.motor_count
318
+
319
+ if current_count != required_count:
320
+ # Clear existing widgets
321
+ for button in self.motor_buttons:
322
+ button.master.destroy()
323
+ for combo in self.detected_comboboxes:
324
+ combo.master.destroy()
325
+ self.motor_buttons.clear()
326
+ self.motor_status_labels.clear()
327
+ self.detected_comboboxes.clear()
328
+
329
+ # Find the motor grid frame by searching for it
330
+ # This is more robust than hardcoded casting
331
+ def find_motor_grid(parent: Union[Frame, ttk.Frame]) -> Optional[Union[Frame, ttk.Frame]]:
332
+ """Find a suitable motor grid frame."""
333
+ for child in parent.winfo_children():
334
+ if isinstance(child, (Frame, ttk.Frame)):
335
+ # Check if this looks like our motor grid
336
+ grid_children = child.winfo_children()
337
+ if len(grid_children) == 0: # Empty frame ready for motor buttons
338
+ return child
339
+ # Recursively search
340
+ result = find_motor_grid(child)
341
+ if result:
342
+ return result
343
+ return None
344
+
345
+ # Find the testing frame and create new motor grid
346
+ testing_frame = None
347
+ if self._content_frame:
348
+ for child in self._content_frame.winfo_children():
349
+ try:
350
+ if hasattr(child, "cget") and "Motor Order/Direction Configuration" in str(child.cget("text")):
351
+ testing_frame = child
352
+ break
353
+ except tk.TclError:
354
+ # Widget doesn't have a "text" option, skip it
355
+ continue
356
+
357
+ if testing_frame and isinstance(testing_frame, (Frame, ttk.Frame)):
358
+ motor_grid = find_motor_grid(testing_frame)
359
+ if motor_grid:
360
+ self._create_motor_buttons(motor_grid)
361
+ else:
362
+ logging_error(_("Could not find motor grid frame"))
363
+ else:
364
+ logging_error(_("Could not find testing frame"))
365
+
366
+ def _load_png_diagram(self, diagram_path: str) -> None:
367
+ """Load and display a PNG motor diagram using BaseWindow.put_image_in_label()."""
368
+ logging_debug(_("Found PNG diagram at: %(path)s"), {"path": diagram_path})
369
+
370
+ try:
371
+ # Create a temporary parent frame for the label creation
372
+ temp_frame = ttk.Frame(self.diagram_label.master)
373
+
374
+ # Use BaseWindow's method with a reasonable height for motor diagrams
375
+ new_label = self.base_window.put_image_in_label(
376
+ parent=temp_frame,
377
+ filepath=diagram_path,
378
+ image_height=230, # Target height for motor diagrams
379
+ fallback_text=_("Error loading diagram"),
380
+ )
381
+
382
+ # Copy the image and text from the new label to our existing label
383
+ image_ref = getattr(new_label, "image", None)
384
+ if image_ref:
385
+ self.diagram_label.configure(image=image_ref, text="")
386
+ # Keep reference to prevent garbage collection
387
+ self._current_diagram_image = image_ref
388
+ logging_debug(_("Image loaded and displayed successfully"))
389
+ else:
390
+ # Fallback case - use the text from the new label
391
+ self.diagram_label.configure(image="", text=new_label.cget("text"))
392
+ self._current_diagram_image = None
393
+
394
+ # Clean up temporary frame
395
+ temp_frame.destroy()
396
+
397
+ except FileNotFoundError:
398
+ logging_error(_("Image file not found: %s"), diagram_path)
399
+ self.diagram_label.configure(image="", text=_("Diagram not found"))
400
+ self._current_diagram_image = None
401
+ except (OSError, ValueError, TypeError, AttributeError) as e:
402
+ logging_error(_("Error loading PNG diagram: %(error)s"), {"error": e})
403
+ self.diagram_label.configure(image="", text=_("Error loading diagram"))
404
+ self._current_diagram_image = None
405
+
406
+ def _update_diagram(self) -> None:
407
+ """Update the motor diagram image."""
408
+ self.diagram_label.configure(image="", text=_("Loading diagram..."))
409
+
410
+ if self.model.motor_diagram_exists():
411
+ if self._diagrams_path:
412
+ diagram_path = self._diagrams_path
413
+ error_msg = ""
414
+ else:
415
+ diagram_path, error_msg = self.model.get_motor_diagram_path()
416
+ self._diagrams_path = diagram_path
417
+
418
+ # Debug logging to understand the issue
419
+ logging_debug(
420
+ _("Diagram path type: %(type)s, value: %(path)s"),
421
+ {"type": type(diagram_path).__name__, "path": repr(diagram_path)},
422
+ )
423
+
424
+ if diagram_path and isinstance(diagram_path, str) and diagram_path.endswith(".png"):
425
+ self._load_png_diagram(diagram_path)
426
+ elif error_msg:
427
+ logging_error(error_msg)
428
+ self.diagram_label.configure(image="", text=error_msg)
429
+ else:
430
+ self.diagram_label.configure(image="", text=_("Diagram: %(path)s") % {"path": diagram_path})
431
+ else:
432
+ self.diagram_label.configure(image="", text=_("Motor diagram not available."))
433
+
434
+ def _update_battery_status(self) -> None:
435
+ """Update battery voltage and current labels."""
436
+ voltage_text, current_text = self.model.get_battery_display_text()
437
+
438
+ self.batt_voltage_label.config(text=voltage_text)
439
+ self.batt_current_label.config(text=current_text)
440
+
441
+ # Update color based on voltage status
442
+ color = self.model.get_battery_status_color()
443
+ self.batt_voltage_label.config(foreground=color)
444
+
445
+ def _on_frame_type_change(self, _event: object) -> None:
446
+ """Handle frame type selection change and immediately upload parameters."""
447
+ # Get the selected frame type code from PairTupleCombobox
448
+ selected_key = self.frame_type_combobox.get_selected_key()
449
+ if selected_key is None:
450
+ logging_warning(_("No frame type selected"))
451
+ return
452
+
453
+ # Convert the selected key to the format expected by the data model
454
+ # The data model expects the display text (type name), not the code
455
+ current_frame_types = self.model.get_current_frame_class_types()
456
+ selected_type_code = int(selected_key)
457
+ selected_text = current_frame_types.get(selected_type_code, f"Type {selected_type_code}")
458
+
459
+ logging_info(_("Frame type changed: %(code)s (%(name)s)"), {"code": selected_key, "name": selected_text})
460
+
461
+ try:
462
+ # Create delayed progress windows that only show if operation takes more than 1 second
463
+ reset_progress_window = ProgressWindow(
464
+ self.root_window,
465
+ _("Resetting Flight Controller"),
466
+ _("Waiting for {} of {} seconds"),
467
+ only_show_when_update_progress_called=True,
468
+ )
469
+ connection_progress_window = ProgressWindow(
470
+ self.root_window,
471
+ _("Re-Connecting to Flight Controller"),
472
+ _("Waiting for {} of {} seconds"),
473
+ only_show_when_update_progress_called=True,
474
+ )
475
+
476
+ # Create delayed callback wrappers that wait 1 second before showing progress
477
+ reset_callback = DelayedProgressCallback(reset_progress_window.update_progress_bar, 1.0)
478
+ connection_callback = DelayedProgressCallback(connection_progress_window.update_progress_bar, 1.0)
479
+
480
+ self.model.update_frame_type_from_selection(
481
+ selected_text,
482
+ reset_callback,
483
+ connection_callback,
484
+ extra_sleep_time=2,
485
+ )
486
+ reset_progress_window.destroy() # for the case that we are doing a test and there is no real FC connected
487
+ connection_progress_window.destroy() # for the case that we are doing a test and there is no real FC connected
488
+
489
+ # Invalidate diagram cache since frame type changed
490
+ self._diagrams_path = ""
491
+ self._diagram_needs_update = True
492
+
493
+ # Update UI components
494
+ self._update_motor_buttons_layout()
495
+
496
+ except (ValidationError, ParameterError, FrameConfigurationError) as e:
497
+ showerror(_("Parameter Update Error"), str(e))
498
+
499
+ def _on_throttle_change(self) -> None:
500
+ """Handle throttle spinbox change."""
501
+ logging_debug(_("_on_throttle_change called with value: %(val)s"), {"val": self.throttle_spinbox.get()})
502
+ try:
503
+ throttle_pct = int(float(self.throttle_spinbox.get()))
504
+ self.model.set_test_throttle_pct(throttle_pct)
505
+ logging_debug(_("Throttle set to %(pct)d%%"), {"pct": throttle_pct})
506
+ except ValueError as e:
507
+ # Invalid value entered, reset to model value
508
+ logging_warning(_("ValueError in _on_throttle_change: %(error)s"), {"error": str(e)})
509
+ throttle_pct = self.model.get_test_throttle_pct()
510
+ self.throttle_spinbox.delete(0, "end")
511
+ self.throttle_spinbox.insert(0, str(throttle_pct))
512
+ showerror(
513
+ _("Throttle value error"), _("Invalid throttle value entered, reset to %(pct)d%%") % {"pct": throttle_pct}
514
+ )
515
+ except (ValidationError, ParameterError) as e:
516
+ # Model validation failed, reset to current model value
517
+ logging_warning(_("ValidationError/ParameterError in _on_throttle_change: %(error)s"), {"error": str(e)})
518
+ throttle_pct = self.model.get_test_throttle_pct()
519
+ self.throttle_spinbox.delete(0, "end")
520
+ self.throttle_spinbox.insert(0, str(throttle_pct))
521
+ showerror(_("Throttle value error"), _("Throttle validation failed: %(error)s") % {"error": str(e)})
522
+
523
+ def _on_duration_change(self) -> None:
524
+ """Handle duration spinbox change."""
525
+ logging_debug(_("_on_duration_change called with value: %(val)s"), {"val": self.duration_spinbox.get()})
526
+ try:
527
+ duration = float(self.duration_spinbox.get())
528
+ self.model.set_test_duration_s(duration)
529
+ logging_debug(_("Duration set to %(dur)g seconds"), {"dur": duration})
530
+ except ValueError as e:
531
+ # Invalid value entered, reset to model value
532
+ logging_warning(_("ValueError in _on_duration_change: %(error)s"), {"error": str(e)})
533
+ duration = self.model.get_test_duration_s()
534
+ self.duration_spinbox.delete(0, "end")
535
+ self.duration_spinbox.insert(0, str(duration))
536
+ showerror(
537
+ _("Duration value error"), _("Invalid duration value entered, reset to %(dur)g seconds") % {"dur": duration}
538
+ )
539
+ except (ValidationError, ParameterError) as e:
540
+ # Model validation failed, reset to current model value
541
+ logging_warning(_("ValidationError/ParameterError in _on_duration_change: %(error)s"), {"error": str(e)})
542
+ duration = self.model.get_test_duration_s()
543
+ self.duration_spinbox.delete(0, "end")
544
+ self.duration_spinbox.insert(0, str(duration))
545
+ showerror(_("Duration value error"), _("Duration validation failed: %(error)s") % {"error": str(e)})
546
+
547
+ def _set_motor_spin_arm(self) -> None:
548
+ """Open a dialog to set MOT_SPIN_ARM."""
549
+ # Simple dialog for now, should be a custom Toplevel
550
+ current_val = self.model.get_parameter("MOT_SPIN_ARM")
551
+ new_val = askfloat(
552
+ _("Set Motor Spin Arm"),
553
+ _("Enter new value for MOT_SPIN_ARM with 0.02 margin:"),
554
+ initialvalue=current_val,
555
+ )
556
+ if new_val is not None:
557
+ try:
558
+ reset_progress_window = ProgressWindow(
559
+ self.root_window, _("Resetting Flight Controller"), _("Waiting for {} of {} seconds")
560
+ )
561
+ self.model.set_parameter("MOT_SPIN_ARM", new_val, reset_progress_window.update_progress_bar)
562
+ reset_progress_window.destroy() # for the case that we are doing a test and there is no real FC connected
563
+ except (ParameterError, ValidationError) as e:
564
+ showerror(_("Error"), str(e))
565
+
566
+ def _set_motor_spin_min(self) -> None:
567
+ """Open a dialog to set MOT_SPIN_MIN."""
568
+ current_val = self.model.get_parameter("MOT_SPIN_MIN")
569
+ new_val = askfloat(
570
+ _("Set Motor Spin Min"),
571
+ _("Enter new value for MOT_SPIN_MIN, must be at least 0.02 higher than MOT_SPIN_ARM:"),
572
+ initialvalue=current_val,
573
+ minvalue=0.0,
574
+ maxvalue=1.0,
575
+ )
576
+ if new_val is not None:
577
+ try:
578
+ self.model.set_parameter("MOT_SPIN_MIN", new_val)
579
+ except (ParameterError, ValidationError) as e:
580
+ showerror(_("Error"), str(e))
581
+
582
+ def _test_motor(self, test_sequence_nr: int, motor_output_nr: int) -> None:
583
+ """Execute a test for a single motor."""
584
+ logging_debug(
585
+ _("Testing motor %(seq)s at motor output %(num)d"),
586
+ {"seq": self.model.motor_labels[test_sequence_nr], "num": motor_output_nr},
587
+ )
588
+
589
+ # First-time safety confirmation
590
+ if self._first_motor_test and self.model.should_show_first_test_warning():
591
+ if not askyesno(_("Safety Confirmation"), self.model.get_safety_warning_message()):
592
+ return
593
+ self._first_motor_test = False
594
+
595
+ try:
596
+ # Check if motor test is safe (includes voltage checks)
597
+ self.model.is_motor_test_safe()
598
+
599
+ # Validate test parameters
600
+ throttle_pct = self.model.get_test_throttle_pct()
601
+ duration = int(self.model.get_test_duration_s())
602
+
603
+ # Execute motor test
604
+ self.model.test_motor(test_sequence_nr, motor_output_nr, throttle_pct, duration)
605
+
606
+ self._update_motor_status(motor_output_nr, _("Command sent"), "green")
607
+
608
+ # Reset status after a short delay
609
+ self.root_window.after(2000, partial(self._update_motor_status, motor_output_nr, _("Ready"), "blue"))
610
+
611
+ except MotorTestSafetyError as e:
612
+ # Check if it's a voltage issue and provide specific guidance
613
+ if self.model.is_battery_related_safety_issue(str(e)):
614
+ showwarning(_("Battery Voltage Warning"), self.model.get_battery_safety_message(str(e)))
615
+ else:
616
+ showwarning(_("Safety Check Failed"), str(e))
617
+ self._update_motor_status(motor_output_nr, _("Safety Check Failed"), "red")
618
+ except ValidationError as e:
619
+ showerror(_("Parameter Validation Error"), str(e))
620
+ self._update_motor_status(motor_output_nr, _("Invalid Parameters"), "red")
621
+ except MotorTestExecutionError as e:
622
+ self._update_motor_status(motor_output_nr, _("Test Failed"), "red")
623
+ showerror(_("Error"), str(e))
624
+ except Exception as e: # pylint: disable=broad-exception-caught
625
+ self._update_motor_status(motor_output_nr, _("Error"), "red")
626
+ showerror(_("Unexpected Error"), str(e))
627
+
628
+ def _test_all_motors(self) -> None:
629
+ """Execute a test for all motors simultaneously."""
630
+ logging_debug(_("Testing all motors"))
631
+
632
+ # First-time safety confirmation
633
+ if self._first_motor_test and self.model.should_show_first_test_warning():
634
+ if not askyesno(_("Safety Confirmation"), self.model.get_safety_warning_message()):
635
+ return
636
+ self._first_motor_test = False
637
+
638
+ try:
639
+ # Check if motor test is safe
640
+ self.model.is_motor_test_safe()
641
+
642
+ # Validate test parameters
643
+ throttle_pct = self.model.get_test_throttle_pct()
644
+ duration = int(self.model.get_test_duration_s())
645
+
646
+ # Execute all motors test
647
+ self.model.test_all_motors(throttle_pct, duration)
648
+
649
+ for motor_number in range(1, self.model.motor_count + 1):
650
+ self._update_motor_status(motor_number, _("Command sent"), "green")
651
+
652
+ # Reset status after a short delay
653
+ self.root_window.after(
654
+ 2000,
655
+ partial(self._update_motor_status, motor_number, _("Ready"), "blue"),
656
+ )
657
+
658
+ except MotorTestSafetyError as e:
659
+ showwarning(_("Safety Check Failed"), str(e))
660
+ except ValidationError as e:
661
+ showerror(_("Parameter Validation Error"), str(e))
662
+ except MotorTestExecutionError as e:
663
+ showerror(_("Error"), str(e))
664
+ except Exception as e: # pylint: disable=broad-exception-caught
665
+ showerror(_("Unexpected Error"), str(e))
666
+
667
+ def _test_motors_in_sequence(self) -> None:
668
+ """Execute a test for all motors in sequence."""
669
+ logging_debug(_("Testing motors in sequence"))
670
+
671
+ # First-time safety confirmation
672
+ if self._first_motor_test and self.model.should_show_first_test_warning():
673
+ if not askyesno(_("Safety Confirmation"), self.model.get_safety_warning_message()):
674
+ return
675
+ self._first_motor_test = False
676
+
677
+ try:
678
+ # Check if motor test is safe
679
+ self.model.is_motor_test_safe()
680
+
681
+ # Validate test parameters
682
+ throttle_pct = self.model.get_test_throttle_pct()
683
+ duration = int(self.model.get_test_duration_s())
684
+
685
+ # Execute sequential test
686
+ self.model.test_motors_in_sequence(throttle_pct, duration)
687
+
688
+ for motor_number in range(1, self.model.motor_count + 1):
689
+ self._update_motor_status(motor_number, _("Command sent"), "green")
690
+
691
+ # Reset status after a short delay
692
+ self.root_window.after(
693
+ 2000,
694
+ partial(self._update_motor_status, motor_number, _("Ready"), "blue"),
695
+ )
696
+
697
+ except MotorTestSafetyError as e:
698
+ showwarning(_("Safety Check Failed"), str(e))
699
+ except ValidationError as e:
700
+ showerror(_("Parameter Validation Error"), str(e))
701
+ except MotorTestExecutionError as e:
702
+ showerror(_("Error"), str(e))
703
+ except Exception as e: # pylint: disable=broad-exception-caught
704
+ showerror(_("Unexpected Error"), str(e))
705
+
706
+ def _stop_all_motors(self) -> None:
707
+ """Stop all motors immediately."""
708
+ logging_info(_("Stopping all motors"))
709
+
710
+ try:
711
+ self.model.stop_all_motors()
712
+ for motor_number in range(1, self.model.motor_count + 1):
713
+ self._update_motor_status(motor_number, _("Stop sent"), "red")
714
+ self.root_window.after(2000, self._reset_all_motor_status)
715
+ except MotorTestExecutionError as e:
716
+ showerror(_("Error"), str(e))
717
+ except Exception as e: # pylint: disable=broad-exception-caught
718
+ showerror(_("Unexpected Error"), str(e))
719
+
720
+ def _emergency_stop(self) -> None:
721
+ """Emergency stop - alias for _stop_all_motors for test compatibility."""
722
+ self._stop_all_motors()
723
+
724
+ def _update_motor_status(self, motor_number: int, status: str, color: str = "black") -> None:
725
+ """
726
+ Update visual status for a specific motor.
727
+
728
+ Args:
729
+ motor_number: Motor number (1-based)
730
+ status: Status text to display
731
+ color: Text color for the status
732
+
733
+ """
734
+ if 1 <= motor_number <= len(self.motor_status_labels):
735
+ label = self.motor_status_labels[self.model.test_order(motor_number)]
736
+ label.config(text=status, foreground=color)
737
+ label.update_idletasks() # Force GUI update
738
+
739
+ def _reset_all_motor_status(self) -> None:
740
+ """Reset all motor status labels to 'Ready'."""
741
+ for label in self.motor_status_labels:
742
+ label.config(text=_("Ready"), foreground="blue")
743
+
744
+ def _setup_keyboard_shortcuts(self) -> None:
745
+ """Setup keyboard shortcuts for critical motor test functions."""
746
+ # Emergency stop (Escape key)
747
+ self.root_window.bind("<Escape>", lambda _: self._stop_all_motors())
748
+ self.root_window.bind("<Control-s>", lambda _: self._stop_all_motors())
749
+
750
+ # Test all motors (Ctrl+A)
751
+ self.root_window.bind("<Control-a>", lambda _: self._test_all_motors())
752
+
753
+ # Test in sequence (Ctrl+Q)
754
+ self.root_window.bind("<Control-q>", lambda _: self._test_motors_in_sequence())
755
+
756
+ # Focus root window to ensure it can capture key events
757
+ self.root_window.focus_set()
758
+
759
+
760
+ class MotorTestWindow(BaseWindow):
761
+ """
762
+ Standalone window for the motor test GUI.
763
+
764
+ Used for development and testing.
765
+ """
766
+
767
+ def __init__(self, model: MotorTestDataModel) -> None:
768
+ super().__init__()
769
+ self.model = model # Store model reference for tests
770
+ self.root.title(_("ArduPilot Motor Test"))
771
+ width = self.calculate_scaled_image_size(400)
772
+ height = self.calculate_scaled_image_size(610)
773
+ self.root.geometry(str(width) + "x" + str(height))
774
+
775
+ self.view = MotorTestView(self.main_frame, model, self)
776
+
777
+ self.root.protocol("WM_DELETE_WINDOW", self.on_close)
778
+
779
+ def on_close(self) -> None:
780
+ """Handle window close event."""
781
+ # Attempt to stop any running tests gracefully
782
+ try:
783
+ self.view.model.stop_all_motors()
784
+ except MotorTestExecutionError:
785
+ # Some frame types (like "No torque yaw") may not support motor commands
786
+ # This is expected and we should just log it without showing an error
787
+ logging_debug(_("Motor stop command failed during shutdown - this is normal for some frame types"))
788
+ except Exception as e: # pylint: disable=broad-exception-caught
789
+ # Log other unexpected exceptions but don't prevent shutdown
790
+ logging_warning(_("Unexpected error during motor stop at shutdown: %(error)s"), {"error": str(e)})
791
+
792
+ self.root.destroy()
793
+
794
+
795
+ def argument_parser() -> Namespace:
796
+ """
797
+ Parses command-line arguments for the script.
798
+
799
+ This function sets up an argument parser to handle the command-line arguments for the script.
800
+ This is just for testing the script. Production code will not call this function.
801
+
802
+ Returns:
803
+ argparse.Namespace: An object containing the parsed arguments.
804
+
805
+ """
806
+ # The rest of the file should not have access to any of these backends.
807
+ # It must use the data_model layer instead of accessing the backends directly.
808
+ # pylint: disable=import-outside-toplevel
809
+ from ardupilot_methodic_configurator.backend_filesystem import LocalFilesystem # noqa: PLC0415
810
+ from ardupilot_methodic_configurator.backend_flightcontroller import FlightController # noqa: PLC0415
811
+ # pylint: enable=import-outside-toplevel
812
+
813
+ parser = ArgumentParser(
814
+ description=_(
815
+ "This main is for testing and development only. Usually, the MotorTestView is called from another script"
816
+ )
817
+ )
818
+ parser = FlightController.add_argparse_arguments(parser)
819
+ parser = LocalFilesystem.add_argparse_arguments(parser)
820
+ return add_common_arguments(parser).parse_args()
821
+
822
+
823
+ # pylint: disable=duplicate-code
824
+ def main() -> None:
825
+ args = argument_parser()
826
+
827
+ state = ApplicationState(args)
828
+
829
+ setup_logging(state)
830
+
831
+ logging_warning(
832
+ _("This main is for testing and development only, usually the MotorTestView is called from another script")
833
+ )
834
+
835
+ # Initialize flight controller and filesystem
836
+ initialize_flight_controller_and_filesystem(state)
837
+
838
+ try:
839
+ data_model = MotorTestDataModel(state.flight_controller, state.local_filesystem)
840
+ window = MotorTestWindow(data_model)
841
+ window.root.mainloop()
842
+
843
+ except Exception as e: # pylint: disable=broad-exception-caught
844
+ logging_error("Failed to start MotorTestWindow: %(error)s", {"error": e})
845
+ # Show error to user
846
+ showerror(_("Error"), f"Failed to start Motor Test: {e}")
847
+ finally:
848
+ if state.flight_controller:
849
+ state.flight_controller.disconnect() # Disconnect from the flight controller
850
+
851
+
852
+ if __name__ == "__main__":
853
+ main()