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 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