258 lines
8.8 KiB
Python
Executable file
258 lines
8.8 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
|
|
# See LICENSE.md for license information.
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""serialLoopPeer.py - the host end of the serial loopback gates.
|
|
|
|
The guest runs SERTEST (examples/sertest): it sends a banner, then echoes
|
|
every byte it receives. This peer waits for the banner (the guest's
|
|
transmit works), then sends a probe line and expects it back (its receive
|
|
works too).
|
|
|
|
serialLoopPeer.py tcp-server PORT WALL [BANNER] listen; the emulator connects
|
|
serialLoopPeer.py tcp-client PORT WALL [BANNER] connect to the emulator
|
|
serialLoopPeer.py pty PATHFILE WALL [BANNER] make a raw pty pair, write the
|
|
guest end's path to PATHFILE
|
|
|
|
WALL is the wall-clock budget in seconds. Prints one LOOP PASS / LOOP FAIL
|
|
line; exit 0 on pass, 1 on fail.
|
|
|
|
--dtr-check (TCP, with DOSBox's nullmodem in transparent:0 mode, which puts
|
|
the guest's modem lines in the byte stream as they change: 0xFF 0xFF is a
|
|
data 0xFF, 0xFF <b> the lines, bit 0 RTS, bit 1 DTR) then runs SERTEST's
|
|
DTR check: it sends DTR_CHECK, acknowledges each of SERTEST's marker lines
|
|
(so SERTEST changes a line only once the marker before has gone out whole),
|
|
and reads the line states against the markers. jlSerialSetDtr(false) and
|
|
(true) must reach the line, closing and opening the port again must keep
|
|
DTR up, and after a hang-up (DTR dropped, then the port closed) DTR must
|
|
stay down for HANGUP_WATCH_SECONDS. Prints one DTR PASS / DTR FAIL line
|
|
more.
|
|
"""
|
|
|
|
import os
|
|
import select
|
|
import socket
|
|
import sys
|
|
import time
|
|
import tty
|
|
|
|
DEFAULT_BANNER = b"JLSERTEST-READY"
|
|
PROBE = b"PING12345"
|
|
RESEND_SECONDS = 3.0 # the probe is re-sent until it comes back
|
|
POLL_SECONDS = 0.5
|
|
CONNECT_RETRY_SECONDS = 0.5
|
|
READ_SIZE = 256
|
|
SHOWN_BYTES = 80
|
|
|
|
# SERTEST's DTR check (examples/sertest/sertest.c).
|
|
DTR_CHECK = b"\x04"
|
|
DTR_ACK = b"\x06"
|
|
MARKS = (("off", b"JLDTR-OFF\r\n"), ("on", b"JLDTR-ON\r\n"), ("reopened", b"JLDTR-REOPENED\r\n"), ("hangup", b"JLDTR-HANGUP\r\n"))
|
|
HANGUP_WATCH_SECONDS = 3.0
|
|
|
|
# DOSBox nullmodem transparent:0 framing.
|
|
LINE_ESCAPE = 0xFF
|
|
LINE_DTR = 0x02
|
|
|
|
|
|
class LineDecoder:
|
|
"""Splits a transparent:0 stream into data and the guest's DTR states,
|
|
each state kept with the count of data bytes that came before it."""
|
|
|
|
def __init__(self):
|
|
self.pending = b""
|
|
self.dataCount = 0
|
|
self.dtr = []
|
|
|
|
def feed(self, raw):
|
|
buf = self.pending + raw
|
|
data = bytearray()
|
|
i = 0
|
|
while i < len(buf):
|
|
if buf[i] != LINE_ESCAPE:
|
|
data.append(buf[i])
|
|
i += 1
|
|
continue
|
|
if i + 1 >= len(buf):
|
|
break
|
|
if buf[i + 1] == LINE_ESCAPE:
|
|
data.append(LINE_ESCAPE)
|
|
else:
|
|
self.dtr.append((self.dataCount + len(data), (buf[i + 1] & LINE_DTR) != 0))
|
|
i += 2
|
|
self.pending = buf[i:]
|
|
self.dataCount += len(data)
|
|
return bytes(data)
|
|
|
|
def dtrBefore(self, position):
|
|
"""DTR as it was when the data byte at position arrived (None: never reported)."""
|
|
state = None
|
|
for at, dtr in self.dtr:
|
|
if at > position:
|
|
break
|
|
state = dtr
|
|
return state
|
|
|
|
def dtrChanges(self, first, last, value):
|
|
"""Whether DTR was reported as value after data byte first, up to last."""
|
|
return any(first < at <= last and dtr == value for at, dtr in self.dtr)
|
|
|
|
|
|
def acceptOne(port, wall):
|
|
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
server.bind(("127.0.0.1", port))
|
|
server.listen(1)
|
|
server.settimeout(wall)
|
|
try:
|
|
conn, _ = server.accept()
|
|
except socket.timeout:
|
|
return None
|
|
return conn
|
|
|
|
|
|
def connectOne(port, wall, start):
|
|
while time.time() - start < wall:
|
|
try:
|
|
return socket.create_connection(("127.0.0.1", port), timeout=2)
|
|
except OSError:
|
|
time.sleep(CONNECT_RETRY_SECONDS)
|
|
return None
|
|
|
|
|
|
def openPty(pathFile):
|
|
master, slave = os.openpty()
|
|
tty.setraw(slave)
|
|
tty.setraw(master)
|
|
# Publish atomically: the gate polls for the file.
|
|
with open(pathFile + ".tmp", "w") as f:
|
|
f.write(os.ttyname(slave))
|
|
os.rename(pathFile + ".tmp", pathFile)
|
|
return master
|
|
|
|
|
|
def dtrCheck(send, receive, wall, start, lines, got):
|
|
# After the loopback: the stream so far is got; lines has decoded it.
|
|
base = len(got)
|
|
send(DTR_CHECK)
|
|
marks = {}
|
|
hangupAt = None
|
|
while time.time() - start < wall:
|
|
if hangupAt is not None and time.time() - hangupAt >= HANGUP_WATCH_SECONDS:
|
|
break
|
|
data = receive()
|
|
if data is None:
|
|
break
|
|
got += data
|
|
# Acknowledge each marker line as it completes; the last starts the
|
|
# watch.
|
|
while len(marks) < len(MARKS):
|
|
name, mark = MARKS[len(marks)]
|
|
at = got.find(mark, base)
|
|
if at < 0:
|
|
break
|
|
marks[name] = at
|
|
send(DTR_ACK)
|
|
if hangupAt is None and len(marks) == len(MARKS):
|
|
hangupAt = time.time()
|
|
if len(marks) < len(MARKS):
|
|
print("DTR FAIL: no %r from SERTEST; stream=%r" % (MARKS[len(marks)][1], got[base:base + SHOWN_BYTES]))
|
|
return 1
|
|
if time.time() - hangupAt < HANGUP_WATCH_SECONDS:
|
|
print("DTR FAIL: out of time before the watch after the hang-up ended")
|
|
return 1
|
|
fails = []
|
|
if lines.dtrBefore(marks["off"]) is not False:
|
|
fails.append("jlSerialSetDtr(false) did not drop DTR")
|
|
if lines.dtrBefore(marks["on"]) is not True:
|
|
fails.append("jlSerialSetDtr(true) did not raise DTR")
|
|
if lines.dtrChanges(marks["on"], marks["reopened"], False):
|
|
fails.append("closing and opening the port dropped DTR")
|
|
if lines.dtrBefore(marks["hangup"]) is not False:
|
|
fails.append("jlSerialSetDtr(false) after the reopen did not drop DTR")
|
|
if lines.dtrChanges(marks["hangup"], lines.dataCount, True):
|
|
fails.append("closing the port after the hang-up raised DTR")
|
|
if fails:
|
|
print("DTR FAIL: %s; DTR states (data byte, DTR) since the check: %r" % ("; ".join(fails), [x for x in lines.dtr if x[0] >= base]))
|
|
return 1
|
|
print("DTR PASS: SetDtr(false/true) reached the line, close+open kept DTR up, a hang-up stayed down %.0f s after the close" % HANGUP_WATCH_SECONDS)
|
|
return 0
|
|
|
|
|
|
def loop(send, receive, wall, start, banner):
|
|
# send(bytes); receive() -> bytes, b"" for "nothing yet", None at EOF.
|
|
# Returns the exit status and the stream.
|
|
got = b""
|
|
sentAt = None
|
|
lastSend = 0.0
|
|
while time.time() - start < wall:
|
|
if banner in got and time.time() - lastSend > RESEND_SECONDS:
|
|
send(PROBE + b"\r\n")
|
|
lastSend = time.time()
|
|
if sentAt is None:
|
|
sentAt = len(got)
|
|
data = receive()
|
|
if data is None:
|
|
break
|
|
got += data
|
|
if sentAt is not None and PROBE in got[sentAt:]:
|
|
print("LOOP PASS: banner %r seen, probe echoed; stream=%r" % (banner, got[:SHOWN_BYTES]))
|
|
return 0, got
|
|
print("LOOP FAIL: stream=%r" % got[:SHOWN_BYTES + 40])
|
|
return 1, got
|
|
|
|
|
|
def main(argv):
|
|
withDtrCheck = "--dtr-check" in argv
|
|
argv = [arg for arg in argv if arg != "--dtr-check"]
|
|
if len(argv) < 4 or argv[1] not in ("tcp-server", "tcp-client", "pty") or (withDtrCheck and argv[1] == "pty"):
|
|
sys.stderr.write(__doc__)
|
|
return 2
|
|
mode = argv[1]
|
|
wall = float(argv[3])
|
|
banner = argv[4].encode() if len(argv) > 4 else DEFAULT_BANNER
|
|
start = time.time()
|
|
|
|
if mode == "pty":
|
|
master = openPty(argv[2])
|
|
|
|
def receive():
|
|
ready, _, _ = select.select([master], [], [], POLL_SECONDS)
|
|
if not ready:
|
|
return b""
|
|
try:
|
|
return os.read(master, READ_SIZE)
|
|
except OSError:
|
|
return b""
|
|
|
|
return loop(lambda data: os.write(master, data), receive, wall, start, banner)[0]
|
|
|
|
port = int(argv[2])
|
|
conn = acceptOne(port, wall) if mode == "tcp-server" else connectOne(port, wall, start)
|
|
if conn is None:
|
|
print("LOOP FAIL: no connection on port %d" % port)
|
|
return 1
|
|
conn.settimeout(POLL_SECONDS)
|
|
|
|
lines = LineDecoder()
|
|
|
|
def receive():
|
|
try:
|
|
data = conn.recv(READ_SIZE)
|
|
except socket.timeout:
|
|
return b""
|
|
except OSError:
|
|
return None
|
|
if not data:
|
|
return None
|
|
return lines.feed(data) if withDtrCheck else data
|
|
|
|
status, got = loop(conn.sendall, receive, wall, start, banner)
|
|
if status != 0 or not withDtrCheck:
|
|
return status
|
|
return dtrCheck(conn.sendall, receive, wall, start, lines, got)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|