Files
pyGUI/tests/test_stream_reader.py
T
jack c4cbc02a15 fix(stream_reader): add ASCII hex path + binary resync guard
Family-A wafers never send 0xAA 0x88 framing — _run() now routes
to _run_ascii() on first byte dispatch instead of treating the whole
stream as corrupt binary.

Binary path gets a 16 KB give-up cap (bytes, not attempts) so a
permanently-corrupt port can't stall the reader indefinitely. Raw
wire bytes go to DEBUG only; the ERROR line that surfaces in the
Activity Log is kept free of internal buffer content.

- add family_code param to StreamReader for ASCII decode routing
- add _run_binary give-up cap + DEBUG-only hex diagnostic
- fix stop(): close transport on wedged thread (binary + ASCII paths)
- refactor test_stream_reader: consolidate ASCII regression + resync
  give-up + DEBUG-vs-ERROR log split tests; drop redundant parse_line
- extend test_session_controller: live-stream gate for non-X family
- StreamControlPanel.qml: thread-safe stop on tab leave
2026-07-13 12:26:33 -07:00

204 lines
8.0 KiB
Python

import time
from pygui.backend.models.frame import Frame
from pygui.serialcomm.stream_reader import StreamReader, _crc16
class FakeTransport:
"""Yield canned data then blocks (returns b'')."""
def __init__(self, lines):
self._data = b"".join(lines)
self._pos = 0
self.closed = False
def read(self, n=1):
time.sleep(0.001)
if self._pos >= len(self._data):
return b""
res = self._data[self._pos:self._pos+n]
self._pos += n
return res
def close(self): self.closed = True
def make_packet(payload: bytes, seq: int = 1) -> bytes:
"""Build a wire packet exactly as the device does: AA 88, msg type,
seq (LE), payload len (LE), payload, CRC16/MODBUS (LE) over the rest."""
body = (b"\xaa\x88\x01" + seq.to_bytes(2, "little")
+ len(payload).to_bytes(2, "little") + payload)
return body + _crc16(body).to_bytes(2, "little")
def parse_payload(payload: bytes, seq: int) -> Frame:
return Frame(seq=seq, time=0.0, values=[float(b) for b in payload])
def test_crc16_known_vector():
# CRC16/MODBUS check value for "123456789" is 0x4B37.
assert _crc16(b"123456789") == 0x4B37
def test_binary_stream_parses_crc_valid_packets():
got = []
data = make_packet(b"\x01\x02", seq=1) + make_packet(b"\x03\x04", seq=2)
transport = FakeTransport([data])
r = StreamReader(transport, parse_payload,
on_frame=got.append, on_error=lambda e: None)
r.start()
time.sleep(0.2)
r.stop()
assert [f.seq for f in got] == [1, 2]
assert r.error_count == 0
assert transport.closed
def test_corrupt_crc_packet_dropped_and_stream_continues():
got, errors = [], []
bad = bytearray(make_packet(b"\x09\x09", seq=1))
bad[-1] ^= 0xFF # break the CRC
data = bytes(bad) + make_packet(b"\x01\x02", seq=2)
r = StreamReader(FakeTransport([data]), parse_payload,
on_frame=got.append, on_error=errors.append)
r.start()
time.sleep(0.2)
r.stop()
assert [f.seq for f in got] == [2]
assert r.error_count == 1
def test_bogus_giant_payload_len_does_not_stall_the_stream():
"""A false 0xAA 0x88 inside data yields a garbage payload_len (up to
64KB). Without a sanity cap the reader waits forever accumulating a
packet that doesn't exist — the 'frames stop, no error' symptom."""
got = []
# False sync + header claiming a 0xFFFF-byte payload, then a real packet.
bogus = b"\xaa\x88\x01\x00\x00\xff\xff"
data = bogus + make_packet(b"\x01\x02", seq=7)
r = StreamReader(FakeTransport([data]), parse_payload,
on_frame=got.append, on_error=lambda e: None)
r.start()
time.sleep(0.2)
r.stop()
assert [f.seq for f in got] == [7]
def test_quiet_port_between_scans_is_not_a_resync_failure():
"""Device pauses between measurement scans. Empty reads must not count
toward the resync give-up cap (C#'s findMessageStart waits forever) —
the old code died with TimeoutError after ~50 empty reads."""
errors = []
got = []
r = StreamReader(FakeTransport([make_packet(b"\x01\x02", seq=1)]),
parse_payload, on_frame=got.append, on_error=errors.append)
r.start()
time.sleep(0.5) # long idle stretch after the single packet drains
r.stop()
assert [f.seq for f in got] == [1]
assert errors == []
def test_resync_giveup_debug_log_has_bytes_error_log_does_not(caplog):
"""Regression: the give-up log used to dump `buf` after it had already
been cleared for the new read, so the hex-diagnostic came back empty —
useless for diagnosing what was actually on the wire. Fix: real bytes
go to DEBUG only; the ERROR line the terminal/Activity Log show stays
free of raw internal buffer content."""
import logging
errors = []
# 0xAA/0x88 excluded so this exercises _run_binary's resync path even
# though it's called directly (bypassing the ASCII/binary dispatch,
# which is exercised separately — see test_ascii_hex_dump_stream_*).
garbage = b"\x42" * 20000
r = StreamReader(FakeTransport([garbage]), parse_payload,
on_frame=lambda f: None, on_error=errors.append)
with caplog.at_level(logging.DEBUG, logger="pygui.serialcomm.stream_reader"):
r._run_binary(bytearray())
assert len(errors) == 1
error_lines = [r.message for r in caplog.records if r.levelname == "ERROR"]
debug_lines = [r.message for r in caplog.records if r.levelname == "DEBUG"]
assert any("giving up" in m for m in error_lines)
assert not any("42424242" in m for m in error_lines), (
"raw wire bytes must not appear in the ERROR line users see"
)
assert any("42424242" in m for m in debug_lines)
def test_resync_gives_up_after_16kb_of_garbage():
"""Give-up cap counts discarded BYTES, not read attempts, so it fires
at a fixed data volume regardless of transfer rate."""
errors = []
got = []
garbage = b"\x01" * 20000 # never contains 0xAA 0x88; well past the cap
r = StreamReader(FakeTransport([garbage]), parse_payload,
on_frame=got.append, on_error=errors.append)
r._run_binary(bytearray()) # deterministic give-up — no thread needed
assert got == []
assert len(errors) == 1
assert "resync" in str(errors[0]).lower()
def test_ascii_hex_dump_stream_decodes_real_wafer_capture():
"""Regression using an exact byte capture from real hardware (family A):
the wire is plain ASCII hex text, 4 chars/word, no 0xAA 0x88 framing at
all — see docs/adr/0007. _run() must route to the ASCII path and the
words must decode to plausible room-temperature values, not stall."""
got = []
words = ["280B", "1C0B", "220B", "1B0B", "200B", "1F0B", "190B", "1D0B",
"1B0B", "240B", "180B", "210B", "2B0B", "260B", "290B", "2B0B"]
# aepwafer.yaml's family "A" has 48 real sensors; pad to one full block
# (256 words) the way the real device does — extra slots are unpopulated.
block = "".join(words) + "0000" * (256 - len(words))
r = StreamReader(FakeTransport([block.encode()]), parse_payload,
on_frame=got.append, on_error=lambda e: None,
family_code="A")
r.start()
time.sleep(0.2)
r.stop()
assert len(got) == 1
assert len(got[0].values) == 48 # sensor_count_for("A")
# First word "280B" byte-swaps to "0B28" -> ~22.31C via the AEP formula.
assert 22.0 < got[0].values[0] < 22.5
class WedgedTransport:
"""Simulates a worker thread stuck in a blocking read() that ignores
the stop event — read() never returns on its own. stop() must close
the port to unblock it rather than relying on the read loop to notice."""
def __init__(self):
self.closed = False
def read(self, n=1):
while not self.closed:
time.sleep(0.001)
raise OSError("port closed")
def close(self):
self.closed = True
def test_stop_closes_transport_even_if_worker_thread_is_wedged():
r = StreamReader(WedgedTransport(), parse_payload,
on_frame=lambda f: None, on_error=lambda e: None)
r.start()
time.sleep(0.05) # let the thread enter the blocking read
r.stop()
assert r._transport.closed, (
"stop() must close the transport itself — otherwise a wedged "
"worker thread keeps the port open and a subsequent startStream() "
"opens a second competing handle to the same port"
)
def test_stop_does_not_report_the_close_it_caused_as_an_error():
"""stop()'s own transport.close() unblocks the worker thread's read()
by making it raise — that's expected teardown, not a stream fault, and
must not reach on_error (Activity Log would otherwise show a fake
"Live stream error" on every ordinary Stop)."""
errors = []
r = StreamReader(WedgedTransport(), parse_payload,
on_frame=lambda f: None, on_error=errors.append)
r.start()
time.sleep(0.05)
r.stop()
time.sleep(0.05) # let the worker thread's except-handler run
assert errors == []