Files
pyGUI/serialcomm/serial_port.py
Jack.Le e306db6816 Add SettingsTab, StatusTab, DataTab and wire up DeviceController
- SettingsTab: chamber ID, master CSV mapping (families A-Z), wafer
  behavior toggle, light/dark mode toggle, Edit CSV Metadata dialog
- StatusTab: connection status card, wafer info, activity log (blank
  until Detect Wafer fires from the side rail)
- DataTab: empty state, ready for parsed data
- HomePage: side rail buttons dispatch device actions and jump to
  Status tab; selectedSideActionIndex defaults to -1 on startup
- DeviceController registered as QML context property in main.py
- LocalSettings: added status persistence fields
- Theme: added subheadingColor and disabledText
- Added serialcomm/ package and backend data/graph modules
- gitignore: restore clean version
2026-05-28 15:49:07 -07:00

204 lines
7.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Low-level serial port communication with the wafer device.
Mirrors the C# Form1.cs wafer protocol:
- Detect : s0 + 510×'F' → 1024 hex chars → WaferInfo
- ReadMemory: D1 + 510×'F' → N hex chars (retry on bad length)
- Erase : p1 + 510×'F' → no response (~15s blocking)
All commands are 512-char strings: 2-char command + 510×'F' padding.
"""
import logging
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Iterator, Optional
import serial as pyserial
log = logging.getLogger(__name__)
@dataclass
class WaferInfo:
"""Parsed wafer identification from the s0 detect response."""
family_code: str # "P", "X", "A", "B", "C", "D", "E", "F"
serial_number: str # e.g. "P00001"
sensor_count: int
mfg_date_hex: str # raw 8 hex chars
sensor_assigned_hex: str # raw 2 hex chars
locked_hex: str # raw 2 hex chars
runtime: int # seconds
cycle_count: int
class SerialPort:
"""Low-level serial transport for the temperature-sensing wafer.
The serial port is opened per-operation (matching C# behaviour)
rather than kept open persistently.
"""
BAUDRATE = 888888
COMMAND_PAD = "F" * 510 # pad to 512 chars total
def __init__(self, port_name: str) -> None:
self._port_name = port_name
# ------------------------------------------------------------------
# Context manager for scoped port access
# ------------------------------------------------------------------
@contextmanager
def _open(self, timeout: float | None = None) -> Iterator[pyserial.Serial]:
"""Open the serial port, yield it, then close on exit."""
port = pyserial.Serial(
self._port_name,
self.BAUDRATE,
parity=pyserial.PARITY_NONE,
bytesize=8,
stopbits=pyserial.STOPBITS_ONE,
xonxoff=False,
rtscts=False,
dsrdtr=False,
)
if timeout is not None:
port.timeout = timeout
try:
yield port
finally:
port.close()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def detect(self, timeout_ms: int = 5000) -> Optional[WaferInfo]:
"""Try s0 command. Returns WaferInfo if response is 1024 hex chars."""
with self._open(timeout_ms / 1000) as port:
port.write(("s0" + self.COMMAND_PAD).encode())
response = port.readline().decode().strip()
if len(response) == 1024:
return self._parse_wafer_info(response)
return None
def read_memory(
self,
cmd: str = "D1",
expected_hex_len: int = 393216,
timeout_ms: int = 120000,
retries: int = 8,
) -> Optional[bytes]:
"""Send read command, retry on bad length or odd-length string.
The wafer sends hex-encoded bytes. A dropped byte corrupts the
alignment, so the entire buffer must be retried.
Returns raw bytes on success, None on failure.
"""
with self._open(timeout_ms / 1000) as port:
port.write((cmd + self.COMMAND_PAD).encode())
for attempt in range(retries, 0, -1):
data_string = port.readline().decode(errors="ignore").strip()
# Trim trailing odd nibble — wafer sometimes drops 1 char
# at the tail; one stray nibble is harmless, the rest is good.
if len(data_string) % 2 != 0:
data_string = data_string[:-1]
# Defensively cap at expected length if the wafer over-sends.
if expected_hex_len > 0 and len(data_string) > expected_hex_len:
data_string = data_string[:expected_hex_len]
# Accept if we got at least 99% of expected (matches C# tolerance).
threshold = int(expected_hex_len * 0.99) if expected_hex_len > 0 else 1
if len(data_string) >= threshold:
break
log.warning(
"Short hex string (len=%d, expected~%d), retries left=%d",
len(data_string),
expected_hex_len,
attempt - 1,
)
if attempt > 1:
port.reset_input_buffer()
# Re-issue the command — reset_input_buffer alone leaves
# the wafer waiting for nothing.
port.write((cmd + self.COMMAND_PAD).encode())
continue
log.error("Retries exhausted after bad read")
return None
try:
return bytes.fromhex(data_string)
except ValueError as exc:
log.error("Failed to decode hex: %s", exc)
return None
def erase_memory(self, timeout_ms: int = 5000) -> bool:
"""Send p1 erase command. Wafer takes ~15s to erase.
Returns True if command was sent successfully.
"""
with self._open(timeout_ms / 1000) as port:
try:
port.write(("p1" + self.COMMAND_PAD).encode())
return True
except Exception as exc:
log.error("Erase command failed: %s", exc)
return False
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _hex_to_ascii(hex_str: str) -> str:
"""Convert hex string (e.g. '50') to ASCII char ('P')."""
hex_str = hex_str.replace(" ", "")
result = []
for i in range(0, len(hex_str), 2):
hex_char = hex_str[i : i + 2]
if len(hex_char) == 2:
result.append(chr(int(hex_char, 16)))
return "".join(result)
def _parse_wafer_info(self, hex_response: str) -> WaferInfo:
"""Parse the 1024-char hex response into WaferInfo.
Layout (from C# checkPort):
bytes 0-1 : FamilyCode (2 hex chars → ASCII)
bytes 2-5 : Serial number (3 bytes hex → decimal, 5 digits)
bytes 6-7 : Sensor count (1 byte hex)
bytes 8-15 : Mfg date (4 bytes hex)
bytes 16-17 : Sensor assigned (1 byte hex)
bytes 18-19 : Locked (1 byte hex)
bytes 1016-1019 : Runtime (2 bytes hex → decimal seconds)
bytes 1020-1023 : Cycle time (2 bytes hex → decimal cycles)
"""
raw = bytes.fromhex(hex_response)
family_code = self._hex_to_ascii(hex_response[0:2])
serial_num = f"{family_code}{int(hex_response[2:6], 16):05d}"
sensor_count = int(hex_response[6:8], 16)
mfg_date_hex = hex_response[8:16]
sensor_assigned_hex = hex_response[16:18]
locked_hex = hex_response[18:20]
runtime = int(hex_response[1016:1020], 16)
cycle_count = int(hex_response[1020:1024], 16)
return WaferInfo(
family_code=family_code,
serial_number=serial_num,
sensor_count=sensor_count,
mfg_date_hex=mfg_date_hex,
sensor_assigned_hex=sensor_assigned_hex,
locked_hex=locked_hex,
runtime=runtime,
cycle_count=cycle_count,
)