""" devices/serial_device.py Generic Serial / UART device module. Uses ArduinoLayer for communication, but works with ANY instrument that sends newline-terminated data. Configurable parse formats: • "csv" – plain comma-separated values mapped to channels in order • "key:val" – "CH0:1.23,CH1:4.56" key-colon-value pairs • "json" – {"CH0":1.23,"CH1":4.56} Switch format in the config widget without restarting. """ import json import math import random import threading import time from typing import Any, Dict, List from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QFormLayout, QGroupBox, QComboBox, QLineEdit, QSpinBox, QLabel, QPushButton, ) from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus from api_layers.arduino_layer import ArduinoLayer _COLORS = ["#7fff6e", "#4cc9f0", "#f72585", "#00d4ff", "#ffcc00", "#c77dff", "#ff6b35", "#38b000"] class SerialDevice(BaseDevice): DEVICE_TYPE = "serial" ICON = "⇌" def __init__( self, device_id: str = "ser_0", port: str = "COM3", baud_rate: int = 115200, num_channels: int = 4, channel_names: List[str] = None, units: List[str] = None, parse_format: str = "key:val", # "csv" | "key:val" | "json" simulate: bool = True, ): self._port = port self._baud = baud_rate self._parse_format = parse_format self.simulate = simulate names = channel_names or [f"CH{i}" for i in range(num_channels)] _units = units or ["" for _ in range(num_channels)] channels = [ ChannelConfig( channel_id=names[i], name=names[i], unit=_units[i], min_value=0.0, max_value=1023.0, color=_COLORS[i % len(_COLORS)], ) for i in range(num_channels) ] info = DeviceInfo( device_id=device_id, name="Serial / UART", device_type=self.DEVICE_TYPE, description=f"{port} @ {baud_rate}", icon=self.ICON, channels=channels, ) super().__init__(info) self._layer = ArduinoLayer( port=port, baud=baud_rate, analog_pins=[ch.channel_id for ch in channels], simulate=simulate, ) self._t0 = 0.0 # ── BaseDevice ────────────────────────────────────────────────────── def connect(self) -> bool: self._t0 = time.time() ok = self._layer.connect() self.status = DeviceStatus.SIMULATED if self.simulate else ( DeviceStatus.CONNECTED if ok else DeviceStatus.ERROR ) return ok def disconnect(self) -> None: self._layer.disconnect() self.status = DeviceStatus.DISCONNECTED def read_channels(self) -> Dict[str, float]: raw = self._layer.read() # Map by order if keys don't match channel IDs if raw: mapped: Dict[str, float] = {} raw_vals = list(raw.values()) for i, ch in enumerate(self.info.channels): if ch.channel_id in raw: mapped[ch.channel_id] = raw[ch.channel_id] elif i < len(raw_vals): mapped[ch.channel_id] = raw_vals[i] return mapped return {} def write_channel(self, channel_id: str, value: Any) -> bool: return self._layer.write(channel_id, int(value)) def get_config_widget(self) -> QWidget: return SerialConfigWidget(self) def reconfigure(self, port: str, baud: int, fmt: str, simulate: bool): was_on = self.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED) if was_on: self.disconnect() self._port = port self._baud = baud self._parse_format = fmt self.simulate = simulate self._layer = ArduinoLayer( port=port, baud=baud, analog_pins=[ch.channel_id for ch in self.info.channels], simulate=simulate, ) if was_on: self.connect() class SerialConfigWidget(QWidget): def __init__(self, device: SerialDevice): super().__init__() self.device = device self._build() def _build(self): root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) grp = QGroupBox("Port Settings") form = QFormLayout(grp) self.port_edit = QLineEdit(self.device._port) form.addRow("Port:", self.port_edit) self.baud_cb = QComboBox() self.baud_cb.addItems(["9600", "19200", "38400", "57600", "115200", "230400", "460800"]) self.baud_cb.setCurrentText(str(self.device._baud)) form.addRow("Baud Rate:", self.baud_cb) self.fmt_cb = QComboBox() self.fmt_cb.addItems(["key:val", "csv", "json"]) self.fmt_cb.setCurrentText(self.device._parse_format) form.addRow("Parse Format:", self.fmt_cb) self.sim_chk = QComboBox() self.sim_chk.addItems(["Simulate", "Real Hardware"]) self.sim_chk.setCurrentIndex(0 if self.device.simulate else 1) form.addRow("Mode:", self.sim_chk) scan_btn = QPushButton("Scan Ports") scan_btn.clicked.connect(self._scan) form.addRow(scan_btn) self.port_lbl = QLabel("") form.addRow(self.port_lbl) root.addWidget(grp) apply_btn = QPushButton("Apply & Reconnect") apply_btn.setObjectName("applyButton") apply_btn.clicked.connect(self._apply) root.addWidget(apply_btn) root.addStretch() def _scan(self): ports = ArduinoLayer.list_ports() self.port_lbl.setText(", ".join(ports) if ports else "None found") def _apply(self): self.device.reconfigure( port=self.port_edit.text(), baud=int(self.baud_cb.currentText()), fmt=self.fmt_cb.currentText(), simulate=(self.sim_chk.currentIndex() == 0), )