inav-toolkit 2.15.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2715 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ INAV Parameter Analyzer - Analyze `diff all` output for configuration issues.
4
+
5
+ Parses INAV CLI `diff all` output and checks for:
6
+ - Safety issues (beepers, failsafe, voltage limits)
7
+ - Performance issues (filters, PID settings, motor protocol)
8
+ - Missing recommended settings for the hardware profile
9
+ - Cross-reference with blackbox analysis results (optional)
10
+ - Navigation configuration review
11
+
12
+ Usage:
13
+ inav-params diff_all.txt [--frame 10] [--blackbox state.json]
14
+ """
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import re
20
+ import sys
21
+ import textwrap
22
+ from datetime import datetime
23
+
24
+ VERSION = "2.15.0"
25
+
26
+
27
+ def _enable_ansi_colors():
28
+ """Enable ANSI color support. Returns True if colors are available."""
29
+ if os.environ.get("NO_COLOR") is not None:
30
+ return False
31
+ if not hasattr(sys.stdout, "isatty") or not sys.stdout.isatty():
32
+ return False
33
+ if sys.platform == "win32":
34
+ try:
35
+ import ctypes
36
+ kernel32 = ctypes.windll.kernel32
37
+ handle = kernel32.GetStdHandle(-11)
38
+ mode = ctypes.c_ulong()
39
+ kernel32.GetConsoleMode(handle, ctypes.byref(mode))
40
+ kernel32.SetConsoleMode(handle, mode.value | 0x0004)
41
+ return True
42
+ except Exception:
43
+ return False
44
+ return True
45
+
46
+ _ANSI_ENABLED = _enable_ansi_colors()
47
+
48
+ def _colors():
49
+ """Return (R, B, C, G, Y, RED, DIM) color codes."""
50
+ if _ANSI_ENABLED:
51
+ return ("\033[0m", "\033[1m", "\033[96m", "\033[92m",
52
+ "\033[93m", "\033[91m", "\033[2m")
53
+ return ("", "", "", "", "", "", "")
54
+
55
+ def _disable_colors():
56
+ global _ANSI_ENABLED
57
+ _ANSI_ENABLED = False
58
+
59
+ # ─── Severity Levels ─────────────────────────────────────────────────────────
60
+
61
+ CRITICAL = "CRITICAL" # Safety risk, could crash or lose the quad
62
+ WARNING = "WARNING" # Likely performance issue or suboptimal config
63
+ INFO = "INFO" # Suggestion, nice-to-have
64
+ OK = "OK" # Checked and looks good
65
+
66
+ SEVERITY_ORDER = {CRITICAL: 0, WARNING: 1, INFO: 2, OK: 3}
67
+
68
+ # ─── INAV Aux Mode IDs (from src/main/fc/rc_modes.h) ────────────────────────
69
+
70
+ INAV_MODES = {
71
+ 0: "ARM", 1: "ANGLE", 2: "HORIZON", 3: "NAV ALTHOLD",
72
+ 5: "HEADING HOLD", 10: "NAV POSHOLD", 11: "NAV RTH",
73
+ 12: "MANUAL", 13: "NAV WP", 28: "NAV LAUNCH", 45: "TURTLE",
74
+ 47: "OSD ALT", 48: "NAV COURSE HOLD", 53: "MULTI FUNCTION",
75
+ 62: "MIXER PROFILE 2", 63: "MIXER TRANSITION",
76
+ }
77
+
78
+ # Modes that require GPS
79
+ GPS_MODES = {10, 11, 13, 48} # POSHOLD, RTH, WP, COURSE HOLD
80
+ # Modes that require baro or althold
81
+ ALT_MODES = {3} # NAV ALTHOLD
82
+ # Modes that benefit from compass
83
+ COMPASS_MODES = {10, 11, 13, 5} # POSHOLD, RTH, WP, HEADING HOLD
84
+
85
+ # Multirotor platform types
86
+ MC_PLATFORMS = {"MULTIROTOR", "TRICOPTER"}
87
+
88
+ # ─── Known Defaults & Limits ─────────────────────────────────────────────────
89
+
90
+ # INAV 9.x defaults for multirotor (applied_defaults=5 is "multirotor with GPS")
91
+ INAV9_MC_DEFAULTS = {
92
+ "looptime": 500, # 2kHz
93
+ "gyro_main_lpf_hz": 110,
94
+ "gyro_main_lpf_type": "PT1",
95
+ "dterm_lpf_hz": 110,
96
+ "dterm_lpf_type": "PT1",
97
+ "dynamic_gyro_notch_q": 250,
98
+ "dynamic_gyro_notch_min_hz": 80,
99
+ "mc_p_pitch": 44, "mc_i_pitch": 75, "mc_d_pitch": 25,
100
+ "mc_p_roll": 40, "mc_i_roll": 75, "mc_d_roll": 25,
101
+ "mc_p_yaw": 85, "mc_i_yaw": 45, "mc_d_yaw": 0,
102
+ "mc_iterm_relax": "RP",
103
+ "d_boost_min": 1.0, "d_boost_max": 1.0,
104
+ "antigravity_gain": 1.0,
105
+ "motor_pwm_protocol": "DSHOT300",
106
+ "blackbox_rate_denom": 1,
107
+ "failsafe_procedure": "DROP",
108
+ "nav_rth_altitude": 5000,
109
+ "nav_mc_hover_thr": 1500,
110
+ }
111
+
112
+ # Motor pole counts for common motors
113
+ COMMON_MOTOR_POLES = {14: "most standard motors", 12: "some smaller motors"}
114
+
115
+ # ─── Frame-Specific Starting Profiles ────────────────────────────────────────
116
+ # Conservative baselines for large INAV multirotors.
117
+ # These are intentionally soft - designed to be flyable on first hover,
118
+ # then refined with the blackbox analyzer. Too low is sluggish, too high
119
+ # oscillates. We err on the sluggish side.
120
+ #
121
+ # Sources: community experience, INAV defaults scaling, physics of prop inertia.
122
+ # Prop inertia scales roughly with diameter^4, so a 10" prop has ~4x the inertia
123
+ # of a 7". PID gains must scale down accordingly.
124
+
125
+ FRAME_PROFILES = {
126
+ # ── 5-inch baseline ─────────────────────────────────────────────────
127
+ # Sources:
128
+ # - INAV 9 firmware defaults (settings.yaml / pid.c)
129
+ # - INAV configurator 5" preset (GitHub issue #167)
130
+ # - INAV Settings.md: dterm_lpf "100 should work best with 5-inch"
131
+ # - INAV Settings.md: dyn_notch_min "default 150 works best with 5\""
132
+ 5: {
133
+ "name": "5-inch racer / freestyle",
134
+ "description": "Standard FPV size. 2306-2207 motors, 4S-6S.",
135
+ "typical_auw": "350-700g",
136
+ "typical_motors": "2207, 2306, 2405",
137
+ "pids": {
138
+ "mc_p_pitch": 44, "mc_i_pitch": 75, "mc_d_pitch": 28,
139
+ "mc_cd_pitch": 60,
140
+ "mc_p_roll": 40, "mc_i_roll": 60, "mc_d_roll": 26,
141
+ "mc_cd_roll": 60,
142
+ "mc_p_yaw": 45, "mc_i_yaw": 80, "mc_d_yaw": 0,
143
+ },
144
+ "filters": {
145
+ "gyro_main_lpf_hz": 110,
146
+ "dterm_lpf_hz": 110,
147
+ "dterm_lpf_type": "PT3",
148
+ "dynamic_gyro_notch_min_hz": 150,
149
+ "dynamic_gyro_notch_q": 250,
150
+ "dynamic_gyro_notch_mode": "3D",
151
+ },
152
+ "rates": {
153
+ "roll_rate": 70,
154
+ "pitch_rate": 70,
155
+ "yaw_rate": 60,
156
+ },
157
+ "other": {
158
+ "mc_iterm_relax": "RPY",
159
+ "mc_iterm_relax_cutoff": 15,
160
+ "d_boost_min": 0.8,
161
+ "d_boost_max": 1.2,
162
+ "antigravity_gain": 2.0,
163
+ "antigravity_accelerator": 5.0,
164
+ "tpa_rate": 20,
165
+ "tpa_breakpoint": 1350,
166
+ "rate_accel_limit_roll_pitch": 0,
167
+ "rate_accel_limit_yaw": 0,
168
+ },
169
+ "notes": [
170
+ "Close to INAV 9 defaults. Fine starting point for 5-inch builds.",
171
+ "Tune from here using blackbox - most 5-inch quads converge quickly.",
172
+ ],
173
+ },
174
+ # ── 7-inch ──────────────────────────────────────────────────────────
175
+ # Sources:
176
+ # - INAV Settings.md: dterm_lpf "80 seems like a gold spot for 7-inch"
177
+ # - INAV Settings.md: dyn_notch_min "values around 100 work fine on 7\""
178
+ # - Pawel Spychalski's 7" builds (INAV core dev, community baseline)
179
+ # - INAV defaults designed for 5-7", minimal changes needed at 7"
180
+ 7: {
181
+ "name": "7-inch long-range",
182
+ "description": "Common for long-range builds. 2807-3007 motors, 4S-6S.",
183
+ "typical_auw": "800-1400g",
184
+ "typical_motors": "2807, 2907, 3007",
185
+ "pids": {
186
+ "mc_p_pitch": 44, "mc_i_pitch": 75, "mc_d_pitch": 25,
187
+ "mc_cd_pitch": 60,
188
+ "mc_p_roll": 40, "mc_i_roll": 60, "mc_d_roll": 23,
189
+ "mc_cd_roll": 60,
190
+ "mc_p_yaw": 45, "mc_i_yaw": 80, "mc_d_yaw": 0,
191
+ },
192
+ "filters": {
193
+ "gyro_main_lpf_hz": 90,
194
+ "dterm_lpf_hz": 80,
195
+ "dterm_lpf_type": "PT3",
196
+ "dynamic_gyro_notch_min_hz": 100,
197
+ "dynamic_gyro_notch_q": 250,
198
+ "dynamic_gyro_notch_mode": "3D",
199
+ },
200
+ "rates": {
201
+ "roll_rate": 50,
202
+ "pitch_rate": 50,
203
+ "yaw_rate": 40,
204
+ },
205
+ "other": {
206
+ "mc_iterm_relax": "RPY",
207
+ "mc_iterm_relax_cutoff": 12,
208
+ "d_boost_min": 0.85,
209
+ "d_boost_max": 1.15,
210
+ "antigravity_gain": 2.0,
211
+ "antigravity_accelerator": 5.0,
212
+ "tpa_rate": 20,
213
+ "tpa_breakpoint": 1350,
214
+ "rate_accel_limit_roll_pitch": 0,
215
+ "rate_accel_limit_yaw": 0,
216
+ },
217
+ "notes": [
218
+ "INAV defaults are designed for 5-7\". Only minor changes needed.",
219
+ "Dterm LPF at 80Hz per INAV dev guidance. Dynamic notch min at 100Hz.",
220
+ "If using triblades, reduce P by 10-15% - they grip harder and respond faster.",
221
+ ],
222
+ },
223
+ # ── 10-inch ─────────────────────────────────────────────────────────
224
+ # Sources:
225
+ # - INAV Settings.md: dyn_notch_min "60-70" for 10", dterm_lpf ~60-70Hz
226
+ # - GitHub #9765 (Ivan): starting PIDs 55/75/35 for 7"+, filters -10Hz from 7"
227
+ # - IntoFPV forum: P:D ratio ~1:1, iterm_relax_cutoff <10 (5 recommended)
228
+ # - Community consensus: frame stiffness 7.5mm arm min, RPM filters critical
229
+ # Conservative starting point biased toward safe first flight.
230
+ 10: {
231
+ "name": "10-inch long-range / cruiser",
232
+ "description": "Long-range with large Li-ion packs. 3507-4010 motors, 4S-6S.",
233
+ "typical_auw": "1500-2800g",
234
+ "typical_motors": "3507, 3508, 4008, 4010",
235
+ "pids": {
236
+ "mc_p_pitch": 40, "mc_i_pitch": 60, "mc_d_pitch": 30,
237
+ "mc_cd_pitch": 40,
238
+ "mc_p_roll": 38, "mc_i_roll": 55, "mc_d_roll": 28,
239
+ "mc_cd_roll": 40,
240
+ "mc_p_yaw": 40, "mc_i_yaw": 60, "mc_d_yaw": 0,
241
+ },
242
+ "filters": {
243
+ "gyro_main_lpf_hz": 70,
244
+ "dterm_lpf_hz": 60,
245
+ "dterm_lpf_type": "PT3",
246
+ "dynamic_gyro_notch_min_hz": 65,
247
+ "dynamic_gyro_notch_q": 250,
248
+ "dynamic_gyro_notch_mode": "3D",
249
+ },
250
+ "rates": {
251
+ "roll_rate": 36,
252
+ "pitch_rate": 36,
253
+ "yaw_rate": 30,
254
+ },
255
+ "other": {
256
+ "mc_iterm_relax": "RPY",
257
+ "mc_iterm_relax_cutoff": 8,
258
+ "d_boost_min": 0.80,
259
+ "d_boost_max": 1.20,
260
+ "antigravity_gain": 2.0,
261
+ "antigravity_accelerator": 5.0,
262
+ "tpa_rate": 20,
263
+ "tpa_breakpoint": 1250,
264
+ "rate_accel_limit_roll_pitch": 1000,
265
+ "rate_accel_limit_yaw": 500,
266
+ },
267
+ "notes": [
268
+ "P:D ratio close to 1:1 on 10\" (unlike 5\" where P >> D).",
269
+ "Lower iterm_relax_cutoff (8) prevents bounce-back on this heavier airframe.",
270
+ "Rate acceleration limits prevent sudden moves that stress the frame.",
271
+ "Frame stiffness is critical - 7.5mm carbon arms minimum.",
272
+ "If heavy (>2.5kg), reduce P by 10% and raise I by 10% for better position hold.",
273
+ "EZ Tune is an alternative: set ez_filter_hz ~70, lower response/stability.",
274
+ ],
275
+ },
276
+ # ── 12-inch ─────────────────────────────────────────────────────────
277
+ # Sources:
278
+ # - Extrapolated from 10" community data + physics-based scaling
279
+ # - GitHub #9765: 16" Tarot flew P=44/I=45/D=36, gyro=50, dterm=40
280
+ # - Noise frequencies shift down proportionally with prop diameter
281
+ # - INAV Settings.md: accel limits ~360-500 dps^2 for heavy multirotors
282
+ 12: {
283
+ "name": "12-inch heavy lifter",
284
+ "description": "Heavy lift, cine, long endurance. 4510-5010 motors, 6S-12S.",
285
+ "typical_auw": "2500-5000g",
286
+ "typical_motors": "4510, 4515, 5010, 5015",
287
+ "pids": {
288
+ "mc_p_pitch": 32, "mc_i_pitch": 50, "mc_d_pitch": 25,
289
+ "mc_cd_pitch": 30,
290
+ "mc_p_roll": 30, "mc_i_roll": 45, "mc_d_roll": 23,
291
+ "mc_cd_roll": 30,
292
+ "mc_p_yaw": 35, "mc_i_yaw": 50, "mc_d_yaw": 0,
293
+ },
294
+ "filters": {
295
+ "gyro_main_lpf_hz": 55,
296
+ "dterm_lpf_hz": 45,
297
+ "dterm_lpf_type": "PT3",
298
+ "dynamic_gyro_notch_min_hz": 45,
299
+ "dynamic_gyro_notch_q": 250,
300
+ "dynamic_gyro_notch_mode": "3D",
301
+ },
302
+ "rates": {
303
+ "roll_rate": 28,
304
+ "pitch_rate": 28,
305
+ "yaw_rate": 22,
306
+ },
307
+ "other": {
308
+ "mc_iterm_relax": "RPY",
309
+ "mc_iterm_relax_cutoff": 5,
310
+ "d_boost_min": 0.75,
311
+ "d_boost_max": 1.25,
312
+ "antigravity_gain": 2.5,
313
+ "antigravity_accelerator": 5.0,
314
+ "tpa_rate": 15,
315
+ "tpa_breakpoint": 1300,
316
+ "rate_accel_limit_roll_pitch": 500,
317
+ "rate_accel_limit_yaw": 250,
318
+ },
319
+ "notes": [
320
+ "Large prop inertia makes overcorrection the main risk, not sluggishness.",
321
+ "Aerodynamic damping from large props reduces the need for D-term.",
322
+ "Higher antigravity helps maintain altitude during throttle changes with heavy payloads.",
323
+ "Acceleration limits are essential - sudden rate demands can flex the frame.",
324
+ "FC vibration isolation is critical. Use soft-mount or TPU dampers.",
325
+ "EZ Tune alternative: set ez_filter_hz ~55, lower response/stability.",
326
+ ],
327
+ },
328
+ # ── 15-inch ─────────────────────────────────────────────────────────
329
+ # Sources:
330
+ # - GitHub #9765 (Ivan): gyro filters ~50Hz on 16", PIDs 44/45/36 (tuned)
331
+ # - INAV Settings.md: accel limits 360/180 dps^2 for big heavy multirotors
332
+ # - Physics: prop inertia ~10x that of 7", motor noise 60-120Hz range
333
+ # - Conservative starting point - 16" Tarot flew P=44 but was well-tuned
334
+ 15: {
335
+ "name": "15-inch heavy lift / cine lifter",
336
+ "description": "Maximum endurance and payload. 5515-7015 motors, 6S-12S.",
337
+ "typical_auw": "4000-10000g",
338
+ "typical_motors": "5515, 6010, 6015, 7015",
339
+ "pids": {
340
+ "mc_p_pitch": 28, "mc_i_pitch": 45, "mc_d_pitch": 22,
341
+ "mc_cd_pitch": 20,
342
+ "mc_p_roll": 26, "mc_i_roll": 40, "mc_d_roll": 20,
343
+ "mc_cd_roll": 20,
344
+ "mc_p_yaw": 30, "mc_i_yaw": 40, "mc_d_yaw": 0,
345
+ },
346
+ "filters": {
347
+ "gyro_main_lpf_hz": 45,
348
+ "dterm_lpf_hz": 35,
349
+ "dterm_lpf_type": "PT3",
350
+ "dynamic_gyro_notch_min_hz": 30,
351
+ "dynamic_gyro_notch_q": 250,
352
+ "dynamic_gyro_notch_mode": "3D",
353
+ },
354
+ "rates": {
355
+ "roll_rate": 20,
356
+ "pitch_rate": 20,
357
+ "yaw_rate": 18,
358
+ },
359
+ "other": {
360
+ "mc_iterm_relax": "RPY",
361
+ "mc_iterm_relax_cutoff": 5,
362
+ "d_boost_min": 0.70,
363
+ "d_boost_max": 1.30,
364
+ "antigravity_gain": 3.0,
365
+ "antigravity_accelerator": 5.0,
366
+ "tpa_rate": 10,
367
+ "tpa_breakpoint": 1300,
368
+ "rate_accel_limit_roll_pitch": 360,
369
+ "rate_accel_limit_yaw": 180,
370
+ },
371
+ "notes": [
372
+ "Prop inertia is enormous. Overcorrection is the main risk, not sluggishness.",
373
+ "Test in calm conditions first. Wind amplifies any PID issues on large frames.",
374
+ "Accel limits (360/180 dps^2) per INAV dev guidance for big heavy multirotors.",
375
+ "FC vibration isolation is critical. Use soft-mount or TPU dampers.",
376
+ "If oscillation persists, lower P before lowering D - unlike smaller quads.",
377
+ "Community data: tuned 16\" flew P=44 - room to increase once vibrations are controlled.",
378
+ "EZ Tune alternative: set ez_filter_hz ~45, lower response/stability significantly.",
379
+ ],
380
+ },
381
+ }
382
+
383
+ # Voltage-specific adjustments (applied on top of frame profile)
384
+ VOLTAGE_ADJUSTMENTS = {
385
+ "4S": {
386
+ "description": "4S (14.8-16.8V)",
387
+ "pid_scale": 1.0, # baseline
388
+ "notes": "Standard voltage. No PID adjustment needed.",
389
+ },
390
+ "6S": {
391
+ "description": "6S (22.2-25.2V)",
392
+ "pid_scale": 0.85, # motors are punchier, need less P
393
+ "notes": "Higher voltage means faster motor response. PIDs scaled down ~15%.",
394
+ },
395
+ "8S": {
396
+ "description": "8S (29.6-33.6V)",
397
+ "pid_scale": 0.75,
398
+ "notes": "Very fast motor response. PIDs scaled down ~25%. Use care on first flight.",
399
+ },
400
+ "12S": {
401
+ "description": "12S (44.4-50.4V)",
402
+ "pid_scale": 0.65,
403
+ "notes": "Extreme voltage. Very conservative PIDs recommended. Motors respond almost instantly.",
404
+ },
405
+ }
406
+
407
+
408
+ # ─── Setup Mode: Generate Starting Config ────────────────────────────────────
409
+
410
+ def generate_setup_config(frame_inches, voltage="4S", use_case="longrange"):
411
+ """Generate conservative starting configuration for a given frame size."""
412
+ if frame_inches not in FRAME_PROFILES:
413
+ # Find nearest
414
+ available = sorted(FRAME_PROFILES.keys())
415
+ nearest = min(available, key=lambda x: abs(x - frame_inches))
416
+ print(f" Warning: No profile for {frame_inches}\". Using nearest: {nearest}\"")
417
+ frame_inches = nearest
418
+
419
+ profile = FRAME_PROFILES[frame_inches]
420
+ v_adj = VOLTAGE_ADJUSTMENTS.get(voltage, VOLTAGE_ADJUSTMENTS["4S"])
421
+ scale = v_adj["pid_scale"]
422
+
423
+ # Scale PIDs by voltage
424
+ pids = {}
425
+ for k, v in profile["pids"].items():
426
+ if v == 0:
427
+ # Respect explicit zeros (e.g. yaw D = 0 on multirotors)
428
+ pids[k] = 0
429
+ elif k.startswith("mc_p_") or k.startswith("mc_d_") or k.startswith("mc_cd_"):
430
+ pids[k] = max(5, round(v * scale))
431
+ elif k.startswith("mc_i_"):
432
+ # I-term scales less with voltage - it's about steady-state
433
+ pids[k] = max(20, round(v * (1.0 + (scale - 1.0) * 0.3)))
434
+ else:
435
+ pids[k] = v
436
+
437
+ config = {
438
+ "frame": frame_inches,
439
+ "voltage": voltage,
440
+ "profile": profile,
441
+ "v_adj": v_adj,
442
+ "pids": pids,
443
+ "filters": dict(profile["filters"]),
444
+ "rates": dict(profile.get("rates", {})),
445
+ "other": dict(profile["other"]),
446
+ }
447
+
448
+ return config
449
+
450
+
451
+ def print_setup_report(config):
452
+ """Print the setup configuration as a readable report with CLI commands."""
453
+ R, B, C, G, Y, _RED, DIM = _colors()
454
+
455
+ profile = config["profile"]
456
+ frame = config["frame"]
457
+ voltage = config["voltage"]
458
+
459
+ print(f"\n{B}{C}{'='*70}{R}")
460
+ print(f"{B}{C} INAV Starting Configuration - {frame}-inch ({voltage}){R}")
461
+ print(f"{B}{C}{'='*70}{R}")
462
+ print(f" {DIM}{profile['description']}{R}")
463
+ print(f" {DIM}Typical AUW: {profile['typical_auw']} | Motors: {profile['typical_motors']}{R}")
464
+
465
+ if config["v_adj"]["pid_scale"] != 1.0:
466
+ pct = (1.0 - config["v_adj"]["pid_scale"]) * 100
467
+ print(f" {Y}Voltage adjustment: {voltage} -> PIDs reduced ~{pct:.0f}%{R}")
468
+
469
+ print(f"\n{B}{C}{'-'*70}{R}")
470
+ print(f" {B}IMPORTANT:{R}")
471
+ print(f" {DIM}These are CONSERVATIVE starting values - safe for first hover.{R}")
472
+ print(f" {DIM}The quad may feel sluggish. That's intentional.{R}")
473
+ print(f" {DIM}Fly, log blackbox data, then use the blackbox analyzer to refine.{R}")
474
+ print(f"{B}{C}{'-'*70}{R}")
475
+
476
+ # PID table
477
+ pids = config["pids"]
478
+ has_cd = any(k.startswith("mc_cd_") for k in pids)
479
+ if has_cd:
480
+ print(f"\n {B}STARTING PIDs:{R}")
481
+ print(f" {'P':>5} {'I':>5} {'D':>5} {'CD':>5}")
482
+ for axis in ("roll", "pitch", "yaw"):
483
+ p = pids.get(f"mc_p_{axis}", "-")
484
+ i = pids.get(f"mc_i_{axis}", "-")
485
+ d = pids.get(f"mc_d_{axis}", "-")
486
+ cd = pids.get(f"mc_cd_{axis}", "-")
487
+ print(f" {axis.capitalize():6} {p:>5} {i:>5} {d:>5} {cd:>5}")
488
+ else:
489
+ print(f"\n {B}STARTING PIDs:{R}")
490
+ print(f" {'P':>5} {'I':>5} {'D':>5}")
491
+ for axis in ("roll", "pitch", "yaw"):
492
+ p = pids.get(f"mc_p_{axis}", "-")
493
+ i = pids.get(f"mc_i_{axis}", "-")
494
+ d = pids.get(f"mc_d_{axis}", "-")
495
+ print(f" {axis.capitalize():6} {p:>5} {i:>5} {d:>5}")
496
+
497
+ # Filters
498
+ f = config["filters"]
499
+ print(f"\n {B}FILTERS:{R}")
500
+ print(f" Gyro LPF: {f['gyro_main_lpf_hz']}Hz")
501
+ dterm_lpf = f.get("dterm_lpf_hz")
502
+ dterm_type = f.get("dterm_lpf_type", "")
503
+ if dterm_lpf:
504
+ type_str = f" ({dterm_type})" if dterm_type else ""
505
+ print(f" D-term LPF: {dterm_lpf}Hz{type_str}")
506
+ print(f" Dynamic notch: {f['dynamic_gyro_notch_mode']} (Q={f['dynamic_gyro_notch_q']}, min={f['dynamic_gyro_notch_min_hz']}Hz)")
507
+
508
+ # Rates
509
+ rates = config.get("rates", {})
510
+ if rates:
511
+ rr = rates.get("roll_rate", "?")
512
+ pr = rates.get("pitch_rate", "?")
513
+ yr = rates.get("yaw_rate", "?")
514
+ # INAV rates are in deca-degrees/sec, so rate 36 = 360 deg/s
515
+ print(f"\n {B}RATES:{R}")
516
+ print(f" Roll: {rr} ({rr*10} deg/s) | Pitch: {pr} ({pr*10} deg/s) | Yaw: {yr} ({yr*10} deg/s)")
517
+
518
+ # Other settings
519
+ print(f"\n {B}RECOMMENDED SETTINGS:{R}")
520
+ o = config["other"]
521
+ relax_cutoff = o.get("mc_iterm_relax_cutoff")
522
+ cutoff_str = f" (cutoff: {relax_cutoff})" if relax_cutoff else ""
523
+ print(f" I-term relax: {o['mc_iterm_relax']}{cutoff_str}")
524
+ print(f" D-boost: {o['d_boost_min']:.2f} - {o['d_boost_max']:.2f}")
525
+ print(f" Antigravity: {o['antigravity_gain']:.1f} (accel: {o['antigravity_accelerator']:.1f})")
526
+ print(f" TPA: {o['tpa_rate']}% above {o['tpa_breakpoint']}")
527
+
528
+ accel_rp = o.get("rate_accel_limit_roll_pitch", 0)
529
+ accel_y = o.get("rate_accel_limit_yaw", 0)
530
+ if accel_rp > 0 or accel_y > 0:
531
+ rp_str = f"{accel_rp} dps^2" if accel_rp > 0 else "OFF"
532
+ y_str = f"{accel_y} dps^2" if accel_y > 0 else "OFF"
533
+ print(f" Accel limits: Roll/Pitch: {rp_str} | Yaw: {y_str}")
534
+
535
+ # Notes
536
+ if profile["notes"]:
537
+ print(f"\n {B}NOTES:{R}")
538
+ for note in profile["notes"]:
539
+ print(f" {DIM}* {note}{R}")
540
+
541
+ if config["v_adj"]["notes"]:
542
+ print(f" {DIM}* {config['v_adj']['notes']}{R}")
543
+
544
+ # CLI commands
545
+ print(f"\n{B}{C}{'-'*70}{R}")
546
+ print(f" {B}INAV CLI - paste into Configurator CLI tab:{R}")
547
+ print(f"{B}{C}{'-'*70}{R}")
548
+ print()
549
+
550
+ all_settings = {}
551
+ all_settings.update(config["pids"])
552
+ all_settings.update(config["filters"])
553
+ all_settings.update(config.get("rates", {}))
554
+ all_settings.update(config["other"])
555
+
556
+ for k, v in all_settings.items():
557
+ if isinstance(v, float):
558
+ print(f" {G}set {k} = {v:.3f}{R}")
559
+ else:
560
+ print(f" {G}set {k} = {v}{R}")
561
+ print(f" {G}save{R}")
562
+
563
+ print(f"\n{B}{C}{'='*70}{R}\n")
564
+
565
+
566
+ def print_setup_json(config):
567
+ """Output setup config as JSON."""
568
+ output = {
569
+ "frame_inches": config["frame"],
570
+ "voltage": config["voltage"],
571
+ "profile_name": config["profile"]["name"],
572
+ "description": config["profile"]["description"],
573
+ "pids": config["pids"],
574
+ "filters": config["filters"],
575
+ "rates": config.get("rates", {}),
576
+ "other": config["other"],
577
+ "notes": config["profile"]["notes"],
578
+ "voltage_notes": config["v_adj"]["notes"],
579
+ }
580
+ print(json.dumps(output, indent=2))
581
+
582
+
583
+ # ─── Parser ──────────────────────────────────────────────────────────────────
584
+
585
+ def parse_diff_all(text):
586
+ """Parse INAV `diff all` output into structured data."""
587
+ result = {
588
+ "version": None,
589
+ "board": None,
590
+ "build_date": None,
591
+ "git_hash": None,
592
+ "master": {}, # global settings
593
+ "control_profiles": {}, # {1: {settings}, 2: {settings}, ...}
594
+ "mixer_profiles": {},
595
+ "battery_profiles": {},
596
+ "active_control_profile": 1,
597
+ "active_mixer_profile": 1,
598
+ "active_battery_profile": 1,
599
+ "features": [],
600
+ "features_disabled": [],
601
+ "beepers_disabled": [],
602
+ "beepers_enabled": [],
603
+ "blackbox_enabled": [],
604
+ "blackbox_disabled": [],
605
+ "serial_ports": {},
606
+ "aux_modes": [],
607
+ "motor_mix": [],
608
+ "raw_text": text,
609
+ }
610
+
611
+ current_section = "master"
612
+ current_profile_type = None
613
+ current_profile_num = 1
614
+
615
+ for line in text.splitlines():
616
+ line = line.strip()
617
+ if not line or line.startswith("#"):
618
+ # Check for version comment
619
+ m = re.match(r"#\s*INAV/(\S+)\s+([\d.]+)\s+(.*?)(?:\s*/\s*(.*))?$", line)
620
+ if m:
621
+ result["board"] = m.group(1)
622
+ result["version"] = m.group(2)
623
+ rest = m.group(3)
624
+ # Extract date and git hash
625
+ dm = re.search(r"(\w+ \d+ \d{4})", rest)
626
+ if dm:
627
+ result["build_date"] = dm.group(1)
628
+ gm = re.search(r"\(([0-9a-f]+)\)", rest)
629
+ if gm:
630
+ result["git_hash"] = gm.group(1)
631
+ continue
632
+
633
+ # Feature lines
634
+ if line.startswith("feature "):
635
+ feat = line[8:].strip()
636
+ if feat.startswith("-"):
637
+ result["features_disabled"].append(feat[1:])
638
+ else:
639
+ result["features"].append(feat)
640
+ continue
641
+
642
+ # Beeper lines
643
+ if line.startswith("beeper "):
644
+ beep = line[7:].strip()
645
+ if beep.startswith("-"):
646
+ result["beepers_disabled"].append(beep[1:])
647
+ else:
648
+ result["beepers_enabled"].append(beep)
649
+ continue
650
+
651
+ # Blackbox field selection
652
+ if line.startswith("blackbox "):
653
+ bb = line[9:].strip()
654
+ if bb.startswith("-"):
655
+ result["blackbox_disabled"].append(bb[1:])
656
+ else:
657
+ result["blackbox_enabled"].append(bb)
658
+ continue
659
+
660
+ # Serial port config
661
+ m = re.match(r"serial\s+(\d+)\s+(.*)", line)
662
+ if m:
663
+ result["serial_ports"][int(m.group(1))] = m.group(2).strip()
664
+ continue
665
+
666
+ # Aux mode
667
+ m = re.match(r"aux\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)", line)
668
+ if m:
669
+ result["aux_modes"].append({
670
+ "index": int(m.group(1)),
671
+ "mode_id": int(m.group(2)),
672
+ "channel": int(m.group(3)),
673
+ "range_low": int(m.group(4)),
674
+ "range_high": int(m.group(5)),
675
+ })
676
+ continue
677
+
678
+ # Motor mix
679
+ m = re.match(r"mmix\s+(\d+)\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)", line)
680
+ if m:
681
+ result["motor_mix"].append({
682
+ "index": int(m.group(1)),
683
+ "throttle": float(m.group(2)),
684
+ "roll": float(m.group(3)),
685
+ "pitch": float(m.group(4)),
686
+ "yaw": float(m.group(5)),
687
+ })
688
+ continue
689
+
690
+ # Profile switches
691
+ m = re.match(r"(control_profile|mixer_profile|battery_profile)\s+(\d+)", line)
692
+ if m:
693
+ current_profile_type = m.group(1)
694
+ current_profile_num = int(m.group(2))
695
+ section_map = {
696
+ "control_profile": "control_profiles",
697
+ "mixer_profile": "mixer_profiles",
698
+ "battery_profile": "battery_profiles",
699
+ }
700
+ current_section = section_map[current_profile_type]
701
+ if current_profile_num not in result[current_section]:
702
+ result[current_section][current_profile_num] = {}
703
+ continue
704
+
705
+ # Channel map
706
+ m = re.match(r"map\s+(\w+)", line)
707
+ if m:
708
+ result["master"]["channel_map"] = m.group(1)
709
+ continue
710
+
711
+ # Set commands
712
+ m = re.match(r"set\s+(\S+)\s*=\s*(.*)", line)
713
+ if m:
714
+ key = m.group(1).strip()
715
+ val = m.group(2).strip()
716
+ # Try to parse numeric values
717
+ parsed_val = _parse_value(val)
718
+
719
+ if current_section == "master":
720
+ result["master"][key] = parsed_val
721
+ else:
722
+ result[current_section][current_profile_num][key] = parsed_val
723
+ continue
724
+
725
+ # Active profile restore
726
+ if line.startswith("control_profile") and "restore" not in line:
727
+ m = re.match(r"control_profile\s+(\d+)", line)
728
+ if m:
729
+ result["active_control_profile"] = int(m.group(1))
730
+ if line.startswith("mixer_profile") and "restore" not in line:
731
+ m = re.match(r"mixer_profile\s+(\d+)", line)
732
+ if m:
733
+ result["active_mixer_profile"] = int(m.group(1))
734
+ if line.startswith("battery_profile") and "restore" not in line:
735
+ m = re.match(r"battery_profile\s+(\d+)", line)
736
+ if m:
737
+ result["active_battery_profile"] = int(m.group(1))
738
+
739
+ return result
740
+
741
+
742
+ def _parse_value(val):
743
+ """Parse a value string into int, float, or string."""
744
+ if val.upper() in ("ON", "TRUE", "YES"):
745
+ return True
746
+ if val.upper() in ("OFF", "FALSE", "NO"):
747
+ return False
748
+ try:
749
+ return int(val)
750
+ except ValueError:
751
+ pass
752
+ try:
753
+ return float(val)
754
+ except ValueError:
755
+ pass
756
+ return val
757
+
758
+
759
+ def get_active_control(parsed):
760
+ """Get the active control profile settings merged with master."""
761
+ n = parsed["active_control_profile"]
762
+ profile = parsed["control_profiles"].get(n, {})
763
+ return profile
764
+
765
+
766
+ def get_active_battery(parsed):
767
+ """Get the active battery profile settings."""
768
+ n = parsed["active_battery_profile"]
769
+ return parsed["battery_profiles"].get(n, {})
770
+
771
+
772
+ def get_active_mixer(parsed):
773
+ """Get the active mixer profile settings."""
774
+ n = parsed["active_mixer_profile"]
775
+ return parsed["mixer_profiles"].get(n, {})
776
+
777
+
778
+ def get_setting(parsed, key, default=None):
779
+ """Get a setting value, checking active profile first then master."""
780
+ profile = get_active_control(parsed)
781
+ if key in profile:
782
+ return profile[key]
783
+ if key in parsed["master"]:
784
+ return parsed["master"][key]
785
+ return default
786
+
787
+
788
+ # ─── Rule Engine ─────────────────────────────────────────────────────────────
789
+
790
+ class Finding:
791
+ def __init__(self, severity, category, title, detail, setting=None,
792
+ current=None, recommended=None, cli_fix=None):
793
+ self.severity = severity
794
+ self.category = category
795
+ self.title = title
796
+ self.detail = detail
797
+ self.setting = setting
798
+ self.current = current
799
+ self.recommended = recommended
800
+ self.cli_fix = cli_fix
801
+
802
+ def __repr__(self):
803
+ return f"<{self.severity} {self.category}: {self.title}>"
804
+
805
+
806
+ def run_all_checks(parsed, frame_inches=None, blackbox_state=None):
807
+ """Run all configuration checks and return list of Findings."""
808
+ findings = []
809
+
810
+ findings.extend(check_safety(parsed))
811
+ findings.extend(check_motors_protocol(parsed))
812
+ findings.extend(check_filters(parsed, frame_inches))
813
+ findings.extend(check_pid_config(parsed, frame_inches))
814
+ findings.extend(check_navigation(parsed))
815
+ findings.extend(check_gps(parsed))
816
+ findings.extend(check_blackbox(parsed))
817
+ findings.extend(check_battery(parsed))
818
+ findings.extend(check_rx(parsed))
819
+ findings.extend(check_general(parsed))
820
+
821
+ if blackbox_state:
822
+ findings.extend(check_crossref_blackbox(parsed, blackbox_state))
823
+
824
+ # Sort by severity
825
+ findings.sort(key=lambda f: SEVERITY_ORDER.get(f.severity, 99))
826
+ return findings
827
+
828
+
829
+ # ─── Safety Checks ───────────────────────────────────────────────────────────
830
+
831
+ def check_safety(parsed):
832
+ findings = []
833
+
834
+ # Beeper configuration
835
+ critical_beepers = ["BAT_CRIT_LOW", "BAT_LOW", "RX_LOST", "RX_LOST_LANDING", "HW_FAILURE"]
836
+ disabled = parsed["beepers_disabled"]
837
+ missing_critical = [b for b in critical_beepers if b in disabled]
838
+
839
+ if missing_critical:
840
+ findings.append(Finding(
841
+ CRITICAL, "Safety", "Critical beeper warnings disabled",
842
+ f"These beepers are disabled: {', '.join(missing_critical)}. "
843
+ f"You will get no audible warning for low battery, signal loss, or hardware failure. "
844
+ f"This is extremely dangerous - a silent low battery can cause a crash with no warning.",
845
+ setting="beeper",
846
+ current=f"{len(missing_critical)} critical beepers off",
847
+ recommended="Enable at minimum BAT_CRIT_LOW, BAT_LOW, RX_LOST",
848
+ cli_fix="\n".join(f"beeper {b}" for b in missing_critical)))
849
+
850
+ all_disabled = len(disabled) > 20 # practically all beepers off
851
+ if all_disabled:
852
+ findings.append(Finding(
853
+ WARNING, "Safety", "Almost all beepers disabled",
854
+ f"{len(disabled)} beepers are disabled. While some are cosmetic (ARMING, SYSTEM_INIT), "
855
+ f"consider enabling at least the safety-related ones.",
856
+ setting="beeper",
857
+ current=f"{len(disabled)} disabled"))
858
+
859
+ # Failsafe
860
+ fs_proc = get_setting(parsed, "failsafe_procedure", "DROP")
861
+ if fs_proc == "DROP" or fs_proc == 0:
862
+ findings.append(Finding(
863
+ WARNING, "Safety", "Failsafe set to DROP",
864
+ "On signal loss, the quad will disarm and drop from the sky. "
865
+ "For a GPS-equipped quad, RTH failsafe is much safer.",
866
+ setting="failsafe_procedure",
867
+ current="DROP",
868
+ recommended="RTH",
869
+ cli_fix="set failsafe_procedure = RTH"))
870
+ elif fs_proc == "RTH" or fs_proc == 2:
871
+ findings.append(Finding(
872
+ OK, "Safety", "Failsafe set to RTH",
873
+ "Good - the quad will attempt to return home on signal loss.",
874
+ setting="failsafe_procedure",
875
+ current="RTH"))
876
+
877
+ fs_min_dist = get_setting(parsed, "failsafe_min_distance", 0)
878
+ fs_min_proc = get_setting(parsed, "failsafe_min_distance_procedure", "DROP")
879
+ if fs_min_dist > 0 and (fs_min_proc == "LAND" or fs_min_proc == 1):
880
+ findings.append(Finding(
881
+ OK, "Safety", f"Failsafe min distance: {fs_min_dist/100:.0f}m → LAND",
882
+ f"If within {fs_min_dist/100:.0f}m when signal is lost, the quad will land instead of RTH. Good.",
883
+ setting="failsafe_min_distance"))
884
+ elif fs_min_dist == 0 and (fs_proc == "RTH" or fs_proc == 2):
885
+ findings.append(Finding(
886
+ INFO, "Safety", "No failsafe minimum distance set",
887
+ "Consider setting failsafe_min_distance so the quad lands instead of RTH when close to home. "
888
+ "RTH from 5 meters away can be unpredictable.",
889
+ setting="failsafe_min_distance",
890
+ recommended="set failsafe_min_distance = 1000",
891
+ cli_fix="set failsafe_min_distance = 1000\nset failsafe_min_distance_procedure = LAND"))
892
+
893
+ # DSHOT beeper as alternative
894
+ dshot_beeper = get_setting(parsed, "dshot_beeper_enabled", True)
895
+ motor_protocol = get_setting(parsed, "motor_pwm_protocol", "")
896
+ if isinstance(motor_protocol, str) and "DSHOT" in motor_protocol.upper():
897
+ if dshot_beeper is False:
898
+ if all_disabled or missing_critical:
899
+ findings.append(Finding(
900
+ WARNING, "Safety", "DSHOT beeper also disabled",
901
+ "With physical beeper warnings off AND DSHOT beeper disabled, "
902
+ "you have no way to find the quad if it lands in tall grass. "
903
+ "DSHOT beeper uses the motors to beep - it's your last-resort finder.",
904
+ setting="dshot_beeper_enabled",
905
+ current="OFF",
906
+ recommended="ON",
907
+ cli_fix="set dshot_beeper_enabled = ON"))
908
+
909
+ return findings
910
+
911
+
912
+ # ─── Motor & Protocol Checks ────────────────────────────────────────────────
913
+
914
+ def check_motors_protocol(parsed):
915
+ findings = []
916
+ protocol = get_setting(parsed, "motor_pwm_protocol", "")
917
+ protocol_str = str(protocol).upper()
918
+
919
+ is_dshot = "DSHOT" in protocol_str
920
+ if is_dshot:
921
+ findings.append(Finding(
922
+ OK, "Motors", f"Motor protocol: {protocol}",
923
+ "DSHOT digital protocol - good for consistent motor response.",
924
+ setting="motor_pwm_protocol",
925
+ current=str(protocol)))
926
+ else:
927
+ findings.append(Finding(
928
+ WARNING, "Motors", f"Motor protocol: {protocol}",
929
+ "Not using DSHOT. Digital protocols (DSHOT300/600) give more consistent "
930
+ "motor timing and enable features like DSHOT beeper.",
931
+ setting="motor_pwm_protocol",
932
+ current=str(protocol),
933
+ recommended="DSHOT300 or DSHOT600",
934
+ cli_fix="set motor_pwm_protocol = DSHOT300"))
935
+
936
+ # RPM filter - INAV uses ESC telemetry wire (not bidirectional DSHOT)
937
+ rpm_filter = get_setting(parsed, "rpm_gyro_filter_enabled", None)
938
+
939
+ # Check if ESC telemetry is configured on any serial port
940
+ # Serial function 4096 = ESC telemetry (ESC_SENSOR)
941
+ has_esc_telemetry = any(
942
+ "4096" in conf for conf in parsed["serial_ports"].values()
943
+ )
944
+
945
+ if rpm_filter is True and not has_esc_telemetry:
946
+ findings.append(Finding(
947
+ CRITICAL, "Motors", "RPM filter enabled but no ESC telemetry port configured",
948
+ "INAV's RPM filter requires ESC telemetry data via a dedicated wire from the ESC "
949
+ "to a UART RX pin. No serial port is configured for ESC telemetry (function 4096). "
950
+ "Without RPM data, the filter has nothing to work with and may cause instability.",
951
+ setting="rpm_gyro_filter_enabled",
952
+ current="ON",
953
+ recommended="Connect ESC telemetry wire and configure port, or disable RPM filter",
954
+ cli_fix="set rpm_gyro_filter_enabled = OFF"))
955
+ elif rpm_filter is True and has_esc_telemetry:
956
+ findings.append(Finding(
957
+ OK, "Motors", "RPM filter enabled with ESC telemetry",
958
+ "RPM filter is active and ESC telemetry port is configured. "
959
+ "This provides precise motor noise tracking.",
960
+ setting="rpm_gyro_filter_enabled",
961
+ current="ON"))
962
+
963
+ # Motor stop on low
964
+ motorstop = get_setting(parsed, "motorstop_on_low", True)
965
+ mixer = get_active_mixer(parsed)
966
+ motorstop = mixer.get("motorstop_on_low", motorstop)
967
+ if motorstop is False:
968
+ findings.append(Finding(
969
+ OK, "Motors", "Motor stop on low throttle: OFF",
970
+ "Motors keep spinning at idle - good for flight stability.",
971
+ setting="motorstop_on_low"))
972
+
973
+ # Throttle idle
974
+ battery = get_active_battery(parsed)
975
+ idle = battery.get("throttle_idle", get_setting(parsed, "throttle_idle", 5.0))
976
+ if isinstance(idle, (int, float)):
977
+ if idle < 3.0:
978
+ findings.append(Finding(
979
+ WARNING, "Motors", f"Throttle idle very low: {idle}%",
980
+ "Very low idle may cause desync on some ESCs, especially with high cell count. "
981
+ "5-8% is typical for multirotor.",
982
+ setting="throttle_idle",
983
+ current=f"{idle}%",
984
+ recommended="5.0",
985
+ cli_fix="set throttle_idle = 5.000"))
986
+ elif idle > 10.0:
987
+ findings.append(Finding(
988
+ INFO, "Motors", f"Throttle idle high: {idle}%",
989
+ "High idle wastes battery but can help with prop wash handling. "
990
+ "5-8% is typical.",
991
+ setting="throttle_idle",
992
+ current=f"{idle}%"))
993
+
994
+ return findings
995
+
996
+
997
+ # ─── Filter Checks ───────────────────────────────────────────────────────────
998
+
999
+ def check_filters(parsed, frame_inches=None):
1000
+ findings = []
1001
+
1002
+ gyro_lpf = get_setting(parsed, "gyro_main_lpf_hz", 110)
1003
+ dyn_notch_q = get_setting(parsed, "dynamic_gyro_notch_q", 250)
1004
+ dyn_notch_mode = get_setting(parsed, "dynamic_gyro_notch_mode", "3D")
1005
+ dyn_notch_min = get_setting(parsed, "dynamic_gyro_notch_min_hz", 80)
1006
+ kalman_q = get_setting(parsed, "setpoint_kalman_q", 100)
1007
+
1008
+ # EZ Tune detection
1009
+ # ez_enabled controls whether EZ Tune actively computes PIDs.
1010
+ # ez_ parameters may exist in diff even when EZ Tune is disabled (residual from past use).
1011
+ profile = get_active_control(parsed)
1012
+ ez_enabled = profile.get("ez_enabled", get_setting(parsed, "ez_enabled", None))
1013
+ ez_filter = profile.get("ez_filter_hz", get_setting(parsed, "ez_filter_hz", None))
1014
+
1015
+ # EZ Tune is active only if ez_enabled is explicitly ON
1016
+ # If ez_enabled is not in the diff, it's at default (OFF)
1017
+ using_ez = ez_enabled is True
1018
+
1019
+ if using_ez:
1020
+ findings.append(Finding(
1021
+ WARNING, "Filters", f"EZ Tune active (ez_filter_hz = {ez_filter})",
1022
+ "EZ Tune computes PID and filter values from its own parameters (ez_response, "
1023
+ "ez_damping, etc). Manual PID changes via CLI or blackbox analyzer recommendations "
1024
+ "will be OVERWRITTEN on reboot. To apply blackbox tuning: either adjust EZ Tune "
1025
+ "parameters (ez_response, ez_damping, ez_stability), or disable EZ Tune "
1026
+ "and set PIDs manually.",
1027
+ setting="ez_enabled",
1028
+ current="ON",
1029
+ recommended="Disable to use manual PIDs",
1030
+ cli_fix="set ez_enabled = OFF"))
1031
+ elif ez_filter is not None and ez_enabled is None:
1032
+ # ez_ params exist but ez_enabled not in diff (default OFF) - just informational
1033
+ findings.append(Finding(
1034
+ INFO, "Filters", "EZ Tune parameters present but EZ Tune appears disabled",
1035
+ f"Residual EZ Tune parameters found (ez_filter_hz={ez_filter}) but ez_enabled "
1036
+ f"is not set (default OFF). These values are not being used. If you want to clean "
1037
+ f"up, you can leave them - they have no effect while EZ Tune is disabled.",
1038
+ setting="ez_filter_hz",
1039
+ current=f"{ez_filter} (inactive)"))
1040
+
1041
+ # Gyro LPF
1042
+ if isinstance(gyro_lpf, (int, float)):
1043
+ if frame_inches and frame_inches >= 8:
1044
+ if gyro_lpf > 80:
1045
+ findings.append(Finding(
1046
+ WARNING, "Filters", f"Gyro LPF at {gyro_lpf}Hz - high for {frame_inches}-inch",
1047
+ f"Large props generate noise at lower frequencies. For {frame_inches}-inch, "
1048
+ f"gyro LPF between 40-80Hz is typical. Higher values let more motor noise "
1049
+ f"reach the PID controller.",
1050
+ setting="gyro_main_lpf_hz",
1051
+ current=str(gyro_lpf),
1052
+ recommended="40-80",
1053
+ cli_fix=f"set gyro_main_lpf_hz = 65"))
1054
+ else:
1055
+ findings.append(Finding(
1056
+ OK, "Filters", f"Gyro LPF at {gyro_lpf}Hz - reasonable for {frame_inches}-inch",
1057
+ "Filter cutoff is in the expected range for this prop size.",
1058
+ setting="gyro_main_lpf_hz",
1059
+ current=str(gyro_lpf)))
1060
+ elif frame_inches and frame_inches <= 5:
1061
+ if gyro_lpf < 80:
1062
+ findings.append(Finding(
1063
+ INFO, "Filters", f"Gyro LPF at {gyro_lpf}Hz - conservative for {frame_inches}-inch",
1064
+ "This is quite low for a small quad. You might be losing responsiveness. "
1065
+ "90-150Hz is typical for 5-inch unless you have vibration issues.",
1066
+ setting="gyro_main_lpf_hz",
1067
+ current=str(gyro_lpf)))
1068
+
1069
+ # Dynamic notch
1070
+ if dyn_notch_mode and str(dyn_notch_mode).upper() not in ("OFF", "0", "FALSE"):
1071
+ findings.append(Finding(
1072
+ OK, "Filters", f"Dynamic notch filter: {dyn_notch_mode} (Q={dyn_notch_q}, min={dyn_notch_min}Hz)",
1073
+ "Dynamic notch tracks motor noise peaks in real-time.",
1074
+ setting="dynamic_gyro_notch_mode",
1075
+ current=f"{dyn_notch_mode}"))
1076
+
1077
+ if isinstance(dyn_notch_min, (int, float)) and frame_inches is not None:
1078
+ # INAV docs: 150 for 5", 100 for 7", 60-70 for 10"
1079
+ expected_max = {5: 180, 7: 120, 10: 80, 12: 60, 15: 45}
1080
+ nearest_size = min(expected_max.keys(), key=lambda x: abs(x - frame_inches))
1081
+ max_thresh = expected_max[nearest_size]
1082
+ if frame_inches >= 8 and dyn_notch_min > max_thresh:
1083
+ rec = max_thresh - 10
1084
+ findings.append(Finding(
1085
+ INFO, "Filters", f"Dynamic notch min_hz = {dyn_notch_min}Hz - may miss low-freq noise",
1086
+ f"Large props can produce harmonics below {dyn_notch_min}Hz. "
1087
+ f"INAV docs recommend lower values for {frame_inches}-inch. "
1088
+ f"Consider lowering to {rec}-{max_thresh}Hz.",
1089
+ setting="dynamic_gyro_notch_min_hz",
1090
+ current=str(dyn_notch_min),
1091
+ recommended=f"{rec}-{max_thresh}",
1092
+ cli_fix=f"set dynamic_gyro_notch_min_hz = {rec}"))
1093
+ else:
1094
+ findings.append(Finding(
1095
+ WARNING, "Filters", "Dynamic notch filter is OFF",
1096
+ "The dynamic notch filter tracks motor noise harmonics in real-time. "
1097
+ "Without it, you rely entirely on the lowpass filter which also adds phase lag. "
1098
+ "Strongly recommended for all INAV multirotor builds.",
1099
+ setting="dynamic_gyro_notch_mode",
1100
+ current="OFF",
1101
+ recommended="3D",
1102
+ cli_fix="set dynamic_gyro_notch_mode = 3D"))
1103
+
1104
+ # D-term LPF
1105
+ dterm_lpf = get_setting(parsed, "dterm_lpf_hz", 110)
1106
+ if isinstance(dterm_lpf, (int, float)) and frame_inches:
1107
+ # Expected ranges per frame size from INAV dev guidance and community data
1108
+ expected = {5: (90, 150), 7: (70, 100), 10: (45, 75), 12: (35, 60), 15: (25, 45)}
1109
+ nearest = min(expected.keys(), key=lambda x: abs(x - frame_inches))
1110
+ low, high = expected[nearest]
1111
+ if dterm_lpf > high + 20:
1112
+ findings.append(Finding(
1113
+ WARNING, "Filters", f"D-term LPF at {dterm_lpf}Hz - high for {frame_inches}-inch",
1114
+ f"Large props generate lower-frequency noise that feeds through D-term. "
1115
+ f"For {frame_inches}-inch, {low}-{high}Hz is typical. "
1116
+ f"High D-term filtering lets motor noise through and causes hot motors.",
1117
+ setting="dterm_lpf_hz",
1118
+ current=str(dterm_lpf),
1119
+ recommended=f"{low}-{high}",
1120
+ cli_fix=f"set dterm_lpf_hz = {(low + high) // 2}"))
1121
+ elif dterm_lpf < low - 15:
1122
+ findings.append(Finding(
1123
+ INFO, "Filters", f"D-term LPF at {dterm_lpf}Hz - conservative for {frame_inches}-inch",
1124
+ f"This adds more phase lag than needed. For {frame_inches}-inch, {low}-{high}Hz "
1125
+ f"is typical. Higher values reduce delay and improve prop wash handling.",
1126
+ setting="dterm_lpf_hz",
1127
+ current=str(dterm_lpf)))
1128
+
1129
+ # Setpoint Kalman
1130
+ if isinstance(kalman_q, (int, float)):
1131
+ if kalman_q > 300:
1132
+ findings.append(Finding(
1133
+ INFO, "Filters", f"Setpoint Kalman Q={kalman_q} - very aggressive",
1134
+ "High Kalman Q means less smoothing on the setpoint. "
1135
+ "This can make the quad feel very snappy but also amplify RC noise.",
1136
+ setting="setpoint_kalman_q",
1137
+ current=str(kalman_q)))
1138
+
1139
+ return findings
1140
+
1141
+
1142
+ # ─── PID Configuration Checks ───────────────────────────────────────────────
1143
+
1144
+ def check_pid_config(parsed, frame_inches=None):
1145
+ findings = []
1146
+ profile = get_active_control(parsed)
1147
+ pnum = parsed["active_control_profile"]
1148
+
1149
+ # Check if EZ Tune is active in this profile
1150
+ ez_active = profile.get("ez_enabled", get_setting(parsed, "ez_enabled", None)) is True
1151
+ ez_note = " EZ Tune does NOT control this setting - it needs to be set manually." if ez_active else ""
1152
+
1153
+ # I-term relax
1154
+ iterm_relax = profile.get("mc_iterm_relax", get_setting(parsed, "mc_iterm_relax", "RP"))
1155
+ other_profiles_have_rpy = False
1156
+ for n, p in parsed["control_profiles"].items():
1157
+ if n != pnum and p.get("mc_iterm_relax") == "RPY":
1158
+ other_profiles_have_rpy = True
1159
+ break
1160
+
1161
+ if str(iterm_relax).upper() == "RP" and other_profiles_have_rpy:
1162
+ findings.append(Finding(
1163
+ WARNING, "PID", f"Active profile {pnum} uses iterm_relax=RP, other profiles use RPY",
1164
+ "I-term relax on yaw (RPY) reduces I-term windup during fast yaw moves, "
1165
+ "preventing yaw bounce-back. Your other profiles have RPY enabled but the active one doesn't. "
1166
+ f"This looks like a configuration oversight.{ez_note}",
1167
+ setting="mc_iterm_relax",
1168
+ current="RP",
1169
+ recommended="RPY",
1170
+ cli_fix="set mc_iterm_relax = RPY"))
1171
+ elif str(iterm_relax).upper() == "RP":
1172
+ findings.append(Finding(
1173
+ INFO, "PID", "I-term relax is RP (roll/pitch only)",
1174
+ "Consider RPY if you experience yaw bounce-back on fast rotations.",
1175
+ setting="mc_iterm_relax",
1176
+ current="RP",
1177
+ recommended="RPY"))
1178
+
1179
+ # I-term relax cutoff - critical for large quads
1180
+ relax_cutoff = profile.get("mc_iterm_relax_cutoff",
1181
+ get_setting(parsed, "mc_iterm_relax_cutoff", 15))
1182
+ if isinstance(relax_cutoff, (int, float)) and frame_inches and frame_inches >= 9:
1183
+ if relax_cutoff > 10:
1184
+ recommended_cutoff = 8 if frame_inches <= 10 else 5
1185
+ findings.append(Finding(
1186
+ WARNING, "PID",
1187
+ f"I-term relax cutoff = {relax_cutoff} - high for {frame_inches}-inch",
1188
+ f"Large quads need lower iterm_relax_cutoff to prevent bounce-back. "
1189
+ f"The default of 15 works for 5\", but {frame_inches}\" builds "
1190
+ f"need {recommended_cutoff} or lower. High cutoff lets I-term build "
1191
+ f"during maneuvers, causing overshoot on heavy airframes.{ez_note}",
1192
+ setting="mc_iterm_relax_cutoff",
1193
+ current=str(relax_cutoff),
1194
+ recommended=str(recommended_cutoff),
1195
+ cli_fix=f"set mc_iterm_relax_cutoff = {recommended_cutoff}"))
1196
+
1197
+ # D-boost
1198
+ d_boost_min = profile.get("d_boost_min", get_setting(parsed, "d_boost_min", 1.0))
1199
+ d_boost_max = profile.get("d_boost_max", get_setting(parsed, "d_boost_max", 1.0))
1200
+ other_profiles_have_dboost = False
1201
+ for n, p in parsed["control_profiles"].items():
1202
+ if n != pnum and (p.get("d_boost_min", 1.0) != 1.0 or p.get("d_boost_max", 1.0) != 1.0):
1203
+ other_profiles_have_dboost = True
1204
+ break
1205
+
1206
+ if d_boost_min == 1.0 and d_boost_max == 1.0 and other_profiles_have_dboost:
1207
+ findings.append(Finding(
1208
+ WARNING, "PID", f"Active profile {pnum} has no D-boost, other profiles do",
1209
+ "D-boost dynamically adjusts D-term based on setpoint changes - it gives more D during "
1210
+ "fast maneuvers (reduces overshoot) and less during straight flight (less noise). "
1211
+ "Your other profiles have it configured but the active one uses defaults. "
1212
+ f"Likely a configuration oversight.{ez_note}",
1213
+ setting="d_boost_min / d_boost_max",
1214
+ current="1.0 / 1.0 (disabled)",
1215
+ recommended="0.8 / 1.2 (like your other profiles)",
1216
+ cli_fix="set d_boost_min = 0.800\nset d_boost_max = 1.200"))
1217
+
1218
+ # Antigravity
1219
+ antigrav = profile.get("antigravity_gain", get_setting(parsed, "antigravity_gain", 1.0))
1220
+ other_have_antigrav = any(
1221
+ p.get("antigravity_gain", 1.0) != 1.0
1222
+ for n, p in parsed["control_profiles"].items() if n != pnum
1223
+ )
1224
+
1225
+ if antigrav == 1.0 and other_have_antigrav:
1226
+ findings.append(Finding(
1227
+ WARNING, "PID", f"Active profile {pnum} has no antigravity, other profiles do",
1228
+ "Antigravity boosts I-term during rapid throttle changes to counteract the "
1229
+ "altitude drop/gain when you punch or chop throttle. Your other profiles have it "
1230
+ f"but the active one doesn't.{ez_note}",
1231
+ setting="antigravity_gain",
1232
+ current="1.0 (disabled)",
1233
+ recommended="2.0 (like your other profiles)",
1234
+ cli_fix="set antigravity_gain = 2.000\nset antigravity_accelerator = 5.000"))
1235
+
1236
+ # TPA
1237
+ tpa_rate = profile.get("tpa_rate", get_setting(parsed, "tpa_rate", 0))
1238
+ tpa_bp = profile.get("tpa_breakpoint", get_setting(parsed, "tpa_breakpoint", 1500))
1239
+ if isinstance(tpa_rate, (int, float)) and tpa_rate > 0:
1240
+ if isinstance(tpa_bp, (int, float)) and tpa_bp < 1300:
1241
+ findings.append(Finding(
1242
+ INFO, "PID", f"TPA breakpoint at {tpa_bp} - quite low",
1243
+ f"TPA starts reducing PID gains at throttle {tpa_bp}. This means gains start "
1244
+ f"dropping early. For aggressive flying, 1350-1500 is more common.",
1245
+ setting="tpa_breakpoint",
1246
+ current=str(tpa_bp),
1247
+ recommended="1350-1500"))
1248
+
1249
+ # Rate acceleration limits - important for large quads
1250
+ if frame_inches and frame_inches >= 9:
1251
+ accel_rp = get_setting(parsed, "rate_accel_limit_roll_pitch", 0)
1252
+ accel_yaw = get_setting(parsed, "rate_accel_limit_yaw", 0)
1253
+ if isinstance(accel_rp, (int, float)) and accel_rp == 0:
1254
+ rec = 500 if frame_inches <= 10 else 360
1255
+ findings.append(Finding(
1256
+ INFO, "PID",
1257
+ f"No roll/pitch acceleration limit set for {frame_inches}-inch quad",
1258
+ f"Large quads benefit from rate acceleration limits to prevent sudden "
1259
+ f"rate demands that stress the frame and overwhelm the motors. "
1260
+ f"INAV devs recommend ~360 dps^2 for big heavy multirotors. "
1261
+ f"A value around {rec} is a good starting point for {frame_inches}\".{ez_note}",
1262
+ setting="rate_accel_limit_roll_pitch",
1263
+ current="0 (unlimited)",
1264
+ recommended=str(rec),
1265
+ cli_fix=f"set rate_accel_limit_roll_pitch = {rec}"))
1266
+
1267
+ # EZ Tune cross-check (only relevant if EZ Tune is actually active)
1268
+ if ez_active:
1269
+ ez_damping = profile.get("ez_damping", get_setting(parsed, "ez_damping", 100))
1270
+ if isinstance(ez_damping, (int, float)) and ez_damping > 120:
1271
+ findings.append(Finding(
1272
+ INFO, "PID", f"EZ Tune damping = {ez_damping} (high)",
1273
+ "High EZ damping means more D-term. This can help with overshoot but "
1274
+ "also amplifies noise. If blackbox shows clean gyro, this is fine.",
1275
+ setting="ez_damping",
1276
+ current=str(ez_damping)))
1277
+
1278
+ return findings
1279
+
1280
+
1281
+ # ─── Navigation Checks ───────────────────────────────────────────────────────
1282
+
1283
+ def check_navigation(parsed):
1284
+ findings = []
1285
+
1286
+ rth_alt = get_setting(parsed, "nav_rth_altitude", 5000)
1287
+ hover_thr = get_setting(parsed, "nav_mc_hover_thr", 1500)
1288
+ profile = get_active_control(parsed)
1289
+
1290
+ # Safehome
1291
+ has_safehome = any("safehome" in line.lower() and "set" in line.lower()
1292
+ for line in parsed["raw_text"].splitlines()
1293
+ if not line.strip().startswith("#"))
1294
+ if not has_safehome:
1295
+ findings.append(Finding(
1296
+ INFO, "Navigation", "No safehome configured",
1297
+ "Safehome lets you define alternative landing points for RTH. "
1298
+ "If you fly at a regular location, setting a safehome ensures the quad "
1299
+ "returns to a safe landing spot even if the home point drifted.",
1300
+ setting="safehome",
1301
+ recommended="Configure via Configurator Mission tab"))
1302
+
1303
+ # RTH altitude
1304
+ if isinstance(rth_alt, (int, float)):
1305
+ alt_m = rth_alt / 100
1306
+ if alt_m > 100:
1307
+ findings.append(Finding(
1308
+ WARNING, "Navigation", f"RTH altitude: {alt_m:.0f}m - very high",
1309
+ "High RTH altitude means longer return time and more battery used. "
1310
+ "Also may exceed legal altitude limits in many countries (120m/400ft).",
1311
+ setting="nav_rth_altitude",
1312
+ current=f"{alt_m:.0f}m",
1313
+ recommended="30-60m"))
1314
+ elif alt_m < 15:
1315
+ findings.append(Finding(
1316
+ WARNING, "Navigation", f"RTH altitude: {alt_m:.0f}m - quite low",
1317
+ "Low RTH altitude risks collision with trees, buildings, or terrain. "
1318
+ "30-50m is typical for safe RTH.",
1319
+ setting="nav_rth_altitude",
1320
+ current=f"{alt_m:.0f}m",
1321
+ recommended="30-50m"))
1322
+ else:
1323
+ findings.append(Finding(
1324
+ OK, "Navigation", f"RTH altitude: {alt_m:.0f}m",
1325
+ "Reasonable RTH altitude.",
1326
+ setting="nav_rth_altitude",
1327
+ current=f"{alt_m:.0f}m"))
1328
+
1329
+ # Hover throttle
1330
+ battery = get_active_battery(parsed)
1331
+ hover = battery.get("nav_mc_hover_thr", hover_thr)
1332
+ if isinstance(hover, (int, float)):
1333
+ if hover > 1600:
1334
+ findings.append(Finding(
1335
+ WARNING, "Navigation", f"Hover throttle: {hover} - high",
1336
+ "Hover throttle above 1600 means the quad is heavy relative to motor power. "
1337
+ "Altitude hold and nav may be sluggish because there's limited headroom for correction.",
1338
+ setting="nav_mc_hover_thr",
1339
+ current=str(hover),
1340
+ recommended="1200-1500"))
1341
+ elif hover < 1100:
1342
+ findings.append(Finding(
1343
+ INFO, "Navigation", f"Hover throttle: {hover} - very low",
1344
+ "This suggests very powerful motors relative to weight. "
1345
+ "Altitude hold may be twitchy. Consider lowering PID gains for nav.",
1346
+ setting="nav_mc_hover_thr",
1347
+ current=str(hover)))
1348
+
1349
+ # Nav PID values from active profile
1350
+ pos_p = profile.get("nav_mc_pos_xy_p", get_setting(parsed, "nav_mc_pos_xy_p", None))
1351
+ heading_p = profile.get("nav_mc_heading_p", get_setting(parsed, "nav_mc_heading_p", None))
1352
+
1353
+ if pos_p is not None and isinstance(pos_p, (int, float)):
1354
+ if pos_p > 50:
1355
+ findings.append(Finding(
1356
+ WARNING, "Navigation", f"Position hold P = {pos_p} - aggressive",
1357
+ "High position P gain can cause oscillation (salad bowling) in position hold and RTH. "
1358
+ "The quad overcorrects, overshoots, and oscillates around the target position.",
1359
+ setting="nav_mc_pos_xy_p",
1360
+ current=str(pos_p),
1361
+ recommended="20-35",
1362
+ cli_fix=f"set nav_mc_pos_xy_p = 30"))
1363
+ elif pos_p < 15:
1364
+ findings.append(Finding(
1365
+ INFO, "Navigation", f"Position hold P = {pos_p} - conservative",
1366
+ "Low position P may result in slow corrections and drifting in wind.",
1367
+ setting="nav_mc_pos_xy_p",
1368
+ current=str(pos_p)))
1369
+
1370
+ if heading_p is not None and isinstance(heading_p, (int, float)):
1371
+ if heading_p > 60:
1372
+ findings.append(Finding(
1373
+ INFO, "Navigation", f"Nav heading P = {heading_p} - high",
1374
+ "High heading P during navigation can cause yaw oscillation. "
1375
+ "Default is 60, consider lowering if you see heading wobble during RTH.",
1376
+ setting="nav_mc_heading_p",
1377
+ current=str(heading_p)))
1378
+
1379
+ return findings
1380
+
1381
+
1382
+ # ─── GPS Checks ──────────────────────────────────────────────────────────────
1383
+
1384
+ def check_gps(parsed):
1385
+ findings = []
1386
+
1387
+ has_gps = "GPS" in parsed["features"]
1388
+ if not has_gps:
1389
+ if any(get_setting(parsed, k) for k in ["nav_rth_altitude", "failsafe_procedure"]
1390
+ if get_setting(parsed, k) in ("RTH", 2)):
1391
+ findings.append(Finding(
1392
+ CRITICAL, "GPS", "GPS feature not enabled but RTH failsafe configured",
1393
+ "Failsafe is set to RTH but GPS is not enabled. The quad cannot navigate home.",
1394
+ setting="feature GPS",
1395
+ current="disabled",
1396
+ recommended="enabled",
1397
+ cli_fix="feature GPS"))
1398
+ return findings
1399
+
1400
+ findings.append(Finding(
1401
+ OK, "GPS", "GPS enabled",
1402
+ "GPS feature is active.",
1403
+ setting="feature GPS"))
1404
+
1405
+ # Multi-constellation
1406
+ galileo = get_setting(parsed, "gps_ublox_use_galileo", False)
1407
+ beidou = get_setting(parsed, "gps_ublox_use_beidou", False)
1408
+ glonass = get_setting(parsed, "gps_ublox_use_glonass", False)
1409
+
1410
+ constellations = ["GPS"] # always on
1411
+ if galileo: constellations.append("Galileo")
1412
+ if beidou: constellations.append("BeiDou")
1413
+ if glonass: constellations.append("GLONASS")
1414
+
1415
+ if len(constellations) >= 3:
1416
+ findings.append(Finding(
1417
+ OK, "GPS", f"Multi-constellation: {', '.join(constellations)}",
1418
+ "Multiple GNSS constellations improve fix quality and satellite count.",
1419
+ setting="gps_ublox_use_*"))
1420
+ elif len(constellations) == 1:
1421
+ findings.append(Finding(
1422
+ INFO, "GPS", "Only GPS constellation enabled",
1423
+ "Enabling Galileo and/or GLONASS improves fix quality with more visible satellites.",
1424
+ setting="gps_ublox_use_galileo",
1425
+ recommended="ON",
1426
+ cli_fix="set gps_ublox_use_galileo = ON"))
1427
+
1428
+ # Compass
1429
+ mag_hw = get_setting(parsed, "mag_hardware", None)
1430
+ align_mag = get_setting(parsed, "align_mag", "DEFAULT")
1431
+ if mag_hw and str(mag_hw).upper() not in ("NONE", "0", "FALSE"):
1432
+ findings.append(Finding(
1433
+ OK, "GPS", f"Compass: {mag_hw} (alignment: {align_mag})",
1434
+ "Compass is configured. Critical for accurate heading in GPS modes.",
1435
+ setting="mag_hardware"))
1436
+
1437
+ # Compass calibration quality check
1438
+ mag_gains = []
1439
+ for axis in ("x", "y", "z"):
1440
+ g = get_setting(parsed, f"maggain_{axis}", None)
1441
+ if g is not None and isinstance(g, (int, float)):
1442
+ mag_gains.append(g)
1443
+ if len(mag_gains) == 3:
1444
+ spread = (max(mag_gains) - min(mag_gains)) / max(max(mag_gains), 1) * 100
1445
+ if spread > 40:
1446
+ findings.append(Finding(
1447
+ WARNING, "GPS",
1448
+ f"Compass calibration may be poor (gain spread: {spread:.0f}%)",
1449
+ f"Mag gains: X={mag_gains[0]}, Y={mag_gains[1]}, Z={mag_gains[2]}. "
1450
+ f"A spread above 30-40% suggests the compass calibration was done near "
1451
+ f"metallic objects or with motor interference. Poor compass cal causes "
1452
+ f"toilet-bowling in position hold and erratic RTH heading. "
1453
+ f"Recalibrate outdoors, away from metal, with motors off.",
1454
+ setting="maggain_x/y/z",
1455
+ current=f"X={mag_gains[0]} Y={mag_gains[1]} Z={mag_gains[2]}",
1456
+ recommended="Recalibrate compass outdoors"))
1457
+ elif spread > 25:
1458
+ findings.append(Finding(
1459
+ INFO, "GPS",
1460
+ f"Compass gain spread: {spread:.0f}% - borderline",
1461
+ f"Mag gains: X={mag_gains[0]}, Y={mag_gains[1]}, Z={mag_gains[2]}. "
1462
+ f"This is borderline acceptable. If you see heading drift in poshold "
1463
+ f"or toilet-bowling, recalibrate the compass.",
1464
+ setting="maggain_x/y/z"))
1465
+
1466
+ elif has_gps:
1467
+ findings.append(Finding(
1468
+ WARNING, "GPS", "No compass configured",
1469
+ "GPS navigation without a compass relies on GPS heading, which only works "
1470
+ "while moving. Hover position hold and slow-speed RTH will have poor heading accuracy.",
1471
+ setting="mag_hardware",
1472
+ recommended="Install and configure a compass"))
1473
+
1474
+ return findings
1475
+
1476
+
1477
+ # ─── Blackbox Checks ─────────────────────────────────────────────────────────
1478
+
1479
+ def check_blackbox(parsed):
1480
+ findings = []
1481
+
1482
+ bb_denom = get_setting(parsed, "blackbox_rate_denom", 1)
1483
+ if isinstance(bb_denom, (int, float)) and bb_denom > 1:
1484
+ d = int(bb_denom)
1485
+ rate_desc = "half" if d == 2 else f"1/{d}"
1486
+ findings.append(Finding(
1487
+ INFO, "Blackbox", f"Blackbox logging at {rate_desc} rate",
1488
+ f"Recording every {d}{'nd' if d==2 else 'rd' if d==3 else 'th'} sample. "
1489
+ f"This saves flash space but reduces analysis resolution. "
1490
+ f"For PID tuning, 1/1 (full rate) gives the best data. "
1491
+ f"For long flights, 1/2 is a reasonable compromise.",
1492
+ setting="blackbox_rate_denom",
1493
+ current=str(bb_denom),
1494
+ recommended="1 for tuning, 2 for long flights"))
1495
+
1496
+ # Check if essential fields are being logged
1497
+ disabled = parsed["blackbox_disabled"]
1498
+ essential_for_tuning = ["GYRO_RAW", "MOTORS", "RC_COMMAND"]
1499
+ missing = [f for f in essential_for_tuning if f in disabled]
1500
+ if missing:
1501
+ findings.append(Finding(
1502
+ WARNING, "Blackbox", f"Essential blackbox fields disabled: {', '.join(missing)}",
1503
+ "These fields are needed for PID tuning analysis. "
1504
+ "Without them, the blackbox analyzer cannot give accurate recommendations.",
1505
+ setting="blackbox",
1506
+ current=f"{', '.join(missing)} disabled",
1507
+ recommended="Enable all for tuning",
1508
+ cli_fix="\n".join(f"blackbox {f}" for f in missing)))
1509
+
1510
+ # Navigation fields for nav analysis
1511
+ nav_disabled = [f for f in ["NAV_ACC", "NAV_POS"] if f in disabled]
1512
+ if nav_disabled:
1513
+ findings.append(Finding(
1514
+ INFO, "Blackbox", f"Nav blackbox fields disabled: {', '.join(nav_disabled)}",
1515
+ "These fields help analyze navigation performance (position hold, RTH). "
1516
+ "Enable them if you want to analyze nav tuning.",
1517
+ setting="blackbox",
1518
+ current=f"{', '.join(nav_disabled)} disabled"))
1519
+
1520
+ return findings
1521
+
1522
+
1523
+ # ─── Battery Checks ──────────────────────────────────────────────────────────
1524
+
1525
+ def check_battery(parsed):
1526
+ findings = []
1527
+ battery = get_active_battery(parsed)
1528
+
1529
+ min_cell = battery.get("vbat_min_cell_voltage",
1530
+ get_setting(parsed, "vbat_min_cell_voltage", 330))
1531
+ warn_cell = battery.get("vbat_warning_cell_voltage",
1532
+ get_setting(parsed, "vbat_warning_cell_voltage", 350))
1533
+ capacity = battery.get("battery_capacity", get_setting(parsed, "battery_capacity", 0))
1534
+ cap_warn = battery.get("battery_capacity_warning",
1535
+ get_setting(parsed, "battery_capacity_warning", 0))
1536
+ cap_crit = battery.get("battery_capacity_critical",
1537
+ get_setting(parsed, "battery_capacity_critical", 0))
1538
+
1539
+ if isinstance(min_cell, (int, float)):
1540
+ min_v = min_cell / 100 if min_cell > 100 else min_cell
1541
+ # Heuristic: large capacity (>7000mAh) + low min voltage often = Li-ion pack
1542
+ likely_lion = (isinstance(capacity, (int, float)) and capacity >= 7000
1543
+ and min_v < 3.0)
1544
+ if min_v < 3.0:
1545
+ if likely_lion:
1546
+ findings.append(Finding(
1547
+ INFO, "Battery", f"Minimum cell voltage: {min_v:.2f}V (likely Li-ion)",
1548
+ f"With {capacity}mAh capacity and {min_v:.2f}V minimum, this looks like a Li-ion "
1549
+ f"setup (18650/21700). This voltage is appropriate for Li-ion. "
1550
+ f"If you switch to LiPo, raise this to 3.3V.",
1551
+ setting="vbat_min_cell_voltage",
1552
+ current=f"{min_v:.2f}V"))
1553
+ else:
1554
+ findings.append(Finding(
1555
+ WARNING, "Battery", f"Minimum cell voltage: {min_v:.2f}V - low for LiPo",
1556
+ "If using LiPo batteries, going below 3.0V per cell causes permanent damage. "
1557
+ "3.3V is the recommended minimum for LiPo. If you're running Li-ion cells "
1558
+ "(18650/21700), 2.5-2.8V is normal - you can ignore this warning.",
1559
+ setting="vbat_min_cell_voltage",
1560
+ current=f"{min_v:.2f}V",
1561
+ recommended="330 (3.30V) for LiPo, 270 (2.70V) for Li-ion",
1562
+ cli_fix="set vbat_min_cell_voltage = 330"))
1563
+ elif min_v < 3.2:
1564
+ findings.append(Finding(
1565
+ INFO, "Battery", f"Minimum cell voltage: {min_v:.2f}V - low side",
1566
+ "Below 3.2V accelerates LiPo wear. 3.3-3.5V is safer.",
1567
+ setting="vbat_min_cell_voltage",
1568
+ current=f"{min_v:.2f}V",
1569
+ recommended="330-350"))
1570
+
1571
+ if isinstance(capacity, (int, float)) and capacity > 0:
1572
+ findings.append(Finding(
1573
+ OK, "Battery", f"Battery capacity: {capacity}mAh",
1574
+ "Capacity-based monitoring is configured.",
1575
+ setting="battery_capacity",
1576
+ current=f"{capacity}mAh"))
1577
+
1578
+ if isinstance(cap_warn, (int, float)) and cap_warn > 0:
1579
+ warn_pct = cap_warn / capacity * 100
1580
+ findings.append(Finding(
1581
+ OK if warn_pct >= 30 else INFO, "Battery",
1582
+ f"Warning at {cap_warn}mAh remaining ({warn_pct:.0f}%)",
1583
+ f"Battery warning triggers at {warn_pct:.0f}% remaining capacity.",
1584
+ setting="battery_capacity_warning"))
1585
+ elif capacity == 0:
1586
+ findings.append(Finding(
1587
+ INFO, "Battery", "No battery capacity set",
1588
+ "Capacity-based monitoring gives more accurate remaining flight time "
1589
+ "than voltage alone. Set battery_capacity to your pack's mAh rating.",
1590
+ setting="battery_capacity",
1591
+ recommended="Set to your battery mAh"))
1592
+
1593
+ return findings
1594
+
1595
+
1596
+ # ─── Receiver Checks ─────────────────────────────────────────────────────────
1597
+
1598
+ def check_rx(parsed):
1599
+ findings = []
1600
+
1601
+ rx_provider = get_setting(parsed, "serialrx_provider", None)
1602
+ if rx_provider:
1603
+ findings.append(Finding(
1604
+ OK, "Receiver", f"Serial RX: {rx_provider}",
1605
+ f"Using {rx_provider} protocol.",
1606
+ setting="serialrx_provider",
1607
+ current=str(rx_provider)))
1608
+
1609
+ channel_map = parsed["master"].get("channel_map", "AETR")
1610
+ if channel_map != "AETR" and channel_map != "TAER":
1611
+ findings.append(Finding(
1612
+ INFO, "Receiver", f"Channel map: {channel_map}",
1613
+ "Non-standard channel map. Make sure your transmitter matches.",
1614
+ setting="map",
1615
+ current=channel_map))
1616
+
1617
+ return findings
1618
+
1619
+
1620
+ # ─── General Checks ──────────────────────────────────────────────────────────
1621
+
1622
+ def check_general(parsed):
1623
+ findings = []
1624
+
1625
+ # Craft name
1626
+ name = get_setting(parsed, "name", "")
1627
+ if not name:
1628
+ findings.append(Finding(
1629
+ INFO, "General", "No craft name set",
1630
+ "Setting a name helps identify logs and OSD display.",
1631
+ setting="name",
1632
+ cli_fix='set name = MY_QUAD'))
1633
+
1634
+ # I2C speed
1635
+ i2c = get_setting(parsed, "i2c_speed", "400KHZ")
1636
+ if str(i2c).upper() == "800KHZ":
1637
+ findings.append(Finding(
1638
+ OK, "General", "I2C bus at 800KHz",
1639
+ "Fast I2C reduces sensor read latency.",
1640
+ setting="i2c_speed",
1641
+ current="800KHZ"))
1642
+
1643
+ # Airmode
1644
+ airmode = get_setting(parsed, "airmode_type", "STICK_CENTER")
1645
+ if str(airmode).upper() == "THROTTLE_THRESHOLD":
1646
+ findings.append(Finding(
1647
+ OK, "General", "Airmode: throttle threshold",
1648
+ "Airmode activates above throttle threshold - good for preventing accidental "
1649
+ "motor spin on the ground.",
1650
+ setting="airmode_type",
1651
+ current="THROTTLE_THRESHOLD"))
1652
+
1653
+ return findings
1654
+
1655
+
1656
+ # ─── Blackbox Cross-Reference ────────────────────────────────────────────────
1657
+
1658
+ def check_crossref_blackbox(parsed, bb_state):
1659
+ """Cross-reference config with blackbox analysis results."""
1660
+ findings = []
1661
+
1662
+ if not isinstance(bb_state, dict):
1663
+ return findings
1664
+
1665
+ actions = bb_state.get("actions", [])
1666
+ config = bb_state.get("config", {})
1667
+ scores = bb_state.get("scores", {})
1668
+
1669
+ overall = scores.get("overall", 0)
1670
+ noise_score = scores.get("noise")
1671
+ pid_score = scores.get("pid")
1672
+
1673
+ if overall > 0:
1674
+ findings.append(Finding(
1675
+ OK if overall >= 75 else WARNING if overall >= 50 else CRITICAL,
1676
+ "Blackbox", f"Last tune quality: {overall:.0f}/100",
1677
+ f"Noise: {noise_score:.0f}, PID: {pid_score:.0f}, Motors: {scores.get('motor', 0):.0f}" if noise_score else "",
1678
+ current=f"{overall:.0f}/100"))
1679
+
1680
+ # Check if EZ Tune is fighting manual PID changes
1681
+ ez_active = get_setting(parsed, "ez_enabled", None) is True
1682
+ if ez_active:
1683
+ bb_roll_p = config.get("roll_p")
1684
+ diff_roll_p = get_active_control(parsed).get("mc_p_roll")
1685
+ if bb_roll_p and diff_roll_p and bb_roll_p != diff_roll_p:
1686
+ findings.append(Finding(
1687
+ WARNING, "Blackbox", "PID values differ between diff and blackbox",
1688
+ f"Roll P in diff: {diff_roll_p}, in last blackbox: {bb_roll_p}. "
1689
+ f"With EZ Tune active, manually set PIDs will be overwritten on reboot. "
1690
+ f"Either tune through EZ Tune parameters or disable EZ Tune (set ez_enabled = OFF).",
1691
+ setting="ez_enabled"))
1692
+
1693
+ return findings
1694
+
1695
+
1696
+ # ─── Terminal Output ─────────────────────────────────────────────────────────
1697
+
1698
+ # ─── Pre-Flight Sanity Check ─────────────────────────────────────────────────
1699
+ # A "will this crash on first flight?" validator.
1700
+ # Different from the tuning-focused analysis above — this catches
1701
+ # dangerous misconfigurations that can destroy hardware or hurt people.
1702
+
1703
+ class SanityItem:
1704
+ """A single pre-flight check result."""
1705
+ FAIL = "FAIL"
1706
+ WARN = "WARN"
1707
+ ASK = "ASK"
1708
+ PASS = "PASS"
1709
+
1710
+ def __init__(self, status, category, message, detail=None, question=None,
1711
+ recommendation=None, cli_fix=None):
1712
+ self.status = status
1713
+ self.category = category
1714
+ self.message = message
1715
+ self.detail = detail or ""
1716
+ self.question = question # Interactive confirmation prompt
1717
+ self.recommendation = recommendation
1718
+ self.cli_fix = cli_fix
1719
+ self.user_confirmed = None # None=not asked, True/False=answer
1720
+
1721
+ def __repr__(self):
1722
+ return f"<{self.status} {self.category}: {self.message}>"
1723
+
1724
+
1725
+ def _ask_pilot(question, default_yes=False):
1726
+ """Ask the pilot a yes/no question interactively."""
1727
+ suffix = "[Y/n]" if default_yes else "[y/N]"
1728
+ try:
1729
+ answer = input(f" ? {question} {suffix} ").strip().lower()
1730
+ if not answer:
1731
+ return default_yes
1732
+ return answer in ("y", "yes")
1733
+ except (EOFError, KeyboardInterrupt):
1734
+ print()
1735
+ return default_yes
1736
+
1737
+
1738
+ def _get_assigned_modes(parsed):
1739
+ """Return set of mode IDs that have aux channel assignments."""
1740
+ return {m["mode_id"] for m in parsed["aux_modes"]
1741
+ if m["range_low"] < m["range_high"]}
1742
+
1743
+
1744
+ def _has_gps_uart(parsed):
1745
+ """Check if any serial port has GPS function enabled."""
1746
+ for _port_id, config in parsed["serial_ports"].items():
1747
+ parts = config.split()
1748
+ if len(parts) >= 1:
1749
+ try:
1750
+ func_mask = int(parts[0])
1751
+ if func_mask & 2: # GPS function bit
1752
+ return True
1753
+ except ValueError:
1754
+ pass
1755
+ return False
1756
+
1757
+
1758
+ def _has_feature(parsed, feat_name):
1759
+ """Check if a feature is enabled (present in features list)."""
1760
+ return feat_name.upper() in [f.upper() for f in parsed["features"]]
1761
+
1762
+
1763
+ def _has_feature_disabled(parsed, feat_name):
1764
+ """Check if a feature is explicitly disabled."""
1765
+ return feat_name.upper() in [f.upper() for f in parsed.get("features_disabled", [])]
1766
+
1767
+
1768
+ def _detect_frame_from_name(parsed):
1769
+ """Try to detect frame size from craft name."""
1770
+ name = get_setting(parsed, "name", "")
1771
+ if not name:
1772
+ return None
1773
+ name_upper = str(name).upper()
1774
+ for size in [15, 12, 10, 7, 5]:
1775
+ if str(size) in name_upper:
1776
+ return size
1777
+ return None
1778
+
1779
+
1780
+ def _detect_motor_count(parsed):
1781
+ """Detect motor count from motor_mix or platform type."""
1782
+ if parsed["motor_mix"]:
1783
+ return max(m["index"] for m in parsed["motor_mix"]) + 1
1784
+ # Infer from platform_type
1785
+ platform = get_setting(parsed, "platform_type", "")
1786
+ platform_map = {
1787
+ "QUADX": 4, "QUAD": 4, "QUADP": 4,
1788
+ "HEX6": 6, "HEXX": 6, "HEXH": 6, "HEX6X": 6,
1789
+ "OCTOX8": 8, "OCTOFLATX": 8, "OCTOFLATP": 8,
1790
+ "Y6": 6,
1791
+ "TRI": 3, "TRICOPTER": 3,
1792
+ }
1793
+ mixer = get_setting(parsed, "mixer_preset", "")
1794
+ if isinstance(mixer, str):
1795
+ for key, count in platform_map.items():
1796
+ if key in mixer.upper():
1797
+ return count
1798
+ return None
1799
+
1800
+
1801
+ def run_sanity_check(parsed, frame_inches=None, interactive=True):
1802
+ """Run pre-flight sanity checks. Returns list of SanityItems."""
1803
+ items = []
1804
+
1805
+ # Auto-detect frame size if not provided
1806
+ if frame_inches is None:
1807
+ frame_inches = _detect_frame_from_name(parsed)
1808
+
1809
+ assigned_modes = _get_assigned_modes(parsed)
1810
+ has_gps = _has_gps_uart(parsed)
1811
+ motor_count = _detect_motor_count(parsed)
1812
+ profile = get_active_control(parsed)
1813
+
1814
+ # ── 1. ARMING ────────────────────────────────────────────────────────
1815
+ if 0 not in assigned_modes:
1816
+ items.append(SanityItem(
1817
+ SanityItem.FAIL, "Arming",
1818
+ "No ARM switch assigned",
1819
+ "Without an ARM mode on an aux channel, the aircraft cannot be armed "
1820
+ "(or worse, may arm unexpectedly if using stick commands).",
1821
+ recommendation="Assign ARM to an AUX channel in the Modes tab"))
1822
+ else:
1823
+ arm_modes = [m for m in parsed["aux_modes"] if m["mode_id"] == 0]
1824
+ for am in arm_modes:
1825
+ rng = am["range_high"] - am["range_low"]
1826
+ if rng > 800:
1827
+ items.append(SanityItem(
1828
+ SanityItem.WARN, "Arming",
1829
+ f"ARM range is very wide ({am['range_low']}-{am['range_high']})",
1830
+ "A wide ARM range increases the risk of accidental arming.",
1831
+ recommendation="Use a narrow range like 1800-2100"))
1832
+ else:
1833
+ items.append(SanityItem(
1834
+ SanityItem.PASS, "Arming",
1835
+ "ARM switch assigned"))
1836
+
1837
+ # ── 2. MOTOR OUTPUT ──────────────────────────────────────────────────
1838
+ output_enabled = get_setting(parsed, "motor_pwm_protocol", "")
1839
+ # In INAV, motor output is disabled by default until you enable it in Outputs tab.
1840
+ # The feature MOTOR_STOP / output is controlled differently.
1841
+ # If we see no motor protocol at all or DISABLED, that's a problem.
1842
+ if output_enabled == "DISABLED" or output_enabled == "" or output_enabled == 0:
1843
+ items.append(SanityItem(
1844
+ SanityItem.FAIL, "Motor Output",
1845
+ "Motor output appears DISABLED",
1846
+ "INAV disables motor output by default as a safety measure. "
1847
+ "Motors will not spin until you enable output in the Outputs tab.",
1848
+ recommendation="In Configurator: Outputs tab → enable motor output, select DSHOT protocol"))
1849
+
1850
+ # ── 3. MOTOR DIRECTION ───────────────────────────────────────────────
1851
+ motor_inverted = get_setting(parsed, "motor_direction_inverted", False)
1852
+ if motor_inverted:
1853
+ items.append(SanityItem(
1854
+ SanityItem.ASK, "Motors",
1855
+ "Motor direction is INVERTED (props-out configuration)",
1856
+ "motor_direction_inverted = ON means the FC expects reversed motor spin "
1857
+ "(props-out). If your motors spin the normal way (props-in), the quad "
1858
+ "will yaw violently and flip on takeoff.",
1859
+ question="Are you running props-out (reversed) motor direction?",
1860
+ recommendation="If props-in, set motor_direction_inverted = OFF",
1861
+ cli_fix="set motor_direction_inverted = OFF"))
1862
+ else:
1863
+ items.append(SanityItem(
1864
+ SanityItem.PASS, "Motors",
1865
+ "Motor direction: standard (props-in)"))
1866
+
1867
+ # ── 4. FAILSAFE ──────────────────────────────────────────────────────
1868
+ fs_proc = get_setting(parsed, "failsafe_procedure", "DROP")
1869
+ if fs_proc == "DROP" or fs_proc == 0:
1870
+ items.append(SanityItem(
1871
+ SanityItem.FAIL, "Failsafe",
1872
+ "Failsafe procedure is DROP",
1873
+ "On signal loss, the aircraft will disarm and freefall from any altitude. "
1874
+ "This is the default but extremely dangerous for any altitude flight.",
1875
+ recommendation="Set failsafe to RTH if you have GPS, or LAND",
1876
+ cli_fix="set failsafe_procedure = RTH"))
1877
+ elif (fs_proc == "RTH" or fs_proc == 2) and not has_gps:
1878
+ items.append(SanityItem(
1879
+ SanityItem.FAIL, "Failsafe",
1880
+ "Failsafe set to RTH but no GPS configured",
1881
+ "RTH failsafe requires GPS. Without it, the aircraft will fall back "
1882
+ "to emergency landing which may not work correctly.",
1883
+ recommendation="Configure GPS on a UART, or change failsafe to LAND"))
1884
+ elif fs_proc == "RTH" or fs_proc == 2:
1885
+ items.append(SanityItem(
1886
+ SanityItem.PASS, "Failsafe",
1887
+ "Failsafe set to RTH with GPS available"))
1888
+
1889
+ # Failsafe throttle + airmode
1890
+ airmode = get_setting(parsed, "airmode_type", "")
1891
+ fs_throttle = get_setting(parsed, "failsafe_throttle", 1000)
1892
+ if isinstance(fs_throttle, (int, float)) and fs_throttle <= 1050:
1893
+ if airmode and str(airmode).upper() != "STICK_CENTER":
1894
+ items.append(SanityItem(
1895
+ SanityItem.WARN, "Failsafe",
1896
+ f"Low failsafe throttle ({fs_throttle}) with airmode enabled",
1897
+ "Airmode keeps PID corrections active even at zero throttle. "
1898
+ "Combined with low failsafe throttle, this can cause the aircraft "
1899
+ "to tumble during emergency landing instead of descending smoothly.",
1900
+ recommendation="Consider raising failsafe_throttle to hover level"))
1901
+
1902
+ # ── 5. BATTERY ───────────────────────────────────────────────────────
1903
+ batt_profile = get_active_battery(parsed)
1904
+ vmin = batt_profile.get("bat_voltage_cell_min", get_setting(parsed, "bat_voltage_cell_min", 330))
1905
+ vmax = batt_profile.get("bat_voltage_cell_max", get_setting(parsed, "bat_voltage_cell_max", 420))
1906
+ vwarn = batt_profile.get("bat_voltage_cell_warning", get_setting(parsed, "bat_voltage_cell_warning", 350))
1907
+
1908
+ if isinstance(vmin, (int, float)) and isinstance(vmax, (int, float)):
1909
+ if vmin >= vmax:
1910
+ items.append(SanityItem(
1911
+ SanityItem.FAIL, "Battery",
1912
+ f"Cell min voltage ({vmin/100:.2f}V) >= max ({vmax/100:.2f}V)",
1913
+ "Battery voltage limits are inverted or equal. "
1914
+ "The aircraft will think the battery is always critical."))
1915
+ elif vmin < 250 or vmin > 380:
1916
+ items.append(SanityItem(
1917
+ SanityItem.WARN, "Battery",
1918
+ f"Unusual cell min voltage: {vmin/100:.2f}V",
1919
+ "Normal range is 2.80-3.50V for LiPo, 2.50-2.80V for Li-ion.",
1920
+ question="Is this voltage correct for your battery type?"))
1921
+ else:
1922
+ items.append(SanityItem(
1923
+ SanityItem.PASS, "Battery",
1924
+ f"Battery limits: {vmin/100:.2f}V min, {vmax/100:.2f}V max"))
1925
+
1926
+ # No battery monitoring
1927
+ if _has_feature_disabled(parsed, "VBAT"):
1928
+ items.append(SanityItem(
1929
+ SanityItem.FAIL, "Battery",
1930
+ "Battery voltage monitoring is DISABLED",
1931
+ "Without voltage monitoring, you will have no low battery warning "
1932
+ "and no battery-related failsafe. The aircraft will fly until "
1933
+ "the battery cuts out, causing an uncontrolled crash.",
1934
+ recommendation="Enable VBAT feature",
1935
+ cli_fix="feature VBAT"))
1936
+
1937
+ # ── 6. GPS & NAVIGATION ──────────────────────────────────────────────
1938
+ nav_modes_assigned = assigned_modes & GPS_MODES
1939
+ nav_mode_names = [INAV_MODES.get(m, f"Mode {m}") for m in nav_modes_assigned]
1940
+
1941
+ if nav_modes_assigned and not has_gps:
1942
+ items.append(SanityItem(
1943
+ SanityItem.FAIL, "Navigation",
1944
+ f"Nav modes assigned but no GPS configured: {', '.join(nav_mode_names)}",
1945
+ "These modes require GPS to function. Without GPS, activating them "
1946
+ "will cause unpredictable behavior or no effect at all.",
1947
+ recommendation="Configure GPS on a UART in the Ports tab"))
1948
+ elif nav_modes_assigned and has_gps:
1949
+ items.append(SanityItem(
1950
+ SanityItem.PASS, "Navigation",
1951
+ f"GPS configured for nav modes: {', '.join(nav_mode_names)}"))
1952
+
1953
+ if has_gps and not nav_modes_assigned and 11 not in assigned_modes:
1954
+ items.append(SanityItem(
1955
+ SanityItem.WARN, "Navigation",
1956
+ "GPS configured but no NAV RTH mode assigned",
1957
+ "You have GPS hardware configured but no return-to-home switch. "
1958
+ "RTH is valuable as an emergency recovery option.",
1959
+ recommendation="Assign NAV RTH to an AUX channel"))
1960
+
1961
+ # ALTHOLD without baro
1962
+ alt_modes_assigned = assigned_modes & ALT_MODES
1963
+ # Check for baro - it's usually a hardware feature, not always in diff
1964
+ baro_hardware = get_setting(parsed, "baro_hardware", "")
1965
+ if alt_modes_assigned and baro_hardware and str(baro_hardware).upper() == "NONE":
1966
+ items.append(SanityItem(
1967
+ SanityItem.FAIL, "Navigation",
1968
+ "NAV ALTHOLD assigned but barometer is disabled",
1969
+ "Altitude hold requires a barometer. Without it, altitude control "
1970
+ "will not work and the aircraft may climb or descend uncontrollably.",
1971
+ recommendation="Enable barometer hardware or remove ALTHOLD mode"))
1972
+
1973
+ # POSHOLD/RTH without compass
1974
+ compass_modes_assigned = assigned_modes & COMPASS_MODES
1975
+ compass_mode_names = [INAV_MODES.get(m, f"Mode {m}") for m in compass_modes_assigned]
1976
+ mag_hardware = get_setting(parsed, "mag_hardware", "")
1977
+ mag_disabled = (isinstance(mag_hardware, str) and mag_hardware.upper() == "NONE") or mag_hardware == 0
1978
+ if compass_modes_assigned and mag_disabled:
1979
+ items.append(SanityItem(
1980
+ SanityItem.WARN, "Navigation",
1981
+ f"Compass disabled but nav modes assigned: {', '.join(compass_mode_names)}",
1982
+ "INAV can fly nav modes without compass using GPS-derived heading, "
1983
+ "but position hold performance is reduced and toilet-bowl patterns "
1984
+ "are more likely, especially at low speed.",
1985
+ question="Are you intentionally flying without compass?"))
1986
+
1987
+ # Hover throttle at default
1988
+ hover_thr = get_setting(parsed, "nav_mc_hover_thr", 1500)
1989
+ if isinstance(hover_thr, (int, float)) and hover_thr == 1500:
1990
+ if nav_modes_assigned:
1991
+ items.append(SanityItem(
1992
+ SanityItem.ASK, "Navigation",
1993
+ "Hover throttle is at default (1500)",
1994
+ "The default hover throttle may not match your aircraft. "
1995
+ "If too low, the aircraft will sink in altitude hold. "
1996
+ "If too high, it will climb.",
1997
+ question="Have you calibrated the hover throttle for this aircraft?",
1998
+ recommendation="Hover the aircraft, note the throttle value, "
1999
+ "set nav_mc_hover_thr to that value"))
2000
+
2001
+ # ── 7. PIDS ──────────────────────────────────────────────────────────
2002
+ p_roll = get_setting(parsed, "mc_p_roll", profile.get("mc_p_roll", 40))
2003
+ p_pitch = get_setting(parsed, "mc_p_pitch", profile.get("mc_p_pitch", 44))
2004
+ d_roll = get_setting(parsed, "mc_d_roll", profile.get("mc_d_roll", 25))
2005
+ d_pitch = get_setting(parsed, "mc_d_pitch", profile.get("mc_d_pitch", 25))
2006
+
2007
+ # Zero P = no stabilization
2008
+ if isinstance(p_roll, (int, float)) and isinstance(p_pitch, (int, float)):
2009
+ if p_roll == 0 or p_pitch == 0:
2010
+ items.append(SanityItem(
2011
+ SanityItem.FAIL, "PIDs",
2012
+ f"P-term is ZERO (roll={p_roll}, pitch={p_pitch})",
2013
+ "With P=0 the aircraft has no stabilization on that axis. "
2014
+ "It will be completely uncontrollable.",
2015
+ recommendation="Set P values to at least 20"))
2016
+
2017
+ # Extremely high PIDs
2018
+ if isinstance(p_roll, (int, float)) and isinstance(p_pitch, (int, float)):
2019
+ if p_roll > 100 or p_pitch > 100:
2020
+ items.append(SanityItem(
2021
+ SanityItem.FAIL, "PIDs",
2022
+ f"P-term is extremely high (roll={p_roll}, pitch={p_pitch})",
2023
+ "P values above 100 will cause violent oscillation on any airframe. "
2024
+ "The aircraft will shake itself apart on takeoff.",
2025
+ recommendation="Reduce P to a sane starting point for your frame size"))
2026
+
2027
+ # PID mismatch with detected frame size
2028
+ if frame_inches and frame_inches in FRAME_PROFILES:
2029
+ fp = FRAME_PROFILES[frame_inches]
2030
+ expected_p = fp["pids"].get("mc_p_roll", 40)
2031
+
2032
+ if isinstance(p_roll, (int, float)):
2033
+ # Check if PIDs are way off for this frame size
2034
+ # A 10" frame with 5" PIDs (40-44) is too aggressive
2035
+ # A 5" frame with 15" PIDs (26) is too sluggish
2036
+ if frame_inches >= 10 and p_roll >= 44:
2037
+ # Looks like 5"/7" defaults on a big frame
2038
+ items.append(SanityItem(
2039
+ SanityItem.ASK, "PIDs",
2040
+ f"PIDs look aggressive for a {frame_inches}-inch frame "
2041
+ f"(P_roll={p_roll}, expected ~{expected_p})",
2042
+ f"These PID values are typical for a 5-7 inch quad. On a "
2043
+ f"{frame_inches}-inch frame, this will likely cause violent "
2044
+ f"oscillation due to higher prop inertia.",
2045
+ question=f"Is this actually a {frame_inches}-inch frame?",
2046
+ recommendation=f"Use inav-params --setup {frame_inches} for starting PIDs",
2047
+ cli_fix=f"# Run: inav-params --setup {frame_inches}"))
2048
+ elif frame_inches <= 5 and p_roll < 25:
2049
+ items.append(SanityItem(
2050
+ SanityItem.ASK, "PIDs",
2051
+ f"PIDs look very low for a {frame_inches}-inch frame "
2052
+ f"(P_roll={p_roll}, expected ~{expected_p})",
2053
+ "Very low PIDs on a small frame will make it feel mushy and unresponsive.",
2054
+ question=f"Is this actually a {frame_inches}-inch frame?"))
2055
+
2056
+ if isinstance(p_roll, (int, float)) and isinstance(p_pitch, (int, float)):
2057
+ if not (p_roll == 0 or p_pitch == 0) and not (p_roll > 100 or p_pitch > 100):
2058
+ items.append(SanityItem(
2059
+ SanityItem.PASS, "PIDs",
2060
+ f"PIDs in reasonable range (P_roll={p_roll}, P_pitch={p_pitch})"))
2061
+
2062
+ # ── 8. RATES ─────────────────────────────────────────────────────────
2063
+ roll_rate = get_setting(parsed, "roll_rate",
2064
+ profile.get("roll_rate", 40))
2065
+ pitch_rate = get_setting(parsed, "pitch_rate",
2066
+ profile.get("pitch_rate", 40))
2067
+ yaw_rate = get_setting(parsed, "yaw_rate",
2068
+ profile.get("yaw_rate", 30))
2069
+
2070
+ for axis, rate in [("Roll", roll_rate), ("Pitch", pitch_rate), ("Yaw", yaw_rate)]:
2071
+ if isinstance(rate, (int, float)):
2072
+ if rate == 0:
2073
+ items.append(SanityItem(
2074
+ SanityItem.FAIL, "Rates",
2075
+ f"{axis} rate is ZERO",
2076
+ f"With rate=0, the aircraft cannot rotate on the {axis.lower()} axis. "
2077
+ "It will be uncontrollable.",
2078
+ recommendation=f"Set {axis.lower()}_rate to at least 20"))
2079
+ elif rate > 120: # 1200 dps — extreme
2080
+ items.append(SanityItem(
2081
+ SanityItem.WARN, "Rates",
2082
+ f"{axis} rate is extremely high ({rate} = {rate*10}dps)",
2083
+ "Very high rates make the aircraft extremely twitchy and hard to control. "
2084
+ "Most pilots use 200-700dps for multirotors.",
2085
+ question=f"Is {rate*10}dps {axis.lower()} rate intentional?"))
2086
+
2087
+ # ── 9. FILTERS ───────────────────────────────────────────────────────
2088
+ gyro_lpf = get_setting(parsed, "gyro_main_lpf_hz", 110)
2089
+ dterm_lpf = get_setting(parsed, "dterm_lpf_hz", 110)
2090
+ looptime = get_setting(parsed, "looptime", 500)
2091
+
2092
+ if isinstance(looptime, (int, float)) and looptime > 0:
2093
+ sample_rate = 1_000_000 / looptime
2094
+ nyquist = sample_rate / 2
2095
+
2096
+ if isinstance(gyro_lpf, (int, float)):
2097
+ if gyro_lpf == 0:
2098
+ items.append(SanityItem(
2099
+ SanityItem.WARN, "Filters",
2100
+ "Gyro LPF is DISABLED (0 Hz)",
2101
+ "Running with no gyro low-pass filter passes all noise directly "
2102
+ "to the PID controller. Unless you have very clean motors and "
2103
+ "a well-built frame, this will cause motor heating and oscillation.",
2104
+ question="Are you intentionally running without gyro LPF?"))
2105
+ elif gyro_lpf > nyquist:
2106
+ items.append(SanityItem(
2107
+ SanityItem.FAIL, "Filters",
2108
+ f"Gyro LPF ({gyro_lpf}Hz) is above Nyquist ({nyquist:.0f}Hz)",
2109
+ "The filter cutoff is higher than what the sample rate can represent. "
2110
+ "This provides no filtering and may cause aliasing artifacts.",
2111
+ recommendation=f"Lower gyro_main_lpf_hz below {nyquist:.0f}"))
2112
+
2113
+ if isinstance(dterm_lpf, (int, float)):
2114
+ if dterm_lpf == 0:
2115
+ items.append(SanityItem(
2116
+ SanityItem.WARN, "Filters",
2117
+ "D-term LPF is DISABLED (0 Hz)",
2118
+ "D-term amplifies noise by design. Without filtering, motor "
2119
+ "heating and high-frequency oscillation are very likely.",
2120
+ question="Are you intentionally running without D-term LPF?"))
2121
+
2122
+ if isinstance(gyro_lpf, (int, float)) and gyro_lpf > 0:
2123
+ if frame_inches and frame_inches in FRAME_PROFILES:
2124
+ expected_lpf = FRAME_PROFILES[frame_inches]["filters"]["gyro_main_lpf_hz"]
2125
+ if gyro_lpf > expected_lpf * 2:
2126
+ items.append(SanityItem(
2127
+ SanityItem.WARN, "Filters",
2128
+ f"Gyro LPF ({gyro_lpf}Hz) seems high for {frame_inches}-inch "
2129
+ f"(expected ~{expected_lpf}Hz)",
2130
+ "Higher filter cutoff passes more noise. Large frames need lower cutoffs "
2131
+ "because they vibrate at lower frequencies."))
2132
+ else:
2133
+ items.append(SanityItem(
2134
+ SanityItem.PASS, "Filters",
2135
+ f"Gyro LPF at {gyro_lpf}Hz"))
2136
+
2137
+ # ── 10. RECEIVER ─────────────────────────────────────────────────────
2138
+ rx_type = get_setting(parsed, "serialrx_provider", None)
2139
+ has_rx_uart = False
2140
+ for _port_id, config in parsed["serial_ports"].items():
2141
+ parts = config.split()
2142
+ if len(parts) >= 1:
2143
+ try:
2144
+ func_mask = int(parts[0])
2145
+ if func_mask & 64: # Serial RX function bit
2146
+ has_rx_uart = True
2147
+ except ValueError:
2148
+ pass
2149
+
2150
+ if not has_rx_uart and rx_type is not None:
2151
+ # Might be using SPI RX or other non-serial receiver
2152
+ items.append(SanityItem(
2153
+ SanityItem.PASS, "Receiver",
2154
+ f"Receiver protocol: {rx_type}"))
2155
+ elif has_rx_uart:
2156
+ items.append(SanityItem(
2157
+ SanityItem.PASS, "Receiver",
2158
+ f"Serial receiver configured" + (f" ({rx_type})" if rx_type else "")))
2159
+ else:
2160
+ items.append(SanityItem(
2161
+ SanityItem.WARN, "Receiver",
2162
+ "No serial receiver UART detected in diff",
2163
+ "This may be normal if using SPI RX (like on AIO boards) or "
2164
+ "if the receiver is on default UART2 (not shown in diff)."))
2165
+
2166
+ # ── 11. BOARD ALIGNMENT ──────────────────────────────────────────────
2167
+ align_roll = get_setting(parsed, "align_board_roll", 0)
2168
+ align_pitch = get_setting(parsed, "align_board_pitch", 0)
2169
+ align_yaw = get_setting(parsed, "align_board_yaw", 0)
2170
+
2171
+ has_alignment = False
2172
+ for axis_name, val in [("roll", align_roll), ("pitch", align_pitch), ("yaw", align_yaw)]:
2173
+ if isinstance(val, (int, float)) and val != 0:
2174
+ has_alignment = True
2175
+ degrees = val / 10.0 # INAV stores in decidegrees
2176
+ if abs(degrees) > 0 and abs(degrees) not in (90, 180, 270):
2177
+ items.append(SanityItem(
2178
+ SanityItem.ASK, "Board Alignment",
2179
+ f"Board alignment {axis_name} = {degrees:.1f} degrees",
2180
+ "Non-standard board alignment. Common values are 0, 90, 180, 270. "
2181
+ "Wrong alignment will cause the FC to fight you — stabilization "
2182
+ "will push the aircraft the wrong way.",
2183
+ question=f"Is your FC rotated {degrees:.1f} degrees on {axis_name}?"))
2184
+ elif abs(degrees) in (90, 180, 270):
2185
+ items.append(SanityItem(
2186
+ SanityItem.PASS, "Board Alignment",
2187
+ f"Board rotated {degrees:.0f}° on {axis_name}"))
2188
+
2189
+ if not has_alignment:
2190
+ items.append(SanityItem(
2191
+ SanityItem.PASS, "Board Alignment",
2192
+ "Standard orientation (no rotation)"))
2193
+
2194
+ # ── 12. OSD / VIDEO ──────────────────────────────────────────────────
2195
+ osd_video = get_setting(parsed, "osd_video_system", "")
2196
+ if isinstance(osd_video, str) and osd_video.upper() not in ("", "AUTO"):
2197
+ # Has specific video system configured
2198
+ items.append(SanityItem(
2199
+ SanityItem.PASS, "OSD",
2200
+ f"OSD video system: {osd_video}"))
2201
+
2202
+ # ── 13. BEEPER SAFETY ────────────────────────────────────────────────
2203
+ critical_beepers = ["BAT_CRIT_LOW", "BAT_LOW", "RX_LOST", "RX_LOST_LANDING"]
2204
+ disabled = parsed.get("beepers_disabled", [])
2205
+ missing_critical = [b for b in critical_beepers if b in disabled]
2206
+ if missing_critical:
2207
+ items.append(SanityItem(
2208
+ SanityItem.WARN, "Safety",
2209
+ f"Critical beepers disabled: {', '.join(missing_critical)}",
2210
+ "You will have no audible warning for low battery or signal loss.",
2211
+ recommendation="Enable critical beepers",
2212
+ cli_fix="\n".join(f"beeper {b}" for b in missing_critical)))
2213
+ else:
2214
+ items.append(SanityItem(
2215
+ SanityItem.PASS, "Safety",
2216
+ "Critical beepers enabled"))
2217
+
2218
+ # ── 14. TRICOPTER SERVO ──────────────────────────────────────────────
2219
+ platform = get_setting(parsed, "platform_type", "")
2220
+ if isinstance(platform, str) and "TRI" in platform.upper():
2221
+ # Check servo direction
2222
+ items.append(SanityItem(
2223
+ SanityItem.ASK, "Platform",
2224
+ "Tricopter detected — verify tail servo direction",
2225
+ "Tricopters need the tail servo to push in the correct direction "
2226
+ "for yaw authority. An inverted servo will cause uncontrollable yaw.",
2227
+ question="Have you verified the tail servo moves correctly for yaw "
2228
+ "in the Configurator Outputs tab?"))
2229
+
2230
+ # ── 15. HEX/OCTO MOTOR ORDER ────────────────────────────────────────
2231
+ if motor_count and motor_count >= 6:
2232
+ platform_name = "Hexacopter" if motor_count == 6 else "Octocopter"
2233
+ items.append(SanityItem(
2234
+ SanityItem.ASK, "Platform",
2235
+ f"{platform_name} detected ({motor_count} motors)",
2236
+ f"With {motor_count} motors, correct motor order and spin direction "
2237
+ "are critical. Verify in the Configurator Outputs tab that each motor "
2238
+ "matches the diagram.",
2239
+ question=f"Have you verified all {motor_count} motor positions and spin directions?"))
2240
+
2241
+ # ── 16. FLIGHT MODES ─────────────────────────────────────────────────
2242
+ has_stabilized = assigned_modes & {1, 2} # ANGLE or HORIZON
2243
+ if not has_stabilized and 12 not in assigned_modes:
2244
+ # No stabilized mode and no manual mode — only ARM
2245
+ items.append(SanityItem(
2246
+ SanityItem.WARN, "Modes",
2247
+ "No flight mode switch assigned (ANGLE/HORIZON)",
2248
+ "Without a flight mode on a switch, the aircraft defaults to "
2249
+ "ACRO/rate mode which requires advanced piloting skills. "
2250
+ "Most pilots want at least ANGLE mode for recovery.",
2251
+ question="Are you an experienced ACRO pilot?"))
2252
+ elif has_stabilized:
2253
+ mode_names = [INAV_MODES.get(m, "") for m in assigned_modes & {1, 2}]
2254
+ items.append(SanityItem(
2255
+ SanityItem.PASS, "Modes",
2256
+ f"Stabilized mode available: {', '.join(mode_names)}"))
2257
+
2258
+ return items
2259
+
2260
+
2261
+ def print_sanity_report(items, parsed, interactive=True):
2262
+ """Print the interactive pre-flight sanity check report."""
2263
+ R, B, C, G, Y, RED, DIM = _colors()
2264
+
2265
+ print(f"\n {B}{'=' * 55}{R}")
2266
+ print(f" {B} INAV Pre-Flight Sanity Check v{VERSION}{R}")
2267
+ print(f" {B}{'=' * 55}{R}")
2268
+
2269
+ # Aircraft identification
2270
+ name = get_setting(parsed, "name", "")
2271
+ board = parsed.get("board", "")
2272
+ version = parsed.get("version", "")
2273
+ platform = get_setting(parsed, "platform_type", "")
2274
+
2275
+ print(f"\n {B}AIRCRAFT{R}")
2276
+ if name:
2277
+ print(f" Craft name: {C}{name}{R}")
2278
+ if board:
2279
+ print(f" Board: {board}")
2280
+ if version:
2281
+ print(f" Firmware: INAV {version}")
2282
+ if platform:
2283
+ print(f" Platform: {platform}")
2284
+ print()
2285
+
2286
+ # Group items by category
2287
+ categories = []
2288
+ seen = set()
2289
+ for item in items:
2290
+ if item.category not in seen:
2291
+ categories.append(item.category)
2292
+ seen.add(item.category)
2293
+
2294
+ n_fail = 0
2295
+ n_warn = 0
2296
+ n_ask_unconfirmed = 0
2297
+ n_pass = 0
2298
+
2299
+ for category in categories:
2300
+ cat_items = [i for i in items if i.category == category]
2301
+ print(f" {B}{category}{R}")
2302
+
2303
+ for item in cat_items:
2304
+ if item.status == SanityItem.PASS:
2305
+ print(f" {G}✓{R} {item.message}")
2306
+ n_pass += 1
2307
+
2308
+ elif item.status == SanityItem.FAIL:
2309
+ print(f" {RED}✗ FAIL:{R} {item.message}")
2310
+ if item.detail:
2311
+ for line in textwrap.wrap(item.detail, 68):
2312
+ print(f" {DIM}{line}{R}")
2313
+ if item.recommendation:
2314
+ print(f" {Y}→ {item.recommendation}{R}")
2315
+ if item.cli_fix:
2316
+ print(f" {C}CLI: {item.cli_fix}{R}")
2317
+ n_fail += 1
2318
+
2319
+ elif item.status == SanityItem.WARN:
2320
+ print(f" {Y}⚠ WARNING:{R} {item.message}")
2321
+ if item.detail:
2322
+ for line in textwrap.wrap(item.detail, 68):
2323
+ print(f" {DIM}{line}{R}")
2324
+ if item.question and interactive:
2325
+ confirmed = _ask_pilot(item.question)
2326
+ item.user_confirmed = confirmed
2327
+ if confirmed:
2328
+ print(f" {G}→ Acknowledged by pilot{R}")
2329
+ else:
2330
+ print(f" {RED}→ Pilot says NO — review this before flying{R}")
2331
+ n_fail += 1
2332
+ continue
2333
+ elif item.recommendation:
2334
+ print(f" {Y}→ {item.recommendation}{R}")
2335
+ if item.cli_fix:
2336
+ print(f" {C}CLI: {item.cli_fix}{R}")
2337
+ n_warn += 1
2338
+
2339
+ elif item.status == SanityItem.ASK:
2340
+ print(f" {C}? CONFIRM:{R} {item.message}")
2341
+ if item.detail:
2342
+ for line in textwrap.wrap(item.detail, 68):
2343
+ print(f" {DIM}{line}{R}")
2344
+ if item.question and interactive:
2345
+ confirmed = _ask_pilot(item.question)
2346
+ item.user_confirmed = confirmed
2347
+ if confirmed:
2348
+ print(f" {G}→ Confirmed{R}")
2349
+ n_pass += 1
2350
+ else:
2351
+ print(f" {RED}→ NOT confirmed — fix this before flying{R}")
2352
+ if item.recommendation:
2353
+ print(f" {Y}→ {item.recommendation}{R}")
2354
+ if item.cli_fix:
2355
+ print(f" {C}CLI: {item.cli_fix}{R}")
2356
+ n_fail += 1
2357
+ else:
2358
+ # Non-interactive: treat ASK as warning
2359
+ if item.recommendation:
2360
+ print(f" {Y}→ {item.recommendation}{R}")
2361
+ n_ask_unconfirmed += 1
2362
+
2363
+ print()
2364
+
2365
+ # ── VERDICT ──────────────────────────────────────────────────────────
2366
+ print(f" {B}{'=' * 55}{R}")
2367
+ print(f" {B} PRE-FLIGHT VERDICT{R}")
2368
+ print(f" {B}{'=' * 55}{R}")
2369
+
2370
+ if n_fail > 0:
2371
+ print(f" {RED}✗{R} {n_fail} CRITICAL issue{'s' if n_fail > 1 else ''} — must fix before flying")
2372
+ if n_warn > 0:
2373
+ print(f" {Y}⚠{R} {n_warn} WARNING{'s' if n_warn > 1 else ''} — review recommended")
2374
+ if n_ask_unconfirmed > 0:
2375
+ print(f" {C}?{R} {n_ask_unconfirmed} item{'s' if n_ask_unconfirmed > 1 else ''} need{'s' if n_ask_unconfirmed == 1 else ''} pilot confirmation (use --check without --no-interactive)")
2376
+ if n_pass > 0:
2377
+ print(f" {G}✓{R} {n_pass} check{'s' if n_pass > 1 else ''} passed")
2378
+
2379
+ print()
2380
+ if n_fail > 0:
2381
+ print(f" {RED}{B}██ NO-GO ██{R}")
2382
+ print(f" {RED}Fix critical issues and run --check again.{R}")
2383
+ elif n_warn > 0 or n_ask_unconfirmed > 0:
2384
+ print(f" {Y}{B}██ CONDITIONAL ██{R}")
2385
+ print(f" {Y}Review warnings before flying.{R}")
2386
+ else:
2387
+ print(f" {G}{B}██ GO ██{R}")
2388
+ print(f" {G}All checks passed. Fly safe!{R}")
2389
+ print()
2390
+
2391
+ return n_fail
2392
+
2393
+
2394
+ def _pull_diff_from_device(device_path, no_color=False):
2395
+ """Connect to FC via MSP and pull diff all. Returns (text, info) or exits."""
2396
+ R, B, C, G, Y, RED, DIM = _colors()
2397
+
2398
+ try:
2399
+ from inav_toolkit.msp import auto_detect_fc, INAVDevice
2400
+ except ImportError:
2401
+ from msp import auto_detect_fc, INAVDevice
2402
+
2403
+ print(f" {B}Connecting to FC...{R}", end=" ", flush=True)
2404
+
2405
+ if device_path == "auto":
2406
+ dev, info = auto_detect_fc()
2407
+ if not dev:
2408
+ print(f"\n {RED}ERROR: No INAV flight controller found.{R}")
2409
+ sys.exit(1)
2410
+ else:
2411
+ dev = INAVDevice(device_path)
2412
+ dev.open()
2413
+ info = dev.get_info()
2414
+
2415
+ fc_name = info.get("craft_name", "Unknown")
2416
+ fw_ver = info.get("fw_version", "?")
2417
+ board = info.get("board_id", "?")
2418
+ print(f"{C}{board}{R} — {fc_name} — INAV {fw_ver}")
2419
+
2420
+ print(f" Pulling configuration...", end=" ", flush=True)
2421
+ try:
2422
+ diff_text = dev.get_diff_all(timeout=15.0)
2423
+ print(f"done ({len(diff_text.splitlines())} lines)")
2424
+ except Exception as e:
2425
+ print(f"\n {RED}ERROR: Failed to pull diff: {e}{R}")
2426
+ dev.close()
2427
+ sys.exit(1)
2428
+
2429
+ dev.close()
2430
+ return diff_text
2431
+
2432
+
2433
+ # ─── Reporting ───────────────────────────────────────────────────────────────
2434
+
2435
+ def print_report(parsed, findings, frame_inches=None):
2436
+ R, B, C, G, Y, RED, DIM = _colors()
2437
+
2438
+ sev_color = {CRITICAL: RED, WARNING: Y, INFO: C, OK: G}
2439
+ sev_icon = {CRITICAL: "✗", WARNING: "⚠", INFO: "ℹ", OK: "✓"}
2440
+
2441
+ print(f"\n{B}{C}{'═'*70}{R}")
2442
+ print(f"{B}{C} INAV Parameter Analyzer v{VERSION}{R}")
2443
+ print(f"{B}{C}{'═'*70}{R}")
2444
+
2445
+ # Header info
2446
+ if parsed["version"]:
2447
+ print(f" {DIM}Firmware: INAV {parsed['version']} | Board: {parsed['board']}{R}")
2448
+ name = get_setting(parsed, "name", "")
2449
+ if name:
2450
+ print(f" {DIM}Craft: {name}{R}")
2451
+ if frame_inches:
2452
+ print(f" {DIM}Profile: {frame_inches}-inch{R}")
2453
+ pnum = parsed["active_control_profile"]
2454
+ print(f" {DIM}Active control profile: {pnum}{R}")
2455
+
2456
+ # Summary counts
2457
+ counts = {CRITICAL: 0, WARNING: 0, INFO: 0, OK: 0}
2458
+ for f in findings:
2459
+ counts[f.severity] = counts.get(f.severity, 0) + 1
2460
+
2461
+ print(f"\n {B}SUMMARY:{R}")
2462
+ if counts[CRITICAL]:
2463
+ print(f" {RED}{B}{counts[CRITICAL]} CRITICAL{R} - fix before flying")
2464
+ if counts[WARNING]:
2465
+ print(f" {Y}{B}{counts[WARNING]} WARNING{R} - should address")
2466
+ if counts[INFO]:
2467
+ print(f" {C}{counts[INFO]} suggestions{R}")
2468
+ if counts[OK]:
2469
+ print(f" {G}{counts[OK]} checks passed{R}")
2470
+
2471
+ # Group findings by category
2472
+ categories = {}
2473
+ for f in findings:
2474
+ if f.category not in categories:
2475
+ categories[f.category] = []
2476
+ categories[f.category].append(f)
2477
+
2478
+ # Print non-OK findings first, grouped by category
2479
+ has_issues = any(f.severity != OK for f in findings)
2480
+ if has_issues:
2481
+ print(f"\n{B}{C}{'─'*70}{R}")
2482
+ print(f" {B}FINDINGS:{R}")
2483
+ print(f"{B}{C}{'─'*70}{R}")
2484
+
2485
+ for cat, cat_findings in categories.items():
2486
+ issues = [f for f in cat_findings if f.severity != OK]
2487
+ if not issues:
2488
+ continue
2489
+ print(f"\n {B}{cat}{R}")
2490
+ for f in issues:
2491
+ sc = sev_color[f.severity]
2492
+ icon = sev_icon[f.severity]
2493
+ print(f" {sc}{B}{icon}{R} {B}{f.title}{R}")
2494
+ for line in textwrap.wrap(f.detail, width=62):
2495
+ print(f" {DIM}{line}{R}")
2496
+ if f.current:
2497
+ print(f" {DIM}Current: {f.current}{R}")
2498
+ if f.recommended:
2499
+ print(f" {DIM}Recommended: {f.recommended}{R}")
2500
+
2501
+ # CLI fixes
2502
+ fixes = [f for f in findings if f.cli_fix and f.severity in (CRITICAL, WARNING)]
2503
+ if fixes:
2504
+ print(f"\n{B}{C}{'─'*70}{R}")
2505
+ print(f" {B}SUGGESTED CLI FIXES:{R}")
2506
+ print(f"{B}{C}{'─'*70}{R}")
2507
+ print()
2508
+ for f in fixes:
2509
+ print(f" {DIM}# {f.title}{R}")
2510
+ for cmd in f.cli_fix.split("\n"):
2511
+ print(f" {G}{cmd}{R}")
2512
+ print()
2513
+ print(f" {G}save{R}")
2514
+
2515
+ # OK items (compact)
2516
+ ok_items = [f for f in findings if f.severity == OK]
2517
+ if ok_items:
2518
+ print(f"\n{B}{C}{'─'*70}{R}")
2519
+ print(f" {B}PASSED:{R}")
2520
+ for f in ok_items:
2521
+ print(f" {G}✓{R} {DIM}{f.title}{R}")
2522
+
2523
+ print(f"\n{B}{C}{'═'*70}{R}\n")
2524
+
2525
+
2526
+ # ─── Main ────────────────────────────────────────────────────────────────────
2527
+
2528
+ def main():
2529
+ parser = argparse.ArgumentParser(
2530
+ description=f"INAV Parameter Analyzer v{VERSION} - Check diff all for issues",
2531
+ formatter_class=argparse.RawDescriptionHelpFormatter,
2532
+ epilog=textwrap.dedent("""\
2533
+ Modes:
2534
+ Analysis (default): inav-params diff_all.txt --frame 10
2535
+ Setup: inav-params --setup 10 --voltage 6S
2536
+ Sanity check: inav-params --check diff_all.txt
2537
+ Sanity check (USB): inav-params --check --device auto
2538
+
2539
+ Supported frame sizes: 5, 7, 10, 12, 15 inches
2540
+ Supported voltages: 4S, 6S, 8S, 12S
2541
+ """))
2542
+ parser.add_argument("--version", action="version", version=f"inav-params {VERSION}")
2543
+ parser.add_argument("difffile", nargs="?", default=None,
2544
+ help="INAV `diff all` output (.txt file or stdin)")
2545
+ parser.add_argument("--frame", type=int, metavar="INCHES",
2546
+ help="Frame size in inches (affects filter/PID recommendations)")
2547
+ parser.add_argument("--blackbox", metavar="STATE_JSON",
2548
+ help="Blackbox analyzer state.json for cross-reference")
2549
+ parser.add_argument("--json", action="store_true",
2550
+ help="Output findings as JSON")
2551
+ parser.add_argument("--setup", type=int, metavar="INCHES",
2552
+ help="Generate starting configuration for a new quad (7/10/12/15)")
2553
+ parser.add_argument("--voltage", type=str, metavar="CELLS", default="4S",
2554
+ help="Battery voltage for --setup (4S/6S/8S/12S, default: 4S)")
2555
+ parser.add_argument("--check", action="store_true",
2556
+ help="Pre-flight sanity check mode - catches dangerous misconfigurations")
2557
+ parser.add_argument("--device", type=str, metavar="PORT", default=None,
2558
+ help="FC serial port for --check mode (use 'auto' to scan)")
2559
+ parser.add_argument("--no-interactive", action="store_true",
2560
+ help="Skip interactive questions in --check mode")
2561
+ parser.add_argument("--no-color", action="store_true",
2562
+ help="Disable colored terminal output.")
2563
+ args = parser.parse_args()
2564
+
2565
+ if args.no_color:
2566
+ _disable_colors()
2567
+
2568
+ # ─── Sanity check mode ─────────────────────────────────────────────
2569
+ if args.check:
2570
+ if args.device:
2571
+ text = _pull_diff_from_device(args.device, args.no_color)
2572
+ elif args.difffile:
2573
+ if args.difffile == "-":
2574
+ text = sys.stdin.read()
2575
+ else:
2576
+ if not os.path.isfile(args.difffile):
2577
+ print(f"ERROR: File not found: {args.difffile}")
2578
+ sys.exit(1)
2579
+ with open(args.difffile, "r", errors="replace") as f:
2580
+ text = f.read()
2581
+ else:
2582
+ parser.error("--check requires a diff file or --device")
2583
+
2584
+ parsed = parse_diff_all(text)
2585
+ interactive = not args.no_interactive
2586
+ items = run_sanity_check(parsed, frame_inches=args.frame,
2587
+ interactive=interactive)
2588
+
2589
+ if args.json:
2590
+ output = [{
2591
+ "status": i.status,
2592
+ "category": i.category,
2593
+ "message": i.message,
2594
+ "detail": i.detail,
2595
+ "recommendation": i.recommendation,
2596
+ "cli_fix": i.cli_fix,
2597
+ "user_confirmed": i.user_confirmed,
2598
+ } for i in items]
2599
+ print(json.dumps(output, indent=2))
2600
+ else:
2601
+ n_fail = print_sanity_report(items, parsed, interactive=interactive)
2602
+ sys.exit(1 if n_fail > 0 else 0)
2603
+ return
2604
+ args = parser.parse_args()
2605
+
2606
+ if args.no_color:
2607
+ _disable_colors()
2608
+
2609
+ # ─── Setup mode ───────────────────────────────────────────────────────
2610
+ if args.setup:
2611
+ if not args.json:
2612
+ print(f"\n ▲ INAV Parameter Analyzer v{VERSION}")
2613
+ print(f" Mode: Starting configuration")
2614
+
2615
+ voltage = args.voltage.upper()
2616
+ if not voltage.endswith("S"):
2617
+ voltage = voltage + "S"
2618
+
2619
+ config = generate_setup_config(args.setup, voltage=voltage)
2620
+
2621
+ if args.json:
2622
+ print_setup_json(config)
2623
+ else:
2624
+ print_setup_report(config)
2625
+
2626
+ # If a diff file was also provided, show what would change
2627
+ if args.difffile and os.path.isfile(args.difffile):
2628
+ with open(args.difffile, "r", errors="replace") as f:
2629
+ text = f.read()
2630
+ parsed = parse_diff_all(text)
2631
+ profile = get_active_control(parsed)
2632
+
2633
+ R, B, C, G, Y, _RED, DIM = _colors()
2634
+ print(f" {B}COMPARISON WITH CURRENT CONFIG ({args.difffile}):{R}")
2635
+ print(f" {'Current':>8} {'Suggested':>10} {'Change':>8}")
2636
+
2637
+ all_new = {}
2638
+ all_new.update(config["pids"])
2639
+ all_new.update(config["filters"])
2640
+ all_new.update(config["other"])
2641
+
2642
+ for k, new_val in sorted(all_new.items()):
2643
+ cur = profile.get(k, get_setting(parsed, k, None))
2644
+ if cur is not None and cur != new_val:
2645
+ if isinstance(cur, (int, float)) and isinstance(new_val, (int, float)):
2646
+ diff = new_val - cur
2647
+ sign = "+" if diff > 0 else ""
2648
+ color = G if abs(diff) < abs(cur) * 0.05 else Y
2649
+ print(f" {k:26} {cur:>8} {new_val:>10} {color}{sign}{diff}{R}")
2650
+ else:
2651
+ print(f" {k:26} {str(cur):>8} {str(new_val):>10}")
2652
+
2653
+ print()
2654
+ return
2655
+
2656
+ # ─── Analysis mode ────────────────────────────────────────────────────
2657
+ if not args.difffile:
2658
+ parser.error("diff file is required (or use --setup INCHES)")
2659
+
2660
+ # Load diff all
2661
+ if args.difffile == "-":
2662
+ text = sys.stdin.read()
2663
+ else:
2664
+ if not os.path.isfile(args.difffile):
2665
+ print(f"ERROR: File not found: {args.difffile}")
2666
+ sys.exit(1)
2667
+ with open(args.difffile, "r", errors="replace") as f:
2668
+ text = f.read()
2669
+
2670
+ if not args.json:
2671
+ print(f"\n ▲ INAV Parameter Analyzer v{VERSION}")
2672
+ print(f" Loading: {args.difffile}")
2673
+
2674
+ parsed = parse_diff_all(text)
2675
+
2676
+ if not args.json:
2677
+ if parsed["version"]:
2678
+ print(f" Firmware: INAV {parsed['version']} on {parsed['board']}")
2679
+ name = get_setting(parsed, "name", "")
2680
+ if name:
2681
+ print(f" Craft: {name}")
2682
+
2683
+ # Load blackbox state if provided
2684
+ bb_state = None
2685
+ if args.blackbox:
2686
+ try:
2687
+ with open(args.blackbox) as f:
2688
+ bb_state = json.load(f)
2689
+ if not args.json:
2690
+ print(f" Blackbox state: {args.blackbox}")
2691
+ except Exception as e:
2692
+ if not args.json:
2693
+ print(f" ⚠ Could not load blackbox state: {e}")
2694
+
2695
+ # Run checks
2696
+ findings = run_all_checks(parsed, frame_inches=args.frame, blackbox_state=bb_state)
2697
+
2698
+ if args.json:
2699
+ output = [{
2700
+ "severity": f.severity,
2701
+ "category": f.category,
2702
+ "title": f.title,
2703
+ "detail": f.detail,
2704
+ "setting": f.setting,
2705
+ "current": f.current,
2706
+ "recommended": f.recommended,
2707
+ "cli_fix": f.cli_fix,
2708
+ } for f in findings]
2709
+ print(json.dumps(output, indent=2))
2710
+ else:
2711
+ print_report(parsed, findings, frame_inches=args.frame)
2712
+
2713
+
2714
+ if __name__ == "__main__":
2715
+ main()