inav-toolkit 2.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- inav_toolkit/__init__.py +3 -0
- inav_toolkit/autotune.py +504 -0
- inav_toolkit/blackbox_analyzer.py +11070 -0
- inav_toolkit/flight_db.py +674 -0
- inav_toolkit/i18n.py +192 -0
- inav_toolkit/locales/en.json +187 -0
- inav_toolkit/locales/es.json +187 -0
- inav_toolkit/locales/pt_BR.json +187 -0
- inav_toolkit/msp.py +1293 -0
- inav_toolkit/param_analyzer.py +2832 -0
- inav_toolkit/vtol_configurator.py +856 -0
- inav_toolkit/wizard.py +1095 -0
- inav_toolkit-2.3.0.dist-info/METADATA +296 -0
- inav_toolkit-2.3.0.dist-info/RECORD +18 -0
- inav_toolkit-2.3.0.dist-info/WHEEL +5 -0
- inav_toolkit-2.3.0.dist-info/entry_points.txt +5 -0
- inav_toolkit-2.3.0.dist-info/licenses/LICENSE +674 -0
- inav_toolkit-2.3.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|