-
Notifications
You must be signed in to change notification settings - Fork 70
Expand file tree
/
Copy pathfrontend_tkinter_parameter_editor.py
More file actions
executable file
·1737 lines (1502 loc) · 80 KB
/
Copy pathfrontend_tkinter_parameter_editor.py
File metadata and controls
executable file
·1737 lines (1502 loc) · 80 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 tempfile
import threading
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, 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 (
ComponentEditorDeps,
ExperimentChoice,
LogAnalysisInputs,
ParameterEditor,
)
from ardupilot_methodic_configurator.frontend_tkinter_about_popup_window import AboutWindow
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_component_editor import ComponentEditorWindow
from ardupilot_methodic_configurator.frontend_tkinter_directory_selection import VehicleDirectorySelectionWidgets
from ardupilot_methodic_configurator.frontend_tkinter_fc_banner_window import FlightControllerBannerWindow
from ardupilot_methodic_configurator.frontend_tkinter_font import get_safe_font_config
from ardupilot_methodic_configurator.frontend_tkinter_log_availability import LogAvailabilityReportWindow
from ardupilot_methodic_configurator.frontend_tkinter_parameter_compare_and_upload import ParameterFileUploadWindow
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 RichText, 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.log_analysis.backend_data_sources import load_apm_pdef
from ardupilot_methodic_configurator.log_analysis.backend_log_analysis import analyze_log_data
from ardupilot_methodic_configurator.log_analysis.backend_log_extraction import extract_log
from ardupilot_methodic_configurator.log_analysis.data_model_log_analysis import LogSummary, validate_log_matches_vehicle
from ardupilot_methodic_configurator.plugins.plugin_factory import plugin_factory
if TYPE_CHECKING:
from collections.abc import Callable
from ardupilot_methodic_configurator.data_model_par_dict import ParDict
from ardupilot_methodic_configurator.log_analysis.data_model_log_data import LogData
from ardupilot_methodic_configurator.log_analysis.utils import APMDoc
from ardupilot_methodic_configurator.plugins.plugin_protocol import PluginView
# 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__( # noqa: PLR0913, PLR0917 # 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],
extract_log_data: Callable[[str, Callable[[int, int], None] | None], LogData],
analyze_log_data_callback: Callable[..., LogSummary],
load_apm_doc: Callable[[str, str, str], APMDoc | 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
self.extract_log_data = extract_log_data
self.analyze_log_data = analyze_log_data_callback
self.load_apm_doc = load_apm_doc
@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)
def _load_apm_doc(vehicle_dir: str, vehicle_type: str, firmware_version: str) -> APMDoc | None:
temp_parent = vehicle_dir if isinstance(vehicle_dir, str) else None
with tempfile.TemporaryDirectory(dir=temp_parent) as temp_dir:
return load_apm_pdef(temp_dir, vehicle_type, firmware_version)
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,
extract_log_data=extract_log,
analyze_log_data_callback=analyze_log_data,
load_apm_doc=_load_apm_doc,
)
def upload_params_with_progress(
self,
parent_window: tk.Misc,
upload_callback: Callable[..., bool],
selected_params: dict,
) -> bool:
"""
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:
return bool(
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: PluginView | None = None
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._log_availability_report_window: LogAvailabilityReportWindow | None = None
self._log_report_return_pending: bool = False
self.inline_component_editor: ComponentEditorWindow | None = None
self._inline_component_name: str | None = None
self._updating_inline_editor: bool = False
self.root.title(
_("Amilcar Lucas's - ArduPilot methodic configurator ") + __version__ + _(" - Parameter file editor and uploader")
)
self.root.geometry(self.calculate_scaled_geometry(990, 630)) # Set the window width and height
self.center_window_on_screen(self.root)
# 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))
# Placeholder frame for the optional inline component editor.
# It lives between the documentation frame and the parameter table.
# Content is populated/cleared by _update_inline_component_editor().
# Not packed initially; _update_inline_component_editor() will pack/unpack as needed.
self.inline_component_container = ttk.LabelFrame(self.main_frame)
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 = max(self.root.winfo_reqheight(), round(820 * self.dpi_scaling_factor))
self.root.geometry(f"{round(990 * self.dpi_scaling_factor)}x{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)
parameter_actions_frame = ttk.Frame(config_subframe)
parameter_actions_frame.pack(side=tk.LEFT, anchor=tk.NW)
compare_and_upload_button = ttk.Button(
parameter_actions_frame,
text=_("Compare and upload"),
command=self.on_compare_and_upload_parameter_file_click,
)
compare_and_upload_button.configure(state="normal" if self.parameter_editor.is_fc_connected else "disabled")
compare_and_upload_button.grid(row=0, column=0, padx=(6, 2), sticky=tk.NW)
show_tooltip(
compare_and_upload_button,
_("Select a .parm or .param file to compare with the FC and optionally upload")
if self.parameter_editor.is_fc_connected
else _("No flight controller connected, external parameter upload not available"),
)
fc_banner_button = ttk.Button(
parameter_actions_frame,
text=_("FC banner"),
command=self.on_fc_banner_click,
)
fc_banner_button.configure(state="normal" if self.parameter_editor.is_fc_connected else "disabled")
fc_banner_button.grid(row=1, column=0, padx=(8, 8), pady=(4, 0), sticky=tk.NW)
show_tooltip(
fc_banner_button,
_("Display the latest flight-controller banner"),
)
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: AboutWindow(self.root, 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 analyse flight log button
analyse_log_button = ttk.Button(
buttons_frame,
text=_("Analyse a .bin log"),
command=self.on_analyse_log_click,
)
analyse_log_button.pack(side=tk.LEFT, padx=(8, 8))
show_tooltip(analyse_log_button, _("Open a .bin flight log and analyse its availability"))
# 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 buttons
self.skip_button = ttk.Button(buttons_frame, text=_("Skip >"), 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,
_(
"Go to the next configuration step 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"
),
)
self.previous_button = ttk.Button(buttons_frame, text=_("< Skip"), command=self.on_previous_click)
self._update_previous_button_state()
self.previous_button.pack(side=tk.RIGHT, padx=(8, 0))
show_tooltip(
self.previous_button,
_(
"Go to the previous configuration step 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 _create_log_analysis_progress(self, message: str) -> ProgressWindow:
show_only_when_update_called = False
progress = self.ui.create_progress_window(
self.root,
_(".bin Log Analysis"),
message,
show_only_when_update_called,
)
progress.progress_bar.configure(mode="indeterminate")
progress.progress_bar.start(10)
return progress
def _resolve_log_analysis_inputs(
self,
log_vehicle_type: str,
log_firmware_version: tuple[int, int, int],
inputs: LogAnalysisInputs,
) -> LogAnalysisInputs | None:
"""Validate log identity and return project or log-specific analysis inputs."""
try:
validate_log_matches_vehicle(
log_vehicle_type,
log_firmware_version,
inputs.project_vehicle_type,
inputs.project_firmware_version,
)
return inputs
except ValueError as error:
mismatch_error = str(error)
version_text = ".".join(str(part) for part in log_firmware_version)
if not self.ui.ask_yesno(
_("Log Mismatch"),
_("{error}\n\nAnalyse this log anyway using ArduPilot metadata for {vehicle_type} {version}?").format(
error=mismatch_error,
vehicle_type=log_vehicle_type,
version=version_text,
),
):
return None
progress = self._create_log_analysis_progress(_("Downloading parameter metadata for this log, please wait..."))
try:
apm_doc = self.ui.load_apm_doc(self.parameter_editor.get_vehicle_directory(), log_vehicle_type, version_text)
return self.parameter_editor.get_log_analysis_context_inputs(apm_doc=apm_doc)
finally:
progress.destroy()
def _set_log_analysis_progress_mode(
self, progress: ProgressWindow
) -> tuple[Callable[[int, int], None], Callable[[], None]]:
"""Return a thread-safe recorder and a main-thread flush callable."""
pct_state = -1
current_state = 0
total_state = 0
is_determinate_state = False
lock = threading.Lock()
def record_progress(current: int, total: int) -> None:
"""Background thread only — zero Tk calls."""
nonlocal pct_state, current_state, total_state
if total == 0:
return
pct = int(current * 100 / total)
with lock:
if pct != pct_state:
pct_state = pct
current_state = current
total_state = total
def flush_to_ui() -> None:
"""Main thread only — reads state and updates widgets."""
nonlocal is_determinate_state
with lock:
pct = pct_state
current = current_state
total = total_state
is_det = is_determinate_state
if pct < 0:
return
if not is_det:
progress.progress_bar.stop()
progress.progress_bar.configure(mode="determinate")
with lock:
is_determinate_state = True
progress.update_progress_bar(current, total)
return record_progress, flush_to_ui
def on_analyse_log_click(self) -> None:
"""Handle the analyse log button click."""
filepath = self.ui.askopenfilename(
title=_("Select a flight log"),
filetypes=[(_("ArduPilot binary log files"), "*.bin"), (_("All files"), "*.*")],
)
if not filepath:
return
self._analyse_log_file(filepath)
def _navigate_to_config_step(self, step: str) -> None:
if not step or step not in self.file_selection_combobox["values"]:
return
self._log_report_return_pending = True
self.file_selection_combobox.set(step)
self.on_param_file_combobox_change(None, forced=True)
def _analyse_log_file(self, filepath: str) -> None:
try:
inputs = self.parameter_editor.get_log_analysis_context_inputs()
except Exception as e: # pylint: disable=broad-exception-caught
self.ui.show_error(_("Log Analysis Error"), str(e))
return
progress = self._create_log_analysis_progress(_("Reading flight log, please wait..."))
result_container: list = []
error_container: list = []
progress_callback, flush_progress = self._set_log_analysis_progress_mode(progress)
def run_extraction() -> None:
try:
result_container.append(self.ui.extract_log_data(filepath, progress_callback))
except Exception as e: # pylint: disable=broad-exception-caught
error_container.append(e)
def check_done() -> None:
flush_progress()
if thread.is_alive():
self.root.after(100, check_done)
return
progress.destroy()
if error_container:
self.ui.show_error(_("Log Analysis Error"), str(error_container[0]))
elif result_container:
log_data = result_container[0]
if log_data.vehicle_type is None or log_data.firmware_version is None:
self.ui.show_error(_("Log Analysis Error"), _("No firmware version information found in parsed log"))
return
try:
resolved_inputs = self._resolve_log_analysis_inputs(
log_data.vehicle_type,
log_data.firmware_version,
inputs,
)
if resolved_inputs is None:
return
summary = self.ui.analyze_log_data(
log_data,
project_vehicle_type=resolved_inputs.project_vehicle_type,
project_firmware_version=resolved_inputs.project_firmware_version,
vehicle_components=resolved_inputs.vehicle_components,
configuration_steps=resolved_inputs.configuration_steps,
apm_doc=resolved_inputs.apm_doc,
validate_project=False,
)
report = None
except Exception as e: # pylint: disable=broad-exception-caught
self.ui.show_error(_("Log Analysis Error"), str(e))
return
report_window = LogAvailabilityReportWindow(
self.root,
summary,
self.parameter_editor.get_vehicle_directory(),
is_fc_connected=self.parameter_editor.is_fc_connected,
upload_callback=self.upload_selected_params,
navigate_callback=self._navigate_to_config_step,
report=report,
)
self._log_availability_report_window = report_window
if isinstance(self.root, tk.Tk) and UsagePopupWindow.should_display("log_availability_report"):
display_log_availability_report_usage_popup(report_window.root)
thread = threading.Thread(target=run_extraction, daemon=True)
thread.start()
self.root.after(100, check_done)
def display_log_availability_report_usage_popup(parent: tk.Tk | tk.Toplevel) -> None:
usage_popup_window = BaseWindow(parent)
usage_popup_window.root.withdraw()
instructions_text = RichText(usage_popup_window.main_frame, height=12, width=80)
instructions_text.insert(tk.END, _("Log availability Report\n\n"), "title")
instructions_text.insert(
tk.END,
_(
"Analysis is only as good as the data in your log.\n\n"
"If a subsystem is not logging, or its parameters are misconfigured, "
"the analyser cannot give you reliable results for that subsystem.\n\n"
"This report tells you exactly what your ArduPilot .bin log can and cannot support for analysis, "
"and points you to the configuration step that fixes each gap.\n\n"
"Hover over any issue count to see the details.\n"
"Issues link back to the configuration step that fixes them."
),
)
UsagePopupWindow.display(
cast("tk.Tk", parent),
usage_popup_window,
_("Log availability Report"),
"log_availability_report",
"520x320",
instructions_text,
)
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()
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()
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