forked from ArduPilot/MethodicConfigurator
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfrontend_tkinter_component_editor.py
More file actions
executable file
·332 lines (269 loc) · 15.2 KB
/
frontend_tkinter_component_editor.py
File metadata and controls
executable file
·332 lines (269 loc) · 15.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
#!/usr/bin/env python3
"""
Data-dependent part of the component editor GUI.
This file is part of ArduPilot Methodic Configurator. https://github.com/ArduPilot/MethodicConfigurator
SPDX-FileCopyrightText: 2024-2026 Amilcar do Carmo Lucas <amilcar.lucas@iav.de>
SPDX-License-Identifier: GPL-3.0-or-later
"""
import tkinter as tk
from argparse import ArgumentParser, Namespace
# from logging import debug as logging_debug
# from logging import info as logging_info
from logging import basicConfig as logging_basicConfig
from logging import getLevelName as logging_getLevelName
from tkinter import ttk
from typing import Union
from ardupilot_methodic_configurator import _, __version__
from ardupilot_methodic_configurator.backend_filesystem import LocalFilesystem
from ardupilot_methodic_configurator.common_arguments import add_common_arguments
from ardupilot_methodic_configurator.data_model_vehicle_components_base import ComponentPath
from ardupilot_methodic_configurator.data_model_vehicle_components_validation import (
BATTERY_CELL_VOLTAGE_PATHS,
FC_CONNECTION_TYPE_PATHS,
get_connection_type_tuples_with_labels,
)
from ardupilot_methodic_configurator.frontend_tkinter_component_editor_base import ComponentEditorWindowBase
from ardupilot_methodic_configurator.frontend_tkinter_pair_tuple_combobox import PairTupleCombobox
# from ardupilot_methodic_configurator.frontend_tkinter_show import show_tooltip
from ardupilot_methodic_configurator.frontend_tkinter_show import show_error_message, show_warning_message
def argument_parser() -> Namespace:
"""
Parses command-line arguments for the script.
This function sets up an argument parser to handle the command-line arguments for the script.
Returns:
argparse.Namespace: An object containing the parsed arguments.
"""
# pylint: disable=duplicate-code
parser = ArgumentParser(
description=_(
"A GUI for editing JSON files that contain vehicle component configurations. "
"Not to be used directly, but through the main ArduPilot methodic configurator script."
)
)
parser = LocalFilesystem.add_argparse_arguments(parser)
parser = ComponentEditorWindow.add_argparse_arguments(parser)
return add_common_arguments(parser).parse_args()
# pylint: enable=duplicate-code
class ComponentEditorWindow(ComponentEditorWindowBase):
"""Validates the user input and handles user interactions for editing component configurations."""
def __init__(self, version: str, local_filesystem: LocalFilesystem) -> None:
ComponentEditorWindowBase.__init__(self, version, local_filesystem)
# when only read from file and no FC is connected
mcu = self.data_model.get_component_value(("Flight Controller", "Specifications", "MCU Series"))
if mcu and isinstance(mcu, str):
self.set_mcu_series(mcu)
def set_vehicle_type_and_version(self, vehicle_type: str, version: str) -> None:
"""Set the vehicle type and version in the data model."""
# Update UI if widgets exist
self.set_component_value_and_update_ui(("Flight Controller", "Firmware", "Type"), vehicle_type)
if version:
self.set_component_value_and_update_ui(("Flight Controller", "Firmware", "Version"), version)
def set_fc_manufacturer(self, manufacturer: str) -> None:
"""Set the flight controller manufacturer if it's valid."""
# Update UI if widget exists
if self.data_model.is_fc_manufacturer_valid(manufacturer):
self.set_component_value_and_update_ui(("Flight Controller", "Product", "Manufacturer"), manufacturer)
def set_fc_model(self, model: str) -> None:
"""Set the flight controller model if it's valid."""
# Update UI if widget exists
if self.data_model.is_fc_model_valid(model):
self.set_component_value_and_update_ui(("Flight Controller", "Product", "Model"), model)
def set_mcu_series(self, mcu: str) -> None:
"""Set the MCU series in the data model."""
# Update UI if widget exists
if mcu:
self.set_component_value_and_update_ui(("Flight Controller", "Specifications", "MCU Series"), mcu)
if mcu.upper() in ("STM32F4XX", "STM32F7XX", "STM32H7XX"):
self.data_model.schema.modify_schema_for_mcu_series(is_optional=True)
def update_component_protocol_combobox_entries(self, component_path: ComponentPath, connection_type: str) -> str:
"""Updates the Protocol combobox entries based on the selected component connection Type."""
self.data_model.set_component_value(component_path, connection_type)
# when the connection Type changes, we need to update the Protocol combobox entries
protocol_path: ComponentPath = (component_path[0], component_path[1], "Protocol")
return self.update_protocol_combobox_entries(
self.data_model.get_combobox_values_for_path(protocol_path), protocol_path
)
def update_protocol_combobox_entries(self, protocols: tuple[str, ...], protocol_path: ComponentPath) -> str:
err_msg = ""
if protocol_path in self.entry_widgets:
protocol_combobox = self.entry_widgets[protocol_path]
# Only update if this is actually a PairTupleCombobox (protocol comboboxes should be)
if isinstance(protocol_combobox, PairTupleCombobox):
# Rebuild the (key, display) pairs for PairTupleCombobox
protocol_tuples = [(p, p) for p in protocols]
# Get current selection and validate it against new protocols
current_selection = protocol_combobox.get_selected_key()
if current_selection and current_selection not in protocols:
# Current selection is not valid for new protocols, clear it
invalid_selection = current_selection
current_selection = None
component: str = " > ".join(protocol_path)
err_msg = _(
"On {component} the selected\nprotocol '{invalid_selection}' "
"is not available for the selected connection Type."
)
err_msg = err_msg.format(component=component, invalid_selection=invalid_selection)
# Update the combobox entries using set_entries_tuple with validated selection
protocol_combobox.set_entries_tuple(protocol_tuples, current_selection)
if err_msg:
show_error_message(_("Error"), err_msg)
protocol_combobox.configure(style="comb_input_invalid.TCombobox")
protocol_combobox.update_idletasks() # re-draw the combobox ASAP
return err_msg
def update_cell_voltage_limits_entries(self, component_path: ComponentPath, chemistry: str) -> str:
"""
Updates the cell voltage limits entries based on the selected battery chemistry.
This method updates the max, low, and crit voltages for the battery based on the selected chemistry.
"""
if self.data_model.get_component_value(component_path) == chemistry:
return ""
show_warning_message(
_("Warning"),
_(
"Will update the cell voltage limits to the recommended\n"
"values for {chemistry} chemistry.\n"
"This will overwrite any custom values you may have set."
).format(chemistry=chemistry),
)
# this will trigger the data_model to update the voltages for the selected chemistry
self.data_model.set_component_value(component_path, chemistry)
err_msg = ""
if component_path in self.entry_widgets:
for voltage_path in BATTERY_CELL_VOLTAGE_PATHS:
voltage_entry = self.entry_widgets[voltage_path]
value = self.data_model.get_component_value(voltage_path)
if value is not None:
voltage_entry.delete(0, tk.END)
voltage_entry.insert(0, str(value))
voltage_entry.configure(style="entry_input_valid.TEntry")
else:
err_msg += _("No valid value found for {voltage_path} with chemistry {chemistry}.\n").format(
voltage_path=voltage_path, chemistry=chemistry
)
return err_msg
def add_entry_or_combobox(
self, value: Union[str, float], entry_frame: ttk.Frame, path: ComponentPath, is_optional: bool = False
) -> Union[ttk.Entry, PairTupleCombobox]:
# Get combobox values from data model
combobox_values = self.data_model.get_combobox_values_for_path(path)
# Determine foreground color based on is_optional flag
fg_color = "gray" if is_optional else "black"
def on_validate_combobox(event: tk.Event) -> bool:
return self._validate_combobox(event, path)
if combobox_values:
# Convert all combobox values to tuples for PairTupleCombobox
# For FC Connection Type paths, add bus labels; for others, use value as both key and display
if path in FC_CONNECTION_TYPE_PATHS:
combobox_tuples = get_connection_type_tuples_with_labels(combobox_values)
else:
combobox_tuples = [(val, val) for val in combobox_values]
# Always use PairTupleCombobox for consistency and simplicity
cb = PairTupleCombobox(
entry_frame,
combobox_tuples,
str(value) if value else None,
f"{' > '.join(path)}",
)
cb.config(foreground=fg_color)
cb.config(width=20)
cb.bind("<FocusOut>", on_validate_combobox)
cb.bind("<KeyRelease>", on_validate_combobox)
cb.bind("<Return>", on_validate_combobox)
cb.bind("<ButtonRelease>", on_validate_combobox)
# Override the FocusOut binding to also handle validation
def combined_focus_out(event: tk.Event) -> None:
# First handle the dropdown closing logic (already done by setup function)
# Then handle validation
self._validate_combobox(event, path)
cb.bind("<FocusOut>", combined_focus_out, add="+")
if path in FC_CONNECTION_TYPE_PATHS:
# immediate update of Protocol combobox choices after changing connection Type selection
cb.bind(
"<<ComboboxSelected>>",
lambda _event: self.update_component_protocol_combobox_entries(path, cb.get_selected_key() or ""),
)
# When battery chemistry changes, the max, low and crit voltages will change to the
# recommended values for the new chemistry, so we need to update the UI
if path == ("Battery", "Specifications", "Chemistry"):
cb.bind(
"<<ComboboxSelected>>",
lambda _event: self.update_cell_voltage_limits_entries(path, cb.get_selected_key() or ""),
)
return cb
entry = ttk.Entry(entry_frame, foreground=fg_color)
def on_validate_entry_limits_ui(event: tk.Event) -> bool:
return self._validate_entry_limits_ui(event, entry, path)
entry.bind("<FocusOut>", on_validate_entry_limits_ui)
entry.bind("<KeyRelease>", on_validate_entry_limits_ui)
entry.bind("<Return>", on_validate_entry_limits_ui)
entry.insert(0, str(value))
return entry
def _validate_combobox(self, event: tk.Event, path: ComponentPath) -> bool:
"""Validates the value of a PairTupleCombobox."""
combobox = event.widget # Get the combobox widget that triggered the event
if not isinstance(combobox, PairTupleCombobox):
return False
# Get the selected key (internal value)
value = combobox.get_selected_key() or ""
# Get allowed values from list_keys
allowed_values = combobox.list_keys
# Events that should trigger data model update (when value is valid)
should_update_data_model = event.type in {
tk.EventType.FocusOut,
tk.EventType.KeyPress, # Return key
tk.EventType.KeyRelease, # Key release events
tk.EventType.ButtonRelease, # Mouse click on dropdown item
}
if should_update_data_model and value in allowed_values:
self.data_model.set_component_value(path, value)
if value not in allowed_values:
if ( # this is complicated because we only want to issue error messages in particular cases
(event.type == tk.EventType.FocusOut and getattr(combobox, "dropdown_is_open", False)) # FocusOut events
or event.type == tk.EventType.KeyPress # KeyPress event (Return key)
):
paths_str = ">".join(list(path))
allowed_str = ", ".join(allowed_values)
error_msg = _("Invalid value '{value}' for {paths_str}\nAllowed values are: {allowed_str}")
show_error_message(_("Error"), error_msg.format(value=value, paths_str=paths_str, allowed_str=allowed_str))
combobox.configure(style="comb_input_invalid.TCombobox")
return False
combobox.configure(style="comb_input_valid.TCombobox")
return True
def _validate_entry_limits_ui(self, event: Union[None, tk.Event], entry: ttk.Entry, path: ComponentPath) -> bool:
"""UI wrapper for entry limits validation."""
is_focusout_event = event and event.type in {
tk.EventType.FocusOut,
tk.EventType.KeyPress, # Return key generates KeyPress event
}
value = entry.get()
error_msg, corrected_value = self.data_model.validate_entry_limits(value, path)
if error_msg:
if is_focusout_event:
if corrected_value is not None:
entry.delete(0, tk.END)
entry.insert(0, str(corrected_value))
paths_str = ">".join(list(path))
error_msg = _("Invalid value '{value}' for {paths_str}\n{error_msg}").format(
value=value, paths_str=paths_str, error_msg=error_msg
)
show_error_message(_("Error"), error_msg)
entry.configure(style="entry_input_invalid.TEntry")
return False
if is_focusout_event:
self.data_model.set_component_value(path, value)
entry.configure(style="entry_input_valid.TEntry")
return True
# pylint: disable=duplicate-code
if __name__ == "__main__": # pragma: no cover
args = argument_parser()
logging_basicConfig(level=logging_getLevelName(args.loglevel), format="%(asctime)s - %(levelname)s - %(message)s")
filesystem = LocalFilesystem(
args.vehicle_dir, args.vehicle_type, "", args.allow_editing_template_files, args.save_component_to_system_templates
)
component_editor_window = ComponentEditorWindow(__version__, filesystem)
component_editor_window.populate_frames()
if args.skip_component_editor:
component_editor_window.root.after(10, component_editor_window.root.destroy)
component_editor_window.validate_data_and_highlight_errors_in_red()
component_editor_window.root.mainloop()
# pylint: enable=duplicate-code