diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-04-13 15:22:56 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-04-13 15:22:56 -0600 |
| commit | 2ed9d37da7b27d25173535550fb92702225ac14e (patch) | |
| tree | d6ff360a656e577629d3cefdaa1ae02af7255864 /devices | |
| parent | 8d6acf3a8ea4b37f86b321dbf430be5be01b1267 (diff) | |
Removed QtPy5 snippet and fixed directories
Diffstat (limited to 'devices')
| -rw-r--r-- | devices/__init__.py | 5 | ||||
| -rw-r--r-- | devices/__pycache__/__init__.cpython-314.pyc | bin | 0 -> 398 bytes | |||
| -rw-r--r-- | devices/__pycache__/analog_input.cpython-314.pyc | bin | 0 -> 16238 bytes | |||
| -rw-r--r-- | devices/__pycache__/base_device.cpython-314.pyc | bin | 0 -> 7731 bytes | |||
| -rw-r--r-- | devices/__pycache__/device_registry.cpython-314.pyc | bin | 0 -> 5927 bytes | |||
| -rw-r--r-- | devices/__pycache__/digital_io.cpython-314.pyc | bin | 0 -> 19275 bytes | |||
| -rw-r--r-- | devices/__pycache__/serial_device.cpython-314.pyc | bin | 0 -> 11824 bytes | |||
| -rw-r--r-- | devices/analog_input.py | 276 | ||||
| -rw-r--r-- | devices/base_device.py | 104 | ||||
| -rw-r--r-- | devices/device_registry.py | 73 | ||||
| -rw-r--r-- | devices/digital_io.py | 261 | ||||
| -rw-r--r-- | devices/serial_device.py | 187 |
12 files changed, 906 insertions, 0 deletions
diff --git a/devices/__init__.py b/devices/__init__.py new file mode 100644 index 0000000..f79b333 --- /dev/null +++ b/devices/__init__.py @@ -0,0 +1,5 @@ +# devices/__init__.py +from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus +from devices.device_registry import DeviceRegistry + +__all__ = ["BaseDevice", "ChannelConfig", "DeviceInfo", "DeviceStatus", "DeviceRegistry"] diff --git a/devices/__pycache__/__init__.cpython-314.pyc b/devices/__pycache__/__init__.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..9ab29a6 --- /dev/null +++ b/devices/__pycache__/__init__.cpython-314.pyc diff --git a/devices/__pycache__/analog_input.cpython-314.pyc b/devices/__pycache__/analog_input.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..4c08631 --- /dev/null +++ b/devices/__pycache__/analog_input.cpython-314.pyc diff --git a/devices/__pycache__/base_device.cpython-314.pyc b/devices/__pycache__/base_device.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..ade2b20 --- /dev/null +++ b/devices/__pycache__/base_device.cpython-314.pyc diff --git a/devices/__pycache__/device_registry.cpython-314.pyc b/devices/__pycache__/device_registry.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..608c8ee --- /dev/null +++ b/devices/__pycache__/device_registry.cpython-314.pyc diff --git a/devices/__pycache__/digital_io.cpython-314.pyc b/devices/__pycache__/digital_io.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..b2bb27f --- /dev/null +++ b/devices/__pycache__/digital_io.cpython-314.pyc diff --git a/devices/__pycache__/serial_device.cpython-314.pyc b/devices/__pycache__/serial_device.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..0674606 --- /dev/null +++ b/devices/__pycache__/serial_device.cpython-314.pyc diff --git a/devices/analog_input.py b/devices/analog_input.py new file mode 100644 index 0000000..5bb5d28 --- /dev/null +++ b/devices/analog_input.py @@ -0,0 +1,276 @@ +""" +devices/analog_input.py + +Analog Input device module. + +Supports two interchangeable backends: + • backend="nidaqmx" → NidaqmxLayer (NI hardware or sim) + • backend="arduino" → ArduinoLayer (Arduino hardware or sim) + +The device is backend-agnostic at the acquisition layer — swap +the backend without changing any other code. +""" + +from typing import Any, Dict, List + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QFormLayout, QGroupBox, + QComboBox, QDoubleSpinBox, QSpinBox, QCheckBox, + QLineEdit, QLabel, QPushButton, QHBoxLayout, +) +from PyQt6.QtCore import Qt + +from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus +from api_layers.nidaqmx_layer import NidaqmxLayer +from api_layers.arduino_layer import ArduinoLayer + +# Distinct colors for up to 16 channels +_COLORS = [ + "#00d4ff", "#ff6b35", "#7fff6e", "#ffcc00", + "#c77dff", "#ff4d6d", "#4cc9f0", "#f72585", + "#38b000", "#e9c46a", "#a8dadc", "#e63946", + "#90e0ef", "#fb8500", "#b5e48c", "#d62828", +] + + +class AnalogInputDevice(BaseDevice): + """Multi-channel analog input. Backend: NI-DAQmx or Arduino.""" + + DEVICE_TYPE = "analog_input" + ICON = "〜" + + def __init__( + self, + device_id: str = "ai_0", + num_channels: int = 4, + simulate: bool = True, + backend: str = "nidaqmx", # "nidaqmx" | "arduino" + # NI-specific + ni_device: str = "Dev1", + ni_min_v: float = -10.0, + ni_max_v: float = 10.0, + # Arduino-specific + ard_port: str = "COM3", + ard_baud: int = 115200, + ): + self.backend = backend + self.simulate = simulate + + # Build channel list + if backend == "arduino": + pins = ArduinoLayer.DEFAULT_ANALOG_PINS[:num_channels] + channels = [ + ChannelConfig( + channel_id=p, name=p, unit="V", + min_value=0.0, max_value=5.0, + alarm_low=None, alarm_high=4.8, + color=_COLORS[i % len(_COLORS)], + ) + for i, p in enumerate(pins) + ] + else: + ni_pins = [f"ai{i}" for i in range(num_channels)] + channels = [ + ChannelConfig( + channel_id=p, name=p.upper(), unit="V", + min_value=ni_min_v, max_value=ni_max_v, + alarm_low=None, alarm_high=ni_max_v * 0.9, + color=_COLORS[i % len(_COLORS)], + ) + for i, p in enumerate(ni_pins) + ] + + info = DeviceInfo( + device_id=device_id, + name=f"Analog Input ({backend.upper()})", + device_type=self.DEVICE_TYPE, + description=f"Multi-channel analog input via {backend}", + manufacturer="NI" if backend == "nidaqmx" else "Arduino", + icon=self.ICON, + channels=channels, + ) + super().__init__(info) + + # Instantiate the backend layer + if backend == "arduino": + self._layer = ArduinoLayer( + port=ard_port, + baud=ard_baud, + analog_pins=[ch.channel_id for ch in channels], + simulate=simulate, + ) + else: + self._layer = NidaqmxLayer( + device_name=ni_device, + channels=[ch.channel_id for ch in channels], + min_val=ni_min_v, + max_val=ni_max_v, + simulate=simulate, + ) + + # Store config for the config widget + self._ni_device = ni_device + self._ni_min_v = ni_min_v + self._ni_max_v = ni_max_v + self._ard_port = ard_port + self._ard_baud = ard_baud + + # ── BaseDevice interface ──────────────────────────────────────────── + + def connect(self) -> bool: + ok = self._layer.start() if hasattr(self._layer, "start") else self._layer.connect() + self.status = DeviceStatus.SIMULATED if self.simulate else ( + DeviceStatus.CONNECTED if ok else DeviceStatus.ERROR + ) + return ok + + def disconnect(self) -> None: + if hasattr(self._layer, "stop"): + self._layer.stop() + else: + self._layer.disconnect() + self.status = DeviceStatus.DISCONNECTED + + def read_channels(self) -> Dict[str, float]: + return self._layer.read() + + def write_channel(self, channel_id: str, value: Any) -> bool: + return False # AI is read-only + + def get_config_widget(self) -> QWidget: + return AnalogInputConfigWidget(self) + + def switch_backend(self, backend: str, **kwargs) -> None: + """Hot-swap the API layer without re-creating the device object.""" + was_running = self.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED) + if was_running: + self.disconnect() + self.backend = backend + self.simulate = kwargs.get("simulate", self.simulate) + if backend == "arduino": + self._layer = ArduinoLayer( + port=kwargs.get("port", self._ard_port), + baud=kwargs.get("baud", self._ard_baud), + analog_pins=[ch.channel_id for ch in self.info.channels], + simulate=self.simulate, + ) + else: + self._layer = NidaqmxLayer( + device_name=kwargs.get("ni_device", self._ni_device), + channels=[ch.channel_id for ch in self.info.channels], + min_val=kwargs.get("min_val", self._ni_min_v), + max_val=kwargs.get("max_val", self._ni_max_v), + simulate=self.simulate, + ) + if was_running: + self.connect() + + +# ── Config Widget ──────────────────────────────────────────────────────────── + +class AnalogInputConfigWidget(QWidget): + def __init__(self, device: AnalogInputDevice): + super().__init__() + self.device = device + self._build() + + def _build(self): + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + + # ── Backend selector ────────────────────────────────────────── + be_grp = QGroupBox("API Backend") + be_form = QFormLayout(be_grp) + + self.backend_cb = QComboBox() + self.backend_cb.addItems(["nidaqmx", "arduino"]) + self.backend_cb.setCurrentText(self.device.backend) + be_form.addRow("Backend:", self.backend_cb) + + self.sim_check = QCheckBox("Simulation Mode") + self.sim_check.setChecked(self.device.simulate) + be_form.addRow(self.sim_check) + + root.addWidget(be_grp) + + # ── NI settings ─────────────────────────────────────────────── + self.ni_grp = QGroupBox("NI-DAQmx Settings") + ni_form = QFormLayout(self.ni_grp) + + self.ni_dev_edit = QLineEdit(self.device._ni_device) + ni_form.addRow("Device:", self.ni_dev_edit) + + self.ni_min_spin = QDoubleSpinBox() + self.ni_min_spin.setRange(-100, 0); self.ni_min_spin.setValue(self.device._ni_min_v) + self.ni_min_spin.setSuffix(" V") + ni_form.addRow("Min V:", self.ni_min_spin) + + self.ni_max_spin = QDoubleSpinBox() + self.ni_max_spin.setRange(0, 100); self.ni_max_spin.setValue(self.device._ni_max_v) + self.ni_max_spin.setSuffix(" V") + ni_form.addRow("Max V:", self.ni_max_spin) + + # Detect button + detect_btn = QPushButton("Detect NI Devices") + detect_btn.clicked.connect(self._detect_ni) + ni_form.addRow(detect_btn) + self.ni_detect_lbl = QLabel("") + ni_form.addRow(self.ni_detect_lbl) + + root.addWidget(self.ni_grp) + + # ── Arduino settings ────────────────────────────────────────── + self.ard_grp = QGroupBox("Arduino Settings") + ard_form = QFormLayout(self.ard_grp) + + self.ard_port_edit = QLineEdit(self.device._ard_port) + ard_form.addRow("Port:", self.ard_port_edit) + + self.ard_baud_cb = QComboBox() + self.ard_baud_cb.addItems(["9600", "57600", "115200", "230400"]) + self.ard_baud_cb.setCurrentText(str(self.device._ard_baud)) + ard_form.addRow("Baud Rate:", self.ard_baud_cb) + + scan_btn = QPushButton("Scan Serial Ports") + scan_btn.clicked.connect(self._scan_ports) + ard_form.addRow(scan_btn) + self.ard_port_lbl = QLabel("") + ard_form.addRow(self.ard_port_lbl) + + root.addWidget(self.ard_grp) + + # ── Apply button ────────────────────────────────────────────── + apply_btn = QPushButton("Apply & Reconnect") + apply_btn.setObjectName("applyButton") + apply_btn.clicked.connect(self._apply) + root.addWidget(apply_btn) + root.addStretch() + + self._update_visibility() + self.backend_cb.currentTextChanged.connect(self._update_visibility) + + def _update_visibility(self): + be = self.backend_cb.currentText() + self.ni_grp.setVisible(be == "nidaqmx") + self.ard_grp.setVisible(be == "arduino") + + def _detect_ni(self): + from api_layers.nidaqmx_layer import NidaqmxLayer + devs = NidaqmxLayer.list_devices() + self.ni_detect_lbl.setText(", ".join(devs) if devs else "None detected") + + def _scan_ports(self): + from api_layers.arduino_layer import ArduinoLayer + ports = ArduinoLayer.list_ports() + self.ard_port_lbl.setText(", ".join(ports) if ports else "None found") + + def _apply(self): + self.device.switch_backend( + backend=self.backend_cb.currentText(), + simulate=self.sim_check.isChecked(), + ni_device=self.ni_dev_edit.text(), + min_val=self.ni_min_spin.value(), + max_val=self.ni_max_spin.value(), + port=self.ard_port_edit.text(), + baud=int(self.ard_baud_cb.currentText()), + ) diff --git a/devices/base_device.py b/devices/base_device.py new file mode 100644 index 0000000..922a743 --- /dev/null +++ b/devices/base_device.py @@ -0,0 +1,104 @@ +""" +devices/base_device.py + +Abstract base class for all DAQ I/O modules. +Every device plugin must subclass BaseDevice and implement the required methods. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional +from enum import Enum + + +class DeviceStatus(Enum): + DISCONNECTED = "disconnected" + CONNECTING = "connecting" + CONNECTED = "connected" + ERROR = "error" + SIMULATED = "simulated" + + +@dataclass +class ChannelConfig: + """Configuration for a single I/O channel.""" + channel_id: str + name: str + unit: str = "" + min_value: float = 0.0 + max_value: float = 100.0 + alarm_low: Optional[float] = None + alarm_high: Optional[float] = None + enabled: bool = True + color: str = "#00d4ff" + extra: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class DeviceInfo: + """Metadata describing a device module.""" + device_id: str + name: str + device_type: str # "analog_input" | "digital_io" | "serial" | "temperature" + description: str = "" + manufacturer: str = "" + model: str = "" + version: str = "1.0.0" + icon: str = "⚙" + channels: List[ChannelConfig] = field(default_factory=list) + + +class BaseDevice(ABC): + """ + Abstract base for all DAQ device plugins. + + To create a new module: + 1. Subclass BaseDevice + 2. Implement all @abstractmethod methods + 3. Drop the file in devices/ — DeviceRegistry auto-discovers it + """ + + def __init__(self, device_info: DeviceInfo): + self.info = device_info + self.status = DeviceStatus.DISCONNECTED + self._callbacks: List[Any] = [] + + # ── Required interface ────────────────────────────────────────────── + + @abstractmethod + def connect(self) -> bool: + """Open connection. Returns True on success.""" + + @abstractmethod + def disconnect(self) -> None: + """Close connection and release resources.""" + + @abstractmethod + def read_channels(self) -> Dict[str, float]: + """Return {channel_id: value} for all enabled channels.""" + + @abstractmethod + def write_channel(self, channel_id: str, value: Any) -> bool: + """Write value to an output channel. Returns True on success.""" + + @abstractmethod + def get_config_widget(self): + """Return a QWidget with device-specific configuration controls.""" + + # ── Shared helpers ────────────────────────────────────────────────── + + def add_data_callback(self, cb) -> None: + self._callbacks.append(cb) + + def _emit(self, channel_id: str, value: float, timestamp: float) -> None: + for cb in self._callbacks: + try: + cb(self.info.device_id, channel_id, value, timestamp) + except Exception: + pass + + def get_channel(self, channel_id: str) -> Optional[ChannelConfig]: + return next((c for c in self.info.channels if c.channel_id == channel_id), None) + + def __repr__(self): + return f"<{self.__class__.__name__} id={self.info.device_id} status={self.status.value}>" diff --git a/devices/device_registry.py b/devices/device_registry.py new file mode 100644 index 0000000..8ed6886 --- /dev/null +++ b/devices/device_registry.py @@ -0,0 +1,73 @@ +""" +devices/device_registry.py + +Auto-discovers and manages all BaseDevice subclasses. +Drop a new .py file in devices/ and it appears automatically. +""" + +import importlib +import inspect +import pkgutil +from pathlib import Path +from typing import Dict, List, Optional, Type + +from devices.base_device import BaseDevice, DeviceInfo + + +class DeviceRegistry: + def __init__(self): + self._classes: Dict[str, Type[BaseDevice]] = {} + self._instances: Dict[str, BaseDevice] = {} + self._discover() + + # ── Discovery ──────────────────────────────────────────────────────── + + def _discover(self): + path = Path(__file__).parent + package = "devices" + skip = {"base_device", "device_registry"} + + for _, mod_name, _ in pkgutil.iter_modules([str(path)]): + if mod_name.startswith("_") or mod_name in skip: + continue + try: + mod = importlib.import_module(f"{package}.{mod_name}") + for name, obj in inspect.getmembers(mod, inspect.isclass): + if issubclass(obj, BaseDevice) and obj is not BaseDevice: + self._classes[name] = obj + except Exception as e: + print(f"[Registry] Could not load {mod_name}: {e}") + + # ── Instance management ────────────────────────────────────────────── + + def add_instance(self, device: BaseDevice) -> None: + self._instances[device.info.device_id] = device + + def remove_instance(self, device_id: str) -> None: + dev = self._instances.pop(device_id, None) + if dev: + try: dev.disconnect() + except Exception: pass + + def get_instance(self, device_id: str) -> Optional[BaseDevice]: + return self._instances.get(device_id) + + def all_instances(self) -> List[BaseDevice]: + return list(self._instances.values()) + + def available_classes(self) -> List[str]: + return list(self._classes.keys()) + + def get_class(self, name: str) -> Optional[Type[BaseDevice]]: + return self._classes.get(name) + + def create(self, class_name: str, device_id: str, **kw) -> BaseDevice: + cls = self._classes.get(class_name) + if not cls: + raise ValueError(f"Unknown device class: {class_name}") + dev = cls(device_id=device_id, **kw) + self._instances[device_id] = dev + return dev + + def __len__(self): + return len(self._instances) diff --git a/devices/digital_io.py b/devices/digital_io.py new file mode 100644 index 0000000..23a345f --- /dev/null +++ b/devices/digital_io.py @@ -0,0 +1,261 @@ +""" +devices/digital_io.py + +Digital I/O device module. Backends: NI-DAQmx or Arduino. + +NI backend – uses nidaqmx digital line tasks (P0.0..P0.7) +Arduino – uses ArduinoLayer digital pin reads; writes via W:Dxx:val +""" + +import random +import time +from typing import Any, Dict + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, + QCheckBox, QPushButton, QLabel, QFormLayout, + QComboBox, QLineEdit, +) + +from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus + +_IN_COLORS = ["#00d4ff", "#4cc9f0", "#90e0ef", "#caf0f8", + "#0077b6", "#023e8a", "#48cae4", "#ade8f4"] +_OUT_COLORS = ["#ff6b35", "#ffcc00", "#f77f00", "#fcbf49", + "#d62828", "#e63946", "#fb8500", "#ffd166"] + + +class DigitalIODevice(BaseDevice): + DEVICE_TYPE = "digital_io" + ICON = "⬛" + + def __init__( + self, + device_id: str = "dio_0", + num_inputs: int = 8, + num_outputs: int = 8, + simulate: bool = True, + backend: str = "nidaqmx", # "nidaqmx" | "arduino" + ni_device: str = "Dev1", + ard_port: str = "COM3", + ard_baud: int = 115200, + ): + self.simulate = simulate + self.backend = backend + self._ni_device = ni_device + self._ard_port = ard_port + self._ard_baud = ard_baud + + channels = [] + for i in range(num_inputs): + channels.append(ChannelConfig( + channel_id=f"di{i}", name=f"DI {i}", unit="", + min_value=0.0, max_value=1.0, + color=_IN_COLORS[i % len(_IN_COLORS)], + )) + for i in range(num_outputs): + channels.append(ChannelConfig( + channel_id=f"do{i}", name=f"DO {i}", unit="", + min_value=0.0, max_value=1.0, + color=_OUT_COLORS[i % len(_OUT_COLORS)], + )) + + info = DeviceInfo( + device_id=device_id, + name=f"Digital I/O ({backend.upper()})", + device_type=self.DEVICE_TYPE, + description="Digital input/output module", + icon=self.ICON, + channels=channels, + ) + super().__init__(info) + + self._output_state: Dict[str, int] = { + f"do{i}": 0 for i in range(num_outputs) + } + self._sim_toggle: Dict[str, int] = {} + self._sim_state: Dict[str, int] = {} + + # Hardware task placeholders + self._ni_in_task = None + self._ni_out_task = None + self._ard_layer = None + + # ── BaseDevice ────────────────────────────────────────────────────── + + def connect(self) -> bool: + if self.simulate: + self.status = DeviceStatus.SIMULATED + return True + + if self.backend == "nidaqmx": + return self._ni_connect() + else: + return self._ard_connect() + + def _ni_connect(self) -> bool: + try: + import nidaqmx # type: ignore + from nidaqmx.constants import LineGrouping # type: ignore + n_in = sum(1 for c in self.info.channels if c.channel_id.startswith("di")) + n_out = sum(1 for c in self.info.channels if c.channel_id.startswith("do")) + + if n_in: + self._ni_in_task = nidaqmx.Task() + for i in range(n_in): + self._ni_in_task.di_channels.add_di_chan( + f"{self._ni_device}/port0/line{i}", + line_grouping=LineGrouping.CHAN_PER_LINE, + ) + self._ni_in_task.start() + + if n_out: + self._ni_out_task = nidaqmx.Task() + for i in range(n_out): + self._ni_out_task.do_channels.add_do_chan( + f"{self._ni_device}/port1/line{i}", + line_grouping=LineGrouping.CHAN_PER_LINE, + ) + self._ni_out_task.start() + + self.status = DeviceStatus.CONNECTED + return True + except Exception as e: + print(f"[DigitalIODevice] NI connect failed: {e}") + self.status = DeviceStatus.ERROR + return False + + def _ard_connect(self) -> bool: + from api_layers.arduino_layer import ArduinoLayer + self._ard_layer = ArduinoLayer( + port=self._ard_port, baud=self._ard_baud, + digital_pins=[c.channel_id for c in self.info.channels if c.channel_id.startswith("di")], + simulate=False, + ) + ok = self._ard_layer.connect() + self.status = DeviceStatus.CONNECTED if ok else DeviceStatus.ERROR + return ok + + def disconnect(self) -> None: + if self._ni_in_task: + try: self._ni_in_task.stop(); self._ni_in_task.close() + except Exception: pass + if self._ni_out_task: + try: self._ni_out_task.stop(); self._ni_out_task.close() + except Exception: pass + if self._ard_layer: + self._ard_layer.disconnect() + self.status = DeviceStatus.DISCONNECTED + + def read_channels(self) -> Dict[str, float]: + if self.simulate: + return self._sim_read() + if self.backend == "nidaqmx": + return self._ni_read() + return self._ard_read() + + def _ni_read(self) -> Dict[str, float]: + result = {} + try: + if self._ni_in_task: + vals = self._ni_in_task.read() + for i, ch in enumerate(c for c in self.info.channels if c.channel_id.startswith("di")): + result[ch.channel_id] = float(vals[i] if isinstance(vals, list) else vals) + except Exception as e: + print(f"[DigitalIODevice] NI read failed: {e}") + for ch in (c for c in self.info.channels if c.channel_id.startswith("do")): + result[ch.channel_id] = float(self._output_state.get(ch.channel_id, 0)) + return result + + def _ard_read(self) -> Dict[str, float]: + if not self._ard_layer: + return {} + raw = self._ard_layer.read() + result = {} + for ch in self.info.channels: + if ch.channel_id in raw: + result[ch.channel_id] = raw[ch.channel_id] + elif ch.channel_id.startswith("do"): + result[ch.channel_id] = float(self._output_state.get(ch.channel_id, 0)) + return result + + def _sim_read(self) -> Dict[str, float]: + result = {} + for ch in self.info.channels: + if ch.channel_id.startswith("di"): + cnt = self._sim_toggle.get(ch.channel_id, 0) + 1 + if cnt >= random.randint(8, 40): + self._sim_state[ch.channel_id] = 1 - self._sim_state.get(ch.channel_id, 0) + cnt = 0 + self._sim_toggle[ch.channel_id] = cnt + result[ch.channel_id] = float(self._sim_state.get(ch.channel_id, 0)) + else: + result[ch.channel_id] = float(self._output_state.get(ch.channel_id, 0)) + return result + + def write_channel(self, channel_id: str, value: Any) -> bool: + self._output_state[channel_id] = int(bool(value)) + if not self.simulate: + if self.backend == "nidaqmx" and self._ni_out_task: + try: + out_chs = [c for c in self.info.channels if c.channel_id.startswith("do")] + idx = next((i for i, c in enumerate(out_chs) if c.channel_id == channel_id), None) + if idx is not None: + states = [self._output_state.get(c.channel_id, 0) for c in out_chs] + self._ni_out_task.write(states) + except Exception as e: + print(f"[DigitalIODevice] NI write failed: {e}") + elif self.backend == "arduino" and self._ard_layer: + self._ard_layer.write(channel_id, int(bool(value))) + return True + + def get_config_widget(self) -> QWidget: + return DigitalIOConfigWidget(self) + + +class DigitalIOConfigWidget(QWidget): + def __init__(self, device: DigitalIODevice): + super().__init__() + self.device = device + self._buttons: Dict[str, QPushButton] = {} + self._build() + + def _build(self): + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + + be_grp = QGroupBox("Backend") + be_form = QFormLayout(be_grp) + self.be_cb = QComboBox() + self.be_cb.addItems(["nidaqmx", "arduino"]) + self.be_cb.setCurrentText(self.device.backend) + be_form.addRow("Backend:", self.be_cb) + self.sim_chk = QCheckBox("Simulate") + self.sim_chk.setChecked(self.device.simulate) + be_form.addRow(self.sim_chk) + root.addWidget(be_grp) + + out_grp = QGroupBox("Digital Outputs") + out_lay = QVBoxLayout(out_grp) + for ch in (c for c in self.device.info.channels if c.channel_id.startswith("do")): + row = QHBoxLayout() + lbl = QLabel(ch.name) + lbl.setMinimumWidth(50) + btn = QPushButton("OFF") + btn.setCheckable(True) + btn.setChecked(bool(self.device._output_state.get(ch.channel_id, 0))) + btn.setObjectName("digitalOutBtn") + cid = ch.channel_id + + def _tog(checked, c=cid, b=btn): + self.device.write_channel(c, checked) + b.setText("ON" if checked else "OFF") + + btn.toggled.connect(_tog) + row.addWidget(lbl) + row.addWidget(btn) + out_lay.addLayout(row) + self._buttons[ch.channel_id] = btn + + root.addWidget(out_grp) + root.addStretch() diff --git a/devices/serial_device.py b/devices/serial_device.py new file mode 100644 index 0000000..6bb7bf1 --- /dev/null +++ b/devices/serial_device.py @@ -0,0 +1,187 @@ +""" +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), + ) |
