ardupilot-methodic-configurator 2.5.0__py3-none-any.whl → 2.6.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.
- ardupilot_methodic_configurator/__init__.py +1 -1
- ardupilot_methodic_configurator/backend_filesystem_program_settings.py +15 -20
- ardupilot_methodic_configurator/configuration_manager.py +113 -1
- ardupilot_methodic_configurator/data_model_configuration_step.py +40 -3
- ardupilot_methodic_configurator/frontend_tkinter_base_window.py +4 -0
- ardupilot_methodic_configurator/frontend_tkinter_component_editor_base.py +1 -1
- ardupilot_methodic_configurator/frontend_tkinter_motor_test.py +853 -0
- ardupilot_methodic_configurator/frontend_tkinter_parameter_editor.py +8 -12
- ardupilot_methodic_configurator/frontend_tkinter_parameter_editor_table.py +78 -108
- ardupilot_methodic_configurator/frontend_tkinter_usage_popup_window.py +11 -6
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500/07_esc.param +1 -1
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500/42_system_id_roll.param +5 -3
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500/43_system_id_pitch.param +3 -3
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500/44_system_id_yaw.param +1 -1
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500/vehicle.jpg +0 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500/vehicle_components.json +3 -4
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/05_remote_controller.param +7 -7
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/06_telemetry.param +2 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/07_esc.param +6 -6
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/10_gnss.param +4 -4
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/13_general_configuration.param +5 -5
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/15_motor.param +1 -1
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/16_pid_adjustment.param +10 -10
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/18_notch_filter_setup.param +3 -3
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/20_throttle_controller.param +1 -1
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/22_quick_tune_setup.param +1 -1
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/26_quick_tune_setup.param +2 -2
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/30_autotune_roll_setup.param +1 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/34_autotune_yaw_setup.param +1 -1
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/37_autotune_yawd_results.param +1 -1
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/Holybro_X500_V2/vehicle.jpg +0 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/00_default.param +1352 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/02_imu_temperature_calibration_setup.param +8 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/03_imu_temperature_calibration_results.param +42 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/04_board_orientation.param +4 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/05_remote_controller.param +13 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/06_telemetry.param +4 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/07_esc.param +43 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/08_batt1.param +15 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/10_gnss.param +11 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/11_initial_atc.param +18 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/12_mp_setup_mandatory_hardware.param +99 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/13_general_configuration.param +17 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/14_logging.param +6 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/15_motor.param +4 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/16_pid_adjustment.param +13 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/17_remote_id.param +4 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/18_notch_filter_setup.param +10 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/19_notch_filter_results.param +7 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/20_throttle_controller.param +5 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/21_ekf_config.param +2 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/22_quick_tune_setup.param +14 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/23_quick_tune_results.param +10 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/24_inflight_magnetometer_fit_setup.param +8 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/24_inflight_magnetometer_fit_setup.pdef.xml +57 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/25_inflight_magnetometer_fit_results.param +15 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/26_quick_tune_setup.param +14 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/27_quick_tune_results.param +10 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/28_evaluate_the_aircraft_tune_ff_disable.param +4 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/29_evaluate_the_aircraft_tune_ff_enable.param +1 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/30_autotune_roll_setup.param +2 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/31_autotune_roll_results.param +5 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/32_autotune_pitch_setup.param +2 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/33_autotune_pitch_results.param +5 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/34_autotune_yaw_setup.param +3 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/35_autotune_yaw_results.param +5 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/36_autotune_yawd_setup.param +3 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/37_autotune_yawd_results.param +5 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/38_autotune_roll_pitch_retune_setup.param +2 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/39_autotune_roll_pitch_retune_results.param +10 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/40_windspeed_estimation.param +5 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/41_barometer_compensation.param +7 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/42_system_id_roll.param +21 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/43_system_id_pitch.param +10 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/44_system_id_yaw.param +10 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/45_system_id_thrust.param +10 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/46_analytical_pid_optimization.param +4 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/47_position_controller.param +13 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/48_guided_operation.param +4 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/49_precision_land.param +27 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/50_optical_flow_setup.param +19 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/51_optical_flow_results.param +3 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/52_use_optical_flow_instead_of_gnss.param +8 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/53_everyday_use.param +7 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/vehicle.jpg +0 -0
- ardupilot_methodic_configurator/vehicle_templates/ArduCopter/TarotFY680Hexacopter/vehicle_components.json +188 -0
- {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.1.dist-info}/METADATA +74 -134
- {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.1.dist-info}/RECORD +101 -43
- {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.1.dist-info}/WHEEL +0 -0
- {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.1.dist-info}/entry_points.txt +0 -0
- {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.1.dist-info}/licenses/LICENSE.md +0 -0
- {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.1.dist-info}/licenses/LICENSES/Apache-2.0.txt +0 -0
- {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.1.dist-info}/licenses/LICENSES/BSD-3-Clause.txt +0 -0
- {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.1.dist-info}/licenses/LICENSES/GPL-3.0-or-later.txt +0 -0
- {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.1.dist-info}/licenses/LICENSES/LGPL-3.0-or-later.txt +0 -0
- {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.1.dist-info}/licenses/LICENSES/MIT-CMU.txt +0 -0
- {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.1.dist-info}/licenses/LICENSES/MIT.txt +0 -0
- {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.1.dist-info}/licenses/LICENSES/MPL-2.0.txt +0 -0
- {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.1.dist-info}/licenses/LICENSES/PSF-2.0.txt +0 -0
- {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.1.dist-info}/licenses/credits/CREDITS.md +0 -0
- {ardupilot_methodic_configurator-2.5.0.dist-info → ardupilot_methodic_configurator-2.6.1.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()
|