summaryrefslogtreecommitdiff
path: root/devices
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-04-13 15:22:56 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-04-13 15:22:56 -0600
commit2ed9d37da7b27d25173535550fb92702225ac14e (patch)
treed6ff360a656e577629d3cefdaa1ae02af7255864 /devices
parent8d6acf3a8ea4b37f86b321dbf430be5be01b1267 (diff)
Removed QtPy5 snippet and fixed directories
Diffstat (limited to 'devices')
-rw-r--r--devices/__init__.py5
-rw-r--r--devices/__pycache__/__init__.cpython-314.pycbin0 -> 398 bytes
-rw-r--r--devices/__pycache__/analog_input.cpython-314.pycbin0 -> 16238 bytes
-rw-r--r--devices/__pycache__/base_device.cpython-314.pycbin0 -> 7731 bytes
-rw-r--r--devices/__pycache__/device_registry.cpython-314.pycbin0 -> 5927 bytes
-rw-r--r--devices/__pycache__/digital_io.cpython-314.pycbin0 -> 19275 bytes
-rw-r--r--devices/__pycache__/serial_device.cpython-314.pycbin0 -> 11824 bytes
-rw-r--r--devices/analog_input.py276
-rw-r--r--devices/base_device.py104
-rw-r--r--devices/device_registry.py73
-rw-r--r--devices/digital_io.py261
-rw-r--r--devices/serial_device.py187
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
new file mode 100644
index 0000000..9ab29a6
--- /dev/null
+++ b/devices/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/analog_input.cpython-314.pyc b/devices/__pycache__/analog_input.cpython-314.pyc
new file mode 100644
index 0000000..4c08631
--- /dev/null
+++ b/devices/__pycache__/analog_input.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/base_device.cpython-314.pyc b/devices/__pycache__/base_device.cpython-314.pyc
new file mode 100644
index 0000000..ade2b20
--- /dev/null
+++ b/devices/__pycache__/base_device.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/device_registry.cpython-314.pyc b/devices/__pycache__/device_registry.cpython-314.pyc
new file mode 100644
index 0000000..608c8ee
--- /dev/null
+++ b/devices/__pycache__/device_registry.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/digital_io.cpython-314.pyc b/devices/__pycache__/digital_io.cpython-314.pyc
new file mode 100644
index 0000000..b2bb27f
--- /dev/null
+++ b/devices/__pycache__/digital_io.cpython-314.pyc
Binary files differ
diff --git a/devices/__pycache__/serial_device.cpython-314.pyc b/devices/__pycache__/serial_device.cpython-314.pyc
new file mode 100644
index 0000000..0674606
--- /dev/null
+++ b/devices/__pycache__/serial_device.cpython-314.pyc
Binary files differ
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),
+ )