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/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()