forked from ArduPilot/MethodicConfigurator
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfrontend_tkinter_parameter_editor.py
More file actions
executable file
·1220 lines (1037 loc) · 56.1 KB
/
frontend_tkinter_parameter_editor.py
File metadata and controls
executable file
·1220 lines (1037 loc) · 56.1 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
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
Parameter 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
"""
from __future__ import annotations
import contextlib
import tkinter as tk
from argparse import ArgumentParser, Namespace
# from logging import debug as logging_debug
from logging import basicConfig as logging_basicConfig
from logging import error as logging_error
from logging import exception as logging_exception
from logging import getLevelName as logging_getLevelName
from logging import warning as logging_warning
from sys import exit as sys_exit
from sys import platform as sys_platform
from tkinter import filedialog, ttk
from typing import TYPE_CHECKING, Callable, Optional, Protocol, Union, cast
# from logging import critical as logging_critical
from ardupilot_methodic_configurator import _, __version__
from ardupilot_methodic_configurator.backend_filesystem import LocalFilesystem
from ardupilot_methodic_configurator.backend_filesystem_freedesktop import FreeDesktop
from ardupilot_methodic_configurator.backend_filesystem_program_settings import ProgramSettings
from ardupilot_methodic_configurator.backend_flightcontroller import FlightController
from ardupilot_methodic_configurator.common_arguments import add_common_arguments
from ardupilot_methodic_configurator.data_model_parameter_editor import ExperimentChoice, ParameterEditor
from ardupilot_methodic_configurator.frontend_tkinter_about_popup_window import show_about_window
from ardupilot_methodic_configurator.frontend_tkinter_autoresize_combobox import AutoResizeCombobox
from ardupilot_methodic_configurator.frontend_tkinter_base_window import (
BaseWindow,
ask_retry_cancel_popup,
ask_yesno_popup,
show_error_popup,
show_info_popup,
show_warning_popup,
)
from ardupilot_methodic_configurator.frontend_tkinter_directory_selection import VehicleDirectorySelectionWidgets
from ardupilot_methodic_configurator.frontend_tkinter_font import get_safe_font_config
from ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_documentation_frame import DocumentationFrame
from ardupilot_methodic_configurator.frontend_tkinter_parameter_editor_table import ParameterEditorTable
from ardupilot_methodic_configurator.frontend_tkinter_progress_window import ProgressWindow
from ardupilot_methodic_configurator.frontend_tkinter_rich_text import get_widget_font_family_and_size
from ardupilot_methodic_configurator.frontend_tkinter_show import show_tooltip
from ardupilot_methodic_configurator.frontend_tkinter_stage_progress import StageProgressBar
from ardupilot_methodic_configurator.frontend_tkinter_usage_popup_window import UsagePopupWindow
from ardupilot_methodic_configurator.frontend_tkinter_usage_popup_windows import (
display_parameter_editor_usage_popup,
only_upload_changed_parameters_usage_popup,
)
from ardupilot_methodic_configurator.plugin_factory import plugin_factory
if TYPE_CHECKING:
from ardupilot_methodic_configurator.data_model_par_dict import ParDict
# pylint: disable=too-many-lines
class _PaneConfigurable(Protocol): # pylint: disable=too-few-public-methods
"""Subset of PanedWindow API needed for type-safe pane configuration."""
def paneconfigure(self, pane: tk.Widget, **kwargs: object) -> None: ...
class ParameterEditorUiServices: # pylint: disable=too-many-instance-attributes
"""Container for UI dependencies injected into the parameter editor window."""
def __init__( # pylint: disable=too-many-arguments, too-many-positional-arguments
self,
create_progress_window: Callable[[tk.Misc, str, str, bool], ProgressWindow],
ask_yesno: Callable[[str, str], bool],
ask_retry_cancel: Callable[[str, str], bool],
show_warning: Callable[[str, str], None],
show_error: Callable[[str, str], None],
show_info: Callable[[str, str], None],
asksaveasfilename: Callable[..., str],
askopenfilename: Callable[..., str],
exit_callback: Callable[[int], None],
) -> None:
self.create_progress_window = create_progress_window
self.ask_yesno = ask_yesno
self.ask_retry_cancel = ask_retry_cancel
self.show_warning = show_warning
self.show_error = show_error
self.show_info = show_info
self.asksaveasfilename = asksaveasfilename
self.askopenfilename = askopenfilename
self.sys_exit = exit_callback
@classmethod
def default(cls) -> ParameterEditorUiServices:
"""Create UI services backed by the real Tkinter helpers."""
def _create_progress_window(
parent: tk.Misc,
title: str,
template: str,
only_show_when_update_called: bool = False,
) -> ProgressWindow:
return ProgressWindow(parent, title, template, only_show_when_update_progress_called=only_show_when_update_called)
return cls(
create_progress_window=_create_progress_window,
ask_yesno=ask_yesno_popup,
ask_retry_cancel=ask_retry_cancel_popup,
show_warning=show_warning_popup,
show_error=show_error_popup,
show_info=show_info_popup,
asksaveasfilename=filedialog.asksaveasfilename,
askopenfilename=filedialog.askopenfilename,
exit_callback=sys_exit,
)
def upload_params_with_progress(
self,
parent_window: tk.Misc,
upload_callback: Callable[..., None],
selected_params: dict,
) -> None:
"""
Handle parameter upload with progress windows.
This method centralizes the common pattern of:
1. Creating reset and download progress window callbacks
2. Calling the upload workflow with all required callbacks
3. Cleaning up progress windows in a finally block
Args:
parent_window: The parent window for progress dialogs
upload_callback: The upload workflow function to call
selected_params: Dictionary of parameters to upload
"""
upload_progress_window: ProgressWindow | None = None
reset_progress_window: ProgressWindow | None = None
connection_progress_window: ProgressWindow | None = None
download_progress_window: ProgressWindow | None = None
def get_upload_progress_callback() -> Callable[[int, int], None] | None:
"""Create and return progress window callback for parameter upload only when needed."""
nonlocal upload_progress_window
show_only_on_update = True
upload_progress_window = self.create_progress_window(
parent_window,
_("Uploading FC parameters"),
_("Uploaded {} of {} parameters"),
show_only_on_update,
)
return upload_progress_window.update_progress_bar
def get_reset_progress_callback() -> Callable[[int, int], None] | None:
"""Create and return progress window callback for FC reset only when needed."""
nonlocal reset_progress_window
show_only_on_update = True
reset_progress_window = self.create_progress_window(
parent_window,
_("Resetting Flight Controller"),
_("Waiting for {} of {} seconds"),
show_only_on_update,
)
return reset_progress_window.update_progress_bar
def get_connection_progress_callback() -> Callable[[int, int], None] | None:
"""Create and return progress window callback for FC connection only when needed."""
nonlocal connection_progress_window
show_only_on_update = True
connection_progress_window = self.create_progress_window(
parent_window,
_("Reconnecting to Flight Controller"),
_("{} of {} percent"),
show_only_on_update,
)
return connection_progress_window.update_progress_bar
def get_download_progress_callback() -> Callable[[int, int], None] | None:
"""Create and return progress window callback for parameter download only when needed."""
nonlocal download_progress_window
show_immediately = False
download_progress_window = self.create_progress_window(
parent_window,
_("Re-downloading FC parameters"),
_("Downloaded {} of {} parameters"),
show_immediately,
)
return download_progress_window.update_progress_bar
try:
upload_callback(
selected_params,
ask_confirmation=self.ask_yesno,
ask_retry_cancel=self.ask_retry_cancel,
show_error=self.show_error,
get_upload_progress_callback=get_upload_progress_callback,
get_reset_progress_callback=get_reset_progress_callback,
get_connection_progress_callback=get_connection_progress_callback,
get_download_progress_callback=get_download_progress_callback,
)
finally:
# Clean up progress windows if they were created
if upload_progress_window is not None:
upload_progress_window.destroy()
if reset_progress_window is not None:
reset_progress_window.destroy()
if connection_progress_window is not None:
connection_progress_window.destroy()
if download_progress_window is not None:
download_progress_window.destroy()
class ParameterEditorWindow(BaseWindow): # pylint: disable=too-many-instance-attributes
"""
Parameter editor and upload graphical user interface (GUI) window.
It inherits from the BaseWindow class and displays documentation and edit widgets to
operate on drone parameters.
"""
def __init__(
self,
parameter_editor: ParameterEditor,
ui_services: ParameterEditorUiServices | None = None,
) -> None:
super().__init__()
self.parameter_editor = parameter_editor
self.ui = ui_services or ParameterEditorUiServices.default()
self.file_selection_combobox: AutoResizeCombobox
self.show_only_differences: tk.BooleanVar
self.annotate_params_into_files: tk.BooleanVar
self.parameter_editor_table: ParameterEditorTable
self.skip_button: ttk.Button
self.gui_complexity = str(ProgramSettings.get_setting("gui_complexity"))
self.parameter_area_container: ttk.Frame
self.current_plugin: dict | None = None
self.current_plugin_view: object | None = None # Plugin view instance (implements PluginView protocol)
self.parameter_area_paned: tk.PanedWindow | None = None
self.parameter_container: ttk.Frame
self._tempcal_imu_progress_window: ProgressWindow | None = None
self.file_upload_progress_window: ProgressWindow | None = None
self._param_download_progress_window: ProgressWindow | None = None
self.root.title(
_("Amilcar Lucas's - ArduPilot methodic configurator ") + __version__ + _(" - Parameter file editor and uploader")
)
self.root.geometry("990x630") # Set the window width and height
# Bind the close_connection_and_quit function to the window close event
self.root.protocol("WM_DELETE_WINDOW", self.close_connection_and_quit)
# Note: Entry and Combobox styles are configured in BaseWindow._setup_theme_and_styling()
self._create_conf_widgets(__version__)
last_step_nr = self.parameter_editor.get_last_configuration_step_number()
if last_step_nr is not None:
phases = self.parameter_editor.get_sorted_phases_with_end_and_weight(last_step_nr)
self.stage_progress_bar = StageProgressBar(self.main_frame, phases, last_step_nr, self.gui_complexity)
self.stage_progress_bar.pack(side=tk.TOP, fill="x", expand=False, pady=(2, 2), padx=(4, 4))
# Create a DocumentationFrame object for the Documentation Content
self.documentation_frame = DocumentationFrame(self.main_frame, self.parameter_editor)
self.documentation_frame.documentation_frame.pack(side=tk.TOP, fill="x", expand=False, pady=(2, 2), padx=(4, 4))
self._create_parameter_area_widgets()
# Resize window height to ensure all widgets, including the skip button, are fully visible
# as some Linux Window managers like KDE, like to change font sizes and padding.
# So we need to dynamically accommodate for that after placing the widgets
self.root.update_idletasks()
req_height = self.root.winfo_reqheight()
self.root.geometry(f"990x{req_height}")
# Set up startup notification for the main application window
FreeDesktop.setup_startup_notification(self.root) # type: ignore[arg-type]
# trigger a table update to ask the user what to do in the case this file needs special actions
self.root.after(10, lambda: self.on_param_file_combobox_change(None, forced=True))
# this one should be on top of the previous one hence the longer time
if isinstance(self.root, tk.Tk) and UsagePopupWindow.should_display("parameter_editor"):
self.root.after(100, lambda: display_parameter_editor_usage_popup(cast("tk.Tk", self.root)))
def run(self) -> None:
"""
Start the GUI main event loop.
This method should be called after instantiation to start the GUI.
Separated from __init__ to allow for testing and more flexible initialization.
"""
self.root.mainloop()
def _create_conf_widgets(self, version: str) -> None:
config_frame = ttk.Frame(self.main_frame)
config_frame.pack(side=tk.TOP, fill="x", expand=False, pady=(4, 0)) # Pack the frame at the top of the window
config_subframe = ttk.Frame(config_frame)
config_subframe.pack(side=tk.LEFT, fill="x", expand=True, anchor=tk.NW) # Pack the frame at the top of the window
# Create a new frame inside the config_subframe for the intermediate parameter file directory selection labels
# and directory selection button
directory_selection_frame = VehicleDirectorySelectionWidgets(
self,
config_subframe,
self.parameter_editor.get_vehicle_directory(),
destroy_parent_on_open=False,
)
if self.gui_complexity != "simple":
directory_selection_frame.container_frame.pack(side=tk.LEFT, fill="x", expand=False, padx=(4, 6))
# Create a new frame inside the config_subframe for the intermediate parameter file selection label and combobox
file_selection_frame = ttk.Frame(config_subframe)
if self.gui_complexity != "simple":
file_selection_frame.pack(side=tk.LEFT, fill="x", expand=False, padx=(6, 6))
# Create a label for the Combobox
file_selection_label = ttk.Label(file_selection_frame, text=_("Current intermediate parameter file:"))
if self.gui_complexity != "simple":
file_selection_label.pack(side=tk.TOP, anchor=tk.NW) # Add the label to the top of the file_selection_frame
# Create Combobox for intermediate parameter file selection
self.file_selection_combobox = AutoResizeCombobox(
file_selection_frame,
self.parameter_editor.parameter_files(),
self.parameter_editor.current_file,
_(
"Select the intermediate parameter file from the list of available"
" files in the selected vehicle directory\nIt will automatically "
"advance to the next file once the current file is uploaded to the "
"fight controller"
),
state="readonly",
width=45,
style="readonly.TCombobox",
)
self.file_selection_combobox.bind("<<ComboboxSelected>>", self.on_param_file_combobox_change)
if self.gui_complexity != "simple": # only display the combobox when not simple
self.file_selection_combobox.pack(side=tk.TOP, anchor=tk.NW, pady=(4, 0))
self.legend_frame(config_subframe)
image_label = self.put_image_in_label(config_frame, LocalFilesystem.application_logo_filepath())
image_label.pack(side=tk.RIGHT, anchor=tk.NE, padx=(4, 4), pady=(4, 0))
image_label.bind("<Button-1>", lambda event: show_about_window(self.main_frame, version)) # noqa: ARG005
show_tooltip(image_label, _("User Manual, Support Forum, Report a Bug, Licenses, Source Code"))
def legend_frame(self, config_subframe: ttk.Frame) -> None: # pylint: disable=too-many-locals
font_family, font_size = get_widget_font_family_and_size(config_subframe)
style = ttk.Style()
style.configure("Legend.TLabelframe", font=(font_family, font_size))
legend_frame = ttk.LabelFrame(config_subframe, text=_("Legend"), style="Legend.TLabelframe")
legend_left = ttk.Frame(legend_frame)
legend_left.pack(side=tk.LEFT, anchor=tk.NW)
show_tooltip(legend_frame, _("the meaning of the text background colors"), position_below=False)
font = (font_family, font_size - 1 if font_size > 0 else font_size + 1)
np_label = ttk.Label(legend_left, text=_("Normal parameter"), font=font)
show_tooltip(np_label, _("Normal parameter - reusable in similar vehicles"))
np_label.pack(side=tk.TOP, anchor=tk.NW)
cal_label = ttk.Label(legend_left, text=_("Calibration param"), background="yellow", font=font)
show_tooltip(cal_label, _("Calibration parameter - not-reusable, even in similar vehicles"))
cal_label.pack(side=tk.TOP, anchor=tk.NW)
readonly_label = ttk.Label(legend_left, text=_("Read-only param"), background="purple1", font=font)
show_tooltip(readonly_label, _("Read-only parameter - not writable nor changeable"))
readonly_label.pack(side=tk.TOP, anchor=tk.NW)
toolow_label = ttk.Label(legend_left, text=_("Below limit"), background="orangered", font=font)
show_tooltip(toolow_label, _("Parameter value below the minimum recommended value"))
toolow_label.pack(side=tk.TOP, anchor=tk.NW)
legend_right = ttk.Frame(legend_frame)
legend_right.pack(side=tk.RIGHT, anchor=tk.NE)
default_label = ttk.Label(legend_right, text=_("Default value"), background="lightblue", font=font)
show_tooltip(default_label, _("This is the default value of this parameter"))
default_label.pack(side=tk.TOP, anchor=tk.NW)
na_label = ttk.Label(legend_right, text=_("Not available"), background="orange", font=font)
show_tooltip(na_label, _("This parameter is not available on the connected flight controller"))
na_label.pack(side=tk.TOP, anchor=tk.NW)
ne_label = ttk.Label(legend_right, text=_("Not editable"), font=font)
show_tooltip(
ne_label,
_(
"This value has been automatically calculated by the software using data\n"
"from the component editor window or from the 'configuration_steps.json' file"
),
)
ne_label.configure(state="disabled")
ne_label.pack(side=tk.TOP, anchor=tk.NW)
toohigh_label = ttk.Label(legend_right, text=_("Above limit"), background="red3", font=font)
show_tooltip(toohigh_label, _("Parameter value above the maximum recommended value"))
toohigh_label.pack(side=tk.TOP, anchor=tk.NW)
legend_frame.pack(side=tk.LEFT, fill="x", expand=False, padx=(2, 2))
def _create_parameter_area_widgets(self) -> None:
self.show_only_differences = tk.BooleanVar(value=False)
self.annotate_params_into_files = tk.BooleanVar(
value=bool(ProgramSettings.get_setting("annotate_docs_into_param_files"))
)
# Container allows hot-swapping between plugin and non-plugin layouts
# without destroying the parent main_frame structure. This enables dynamic
# UI reconfiguration when switching between parameter files with different plugin requirements.
self.parameter_area_container = ttk.Frame(self.main_frame)
self.parameter_area_container.pack(side="top", fill="both", expand=True)
# Track current plugin state to enable efficient layout switching and lifecycle management
self.current_plugin = None
self.parameter_container = self.parameter_area_container
# Create the scrollable parameter editor table in the container
self.parameter_editor_table = ParameterEditorTable(self.parameter_container, self.parameter_editor, self)
# Table will be populated by the scheduled on_param_file_combobox_change callback
self.parameter_editor_table.pack(side="top", fill="both", expand=True)
# Create a frame for the buttons
buttons_frame = ttk.Frame(self.main_frame)
buttons_frame.pack(side="bottom", fill="x", expand=False, pady=(10, 10))
# Create a frame for the checkboxes
checkboxes_frame = ttk.Frame(buttons_frame)
checkboxes_frame.pack(side=tk.LEFT, padx=(8, 8))
# Create a checkbox for toggling parameter display
only_changed_checkbox = ttk.Checkbutton(
checkboxes_frame,
text=_("See only changed parameters"),
variable=self.show_only_differences,
command=self.on_show_only_changed_checkbox_change,
)
if self.gui_complexity != "simple":
only_changed_checkbox.pack(side=tk.TOP, anchor=tk.NW)
show_tooltip(
only_changed_checkbox,
_("Toggle to show only parameters that will change if/when uploaded to the flight controller"),
)
annotate_params_checkbox = ttk.Checkbutton(
checkboxes_frame,
text=_("Annotate docs into .param files"),
state="normal" if self.parameter_editor.parameter_documentation_available() else "disabled",
variable=self.annotate_params_into_files,
command=lambda: ProgramSettings.set_setting(
"annotate_docs_into_param_files", self.annotate_params_into_files.get()
),
)
if self.gui_complexity != "simple":
annotate_params_checkbox.pack(side=tk.TOP, anchor=tk.NW)
show_tooltip(
annotate_params_checkbox,
_(
"Annotate ArduPilot parameter documentation metadata into the intermediate parameter files\n"
"The files will be bigger, but all the existing parameter documentation will be included inside"
),
)
# Create upload button
upload_selected_button = ttk.Button(
buttons_frame,
text=_("Upload selected params to FC,\nand advance to next param file"),
command=self.on_upload_selected_click,
)
upload_selected_button.configure(state="normal" if self.parameter_editor.is_fc_connected else "disabled")
upload_selected_button.pack(side=tk.LEFT, padx=(8, 8)) # Add padding on both sides of the upload selected button
show_tooltip(
upload_selected_button,
_(
"Upload selected parameters to the flight controller and advance to the next "
"intermediate parameter file\nIf changes have been made to the current file it will ask if you want "
"to save them\nIt will reset the FC if necessary, re-download all parameters and validate their value"
)
if self.parameter_editor.is_fc_connected
else _("No flight controller connected, upload not available"),
)
# Create download last flight log button
download_log_button = ttk.Button(
buttons_frame,
text=_("Download last flight log"),
command=self.on_download_last_flight_log_click,
)
download_log_button.configure(
state=(
"normal"
if (self.parameter_editor.is_fc_connected and self.parameter_editor.is_mavftp_supported)
else "disabled"
)
)
download_log_button.pack(side=tk.LEFT, padx=(8, 8)) # Add padding on both sides of the download log button
show_tooltip(
download_log_button,
_(
"Download the last flight log from the flight controller\n"
"This will save the previous flight log to a file on your computer for analysis"
)
if (self.parameter_editor.is_fc_connected and self.parameter_editor.is_mavftp_supported)
else _("No flight controller connected or MAVFTP not supported"),
)
# Create Zip file for forum button
zip_vehicle_for_forum_button = ttk.Button(
buttons_frame,
text=_("Zip Vehicle for Forum Help"),
command=self.on_zip_vehicle_for_forum_help_click,
)
zip_vehicle_for_forum_button.configure(state=("normal" if self.parameter_editor.parameter_files() else "disabled"))
zip_vehicle_for_forum_button.pack(
side=tk.LEFT, padx=(8, 8)
) # Add padding on both sides of the Create Zip file for forum button
show_tooltip(
zip_vehicle_for_forum_button,
_("Creates a .zip file of the configuration files\nso that they can be easily shared for forum help")
if self.parameter_editor.parameter_files()
else _("No intermediate parameter files available"),
)
# Create skip button
self.skip_button = ttk.Button(buttons_frame, text=_("Skip parameter file"), command=self.on_skip_click)
self.skip_button.configure(
state=(
"normal"
if self.gui_complexity != "simple"
or self.parameter_editor.is_configuration_step_optional()
or not self.parameter_editor.is_fc_connected
else "disabled"
)
)
self.skip_button.pack(side=tk.RIGHT, padx=(8, 8)) # Add right padding to the skip button
show_tooltip(
self.skip_button,
_(
"Skip to the next intermediate parameter file without uploading any changes to the flight "
"controller\nIf changes have been made to the current file it will ask if you want to save them"
),
)
def _cleanup_plugin_views(self) -> None:
"""Clean up existing plugin views and UI elements."""
# Call deactivation hook on current plugin before cleanup
if self.current_plugin_view is not None and hasattr(self.current_plugin_view, "on_deactivate"):
try:
self.current_plugin_view.on_deactivate() # pyright: ignore[reportAttributeAccessIssue]
except (AttributeError, TypeError) as e:
logging_warning(_("Error deactivating plugin: %s"), e)
# Clean up existing plugin view
if self.current_plugin_view is not None:
try:
self.current_plugin_view.destroy() # type: ignore[attr-defined]
except (AttributeError, tk.TclError) as e:
logging_warning(_("Error destroying plugin view: %s"), e)
finally:
self.current_plugin_view = None
# Note: parameter_editor_table doesn't need explicit destroy()
# It will be automatically destroyed when its parent container is destroyed
# Clean up existing paned window
if self.parameter_area_paned is not None:
try:
self.parameter_area_paned.destroy()
except (AttributeError, tk.TclError) as e:
logging_warning(_("Error destroying paned window: %s"), e)
finally:
self.parameter_area_paned = None
def _update_plugin_layout(self, plugin: Optional[dict]) -> None: # noqa: UP045
"""
Update the plugin layout based on the current plugin configuration.
This method handles the complete lifecycle of plugin views:
- Calls deactivation hook on current plugin
- Destroys existing plugin views and UI elements
- Creates new layout based on plugin placement
- Instantiates new plugin view
- Calls activation hook on new plugin
- Recreates parameter table
Args:
plugin: Plugin configuration dict with 'name' and 'placement' keys, or None
"""
# If plugin configuration hasn't changed at all, do nothing
if self.current_plugin == plugin:
return
# Determine old and new placements
old_placement = self.current_plugin.get("placement") if self.current_plugin else None
new_placement = plugin.get("placement") if plugin else None
# Optimization: If both old and new are None (no plugin), avoid any widget destruction
if old_placement is None and new_placement is None:
self.current_plugin = plugin
return
# If layout structure is the same, just swap the plugin content without rebuilding
if old_placement == new_placement and old_placement is not None and plugin is not None:
# Same layout structure, just swap plugin
self._swap_plugin_in_place(plugin)
return
# Layout structure is different - need full rebuild
self._rebuild_plugin_layout(plugin)
def _swap_plugin_in_place(self, plugin: dict) -> None: # pylint: disable=too-many-branches
"""
Swap plugin content without rebuilding the entire layout.
This is an optimization for when the layout structure (left/top) doesn't change,
only the plugin itself changes.
Args:
plugin: New plugin configuration dict
"""
# Deactivate and destroy old plugin
if self.current_plugin_view is not None:
if hasattr(self.current_plugin_view, "on_deactivate"):
try:
self.current_plugin_view.on_deactivate() # pyright: ignore[reportAttributeAccessIssue]
except (AttributeError, TypeError) as e:
logging_warning(_("Error deactivating plugin: %s"), e)
try:
self.current_plugin_view.destroy() # type: ignore[attr-defined]
except (AttributeError, tk.TclError) as e:
logging_warning(_("Error destroying plugin view: %s"), e)
finally:
self.current_plugin_view = None
# Locate the existing plugin parent frame based on placement strategy.
# This avoids full layout rebuild when only the plugin content needs updating.
placement = plugin.get("placement")
plugin_parent: ttk.Frame | None = None
if placement == "left" and self.parameter_area_paned is not None:
# Left placement uses a PanedWindow with plugin in the first (left) pane
panes: tuple[str, ...] = self.parameter_area_paned.panes() # type: ignore[no-untyped-call]
if panes:
# Convert Tcl pane path to widget reference
plugin_parent = self.parameter_area_paned.nametowidget(panes[0])
elif placement == "top":
# Top placement uses a vertical stack with plugin frame at the top
# Navigate the widget tree to find the plugin container
for child in self.parameter_area_container.winfo_children():
if isinstance(child, ttk.Frame):
# Located the top_container frame
children = child.winfo_children()
if children and isinstance(children[0], ttk.Frame):
plugin_parent = children[0]
break
if plugin_parent:
# Clear the plugin parent frame
for widget in plugin_parent.winfo_children():
widget.destroy()
# Load new plugin into existing frame
self._load_plugin(plugin_parent, plugin)
self.current_plugin = plugin
def _rebuild_plugin_layout(self, plugin: dict | None) -> None:
"""
Completely rebuild the plugin layout structure.
This is only called when the layout structure actually changes
(e.g., from no plugin to left plugin, or from left to top).
Args:
plugin: New plugin configuration dict or None
"""
# Clean up existing views
self._cleanup_plugin_views()
# Complete rebuild: destroy old layout and create new structure from scratch
self.parameter_area_container.destroy()
# Recreate container to hold the new layout configuration
self.parameter_area_container = ttk.Frame(self.main_frame)
self.parameter_area_container.pack(side="top", fill="both", expand=True)
self.current_plugin = plugin
if plugin and plugin.get("placement") == "left":
# Left placement: Create horizontal split with plugin on left, parameters on right
# This gives the plugin persistent visibility while working with parameters
self.parameter_area_paned = tk.PanedWindow(self.parameter_area_container, orient=tk.HORIZONTAL)
self.parameter_area_paned.pack(side="top", fill="both", expand=True)
# Create left pane for plugin with minimum width to ensure visibility
left_frame = ttk.Frame(self.parameter_area_paned)
self.parameter_area_paned.add(left_frame, minsize=500, stretch="always")
self._set_pane_sticky(self.parameter_area_paned, left_frame)
# Instantiate and display the plugin in the left frame
self._load_plugin(left_frame, plugin)
# Create right pane for parameter table (gets remaining space)
right_frame = ttk.Frame(self.parameter_area_paned)
self.parameter_area_paned.add(right_frame, stretch="always")
self._set_pane_sticky(self.parameter_area_paned, right_frame)
self.parameter_container = right_frame
elif plugin and plugin.get("placement") == "top":
# Top placement: Create vertical stack with plugin above parameters
# This gives plugin full horizontal width, useful for wide controls
top_container = ttk.Frame(self.parameter_area_container)
top_container.pack(side="top", fill="both", expand=True)
# Create top frame for plugin (fixed height, expands horizontally only)
top_plugin_frame = ttk.Frame(top_container)
top_plugin_frame.pack(side="top", fill="both", expand=True)
self._load_plugin(top_plugin_frame, plugin)
# Visual separator between plugin and parameter table
ttk.Separator(top_container, orient="horizontal").pack(side="top", fill="x", pady=2)
# Create bottom frame for parameter table (takes remaining vertical space)
bottom_frame = ttk.Frame(top_container)
bottom_frame.pack(side="top", fill="both", expand=True)
self.parameter_container = bottom_frame
else:
# No plugin configured: use simple single-frame layout
# Parameter table directly fills the entire container
self.parameter_container = self.parameter_area_container
# Recreate the parameter editor table in the appropriate container.
# The container varies based on plugin placement (left pane, below plugin, or full area).
self.parameter_editor_table = ParameterEditorTable(self.parameter_container, self.parameter_editor, self)
self.parameter_editor_table.pack(side="top", fill="both", expand=True)
@staticmethod
def _set_pane_sticky(paned_window: tk.PanedWindow, pane: tk.Widget) -> None:
"""Type-safe helper to configure pane stickiness on paned windows."""
pane_interface = cast("_PaneConfigurable", paned_window)
pane_interface.paneconfigure(pane, sticky="nsew")
def _load_plugin(self, parent_frame: ttk.Frame, plugin: dict) -> None:
"""
Load a plugin into the given frame using the plugin factory.
This method uses dependency injection to avoid circular imports.
Plugins are registered with the factory and instantiated dynamically.
Args:
parent_frame: The frame to load the plugin into
plugin: Plugin configuration dict with 'name' key
"""
plugin_name = plugin.get("name")
if not plugin_name:
ttk.Label(parent_frame, text=_("Plugin configuration missing name")).pack()
return
# Check if plugin is registered
if not plugin_factory.is_registered(plugin_name):
error_msg = _("Unknown plugin: {plugin_name}").format(plugin_name=plugin_name)
ttk.Label(parent_frame, text=error_msg, foreground="red").pack()
logging_error(error_msg)
return
# Get the data model for the plugin
model = self.parameter_editor.create_plugin_data_model(plugin_name)
if model is None:
error_msg = _("Plugin requires flight controller connection")
ttk.Label(parent_frame, text=error_msg).pack()
logging_warning(error_msg)
return
# Create plugin using factory with error handling
try:
plugin_view = plugin_factory.create(plugin_name, parent_frame, model, self)
if plugin_view is None:
msg = _("Failed to create plugin: {plugin_name}").format(plugin_name=plugin_name)
logging_error(msg)
ttk.Label(parent_frame, text=msg, foreground="red").pack()
return
# Pack the plugin view
plugin_view.pack(fill="both", expand=True) # type: ignore[attr-defined]
# Call activation hook if available
if hasattr(plugin_view, "on_activate"):
try:
plugin_view.on_activate() # pyright: ignore[reportAttributeAccessIssue]
except (AttributeError, TypeError) as e:
logging_warning(_("Error activating plugin: %s"), e)
# Failed to activate - clean up and abort
with contextlib.suppress(AttributeError, tk.TclError):
plugin_view.destroy() # type: ignore[attr-defined]
return
# Only store reference after successful activation
self.current_plugin_view = plugin_view
except (ImportError, AttributeError, TypeError, ValueError) as e:
msg = _("Error loading plugin {plugin_name}: {error}").format(plugin_name=plugin_name, error=str(e))
logging_exception(msg) # Log full traceback
ttk.Label(parent_frame, text=msg, foreground="red").pack()
def _do_tempcal_imu(self, selected_file: str) -> None:
"""
Handle IMU temperature calibration using the new callback-based workflow.
This method creates GUI-specific callback functions and injects them into
the business logic workflow method, achieving proper separation of concerns.
"""
def select_file(title: str, filetypes: list[str]) -> Optional[str]: # noqa: UP045
"""GUI callback for file selection dialog."""
return self.ui.askopenfilename(title=title, filetypes=[(_("ArduPilot binary log files"), filetypes)])
def get_progress_callback() -> Callable | None:
"""Create and return progress window callback only when calibration will actually happen."""
show_only_on_update = True
tempcal_imu_progress_window = self.ui.create_progress_window(
self.root,
_("Reading IMU calibration messages"),
_("Please wait, this can take a long time"),
show_only_on_update,
)
# Store reference for cleanup
self._tempcal_imu_progress_window = tempcal_imu_progress_window
return tempcal_imu_progress_window.update_progress_bar_300_pct
try:
# Inject GUI callbacks into business logic workflow
_success = self.parameter_editor.handle_imu_temperature_calibration_workflow(
selected_file,
ask_user_confirmation=self.ui.ask_yesno,
select_file=select_file,
show_warning=self.ui.show_warning,
show_error=self.ui.show_error,
get_progress_callback=get_progress_callback,
)
finally:
# Clean up progress window if it was created
if self._tempcal_imu_progress_window is not None:
self._tempcal_imu_progress_window.destroy()
self._tempcal_imu_progress_window = None
def _handle_dialog_choice(self, result: list, dialog: tk.Toplevel, choice: ExperimentChoice) -> None:
result.append(choice)
dialog.destroy()
def _should_copy_fc_values_to_file(self, selected_file: str) -> ExperimentChoice:
def ask_user_choice(title: str, message: str, options: list[str]) -> ExperimentChoice: # pylint: disable=too-many-locals
"""GUI callback for asking user choice with custom dialog."""
# Create custom dialog with Close, Yes, No buttons
dialog = tk.Toplevel(self.root)
# Hide dialog initially to prevent flickering
dialog.withdraw()
dialog.transient(self.root)
dialog.title(title)
dialog.resizable(width=False, height=False)
dialog.protocol("WM_DELETE_WINDOW", dialog.destroy)
# Message text
message_label = tk.Label(dialog, text=message, justify=tk.LEFT, padx=20, pady=10)
message_label.pack(padx=10, pady=10)
# Clickable link to tuning guide
safe_font_config = get_safe_font_config()
link_label = tk.Label(
dialog,
text=_("Click here to open the Tuning Guide relevant Section"),
fg="blue",
cursor="hand2",
font=(str(safe_font_config["family"]), int(safe_font_config["size"]), "underline"),
)
link_label.pack(pady=(0, 10))
link_label.bind("<Button-1>", lambda _e: self.parameter_editor.open_documentation_in_browser(selected_file))
# Result variable
result: list[ExperimentChoice] = []
# Button frame
button_frame = tk.Frame(dialog)
button_frame.pack(pady=10)
# Close button (default)
close_button = tk.Button(
button_frame,
text=options[0], # "Close"
width=10,
command=lambda: self._handle_dialog_choice(result, dialog, choice="close"),
)
close_button.pack(side=tk.LEFT, padx=5)
# Yes button
yes_button = tk.Button(
button_frame,
text=options[1],
width=10, # "Yes"
command=lambda: self._handle_dialog_choice(result, dialog, choice=True),
)
yes_button.pack(side=tk.LEFT, padx=5)
# No button
no_button = tk.Button(
button_frame,
text=options[2],
width=10, # "No"
command=lambda: self._handle_dialog_choice(result, dialog, choice=False),
)
no_button.pack(side=tk.LEFT, padx=5)
dialog.bind("<Return>", lambda _event: self._handle_dialog_choice(result, dialog, choice="close"))
# Center the dialog on the parent window
dialog.deiconify()
dialog.update_idletasks()
dialog_width = dialog.winfo_width()
dialog_height = dialog.winfo_height()
parent_x = self.root.winfo_rootx()
parent_y = self.root.winfo_rooty()
parent_width = self.root.winfo_width()
parent_height = self.root.winfo_height()
x = parent_x + (parent_width - dialog_width) // 2
y = parent_y + (parent_height - dialog_height) // 2
dialog.geometry(f"+{x}+{y}")
dialog.lift()
dialog.update() # Ensure the window is fully rendered before setting focus
# Show dialog at correct position and make it modal
# On macOS, grab_set() causes UI freeze (issue #1264), so skip it
if sys_platform != "darwin":
dialog.focus_force()
dialog.grab_set()
# Set focus after dialog is shown and modal
close_button.focus_set() # Give the Close button focus
# Wait until dialog is closed
self.root.wait_window(dialog)
return result[-1] if result else "close"
return self.parameter_editor.handle_copy_fc_values_workflow(
selected_file,
ask_user_choice,
self.ui.show_info,
)
def _should_upload_file_to_fc(self, selected_file: str) -> None:
def get_progress_callback() -> Callable | None:
"""Create and return progress window callback only when upload will actually happen."""
show_only_on_update = True
self.file_upload_progress_window = self.ui.create_progress_window(
self.root,
_("Uploading file"),
_("Uploaded {} of {} %"),
show_only_on_update,
)
return self.file_upload_progress_window.update_progress_bar
try:
self.parameter_editor.should_upload_file_to_fc_workflow(
selected_file,
ask_confirmation=self.ui.ask_yesno,
show_error=self.ui.show_error,
show_warning=self.ui.show_warning,
get_progress_callback=get_progress_callback,
)
finally:
# Clean up progress window if it was created
if self.file_upload_progress_window is not None:
self.file_upload_progress_window.destroy()
self.file_upload_progress_window = None
def on_param_file_combobox_change(self, _event: Union[None, tk.Event], forced: bool = False) -> None: # noqa: UP007
if not self.file_selection_combobox["values"]:
return
selected_file = self.file_selection_combobox.get()
self._update_progress_bar_from_file(selected_file)
# Only proceed if file changed or forced
if self.parameter_editor.current_file != selected_file or forced:
# Write any pending changes before switching files
self.write_changes_to_intermediate_parameter_file()
# Handle the parameter file change workflow
final_file, should_continue = self.parameter_editor.handle_param_file_change_workflow(
selected_file,
forced,
self.gui_complexity,
self.documentation_frame.get_auto_open_documentation_in_browser(),
handle_imu_temp_cal=self._do_tempcal_imu,
handle_copy_fc_values=self._should_copy_fc_values_to_file,
handle_upload_file=self._should_upload_file_to_fc,