Skip to content

Commit d225757

Browse files
committed
feat(download last .bin): button to download the last .bin file from the FC
1 parent 4fa3c1c commit d225757

3 files changed

Lines changed: 280 additions & 6 deletions

File tree

ardupilot_methodic_configurator/backend_flightcontroller.py

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
SPDX-License-Identifier: GPL-3.0-or-later
99
"""
1010

11+
import os
1112
from argparse import ArgumentParser
1213
from logging import debug as logging_debug
1314
from logging import error as logging_error
@@ -87,7 +88,7 @@ def close(self) -> None:
8788
]
8889

8990

90-
class FlightController:
91+
class FlightController: # pylint: disable=too-many-public-methods
9192
"""
9293
A class to manage the connection and parameters of a flight controller.
9394
@@ -1163,6 +1164,180 @@ def put_progress_callback(completion: float) -> None:
11631164
ret.display_message()
11641165
return ret.error_code == 0
11651166

1167+
def download_last_flight_log(
1168+
self, local_filename: str, progress_callback: Union[None, Callable[[int, int], None]] = None
1169+
) -> bool:
1170+
"""Download the last flight log from the flight controller."""
1171+
if self.master is None:
1172+
error_msg = _("No flight controller connected")
1173+
logging_error(error_msg)
1174+
return False
1175+
if not self.info.is_mavftp_supported:
1176+
error_msg = _("MAVFTP is not supported by the flight controller")
1177+
logging_error(error_msg)
1178+
return False
1179+
1180+
mavftp = MAVFTP(self.master, target_system=self.master.target_system, target_component=self.master.target_component)
1181+
1182+
def get_progress_callback(completion: float) -> None:
1183+
if progress_callback is not None and completion is not None:
1184+
progress_callback(int(completion * 100), 100)
1185+
1186+
try:
1187+
# Try to get the last log number using different methods
1188+
remote_filenumber = self._get_last_log_number(mavftp)
1189+
if remote_filenumber is None:
1190+
return False
1191+
1192+
# We want the previous log, not the current one (which might be incomplete)
1193+
# remote_filenumber -= 1
1194+
# if remote_filenumber < 1:
1195+
# logging_error(_("No previous flight log available"))
1196+
# return False
1197+
1198+
return self._download_log_file(mavftp, remote_filenumber, local_filename, get_progress_callback)
1199+
1200+
except Exception as e: # pylint: disable=broad-exception-caught
1201+
logging_error(_("Error during flight log download: %s"), str(e))
1202+
return False
1203+
1204+
def _get_last_log_number(self, mavftp: MAVFTP) -> Union[int, None]:
1205+
"""Get the last log number using multiple fallback methods."""
1206+
# Method 1: Try to get LASTLOG.TXT
1207+
log_number = self._get_log_number_from_lastlog_txt(mavftp)
1208+
if log_number is not None:
1209+
return log_number
1210+
1211+
# Method 2: Try to list the logs directory and find the highest numbered log
1212+
log_number = self._get_log_number_from_directory_listing(mavftp)
1213+
if log_number is not None:
1214+
return log_number
1215+
1216+
# Method 3: Try common log numbers (scan backwards from a reasonable max)
1217+
log_number = self._get_log_number_by_scanning(mavftp)
1218+
if log_number is not None:
1219+
return log_number
1220+
1221+
logging_error(_("Could not determine the last log number using any method"))
1222+
return None
1223+
1224+
def _get_log_number_from_lastlog_txt(self, mavftp: MAVFTP) -> Union[int, None]:
1225+
"""Try to get the log number from LASTLOG.TXT file."""
1226+
logging_info(_("Trying to get log number from LASTLOG.TXT"))
1227+
try:
1228+
temp_lastlog_file = "temp_lastlog.txt"
1229+
mavftp.cmd_get(["/APM/LOGS/LASTLOG.TXT", temp_lastlog_file])
1230+
ret = mavftp.process_ftp_reply("OpenFileRO", timeout=10)
1231+
if ret.error_code != 0:
1232+
logging_warning(_("LASTLOG.TXT not available, trying alternative methods"))
1233+
return None
1234+
1235+
return self._extract_log_number_from_file(temp_lastlog_file)
1236+
except Exception as e: # pylint: disable=broad-exception-caught
1237+
logging_warning(_("Failed to get log number from LASTLOG.TXT: %s"), str(e))
1238+
return None
1239+
1240+
def _get_log_number_from_directory_listing(self, _mavftp: MAVFTP) -> Union[int, None]:
1241+
"""Try to get the highest log number by listing the logs directory using MAVFTP."""
1242+
logging_info(_("Trying to get log number from directory listing"))
1243+
try:
1244+
result = _mavftp.cmd_list(["/APM/LOGS/"])
1245+
if not hasattr(result, "directory_listing") or not isinstance(result.directory_listing, dict):
1246+
logging_error(_("No directory listing found in MAVFTPReturn"))
1247+
return None
1248+
highest = -1
1249+
for name in result.directory_listing:
1250+
# Typical log file names: 00000036.BIN, 00000037.BIN, etc.
1251+
if name.endswith(".BIN") and name[:8].isdigit():
1252+
try:
1253+
log_num = int(name[:8])
1254+
highest = max(highest, log_num)
1255+
except ValueError:
1256+
continue
1257+
if highest != -1:
1258+
logging_info(_("Highest log number found: %d"), highest)
1259+
return highest
1260+
logging_error(_("No log files found in directory listing"))
1261+
return None
1262+
except Exception as e: # pylint: disable=broad-exception-caught
1263+
logging_warning(_("Failed to get log number from directory listing: %s"), str(e))
1264+
return None
1265+
1266+
def _get_log_number_by_scanning(self, mavftp: MAVFTP) -> Union[int, None]:
1267+
"""Try to find the last log using binary search for efficiency."""
1268+
logging_info(_("Trying to find log number using binary search"))
1269+
try:
1270+
# Binary search to find the highest log number
1271+
low = 1
1272+
high = 9999 # Reasonable upper bound for log numbers
1273+
last_found = None
1274+
1275+
while low <= high:
1276+
mid = (low + high) // 2
1277+
remote_filename = f"/APM/LOGS/{mid:08}.BIN"
1278+
1279+
# Test if this log file exists
1280+
temp_test_file = f"temp_test_{mid}.tmp"
1281+
mavftp.cmd_get([remote_filename, temp_test_file])
1282+
ret = mavftp.process_ftp_reply("OpenFileRO", timeout=5) # Must be > idle_detection_time (3.7s)
1283+
1284+
# Clean up the temp file if it was created
1285+
if os.path.exists(temp_test_file):
1286+
os.remove(temp_test_file)
1287+
1288+
if ret.error_code == 0:
1289+
# File exists, search in upper half
1290+
last_found = mid
1291+
low = mid + 1
1292+
logging_debug(_("Log %d exists, searching higher"), mid)
1293+
else:
1294+
# File doesn't exist, search in lower half
1295+
high = mid - 1
1296+
logging_debug(_("Log %d doesn't exist, searching lower"), mid)
1297+
1298+
if last_found is not None:
1299+
logging_info(_("Found highest log number using binary search: %d"), last_found)
1300+
return last_found
1301+
1302+
logging_warning(_("No log files found using binary search"))
1303+
return None
1304+
1305+
except Exception as e: # pylint: disable=broad-exception-caught
1306+
logging_warning(_("Failed to scan for log numbers using binary search: %s"), str(e))
1307+
return None
1308+
1309+
def _download_log_file(
1310+
self, mavftp: MAVFTP, remote_filenumber: int, local_filename: str, get_progress_callback: Callable
1311+
) -> bool:
1312+
"""Download the actual log file from the flight controller."""
1313+
remote_filename = f"/APM/LOGS/{remote_filenumber:08}.BIN"
1314+
logging_info(_("Downloading flight log %s to %s"), remote_filename, local_filename)
1315+
1316+
# Download the actual log file
1317+
mavftp.cmd_get([remote_filename, local_filename], progress_callback=get_progress_callback)
1318+
ret = mavftp.process_ftp_reply("OpenFileRO", timeout=0) # No timeout for large log files
1319+
if ret.error_code != 0:
1320+
logging_error(_("Failed to download flight log %s"), remote_filename)
1321+
ret.display_message()
1322+
return False
1323+
1324+
logging_info(_("Successfully downloaded flight log to %s"), local_filename)
1325+
return True
1326+
1327+
def _extract_log_number_from_file(self, temp_lastlog_file: str) -> Union[int, None]:
1328+
"""Extract log number from LASTLOG.TXT file and clean up the temporary file."""
1329+
try:
1330+
with open(temp_lastlog_file, encoding="UTF-8") as file:
1331+
file_contents = file.readline()
1332+
return int(file_contents.strip())
1333+
except (FileNotFoundError, ValueError) as e:
1334+
logging_error(_("Could not extract last log file number from LASTLOG.TXT: %s"), e)
1335+
return None
1336+
finally:
1337+
# Clean up the temporary file
1338+
if os.path.exists(temp_lastlog_file):
1339+
os.remove(temp_lastlog_file)
1340+
11661341
@staticmethod
11671342
def add_argparse_arguments(parser: ArgumentParser) -> ArgumentParser:
11681343
parser.add_argument(

ardupilot_methodic_configurator/backend_mavftp.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -229,15 +229,17 @@ def __init__( # pylint: disable=too-many-arguments, too-many-positional-argumen
229229
invalid_error_code: int = 0,
230230
invalid_opcode: int = 0,
231231
invalid_payload_size: int = 0,
232+
directory_listing: Union[dict[str, int], None] = None,
232233
) -> None:
233234
self.operation_name = operation_name
234235
self.error_code = error_code
235236
self.system_error = system_error
236237
self.invalid_error_code = invalid_error_code
237238
self.invalid_opcode = invalid_opcode
238239
self.invalid_payload_size = invalid_payload_size
240+
self.directory_listing = directory_listing
239241

240-
def display_message(self) -> None: # pylint: disable=too-many-branches
242+
def display_message(self) -> None: # pylint: disable=too-many-branches, too-many-statements # noqa: C901, PLR0912, PLR0915
241243
if self.error_code == ERR_None:
242244
logging.info("%s succeeded", self.operation_name)
243245
elif self.error_code == ERR_Fail:
@@ -284,6 +286,16 @@ def display_message(self) -> None: # pylint: disable=too-many-branches
284286
else:
285287
logging.error("%s failed, unknown error %u in display_message()", self.operation_name, self.error_code)
286288

289+
if self.directory_listing is not None:
290+
total_size = 0
291+
for name, size in self.directory_listing.items():
292+
if size == -1: # directories are defined by a size of -1
293+
logging.info(" %s/", name)
294+
else:
295+
logging.info(" %s\t%u", name, size)
296+
total_size += max(0, size)
297+
logging.info("Total size %.2f kByte", total_size / 1024.0)
298+
287299
@property
288300
def return_code(self) -> int:
289301
return self.error_code
@@ -357,6 +369,7 @@ def __init__(
357369
self.write_pending = 0
358370
self.write_last_send: Union[None, float] = None
359371
self.open_retries = 0
372+
self.directory_listing: dict[str, int] = {}
360373

361374
self.master = master
362375
self.target_system = target_system
@@ -464,6 +477,7 @@ def cmd_list(self, args) -> MAVFTPReturn:
464477
self.dir_offset = 0
465478
op = FTP_OP(self.seq, self.session, OP_ListDirectory, len(enc_dname), 0, 0, self.dir_offset, enc_dname)
466479
self.__send(op)
480+
self.directory_listing = {}
467481
return self.process_ftp_reply("ListDirectory")
468482

469483
def __handle_list_reply(self, op, _m) -> MAVFTPReturn:
@@ -477,13 +491,20 @@ def __handle_list_reply(self, op, _m) -> MAVFTPReturn:
477491
self.dir_offset += 1
478492
try:
479493
d = str(d, "ascii") # noqa: PLW2901
480-
except Exception: # noqa: S112 pylint: disable=broad-exception-caught
494+
except (TypeError, UnicodeDecodeError):
481495
continue
482496
if d[0] == "D":
483-
logging.info(" D %s", d[1:])
497+
name = d[1:]
498+
self.directory_listing[name] = -1 # directories are defined by a size of -1
499+
logging.info(" D %s", name)
484500
elif d[0] == "F":
485501
(name, size) = d[1:].split("\t")
486-
size_int = int(size)
502+
try:
503+
size_int = int(size)
504+
except (ValueError, TypeError, OverflowError):
505+
logging.error("Invalid file size: %s", size)
506+
size_int = 0
507+
self.directory_listing[name] = size_int
487508
self.total_size += size_int
488509
logging.info(" %s\t%u", name, size_int)
489510
else:
@@ -497,7 +518,7 @@ def __handle_list_reply(self, op, _m) -> MAVFTPReturn:
497518
self.total_size = 0
498519
else:
499520
return self.__decode_ftp_ack_and_nack(op)
500-
return MAVFTPReturn("ListDirectory", ERR_None)
521+
return MAVFTPReturn("ListDirectory", ERR_None, directory_listing=self.directory_listing)
501522

502523
def cmd_get(self, args, callback=None, progress_callback=None) -> MAVFTPReturn:
503524
"""Get file."""

ardupilot_methodic_configurator/frontend_tkinter_parameter_editor.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"""
1212

1313
import sys
14+
import threading
1415
import time
1516
import tkinter as tk
1617
from argparse import ArgumentParser, Namespace
@@ -373,6 +374,28 @@ def __create_parameter_area_widgets(self) -> None:
373374
else _("No flight controller connected, upload not available"),
374375
)
375376

377+
# Create download last flight log button
378+
download_log_button = ttk.Button(
379+
buttons_frame,
380+
text=_("Download last flight log"),
381+
command=self.on_download_last_flight_log_click,
382+
)
383+
download_log_button.configure(
384+
state=(
385+
"normal" if (self.flight_controller.master and self.flight_controller.info.is_mavftp_supported) else "disabled"
386+
)
387+
)
388+
download_log_button.pack(side=tk.LEFT, padx=(8, 8)) # Add padding on both sides of the download log button
389+
show_tooltip(
390+
download_log_button,
391+
_(
392+
"Download the last flight log from the flight controller\n"
393+
"This will save the previous flight log to a file on your computer for analysis"
394+
)
395+
if (self.flight_controller.master and self.flight_controller.info.is_mavftp_supported)
396+
else _("No flight controller connected or MAVFTP not supported"),
397+
)
398+
376399
# Create skip button
377400
self.skip_button = ttk.Button(buttons_frame, text=_("Skip parameter file"), command=self.on_skip_click)
378401
self.skip_button.configure(
@@ -935,6 +958,61 @@ def _export_fc_params_missing_or_different_in_amc_files(self, fc_parameters: Par
935958
else:
936959
logging_info(_("No FC parameters are missing or different from AMC parameter files"))
937960

961+
def on_download_last_flight_log_click(self) -> None:
962+
"""Handle the download last flight log button click."""
963+
if not self.flight_controller.master:
964+
messagebox.showerror(_("Error"), _("No flight controller connected"))
965+
return
966+
967+
if not self.flight_controller.info.is_mavftp_supported:
968+
messagebox.showerror(_("Error"), _("MAVFTP is not supported by the flight controller"))
969+
return
970+
971+
# Show file dialog to select where to save the log file
972+
filename = filedialog.asksaveasfilename(
973+
title=_("Save flight log as"),
974+
defaultextension=".bin",
975+
filetypes=[
976+
(_("Binary log files"), "*.bin"),
977+
(_("All files"), "*.*"),
978+
],
979+
)
980+
981+
if not filename: # User cancelled the dialog
982+
return
983+
984+
# Create a progress window for the download
985+
progress_window = ProgressWindow(
986+
self.root,
987+
_("Downloading Flight Log"),
988+
_("Downloaded {}% from {}%"),
989+
)
990+
991+
# Start the download in a separate thread to avoid blocking the GUI
992+
def download_thread() -> None:
993+
try:
994+
success = self.flight_controller.download_last_flight_log(filename, progress_window.update_progress_bar)
995+
if success:
996+
self.root.after(
997+
0,
998+
lambda: messagebox.showinfo(_("Success"), _("Flight log downloaded successfully to:\n%s") % filename),
999+
)
1000+
else:
1001+
self.root.after(
1002+
0,
1003+
lambda: messagebox.showerror(
1004+
_("Error"), _("Failed to download flight log. Check the console for details.")
1005+
),
1006+
)
1007+
except Exception as e: # pylint: disable=broad-exception-caught
1008+
error_msg = str(e)
1009+
self.root.after(0, lambda: messagebox.showerror(_("Error"), _("Download error: %s") % error_msg))
1010+
finally:
1011+
self.root.after(0, progress_window.destroy)
1012+
1013+
download_thread_obj = threading.Thread(target=download_thread, daemon=True)
1014+
download_thread_obj.start()
1015+
9381016
def _configuration_step_is_optional(self, file_name: str, threshold_pct: int = 20) -> bool:
9391017
# Check if the configuration step for the given file is optional
9401018
mandatory_text, _mandatory_url = self.local_filesystem.get_documentation_text_and_url(file_name, "mandatory")

0 commit comments

Comments
 (0)