Files
MeshCore/tools/hil/serial_session.py
T

108 lines
4.5 KiB
Python

"""Open an already configured serial session without a bridge reset pulse."""
import os
import re
import shutil
import subprocess
import sys
import time
class SerialCommandTimeout(TimeoutError):
def __init__(self, partial_reply):
super().__init__("Timed out waiting for this command's exact echo and completed terminal prompt")
self.partial_reply = partial_reply
def _command_echoes(command):
echoes = [command]
# Match the firmware's intentional password masking, not a wildcard echo.
# Masked UTF-8 values occupy one '*' for each input byte on the device.
for pattern in (r"^(\s*login[ \t]+)(.+)$", r"^(\s*set[ \t]+wifi\.pwd[ \t]+)(.+)$",
r"^(\s*set[ \t]+mt\.channel[ \t]+\S+[ \t]+\S+[ \t]+)(.+)$"):
match = re.match(pattern, command)
if match:
echoes.append(match[1] + "*" * len(match[2].encode("utf-8")))
break
return echoes
def read_command_reply(port, command, timeout=10):
"""Wait for this exact echoed command and its completion prompt, not silence.
A settings save can pause for over 350 ms after the immediate input echo.
Returning at that silence would attribute the delayed OK to the next command.
Stale responses/prompts before this command's echo cannot finish the read.
"""
if not command or any(value in command for value in "\r\n\0") or timeout <= 0:
raise ValueError("one nonempty command and a positive timeout are required")
echoed = re.compile(r"(?:^|\r?\n)(?:> ?)?(?:" + "|".join(
re.escape(value) for value in _command_echoes(command)) + r")\r?\n")
# The real terminal writes "> ". Requiring its trailing space avoids
# returning on a fragmented '>' and leaving that space to poison next echo.
prompt = re.compile(r"(?:^|\r?\n)>[ \t]+(?=$|\r?\n)")
deadline = time.monotonic() + timeout
data = bytearray()
while time.monotonic() < deadline:
chunk = port.read(min(port.in_waiting or 1, 4096))
if not chunk:
continue
data.extend(chunk)
if len(data) > 65536:
raise RuntimeError("CLI transaction output exceeded the bounded capture limit")
text = data.decode("utf-8", errors="replace")
match = echoed.search(text)
if match and prompt.search(text[match.end():]):
return text
raise SerialCommandTimeout(data.decode("utf-8", errors="replace"))
def assert_port_idle(path):
"""Optional Linux preflight: never open/reset a port owned by another tool.
fuser does not open the serial device. This is an extra immediate check,
not a substitute for an exclusive open or coordination with the flasher.
"""
if not sys.platform.startswith("linux"):
return
inspector = shutil.which("fuser")
if not inspector:
return
try:
# Some Pi fuser implementations reject "--" with status 1, which is
# also their normal no-owner status. The resolved absolute path cannot
# be interpreted as an option, so no separator is needed.
result = subprocess.run([inspector, os.path.realpath(path)],
capture_output=True, text=True, timeout=3)
except (OSError, subprocess.TimeoutExpired) as error:
raise RuntimeError("Cannot safely inspect serial-port ownership") from error
if result.returncode == 0:
raise RuntimeError("Selected serial port is already in use; finish the flasher/client first")
if result.returncode != 1 or result.stdout or result.stderr:
raise RuntimeError("Cannot safely inspect serial-port ownership (fuser error)")
def open_configured_session(port):
# POSIX pySerial normally applies DTR before RTS when opening. Some
# bridge drivers initially assert both lines; clearing DTR first pulses
# ESP32 EN. Defer DTR until RTS is released. Windows configures both in
# one DCB operation and uses dsrdtr for real handshaking, so leave it alone.
# The caller selects DTR and supplies the already configured port; no USB
# inventory or pySerial import is needed here.
defer_dtr = os.name == "posix" and not port.dtr
previous_dsrdtr = port.dsrdtr
try:
if defer_dtr:
port.dsrdtr = True
port.open()
if defer_dtr:
port.rts = False
port.dtr = False
port.dsrdtr = previous_dsrdtr
except BaseException:
try:
port.close()
finally:
if defer_dtr:
port.dsrdtr = previous_dsrdtr
raise