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/__init__.py +3 -0
- inav_toolkit/autotune.py +504 -0
- inav_toolkit/blackbox_analyzer.py +11070 -0
- inav_toolkit/flight_db.py +674 -0
- inav_toolkit/i18n.py +192 -0
- inav_toolkit/locales/en.json +187 -0
- inav_toolkit/locales/es.json +187 -0
- inav_toolkit/locales/pt_BR.json +187 -0
- inav_toolkit/msp.py +1293 -0
- inav_toolkit/param_analyzer.py +2832 -0
- inav_toolkit/vtol_configurator.py +856 -0
- inav_toolkit/wizard.py +1095 -0
- inav_toolkit-2.3.0.dist-info/METADATA +296 -0
- inav_toolkit-2.3.0.dist-info/RECORD +18 -0
- inav_toolkit-2.3.0.dist-info/WHEEL +5 -0
- inav_toolkit-2.3.0.dist-info/entry_points.txt +5 -0
- inav_toolkit-2.3.0.dist-info/licenses/LICENSE +674 -0
- inav_toolkit-2.3.0.dist-info/top_level.txt +1 -0
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()
|