inav-mcp 0.3.1__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_mcp/__init__.py +0 -0
- inav_mcp/cli.py +174 -0
- inav_mcp/connection.py +429 -0
- inav_mcp/knowledge/__init__.py +0 -0
- inav_mcp/knowledge/arming_flags.json +162 -0
- inav_mcp/knowledge/esc_protocols.json +39 -0
- inav_mcp/knowledge/fc_targets.json +27 -0
- inav_mcp/knowledge/modes_reference.json +113 -0
- inav_mcp/modes.py +423 -0
- inav_mcp/msp.py +462 -0
- inav_mcp/profiles.py +332 -0
- inav_mcp/safety.py +78 -0
- inav_mcp/server.py +2046 -0
- inav_mcp/state.py +70 -0
- inav_mcp/troubleshoot.py +313 -0
- inav_mcp-0.3.1.dist-info/METADATA +347 -0
- inav_mcp-0.3.1.dist-info/RECORD +21 -0
- inav_mcp-0.3.1.dist-info/WHEEL +5 -0
- inav_mcp-0.3.1.dist-info/entry_points.txt +2 -0
- inav_mcp-0.3.1.dist-info/licenses/LICENSE +21 -0
- inav_mcp-0.3.1.dist-info/top_level.txt +1 -0
inav_mcp/__init__.py
ADDED
|
File without changes
|
inav_mcp/cli.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""CLI response parsing utilities (no serial I/O here).
|
|
2
|
+
|
|
3
|
+
The FC echoes our command on the first line and ends each response with '# ' (no newline).
|
|
4
|
+
strip_cli_response removes both so callers get clean output.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def strip_cli_response(cmd: str, raw: str) -> str:
|
|
10
|
+
"""Remove the echoed command and trailing '# ' prompt from a CLI response.
|
|
11
|
+
|
|
12
|
+
FC behaviour:
|
|
13
|
+
- Echoes our command as the first line (exact match of what we sent).
|
|
14
|
+
- Appends '# ' (without a preceding newline) as the prompt after output.
|
|
15
|
+
"""
|
|
16
|
+
# Remove the trailing "# " prompt
|
|
17
|
+
if raw.endswith("# "):
|
|
18
|
+
raw = raw[:-2]
|
|
19
|
+
|
|
20
|
+
# Normalise line endings
|
|
21
|
+
raw = raw.replace("\r\n", "\n").replace("\r", "\n")
|
|
22
|
+
|
|
23
|
+
lines = raw.split("\n")
|
|
24
|
+
|
|
25
|
+
# Drop the echoed command (first non-empty line matching what we sent)
|
|
26
|
+
cmd_stripped = cmd.strip()
|
|
27
|
+
if lines and lines[0].strip() == cmd_stripped:
|
|
28
|
+
lines = lines[1:]
|
|
29
|
+
|
|
30
|
+
# Drop leading/trailing blank lines
|
|
31
|
+
while lines and not lines[0].strip():
|
|
32
|
+
lines.pop(0)
|
|
33
|
+
while lines and not lines[-1].strip():
|
|
34
|
+
lines.pop()
|
|
35
|
+
|
|
36
|
+
return "\n".join(lines)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Commands that can WRITE to the FC — require explicit confirmation.
|
|
40
|
+
# Conservative: anything not explicitly read-only that matches a prefix below.
|
|
41
|
+
_WRITE_PREFIXES: tuple[str, ...] = (
|
|
42
|
+
"set ",
|
|
43
|
+
"save",
|
|
44
|
+
"defaults",
|
|
45
|
+
"mixer ",
|
|
46
|
+
"motor ",
|
|
47
|
+
"aux ",
|
|
48
|
+
"feature ",
|
|
49
|
+
"map ",
|
|
50
|
+
"smix ",
|
|
51
|
+
"servo ",
|
|
52
|
+
"resource ",
|
|
53
|
+
"led ",
|
|
54
|
+
"color ",
|
|
55
|
+
"mode_color ",
|
|
56
|
+
"gpspassthrough",
|
|
57
|
+
"adjrange ",
|
|
58
|
+
"rxrange ",
|
|
59
|
+
"vtx ",
|
|
60
|
+
"beacon ",
|
|
61
|
+
"batch ",
|
|
62
|
+
"profile ",
|
|
63
|
+
"rateprofile ",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
_READ_PREFIXES: tuple[str, ...] = (
|
|
67
|
+
"diff",
|
|
68
|
+
"get ",
|
|
69
|
+
"get\r",
|
|
70
|
+
"status",
|
|
71
|
+
"tasks",
|
|
72
|
+
"dump",
|
|
73
|
+
"help",
|
|
74
|
+
"version",
|
|
75
|
+
"boards",
|
|
76
|
+
"exit",
|
|
77
|
+
"# ", # comment/noop
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Lines in a saved 'diff all' / 'dump' that manage batch/save/reboot themselves.
|
|
82
|
+
# When REPLAYING a backup we drive the single CLI session (and its save+reboot)
|
|
83
|
+
# ourselves, so these must be skipped — a mid-replay `save` would reboot the FC
|
|
84
|
+
# and abort the rest of the session, and `batch start` without our control would
|
|
85
|
+
# defer commits. `defaults noreboot` is intentionally NOT skipped: it's the clean
|
|
86
|
+
# base a diff is meant to be applied onto.
|
|
87
|
+
_REPLAY_SKIP: frozenset[str] = frozenset(
|
|
88
|
+
{"batch start", "batch end", "save", "exit", "diff", "diff all", "dump", "dump all"}
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_replayable(cmd: str) -> bool:
|
|
93
|
+
"""False for backup-file lines that manage their own batch/save/reboot."""
|
|
94
|
+
return cmd.strip().lower() not in _REPLAY_SKIP
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_get_output(text: str) -> dict[str, str]:
|
|
98
|
+
"""Parse the CLI `get <prefix>` output into a {name: value} dict.
|
|
99
|
+
|
|
100
|
+
iNAV prints one `name = value` line per matching setting. Lines that are
|
|
101
|
+
comments, errors, or lack '=' are skipped.
|
|
102
|
+
"""
|
|
103
|
+
settings: dict[str, str] = {}
|
|
104
|
+
for line in text.replace("\r", "\n").split("\n"):
|
|
105
|
+
s = line.strip()
|
|
106
|
+
if not s or s.startswith("#") or s.startswith("###") or "=" not in s:
|
|
107
|
+
continue
|
|
108
|
+
name, _, value = s.partition("=")
|
|
109
|
+
name = name.strip()
|
|
110
|
+
value = value.strip()
|
|
111
|
+
if name and " " not in name: # a setting name is a single token
|
|
112
|
+
settings[name] = value
|
|
113
|
+
return settings
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cli_error(raw: str) -> str | None:
|
|
117
|
+
"""Return the CLI error text if the response indicates a rejected command.
|
|
118
|
+
|
|
119
|
+
iNAV prints an '### ERROR: ...' line (e.g. 'Invalid name', 'Invalid value')
|
|
120
|
+
when a command or setting name/value is rejected. A normal response never
|
|
121
|
+
starts a line with '###', so that marker is a reliable rejection signal.
|
|
122
|
+
Without this, a silently-rejected write (e.g. an unknown `set` variable) would
|
|
123
|
+
look successful because the serial read itself didn't time out.
|
|
124
|
+
"""
|
|
125
|
+
for line in raw.replace("\r", "\n").split("\n"):
|
|
126
|
+
s = line.strip()
|
|
127
|
+
if s.startswith("###"):
|
|
128
|
+
return s
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def is_write_command(cmd: str) -> bool:
|
|
133
|
+
"""Return True if the CLI command looks like a write operation."""
|
|
134
|
+
c = cmd.strip().lower()
|
|
135
|
+
# Explicit read-only list takes priority
|
|
136
|
+
if any(c.startswith(p) or c == p.rstrip() for p in _READ_PREFIXES):
|
|
137
|
+
return False
|
|
138
|
+
return any(c.startswith(p) for p in _WRITE_PREFIXES)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Commands that drive the CLI session lifecycle itself (commit/leave/reboot). A
|
|
142
|
+
# batched CLI session manages save/exit on its own, so these must not appear in a
|
|
143
|
+
# user-supplied batch — a mid-batch `save`/`exit`/`batch end` would reboot or
|
|
144
|
+
# commit early and break the one-reboot-per-batch guarantee.
|
|
145
|
+
_SESSION_CONTROL_VERBS: frozenset[str] = frozenset({"save", "exit", "batch"})
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def is_session_control_command(cmd: str) -> bool:
|
|
149
|
+
"""Return True for save/exit/batch — commands the batch runner owns itself."""
|
|
150
|
+
tokens = cmd.strip().lower().split()
|
|
151
|
+
return bool(tokens) and tokens[0] in _SESSION_CONTROL_VERBS
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# Commands that drive a LIVE motor output the instant they run — `motor <index>
|
|
155
|
+
# <value>` overrides a motor and spins it immediately while in CLI. These are
|
|
156
|
+
# momentary bench tests, NOT persistent config: they gate on props-off (§10.1)
|
|
157
|
+
# and must never be SAVEd (saving a momentary value is wrong, and 'save' reboots
|
|
158
|
+
# the FC mid-test). Bare read forms — `motor` / `motor <index>`, which only PRINT
|
|
159
|
+
# values — do not match, so reading stays free. NOTE: `servo ...` in the CLI is a
|
|
160
|
+
# persistent servo-config write (min/max/middle/rate), not a live output, so it
|
|
161
|
+
# stays on the normal write path and is NOT gated here.
|
|
162
|
+
_ACTUATOR_VERBS: frozenset[str] = frozenset({"motor"})
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def is_actuator_command(cmd: str) -> bool:
|
|
166
|
+
"""Return True if the command drives a live motor output (can spin a prop).
|
|
167
|
+
|
|
168
|
+
Matches `motor <index> <value>` (verb + index + value). Read forms — `motor`
|
|
169
|
+
or `motor <index>`, which only print values — do not match. Enforces the
|
|
170
|
+
props-off gate: such commands require props_removed=True, refuse while armed,
|
|
171
|
+
and are never saved.
|
|
172
|
+
"""
|
|
173
|
+
tokens = cmd.strip().lower().split()
|
|
174
|
+
return len(tokens) >= 3 and tokens[0] in _ACTUATOR_VERBS
|
inav_mcp/connection.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""SerialConnection — the single serial handle, shared between MSP and CLI modes.
|
|
2
|
+
|
|
3
|
+
One instance at a time; the singleton lives in state.py.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import struct
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import serial
|
|
10
|
+
|
|
11
|
+
from .msp import encode_v1, decode_v1_response, encode_v2, decode_v2_response
|
|
12
|
+
from .cli import strip_cli_response
|
|
13
|
+
|
|
14
|
+
# Max bytes we'll buffer while waiting for the CLI prompt before giving up.
|
|
15
|
+
_CLI_MAX_BYTES = 128 * 1024 # 128 KB is plenty for a full `dump all`
|
|
16
|
+
|
|
17
|
+
# Substrings that mark a USB-serial / CDC device worth probing for an FC after a
|
|
18
|
+
# reboot (kept in sync with server._FC_PORT_HINTS — duplicated here to avoid a
|
|
19
|
+
# circular import: server imports connection, not the other way round).
|
|
20
|
+
_FC_SERIAL_HINTS = ("USB", "STM", "CP210", "CH340", "CDC", "VCP", "ACM", "SERIAL")
|
|
21
|
+
|
|
22
|
+
# Substrings/USB-PID text that mark an STM32 sitting in DFU (bootloader) mode.
|
|
23
|
+
# The iNAV USB VCP is VID:PID 0483:5740; the DFU bootloader is 0483:DF11 and
|
|
24
|
+
# usually shows up as "STM32 BOOTLOADER" / "DFU in FS Mode". A board in DFU has
|
|
25
|
+
# dropped off USB-serial entirely — MSP can't reach it and it needs a power-cycle.
|
|
26
|
+
_DFU_HINTS = ("BOOTLOADER", "DFU", "DF11")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _enumerate_ports() -> list[dict]:
|
|
30
|
+
"""Enumerate serial ports as plain dicts. Isolated for testability."""
|
|
31
|
+
from serial.tools.list_ports import comports
|
|
32
|
+
return [
|
|
33
|
+
{"device": p.device, "description": p.description or "", "hwid": p.hwid or ""}
|
|
34
|
+
for p in comports()
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def port_in_dfu(ports: list[dict]) -> dict | None:
|
|
39
|
+
"""Return the first port that looks like an STM32 in DFU/bootloader mode, else None.
|
|
40
|
+
|
|
41
|
+
`ports` is a list of {"device", "description", "hwid"} dicts (as from
|
|
42
|
+
_enumerate_ports). A match means the FC re-enumerated as a bootloader device
|
|
43
|
+
instead of coming back as its USB-serial port — the user must power-cycle it.
|
|
44
|
+
"""
|
|
45
|
+
for p in ports:
|
|
46
|
+
blob = f"{p.get('description', '')} {p.get('hwid', '')}".upper()
|
|
47
|
+
if any(h in blob for h in _DFU_HINTS):
|
|
48
|
+
return p
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fc_serial_candidates(ports: list[dict], original_port: str) -> list[str]:
|
|
53
|
+
"""Ordered list of ports to retry after a reboot: original first, then any other
|
|
54
|
+
USB-serial-looking device the FC may have re-enumerated as.
|
|
55
|
+
|
|
56
|
+
DFU/bootloader devices are excluded — they don't speak MSP. Pure function
|
|
57
|
+
(ports injected) so the re-enumeration logic is unit-testable offline.
|
|
58
|
+
"""
|
|
59
|
+
candidates = [original_port]
|
|
60
|
+
for p in ports:
|
|
61
|
+
dev = p.get("device")
|
|
62
|
+
if not dev or dev == original_port:
|
|
63
|
+
continue
|
|
64
|
+
blob = f"{p.get('description', '')} {p.get('hwid', '')}".upper()
|
|
65
|
+
if any(h in blob for h in _DFU_HINTS):
|
|
66
|
+
continue # bootloader device — can't MSP it
|
|
67
|
+
if any(h in blob for h in _FC_SERIAL_HINTS):
|
|
68
|
+
candidates.append(dev)
|
|
69
|
+
return candidates
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SerialConnection:
|
|
73
|
+
def __init__(self, port: str, baud: int = 115200) -> None:
|
|
74
|
+
self.port = port
|
|
75
|
+
self.baud = baud
|
|
76
|
+
self._ser: serial.Serial | None = None
|
|
77
|
+
self.mode: str = "IDLE" # IDLE | MSP | CLI
|
|
78
|
+
self.stale: bool = False # True after save/reboot — must reconnect
|
|
79
|
+
|
|
80
|
+
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
def open(self) -> None:
|
|
83
|
+
if self._ser and self._ser.is_open:
|
|
84
|
+
return
|
|
85
|
+
self._ser = serial.Serial(
|
|
86
|
+
port=self.port,
|
|
87
|
+
baudrate=self.baud,
|
|
88
|
+
timeout=1.0, # per-read timeout; frame assembly adds its own deadline
|
|
89
|
+
write_timeout=2.0,
|
|
90
|
+
)
|
|
91
|
+
self.mode = "MSP"
|
|
92
|
+
self.stale = False
|
|
93
|
+
time.sleep(0.15) # let the VCP enumerate
|
|
94
|
+
self._ser.reset_input_buffer()
|
|
95
|
+
|
|
96
|
+
def close(self) -> None:
|
|
97
|
+
if self._ser and self._ser.is_open:
|
|
98
|
+
self._ser.close()
|
|
99
|
+
self._ser = None
|
|
100
|
+
self.mode = "IDLE"
|
|
101
|
+
|
|
102
|
+
def _try_msp_handshake(self, device: str) -> bool:
|
|
103
|
+
"""Open `device` and probe MSP_API_VERSION once. On success, leave the handle
|
|
104
|
+
open and MSP-ready and return True; otherwise close it and return False.
|
|
105
|
+
"""
|
|
106
|
+
from .msp import MSP_API_VERSION
|
|
107
|
+
try:
|
|
108
|
+
self._ser = serial.Serial(
|
|
109
|
+
port=device, baudrate=self.baud,
|
|
110
|
+
timeout=0.5, write_timeout=1.0,
|
|
111
|
+
)
|
|
112
|
+
self.mode = "MSP"
|
|
113
|
+
self.stale = False
|
|
114
|
+
time.sleep(0.1)
|
|
115
|
+
self._ser.reset_input_buffer()
|
|
116
|
+
self._ser.write(encode_v1(MSP_API_VERSION))
|
|
117
|
+
time.sleep(0.2)
|
|
118
|
+
frame = self._read_msp_frame(timeout=0.8)
|
|
119
|
+
decode_v1_response(frame)
|
|
120
|
+
return True
|
|
121
|
+
except Exception:
|
|
122
|
+
if self._ser and self._ser.is_open:
|
|
123
|
+
try:
|
|
124
|
+
self._ser.close()
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
self._ser = None
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
def reconnect(self, settle_timeout: float = 15.0, initial_settle: float = 1.0) -> float:
|
|
131
|
+
"""Close and reopen the port after an FC reboot, polling until MSP responds.
|
|
132
|
+
|
|
133
|
+
iNAV's CLI `exit`/`save` both reboot the FC, which drops the USB VCP and
|
|
134
|
+
re-enumerates it (~7s to answer MSP again). Call this after any CLI session
|
|
135
|
+
to restore a usable MSP connection. Returns the elapsed reconnect time in
|
|
136
|
+
seconds (so callers can surface/measure the per-reboot cost).
|
|
137
|
+
|
|
138
|
+
Hardening over a naive "reopen the same port" loop:
|
|
139
|
+
1. A short `initial_settle` before the first probe — hammering the port
|
|
140
|
+
while the MCU is still resetting wastes attempts, and rapid reconnect
|
|
141
|
+
churn is itself a suspected trigger for the board dropping into DFU.
|
|
142
|
+
2. Exponential-ish backoff between attempts instead of a flat 0.5s.
|
|
143
|
+
3. Re-enumeration awareness: if the original port doesn't return, scan
|
|
144
|
+
OTHER USB-serial ports (the FC may have come back as a different COM
|
|
145
|
+
device) and adopt the one that answers MSP — updating self.port.
|
|
146
|
+
4. DFU detection: if it gives up, distinguish "board is in the STM32
|
|
147
|
+
bootloader, power-cycle it" from a merely-slow board.
|
|
148
|
+
"""
|
|
149
|
+
original_port = self.port
|
|
150
|
+
self.close()
|
|
151
|
+
t0 = time.monotonic()
|
|
152
|
+
time.sleep(initial_settle)
|
|
153
|
+
|
|
154
|
+
deadline = t0 + settle_timeout
|
|
155
|
+
# Only fan out to other ports once the original is clearly not coming back,
|
|
156
|
+
# to keep the common case (same port returns) fast.
|
|
157
|
+
scan_others_after = time.monotonic() + min(5.0, settle_timeout / 2)
|
|
158
|
+
backoff = 0.5
|
|
159
|
+
while time.monotonic() < deadline:
|
|
160
|
+
if self._try_msp_handshake(original_port):
|
|
161
|
+
self.port = original_port
|
|
162
|
+
return time.monotonic() - t0
|
|
163
|
+
|
|
164
|
+
if time.monotonic() >= scan_others_after:
|
|
165
|
+
for cand in fc_serial_candidates(_enumerate_ports(), original_port):
|
|
166
|
+
if cand == original_port:
|
|
167
|
+
continue
|
|
168
|
+
if self._try_msp_handshake(cand):
|
|
169
|
+
self.port = cand
|
|
170
|
+
return time.monotonic() - t0
|
|
171
|
+
|
|
172
|
+
time.sleep(backoff)
|
|
173
|
+
backoff = min(backoff * 1.5, 2.0)
|
|
174
|
+
|
|
175
|
+
# Gave up — give the caller an actionable reason.
|
|
176
|
+
self.mode = "IDLE"
|
|
177
|
+
dfu = port_in_dfu(_enumerate_ports())
|
|
178
|
+
if dfu:
|
|
179
|
+
raise ConnectionError(
|
|
180
|
+
f"FC did not return on USB-serial and a device is in STM32 "
|
|
181
|
+
f"DFU/bootloader mode ({dfu['device']}: {dfu['description'] or 'STM32 BOOTLOADER'}). "
|
|
182
|
+
"The board dropped into its bootloader — POWER-CYCLE it (unplug/replug "
|
|
183
|
+
"USB), then reconnect. Rapid back-to-back CLI reboots can trigger this; "
|
|
184
|
+
"batch commands with cli_batch() to cut reboot churn."
|
|
185
|
+
)
|
|
186
|
+
raise TimeoutError(
|
|
187
|
+
f"FC did not respond to MSP within {settle_timeout:.0f}s after reboot "
|
|
188
|
+
f"(original port {original_port}). It may need more time, re-enumerated as "
|
|
189
|
+
"a different COM port, or dropped into DFU/bootloader mode — check for an "
|
|
190
|
+
"'STM32 BOOTLOADER' device and power-cycle the board if so."
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def is_open(self) -> bool:
|
|
194
|
+
return (
|
|
195
|
+
self._ser is not None
|
|
196
|
+
and self._ser.is_open
|
|
197
|
+
and not self.stale
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def mark_stale(self) -> None:
|
|
201
|
+
"""Call after save/reboot. Connection is invalid until reconnect."""
|
|
202
|
+
self.stale = True
|
|
203
|
+
|
|
204
|
+
# ── MSP frame I/O ─────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
def _read_msp_frame(self, timeout: float = 2.0) -> bytes:
|
|
207
|
+
"""Scan the byte stream for one complete MSP v1 or v2 response frame."""
|
|
208
|
+
if self._ser is None:
|
|
209
|
+
raise ConnectionError("Serial port not open")
|
|
210
|
+
deadline = time.monotonic() + timeout
|
|
211
|
+
|
|
212
|
+
while time.monotonic() < deadline:
|
|
213
|
+
b = self._ser.read(1)
|
|
214
|
+
if not b or b != b"$":
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
proto = self._ser.read(1)
|
|
218
|
+
if not proto:
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
if proto == b"M":
|
|
222
|
+
direction = self._ser.read(1)
|
|
223
|
+
if direction not in (b">", b"!"):
|
|
224
|
+
continue
|
|
225
|
+
size_b = self._ser.read(1)
|
|
226
|
+
cmd_b = self._ser.read(1)
|
|
227
|
+
if len(size_b) < 1 or len(cmd_b) < 1:
|
|
228
|
+
continue
|
|
229
|
+
size = size_b[0]
|
|
230
|
+
rest = self._ser.read(size + 1) # payload + checksum
|
|
231
|
+
if len(rest) < size + 1:
|
|
232
|
+
continue
|
|
233
|
+
return b"$M" + direction + size_b + cmd_b + rest
|
|
234
|
+
|
|
235
|
+
elif proto == b"X":
|
|
236
|
+
direction = self._ser.read(1)
|
|
237
|
+
if direction not in (b">", b"!"):
|
|
238
|
+
continue
|
|
239
|
+
header = self._ser.read(5) # flag(1) + function(2le) + length(2le)
|
|
240
|
+
if len(header) < 5:
|
|
241
|
+
continue
|
|
242
|
+
length = struct.unpack_from("<H", header, 3)[0]
|
|
243
|
+
rest = self._ser.read(length + 1) # payload + CRC
|
|
244
|
+
if len(rest) < length + 1:
|
|
245
|
+
continue
|
|
246
|
+
return b"$X" + direction + header + rest
|
|
247
|
+
|
|
248
|
+
raise TimeoutError(f"No MSP frame received within {timeout:.1f}s")
|
|
249
|
+
|
|
250
|
+
def send_msp_v1(self, cmd: int, payload: bytes = b"", timeout: float = 2.0) -> bytes:
|
|
251
|
+
"""Send an MSP v1 request and return the response payload.
|
|
252
|
+
|
|
253
|
+
Retries until timeout, accepting only the frame that matches our cmd.
|
|
254
|
+
"""
|
|
255
|
+
if not self.is_open():
|
|
256
|
+
raise ConnectionError(
|
|
257
|
+
"Not connected to FC. Call connect(port) first, "
|
|
258
|
+
"or reconnect after a save/reboot."
|
|
259
|
+
)
|
|
260
|
+
if self.mode == "CLI":
|
|
261
|
+
raise ConnectionError("Currently in CLI mode. Exit CLI before sending MSP.")
|
|
262
|
+
|
|
263
|
+
req = encode_v1(cmd, payload)
|
|
264
|
+
self._ser.reset_input_buffer()
|
|
265
|
+
self._ser.write(req)
|
|
266
|
+
|
|
267
|
+
deadline = time.monotonic() + timeout
|
|
268
|
+
while time.monotonic() < deadline:
|
|
269
|
+
remaining = deadline - time.monotonic()
|
|
270
|
+
if remaining <= 0:
|
|
271
|
+
break
|
|
272
|
+
try:
|
|
273
|
+
frame = self._read_msp_frame(timeout=min(1.0, remaining))
|
|
274
|
+
except TimeoutError:
|
|
275
|
+
break
|
|
276
|
+
try:
|
|
277
|
+
resp_cmd, resp_payload = decode_v1_response(frame)
|
|
278
|
+
except (ValueError, RuntimeError):
|
|
279
|
+
continue
|
|
280
|
+
if resp_cmd == cmd:
|
|
281
|
+
return resp_payload
|
|
282
|
+
|
|
283
|
+
raise TimeoutError(
|
|
284
|
+
f"No valid MSP v1 response to cmd {cmd} within {timeout:.1f}s"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def send_msp_v2(self, cmd: int, payload: bytes = b"", timeout: float = 2.0) -> bytes:
|
|
288
|
+
"""Send an MSP v2 request and return the response payload.
|
|
289
|
+
|
|
290
|
+
Needed for iNAV-specific commands (cmd >= 0x1000) like MSPV2_INAV_STATUS,
|
|
291
|
+
which the FC answers with a $X> frame. Retries until timeout, accepting only
|
|
292
|
+
the frame matching our cmd.
|
|
293
|
+
"""
|
|
294
|
+
if not self.is_open():
|
|
295
|
+
raise ConnectionError(
|
|
296
|
+
"Not connected to FC. Call connect(port) first, "
|
|
297
|
+
"or reconnect after a save/reboot."
|
|
298
|
+
)
|
|
299
|
+
if self.mode == "CLI":
|
|
300
|
+
raise ConnectionError("Currently in CLI mode. Exit CLI before sending MSP.")
|
|
301
|
+
|
|
302
|
+
req = encode_v2(cmd, payload)
|
|
303
|
+
self._ser.reset_input_buffer()
|
|
304
|
+
self._ser.write(req)
|
|
305
|
+
|
|
306
|
+
deadline = time.monotonic() + timeout
|
|
307
|
+
while time.monotonic() < deadline:
|
|
308
|
+
remaining = deadline - time.monotonic()
|
|
309
|
+
if remaining <= 0:
|
|
310
|
+
break
|
|
311
|
+
try:
|
|
312
|
+
frame = self._read_msp_frame(timeout=min(1.0, remaining))
|
|
313
|
+
except TimeoutError:
|
|
314
|
+
break
|
|
315
|
+
try:
|
|
316
|
+
resp_cmd, resp_payload = decode_v2_response(frame)
|
|
317
|
+
except (ValueError, RuntimeError):
|
|
318
|
+
continue
|
|
319
|
+
if resp_cmd == cmd:
|
|
320
|
+
return resp_payload
|
|
321
|
+
|
|
322
|
+
raise TimeoutError(
|
|
323
|
+
f"No valid MSP v2 response to cmd {cmd} within {timeout:.1f}s"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# ── CLI mode ──────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
def _read_until_prompt(self, timeout: float = 5.0) -> str:
|
|
329
|
+
"""Read bytes from the serial port until the CLI prompt '# ' appears.
|
|
330
|
+
|
|
331
|
+
Returns the full raw string including the prompt.
|
|
332
|
+
Raises TimeoutError if the prompt doesn't arrive within `timeout` seconds.
|
|
333
|
+
"""
|
|
334
|
+
if self._ser is None:
|
|
335
|
+
raise ConnectionError("Serial port not open")
|
|
336
|
+
deadline = time.monotonic() + timeout
|
|
337
|
+
buf = bytearray()
|
|
338
|
+
|
|
339
|
+
while time.monotonic() < deadline:
|
|
340
|
+
chunk = self._ser.read(64)
|
|
341
|
+
if not chunk:
|
|
342
|
+
continue
|
|
343
|
+
buf.extend(chunk)
|
|
344
|
+
if len(buf) > _CLI_MAX_BYTES:
|
|
345
|
+
raise RuntimeError(
|
|
346
|
+
f"CLI response exceeded {_CLI_MAX_BYTES // 1024} KB — "
|
|
347
|
+
"possible runaway output or wrong baud rate"
|
|
348
|
+
)
|
|
349
|
+
if buf.endswith(b"# "):
|
|
350
|
+
return buf.decode("utf-8", errors="replace")
|
|
351
|
+
|
|
352
|
+
tail = bytes(buf[-300:])
|
|
353
|
+
raise TimeoutError(
|
|
354
|
+
f"CLI prompt '# ' not received within {timeout:.1f}s. "
|
|
355
|
+
f"Last bytes: {tail!r}"
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def _drain(self, timeout: float = 1.0) -> None:
|
|
359
|
+
"""Read and discard bytes until the serial stream goes quiet."""
|
|
360
|
+
if self._ser is None:
|
|
361
|
+
return
|
|
362
|
+
deadline = time.monotonic() + timeout
|
|
363
|
+
while time.monotonic() < deadline:
|
|
364
|
+
chunk = self._ser.read(64)
|
|
365
|
+
if not chunk:
|
|
366
|
+
break
|
|
367
|
+
|
|
368
|
+
def enter_cli(self, timeout: float = 5.0) -> str:
|
|
369
|
+
"""Switch to CLI mode.
|
|
370
|
+
|
|
371
|
+
Sends '#\\r' to trigger the FC CLI banner, waits for the '# ' prompt.
|
|
372
|
+
Returns the banner text (informational).
|
|
373
|
+
|
|
374
|
+
The spec notes this handshake may be firmware-picky; empirically verify
|
|
375
|
+
line endings on first run (§4, §12).
|
|
376
|
+
"""
|
|
377
|
+
if not self.is_open():
|
|
378
|
+
raise ConnectionError("Not connected. Call connect() first.")
|
|
379
|
+
if self.mode == "CLI":
|
|
380
|
+
return "" # already there
|
|
381
|
+
|
|
382
|
+
self._ser.reset_input_buffer()
|
|
383
|
+
self._ser.write(b"#\r")
|
|
384
|
+
banner = self._read_until_prompt(timeout=timeout)
|
|
385
|
+
self.mode = "CLI"
|
|
386
|
+
return banner
|
|
387
|
+
|
|
388
|
+
def run_cli(self, cmd: str, timeout: float = 15.0) -> str:
|
|
389
|
+
"""Send one CLI command and return its cleaned output.
|
|
390
|
+
|
|
391
|
+
Strips the echoed command header and the trailing '# ' prompt.
|
|
392
|
+
`timeout` should be generous for slow commands like 'dump all'.
|
|
393
|
+
"""
|
|
394
|
+
if self.mode != "CLI":
|
|
395
|
+
raise ConnectionError("Not in CLI mode. Call enter_cli() first.")
|
|
396
|
+
self._ser.write((cmd + "\r").encode("ascii"))
|
|
397
|
+
raw = self._read_until_prompt(timeout=timeout)
|
|
398
|
+
return strip_cli_response(cmd, raw)
|
|
399
|
+
|
|
400
|
+
def exit_cli(self, save: bool = False, reconnect: bool = False) -> float | None:
|
|
401
|
+
"""Leave CLI mode. On iNAV, BOTH paths reboot the FC.
|
|
402
|
+
|
|
403
|
+
save=False → 'exit' : discards unsaved changes, then reboots.
|
|
404
|
+
save=True → 'save' : persists to EEPROM, then reboots.
|
|
405
|
+
|
|
406
|
+
Because the FC reboots and the USB VCP re-enumerates, the current handle
|
|
407
|
+
becomes invalid. The connection is marked stale. If reconnect=True, we poll
|
|
408
|
+
the port back to a usable MSP state (~7s) and return the elapsed reconnect
|
|
409
|
+
seconds (the measured reboot cost); otherwise the caller must reconnect()
|
|
410
|
+
before further use and None is returned.
|
|
411
|
+
|
|
412
|
+
NOTE: 'exit' DISCARDS any `set`/`aux`/etc. changes made in this session —
|
|
413
|
+
only `save` makes CLI writes stick.
|
|
414
|
+
"""
|
|
415
|
+
if self.mode != "CLI":
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
cmd = b"save\r" if save else b"exit\r"
|
|
419
|
+
try:
|
|
420
|
+
self._ser.write(cmd)
|
|
421
|
+
except Exception:
|
|
422
|
+
pass # FC may drop the link mid-write as it reboots
|
|
423
|
+
self.mode = "MSP"
|
|
424
|
+
self.mark_stale()
|
|
425
|
+
time.sleep(0.3) # let the reboot begin
|
|
426
|
+
|
|
427
|
+
if reconnect:
|
|
428
|
+
return self.reconnect()
|
|
429
|
+
return None
|
|
File without changes
|