168 lines
5.8 KiB
Python
168 lines
5.8 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 readline(self):
|
|
time.sleep(0.001)
|
|
if self._pos >= len(self._data):
|
|
return b""
|
|
idx = self._data.find(b"\n", self._pos)
|
|
if idx == -1:
|
|
res = self._data[self._pos:]
|
|
self._pos = len(self._data)
|
|
return res
|
|
res = self._data[self._pos:idx+1]
|
|
self._pos = idx + 1
|
|
return res
|
|
def close(self): self.closed = True
|
|
|
|
def parse_line(raw: str, seq: int) -> Frame:
|
|
parts = [float(x) for x in raw.split(",")]
|
|
return Frame(seq = seq, time = parts[0], values = parts[1:])
|
|
|
|
def test_reads_frames_and_counts_errors():
|
|
got, errors = [], []
|
|
transport = FakeTransport([b"0.0,149,148\n", b"garbage\n", b"0.5,150,149\n"])
|
|
r = StreamReader(transport, parse_line,
|
|
on_frame = got.append, on_error = lambda e: errors.append(e))
|
|
|
|
r.start()
|
|
time.sleep(0.1)
|
|
r.stop()
|
|
assert [f.values for f in got] == [[149.0, 148.0], [150.0, 149.0]]
|
|
assert r.error_count == 1
|
|
assert transport.closed
|
|
|
|
|
|
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_line,
|
|
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 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)
|
|
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] == [1, 2]
|
|
assert r.error_count == 0
|
|
|
|
|
|
def test_corrupt_crc_packet_dropped_and_stream_continues():
|
|
got = []
|
|
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=lambda e: None)
|
|
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) # ~100 empty reads after the single packet drains
|
|
r.stop()
|
|
assert [f.seq for f in got] == [1]
|
|
assert errors == []
|
|
|
|
|
|
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_line,
|
|
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 == []
|