Files
WiFiManager/tools/capture-serial.py
2026-09-20 19:44:05 +10:00

130 lines
4.2 KiB
Python

#!/usr/bin/env python3
"""Passively retain serial evidence for a physical portal OTA upload.
The recorder deliberately opens the ESP USB-UART with both modem-control lines
inactive. It never resets the target: the calling runner has already flashed
and booted fixture A before this process attaches.
"""
import argparse
import os
import signal
import sys
import time
import serial
_stop_requested = False
IMMEDIATE_EMPTY_READ_SECONDS = 0.01
EMPTY_READ_BACKOFF_INITIAL_SECONDS = 0.01
EMPTY_READ_BACKOFF_MAX_SECONDS = 0.25
def request_stop(_signum, _frame):
"""Let the read loop finish promptly after a normal runner cleanup."""
global _stop_requested
_stop_requested = True
def open_capture_port(port):
"""Open a UART without PySerial's default reset-causing line assertion."""
serial_port = serial.Serial(
port=None,
baudrate=115200,
timeout=0.25,
rtscts=False,
dsrdtr=False,
)
serial_port.dtr = False
serial_port.rts = False
serial_port.port = port
serial_port.open()
return serial_port
def write_ready(path):
"""Publish readiness only after the passive serial port is open."""
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.fchmod(descriptor, 0o600)
os.write(descriptor, b"ready\n")
finally:
os.close(descriptor)
def capture(
port,
output,
ready_file,
should_stop=None,
deadline_seconds=None,
clock=time.monotonic,
sleep=time.sleep,
):
"""Append serial bytes until terminated by the owning hardware runner."""
global _stop_requested
_stop_requested = False
if should_stop is None:
should_stop = lambda: _stop_requested
try:
with (
open_capture_port(port) as serial_port,
open(output, "ab", buffering=0) as output_file,
):
os.fchmod(output_file.fileno(), 0o600)
write_ready(ready_file)
deadline = None
if deadline_seconds is not None:
deadline = clock() + deadline_seconds
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
while not should_stop():
if deadline is not None and clock() >= deadline:
print("Passive serial capture reached its deadline.", file=sys.stderr)
return 2
read_started = clock()
data = serial_port.read(4096)
if data:
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
output_file.write(data)
continue
# A real serial port blocks for its configured timeout. A
# broken or detached backend can return an empty read
# immediately. Back off only in that pathological case, and
# reset after real data, so the recorder cannot become a
# CPU-bound loop without penalising normal serial timeouts.
if clock() - read_started < IMMEDIATE_EMPTY_READ_SECONDS:
sleep(empty_read_backoff)
empty_read_backoff = min(
empty_read_backoff * 2,
EMPTY_READ_BACKOFF_MAX_SECONDS,
)
else:
empty_read_backoff = EMPTY_READ_BACKOFF_INITIAL_SECONDS
except (OSError, serial.SerialException) as error:
print(f"Passive serial capture failed: {error}", file=sys.stderr)
return 1
return 0
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--port", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--ready-file", required=True)
parser.add_argument("--deadline-seconds", type=float)
args = parser.parse_args()
if args.deadline_seconds is not None and args.deadline_seconds <= 0:
parser.error("--deadline-seconds must be greater than zero")
signal.signal(signal.SIGTERM, request_stop)
signal.signal(signal.SIGINT, request_stop)
return capture(args.port, args.output, args.ready_file, deadline_seconds=args.deadline_seconds)
if __name__ == "__main__":
sys.exit(main())