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/wizard.py
ADDED
|
@@ -0,0 +1,1095 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
INAV Toolkit - Guided Session Manager
|
|
4
|
+
|
|
5
|
+
Interactive wizard that orchestrates the INAV analysis tools into a
|
|
6
|
+
guided tuning workflow. Handles FC connection, blackbox download,
|
|
7
|
+
analysis, result presentation, and CLI command application.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
inav-toolkit # Interactive guided mode
|
|
11
|
+
inav-toolkit --device /dev/ttyACM0 # Specify port
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import glob
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from inav_toolkit import __version__ as VERSION
|
|
24
|
+
except ImportError:
|
|
25
|
+
VERSION = "2.3.0"
|
|
26
|
+
|
|
27
|
+
# Module paths for subprocess invocation (package-aware)
|
|
28
|
+
ANALYZER_MODULE = "inav_toolkit.blackbox_analyzer"
|
|
29
|
+
PARAM_MODULE = "inav_toolkit.param_analyzer"
|
|
30
|
+
# Legacy script names (for git-clone-without-install usage)
|
|
31
|
+
ANALYZER_SCRIPT = "inav_blackbox_analyzer.py"
|
|
32
|
+
PARAM_SCRIPT = "inav_param_analyzer.py"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ─── Color Support ────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
def _enable_ansi_colors():
|
|
38
|
+
"""Enable ANSI color support."""
|
|
39
|
+
if os.environ.get("NO_COLOR") is not None:
|
|
40
|
+
return False
|
|
41
|
+
if not hasattr(sys.stdout, "isatty") or not sys.stdout.isatty():
|
|
42
|
+
return False
|
|
43
|
+
if sys.platform == "win32":
|
|
44
|
+
try:
|
|
45
|
+
import ctypes
|
|
46
|
+
kernel32 = ctypes.windll.kernel32
|
|
47
|
+
handle = kernel32.GetStdHandle(-11)
|
|
48
|
+
mode = ctypes.c_ulong()
|
|
49
|
+
kernel32.GetConsoleMode(handle, ctypes.byref(mode))
|
|
50
|
+
kernel32.SetConsoleMode(handle, mode.value | 0x0004)
|
|
51
|
+
return True
|
|
52
|
+
except Exception:
|
|
53
|
+
return False
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
_ANSI = _enable_ansi_colors()
|
|
57
|
+
|
|
58
|
+
def _c(code):
|
|
59
|
+
"""Return ANSI code if colors enabled, empty string otherwise."""
|
|
60
|
+
return code if _ANSI else ""
|
|
61
|
+
|
|
62
|
+
R = _c("\033[0m")
|
|
63
|
+
B = _c("\033[1m")
|
|
64
|
+
C = _c("\033[96m")
|
|
65
|
+
G = _c("\033[92m")
|
|
66
|
+
Y = _c("\033[93m")
|
|
67
|
+
RED = _c("\033[91m")
|
|
68
|
+
DIM = _c("\033[2m")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ─── Utilities ────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
def _script_dir():
|
|
74
|
+
"""Directory where toolkit scripts live."""
|
|
75
|
+
return os.path.dirname(os.path.abspath(__file__))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _module_cmd(module_path, fallback_script=None):
|
|
79
|
+
"""Build command prefix to run a toolkit module.
|
|
80
|
+
|
|
81
|
+
Works both as installed package (python -m inav_toolkit.X)
|
|
82
|
+
and from legacy script layout (python inav_blackbox_analyzer.py).
|
|
83
|
+
"""
|
|
84
|
+
# Try package module first (pip install or running from repo root)
|
|
85
|
+
cmd = [sys.executable, "-m", module_path]
|
|
86
|
+
try:
|
|
87
|
+
# Quick check: can Python find this module?
|
|
88
|
+
result = subprocess.run(
|
|
89
|
+
cmd + ["--help"], capture_output=True, timeout=5)
|
|
90
|
+
if result.returncode in (0, 2): # 0=ok, 2=argparse error (no args)
|
|
91
|
+
return cmd
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
# Fallback: look for legacy script alongside this file's parent dir
|
|
96
|
+
if fallback_script:
|
|
97
|
+
parent = os.path.dirname(_script_dir())
|
|
98
|
+
script = os.path.join(parent, fallback_script)
|
|
99
|
+
if os.path.isfile(script):
|
|
100
|
+
return [sys.executable, script]
|
|
101
|
+
# Also check same directory (flat layout)
|
|
102
|
+
script = os.path.join(_script_dir(), fallback_script)
|
|
103
|
+
if os.path.isfile(script):
|
|
104
|
+
return [sys.executable, script]
|
|
105
|
+
|
|
106
|
+
# PyInstaller bundle
|
|
107
|
+
if fallback_script and getattr(sys, '_MEIPASS', None):
|
|
108
|
+
script = os.path.join(sys._MEIPASS, fallback_script)
|
|
109
|
+
if os.path.isfile(script):
|
|
110
|
+
return [sys.executable, script]
|
|
111
|
+
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _clear_line():
|
|
116
|
+
"""Clear current terminal line."""
|
|
117
|
+
print("\r" + " " * 70 + "\r", end="", flush=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _prompt(question, options=None, default=None):
|
|
121
|
+
"""Interactive prompt with optional choices.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
question: Text to display
|
|
125
|
+
options: List of (key, label) tuples, or None for free text
|
|
126
|
+
default: Default value if user presses Enter
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
User's choice (lowercase key, or free text)
|
|
130
|
+
"""
|
|
131
|
+
if options:
|
|
132
|
+
print(f"\n {B}{question}{R}")
|
|
133
|
+
for key, label in options:
|
|
134
|
+
marker = f" {DIM}(default){R}" if key == default else ""
|
|
135
|
+
print(f" {C}{key}{R} {label}{marker}")
|
|
136
|
+
while True:
|
|
137
|
+
hint = f" [{default}]" if default else ""
|
|
138
|
+
choice = input(f"\n > {hint} ").strip().lower()
|
|
139
|
+
if not choice and default:
|
|
140
|
+
return default
|
|
141
|
+
valid = [k.lower() for k, _ in options]
|
|
142
|
+
if choice in valid:
|
|
143
|
+
return choice
|
|
144
|
+
print(f" {Y}Choose one of: {', '.join(valid)}{R}")
|
|
145
|
+
else:
|
|
146
|
+
hint = f" [{default}]" if default else ""
|
|
147
|
+
print(f"\n {B}{question}{R}")
|
|
148
|
+
choice = input(f" > {hint} ").strip()
|
|
149
|
+
if not choice and default:
|
|
150
|
+
return default
|
|
151
|
+
return choice
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _confirm(question, default=True):
|
|
155
|
+
"""Yes/no prompt."""
|
|
156
|
+
hint = "Y/n" if default else "y/N"
|
|
157
|
+
answer = input(f" {question} [{hint}] ").strip().lower()
|
|
158
|
+
if not answer:
|
|
159
|
+
return default
|
|
160
|
+
return answer in ("y", "yes")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _banner():
|
|
164
|
+
"""Print welcome banner."""
|
|
165
|
+
print(f"""
|
|
166
|
+
{B}{C}{'=' * 54}{R}
|
|
167
|
+
{B} INAV Toolkit v{VERSION} - Guided Session Manager {R}
|
|
168
|
+
{B}{C}{'=' * 54}{R}
|
|
169
|
+
""")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ─── FC Connection ────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
def _connect_fc(port=None):
|
|
175
|
+
"""Connect to FC and return (device, info) or (None, None)."""
|
|
176
|
+
try:
|
|
177
|
+
from inav_toolkit.msp import INAVDevice, auto_detect_fc, find_serial_ports
|
|
178
|
+
except ImportError:
|
|
179
|
+
try:
|
|
180
|
+
from inav_msp import INAVDevice, auto_detect_fc, find_serial_ports
|
|
181
|
+
except ImportError:
|
|
182
|
+
print(f" {RED}ERROR: MSP module not found{R}")
|
|
183
|
+
print(f" Install: pip install inav-toolkit[serial]")
|
|
184
|
+
return None, None
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
import serial
|
|
188
|
+
except ImportError:
|
|
189
|
+
print(f" {RED}ERROR: pyserial is required for FC communication.{R}")
|
|
190
|
+
print(f" Debian/Ubuntu: sudo apt install python3-serial")
|
|
191
|
+
print(f" Other: pip install pyserial (in a venv)")
|
|
192
|
+
return None, None
|
|
193
|
+
|
|
194
|
+
if port and port != "auto":
|
|
195
|
+
print(f" Connecting to {port}...", end="", flush=True)
|
|
196
|
+
try:
|
|
197
|
+
fc = INAVDevice(port)
|
|
198
|
+
fc.open()
|
|
199
|
+
info = fc.get_info()
|
|
200
|
+
if info and info.get("fc_variant") == "INAV":
|
|
201
|
+
print(f" {G}connected{R}")
|
|
202
|
+
return fc, info
|
|
203
|
+
print(f" {RED}not an INAV FC{R}")
|
|
204
|
+
fc.close()
|
|
205
|
+
except Exception as e:
|
|
206
|
+
print(f" {RED}failed: {e}{R}")
|
|
207
|
+
return None, None
|
|
208
|
+
|
|
209
|
+
print(f" Scanning for INAV flight controller...", end="", flush=True)
|
|
210
|
+
fc, info = auto_detect_fc()
|
|
211
|
+
if fc and info:
|
|
212
|
+
print(f" {G}found{R}")
|
|
213
|
+
return fc, info
|
|
214
|
+
|
|
215
|
+
print(f" {Y}not found{R}")
|
|
216
|
+
ports = find_serial_ports()
|
|
217
|
+
if ports:
|
|
218
|
+
print(f" Ports detected but none responded as INAV: {', '.join(ports)}")
|
|
219
|
+
print(f" Make sure the FC is powered and not in DFU mode.")
|
|
220
|
+
else:
|
|
221
|
+
print(f" No serial ports detected. Is the FC connected via USB?")
|
|
222
|
+
return None, None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _print_fc_info(info):
|
|
226
|
+
"""Display FC identification."""
|
|
227
|
+
craft = info.get("craft_name") or "(unnamed)"
|
|
228
|
+
fw = info.get("firmware", "")
|
|
229
|
+
board = info.get("board_id", "")
|
|
230
|
+
print(f"\n {B}Aircraft:{R} {craft}")
|
|
231
|
+
print(f" {B}Firmware:{R} {fw}")
|
|
232
|
+
if board:
|
|
233
|
+
print(f" {B}Board:{R} {board}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ─── Download & Analyze ──────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
def _download_blackbox(fc, info, output_dir="./blackbox"):
|
|
239
|
+
"""Download blackbox from FC. Returns filepath or None."""
|
|
240
|
+
summary = fc.get_dataflash_summary()
|
|
241
|
+
if not summary or not summary.get("supported"):
|
|
242
|
+
print(f" {RED}Dataflash not available on this FC.{R}")
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
used_kb = summary["used_size"] / 1024
|
|
246
|
+
total_kb = summary["total_size"] / 1024
|
|
247
|
+
pct = summary["used_size"] * 100 // summary["total_size"] if summary["total_size"] > 0 else 0
|
|
248
|
+
|
|
249
|
+
if summary["used_size"] == 0:
|
|
250
|
+
print(f"\n Dataflash: {used_kb:.0f}KB / {total_kb:.0f}KB ({pct}% used)")
|
|
251
|
+
print(f" {Y}No blackbox data to download.{R}")
|
|
252
|
+
print(f" Fly with blackbox enabled, then come back.")
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
print(f"\n Dataflash: {used_kb:.0f}KB / {total_kb:.0f}KB ({pct}% used)")
|
|
256
|
+
|
|
257
|
+
craft = info.get("craft_name") or "fc"
|
|
258
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
259
|
+
filename = f"{craft}_{timestamp}.bbl"
|
|
260
|
+
filepath = fc.download_blackbox(output_dir=output_dir, filename=filename)
|
|
261
|
+
|
|
262
|
+
if filepath and os.path.isfile(filepath):
|
|
263
|
+
return filepath
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _pull_diff(fc, output_dir="./blackbox", craft_name="fc"):
|
|
268
|
+
"""Pull diff all from FC. Returns raw text or None."""
|
|
269
|
+
print(f" Pulling configuration...", end="", flush=True)
|
|
270
|
+
diff_raw = fc.get_diff_all(timeout=10.0)
|
|
271
|
+
if diff_raw:
|
|
272
|
+
n = len([l for l in diff_raw.splitlines() if l.strip().startswith("set ")])
|
|
273
|
+
print(f" {n} settings")
|
|
274
|
+
# Save alongside blackbox
|
|
275
|
+
diff_path = os.path.join(output_dir, f"{craft_name}_diff.txt")
|
|
276
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
277
|
+
with open(diff_path, "w") as f:
|
|
278
|
+
f.write(diff_raw)
|
|
279
|
+
return diff_raw
|
|
280
|
+
print(f" {Y}no response{R}")
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _run_analyzer(logfile, mode="tune", diff_file=None, frame=None, extra_args=None):
|
|
285
|
+
"""Run the blackbox analyzer and return results.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
logfile: Path to BBL file
|
|
289
|
+
mode: 'tune' or 'nav'
|
|
290
|
+
diff_file: Path to diff file (optional)
|
|
291
|
+
frame: Frame size in inches (optional)
|
|
292
|
+
extra_args: Additional CLI arguments
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
dict with keys: success, score, verdict, actions, state_json_path,
|
|
296
|
+
html_path, output (terminal text)
|
|
297
|
+
"""
|
|
298
|
+
cmd_prefix = _module_cmd(ANALYZER_MODULE, ANALYZER_SCRIPT)
|
|
299
|
+
if not cmd_prefix:
|
|
300
|
+
return {"success": False, "output": f"Cannot find analyzer module"}
|
|
301
|
+
|
|
302
|
+
cmd = cmd_prefix + [logfile]
|
|
303
|
+
if mode == "nav":
|
|
304
|
+
cmd.append("--nav")
|
|
305
|
+
if diff_file:
|
|
306
|
+
cmd.extend(["--diff", diff_file])
|
|
307
|
+
if frame:
|
|
308
|
+
cmd.extend(["--frame", str(frame)])
|
|
309
|
+
if extra_args:
|
|
310
|
+
cmd.extend(extra_args)
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
314
|
+
output = result.stdout + result.stderr
|
|
315
|
+
except subprocess.TimeoutExpired:
|
|
316
|
+
return {"success": False, "output": "Analysis timed out (120s)"}
|
|
317
|
+
except Exception as e:
|
|
318
|
+
return {"success": False, "output": str(e)}
|
|
319
|
+
|
|
320
|
+
# Find state.json
|
|
321
|
+
state_json = logfile.rsplit(".", 1)[0] + "_state.json"
|
|
322
|
+
state = None
|
|
323
|
+
if os.path.isfile(state_json):
|
|
324
|
+
try:
|
|
325
|
+
with open(state_json, "r") as f:
|
|
326
|
+
state = json.load(f)
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
# Find HTML report
|
|
331
|
+
html_path = None
|
|
332
|
+
base = logfile.rsplit(".", 1)[0]
|
|
333
|
+
for suffix in ("_report.html", "_nav_report.html"):
|
|
334
|
+
candidate = base + suffix
|
|
335
|
+
if os.path.isfile(candidate):
|
|
336
|
+
html_path = candidate
|
|
337
|
+
|
|
338
|
+
score = None
|
|
339
|
+
verdict = None
|
|
340
|
+
actions = []
|
|
341
|
+
if state:
|
|
342
|
+
scores = state.get("scores", {})
|
|
343
|
+
score = scores.get("overall")
|
|
344
|
+
verdict = state.get("verdict", "")
|
|
345
|
+
actions = state.get("actions", [])
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
"success": result.returncode == 0,
|
|
349
|
+
"score": score,
|
|
350
|
+
"verdict": verdict,
|
|
351
|
+
"actions": actions,
|
|
352
|
+
"deferred": state.get("deferred_actions", []) if state else [],
|
|
353
|
+
"state": state,
|
|
354
|
+
"state_json_path": state_json if state else None,
|
|
355
|
+
"html_path": html_path,
|
|
356
|
+
"output": output,
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _run_param_check(diff_file, frame=None):
|
|
361
|
+
"""Run parameter analyzer safety check. Returns (success, output)."""
|
|
362
|
+
cmd_prefix = _module_cmd(PARAM_MODULE, PARAM_SCRIPT)
|
|
363
|
+
if not cmd_prefix:
|
|
364
|
+
return False, f"Cannot find parameter analyzer module"
|
|
365
|
+
|
|
366
|
+
cmd = cmd_prefix + [diff_file]
|
|
367
|
+
if frame:
|
|
368
|
+
cmd.extend(["--frame", str(frame)])
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
372
|
+
return result.returncode == 0, result.stdout + result.stderr
|
|
373
|
+
except Exception as e:
|
|
374
|
+
return False, str(e)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# ─── Backup & Restore ─────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
def _create_backup(fc, info, output_dir="./blackbox"):
|
|
380
|
+
"""Pull diff from FC and save as timestamped backup.
|
|
381
|
+
|
|
382
|
+
This is a SAFETY GATE - if the backup cannot be written, the session
|
|
383
|
+
must not proceed with any changes to the FC.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
fc: Connected INAVDevice
|
|
387
|
+
info: FC info dict
|
|
388
|
+
output_dir: Directory to save backup
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
(backup_path, diff_raw) on success, (None, None) on failure
|
|
392
|
+
"""
|
|
393
|
+
craft = info.get("craft_name") or "fc"
|
|
394
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
395
|
+
backup_name = f"{craft}_backup_{timestamp}.txt"
|
|
396
|
+
|
|
397
|
+
print(f"\n {B}Creating configuration backup...{R}")
|
|
398
|
+
|
|
399
|
+
# Pull current config
|
|
400
|
+
diff_raw = fc.get_diff_all(timeout=10.0)
|
|
401
|
+
if not diff_raw or not diff_raw.strip():
|
|
402
|
+
print(f" {RED}FAILED: Could not read configuration from FC.{R}")
|
|
403
|
+
print(f" {RED}No changes will be made without a backup.{R}")
|
|
404
|
+
return None, None
|
|
405
|
+
|
|
406
|
+
# Count settings to sanity-check the diff
|
|
407
|
+
set_lines = [l for l in diff_raw.splitlines() if l.strip().startswith("set ")]
|
|
408
|
+
if len(set_lines) < 5:
|
|
409
|
+
print(f" {RED}FAILED: Config looks incomplete ({len(set_lines)} settings).{R}")
|
|
410
|
+
print(f" {RED}No changes will be made without a valid backup.{R}")
|
|
411
|
+
return None, None
|
|
412
|
+
|
|
413
|
+
# Write backup file
|
|
414
|
+
try:
|
|
415
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
416
|
+
backup_path = os.path.join(output_dir, backup_name)
|
|
417
|
+
with open(backup_path, "w") as f:
|
|
418
|
+
f.write(f"# INAV Toolkit backup - {craft}\n")
|
|
419
|
+
f.write(f"# Created: {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
|
|
420
|
+
f.write(f"# Firmware: {info.get('firmware', 'unknown')}\n")
|
|
421
|
+
f.write(f"# Board: {info.get('board_id', 'unknown')}\n")
|
|
422
|
+
f.write(f"# To restore: paste this entire file in the INAV CLI tab\n")
|
|
423
|
+
f.write(f"# after running 'defaults' (or use the toolkit's restore).\n")
|
|
424
|
+
f.write(f"#\n")
|
|
425
|
+
f.write(diff_raw)
|
|
426
|
+
# Verify write
|
|
427
|
+
verify_size = os.path.getsize(backup_path)
|
|
428
|
+
if verify_size < len(diff_raw):
|
|
429
|
+
raise IOError("Written file smaller than expected")
|
|
430
|
+
except Exception as e:
|
|
431
|
+
print(f" {RED}FAILED: Could not write backup file: {e}{R}")
|
|
432
|
+
print(f" {RED}No changes will be made without a backup.{R}")
|
|
433
|
+
return None, None
|
|
434
|
+
|
|
435
|
+
print(f" {G}Backup saved: {backup_path}{R}")
|
|
436
|
+
print(f" {DIM}({len(set_lines)} settings, {verify_size:,} bytes){R}")
|
|
437
|
+
print(f" {Y}Keep this file safe. If anything goes wrong, paste it in{R}")
|
|
438
|
+
print(f" {Y}the INAV Configurator CLI tab after running 'defaults'.{R}")
|
|
439
|
+
|
|
440
|
+
return backup_path, diff_raw
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _restore_backup(fc, info, backup_path, port=None):
|
|
444
|
+
"""Restore FC to backup state: defaults + replay diff.
|
|
445
|
+
|
|
446
|
+
This is a destructive operation:
|
|
447
|
+
1. Sends 'defaults' (FC reboots)
|
|
448
|
+
2. Reconnects to FC
|
|
449
|
+
3. Replays the backup diff line by line
|
|
450
|
+
4. Saves
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
fc: Connected INAVDevice (will be closed and reopened)
|
|
454
|
+
info: FC info dict
|
|
455
|
+
backup_path: Path to backup diff file
|
|
456
|
+
port: Serial port path for reconnection
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
(new_fc, success) - new device object and success flag
|
|
460
|
+
"""
|
|
461
|
+
# Read backup
|
|
462
|
+
try:
|
|
463
|
+
with open(backup_path, "r") as f:
|
|
464
|
+
backup_text = f.read()
|
|
465
|
+
except Exception as e:
|
|
466
|
+
print(f" {RED}Cannot read backup file: {e}{R}")
|
|
467
|
+
return fc, False
|
|
468
|
+
|
|
469
|
+
# Extract CLI commands (skip comments, empty lines)
|
|
470
|
+
cli_lines = []
|
|
471
|
+
for line in backup_text.splitlines():
|
|
472
|
+
line = line.strip()
|
|
473
|
+
if not line or line.startswith("#"):
|
|
474
|
+
continue
|
|
475
|
+
cli_lines.append(line)
|
|
476
|
+
|
|
477
|
+
if not cli_lines:
|
|
478
|
+
print(f" {RED}Backup file is empty or contains only comments.{R}")
|
|
479
|
+
return fc, False
|
|
480
|
+
|
|
481
|
+
set_count = len([l for l in cli_lines if l.startswith("set ")])
|
|
482
|
+
print(f"\n {B}Restoring from backup: {os.path.basename(backup_path)}{R}")
|
|
483
|
+
print(f" {DIM}({set_count} settings to restore){R}")
|
|
484
|
+
print(f"\n {Y}WARNING: This will reset ALL settings to defaults,{R}")
|
|
485
|
+
print(f" {Y}then restore from backup. The FC will reboot.{R}")
|
|
486
|
+
|
|
487
|
+
if not _confirm(f"\n Proceed with full restore?", default=False):
|
|
488
|
+
print(f" Restore cancelled.")
|
|
489
|
+
return fc, False
|
|
490
|
+
|
|
491
|
+
# Remember port for reconnection
|
|
492
|
+
port_path = port or fc.port_path
|
|
493
|
+
|
|
494
|
+
# Step 1: Send defaults (triggers reboot)
|
|
495
|
+
print(f"\n Sending 'defaults'...", end="", flush=True)
|
|
496
|
+
try:
|
|
497
|
+
ser = fc._ser
|
|
498
|
+
# Enter CLI
|
|
499
|
+
ser.write(b"#")
|
|
500
|
+
time.sleep(0.3)
|
|
501
|
+
if ser.in_waiting:
|
|
502
|
+
ser.read(ser.in_waiting)
|
|
503
|
+
# Send defaults
|
|
504
|
+
ser.write(b"defaults\n")
|
|
505
|
+
time.sleep(0.5)
|
|
506
|
+
print(f" FC is rebooting...")
|
|
507
|
+
except Exception as e:
|
|
508
|
+
print(f" {RED}failed: {e}{R}")
|
|
509
|
+
print(f" {Y}You may need to manually restore by pasting the backup{R}")
|
|
510
|
+
print(f" {Y}in the CLI tab after running 'defaults'.{R}")
|
|
511
|
+
return fc, False
|
|
512
|
+
|
|
513
|
+
# Close old connection
|
|
514
|
+
try:
|
|
515
|
+
fc.close()
|
|
516
|
+
except Exception:
|
|
517
|
+
pass
|
|
518
|
+
|
|
519
|
+
# Step 2: Wait for reboot and reconnect
|
|
520
|
+
print(f" Waiting for FC to reboot...", end="", flush=True)
|
|
521
|
+
time.sleep(5)
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
from inav_toolkit.msp import INAVDevice, auto_detect_fc
|
|
525
|
+
except ImportError:
|
|
526
|
+
try:
|
|
527
|
+
from inav_msp import INAVDevice, auto_detect_fc
|
|
528
|
+
except ImportError:
|
|
529
|
+
print(f" {RED}cannot import MSP module{R}")
|
|
530
|
+
return None, False
|
|
531
|
+
|
|
532
|
+
# Try reconnecting several times
|
|
533
|
+
new_fc = None
|
|
534
|
+
for attempt in range(6):
|
|
535
|
+
try:
|
|
536
|
+
new_fc = INAVDevice(port_path)
|
|
537
|
+
new_fc.open()
|
|
538
|
+
new_info = new_fc.get_info()
|
|
539
|
+
if new_info and new_info.get("fc_variant") == "INAV":
|
|
540
|
+
print(f" {G}reconnected{R}")
|
|
541
|
+
break
|
|
542
|
+
new_fc.close()
|
|
543
|
+
new_fc = None
|
|
544
|
+
except Exception:
|
|
545
|
+
new_fc = None
|
|
546
|
+
time.sleep(2)
|
|
547
|
+
print(".", end="", flush=True)
|
|
548
|
+
|
|
549
|
+
if not new_fc:
|
|
550
|
+
print(f" {RED}could not reconnect{R}")
|
|
551
|
+
print(f"\n {RED}FC was reset to defaults but config was NOT restored.{R}")
|
|
552
|
+
print(f" {Y}To restore manually:{R}")
|
|
553
|
+
print(f" 1. Open INAV Configurator")
|
|
554
|
+
print(f" 2. Go to CLI tab")
|
|
555
|
+
print(f" 3. Paste contents of: {backup_path}")
|
|
556
|
+
print(f" 4. Type 'save'")
|
|
557
|
+
return None, False
|
|
558
|
+
|
|
559
|
+
# Step 3: Replay backup config
|
|
560
|
+
print(f" Restoring {set_count} settings...", end="", flush=True)
|
|
561
|
+
try:
|
|
562
|
+
results = new_fc.cli_batch(cli_lines, timeout=5.0, save=True)
|
|
563
|
+
# Check for errors
|
|
564
|
+
errors = []
|
|
565
|
+
for cmd, response in results:
|
|
566
|
+
if cmd == "save":
|
|
567
|
+
continue
|
|
568
|
+
if "invalid" in response.lower():
|
|
569
|
+
errors.append(cmd)
|
|
570
|
+
|
|
571
|
+
if errors:
|
|
572
|
+
print(f" {Y}done with {len(errors)} warnings{R}")
|
|
573
|
+
print(f" {DIM}Unrecognized settings (may be version-specific):{R}")
|
|
574
|
+
for cmd in errors[:5]:
|
|
575
|
+
print(f" {DIM}{cmd}{R}")
|
|
576
|
+
if len(errors) > 5:
|
|
577
|
+
print(f" {DIM}...and {len(errors) - 5} more{R}")
|
|
578
|
+
else:
|
|
579
|
+
print(f" {G}done{R}")
|
|
580
|
+
|
|
581
|
+
print(f"\n {G}Configuration restored successfully.{R}")
|
|
582
|
+
return new_fc, True
|
|
583
|
+
|
|
584
|
+
except Exception as e:
|
|
585
|
+
print(f" {RED}failed: {e}{R}")
|
|
586
|
+
print(f"\n {RED}Defaults were applied but restore did not complete.{R}")
|
|
587
|
+
print(f" {Y}Restore manually from: {backup_path}{R}")
|
|
588
|
+
return new_fc, False
|
|
589
|
+
|
|
590
|
+
def _extract_cli_commands(actions):
|
|
591
|
+
"""Extract CLI set commands from action list.
|
|
592
|
+
|
|
593
|
+
Returns list of 'set param = value' strings.
|
|
594
|
+
"""
|
|
595
|
+
commands = []
|
|
596
|
+
for a in actions:
|
|
597
|
+
action_text = a.get("action", "")
|
|
598
|
+
# Match patterns like "set mc_p_roll = 28" within the action text
|
|
599
|
+
matches = re.findall(r"set\s+\S+\s*=\s*\S+", action_text)
|
|
600
|
+
commands.extend(matches)
|
|
601
|
+
# Also check param/new fields
|
|
602
|
+
param = a.get("param", "")
|
|
603
|
+
new_val = a.get("new", "")
|
|
604
|
+
if param and new_val:
|
|
605
|
+
cmd = f"set {param} = {new_val}"
|
|
606
|
+
if cmd not in commands:
|
|
607
|
+
commands.append(cmd)
|
|
608
|
+
return commands
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _apply_commands(fc, commands):
|
|
612
|
+
"""Apply CLI commands to FC via batch mode."""
|
|
613
|
+
if not commands:
|
|
614
|
+
return True
|
|
615
|
+
|
|
616
|
+
print(f"\n Applying {len(commands)} changes...")
|
|
617
|
+
for cmd in commands:
|
|
618
|
+
print(f" {C}{cmd}{R}")
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
results = fc.cli_batch(commands, save=True)
|
|
622
|
+
# Check for errors
|
|
623
|
+
errors = []
|
|
624
|
+
for cmd, response in results:
|
|
625
|
+
if cmd == "save":
|
|
626
|
+
continue
|
|
627
|
+
if "invalid" in response.lower() or "error" in response.lower():
|
|
628
|
+
errors.append((cmd, response))
|
|
629
|
+
|
|
630
|
+
if errors:
|
|
631
|
+
print(f"\n {Y}Some commands had issues:{R}")
|
|
632
|
+
for cmd, resp in errors:
|
|
633
|
+
print(f" {cmd}: {resp}")
|
|
634
|
+
return False
|
|
635
|
+
|
|
636
|
+
print(f" {G}Settings applied and saved.{R}")
|
|
637
|
+
return True
|
|
638
|
+
|
|
639
|
+
except Exception as e:
|
|
640
|
+
print(f" {RED}Failed to apply: {e}{R}")
|
|
641
|
+
return False
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _erase_dataflash(fc):
|
|
645
|
+
"""Erase dataflash after confirmation."""
|
|
646
|
+
print(f"\n Erasing dataflash...", end="", flush=True)
|
|
647
|
+
try:
|
|
648
|
+
fc.erase_dataflash()
|
|
649
|
+
print(f" {G}done{R}")
|
|
650
|
+
return True
|
|
651
|
+
except Exception as e:
|
|
652
|
+
print(f" {RED}failed: {e}{R}")
|
|
653
|
+
return False
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# ─── Result Presentation ─────────────────────────────────────────────────────
|
|
657
|
+
|
|
658
|
+
def _print_score(score, verdict, prev_score=None):
|
|
659
|
+
"""Display the tuning score prominently."""
|
|
660
|
+
if score is None:
|
|
661
|
+
print(f"\n {DIM}Score: N/A{R}")
|
|
662
|
+
return
|
|
663
|
+
|
|
664
|
+
if score >= 80:
|
|
665
|
+
color = G
|
|
666
|
+
elif score >= 60:
|
|
667
|
+
color = Y
|
|
668
|
+
else:
|
|
669
|
+
color = RED
|
|
670
|
+
|
|
671
|
+
bar_width = 30
|
|
672
|
+
filled = int(score / 100 * bar_width)
|
|
673
|
+
bar = f"{color}{'=' * filled}{DIM}{'-' * (bar_width - filled)}{R}"
|
|
674
|
+
|
|
675
|
+
delta = ""
|
|
676
|
+
if prev_score is not None:
|
|
677
|
+
diff = score - prev_score
|
|
678
|
+
if diff > 0:
|
|
679
|
+
delta = f" {G}+{diff}{R}"
|
|
680
|
+
elif diff < 0:
|
|
681
|
+
delta = f" {RED}{diff}{R}"
|
|
682
|
+
else:
|
|
683
|
+
delta = f" {DIM}+0{R}"
|
|
684
|
+
|
|
685
|
+
vd = (verdict or "").replace("_", " ").title()
|
|
686
|
+
print(f"\n Score: {color}{B}{score}/100{R}{delta}")
|
|
687
|
+
print(f" [{bar}]")
|
|
688
|
+
if vd:
|
|
689
|
+
print(f" {DIM}{vd}{R}")
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _print_actions(actions, deferred=None):
|
|
693
|
+
"""Display recommended actions."""
|
|
694
|
+
if not actions and not deferred:
|
|
695
|
+
print(f"\n {G}No changes needed - go fly!{R}")
|
|
696
|
+
return
|
|
697
|
+
|
|
698
|
+
if actions:
|
|
699
|
+
print(f"\n {B}Recommended changes:{R}")
|
|
700
|
+
for i, a in enumerate(actions, 1):
|
|
701
|
+
action = a.get("action", "")
|
|
702
|
+
print(f" {C}{i}.{R} {action}")
|
|
703
|
+
|
|
704
|
+
if deferred:
|
|
705
|
+
print(f"\n {DIM}Deferred (fix filters first, then re-fly):{R}")
|
|
706
|
+
for a in deferred:
|
|
707
|
+
action = a.get("action", a.get("original_action", ""))
|
|
708
|
+
print(f" {DIM} {action}{R}")
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _print_nav_summary(output):
|
|
712
|
+
"""Extract and display nav score from analyzer output."""
|
|
713
|
+
# Parse nav score from terminal output
|
|
714
|
+
for line in output.splitlines():
|
|
715
|
+
if "Nav Score:" in line or "nav_score" in line.lower():
|
|
716
|
+
print(f" {line.strip()}")
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
# ─── Session Flows ────────────────────────────────────────────────────────────
|
|
720
|
+
|
|
721
|
+
def _flow_new_build(fc, info):
|
|
722
|
+
"""New build flow: safety check then baseline."""
|
|
723
|
+
print(f"\n {B}{C}--- New Build: Safety Check ---{R}")
|
|
724
|
+
|
|
725
|
+
# Backup first - before any changes
|
|
726
|
+
backup_path, diff_raw = _create_backup(fc, info)
|
|
727
|
+
if backup_path is None:
|
|
728
|
+
print(f" {RED}Cannot proceed without a backup.{R}")
|
|
729
|
+
return
|
|
730
|
+
|
|
731
|
+
# Save diff to temp file for param analyzer
|
|
732
|
+
diff_path = os.path.join("./blackbox", f"{info.get('craft_name', 'fc')}_diff.txt")
|
|
733
|
+
os.makedirs("./blackbox", exist_ok=True)
|
|
734
|
+
with open(diff_path, "w") as f:
|
|
735
|
+
f.write(diff_raw)
|
|
736
|
+
|
|
737
|
+
# Ask frame size
|
|
738
|
+
frame = _prompt("What is your frame size (inches)?",
|
|
739
|
+
options=[("7", "7 inch"), ("10", "10 inch"),
|
|
740
|
+
("12", "12 inch"), ("15", "15 inch")],
|
|
741
|
+
default="10")
|
|
742
|
+
frame = int(frame)
|
|
743
|
+
|
|
744
|
+
# Run parameter analyzer
|
|
745
|
+
print(f"\n Running safety check...")
|
|
746
|
+
success, output = _run_param_check(diff_path, frame=frame)
|
|
747
|
+
print(output)
|
|
748
|
+
|
|
749
|
+
if not success:
|
|
750
|
+
print(f"\n {Y}Issues found. Review above and fix before flying.{R}")
|
|
751
|
+
else:
|
|
752
|
+
print(f"\n {G}Config looks good for first flights.{R}")
|
|
753
|
+
|
|
754
|
+
if _confirm("\n Ready to analyze a flight?", default=True):
|
|
755
|
+
_flow_tune_session(fc, info, frame=frame, diff_raw=diff_raw)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _flow_tune_session(fc, info, frame=None, diff_raw=None):
|
|
759
|
+
"""Tuning session: download, analyze, apply, repeat.
|
|
760
|
+
|
|
761
|
+
Backup is created before the first change is applied. If the backup
|
|
762
|
+
fails, no changes will be made to the FC. The user can restore to
|
|
763
|
+
the original state at any point during the session.
|
|
764
|
+
"""
|
|
765
|
+
craft = info.get("craft_name", "fc")
|
|
766
|
+
prev_score = None
|
|
767
|
+
session_num = 0
|
|
768
|
+
output_dir = "./blackbox"
|
|
769
|
+
backup_path = None
|
|
770
|
+
changes_applied = False
|
|
771
|
+
|
|
772
|
+
# Pull diff if not already available
|
|
773
|
+
if diff_raw is None:
|
|
774
|
+
diff_raw = _pull_diff(fc, output_dir=output_dir, craft_name=craft)
|
|
775
|
+
|
|
776
|
+
# Save diff file path for analyzer
|
|
777
|
+
diff_file = None
|
|
778
|
+
if diff_raw:
|
|
779
|
+
diff_file = os.path.join(output_dir, f"{craft}_diff.txt")
|
|
780
|
+
|
|
781
|
+
try:
|
|
782
|
+
while True:
|
|
783
|
+
session_num += 1
|
|
784
|
+
print(f"\n {B}{C}--- Tune: Session {session_num} ---{R}")
|
|
785
|
+
|
|
786
|
+
# Download
|
|
787
|
+
filepath = _download_blackbox(fc, info, output_dir=output_dir)
|
|
788
|
+
if not filepath:
|
|
789
|
+
if _confirm("Try again?"):
|
|
790
|
+
continue
|
|
791
|
+
break
|
|
792
|
+
|
|
793
|
+
# Analyze
|
|
794
|
+
print(f"\n Analyzing...\n")
|
|
795
|
+
results = _run_analyzer(filepath, mode="tune", diff_file=diff_file,
|
|
796
|
+
frame=frame)
|
|
797
|
+
|
|
798
|
+
if not results["success"]:
|
|
799
|
+
print(f" {RED}Analysis failed:{R}")
|
|
800
|
+
print(results.get("output", "")[:500])
|
|
801
|
+
if _confirm("Try again?"):
|
|
802
|
+
continue
|
|
803
|
+
break
|
|
804
|
+
|
|
805
|
+
# Show results
|
|
806
|
+
_print_score(results["score"], results["verdict"], prev_score)
|
|
807
|
+
_print_actions(results["actions"], results.get("deferred"))
|
|
808
|
+
|
|
809
|
+
if results.get("html_path"):
|
|
810
|
+
print(f"\n {DIM}Full report: {results['html_path']}{R}")
|
|
811
|
+
|
|
812
|
+
prev_score = results["score"]
|
|
813
|
+
|
|
814
|
+
# Extract CLI commands
|
|
815
|
+
commands = _extract_cli_commands(results["actions"])
|
|
816
|
+
if commands:
|
|
817
|
+
print(f"\n {B}CLI commands to apply:{R}")
|
|
818
|
+
for cmd in commands:
|
|
819
|
+
print(f" {C}{cmd}{R}")
|
|
820
|
+
|
|
821
|
+
choice = _prompt("What do you want to do?",
|
|
822
|
+
options=[
|
|
823
|
+
("a", "Apply these changes to the FC"),
|
|
824
|
+
("s", "Skip - fly again without changes"),
|
|
825
|
+
("r", "Restore original config and exit"),
|
|
826
|
+
("q", "Quit session"),
|
|
827
|
+
],
|
|
828
|
+
default="a")
|
|
829
|
+
|
|
830
|
+
if choice == "a":
|
|
831
|
+
# Create backup before first apply
|
|
832
|
+
if backup_path is None:
|
|
833
|
+
backup_path, _backup_diff = _create_backup(
|
|
834
|
+
fc, info, output_dir=output_dir)
|
|
835
|
+
if backup_path is None:
|
|
836
|
+
print(f"\n {RED}Cannot proceed without a backup.{R}")
|
|
837
|
+
print(f" {RED}No changes will be made to the FC.{R}")
|
|
838
|
+
break
|
|
839
|
+
|
|
840
|
+
_apply_commands(fc, commands)
|
|
841
|
+
changes_applied = True
|
|
842
|
+
|
|
843
|
+
elif choice == "r":
|
|
844
|
+
if backup_path and changes_applied:
|
|
845
|
+
fc_new, ok = _restore_backup(fc, info, backup_path)
|
|
846
|
+
if fc_new:
|
|
847
|
+
fc.__dict__.update(fc_new.__dict__)
|
|
848
|
+
if ok:
|
|
849
|
+
print(f" {G}Restored to original config.{R}")
|
|
850
|
+
elif not changes_applied:
|
|
851
|
+
print(f" No changes were applied - nothing to restore.")
|
|
852
|
+
break
|
|
853
|
+
|
|
854
|
+
elif choice == "q":
|
|
855
|
+
if changes_applied and backup_path:
|
|
856
|
+
print(f"\n {Y}Changes were applied to the FC.{R}")
|
|
857
|
+
if _confirm("Restore original config before quitting?",
|
|
858
|
+
default=False):
|
|
859
|
+
fc_new, ok = _restore_backup(fc, info, backup_path)
|
|
860
|
+
if fc_new:
|
|
861
|
+
fc.__dict__.update(fc_new.__dict__)
|
|
862
|
+
break
|
|
863
|
+
|
|
864
|
+
elif choice == "s":
|
|
865
|
+
pass # Fall through to erase/fly
|
|
866
|
+
|
|
867
|
+
else:
|
|
868
|
+
# No commands to apply
|
|
869
|
+
if not _confirm("\n Continue tuning?", default=True):
|
|
870
|
+
break
|
|
871
|
+
|
|
872
|
+
# Erase and loop
|
|
873
|
+
if _confirm("\n Erase dataflash for next flight?", default=True):
|
|
874
|
+
_erase_dataflash(fc)
|
|
875
|
+
|
|
876
|
+
print(f"\n {B}Go fly!{R}")
|
|
877
|
+
print(f" When you land, plug back in and press Enter.\n")
|
|
878
|
+
input(f" {DIM}Press Enter when ready...{R} ")
|
|
879
|
+
|
|
880
|
+
# Refresh diff after applying changes
|
|
881
|
+
diff_raw = _pull_diff(fc, output_dir=output_dir, craft_name=craft)
|
|
882
|
+
if diff_raw:
|
|
883
|
+
diff_file = os.path.join(output_dir, f"{craft}_diff.txt")
|
|
884
|
+
|
|
885
|
+
except KeyboardInterrupt:
|
|
886
|
+
print(f"\n\n {Y}Session interrupted.{R}")
|
|
887
|
+
|
|
888
|
+
# Session end - always remind about backup if changes were made
|
|
889
|
+
if backup_path and changes_applied:
|
|
890
|
+
print(f"\n {B}Session backup:{R} {backup_path}")
|
|
891
|
+
print(f" To restore at any time, run the toolkit and")
|
|
892
|
+
print(f" choose 'Restore configuration from backup'.")
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _flow_nav_check(fc, info):
|
|
896
|
+
"""Nav health check flow."""
|
|
897
|
+
craft = info.get("craft_name", "fc")
|
|
898
|
+
output_dir = "./blackbox"
|
|
899
|
+
|
|
900
|
+
print(f"\n {B}{C}--- Nav Health Check ---{R}")
|
|
901
|
+
|
|
902
|
+
diff_raw = _pull_diff(fc, output_dir=output_dir, craft_name=craft)
|
|
903
|
+
diff_file = None
|
|
904
|
+
if diff_raw:
|
|
905
|
+
diff_file = os.path.join(output_dir, f"{craft}_diff.txt")
|
|
906
|
+
|
|
907
|
+
filepath = _download_blackbox(fc, info, output_dir=output_dir)
|
|
908
|
+
if not filepath:
|
|
909
|
+
return
|
|
910
|
+
|
|
911
|
+
print(f"\n Analyzing nav health...\n")
|
|
912
|
+
results = _run_analyzer(filepath, mode="nav", diff_file=diff_file)
|
|
913
|
+
|
|
914
|
+
# Nav mode output goes to terminal directly via subprocess
|
|
915
|
+
print(results.get("output", ""))
|
|
916
|
+
|
|
917
|
+
if results.get("html_path"):
|
|
918
|
+
print(f"\n {DIM}Full report: {results['html_path']}{R}")
|
|
919
|
+
|
|
920
|
+
if _confirm("\n Erase dataflash?", default=False):
|
|
921
|
+
_erase_dataflash(fc)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _flow_download_only(fc, info):
|
|
925
|
+
"""Just download the blackbox log."""
|
|
926
|
+
print(f"\n {B}{C}--- Download Blackbox ---{R}")
|
|
927
|
+
|
|
928
|
+
filepath = _download_blackbox(fc, info)
|
|
929
|
+
if filepath:
|
|
930
|
+
print(f"\n {G}Saved: {filepath}{R}")
|
|
931
|
+
|
|
932
|
+
if _confirm("Erase dataflash?", default=False):
|
|
933
|
+
_erase_dataflash(fc)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _flow_restore(fc, info):
|
|
937
|
+
"""Restore from a previous backup file."""
|
|
938
|
+
print(f"\n {B}{C}--- Restore Configuration ---{R}")
|
|
939
|
+
|
|
940
|
+
# Find backup files
|
|
941
|
+
backup_dir = "./blackbox"
|
|
942
|
+
craft = info.get("craft_name") or "fc"
|
|
943
|
+
backups = []
|
|
944
|
+
if os.path.isdir(backup_dir):
|
|
945
|
+
for f in sorted(os.listdir(backup_dir), reverse=True):
|
|
946
|
+
if f.endswith(".txt") and "_backup_" in f:
|
|
947
|
+
backups.append(os.path.join(backup_dir, f))
|
|
948
|
+
|
|
949
|
+
if backups:
|
|
950
|
+
print(f"\n {B}Available backups:{R}")
|
|
951
|
+
for i, bp in enumerate(backups[:5], 1):
|
|
952
|
+
mtime = time.strftime("%Y-%m-%d %H:%M",
|
|
953
|
+
time.localtime(os.path.getmtime(bp)))
|
|
954
|
+
size = os.path.getsize(bp)
|
|
955
|
+
print(f" {C}{i}{R} {os.path.basename(bp)} "
|
|
956
|
+
f"{DIM}({mtime}, {size:,} bytes){R}")
|
|
957
|
+
|
|
958
|
+
choice = _prompt("Choose a backup (number) or enter a file path:",
|
|
959
|
+
default="1")
|
|
960
|
+
try:
|
|
961
|
+
idx = int(choice) - 1
|
|
962
|
+
if 0 <= idx < len(backups):
|
|
963
|
+
backup_path = backups[idx]
|
|
964
|
+
else:
|
|
965
|
+
print(f" {RED}Invalid selection.{R}")
|
|
966
|
+
return
|
|
967
|
+
except ValueError:
|
|
968
|
+
backup_path = choice
|
|
969
|
+
else:
|
|
970
|
+
backup_path = _prompt("Path to backup file:")
|
|
971
|
+
|
|
972
|
+
if not backup_path or not os.path.isfile(backup_path):
|
|
973
|
+
print(f" {RED}File not found: {backup_path}{R}")
|
|
974
|
+
return
|
|
975
|
+
|
|
976
|
+
fc_new, ok = _restore_backup(fc, info, backup_path)
|
|
977
|
+
if fc_new and fc_new is not fc:
|
|
978
|
+
# Update the caller's reference
|
|
979
|
+
fc.__dict__.update(fc_new.__dict__)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _flow_offline():
|
|
983
|
+
"""Offline analysis: analyze an existing BBL file."""
|
|
984
|
+
print(f"\n {B}{C}--- Offline Analysis ---{R}")
|
|
985
|
+
|
|
986
|
+
logfile = _prompt("Path to blackbox log file (.bbl):")
|
|
987
|
+
if not logfile or not os.path.isfile(logfile):
|
|
988
|
+
print(f" {RED}File not found: {logfile}{R}")
|
|
989
|
+
return
|
|
990
|
+
|
|
991
|
+
mode = _prompt("Analysis mode:",
|
|
992
|
+
options=[("t", "PID tuning"), ("n", "Nav health")],
|
|
993
|
+
default="t")
|
|
994
|
+
|
|
995
|
+
diff_file = None
|
|
996
|
+
# Auto-discover diff
|
|
997
|
+
log_dir = os.path.dirname(os.path.abspath(logfile))
|
|
998
|
+
candidates = [f for f in os.listdir(log_dir)
|
|
999
|
+
if f.endswith("_diff.txt") or f in ("diff.txt", "diff_all.txt")]
|
|
1000
|
+
if candidates:
|
|
1001
|
+
diff_file = os.path.join(log_dir, candidates[0])
|
|
1002
|
+
print(f" {DIM}Found config: {candidates[0]}{R}")
|
|
1003
|
+
|
|
1004
|
+
print(f"\n Analyzing...\n")
|
|
1005
|
+
results = _run_analyzer(logfile,
|
|
1006
|
+
mode="nav" if mode == "n" else "tune",
|
|
1007
|
+
diff_file=diff_file)
|
|
1008
|
+
|
|
1009
|
+
if mode == "n":
|
|
1010
|
+
print(results.get("output", ""))
|
|
1011
|
+
else:
|
|
1012
|
+
if results["success"]:
|
|
1013
|
+
_print_score(results["score"], results["verdict"])
|
|
1014
|
+
_print_actions(results["actions"], results.get("deferred"))
|
|
1015
|
+
else:
|
|
1016
|
+
print(results.get("output", "")[:500])
|
|
1017
|
+
|
|
1018
|
+
if results.get("html_path"):
|
|
1019
|
+
print(f"\n {DIM}Full report: {results['html_path']}{R}")
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
# ─── Main Entry ───────────────────────────────────────────────────────────────
|
|
1023
|
+
|
|
1024
|
+
def main():
|
|
1025
|
+
import argparse
|
|
1026
|
+
parser = argparse.ArgumentParser(
|
|
1027
|
+
description=f"INAV Toolkit v{VERSION} - Guided Session Manager")
|
|
1028
|
+
parser.add_argument("--version", action="version", version=f"inav-toolkit {VERSION}")
|
|
1029
|
+
parser.add_argument("--device", metavar="PORT", default=None,
|
|
1030
|
+
help="Serial port or 'auto' (e.g., /dev/ttyACM0, COM3)")
|
|
1031
|
+
parser.add_argument("--no-color", action="store_true",
|
|
1032
|
+
help="Disable colored output")
|
|
1033
|
+
args = parser.parse_args()
|
|
1034
|
+
|
|
1035
|
+
if args.no_color:
|
|
1036
|
+
global R, B, C, G, Y, RED, DIM
|
|
1037
|
+
R = B = C = G = Y = RED = DIM = ""
|
|
1038
|
+
|
|
1039
|
+
_banner()
|
|
1040
|
+
|
|
1041
|
+
# Try to connect to FC
|
|
1042
|
+
fc = None
|
|
1043
|
+
info = None
|
|
1044
|
+
|
|
1045
|
+
if args.device:
|
|
1046
|
+
fc, info = _connect_fc(args.device)
|
|
1047
|
+
else:
|
|
1048
|
+
# Ask if FC is connected
|
|
1049
|
+
has_fc = _confirm("Is your flight controller connected via USB?", default=True)
|
|
1050
|
+
if has_fc:
|
|
1051
|
+
fc, info = _connect_fc("auto")
|
|
1052
|
+
|
|
1053
|
+
if fc and info:
|
|
1054
|
+
_print_fc_info(info)
|
|
1055
|
+
|
|
1056
|
+
try:
|
|
1057
|
+
mode = _prompt("What are we doing today?",
|
|
1058
|
+
options=[
|
|
1059
|
+
("1", "Tuning session - analyze and improve"),
|
|
1060
|
+
("2", "Nav health check"),
|
|
1061
|
+
("3", "New build - safety check + baseline"),
|
|
1062
|
+
("4", "Just download the blackbox log"),
|
|
1063
|
+
("5", "Restore configuration from backup"),
|
|
1064
|
+
],
|
|
1065
|
+
default="1")
|
|
1066
|
+
|
|
1067
|
+
if mode == "1":
|
|
1068
|
+
_flow_tune_session(fc, info)
|
|
1069
|
+
elif mode == "2":
|
|
1070
|
+
_flow_nav_check(fc, info)
|
|
1071
|
+
elif mode == "3":
|
|
1072
|
+
_flow_new_build(fc, info)
|
|
1073
|
+
elif mode == "4":
|
|
1074
|
+
_flow_download_only(fc, info)
|
|
1075
|
+
elif mode == "5":
|
|
1076
|
+
_flow_restore(fc, info)
|
|
1077
|
+
|
|
1078
|
+
except KeyboardInterrupt:
|
|
1079
|
+
print(f"\n\n {DIM}Interrupted.{R}")
|
|
1080
|
+
finally:
|
|
1081
|
+
fc.close()
|
|
1082
|
+
print(f"\n {DIM}Disconnected.{R}")
|
|
1083
|
+
else:
|
|
1084
|
+
# No FC - offer offline analysis
|
|
1085
|
+
if _confirm("\n No FC connected. Analyze an existing log file?", default=True):
|
|
1086
|
+
try:
|
|
1087
|
+
_flow_offline()
|
|
1088
|
+
except KeyboardInterrupt:
|
|
1089
|
+
print(f"\n\n {DIM}Interrupted.{R}")
|
|
1090
|
+
|
|
1091
|
+
print(f"\n {DIM}Fly safe.{R}\n")
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
if __name__ == "__main__":
|
|
1095
|
+
main()
|