inav-toolkit 2.15.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,1115 @@
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.15.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
+ probe = self.read_dataflash_chunk(0, 4096)
615
+ if probe is None:
616
+ # Fall back to smaller chunk
617
+ probe = self.read_dataflash_chunk(0, 1024)
618
+ if probe is None:
619
+ print(" ERROR: Cannot read dataflash")
620
+ return None
621
+
622
+ chunk_size = len(probe[1])
623
+ # Pipeline depth: how many requests to keep in-flight.
624
+ # USB VCP has limited buffer - too many in-flight fills the FC's
625
+ # USB output buffer and causes write timeouts. 4 is safe for F4/F7/H7.
626
+ pipeline_depth = 4 if chunk_size >= 1024 else 1
627
+
628
+ data_buf = bytearray()
629
+ data_buf.extend(probe[1]) # Already have first chunk
630
+ address = len(probe[1])
631
+
632
+ start_time = time.monotonic()
633
+ retries = 0
634
+ max_retries = 10
635
+
636
+ print(f" Downloading {used / 1024:.0f}KB ({chunk_size}B chunks, "
637
+ f"pipeline={pipeline_depth})...")
638
+
639
+ # ── Pipelined download loop ──
640
+ # Strategy: keep N requests in flight at all times.
641
+ # Fire N requests, then enter a loop: receive 1 response, fire 1 request.
642
+ # This keeps the FC busy reading flash while we process the previous chunk.
643
+
644
+ if pipeline_depth > 1:
645
+ # Flush any stale data
646
+ if self._ser.in_waiting:
647
+ self._ser.read(self._ser.in_waiting)
648
+ self._rxbuf = b""
649
+
650
+ in_flight = 0
651
+ next_send_addr = address
652
+
653
+ # Prime the pipeline gradually to avoid overwhelming USB buffer
654
+ while in_flight < pipeline_depth and next_send_addr < used:
655
+ if not self._send_dataflash_read(next_send_addr, chunk_size):
656
+ # Write failed - drain some responses first
657
+ time.sleep(0.01)
658
+ if self._ser.in_waiting:
659
+ self._rxbuf += self._ser.read(self._ser.in_waiting)
660
+ if not self._send_dataflash_read(next_send_addr, chunk_size):
661
+ break # Still failing, proceed with what we have
662
+ next_send_addr += chunk_size
663
+ in_flight += 1
664
+ # Small delay between initial sends to let FC start processing
665
+ if in_flight < pipeline_depth:
666
+ time.sleep(0.002)
667
+
668
+ drain_retries = 0
669
+ max_drain_retries = 5
670
+ while in_flight > 0 or next_send_addr < used:
671
+ # Re-prime if pipeline drained but work remains
672
+ if in_flight == 0 and next_send_addr < used:
673
+ # Flush any stale data in buffers before re-priming
674
+ if self._ser.in_waiting:
675
+ self._ser.read(self._ser.in_waiting)
676
+ self._rxbuf = b""
677
+ time.sleep(0.05 * (drain_retries + 1))
678
+
679
+ while in_flight < pipeline_depth and next_send_addr < used:
680
+ if self._send_dataflash_read(next_send_addr, chunk_size):
681
+ next_send_addr += chunk_size
682
+ in_flight += 1
683
+ time.sleep(0.002)
684
+ else:
685
+ time.sleep(0.01)
686
+ break
687
+
688
+ if in_flight == 0:
689
+ drain_retries += 1
690
+ if drain_retries > max_drain_retries:
691
+ print(f"\n ERROR: Download stalled at {len(data_buf)/1024:.0f}KB "
692
+ f"({address * 100 // used}%) - FC stopped responding")
693
+ return None
694
+ continue # retry re-prime
695
+
696
+ # Receive one response
697
+ result = self._recv_dataflash_chunk(timeout=5.0)
698
+
699
+ if result is None:
700
+ retries += 1
701
+ if retries > max_retries:
702
+ print(f"\n ERROR: Too many read errors at offset {address}")
703
+ return None
704
+ # Re-send all in-flight requests from current address
705
+ time.sleep(0.05 * retries)
706
+ if self._ser.in_waiting:
707
+ self._ser.read(self._ser.in_waiting)
708
+ self._rxbuf = b""
709
+ in_flight = 0
710
+ next_send_addr = address
711
+ while in_flight < pipeline_depth and next_send_addr < used:
712
+ if not self._send_dataflash_read(next_send_addr, chunk_size):
713
+ time.sleep(0.01)
714
+ if not self._send_dataflash_read(next_send_addr, chunk_size):
715
+ break
716
+ next_send_addr += chunk_size
717
+ in_flight += 1
718
+ time.sleep(0.002)
719
+ continue
720
+
721
+ retries = 0
722
+ drain_retries = 0
723
+ resp_addr, data = result
724
+
725
+ # Handle out-of-order or duplicate responses
726
+ if resp_addr == address:
727
+ data_buf.extend(data)
728
+ address += len(data)
729
+ in_flight -= 1
730
+
731
+ # Send next request to keep pipeline full
732
+ if next_send_addr < used:
733
+ if self._send_dataflash_read(next_send_addr, chunk_size):
734
+ next_send_addr += chunk_size
735
+ in_flight += 1
736
+ else:
737
+ # Write failed - FC buffer full, just continue draining
738
+ pass
739
+ else:
740
+ # Got unexpected address - drain pipeline, resync
741
+ in_flight -= 1
742
+ if resp_addr > address:
743
+ address = resp_addr
744
+ data_buf.extend(data)
745
+ address += len(data)
746
+
747
+ # Progress
748
+ elapsed = time.monotonic() - start_time
749
+ speed = len(data_buf) / elapsed if elapsed > 0 else 0
750
+ pct = min(100, address * 100 // used)
751
+ bar_width = 20
752
+ filled = bar_width * pct // 100
753
+ bar = "█" * filled + "░" * (bar_width - filled)
754
+ print(f"\r Downloading: {pct:3d}% [{bar}] "
755
+ f"{len(data_buf) / 1024:.0f}KB / {used / 1024:.0f}KB "
756
+ f"{speed / 1024:.0f}KB/s", end="", flush=True)
757
+
758
+ if progress_callback:
759
+ progress_callback(len(data_buf), used)
760
+
761
+ else:
762
+ # Simple sequential download (fallback for tiny chunks)
763
+ while address < used:
764
+ chunk = self.read_dataflash_chunk(address, chunk_size)
765
+
766
+ if chunk is None:
767
+ retries += 1
768
+ if retries > max_retries:
769
+ print(f"\n ERROR: Too many read errors at offset {address}")
770
+ return None
771
+ time.sleep(0.05 * retries)
772
+ if self._ser.in_waiting:
773
+ self._ser.read(self._ser.in_waiting)
774
+ continue
775
+
776
+ retries = 0
777
+ resp_addr, data = chunk
778
+ if resp_addr != address:
779
+ address = resp_addr
780
+ data_buf.extend(data)
781
+ address += len(data)
782
+
783
+ elapsed = time.monotonic() - start_time
784
+ speed = len(data_buf) / elapsed if elapsed > 0 else 0
785
+ pct = min(100, address * 100 // used)
786
+ bar_width = 20
787
+ filled = bar_width * pct // 100
788
+ bar = "█" * filled + "░" * (bar_width - filled)
789
+ print(f"\r Downloading: {pct:3d}% [{bar}] "
790
+ f"{len(data_buf) / 1024:.0f}KB / {used / 1024:.0f}KB "
791
+ f"{speed / 1024:.0f}KB/s", end="", flush=True)
792
+
793
+ if progress_callback:
794
+ progress_callback(len(data_buf), used)
795
+
796
+ print() # newline after progress bar
797
+
798
+ elapsed = time.monotonic() - start_time
799
+ avg_speed = len(data_buf) / elapsed if elapsed > 0 else 0
800
+
801
+ # Completeness check - don't save partial downloads as success
802
+ completeness = len(data_buf) / used if used > 0 else 1.0
803
+ if completeness < 0.95:
804
+ print(f" ✖ Download incomplete: {len(data_buf)/1024:.0f}KB of {used/1024:.0f}KB "
805
+ f"({completeness*100:.0f}%)")
806
+ print(f" Try: unplug/replug USB and retry")
807
+ return None
808
+
809
+ # Write file
810
+ with open(filepath, "wb") as f:
811
+ f.write(data_buf)
812
+
813
+ print(f" ✓ Saved: {filepath} ({len(data_buf) / 1024:.0f}KB in {elapsed:.1f}s, "
814
+ f"{avg_speed / 1024:.0f}KB/s)")
815
+
816
+ # Erase if requested
817
+ if erase_after:
818
+ self.erase_dataflash()
819
+
820
+ return filepath
821
+
822
+ def erase_dataflash(self):
823
+ """Erase all blackbox data from dataflash."""
824
+ print(" Erasing dataflash...", end="", flush=True)
825
+ self._send(MSP_DATAFLASH_ERASE)
826
+
827
+ # Erase can take a while - poll until ready
828
+ for _ in range(60): # up to 30 seconds
829
+ time.sleep(0.5)
830
+ summary = self.get_dataflash_summary()
831
+ if summary and summary["ready"] and summary["used_size"] == 0:
832
+ print(" done")
833
+ return True
834
+
835
+ # Check one more time
836
+ summary = self.get_dataflash_summary()
837
+ if summary and summary["used_size"] == 0:
838
+ print(" done")
839
+ return True
840
+
841
+ print(" timeout (flash may still be erasing)")
842
+ return False
843
+
844
+ # ── CLI Mode (for diff all) ───────────────────────────────────────────
845
+
846
+ def cli_command(self, command, timeout=5.0):
847
+ """Send a CLI command and capture the response.
848
+
849
+ INAV enters CLI mode when it receives '#' character.
850
+ Commands are sent as plain text, responses end with '# ' prompt.
851
+
852
+ Args:
853
+ command: CLI command string (e.g., 'diff all')
854
+ timeout: Max seconds to wait for response
855
+
856
+ Returns:
857
+ Response string (without prompt), or None on error
858
+ """
859
+ ser = self._ser
860
+
861
+ # Flush any pending data
862
+ if ser.in_waiting:
863
+ ser.read(ser.in_waiting)
864
+
865
+ # Enter CLI mode by sending '#'
866
+ ser.write(b"#")
867
+ time.sleep(0.3)
868
+
869
+ # Read and discard the CLI banner
870
+ if ser.in_waiting:
871
+ ser.read(ser.in_waiting)
872
+
873
+ # Send the command
874
+ ser.write((command + "\n").encode("ascii"))
875
+ time.sleep(0.1)
876
+
877
+ # Read response until we get '# ' prompt
878
+ buf = b""
879
+ deadline = time.monotonic() + timeout
880
+
881
+ while time.monotonic() < deadline:
882
+ if ser.in_waiting:
883
+ buf += ser.read(ser.in_waiting)
884
+ # Look for the CLI prompt at the end
885
+ # INAV CLI prompt is "\r\n# " at the end of output
886
+ if buf.rstrip().endswith(b"#") or buf.endswith(b"# "):
887
+ break
888
+ else:
889
+ time.sleep(0.01)
890
+
891
+ # Exit CLI mode
892
+ ser.write(b"exit\n")
893
+ time.sleep(0.3)
894
+ # Flush the exit response
895
+ if ser.in_waiting:
896
+ ser.read(ser.in_waiting)
897
+
898
+ if not buf:
899
+ return None
900
+
901
+ # Decode and clean up
902
+ try:
903
+ text = buf.decode("ascii", errors="replace")
904
+ except Exception:
905
+ return None
906
+
907
+ # Remove the command echo and trailing prompt
908
+ lines = text.splitlines()
909
+ # Skip echo of our command and trailing prompt
910
+ result_lines = []
911
+ for line in lines:
912
+ line = line.rstrip()
913
+ if line == command:
914
+ continue # Skip command echo
915
+ if line == "#" or line == "# ":
916
+ continue # Skip prompt
917
+ result_lines.append(line)
918
+
919
+ return "\n".join(result_lines).strip()
920
+
921
+ def get_diff_all(self, timeout=10.0):
922
+ """Pull the full 'diff all' configuration from the FC.
923
+
924
+ Returns:
925
+ Raw diff output string, or None on error
926
+ """
927
+ return self.cli_command("diff all", timeout=timeout)
928
+
929
+ def cli_batch(self, commands, timeout=5.0, save=True):
930
+ """Send multiple CLI commands in a single CLI session.
931
+
932
+ Enters CLI mode once, sends all commands, optionally saves,
933
+ then exits. Much faster than calling cli_command() per line.
934
+
935
+ Args:
936
+ commands: List of CLI command strings (e.g., ['set mc_p_roll = 28'])
937
+ timeout: Max seconds to wait per command response
938
+ save: If True, sends 'save' after all commands
939
+
940
+ Returns:
941
+ List of (command, response) tuples
942
+ """
943
+ ser = self._ser
944
+
945
+ # Flush any pending data
946
+ if ser.in_waiting:
947
+ ser.read(ser.in_waiting)
948
+
949
+ # Enter CLI mode
950
+ ser.write(b"#")
951
+ time.sleep(0.3)
952
+ if ser.in_waiting:
953
+ ser.read(ser.in_waiting)
954
+
955
+ results = []
956
+ all_cmds = list(commands)
957
+ if save:
958
+ all_cmds.append("save")
959
+
960
+ for cmd in all_cmds:
961
+ ser.write((cmd + "\n").encode("ascii"))
962
+ time.sleep(0.05)
963
+
964
+ # Read until prompt
965
+ buf = b""
966
+ deadline = time.monotonic() + timeout
967
+ while time.monotonic() < deadline:
968
+ if ser.in_waiting:
969
+ buf += ser.read(ser.in_waiting)
970
+ if buf.rstrip().endswith(b"#") or buf.endswith(b"# "):
971
+ break
972
+ else:
973
+ time.sleep(0.01)
974
+
975
+ try:
976
+ text = buf.decode("ascii", errors="replace")
977
+ except Exception:
978
+ text = ""
979
+
980
+ # Clean response
981
+ lines = []
982
+ for line in text.splitlines():
983
+ line = line.rstrip()
984
+ if line == cmd or line == "#" or line == "# ":
985
+ continue
986
+ lines.append(line)
987
+ results.append((cmd, "\n".join(lines).strip()))
988
+
989
+ # Exit CLI mode
990
+ ser.write(b"exit\n")
991
+ time.sleep(0.5)
992
+ if ser.in_waiting:
993
+ ser.read(ser.in_waiting)
994
+
995
+ return results
996
+
997
+
998
+ # ─── CLI Entrypoint ──────────────────────────────────────────────────────────
999
+
1000
+ def main():
1001
+ """Standalone usage: identify FC and download blackbox."""
1002
+ import argparse
1003
+
1004
+ parser = argparse.ArgumentParser(
1005
+ description="INAV MSP - Download blackbox logs directly from flight controller")
1006
+ parser.add_argument("--version", action="version", version=f"inav-msp {VERSION}")
1007
+ parser.add_argument("--device", "-d", default="auto",
1008
+ help="Serial port or 'auto' to scan. "
1009
+ "Examples: auto, /dev/ttyACM0 (Linux), "
1010
+ "/dev/cu.usbmodem14201 (macOS), COM3 (Windows)")
1011
+ parser.add_argument("--baud", type=int, default=115200,
1012
+ help="Baud rate (default: 115200)")
1013
+ parser.add_argument("--output-dir", "-o", default="./blackbox",
1014
+ help="Directory to save downloaded logs (default: ./blackbox)")
1015
+ parser.add_argument("--erase", action="store_true",
1016
+ help="Erase dataflash after successful download")
1017
+ parser.add_argument("--info-only", action="store_true",
1018
+ help="Only show FC info, don't download")
1019
+ args = parser.parse_args()
1020
+
1021
+ print(f"\n ▲ INAV MSP v{VERSION}")
1022
+
1023
+ # Connect
1024
+ fc = None
1025
+ if args.device == "auto":
1026
+ print(" Scanning for INAV flight controller...")
1027
+ fc, info = auto_detect_fc(baudrate=args.baud)
1028
+ if not fc:
1029
+ print(" ERROR: No INAV flight controller found.")
1030
+ ports = find_serial_ports()
1031
+ if ports:
1032
+ print(f" Ports found but none responded as INAV: {', '.join(ports)}")
1033
+ print(" Make sure the FC is powered and not in DFU mode.")
1034
+ else:
1035
+ print(" No serial ports detected. Is the FC connected via USB?")
1036
+ sys.exit(1)
1037
+ print(f" Found: {fc.port_path}")
1038
+ else:
1039
+ port = args.device
1040
+ if not os.path.exists(port):
1041
+ print(f" ERROR: Port not found: {port}")
1042
+ sys.exit(1)
1043
+ print(f" Connecting: {port}")
1044
+ fc = INAVDevice(port, baudrate=args.baud)
1045
+ fc.open()
1046
+ info = fc.get_info()
1047
+
1048
+ try:
1049
+ if not info:
1050
+ print(" ERROR: No response from FC. Check connection and baud rate.")
1051
+ sys.exit(1)
1052
+
1053
+ print(f"\n {'─' * 50}")
1054
+ print(f" Firmware: {info['firmware']}")
1055
+ print(f" Craft: {info['craft_name'] or '(not set)'}")
1056
+ print(f" Board: {info['board'] or '?'}")
1057
+
1058
+ summary = fc.get_dataflash_summary()
1059
+ if summary and summary['supported']:
1060
+ used_kb = summary['used_size'] / 1024
1061
+ total_kb = summary['total_size'] / 1024
1062
+ pct = summary['used_size'] * 100 // summary['total_size'] if summary['total_size'] > 0 else 0
1063
+ print(f" Dataflash: {used_kb:.0f}KB / {total_kb:.0f}KB ({pct}% used)")
1064
+ print(f" {'─' * 50}")
1065
+
1066
+ if args.info_only:
1067
+ return
1068
+
1069
+ if summary['used_size'] == 0:
1070
+ print("\n No blackbox data to download.")
1071
+ return
1072
+
1073
+ print()
1074
+ filepath = fc.download_blackbox(
1075
+ output_dir=args.output_dir,
1076
+ erase_after=args.erase,
1077
+ )
1078
+
1079
+ if filepath:
1080
+ print(f"\n Ready to analyze:")
1081
+ print(f" inav-analyze {filepath}")
1082
+ else:
1083
+ # No dataflash - check what blackbox device is configured
1084
+ bb_config = fc.get_blackbox_config()
1085
+ if bb_config and bb_config["device"] == BB_DEVICE_SDCARD:
1086
+ print(f" Blackbox: SD card")
1087
+ print(f" {'─' * 50}")
1088
+ print(f"\n Direct SD card download is not yet supported.")
1089
+ print(f" To get your logs:")
1090
+ print(f" - Remove the SD card and copy .bbl files, or")
1091
+ print(f" - Type 'msc' in INAV Configurator CLI to mount as USB drive")
1092
+ print(f" Then run the analyzer on the file directly:")
1093
+ print(f" inav-analyze <file.bbl>")
1094
+ elif bb_config and bb_config["device"] == BB_DEVICE_SERIAL:
1095
+ print(f" Blackbox: serial (external logger)")
1096
+ print(f" {'─' * 50}")
1097
+ print(f"\n Retrieve logs from the external logger's SD card,")
1098
+ print(f" then run the analyzer on the file directly.")
1099
+ else:
1100
+ print(" Dataflash: not available")
1101
+ print(f" {'─' * 50}")
1102
+
1103
+ except KeyboardInterrupt:
1104
+ print("\n Interrupted.")
1105
+ sys.exit(1)
1106
+ except Exception as e:
1107
+ print(f" ERROR: {e}")
1108
+ sys.exit(1)
1109
+ finally:
1110
+ if fc:
1111
+ fc.close()
1112
+
1113
+
1114
+ if __name__ == "__main__":
1115
+ main()