inav-toolkit 2.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,856 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ INAV VTOL Configurator - Validate and configure VTOL mixer profiles.
4
+
5
+ Reads an INAV `diff all`, validates the two mixer profiles (MC + FW),
6
+ checks for common VTOL configuration mistakes, and optionally runs
7
+ an interactive wizard to fix or complete the configuration.
8
+
9
+ INAV VTOL works by switching between two mixer_profiles:
10
+ - Profile 1 (or 2): Multirotor/Tricopter mode (hover, takeoff, landing)
11
+ - Profile 2 (or 1): Airplane mode (forward flight)
12
+ Switching is via the MIXER PROFILE 2 RC mode.
13
+ Transition mixing (smix source 38) handles tilt servos.
14
+
15
+ Usage:
16
+ python3 -m inav_toolkit.vtol_configurator diff_all.txt # Validate
17
+ python3 -m inav_toolkit.vtol_configurator diff_all.txt --wizard # Interactive setup
18
+ python3 -m inav_toolkit.vtol_configurator diff_all.txt --json # Machine output
19
+ """
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import re
25
+ import sys
26
+ import textwrap
27
+
28
+ VERSION = "2.3.0"
29
+
30
+
31
+ def _enable_ansi_colors():
32
+ """Enable ANSI color support. Returns True if colors are available."""
33
+ if os.environ.get("NO_COLOR") is not None:
34
+ return False
35
+ if not hasattr(sys.stdout, "isatty") or not sys.stdout.isatty():
36
+ return False
37
+ if sys.platform == "win32":
38
+ try:
39
+ import ctypes
40
+ kernel32 = ctypes.windll.kernel32
41
+ handle = kernel32.GetStdHandle(-11)
42
+ mode = ctypes.c_ulong()
43
+ kernel32.GetConsoleMode(handle, ctypes.byref(mode))
44
+ kernel32.SetConsoleMode(handle, mode.value | 0x0004)
45
+ return True
46
+ except Exception:
47
+ return False
48
+ return True
49
+
50
+ _ANSI_ENABLED = _enable_ansi_colors()
51
+
52
+ def _colors():
53
+ """Return (R, B, C, G, Y, RED, DIM) color codes."""
54
+ if _ANSI_ENABLED:
55
+ return ("\033[0m", "\033[1m", "\033[96m", "\033[92m",
56
+ "\033[93m", "\033[91m", "\033[2m")
57
+ return ("", "", "", "", "", "", "")
58
+
59
+ def _disable_colors():
60
+ global _ANSI_ENABLED
61
+ _ANSI_ENABLED = False
62
+
63
+ # ─── Severity Levels ─────────────────────────────────────────────────────────
64
+
65
+ CRITICAL = "CRITICAL"
66
+ WARNING = "WARNING"
67
+ INFO = "INFO"
68
+ OK = "OK"
69
+
70
+ SEVERITY_ORDER = {CRITICAL: 0, WARNING: 1, INFO: 2, OK: 3}
71
+
72
+ # ─── INAV Constants ──────────────────────────────────────────────────────────
73
+
74
+ # Platform types in INAV
75
+ PLATFORM_MULTIROTOR = "MULTIROTOR"
76
+ PLATFORM_TRICOPTER = "TRICOPTER"
77
+ PLATFORM_AIRPLANE = "AIRPLANE"
78
+ PLATFORM_TAILSITTER = "TAILSITTER"
79
+
80
+ MC_PLATFORMS = {PLATFORM_MULTIROTOR, PLATFORM_TRICOPTER, PLATFORM_TAILSITTER}
81
+ FW_PLATFORMS = {PLATFORM_AIRPLANE}
82
+
83
+ # INAV smix input sources (the important ones for VTOL)
84
+ SMIX_SOURCES = {
85
+ 0: "Stabilised ROLL",
86
+ 1: "Stabilised PITCH",
87
+ 2: "Stabilised YAW",
88
+ 3: "Stabilised THROTTLE",
89
+ 4: "RC ROLL",
90
+ 5: "RC PITCH",
91
+ 6: "RC YAW",
92
+ 7: "RC THROTTLE",
93
+ 8: "RC AUX 1",
94
+ 9: "RC AUX 2",
95
+ 10: "RC AUX 3",
96
+ 11: "RC AUX 4",
97
+ 12: "MAX (always 100%)",
98
+ 38: "Mixer Transition",
99
+ }
100
+
101
+ # INAV aux mode IDs (from src/main/fc/rc_modes.h)
102
+ # These are the mode_id values used in `aux` commands
103
+ INAV_MODE_NAMES = {
104
+ 0: "ARM",
105
+ 1: "ANGLE",
106
+ 2: "HORIZON",
107
+ 3: "NAV ALTHOLD",
108
+ 5: "HEADING HOLD",
109
+ 10: "NAV POSHOLD",
110
+ 11: "NAV RTH",
111
+ 12: "MANUAL",
112
+ 13: "NAV WP",
113
+ 28: "NAV LAUNCH",
114
+ 45: "TURTLE",
115
+ 47: "OSD ALT",
116
+ 48: "NAV COURSE HOLD",
117
+ 53: "MULTI FUNCTION",
118
+ 62: "MIXER PROFILE 2",
119
+ 63: "MIXER TRANSITION",
120
+ }
121
+
122
+ MODE_MIXER_PROFILE_2 = 62 # BOXMIXERPROFILE
123
+ MODE_MIXER_TRANSITION = 63 # BOXMIXERTRANSITION
124
+
125
+
126
+ # ─── Parser ──────────────────────────────────────────────────────────────────
127
+
128
+ class Finding:
129
+ """A single validation finding."""
130
+ def __init__(self, severity, category, title, detail, cli_fix=None):
131
+ self.severity = severity
132
+ self.category = category
133
+ self.title = title
134
+ self.detail = detail
135
+ self.cli_fix = cli_fix
136
+
137
+ def __repr__(self):
138
+ return f"<{self.severity} {self.category}: {self.title}>"
139
+
140
+
141
+ def parse_diff_all(text):
142
+ """Parse INAV diff all into structured data with full VTOL support."""
143
+ result = {
144
+ "version": None,
145
+ "board": None,
146
+ "master": {},
147
+ "control_profiles": {},
148
+ "mixer_profiles": {},
149
+ "battery_profiles": {},
150
+ "active_control_profile": 1,
151
+ "active_mixer_profile": 1,
152
+ "active_battery_profile": 1,
153
+ "features": [],
154
+ "features_disabled": [],
155
+ "serial_ports": {},
156
+ "aux_modes": [],
157
+ "mmix_by_profile": {}, # {profile_num: [{'index':0, 'throttle':1.0, ...}, ...]}
158
+ "smix_by_profile": {}, # {profile_num: [{'index':0, 'servo':0, 'source':0, ...}, ...]}
159
+ "servo_config": {}, # {servo_index: {'min':..., 'max':..., 'middle':..., 'rate':...}}
160
+ "resources": {}, # {'MOTOR': {1: 'PA0', ...}, 'SERVO': {1: 'PB1', ...}}
161
+ }
162
+
163
+ current_section = "master"
164
+ current_profile_type = None
165
+ current_profile_num = 1
166
+
167
+ for line in text.splitlines():
168
+ line = line.strip()
169
+ if not line or line.startswith("#"):
170
+ m = re.match(r"#\s*INAV/(\S+)\s+([\d.]+)", line)
171
+ if m:
172
+ result["board"] = m.group(1)
173
+ result["version"] = m.group(2)
174
+ continue
175
+
176
+ # Features
177
+ if line.startswith("feature "):
178
+ feat = line[8:].strip()
179
+ if feat.startswith("-"):
180
+ result["features_disabled"].append(feat[1:])
181
+ else:
182
+ result["features"].append(feat)
183
+ continue
184
+
185
+ # Serial ports
186
+ m = re.match(r"serial\s+(\d+)\s+(.*)", line)
187
+ if m:
188
+ result["serial_ports"][int(m.group(1))] = m.group(2).strip()
189
+ continue
190
+
191
+ # Aux modes
192
+ m = re.match(r"aux\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)", line)
193
+ if m:
194
+ result["aux_modes"].append({
195
+ "index": int(m.group(1)),
196
+ "mode_id": int(m.group(2)),
197
+ "channel": int(m.group(3)),
198
+ "range_low": int(m.group(4)),
199
+ "range_high": int(m.group(5)),
200
+ })
201
+ continue
202
+
203
+ # Profile switches
204
+ pm = re.match(r"(control_profile|mixer_profile|battery_profile)\s+(\d+)", line)
205
+ if pm:
206
+ current_profile_type = pm.group(1)
207
+ current_profile_num = int(pm.group(2))
208
+ section_map = {
209
+ "control_profile": "control_profiles",
210
+ "mixer_profile": "mixer_profiles",
211
+ "battery_profile": "battery_profiles",
212
+ }
213
+ current_section = section_map[current_profile_type]
214
+ if current_profile_num not in result[current_section]:
215
+ result[current_section][current_profile_num] = {}
216
+ continue
217
+
218
+ # Motor mix: mmix <index> <throttle> <roll> <pitch> <yaw>
219
+ mm = re.match(r"mmix\s+(\d+)\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)", line)
220
+ if mm:
221
+ entry = {
222
+ "index": int(mm.group(1)),
223
+ "throttle": float(mm.group(2)),
224
+ "roll": float(mm.group(3)),
225
+ "pitch": float(mm.group(4)),
226
+ "yaw": float(mm.group(5)),
227
+ }
228
+ profile = current_profile_num if current_section == "mixer_profiles" else 0
229
+ if profile not in result["mmix_by_profile"]:
230
+ result["mmix_by_profile"][profile] = []
231
+ result["mmix_by_profile"][profile].append(entry)
232
+ continue
233
+
234
+ # Servo mix: smix <index> <servo> <source> [<rate> <speed> <min> <max> <box>]
235
+ sm = re.match(r"smix\s+(\d+)\s+(\d+)\s+(\d+)\s*(.*)", line)
236
+ if sm:
237
+ rest = sm.group(4).split()
238
+ entry = {
239
+ "index": int(sm.group(1)),
240
+ "servo": int(sm.group(2)),
241
+ "source": int(sm.group(3)),
242
+ "rate": int(rest[0]) if len(rest) > 0 else 100,
243
+ "speed": int(rest[1]) if len(rest) > 1 else 0,
244
+ "min": int(rest[2]) if len(rest) > 2 else 0,
245
+ "max": int(rest[3]) if len(rest) > 3 else 0,
246
+ "box": int(rest[4]) if len(rest) > 4 else 0,
247
+ }
248
+ profile = current_profile_num if current_section == "mixer_profiles" else 0
249
+ if profile not in result["smix_by_profile"]:
250
+ result["smix_by_profile"][profile] = []
251
+ result["smix_by_profile"][profile].append(entry)
252
+ continue
253
+
254
+ # mmix reset / smix reset
255
+ if line in ("mmix reset", "smix reset"):
256
+ continue
257
+
258
+ # Servo config: servo <index> <min> <max> <middle> <rate>
259
+ sv = re.match(r"servo\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(-?\d+)", line)
260
+ if sv:
261
+ result["servo_config"][int(sv.group(1))] = {
262
+ "min": int(sv.group(2)),
263
+ "max": int(sv.group(3)),
264
+ "middle": int(sv.group(4)),
265
+ "rate": int(sv.group(5)),
266
+ }
267
+ continue
268
+
269
+ # Resources: resource <TYPE> <index> <pin>
270
+ rm = re.match(r"resource\s+(\w+)\s+(\d+)\s+(\S+)", line)
271
+ if rm:
272
+ res_type = rm.group(1)
273
+ if res_type not in result["resources"]:
274
+ result["resources"][res_type] = {}
275
+ result["resources"][res_type][int(rm.group(2))] = rm.group(3)
276
+ continue
277
+
278
+ # Channel map
279
+ cm = re.match(r"map\s+(\w+)", line)
280
+ if cm:
281
+ result["master"]["channel_map"] = cm.group(1)
282
+ continue
283
+
284
+ # Set commands
285
+ sm2 = re.match(r"set\s+(\S+)\s*=\s*(.*)", line)
286
+ if sm2:
287
+ key = sm2.group(1).strip()
288
+ val = _parse_value(sm2.group(2).strip())
289
+ if current_section == "master":
290
+ result["master"][key] = val
291
+ else:
292
+ result[current_section][current_profile_num][key] = val
293
+ continue
294
+
295
+ return result
296
+
297
+
298
+ def _parse_value(val):
299
+ """Parse a string value into appropriate Python type."""
300
+ if val.upper() in ("ON", "TRUE", "YES"):
301
+ return True
302
+ if val.upper() in ("OFF", "FALSE", "NO"):
303
+ return False
304
+ try:
305
+ return int(val)
306
+ except ValueError:
307
+ pass
308
+ try:
309
+ return float(val)
310
+ except ValueError:
311
+ pass
312
+ return val
313
+
314
+
315
+ # ─── VTOL Profile Detection ─────────────────────────────────────────────────
316
+
317
+ def detect_vtol_profiles(parsed):
318
+ """Detect which mixer profiles are MC and FW."""
319
+ mc_profile = None
320
+ fw_profile = None
321
+ profiles_info = {}
322
+
323
+ for num, settings in parsed["mixer_profiles"].items():
324
+ ptype = str(settings.get("platform_type", "")).upper()
325
+ mmix = parsed["mmix_by_profile"].get(num, [])
326
+ smix = parsed["smix_by_profile"].get(num, [])
327
+ active_motors = [m for m in mmix if m["throttle"] != 0 and m["throttle"] != -1]
328
+
329
+ profiles_info[num] = {
330
+ "platform_type": ptype or "(not set)",
331
+ "motor_count": len(mmix),
332
+ "active_motors": len(active_motors),
333
+ "servo_rules": len(smix),
334
+ "mmix": mmix,
335
+ "smix": smix,
336
+ }
337
+
338
+ if ptype in MC_PLATFORMS:
339
+ mc_profile = num
340
+ elif ptype in FW_PLATFORMS:
341
+ fw_profile = num
342
+
343
+ return mc_profile, fw_profile, profiles_info
344
+
345
+
346
+ def detect_motor_roles(parsed, mc_profile, fw_profile):
347
+ """Infer motor roles from mixer profile cross-reference."""
348
+ mc_motors = set()
349
+ fw_motors = set()
350
+
351
+ if mc_profile:
352
+ for m in parsed["mmix_by_profile"].get(mc_profile, []):
353
+ if m["throttle"] > 0:
354
+ mc_motors.add(m["index"] + 1) # 1-based
355
+
356
+ if fw_profile:
357
+ for m in parsed["mmix_by_profile"].get(fw_profile, []):
358
+ if m["throttle"] > 0:
359
+ fw_motors.add(m["index"] + 1)
360
+
361
+ tilt_motors = mc_motors & fw_motors # in both = tilt
362
+ lift_only = mc_motors - fw_motors # MC only = lift
363
+ push_only = fw_motors - mc_motors # FW only = pusher
364
+
365
+ return {
366
+ "mc_motors": mc_motors,
367
+ "fw_motors": fw_motors,
368
+ "tilt_motors": tilt_motors,
369
+ "lift_only": lift_only,
370
+ "push_only": push_only,
371
+ }
372
+
373
+
374
+ def detect_tilt_servos(parsed, mc_profile):
375
+ """Find servo mix rules using Mixer Transition (source 38)."""
376
+ tilt_rules = []
377
+ if mc_profile:
378
+ for rule in parsed["smix_by_profile"].get(mc_profile, []):
379
+ if rule["source"] == 38: # Mixer Transition
380
+ tilt_rules.append(rule)
381
+ return tilt_rules
382
+
383
+
384
+ # ─── Validation Checks ──────────────────────────────────────────────────────
385
+
386
+ def run_vtol_checks(parsed):
387
+ """Run all VTOL-specific validation checks."""
388
+ findings = []
389
+ mc_profile, fw_profile, profiles_info = detect_vtol_profiles(parsed)
390
+
391
+ # ── Profile existence ────────────────────────────────────────────────
392
+ if mc_profile is None and fw_profile is None:
393
+ findings.append(Finding(
394
+ INFO, "Profiles", "No VTOL profiles detected",
395
+ "Neither mixer profile has platform_type set to a multirotor or airplane type. "
396
+ "This doesn't appear to be a VTOL configuration. If it should be, set "
397
+ "platform_type in each mixer_profile."))
398
+ return findings
399
+
400
+ if mc_profile is None:
401
+ findings.append(Finding(
402
+ CRITICAL, "Profiles", "No multirotor mixer profile found",
403
+ "VTOL requires one mixer_profile with platform_type set to MULTIROTOR, "
404
+ "TRICOPTER, or TAILSITTER for hover/takeoff/landing.",
405
+ cli_fix=f"mixer_profile 1\nset platform_type = TRICOPTER"))
406
+ else:
407
+ ptype = profiles_info[mc_profile]["platform_type"]
408
+ n_motors = profiles_info[mc_profile]["active_motors"]
409
+ n_smix = profiles_info[mc_profile]["servo_rules"]
410
+ findings.append(Finding(
411
+ OK, "Profiles", f"MC profile: mixer_profile {mc_profile} ({ptype})",
412
+ f"{n_motors} active motors, {n_smix} servo mix rules."))
413
+
414
+ if fw_profile is None:
415
+ findings.append(Finding(
416
+ CRITICAL, "Profiles", "No airplane mixer profile found",
417
+ "VTOL requires one mixer_profile with platform_type = AIRPLANE for forward flight.",
418
+ cli_fix=f"mixer_profile 2\nset platform_type = AIRPLANE"))
419
+ else:
420
+ n_motors = profiles_info[fw_profile]["active_motors"]
421
+ n_smix = profiles_info[fw_profile]["servo_rules"]
422
+ findings.append(Finding(
423
+ OK, "Profiles", f"FW profile: mixer_profile {fw_profile} (AIRPLANE)",
424
+ f"{n_motors} active motors, {n_smix} servo mix rules."))
425
+
426
+ if mc_profile is None or fw_profile is None:
427
+ return findings
428
+
429
+ # ── Motor mix validation ─────────────────────────────────────────────
430
+ roles = detect_motor_roles(parsed, mc_profile, fw_profile)
431
+
432
+ if not roles["mc_motors"]:
433
+ findings.append(Finding(
434
+ CRITICAL, "Motor Mix", "MC profile has no active motors",
435
+ "No motors with throttle > 0 in the multirotor mixer profile."))
436
+
437
+ if not roles["fw_motors"]:
438
+ findings.append(Finding(
439
+ CRITICAL, "Motor Mix", "FW profile has no active motors",
440
+ "No motors with throttle > 0 in the airplane mixer profile."))
441
+
442
+ mc_mmix = parsed["mmix_by_profile"].get(mc_profile, [])
443
+ fw_mmix = parsed["mmix_by_profile"].get(fw_profile, [])
444
+
445
+ # Tricopter-specific: should have exactly 3 motors
446
+ mc_ptype = str(parsed["mixer_profiles"][mc_profile].get("platform_type", "")).upper()
447
+ if mc_ptype == PLATFORM_TRICOPTER:
448
+ active = [m for m in mc_mmix if m["throttle"] > 0]
449
+ if len(active) != 3:
450
+ findings.append(Finding(
451
+ WARNING, "Motor Mix",
452
+ f"Tricopter profile has {len(active)} active motors (expected 3)",
453
+ "A tricopter should have exactly 3 motors with throttle > 0."))
454
+
455
+ # Check yaw authority in MC profile
456
+ yaw_motors = [m for m in mc_mmix if m["yaw"] != 0 and m["throttle"] > 0]
457
+ if mc_ptype == PLATFORM_TRICOPTER:
458
+ # Tricopter uses tilt servo for yaw, motor yaw mix is optional
459
+ pass
460
+ elif not yaw_motors:
461
+ findings.append(Finding(
462
+ WARNING, "Motor Mix", "MC profile: no motor yaw mixing",
463
+ "No active motors have a yaw factor. Yaw authority in hover may be insufficient."))
464
+
465
+ # Check FW motor mix
466
+ fw_active = [m for m in fw_mmix if m["throttle"] > 0]
467
+ if len(fw_active) == 1:
468
+ # Single pusher - normal
469
+ findings.append(Finding(
470
+ OK, "Motor Mix", f"FW profile: single motor (pusher)",
471
+ f"Motor {fw_active[0]['index']+1} is the forward-flight motor."))
472
+ elif len(fw_active) >= 2:
473
+ # Multi-motor FW - check for differential thrust
474
+ yaw_fw = [m for m in fw_active if m["yaw"] != 0]
475
+ if not yaw_fw:
476
+ findings.append(Finding(
477
+ INFO, "Motor Mix", "FW profile: multiple motors but no yaw mixing",
478
+ "With multiple motors in airplane mode, consider adding yaw factors "
479
+ "for differential thrust (helps with rudder-less turns)."))
480
+
481
+ # Motor roles summary
482
+ if roles["tilt_motors"]:
483
+ findings.append(Finding(
484
+ OK, "Motor Roles",
485
+ f"Tilt motors: {sorted(roles['tilt_motors'])} (used in both MC and FW profiles)",
486
+ "These motors are active in both profiles - they tilt between hover and forward flight."))
487
+ if roles["lift_only"]:
488
+ findings.append(Finding(
489
+ OK, "Motor Roles",
490
+ f"Lift-only motors: {sorted(roles['lift_only'])} (MC profile only)",
491
+ "These motors are only active in hover mode."))
492
+ if roles["push_only"]:
493
+ findings.append(Finding(
494
+ OK, "Motor Roles",
495
+ f"Pusher motors: {sorted(roles['push_only'])} (FW profile only)",
496
+ "These motors are only active in forward flight."))
497
+
498
+ # Placeholder motors in FW profile (throttle = -1)
499
+ placeholders = [m for m in fw_mmix if m["throttle"] == -1]
500
+ if placeholders:
501
+ findings.append(Finding(
502
+ OK, "Motor Mix",
503
+ f"FW profile: {len(placeholders)} placeholder motor(s) (throttle=-1)",
504
+ "Placeholder entries keep motor indices aligned between profiles. Good."))
505
+
506
+ # Transition motors (throttle between -2 and -1)
507
+ transition_motors = [m for m in mc_mmix if -2.0 < m["throttle"] < -1.0]
508
+ if transition_motors:
509
+ for tm in transition_motors:
510
+ speed_pct = (abs(tm["throttle"]) - 1.0) * 100
511
+ findings.append(Finding(
512
+ OK, "Motor Mix",
513
+ f"Transition motor {tm['index']+1}: spins at {speed_pct:.0f}% during transition",
514
+ "This motor activates only when MIXER TRANSITION is engaged (for gaining airspeed)."))
515
+
516
+ # ── Servo mix validation ─────────────────────────────────────────────
517
+ tilt_rules = detect_tilt_servos(parsed, mc_profile)
518
+
519
+ if mc_ptype in (PLATFORM_TRICOPTER,) and not tilt_rules:
520
+ findings.append(Finding(
521
+ WARNING, "Servo Mix", "No tilt servo rules found (source 38) in MC profile",
522
+ "Tilt-rotor VTOLs need servo mix rules with source 38 (Mixer Transition) "
523
+ "to control the tilt angle during transition. Without this, motors won't "
524
+ "tilt for forward flight.",
525
+ cli_fix=f"mixer_profile {mc_profile}\n"
526
+ f"# Example: tilt servo 3 by +45° during transition\n"
527
+ f"smix 0 3 38 45 150 -1"))
528
+ elif tilt_rules:
529
+ servos_used = sorted(set(r["servo"] for r in tilt_rules))
530
+ for rule in tilt_rules:
531
+ src_name = SMIX_SOURCES.get(rule["source"], f"source {rule['source']}")
532
+ findings.append(Finding(
533
+ OK, "Servo Mix",
534
+ f"Tilt rule: servo {rule['servo']} ← {src_name} "
535
+ f"(rate={rule['rate']}, speed={rule['speed']})",
536
+ f"Servo {rule['servo']} tilts during transition."))
537
+
538
+ # Check for yaw mixing on tilt servos (tricopter yaw via tilt)
539
+ if mc_ptype == PLATFORM_TRICOPTER:
540
+ mc_smix = parsed["smix_by_profile"].get(mc_profile, [])
541
+ yaw_on_tilt = [r for r in mc_smix
542
+ if r["servo"] in servos_used and r["source"] == 2]
543
+ if not yaw_on_tilt:
544
+ findings.append(Finding(
545
+ WARNING, "Servo Mix",
546
+ "No yaw (source 2) mixed onto tilt servos",
547
+ "Tricopters typically use tilt servos for yaw control in hover. "
548
+ "Without yaw mixing on the tilt servos, you may have no yaw authority "
549
+ "in multirotor mode.",
550
+ cli_fix=f"mixer_profile {mc_profile}\n"
551
+ f"smix <next_index> {servos_used.pop()} 2 100 0 -1"))
552
+ else:
553
+ findings.append(Finding(
554
+ OK, "Servo Mix", "Yaw mixed onto tilt servos",
555
+ "Tilt servos provide yaw authority in hover mode. Good."))
556
+
557
+ # Check FW control surfaces
558
+ fw_smix = parsed["smix_by_profile"].get(fw_profile, [])
559
+ fw_has_roll = any(r["source"] == 0 for r in fw_smix) # stabilised roll
560
+ fw_has_pitch = any(r["source"] == 1 for r in fw_smix) # stabilised pitch
561
+ fw_has_yaw = any(r["source"] == 2 for r in fw_smix) # stabilised yaw
562
+
563
+ if not fw_has_roll:
564
+ findings.append(Finding(
565
+ WARNING, "Servo Mix", "FW profile: no roll control surface",
566
+ "Airplane profile has no servo mixed with Stabilised Roll (source 0). "
567
+ "Without ailerons or elevons, the aircraft can't bank in forward flight."))
568
+ if not fw_has_pitch:
569
+ findings.append(Finding(
570
+ WARNING, "Servo Mix", "FW profile: no pitch control surface",
571
+ "Airplane profile has no servo mixed with Stabilised Pitch (source 1). "
572
+ "Without elevator, the aircraft can't control pitch in forward flight."))
573
+ if fw_has_roll and fw_has_pitch:
574
+ findings.append(Finding(
575
+ OK, "Servo Mix", "FW profile has roll and pitch control surfaces",
576
+ "Airplane mode has both roll and pitch authority."))
577
+
578
+ # ── Mode switch validation ───────────────────────────────────────────
579
+ has_profile_switch = any(
580
+ m["mode_id"] == MODE_MIXER_PROFILE_2 for m in parsed["aux_modes"])
581
+ has_transition_switch = any(
582
+ m["mode_id"] == MODE_MIXER_TRANSITION for m in parsed["aux_modes"])
583
+
584
+ if not has_profile_switch:
585
+ findings.append(Finding(
586
+ CRITICAL, "Modes", "MIXER PROFILE 2 mode not assigned to any switch",
587
+ "Without this mode, you cannot switch between MC and FW profiles in flight. "
588
+ "Assign MIXER PROFILE 2 (mode 62) to an aux channel in the Modes tab.",
589
+ cli_fix="# Example: aux channel 4, high position\n"
590
+ "aux <next_index> 62 4 1700 2100"))
591
+ else:
592
+ mode = next(m for m in parsed["aux_modes"] if m["mode_id"] == MODE_MIXER_PROFILE_2)
593
+ findings.append(Finding(
594
+ OK, "Modes",
595
+ f"MIXER PROFILE 2 on channel {mode['channel']} "
596
+ f"({mode['range_low']}-{mode['range_high']})",
597
+ "Profile switching is configured."))
598
+
599
+ if not has_transition_switch:
600
+ findings.append(Finding(
601
+ WARNING, "Modes", "MIXER TRANSITION mode not assigned",
602
+ "MIXER TRANSITION activates tilt servos and transition motors while in MC mode. "
603
+ "Without it, tilt-rotor transition won't work. Usually mapped to a 3-position "
604
+ "switch: low=MC, mid=transition, high=FW profile.",
605
+ cli_fix="# Example: aux channel 4, mid position\n"
606
+ "aux <next_index> 63 4 1300 1700"))
607
+ else:
608
+ mode = next(m for m in parsed["aux_modes"] if m["mode_id"] == MODE_MIXER_TRANSITION)
609
+ findings.append(Finding(
610
+ OK, "Modes",
611
+ f"MIXER TRANSITION on channel {mode['channel']} "
612
+ f"({mode['range_low']}-{mode['range_high']})",
613
+ "Transition mixing is configured."))
614
+
615
+ # ── Automated transition (RTH) ───────────────────────────────────────
616
+ mc_settings = parsed["mixer_profiles"].get(mc_profile, {})
617
+ fw_settings = parsed["mixer_profiles"].get(fw_profile, {})
618
+
619
+ mc_auto = mc_settings.get("mixer_automated_switch", False)
620
+ fw_auto = fw_settings.get("mixer_automated_switch", False)
621
+ mc_timer = mc_settings.get("mixer_switch_trans_timer", 0)
622
+
623
+ if mc_auto and fw_auto:
624
+ findings.append(Finding(
625
+ OK, "Transition",
626
+ f"Automated RTH transition enabled (timer: {mc_timer/10:.1f}s)",
627
+ "On RTH, the quad will gain airspeed in MC mode, transition to FW for "
628
+ "efficient cruise, then switch back to MC for landing."))
629
+ elif mc_auto and not fw_auto:
630
+ findings.append(Finding(
631
+ WARNING, "Transition",
632
+ "Automated switch ON in MC profile but OFF in FW profile",
633
+ "For full automated RTH transition, both profiles need mixer_automated_switch = ON. "
634
+ "MC profile switches to FW for cruise, FW profile switches back to MC for landing.",
635
+ cli_fix=f"mixer_profile {fw_profile}\nset mixer_automated_switch = ON"))
636
+ elif not mc_auto and not fw_auto:
637
+ findings.append(Finding(
638
+ INFO, "Transition", "No automated transition configured",
639
+ "Automated switching handles MC↔FW transitions during RTH automatically. "
640
+ "Consider enabling it for hands-off return-to-home behavior.",
641
+ cli_fix=f"mixer_profile {mc_profile}\n"
642
+ f"set mixer_automated_switch = ON\n"
643
+ f"set mixer_switch_trans_timer = 30\n"
644
+ f"mixer_profile {fw_profile}\n"
645
+ f"set mixer_automated_switch = ON"))
646
+
647
+ # ── Control profile linking ──────────────────────────────────────────
648
+ mc_linking = mc_settings.get("mixer_control_profile_linking", False)
649
+ fw_linking = fw_settings.get("mixer_control_profile_linking", False)
650
+
651
+ if mc_linking and fw_linking:
652
+ findings.append(Finding(
653
+ OK, "Profiles", "Control profile linking enabled in both mixer profiles",
654
+ "PIDs and rates will automatically switch when changing mixer profiles. "
655
+ "MC PIDs for hover, FW PIDs for forward flight."))
656
+ elif not mc_linking or not fw_linking:
657
+ missing = []
658
+ if not mc_linking:
659
+ missing.append(f"MC (profile {mc_profile})")
660
+ if not fw_linking:
661
+ missing.append(f"FW (profile {fw_profile})")
662
+ findings.append(Finding(
663
+ WARNING, "Profiles",
664
+ f"Control profile linking not enabled in: {', '.join(missing)}",
665
+ "Without mixer_control_profile_linking = ON, PIDs and rates won't "
666
+ "automatically switch when changing between MC and FW modes. You may end up "
667
+ "flying a multirotor with airplane PIDs or vice versa.",
668
+ cli_fix="\n".join(
669
+ [f"mixer_profile {p}\nset mixer_control_profile_linking = ON"
670
+ for p in ([mc_profile] if not mc_linking else []) +
671
+ ([fw_profile] if not fw_linking else [])])))
672
+
673
+ # ── Safety checks ────────────────────────────────────────────────────
674
+ # Airmode type for transition motors
675
+ airmode = parsed["master"].get("airmode_type", "STICK_CENTER")
676
+ transition_motors = [m for m in mc_mmix if -2.0 < m["throttle"] < -1.0]
677
+ if transition_motors and str(airmode).upper() == "THROTTLE_THRESHOLD":
678
+ findings.append(Finding(
679
+ CRITICAL, "Safety",
680
+ "airmode_type is THROTTLE_THRESHOLD with transition motors",
681
+ "Transition motors (throttle between -2.0 and -1.0) require "
682
+ "airmode_type = STICK_CENTER. With THROTTLE_THRESHOLD, transition motors "
683
+ "will spin at the wrong times.",
684
+ cli_fix="set airmode_type = STICK_CENTER"))
685
+
686
+ # Compass required for MC nav modes
687
+ mag_hw = parsed["master"].get("mag_hardware", None)
688
+ if mag_hw is None or str(mag_hw).upper() in ("NONE", "0", "FALSE"):
689
+ findings.append(Finding(
690
+ WARNING, "Safety", "No compass configured",
691
+ "INAV requires a compass for navigation modes in multirotor profile. "
692
+ "Without it, GPS position hold and RTH won't work in hover mode."))
693
+
694
+ # MC profile should have some control surface mixing for high-speed recovery
695
+ mc_smix = parsed["smix_by_profile"].get(mc_profile, [])
696
+ mc_has_surfaces = any(r["source"] in (0, 1) for r in mc_smix)
697
+ if not mc_has_surfaces and fw_has_roll:
698
+ findings.append(Finding(
699
+ INFO, "Safety",
700
+ "Consider adding control surface mixing to MC profile",
701
+ "Having some aileron/elevator authority in multirotor mode helps recovery "
702
+ "if the aircraft enters a high-speed dive during transition. The INAV docs "
703
+ "recommend mapping control surfaces in both profiles."))
704
+
705
+ # Sort by severity
706
+ findings.sort(key=lambda f: SEVERITY_ORDER.get(f.severity, 99))
707
+ return findings
708
+
709
+
710
+ # ─── Terminal Output ─────────────────────────────────────────────────────────
711
+
712
+ def print_report(parsed, findings):
713
+ """Print validation report to terminal."""
714
+ R, B, C, G, Y, RED, DIM = _colors()
715
+ sev_color = {CRITICAL: RED, WARNING: Y, INFO: C, OK: G}
716
+ sev_icon = {CRITICAL: "✗", WARNING: "⚠", INFO: "ℹ", OK: "✓"}
717
+
718
+ print(f"\n{B}{C}{'═'*70}{R}")
719
+ print(f"{B}{C} INAV VTOL Configurator v{VERSION}{R}")
720
+ print(f"{B}{C}{'═'*70}{R}")
721
+
722
+ if parsed["version"]:
723
+ print(f" {DIM}Firmware: INAV {parsed['version']} | Board: {parsed['board']}{R}")
724
+
725
+ # Profile summary
726
+ mc_profile, fw_profile, profiles_info = detect_vtol_profiles(parsed)
727
+ for num, info in sorted(profiles_info.items()):
728
+ print(f" {DIM}mixer_profile {num}: {info['platform_type']} "
729
+ f"({info['active_motors']} motors, {info['servo_rules']} servo rules){R}")
730
+
731
+ if mc_profile and fw_profile:
732
+ roles = detect_motor_roles(parsed, mc_profile, fw_profile)
733
+ if roles["tilt_motors"]:
734
+ print(f" {DIM}VTOL type: Tilt-rotor{R}")
735
+ elif roles["push_only"]:
736
+ print(f" {DIM}VTOL type: Separate lift + pusher{R}")
737
+
738
+ # Summary counts
739
+ counts = {CRITICAL: 0, WARNING: 0, INFO: 0, OK: 0}
740
+ for f in findings:
741
+ counts[f.severity] = counts.get(f.severity, 0) + 1
742
+
743
+ print(f"\n {B}SUMMARY:{R}")
744
+ if counts[CRITICAL]:
745
+ print(f" {RED}{B}{counts[CRITICAL]} CRITICAL{R} - fix before flying")
746
+ if counts[WARNING]:
747
+ print(f" {Y}{B}{counts[WARNING]} WARNING{R} - should address")
748
+ if counts[INFO]:
749
+ print(f" {C}{counts[INFO]} suggestions{R}")
750
+ if counts[OK]:
751
+ print(f" {G}{counts[OK]} checks passed{R}")
752
+
753
+ # Non-OK findings
754
+ has_issues = any(f.severity != OK for f in findings)
755
+ if has_issues:
756
+ print(f"\n{B}{C}{'─'*70}{R}")
757
+ print(f" {B}FINDINGS:{R}")
758
+ print(f"{B}{C}{'─'*70}{R}")
759
+
760
+ categories = {}
761
+ for f in findings:
762
+ if f.severity == OK:
763
+ continue
764
+ if f.category not in categories:
765
+ categories[f.category] = []
766
+ categories[f.category].append(f)
767
+
768
+ for cat, cat_findings in categories.items():
769
+ print(f"\n {B}{cat}{R}")
770
+ for f in cat_findings:
771
+ sc = sev_color[f.severity]
772
+ icon = sev_icon[f.severity]
773
+ print(f" {sc}{B}{icon}{R} {B}{f.title}{R}")
774
+ for line in textwrap.wrap(f.detail, width=62):
775
+ print(f" {DIM}{line}{R}")
776
+
777
+ # CLI fixes
778
+ fixes = [f for f in findings if f.cli_fix and f.severity in (CRITICAL, WARNING)]
779
+ if fixes:
780
+ print(f"\n{B}{C}{'─'*70}{R}")
781
+ print(f" {B}SUGGESTED CLI FIXES:{R}")
782
+ print(f"{B}{C}{'─'*70}{R}")
783
+ print()
784
+ for f in fixes:
785
+ print(f" {DIM}# {f.title}{R}")
786
+ for cmd in f.cli_fix.split("\n"):
787
+ print(f" {G}{cmd}{R}")
788
+ print()
789
+ print(f" {G}save{R}")
790
+
791
+ # Passed
792
+ ok_items = [f for f in findings if f.severity == OK]
793
+ if ok_items:
794
+ print(f"\n{B}{C}{'─'*70}{R}")
795
+ print(f" {B}PASSED:{R}")
796
+ for f in ok_items:
797
+ print(f" {G}✓{R} {DIM}{f.title}{R}")
798
+
799
+ print(f"\n{B}{C}{'═'*70}{R}\n")
800
+
801
+
802
+ # ─── Main ────────────────────────────────────────────────────────────────────
803
+
804
+ def main():
805
+ parser = argparse.ArgumentParser(
806
+ description=f"INAV VTOL Configurator v{VERSION} - Validate VTOL mixer profiles",
807
+ formatter_class=argparse.RawDescriptionHelpFormatter,
808
+ epilog=textwrap.dedent("""\
809
+ Validates INAV VTOL configuration from a diff all file.
810
+ Checks mixer profiles, motor/servo mixing, mode assignments,
811
+ and transition settings for common mistakes.
812
+ """))
813
+ parser.add_argument("--version", action="version", version=f"inav-vtol {VERSION}")
814
+ parser.add_argument("difffile", help="INAV `diff all` output file")
815
+ parser.add_argument("--json", action="store_true",
816
+ help="Output findings as JSON")
817
+ parser.add_argument("--no-color", action="store_true",
818
+ help="Disable colored terminal output.")
819
+ args = parser.parse_args()
820
+
821
+ if args.no_color:
822
+ _disable_colors()
823
+
824
+ if not os.path.isfile(args.difffile):
825
+ print(f"ERROR: File not found: {args.difffile}")
826
+ sys.exit(1)
827
+
828
+ with open(args.difffile, "r", errors="replace") as f:
829
+ text = f.read()
830
+
831
+ if not args.json:
832
+ print(f"\n ▲ INAV VTOL Configurator v{VERSION}")
833
+ print(f" Loading: {args.difffile}")
834
+
835
+ parsed = parse_diff_all(text)
836
+
837
+ if not args.json and parsed["version"]:
838
+ print(f" Firmware: INAV {parsed['version']} on {parsed['board']}")
839
+
840
+ findings = run_vtol_checks(parsed)
841
+
842
+ if args.json:
843
+ output = [{
844
+ "severity": f.severity,
845
+ "category": f.category,
846
+ "title": f.title,
847
+ "detail": f.detail,
848
+ "cli_fix": f.cli_fix,
849
+ } for f in findings]
850
+ print(json.dumps(output, indent=2))
851
+ else:
852
+ print_report(parsed, findings)
853
+
854
+
855
+ if __name__ == "__main__":
856
+ main()