312 lines
14 KiB
Python
312 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Identity-bound Windows USB <-> binary stdio, and a WSL pipe transport.
|
|
|
|
No commands, resets, listening sockets, drivers or USB assignments are changed.
|
|
Only the selected COM handle is opened. Child stdout is exclusively raw bytes;
|
|
stderr carries at most a ready record and a final, payload-free status record.
|
|
The Windows supervisor closes USB on stdin EOF or its own deadline, including
|
|
when its daemon stdout pump is blocked by a stopped Linux consumer.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import select
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from urllib.parse import urlparse
|
|
|
|
from serial_session import open_configured_session
|
|
|
|
PROTOCOL = "meshcore-usb-stdio-1"
|
|
MAX_STATUS_BYTES = 4096
|
|
|
|
|
|
def validate_windows_endpoint(endpoint):
|
|
parsed = urlparse(endpoint)
|
|
if parsed.scheme != "winusb" or not re.fullmatch(r"COM[1-9][0-9]{0,3}", parsed.netloc, re.I) \
|
|
or parsed.path or parsed.query or parsed.fragment:
|
|
raise ValueError("use an explicit winusb://COM6 endpoint without other URL fields")
|
|
return parsed
|
|
|
|
|
|
def validate_settings(port, expected_serial, expected_vid, expected_pid, baud, serial_dtr, duration):
|
|
if not re.fullmatch(r"COM[1-9][0-9]{0,3}", port, re.I):
|
|
raise ValueError("invalid selected COM port")
|
|
if not isinstance(expected_serial, str) or not re.fullmatch(r"[A-Za-z0-9_.:+-]{1,128}", expected_serial):
|
|
raise ValueError("an exact nonempty USB serial is required")
|
|
if any(type(value) is not int or not 1 <= value <= 0xffff for value in (expected_vid, expected_pid)):
|
|
raise ValueError("exact USB VID and PID are required")
|
|
if type(baud) is not int or not 1200 <= baud <= 2000000 or type(serial_dtr) is not bool:
|
|
raise ValueError("invalid baud or explicit DTR selection")
|
|
if not isinstance(duration, (int, float)) or not math.isfinite(duration) or not 0 < duration <= 3600:
|
|
raise ValueError("a finite USB ownership deadline in (0,3600] is required")
|
|
|
|
|
|
def selected_identity(port, expected_serial, expected_vid, expected_pid, inventory):
|
|
matches = [item for item in inventory() if item.device.upper() == port.upper()]
|
|
if len(matches) != 1:
|
|
raise ValueError("selected COM identity is missing or ambiguous")
|
|
item = matches[0]
|
|
identity = dict(port=port.upper(), serial=item.serial_number, vid=item.vid, pid=item.pid)
|
|
if (identity["serial"], identity["vid"], identity["pid"]) != (expected_serial, expected_vid, expected_pid):
|
|
raise ValueError("selected COM USB identity does not match")
|
|
return identity
|
|
|
|
|
|
def open_selected_port(port, expected_serial, expected_vid, expected_pid, *, baud, serial_dtr,
|
|
inventory, serial_factory):
|
|
"""Verify twice; Windows CreateFile/pySerial opens the handle exclusively."""
|
|
before = selected_identity(port, expected_serial, expected_vid, expected_pid, inventory)
|
|
device = serial_factory()
|
|
try:
|
|
device.port, device.baudrate = port.upper(), baud
|
|
device.timeout, device.write_timeout = .05, .5
|
|
device.exclusive = True
|
|
device.xonxoff = device.rtscts = device.dsrdtr = False
|
|
device.dtr, device.rts = serial_dtr, False
|
|
open_configured_session(device)
|
|
after = selected_identity(port, expected_serial, expected_vid, expected_pid, inventory)
|
|
if before != after:
|
|
raise ValueError("selected USB identity changed during open")
|
|
return device, dict(after, baud=baud, serial_dtr=serial_dtr)
|
|
except BaseException:
|
|
device.close()
|
|
raise
|
|
|
|
|
|
def pump_stdio(device, input_fd, output_fd, *, duration, read=os.read, write=os.write,
|
|
now=time.monotonic):
|
|
"""Bounded USB ownership; daemon workers cannot hold COM past supervision."""
|
|
stopping = threading.Event()
|
|
counters = {"to_usb": 0, "from_usb": 0}
|
|
reason = ["deadline"]
|
|
|
|
def failed(code):
|
|
reason[0] = code
|
|
stopping.set()
|
|
|
|
def to_usb():
|
|
try:
|
|
while not stopping.is_set():
|
|
data = read(input_fd, 4096) # os.read returns short pipe data immediately.
|
|
if not data:
|
|
failed("stdin_eof")
|
|
return
|
|
offset = 0
|
|
until = now() + 1
|
|
while offset < len(data) and not stopping.is_set():
|
|
count = device.write(data[offset:])
|
|
if not isinstance(count, int) or not 0 <= count <= len(data) - offset:
|
|
raise IOError("invalid serial write length")
|
|
if count:
|
|
offset += count
|
|
counters["to_usb"] += count
|
|
elif now() >= until:
|
|
raise TimeoutError("serial write deadline")
|
|
else:
|
|
stopping.wait(.005)
|
|
except Exception:
|
|
failed("input_or_usb_write_error")
|
|
|
|
def from_usb():
|
|
try:
|
|
while not stopping.is_set():
|
|
# A short interactive SSH packet must not wait to fill 4096
|
|
# bytes (or pay one serial timeout on every round trip).
|
|
data = device.read(min(device.in_waiting or 1, 4096))
|
|
offset = 0
|
|
while offset < len(data) and not stopping.is_set():
|
|
count = write(output_fd, data[offset:])
|
|
if not isinstance(count, int) or not 0 < count <= len(data) - offset:
|
|
raise IOError("invalid stdout write length")
|
|
offset += count
|
|
counters["from_usb"] += count
|
|
except Exception:
|
|
failed("usb_read_or_output_error")
|
|
|
|
workers = [threading.Thread(target=target, daemon=True) for target in (to_usb, from_usb)]
|
|
start = now()
|
|
try:
|
|
for worker in workers:
|
|
worker.start()
|
|
while not stopping.wait(min(.05, max(0, duration - (now() - start)))):
|
|
if now() - start >= duration:
|
|
break
|
|
finally:
|
|
stopping.set()
|
|
# The supervisor never joins a potentially blocked stdout writer first.
|
|
for method in ("cancel_read", "cancel_write"):
|
|
try:
|
|
getattr(device, method)()
|
|
except Exception:
|
|
pass
|
|
device.close()
|
|
for worker in workers:
|
|
worker.join(.1)
|
|
return dict(counters, reason=reason[0])
|
|
|
|
|
|
def _ready_record(process, timeout=10):
|
|
captured = bytearray()
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
ready, _, _ = select.select([process.stderr.fileno()], [], [], min(.1, max(0, deadline - time.monotonic())))
|
|
if not ready:
|
|
if process.poll() is not None:
|
|
break
|
|
continue
|
|
chunk = os.read(process.stderr.fileno(), 512)
|
|
if not chunk:
|
|
break
|
|
captured.extend(chunk)
|
|
if len(captured) > MAX_STATUS_BYTES:
|
|
raise RuntimeError("Windows USB status exceeded bounded limit")
|
|
if b"\n" in captured:
|
|
try:
|
|
value = json.loads(captured.split(b"\n", 1)[0])
|
|
except (ValueError, UnicodeError) as error:
|
|
raise RuntimeError("Invalid Windows USB status") from error
|
|
if not isinstance(value, dict) or value.get("state") != "ready" or value.get("protocol") != PROTOCOL:
|
|
raise RuntimeError("Windows USB bridge did not become ready")
|
|
return value
|
|
raise TimeoutError("Windows USB bridge ready deadline")
|
|
|
|
|
|
class WindowsUsbStream:
|
|
"""Linux select-compatible separate FDs backed by one owned Windows COM."""
|
|
def __init__(self, endpoint, *, python, expected_serial, expected_vid, expected_pid,
|
|
baud=115200, serial_dtr=False, duration=300):
|
|
self.process = None
|
|
parsed = validate_windows_endpoint(endpoint)
|
|
port = parsed.netloc.upper()
|
|
validate_settings(port, expected_serial, expected_vid, expected_pid, baud, serial_dtr, duration)
|
|
executable = Path(python or "")
|
|
if not executable.is_absolute() or executable.name.lower() != "python.exe" or not executable.is_file():
|
|
raise ValueError("use an existing absolute WSL path to the trusted Windows python.exe")
|
|
if not sys.platform.startswith("linux"):
|
|
raise ValueError("WindowsUsbStream is a WSL Linux transport")
|
|
script = subprocess.run(["wslpath", "-w", str(Path(__file__).resolve())],
|
|
check=True, capture_output=True, text=True, timeout=5).stdout.strip()
|
|
if not re.fullmatch(r"[A-Za-z]:\\[^\r\n\0]+\.py", script):
|
|
raise ValueError("trusted bridge must reside on an accessible Windows drive")
|
|
command = [str(executable), "-u", script, "--port", port,
|
|
"--expect-serial", expected_serial, "--expect-vid", str(expected_vid),
|
|
"--expect-pid", str(expected_pid), "--baud", str(baud),
|
|
"--serial-dtr", "on" if serial_dtr else "off", "--duration", str(duration)]
|
|
try:
|
|
self.process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE, bufsize=0, shell=False)
|
|
record = _ready_record(self.process)
|
|
expected = dict(port=port, serial=expected_serial, vid=expected_vid, pid=expected_pid,
|
|
baud=baud, serial_dtr=serial_dtr)
|
|
if record.get("identity") != expected:
|
|
raise ValueError("Windows bridge ready identity does not match")
|
|
self.identity = expected
|
|
self.read_fd, self.write_fd = self.process.stdout.fileno(), self.process.stdin.fileno()
|
|
self.fd = self.read_fd # Compatibility only; relays use separate read/write FDs.
|
|
os.set_blocking(self.read_fd, False)
|
|
os.set_blocking(self.write_fd, False)
|
|
except BaseException:
|
|
self.close()
|
|
raise
|
|
|
|
def read(self, count):
|
|
data = os.read(self.read_fd, count)
|
|
if not data:
|
|
raise ConnectionError("Windows USB bridge disconnected")
|
|
return data
|
|
|
|
def write(self, data):
|
|
return os.write(self.write_fd, data)
|
|
|
|
def close(self):
|
|
process, self.process = self.process, None
|
|
if process is None:
|
|
return
|
|
# EOF is an independent Windows-side stop signal, not a USB reset.
|
|
if process.stdin:
|
|
process.stdin.close()
|
|
try:
|
|
process.wait(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=1)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=1)
|
|
finally:
|
|
for pipe in (process.stdout, process.stderr):
|
|
if pipe:
|
|
pipe.close()
|
|
|
|
|
|
def add_windows_arguments(parser):
|
|
parser.add_argument("--windows-python", help="absolute WSL path to trusted Windows python.exe for winusb")
|
|
parser.add_argument("--expect-vid", type=lambda value: int(value, 0))
|
|
parser.add_argument("--expect-pid", type=lambda value: int(value, 0))
|
|
|
|
|
|
def validate_windows_options(parsed, *, python, expected_serial, expected_vid, expected_pid,
|
|
baud, serial_dtr, duration):
|
|
if parsed.scheme == "winusb":
|
|
validate_settings(parsed.netloc.upper(), expected_serial, expected_vid, expected_pid,
|
|
baud, serial_dtr, duration)
|
|
if not python:
|
|
raise ValueError("winusb requires --windows-python and exact --expect-serial/vid/pid")
|
|
elif python is not None or expected_vid is not None or expected_pid is not None:
|
|
raise ValueError("Windows USB options require winusb://COM6")
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--port", required=True)
|
|
parser.add_argument("--expect-serial", required=True)
|
|
parser.add_argument("--expect-vid", type=lambda value: int(value, 0), required=True)
|
|
parser.add_argument("--expect-pid", type=lambda value: int(value, 0), required=True)
|
|
parser.add_argument("--baud", type=int, default=115200)
|
|
parser.add_argument("--serial-dtr", choices=("off", "on"), default="off")
|
|
parser.add_argument("--duration", type=float, required=True)
|
|
args = parser.parse_args(argv)
|
|
device = None
|
|
|
|
def status(value):
|
|
# Exactly two small records maximum. Never include payloads/exceptions.
|
|
sys.stderr.write(json.dumps(dict(protocol=PROTOCOL, **value), sort_keys=True) + "\n")
|
|
sys.stderr.flush()
|
|
|
|
try:
|
|
if os.name != "nt":
|
|
raise ValueError("Windows child only")
|
|
validate_settings(args.port, args.expect_serial, args.expect_vid, args.expect_pid,
|
|
args.baud, args.serial_dtr == "on", args.duration)
|
|
import msvcrt
|
|
import serial
|
|
from serial.tools.list_ports import comports
|
|
msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
|
|
msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
|
|
device, identity = open_selected_port(args.port, args.expect_serial, args.expect_vid, args.expect_pid,
|
|
baud=args.baud, serial_dtr=args.serial_dtr == "on", inventory=comports, serial_factory=serial.Serial)
|
|
status(dict(state="ready", identity=identity))
|
|
stats = pump_stdio(device, sys.stdin.fileno(), sys.stdout.fileno(), duration=args.duration)
|
|
device = None # pump owns close, even on EOF/deadline.
|
|
status(dict(state="stopped", stats=stats))
|
|
return 0
|
|
except Exception:
|
|
status(dict(state="error", error="USB bridge stopped; check exact identity and exclusive ownership"))
|
|
return 1
|
|
finally:
|
|
if device:
|
|
device.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|