inav-toolkit 2.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
inav_toolkit/msp.py ADDED
@@ -0,0 +1,1293 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ INAV MSP - MultiWii Serial Protocol v2 communication for INAV flight controllers.
4
+
5
+ Handles serial communication with INAV FCs for:
6
+ - Flight controller identification (firmware, craft name, board)
7
+ - Dataflash blackbox download with progress reporting
8
+ - Dataflash erase
9
+ - (Future) CLI command send/receive, diff all, parameter read/write
10
+
11
+ MSP v2 frame format:
12
+ $X< flag(u8) cmd(u16 LE) size(u16 LE) payload crc8
13
+ $X> flag(u8) cmd(u16 LE) size(u16 LE) payload crc8
14
+
15
+ Usage:
16
+ from inav_toolkit.msp import INAVDevice, auto_detect_fc
17
+
18
+ fc, info = auto_detect_fc() # works on Linux, macOS, Windows
19
+ if fc:
20
+ print(f"Connected: {info['craft_name']} running {info['firmware']}")
21
+ fc.download_blackbox("./blackbox", erase_after=True)
22
+ fc.close()
23
+ """
24
+
25
+ import glob
26
+ import os
27
+ import struct
28
+ import sys
29
+ import time
30
+
31
+ try:
32
+ import serial
33
+ except ImportError:
34
+ serial = None # Checked in open()
35
+
36
+ VERSION = "2.3.0"
37
+
38
+ # ─── MSP Command IDs ─────────────────────────────────────────────────────────
39
+
40
+ MSP_API_VERSION = 1
41
+ MSP_FC_VARIANT = 2
42
+ MSP_FC_VERSION = 3
43
+ MSP_BOARD_INFO = 4
44
+ MSP_BUILD_INFO = 5
45
+ MSP_NAME = 10
46
+ MSP_DATAFLASH_SUMMARY = 70
47
+ MSP_DATAFLASH_READ = 71
48
+ MSP_DATAFLASH_ERASE = 72
49
+ MSP_BLACKBOX_CONFIG = 80
50
+
51
+ # Blackbox device types (from MSP_BLACKBOX_CONFIG byte 0)
52
+ BB_DEVICE_NONE = 0
53
+ BB_DEVICE_SERIAL = 1
54
+ BB_DEVICE_SPIFLASH = 2
55
+ BB_DEVICE_SDCARD = 3
56
+
57
+ BB_DEVICE_NAMES = {
58
+ BB_DEVICE_NONE: "NONE",
59
+ BB_DEVICE_SERIAL: "SERIAL",
60
+ BB_DEVICE_SPIFLASH: "SPIFLASH",
61
+ BB_DEVICE_SDCARD: "SDCARD",
62
+ }
63
+
64
+ # ─── MSP v2 CRC-8 DVB-S2 ─────────────────────────────────────────────────────
65
+
66
+ def _build_crc8_table():
67
+ """Build CRC-8 lookup table using DVB-S2 polynomial (0xD5)."""
68
+ table = []
69
+ for i in range(256):
70
+ crc = i
71
+ for _ in range(8):
72
+ if crc & 0x80:
73
+ crc = ((crc << 1) ^ 0xD5) & 0xFF
74
+ else:
75
+ crc = (crc << 1) & 0xFF
76
+ table.append(crc)
77
+ return bytes(table)
78
+
79
+ _CRC8_TABLE = _build_crc8_table()
80
+
81
+
82
+ def _crc8_dvb_s2(data):
83
+ """Compute CRC-8 DVB-S2 over a bytes-like object."""
84
+ crc = 0
85
+ for b in data:
86
+ crc = _CRC8_TABLE[crc ^ b]
87
+ return crc
88
+
89
+
90
+ # ─── MSP v2 Frame Encoding/Decoding ──────────────────────────────────────────
91
+
92
+ def msp_v2_encode(cmd, payload=b""):
93
+ """Encode an MSP v2 request frame (direction: to FC)."""
94
+ flag = 0
95
+ size = len(payload)
96
+ body = struct.pack("<BHH", flag, cmd, size) + payload
97
+ crc = _crc8_dvb_s2(body)
98
+ return b"$X<" + body + bytes([crc])
99
+
100
+
101
+ def msp_v2_decode(raw):
102
+ """Decode an MSP v2 response frame.
103
+
104
+ Returns:
105
+ (cmd, payload) on success
106
+ None on error/invalid frame
107
+ """
108
+ if len(raw) < 9: # minimum: $X> + flag(1) + cmd(2) + size(2) + crc(1)
109
+ return None
110
+
111
+ # Find frame start
112
+ idx = raw.find(b"$X")
113
+ if idx < 0:
114
+ return None
115
+
116
+ raw = raw[idx:]
117
+ if len(raw) < 9:
118
+ return None
119
+
120
+ direction = raw[2:3]
121
+ if direction == b"!":
122
+ # Error response from FC
123
+ return None
124
+
125
+ flag = raw[3]
126
+ cmd = struct.unpack_from("<H", raw, 4)[0]
127
+ size = struct.unpack_from("<H", raw, 6)[0]
128
+
129
+ if len(raw) < 8 + size + 1:
130
+ return None
131
+
132
+ payload = raw[8:8 + size]
133
+ expected_crc = raw[8 + size]
134
+
135
+ # CRC covers flag + cmd + size + payload
136
+ body = raw[3:8 + size]
137
+ actual_crc = _crc8_dvb_s2(body)
138
+
139
+ if actual_crc != expected_crc:
140
+ return None
141
+
142
+ return (cmd, payload)
143
+
144
+
145
+ # ─── Serial Port Discovery ───────────────────────────────────────────────────
146
+
147
+ def find_serial_ports():
148
+ """Find candidate serial ports for INAV flight controllers.
149
+
150
+ Uses pyserial's list_ports for cross-platform discovery (Linux, macOS,
151
+ Windows). Falls back to glob patterns if list_ports is unavailable.
152
+
153
+ Returns list of port paths, ordered by likelihood.
154
+ """
155
+ # Try pyserial's cross-platform port enumeration first
156
+ try:
157
+ from serial.tools import list_ports
158
+ ports = []
159
+ for p in sorted(list_ports.comports(), key=lambda x: x.device):
160
+ # Prioritize known FC USB descriptors
161
+ desc = (p.description or "").lower()
162
+ vid = p.vid
163
+ # STM32 VCP (most INAV FCs): VID 0x0483
164
+ # CP2102/CH340/FTDI: common USB-serial chips
165
+ if vid == 0x0483:
166
+ ports.insert(0, p.device) # STM32 first
167
+ elif any(k in desc for k in ("uart", "serial", "cp210", "ch340",
168
+ "ftdi", "usb", "stm", "acm")):
169
+ ports.append(p.device)
170
+ elif p.device.startswith(("/dev/ttyACM", "/dev/ttyUSB", "/dev/cu.",
171
+ "COM")):
172
+ ports.append(p.device)
173
+ if ports:
174
+ return ports
175
+ except ImportError:
176
+ pass
177
+
178
+ # Fallback: glob patterns (Linux/macOS only, Windows has no /dev/)
179
+ candidates = []
180
+ # Linux: USB CDC (STM32 DFU/VCP), FTDI/CP2102/CH340
181
+ candidates.extend(sorted(glob.glob("/dev/ttyACM*")))
182
+ candidates.extend(sorted(glob.glob("/dev/ttyUSB*")))
183
+ # macOS: various USB-serial drivers
184
+ candidates.extend(sorted(glob.glob("/dev/cu.usbmodem*")))
185
+ candidates.extend(sorted(glob.glob("/dev/cu.usbserial*")))
186
+ candidates.extend(sorted(glob.glob("/dev/cu.SLAB_USBtoUART*")))
187
+ candidates.extend(sorted(glob.glob("/dev/cu.wchusbserial*")))
188
+ return candidates
189
+
190
+
191
+ def auto_detect_fc(baudrate=115200, timeout=2.0):
192
+ """Scan serial ports and return the first one that responds as INAV.
193
+
194
+ Returns:
195
+ (INAVDevice, info_dict) on success - device is open, caller must close
196
+ (None, None) if no FC found
197
+ """
198
+ ports = find_serial_ports()
199
+ if not ports:
200
+ return None, None
201
+
202
+ for port in ports:
203
+ dev = None
204
+ try:
205
+ dev = INAVDevice(port, baudrate=baudrate, timeout=timeout)
206
+ dev.open()
207
+ info = dev.get_info()
208
+ if info and info.get("fc_variant") == "INAV":
209
+ return dev, info
210
+ dev.close()
211
+ except Exception:
212
+ try:
213
+ if dev:
214
+ dev.close()
215
+ except Exception:
216
+ pass
217
+ continue
218
+
219
+ return None, None
220
+
221
+
222
+ # ─── INAV Device ──────────────────────────────────────────────────────────────
223
+
224
+ class INAVDevice:
225
+ """MSP v2 communication with an INAV flight controller."""
226
+
227
+ def __init__(self, port, baudrate=115200, timeout=2.0):
228
+ self.port_path = port
229
+ self.baudrate = baudrate
230
+ self.timeout = timeout
231
+ self._ser = None
232
+ self._info = None
233
+ self._rxbuf = b"" # Persistent receive buffer for pipelining
234
+
235
+ def open(self):
236
+ """Open serial connection."""
237
+ try:
238
+ import serial
239
+ except ImportError:
240
+ print(" ERROR: pyserial is required for device communication.")
241
+ print(" Debian/Ubuntu: sudo apt install python3-serial")
242
+ print(" Other: pip install pyserial (in a venv)")
243
+ sys.exit(1)
244
+
245
+ self._ser = serial.Serial(
246
+ port=self.port_path,
247
+ baudrate=self.baudrate,
248
+ timeout=self.timeout,
249
+ write_timeout=self.timeout,
250
+ bytesize=serial.EIGHTBITS,
251
+ parity=serial.PARITY_NONE,
252
+ stopbits=serial.STOPBITS_ONE,
253
+ )
254
+ # Flush any stale data
255
+ time.sleep(0.1)
256
+ self._ser.reset_input_buffer()
257
+ self._ser.reset_output_buffer()
258
+ self._rxbuf = b""
259
+
260
+ def close(self):
261
+ """Close serial connection."""
262
+ if self._ser and self._ser.is_open:
263
+ try:
264
+ self._ser.close()
265
+ except BaseException:
266
+ pass
267
+ self._ser = None
268
+
269
+ def __enter__(self):
270
+ self.open()
271
+ return self
272
+
273
+ def __exit__(self, *args):
274
+ self.close()
275
+
276
+ def _send(self, cmd, payload=b"", flush=True):
277
+ """Send an MSP v2 request."""
278
+ # Only flush stale data for non-pipelined requests
279
+ if flush:
280
+ if self._ser.in_waiting:
281
+ self._ser.read(self._ser.in_waiting)
282
+ self._rxbuf = b""
283
+ frame = msp_v2_encode(cmd, payload)
284
+ self._ser.write(frame)
285
+
286
+ def _recv(self, expected_cmd=None, timeout=None):
287
+ """Receive and decode an MSP v2 response.
288
+
289
+ Uses a persistent receive buffer (_rxbuf) so that extra bytes
290
+ read from serial are preserved across calls - essential for
291
+ pipelined reads where multiple responses arrive back-to-back.
292
+ Returns (cmd, payload) or None.
293
+ """
294
+ if timeout is None:
295
+ timeout = self.timeout
296
+
297
+ deadline = time.monotonic() + timeout
298
+
299
+ while time.monotonic() < deadline:
300
+ # Read any available serial data into persistent buffer
301
+ waiting = self._ser.in_waiting
302
+ if waiting > 0:
303
+ self._rxbuf += self._ser.read(waiting)
304
+ elif len(self._rxbuf) == 0:
305
+ # Nothing buffered and nothing waiting - brief sleep
306
+ time.sleep(0.0005)
307
+ continue
308
+
309
+ # Try to decode a frame from the buffer
310
+ search_start = 0
311
+ while True:
312
+ idx = self._rxbuf.find(b"$X", search_start)
313
+ if idx < 0:
314
+ # No frame start found - discard obvious garbage
315
+ # but keep tail that might be start of a partial frame
316
+ if len(self._rxbuf) > 2:
317
+ self._rxbuf = self._rxbuf[-2:]
318
+ break
319
+
320
+ # Need at least 9 bytes for header: $X> + flag(1) + cmd(2) + size(2) + crc(1)
321
+ if len(self._rxbuf) - idx < 9:
322
+ break # Incomplete header, wait for more data
323
+
324
+ # Check direction byte
325
+ direction = self._rxbuf[idx + 2:idx + 3]
326
+ if direction == b"!":
327
+ # Error frame - skip it
328
+ search_start = idx + 1
329
+ continue
330
+
331
+ # Parse size to check if full frame is available
332
+ size = struct.unpack_from("<H", self._rxbuf, idx + 6)[0]
333
+ frame_len = 8 + size + 1 # header(8) + payload + crc(1)
334
+
335
+ if len(self._rxbuf) - idx < frame_len:
336
+ break # Incomplete frame, wait for more data
337
+
338
+ # Full frame available - try decode
339
+ result = msp_v2_decode(self._rxbuf[idx:idx + frame_len])
340
+ if result is not None:
341
+ cmd, payload = result
342
+ if expected_cmd is None or cmd == expected_cmd:
343
+ # Consume this frame from the buffer
344
+ self._rxbuf = self._rxbuf[idx + frame_len:]
345
+ return result
346
+
347
+ # CRC mismatch or wrong cmd - skip this $X marker
348
+ search_start = idx + 1
349
+
350
+ # If buffer has partial data but no new bytes arrived from
351
+ # serial, sleep briefly to avoid hot-spinning. Without this,
352
+ # the loop burns 100% CPU while waiting for the rest of a
353
+ # frame, which starves USB servicing in heavy processes
354
+ # (numpy/scipy loaded) and causes cascading slowdowns.
355
+ if waiting == 0:
356
+ time.sleep(0.0002)
357
+
358
+ return None
359
+
360
+ def _request(self, cmd, payload=b"", timeout=None):
361
+ """Send a request and wait for the matching response.
362
+
363
+ Returns payload bytes or None on timeout/error.
364
+ """
365
+ self._send(cmd, payload)
366
+ result = self._recv(expected_cmd=cmd, timeout=timeout)
367
+ if result:
368
+ return result[1]
369
+ return None
370
+
371
+ # ── FC Identification ─────────────────────────────────────────────────
372
+
373
+ def get_fc_variant(self):
374
+ """Get FC variant string (e.g., 'INAV')."""
375
+ payload = self._request(MSP_FC_VARIANT)
376
+ if payload and len(payload) >= 4:
377
+ return payload[:4].decode("ascii", errors="ignore")
378
+ return None
379
+
380
+ def get_fc_version(self):
381
+ """Get FC firmware version as (major, minor, patch) tuple."""
382
+ payload = self._request(MSP_FC_VERSION)
383
+ if payload and len(payload) >= 3:
384
+ return (payload[0], payload[1], payload[2])
385
+ return None
386
+
387
+ def get_craft_name(self):
388
+ """Get craft name string."""
389
+ payload = self._request(MSP_NAME)
390
+ if payload:
391
+ return payload.decode("ascii", errors="ignore").strip("\x00").strip()
392
+ return ""
393
+
394
+ def get_board_info(self):
395
+ """Get board identifier string."""
396
+ payload = self._request(MSP_BOARD_INFO)
397
+ if payload and len(payload) >= 4:
398
+ return payload[:4].decode("ascii", errors="ignore")
399
+ return None
400
+
401
+ def get_info(self):
402
+ """Get comprehensive FC identification.
403
+
404
+ Returns dict with fc_variant, version, craft_name, board, firmware.
405
+ """
406
+ if self._info:
407
+ return self._info
408
+
409
+ variant = self.get_fc_variant()
410
+ if not variant:
411
+ return None
412
+
413
+ version = self.get_fc_version()
414
+ craft = self.get_craft_name()
415
+ board = self.get_board_info()
416
+
417
+ version_str = f"{version[0]}.{version[1]}.{version[2]}" if version else "?"
418
+ firmware = f"{variant} {version_str}"
419
+
420
+ self._info = {
421
+ "fc_variant": variant,
422
+ "version": version,
423
+ "version_str": version_str,
424
+ "craft_name": craft,
425
+ "board": board,
426
+ "firmware": firmware,
427
+ }
428
+ return self._info
429
+
430
+ # ── Dataflash (Blackbox) ──────────────────────────────────────────────
431
+
432
+ def get_dataflash_summary(self):
433
+ """Get dataflash status and size info.
434
+
435
+ Returns dict with:
436
+ ready (bool): Flash ready for read
437
+ supported (bool): Dataflash present (inferred from total_size > 0)
438
+ sectors (int): Number of flash sectors
439
+ total_size (int): Total flash size in bytes
440
+ used_size (int): Used flash size in bytes
441
+ """
442
+ # Retry up to 3 times - first attempt after get_info() can
443
+ # hit stale serial data on some FCs
444
+ for attempt in range(3):
445
+ payload = self._request(MSP_DATAFLASH_SUMMARY)
446
+ if payload and len(payload) >= 13:
447
+ break
448
+ time.sleep(0.1)
449
+ else:
450
+ return None
451
+
452
+ flags, sectors, total_size, used_size = struct.unpack_from("<BIII", payload, 0)
453
+
454
+ # INAV uses flags byte as a simple ready boolean (0x01 = ready).
455
+ # Unlike Betaflight, INAV does NOT set bit 1 for "supported".
456
+ # Detect support from total_size > 0 instead.
457
+ return {
458
+ "ready": bool(flags & 0x01),
459
+ "supported": total_size > 0,
460
+ "sectors": sectors,
461
+ "total_size": total_size,
462
+ "used_size": used_size,
463
+ }
464
+
465
+ def get_blackbox_config(self):
466
+ """Get blackbox configuration including storage device type.
467
+
468
+ Returns dict with:
469
+ device (int): Blackbox device type (BB_DEVICE_* constants)
470
+ device_name (str): Human-readable device name
471
+ rate_num (int): Blackbox rate numerator
472
+ rate_denom (int): Blackbox rate denominator
473
+ Or None on error.
474
+ """
475
+ payload = self._request(MSP_BLACKBOX_CONFIG)
476
+ if not payload or len(payload) < 3:
477
+ return None
478
+ return {
479
+ "device": payload[0],
480
+ "device_name": BB_DEVICE_NAMES.get(payload[0], f"UNKNOWN({payload[0]})"),
481
+ "rate_num": payload[1],
482
+ "rate_denom": payload[2],
483
+ }
484
+
485
+ def read_dataflash_chunk(self, address, size=4096):
486
+ """Read a chunk of dataflash at the given address.
487
+
488
+ Args:
489
+ address: Byte offset in dataflash
490
+ size: Requested read size (FC may return less)
491
+
492
+ Returns:
493
+ (actual_address, data_bytes) or None on error
494
+ """
495
+ # MSP_DATAFLASH_READ request: address(u32) + requestedSize(u16)
496
+ # Note: INAV does NOT require the compression flag byte
497
+ req = struct.pack("<IH", address, size)
498
+ payload = self._request(MSP_DATAFLASH_READ, req, timeout=5.0)
499
+
500
+ if not payload or len(payload) < 5:
501
+ return None
502
+
503
+ resp_addr = struct.unpack_from("<I", payload, 0)[0]
504
+
505
+ # INAV response format: address(u32) + data
506
+ # (Betaflight v2 adds dataSize + compressedSize, but INAV doesn't)
507
+ data = payload[4:]
508
+
509
+ if len(data) == 0:
510
+ return None
511
+
512
+ return (resp_addr, data)
513
+
514
+ def _send_dataflash_read(self, address, size=4096):
515
+ """Send a dataflash read request WITHOUT waiting for response.
516
+
517
+ Used for pipelining - fire multiple requests, collect responses later.
518
+ Returns True if sent, False on write failure (buffer full).
519
+ """
520
+ req = struct.pack("<IH", address, size)
521
+ frame = msp_v2_encode(MSP_DATAFLASH_READ, req)
522
+ try:
523
+ self._ser.write(frame)
524
+ return True
525
+ except serial.SerialTimeoutException:
526
+ return False
527
+
528
+ def _recv_dataflash_chunk(self, timeout=5.0):
529
+ """Receive a single dataflash read response.
530
+
531
+ Returns (address, data_bytes) or None on timeout.
532
+ """
533
+ result = self._recv(expected_cmd=MSP_DATAFLASH_READ, timeout=timeout)
534
+ if result is None:
535
+ return None
536
+ payload = result[1]
537
+ if not payload or len(payload) < 5:
538
+ return None
539
+ resp_addr = struct.unpack_from("<I", payload, 0)[0]
540
+ data = payload[4:]
541
+ if len(data) == 0:
542
+ return None
543
+ return (resp_addr, data)
544
+
545
+ def download_blackbox(self, output_dir="./blackbox", erase_after=False,
546
+ progress_callback=None):
547
+ """Download entire blackbox log from dataflash.
548
+
549
+ Uses pipelined MSP reads for maximum throughput - multiple read
550
+ requests are sent before collecting responses, eliminating idle
551
+ time between round-trips.
552
+
553
+ Args:
554
+ output_dir: Directory to save the .bbl file
555
+ erase_after: If True, erase dataflash after successful download
556
+ progress_callback: Optional fn(bytes_read, total_bytes) for progress
557
+
558
+ Returns:
559
+ filepath of saved .bbl file, or None on failure
560
+ """
561
+ summary = self.get_dataflash_summary()
562
+ if not summary:
563
+ print(" ERROR: Could not read dataflash summary")
564
+ return None
565
+
566
+ if not summary["supported"]:
567
+ # Check if this FC uses SD card or serial instead of dataflash
568
+ bb_config = self.get_blackbox_config()
569
+ if bb_config and bb_config["device"] == BB_DEVICE_SDCARD:
570
+ print(" This FC uses an SD card for blackbox logging.")
571
+ print(" Direct SD card download is not yet supported.")
572
+ print(" To get your logs:")
573
+ print(" - Remove the SD card and copy .bbl files, or")
574
+ print(" - Type 'msc' in INAV Configurator CLI to mount as USB drive")
575
+ print(" Then run the analyzer on the file directly.")
576
+ elif bb_config and bb_config["device"] == BB_DEVICE_SERIAL:
577
+ print(" This FC logs blackbox over serial (OpenLog/external logger).")
578
+ print(" Retrieve logs from the external logger's SD card,")
579
+ print(" then run the analyzer on the file directly.")
580
+ else:
581
+ print(" ERROR: Dataflash not supported on this board")
582
+ return None
583
+
584
+ if not summary["ready"]:
585
+ print(" ERROR: Dataflash not ready")
586
+ return None
587
+
588
+ used = summary["used_size"]
589
+ total = summary["total_size"]
590
+
591
+ if used == 0:
592
+ print(" No blackbox data on flash (0 bytes used)")
593
+ return None
594
+
595
+ # Get FC info for filename
596
+ info = self.get_info()
597
+ craft = info.get("craft_name", "unknown") if info else "unknown"
598
+ # Sanitize craft name for filename
599
+ safe_craft = "".join(c if c.isalnum() or c in "-_ " else "_" for c in craft)
600
+ safe_craft = safe_craft.strip().replace(" ", "_")
601
+ if not safe_craft:
602
+ safe_craft = "blackbox"
603
+
604
+ timestamp = time.strftime("%Y-%m-%d_%H%M%S")
605
+ filename = f"{safe_craft}_{timestamp}.bbl"
606
+
607
+ os.makedirs(output_dir, exist_ok=True)
608
+ filepath = os.path.join(output_dir, filename)
609
+
610
+ # ── Determine optimal chunk size ──
611
+ # Start with a single request to probe actual response size.
612
+ # The FC returns MIN(requested, buffer_space, remaining_data).
613
+ # Typical: 2048 on F4, 4096 on F7/H7 targets.
614
+
615
+ # Flush any stale data (critical after CLI mode reconnection —
616
+ # dump all leaves residual bytes that corrupt the first read)
617
+ time.sleep(0.1)
618
+ if self._ser.in_waiting:
619
+ self._ser.read(self._ser.in_waiting)
620
+ self._rxbuf = b""
621
+
622
+ # Verify connection is alive before starting download
623
+ try:
624
+ identity = self._request(MSP_FC_VARIANT, timeout=1.0)
625
+ if not identity:
626
+ raise Exception("no response")
627
+ except Exception:
628
+ # Connection stale — flush harder and retry
629
+ time.sleep(0.5)
630
+ if self._ser.in_waiting:
631
+ self._ser.read(self._ser.in_waiting)
632
+ self._rxbuf = b""
633
+
634
+ probe = self.read_dataflash_chunk(0, 4096)
635
+ if probe is None:
636
+ # Retry with flush — likely stale data from CLI reconnection
637
+ time.sleep(0.3)
638
+ if self._ser.in_waiting:
639
+ self._ser.read(self._ser.in_waiting)
640
+ self._rxbuf = b""
641
+ probe = self.read_dataflash_chunk(0, 4096)
642
+ if probe is None:
643
+ # Fall back to smaller chunk
644
+ probe = self.read_dataflash_chunk(0, 1024)
645
+ if probe is None:
646
+ print(" ERROR: Cannot read dataflash")
647
+ return None
648
+
649
+ chunk_size = len(probe[1])
650
+ # Pipeline depth: how many requests to keep in-flight.
651
+ # USB VCP has limited buffer - too many in-flight fills the FC's
652
+ # USB output buffer and causes write timeouts. 4 is safe for F4/F7/H7.
653
+ pipeline_depth = 4 if chunk_size >= 1024 else 1
654
+
655
+ data_buf = bytearray()
656
+ data_buf.extend(probe[1]) # Already have first chunk
657
+ address = len(probe[1])
658
+
659
+ start_time = time.monotonic()
660
+ retries = 0
661
+ max_retries = 10
662
+
663
+ print(f" Downloading {used / 1024:.0f}KB ({chunk_size}B chunks, "
664
+ f"pipeline={pipeline_depth})...")
665
+
666
+ # ── Pipelined download loop ──
667
+ # Strategy: keep N requests in flight at all times.
668
+ # Fire N requests, then enter a loop: receive 1 response, fire 1 request.
669
+ # This keeps the FC busy reading flash while we process the previous chunk.
670
+
671
+ if pipeline_depth > 1:
672
+ # Flush any stale data
673
+ if self._ser.in_waiting:
674
+ self._ser.read(self._ser.in_waiting)
675
+ self._rxbuf = b""
676
+
677
+ in_flight = 0
678
+ next_send_addr = address
679
+
680
+ # Prime the pipeline gradually to avoid overwhelming USB buffer
681
+ while in_flight < pipeline_depth and next_send_addr < used:
682
+ if not self._send_dataflash_read(next_send_addr, chunk_size):
683
+ # Write failed - drain some responses first
684
+ time.sleep(0.01)
685
+ if self._ser.in_waiting:
686
+ self._rxbuf += self._ser.read(self._ser.in_waiting)
687
+ if not self._send_dataflash_read(next_send_addr, chunk_size):
688
+ break # Still failing, proceed with what we have
689
+ next_send_addr += chunk_size
690
+ in_flight += 1
691
+ # Small delay between initial sends to let FC start processing
692
+ if in_flight < pipeline_depth:
693
+ time.sleep(0.002)
694
+
695
+ drain_retries = 0
696
+ max_drain_retries = 5
697
+ while in_flight > 0 or next_send_addr < used:
698
+ # Re-prime if pipeline drained but work remains
699
+ if in_flight == 0 and next_send_addr < used:
700
+ # Flush any stale data in buffers before re-priming
701
+ if self._ser.in_waiting:
702
+ self._ser.read(self._ser.in_waiting)
703
+ self._rxbuf = b""
704
+ time.sleep(0.05 * (drain_retries + 1))
705
+
706
+ while in_flight < pipeline_depth and next_send_addr < used:
707
+ if self._send_dataflash_read(next_send_addr, chunk_size):
708
+ next_send_addr += chunk_size
709
+ in_flight += 1
710
+ time.sleep(0.002)
711
+ else:
712
+ time.sleep(0.01)
713
+ break
714
+
715
+ if in_flight == 0:
716
+ drain_retries += 1
717
+ if drain_retries > max_drain_retries:
718
+ print(f"\n ERROR: Download stalled at {len(data_buf)/1024:.0f}KB "
719
+ f"({address * 100 // used}%) - FC stopped responding")
720
+ return None
721
+ continue # retry re-prime
722
+
723
+ # Receive one response
724
+ result = self._recv_dataflash_chunk(timeout=5.0)
725
+
726
+ if result is None:
727
+ retries += 1
728
+ if retries > max_retries:
729
+ print(f"\n ERROR: Too many read errors at offset {address}")
730
+ return None
731
+ # Re-send all in-flight requests from current address
732
+ time.sleep(0.05 * retries)
733
+ if self._ser.in_waiting:
734
+ self._ser.read(self._ser.in_waiting)
735
+ self._rxbuf = b""
736
+ in_flight = 0
737
+ next_send_addr = address
738
+ while in_flight < pipeline_depth and next_send_addr < used:
739
+ if not self._send_dataflash_read(next_send_addr, chunk_size):
740
+ time.sleep(0.01)
741
+ if not self._send_dataflash_read(next_send_addr, chunk_size):
742
+ break
743
+ next_send_addr += chunk_size
744
+ in_flight += 1
745
+ time.sleep(0.002)
746
+ continue
747
+
748
+ retries = 0
749
+ drain_retries = 0
750
+ resp_addr, data = result
751
+
752
+ # Handle out-of-order or duplicate responses
753
+ if resp_addr == address:
754
+ data_buf.extend(data)
755
+ address += len(data)
756
+ in_flight -= 1
757
+
758
+ # Send next request to keep pipeline full
759
+ if next_send_addr < used:
760
+ if self._send_dataflash_read(next_send_addr, chunk_size):
761
+ next_send_addr += chunk_size
762
+ in_flight += 1
763
+ else:
764
+ # Write failed - FC buffer full, just continue draining
765
+ pass
766
+ else:
767
+ # Got unexpected address - drain pipeline, resync
768
+ in_flight -= 1
769
+ if resp_addr > address:
770
+ address = resp_addr
771
+ data_buf.extend(data)
772
+ address += len(data)
773
+
774
+ # Progress
775
+ elapsed = time.monotonic() - start_time
776
+ speed = len(data_buf) / elapsed if elapsed > 0 else 0
777
+ pct = min(100, address * 100 // used)
778
+ bar_width = 20
779
+ filled = bar_width * pct // 100
780
+ bar = "█" * filled + "░" * (bar_width - filled)
781
+ print(f"\r Downloading: {pct:3d}% [{bar}] "
782
+ f"{len(data_buf) / 1024:.0f}KB / {used / 1024:.0f}KB "
783
+ f"{speed / 1024:.0f}KB/s", end="", flush=True)
784
+
785
+ if progress_callback:
786
+ progress_callback(len(data_buf), used)
787
+
788
+ else:
789
+ # Simple sequential download (fallback for tiny chunks)
790
+ while address < used:
791
+ chunk = self.read_dataflash_chunk(address, chunk_size)
792
+
793
+ if chunk is None:
794
+ retries += 1
795
+ if retries > max_retries:
796
+ print(f"\n ERROR: Too many read errors at offset {address}")
797
+ return None
798
+ time.sleep(0.05 * retries)
799
+ if self._ser.in_waiting:
800
+ self._ser.read(self._ser.in_waiting)
801
+ continue
802
+
803
+ retries = 0
804
+ resp_addr, data = chunk
805
+ if resp_addr != address:
806
+ address = resp_addr
807
+ data_buf.extend(data)
808
+ address += len(data)
809
+
810
+ elapsed = time.monotonic() - start_time
811
+ speed = len(data_buf) / elapsed if elapsed > 0 else 0
812
+ pct = min(100, address * 100 // used)
813
+ bar_width = 20
814
+ filled = bar_width * pct // 100
815
+ bar = "█" * filled + "░" * (bar_width - filled)
816
+ print(f"\r Downloading: {pct:3d}% [{bar}] "
817
+ f"{len(data_buf) / 1024:.0f}KB / {used / 1024:.0f}KB "
818
+ f"{speed / 1024:.0f}KB/s", end="", flush=True)
819
+
820
+ if progress_callback:
821
+ progress_callback(len(data_buf), used)
822
+
823
+ print() # newline after progress bar
824
+
825
+ elapsed = time.monotonic() - start_time
826
+ avg_speed = len(data_buf) / elapsed if elapsed > 0 else 0
827
+
828
+ # Completeness check - don't save partial downloads as success
829
+ completeness = len(data_buf) / used if used > 0 else 1.0
830
+ if completeness < 0.95:
831
+ print(f" ✖ Download incomplete: {len(data_buf)/1024:.0f}KB of {used/1024:.0f}KB "
832
+ f"({completeness*100:.0f}%)")
833
+ print(f" Try: unplug/replug USB and retry")
834
+ return None
835
+
836
+ # Write file
837
+ with open(filepath, "wb") as f:
838
+ f.write(data_buf)
839
+
840
+ print(f" ✓ Saved: {filepath} ({len(data_buf) / 1024:.0f}KB in {elapsed:.1f}s, "
841
+ f"{avg_speed / 1024:.0f}KB/s)")
842
+
843
+ # Erase if requested
844
+ if erase_after:
845
+ self.erase_dataflash()
846
+
847
+ return filepath
848
+
849
+ def erase_dataflash(self):
850
+ """Erase all blackbox data from dataflash."""
851
+ print(" Erasing dataflash...", end="", flush=True)
852
+ self._send(MSP_DATAFLASH_ERASE)
853
+
854
+ # Erase can take a while - poll until ready
855
+ for _ in range(60): # up to 30 seconds
856
+ time.sleep(0.5)
857
+ summary = self.get_dataflash_summary()
858
+ if summary and summary["ready"] and summary["used_size"] == 0:
859
+ print(" done")
860
+ return True
861
+
862
+ # Check one more time
863
+ summary = self.get_dataflash_summary()
864
+ if summary and summary["used_size"] == 0:
865
+ print(" done")
866
+ return True
867
+
868
+ print(" timeout (flash may still be erasing)")
869
+ return False
870
+
871
+ # ── CLI Mode (for diff all) ───────────────────────────────────────────
872
+
873
+ def _recover_after_cli(self, max_retries=5):
874
+ """Verify and recover serial connection after CLI mode exit.
875
+
876
+ Some STM32 targets reset their USB VCP when leaving CLI mode,
877
+ causing /dev/ttyACMx to disappear briefly. This method detects
878
+ the dead connection, waits for the port to reappear, and reopens it.
879
+ """
880
+ import serial as _serial
881
+ reconnecting = False
882
+
883
+ for attempt in range(max_retries):
884
+ # Check if port is alive with a quick MSP probe
885
+ try:
886
+ if self._ser and self._ser.is_open:
887
+ self._ser.reset_input_buffer()
888
+ self._rxbuf = b""
889
+ result = self._request(MSP_FC_VARIANT, timeout=1.0)
890
+ if result and len(result) >= 4:
891
+ if reconnecting:
892
+ print(" ok")
893
+ return True
894
+ except BaseException:
895
+ pass # port dead — fd gone, OSError, fileno error, etc.
896
+
897
+ # Connection dead — need to reconnect
898
+ if not reconnecting:
899
+ print(" Reconnecting after CLI exit...", end="", flush=True)
900
+ reconnecting = True
901
+
902
+ # Close whatever we have
903
+ try:
904
+ if self._ser:
905
+ self._ser.close()
906
+ except BaseException:
907
+ pass
908
+ self._ser = None
909
+
910
+ # Wait for the port device node to reappear
911
+ wait_time = 0.5 * (attempt + 1)
912
+ deadline = time.monotonic() + wait_time
913
+ port_exists = False
914
+ while time.monotonic() < deadline:
915
+ if os.path.exists(self.port_path):
916
+ port_exists = True
917
+ time.sleep(0.2) # let kernel finish setting up the device
918
+ break
919
+ time.sleep(0.1)
920
+
921
+ if not port_exists:
922
+ continue # retry — port hasn't come back yet
923
+
924
+ # Reopen
925
+ try:
926
+ self._ser = _serial.Serial(
927
+ port=self.port_path,
928
+ baudrate=self.baudrate,
929
+ timeout=self.timeout,
930
+ write_timeout=self.timeout,
931
+ bytesize=_serial.EIGHTBITS,
932
+ parity=_serial.PARITY_NONE,
933
+ stopbits=_serial.STOPBITS_ONE,
934
+ )
935
+ time.sleep(0.2)
936
+ self._ser.reset_input_buffer()
937
+ self._ser.reset_output_buffer()
938
+ self._rxbuf = b""
939
+ except BaseException:
940
+ continue # retry
941
+
942
+ if reconnecting:
943
+ print(" failed")
944
+ return False # all retries exhausted
945
+
946
+ def cli_command(self, command, timeout=5.0):
947
+ """Send a CLI command and capture the response.
948
+
949
+ INAV enters CLI mode when it receives '#' character.
950
+ Commands are sent as plain text, responses end with '# ' prompt.
951
+
952
+ Args:
953
+ command: CLI command string (e.g., 'diff all')
954
+ timeout: Max seconds to wait for response
955
+
956
+ Returns:
957
+ Response string (without prompt), or None on error
958
+ """
959
+ ser = self._ser
960
+
961
+ # Flush any pending data
962
+ if ser.in_waiting:
963
+ ser.read(ser.in_waiting)
964
+
965
+ # Enter CLI mode by sending '#'
966
+ ser.write(b"#")
967
+ time.sleep(0.3)
968
+
969
+ # Read and discard the CLI banner
970
+ if ser.in_waiting:
971
+ ser.read(ser.in_waiting)
972
+
973
+ # Send the command
974
+ ser.write((command + "\n").encode("ascii"))
975
+ time.sleep(0.1)
976
+
977
+ # Read response until we get '# ' prompt
978
+ buf = b""
979
+ deadline = time.monotonic() + timeout
980
+
981
+ while time.monotonic() < deadline:
982
+ if ser.in_waiting:
983
+ buf += ser.read(ser.in_waiting)
984
+ # Look for the CLI prompt at the end
985
+ # INAV CLI prompt is "\r\n# " at the end of output
986
+ if buf.rstrip().endswith(b"#") or buf.endswith(b"# "):
987
+ break
988
+ else:
989
+ time.sleep(0.01)
990
+
991
+ # Exit CLI mode
992
+ # Some FCs (especially STM32 targets) briefly reset their USB VCP
993
+ # when leaving CLI mode. We need to verify the connection is still
994
+ # alive and reconnect if it died.
995
+ try:
996
+ ser.write(b"exit\n")
997
+ except OSError:
998
+ pass # port may already be dead
999
+ time.sleep(1.0) # give FC time to reinitialize USB
1000
+
1001
+ # Verify connection is alive with a simple MSP probe
1002
+ self._recover_after_cli()
1003
+
1004
+ if not buf:
1005
+ return None
1006
+
1007
+ # Decode and clean up
1008
+ try:
1009
+ text = buf.decode("ascii", errors="replace")
1010
+ except Exception:
1011
+ return None
1012
+
1013
+ # Remove the command echo and trailing prompt
1014
+ lines = text.splitlines()
1015
+ # Skip echo of our command and trailing prompt
1016
+ result_lines = []
1017
+ for line in lines:
1018
+ line = line.rstrip()
1019
+ if line == command:
1020
+ continue # Skip command echo
1021
+ if line == "#" or line == "# ":
1022
+ continue # Skip prompt
1023
+ result_lines.append(line)
1024
+
1025
+ return "\n".join(result_lines).strip()
1026
+
1027
+ def get_diff_all(self, timeout=10.0):
1028
+ """Pull the full 'diff all' configuration from the FC.
1029
+
1030
+ Returns:
1031
+ Raw diff output string, or None on error
1032
+ """
1033
+ return self.cli_command("diff all", timeout=timeout)
1034
+
1035
+ def get_dump_all(self, timeout=30.0):
1036
+ """Pull the full 'dump all' configuration from the FC.
1037
+
1038
+ This is a complete backup of every parameter — much larger output
1039
+ than 'diff all' which only shows non-defaults. The larger output
1040
+ means the USB reset after CLI exit can take longer.
1041
+
1042
+ Returns:
1043
+ Raw dump output string, or None on error
1044
+ """
1045
+ ser = self._ser
1046
+
1047
+ # Flush any pending data
1048
+ if ser.in_waiting:
1049
+ ser.read(ser.in_waiting)
1050
+
1051
+ # Enter CLI mode
1052
+ ser.write(b"#")
1053
+ time.sleep(0.3)
1054
+ if ser.in_waiting:
1055
+ ser.read(ser.in_waiting)
1056
+
1057
+ # Send command
1058
+ ser.write(b"dump all\n")
1059
+ time.sleep(0.1)
1060
+
1061
+ # Read response — dump all is large, needs full timeout
1062
+ buf = b""
1063
+ deadline = time.monotonic() + timeout
1064
+ while time.monotonic() < deadline:
1065
+ if ser.in_waiting:
1066
+ buf += ser.read(ser.in_waiting)
1067
+ if buf.rstrip().endswith(b"#") or buf.endswith(b"# "):
1068
+ break
1069
+ else:
1070
+ time.sleep(0.01)
1071
+
1072
+ # Exit CLI mode — give extra time for large output to finish flushing
1073
+ try:
1074
+ ser.write(b"exit\n")
1075
+ except OSError:
1076
+ pass
1077
+ time.sleep(3.0) # longer wait for dump all (vs 1.0s for diff all)
1078
+
1079
+ self._recover_after_cli(max_retries=8)
1080
+
1081
+ if not buf:
1082
+ return None
1083
+
1084
+ try:
1085
+ text = buf.decode("ascii", errors="replace")
1086
+ except Exception:
1087
+ return None
1088
+
1089
+ lines = text.splitlines()
1090
+ result_lines = []
1091
+ for line in lines:
1092
+ line = line.rstrip()
1093
+ if line == "dump all":
1094
+ continue
1095
+ if line == "#" or line == "# ":
1096
+ continue
1097
+ result_lines.append(line)
1098
+
1099
+ result = "\n".join(result_lines).strip()
1100
+ return result if result else None
1101
+
1102
+ def cli_batch(self, commands, timeout=5.0, save=True):
1103
+ """Send multiple CLI commands in a single CLI session.
1104
+
1105
+ Enters CLI mode once, sends all commands, optionally saves,
1106
+ then exits. Much faster than calling cli_command() per line.
1107
+
1108
+ Args:
1109
+ commands: List of CLI command strings (e.g., ['set mc_p_roll = 28'])
1110
+ timeout: Max seconds to wait per command response
1111
+ save: If True, sends 'save' after all commands
1112
+
1113
+ Returns:
1114
+ List of (command, response) tuples
1115
+ """
1116
+ ser = self._ser
1117
+
1118
+ # Flush any pending data
1119
+ if ser.in_waiting:
1120
+ ser.read(ser.in_waiting)
1121
+
1122
+ # Enter CLI mode
1123
+ ser.write(b"#")
1124
+ time.sleep(0.3)
1125
+ if ser.in_waiting:
1126
+ ser.read(ser.in_waiting)
1127
+
1128
+ results = []
1129
+ all_cmds = list(commands)
1130
+ if save:
1131
+ all_cmds.append("save")
1132
+
1133
+ for cmd in all_cmds:
1134
+ ser.write((cmd + "\n").encode("ascii"))
1135
+ time.sleep(0.05)
1136
+
1137
+ # Read until prompt
1138
+ buf = b""
1139
+ deadline = time.monotonic() + timeout
1140
+ while time.monotonic() < deadline:
1141
+ if ser.in_waiting:
1142
+ buf += ser.read(ser.in_waiting)
1143
+ if buf.rstrip().endswith(b"#") or buf.endswith(b"# "):
1144
+ break
1145
+ else:
1146
+ time.sleep(0.01)
1147
+
1148
+ try:
1149
+ text = buf.decode("ascii", errors="replace")
1150
+ except Exception:
1151
+ text = ""
1152
+
1153
+ # Clean response
1154
+ lines = []
1155
+ for line in text.splitlines():
1156
+ line = line.rstrip()
1157
+ if line == cmd or line == "#" or line == "# ":
1158
+ continue
1159
+ lines.append(line)
1160
+ results.append((cmd, "\n".join(lines).strip()))
1161
+
1162
+ # Exit CLI mode (if save was sent, FC will reboot — USB may reset)
1163
+ try:
1164
+ ser.write(b"exit\n")
1165
+ except OSError:
1166
+ pass
1167
+ # save triggers a reboot (~3s), plain exit just returns to MSP (~1s)
1168
+ time.sleep(3.0 if save else 1.0)
1169
+
1170
+ # Verify connection survived CLI exit / reboot
1171
+ self._recover_after_cli(max_retries=5 if save else 3)
1172
+
1173
+ return results
1174
+
1175
+
1176
+ # ─── CLI Entrypoint ──────────────────────────────────────────────────────────
1177
+
1178
+ def main():
1179
+ """Standalone usage: identify FC and download blackbox."""
1180
+ import argparse
1181
+
1182
+ parser = argparse.ArgumentParser(
1183
+ description="INAV MSP - Download blackbox logs directly from flight controller")
1184
+ parser.add_argument("--version", action="version", version=f"inav-msp {VERSION}")
1185
+ parser.add_argument("--device", "-d", default="auto",
1186
+ help="Serial port or 'auto' to scan. "
1187
+ "Examples: auto, /dev/ttyACM0 (Linux), "
1188
+ "/dev/cu.usbmodem14201 (macOS), COM3 (Windows)")
1189
+ parser.add_argument("--baud", type=int, default=115200,
1190
+ help="Baud rate (default: 115200)")
1191
+ parser.add_argument("--output-dir", "-o", default="./blackbox",
1192
+ help="Directory to save downloaded logs (default: ./blackbox)")
1193
+ parser.add_argument("--erase", action="store_true",
1194
+ help="Erase dataflash after successful download")
1195
+ parser.add_argument("--info-only", action="store_true",
1196
+ help="Only show FC info, don't download")
1197
+ args = parser.parse_args()
1198
+
1199
+ print(f"\n ▲ INAV MSP v{VERSION}")
1200
+
1201
+ # Connect
1202
+ fc = None
1203
+ if args.device == "auto":
1204
+ print(" Scanning for INAV flight controller...")
1205
+ fc, info = auto_detect_fc(baudrate=args.baud)
1206
+ if not fc:
1207
+ print(" ERROR: No INAV flight controller found.")
1208
+ ports = find_serial_ports()
1209
+ if ports:
1210
+ print(f" Ports found but none responded as INAV: {', '.join(ports)}")
1211
+ print(" Make sure the FC is powered and not in DFU mode.")
1212
+ else:
1213
+ print(" No serial ports detected. Is the FC connected via USB?")
1214
+ sys.exit(1)
1215
+ print(f" Found: {fc.port_path}")
1216
+ else:
1217
+ port = args.device
1218
+ if not os.path.exists(port):
1219
+ print(f" ERROR: Port not found: {port}")
1220
+ sys.exit(1)
1221
+ print(f" Connecting: {port}")
1222
+ fc = INAVDevice(port, baudrate=args.baud)
1223
+ fc.open()
1224
+ info = fc.get_info()
1225
+
1226
+ try:
1227
+ if not info:
1228
+ print(" ERROR: No response from FC. Check connection and baud rate.")
1229
+ sys.exit(1)
1230
+
1231
+ print(f"\n {'─' * 50}")
1232
+ print(f" Firmware: {info['firmware']}")
1233
+ print(f" Craft: {info['craft_name'] or '(not set)'}")
1234
+ print(f" Board: {info['board'] or '?'}")
1235
+
1236
+ summary = fc.get_dataflash_summary()
1237
+ if summary and summary['supported']:
1238
+ used_kb = summary['used_size'] / 1024
1239
+ total_kb = summary['total_size'] / 1024
1240
+ pct = summary['used_size'] * 100 // summary['total_size'] if summary['total_size'] > 0 else 0
1241
+ print(f" Dataflash: {used_kb:.0f}KB / {total_kb:.0f}KB ({pct}% used)")
1242
+ print(f" {'─' * 50}")
1243
+
1244
+ if args.info_only:
1245
+ return
1246
+
1247
+ if summary['used_size'] == 0:
1248
+ print("\n No blackbox data to download.")
1249
+ return
1250
+
1251
+ print()
1252
+ filepath = fc.download_blackbox(
1253
+ output_dir=args.output_dir,
1254
+ erase_after=args.erase,
1255
+ )
1256
+
1257
+ if filepath:
1258
+ print(f"\n Ready to analyze:")
1259
+ print(f" inav-analyze {filepath}")
1260
+ else:
1261
+ # No dataflash - check what blackbox device is configured
1262
+ bb_config = fc.get_blackbox_config()
1263
+ if bb_config and bb_config["device"] == BB_DEVICE_SDCARD:
1264
+ print(f" Blackbox: SD card")
1265
+ print(f" {'─' * 50}")
1266
+ print(f"\n Direct SD card download is not yet supported.")
1267
+ print(f" To get your logs:")
1268
+ print(f" - Remove the SD card and copy .bbl files, or")
1269
+ print(f" - Type 'msc' in INAV Configurator CLI to mount as USB drive")
1270
+ print(f" Then run the analyzer on the file directly:")
1271
+ print(f" inav-analyze <file.bbl>")
1272
+ elif bb_config and bb_config["device"] == BB_DEVICE_SERIAL:
1273
+ print(f" Blackbox: serial (external logger)")
1274
+ print(f" {'─' * 50}")
1275
+ print(f"\n Retrieve logs from the external logger's SD card,")
1276
+ print(f" then run the analyzer on the file directly.")
1277
+ else:
1278
+ print(" Dataflash: not available")
1279
+ print(f" {'─' * 50}")
1280
+
1281
+ except KeyboardInterrupt:
1282
+ print("\n Interrupted.")
1283
+ sys.exit(1)
1284
+ except Exception as e:
1285
+ print(f" ERROR: {e}")
1286
+ sys.exit(1)
1287
+ finally:
1288
+ if fc:
1289
+ fc.close()
1290
+
1291
+
1292
+ if __name__ == "__main__":
1293
+ main()