diff --git a/src/pygui/backend/wafer/zwafer_parser.py b/src/pygui/backend/wafer/zwafer_parser.py index 014aa32..99d6d4b 100644 --- a/src/pygui/backend/wafer/zwafer_parser.py +++ b/src/pygui/backend/wafer/zwafer_parser.py @@ -104,9 +104,6 @@ class ZWaferParser: elif key.lower() == "wafer id": wafer_data.serial = value - if found_kv and wafer_data.date == datetime.min: - wafer_data.date = datetime.min - return found_kv # ===== Sensor Layout Parsing ===== diff --git a/src/pygui/serialcomm/data_parser.py b/src/pygui/serialcomm/data_parser.py index a4ef950..db81964 100644 --- a/src/pygui/serialcomm/data_parser.py +++ b/src/pygui/serialcomm/data_parser.py @@ -274,7 +274,13 @@ def save_to_csv( filename = f"{serial_number}-{timestamp}.csv" filepath = os.path.join(output_dir, filename) - num_cols = csv_column_count(family_code) + # Header count = max of display column count and actual data width. + # csv_column_count gives the display sensor count (e.g. 48 for P), + # but the binary parser may yield more readings (244 for P). Use the + # actual data width so no sensor values are silently dropped. + display_cols = csv_column_count(family_code) + data_width = max((len(r) for r in data), default=0) + num_cols = max(display_cols, data_width) with open(filepath, "w", encoding="utf-8") as f: # Header row: Sensor1,Sensor2,... headers = [f"Sensor{j + 1}" for j in range(num_cols)] diff --git a/src/pygui/serialcomm/stream_reader.py b/src/pygui/serialcomm/stream_reader.py index 74fc39d..2c872eb 100644 --- a/src/pygui/serialcomm/stream_reader.py +++ b/src/pygui/serialcomm/stream_reader.py @@ -13,6 +13,9 @@ log = logging.getLogger(__name__) ParseFrame = Callable[[bytes, int], Frame] +# Max consecutive resync attempts before giving up on a corrupt stream. +_MAX_RESYNC_ATTEMPTS = 50 + class StreamReader: def __init__(self, transport, parse_frame: ParseFrame, @@ -103,6 +106,7 @@ class StreamReader: def _run_binary(self, initial_bytes: bytes) -> None: log.info("StreamReader: starting binary stream parsing") buf = bytearray(initial_bytes) + resync_attempts = 0 while not self._stop.is_set(): # Find sync marker in buffer. @@ -115,6 +119,7 @@ class StreamReader: idx += 1 if synced: + resync_attempts = 0 buf = buf[idx:] # Accumulate header (5 bytes after sync). while len(buf) < 7 and not self._stop.is_set(): @@ -152,6 +157,17 @@ class StreamReader: buf = buf[total_packet_len:] else: # Resync: keep only trailing 0xAA if present, then read more. + resync_attempts += 1 + if resync_attempts > _MAX_RESYNC_ATTEMPTS: + log.error( + "StreamReader: giving up after %d resync attempts " + "(no 0xAA 0x88 sync marker found in stream)", + resync_attempts, + ) + self._on_error( + TimeoutError("Binary stream resync failed: no sync marker") + ) + return if len(buf) > 0 and buf[-1] == 0xAA: buf = bytearray([0xAA]) else: diff --git a/tests/test_data_parser.py b/tests/test_data_parser.py index e89a075..42909e9 100644 --- a/tests/test_data_parser.py +++ b/tests/test_data_parser.py @@ -266,20 +266,25 @@ class TestSaveToCsv: assert result is not None assert "P12345" in result - def test_csv_has_sensor_headers(self, tmp_path): - # Header count is driven by csv_column_count(family), not data width. - # P family → 48 sensors; F family → 22 sensors; X family → 80 sensors. - cases = [("P", 48), ("F", 22), ("X", 80), ("B", 29)] - for family, expected_cols in cases: - data = [["25.0", "24.5"]] - result = save_to_csv(data, family, f"{family}00001", str(tmp_path)) - assert result is not None, f"save_to_csv returned None for {family}" - headers = open(result).readline().strip().split(",") - assert ( - len(headers) == expected_cols - ), f"{family}: expected {expected_cols} headers, got {len(headers)}" - assert headers[0] == "Sensor1" - assert headers[-1] == f"Sensor{expected_cols}" + def test_csv_header_wins_when_data_wider_than_display(self, tmp_path): + # Header count = max(csv_column_count, data width). + # When data has 60 columns and P display is 48, header is 60 — no truncation. + data = [[f"{i}.0" for i in range(60)]] + result = save_to_csv(data, "P", "P00001", str(tmp_path)) + assert result is not None + headers = open(result).readline().strip().split(",") + assert len(headers) == 60, f"data width 60 should win over display 48" + assert headers[0] == "Sensor1" + assert headers[-1] == "Sensor60" + + def test_csv_uses_display_count_when_data_is_narrower(self, tmp_path): + # When data has fewer columns than the display count, pad to display. + data = [["25.0"]] + result = save_to_csv(data, "X", "X00001", str(tmp_path)) + assert result is not None + headers = open(result).readline().strip().split(",") + # X display = 80, data = 1 → headers = 80 + assert len(headers) == 80 def test_csv_row_count_matches_data(self, tmp_path): data = [["25.0"], ["24.5"], ["23.0"]] @@ -302,3 +307,18 @@ class TestSaveToCsv: data = [["25.0"]] result = save_to_csv(data, "P", "P00001", "/\x00invalid\x00path") assert result is None + + def test_preserves_all_columns_when_data_wider_than_display(self, tmp_path): + # Bug fix: when data has more columns than csv_column_count(family), + # all data columns must be preserved (no silent truncation). + data = [[f"{i}.0" for i in range(244)]] + result = save_to_csv(data, "P", "P00001", str(tmp_path)) + assert result is not None + with open(result) as f: + lines = f.readlines() + headers = lines[0].strip().split(",") + data_row = lines[1].strip().split(",") + # All 244 columns should be preserved, not truncated to 48 + assert len(headers) == 244, f"expected 244 cols, got {len(headers)}" + assert len(data_row) == 244, f"expected 244 data cols, got {len(data_row)}" + assert data_row[243] == "243.0", "last column value must be preserved"