summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-04-21 22:58:30 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-04-21 22:58:30 -0600
commit4db119fb9c04428e7aef6f1a278c1639aa867baf (patch)
treee0e0645559637e593f1bbfb9d713256028668527
parent97e15f69b835af03b071c4eeb4547c65a02e6f80 (diff)
Major update to Output control
Including: - Control panel - Change status of simulation after devices creation. - Arduino serial conflict with multiple channels. - Claude instructions. - Update to arduino on board code.
-rw-r--r--CLAUDE.md81
-rw-r--r--CLAUDE.original.md81
-rw-r--r--api_layers/__pycache__/__init__.cpython-312.pycbin322 -> 309 bytes
-rw-r--r--api_layers/__pycache__/arduino_layer.cpython-312.pycbin30786 -> 31077 bytes
-rw-r--r--api_layers/__pycache__/nidaqmx_layer.cpython-312.pycbin8512 -> 8492 bytes
-rw-r--r--api_layers/__pycache__/port_registry.cpython-312.pycbin0 -> 5972 bytes
-rw-r--r--api_layers/arduino_layer.py12
-rw-r--r--api_layers/port_registry.py124
-rw-r--r--core/__pycache__/__init__.cpython-312.pycbin275 -> 262 bytes
-rw-r--r--core/__pycache__/acquisition.cpython-312.pycbin11079 -> 11080 bytes
-rw-r--r--core/__pycache__/profile.cpython-312.pycbin11790 -> 11805 bytes
-rw-r--r--core/__pycache__/signal_processor.cpython-312.pycbin23685 -> 23755 bytes
-rw-r--r--devices/__init__.py3
-rw-r--r--devices/__pycache__/__init__.cpython-312.pycbin396 -> 290 bytes
-rw-r--r--devices/__pycache__/analog_input.cpython-312.pycbin23750 -> 24161 bytes
-rw-r--r--devices/__pycache__/base_device.cpython-312.pycbin5615 -> 5604 bytes
-rw-r--r--devices/__pycache__/device_registry.cpython-312.pycbin4582 -> 4579 bytes
-rw-r--r--devices/__pycache__/digital_io.cpython-312.pycbin18353 -> 21995 bytes
-rw-r--r--devices/__pycache__/serial_device.cpython-312.pycbin14680 -> 14645 bytes
-rw-r--r--devices/analog_input.py36
-rw-r--r--devices/digital_io.py73
-rw-r--r--ui/__pycache__/__init__.cpython-312.pycbin151 -> 138 bytes
-rw-r--r--ui/__pycache__/add_device_dialog.cpython-312.pycbin0 -> 21811 bytes
-rw-r--r--ui/__pycache__/config_dialog.cpython-312.pycbin0 -> 6931 bytes
-rw-r--r--ui/__pycache__/control_editor.cpython-312.pycbin20107 -> 20127 bytes
-rw-r--r--ui/__pycache__/control_panel.cpython-312.pycbin39988 -> 39996 bytes
-rw-r--r--ui/__pycache__/main_window.cpython-312.pycbin25316 -> 25368 bytes
-rw-r--r--ui/__pycache__/profile_manager_ui.cpython-312.pycbin8368 -> 8360 bytes
-rw-r--r--ui/__pycache__/strip_chart.cpython-312.pycbin14890 -> 14890 bytes
-rw-r--r--ui/control_editor.py2
-rw-r--r--ui/main_window.py8
-rw-r--r--ui/windows/__pycache__/__init__.cpython-312.pycbin159 -> 146 bytes
-rw-r--r--ui/windows/__pycache__/devices_window.cpython-312.pycbin22019 -> 22030 bytes
-rw-r--r--ui/windows/__pycache__/plot_window.cpython-312.pycbin44562 -> 44675 bytes
-rw-r--r--ui/windows/__pycache__/settings_window.cpython-312.pycbin15917 -> 15924 bytes
-rw-r--r--ui/windows/__pycache__/signals_window.cpython-312.pycbin51395 -> 51458 bytes
36 files changed, 394 insertions, 26 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..5347d9d
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,81 @@
+# CLAUDE.md
+
+Guidance for Claude Code (claude.ai/code) working in this repo.
+
+## Running the app
+
+```bash
+python main.py
+```
+
+No build step. Simulation mode default (no hardware needed).
+
+## Dependencies
+
+```bash
+pip install PyQt6 pyqtgraph numpy pyserial
+pip install nidaqmx # optional — only for real NI hardware
+```
+
+## Architecture
+
+### Data flow
+
+```
+Hardware / Simulation
+ ↓
+ api_layers/ ArduinoLayer, NidaqmxLayer — low-level I/O, background threads
+ ↓
+ devices/ BaseDevice subclasses — wrap api_layers, expose read_channels() / write_channel()
+ ↓
+ core/acquisition.py AcquisitionEngine — polls devices at 100 ms, fills ChannelBuffers, emits new_data signal
+ ↓
+ core/signal_processor.py SignalProcessor — filters + derived channels, re-emits processed_data
+ ↓
+ ui/strip_chart.py StripChartWidget — consumes processed_data, renders via pyqtgraph
+```
+
+Control widgets (left panel) go other direction: UI → `ControlWidget._write()` → `DeviceRegistry.get_instance()` → `device.write_channel()` → api_layer.
+
+### Key design patterns
+
+**Shared serial port** — `api_layers/port_registry.py` holds module-level `port_registry` singleton. When multiple devices share one Arduino (e.g. `AnalogInputDevice` + `DigitalIODevice` on same port), both call `port_registry.get_layer(port, baud)`, get same `ArduinoLayer` instance. Never construct `ArduinoLayer` directly in device code.
+
+**Device auto-discovery** — `DeviceRegistry._discover()` scans `devices/` with `pkgutil`, imports every module, registers any class subclassing `BaseDevice`. New device type = drop file in `devices/`, no registration needed.
+
+**Backend switching at runtime** — `AnalogInputDevice` and `DigitalIODevice` both have `switch_backend(backend, simulate, ...)` — disconnects, reconfigures, reconnects without restart. Called from "Apply & Reconnect" in config dialog.
+
+**Profile persistence** — `core/profile.py` serialises full operator state (controls, plot layout, signal pipelines, derived channels) to `.labdaq` JSON files. `ProfileManager` handles save/load.
+
+### Directory map
+
+| Path | Purpose |
+|------|---------|
+| `api_layers/arduino_layer.py` | Serial protocol + simulation; `ARDUINO_FIRMWARE` string is the uploadable sketch |
+| `api_layers/nidaqmx_layer.py` | NI-DAQmx wrapper with simulation fallback |
+| `api_layers/port_registry.py` | Shared `ArduinoLayer` singleton per (port, baud) |
+| `devices/base_device.py` | `BaseDevice`, `ChannelConfig`, `DeviceInfo`, `DeviceStatus` |
+| `devices/analog_input.py` | Analog input — NI or Arduino backend |
+| `devices/digital_io.py` | Digital I/O — NI or Arduino backend |
+| `devices/serial_device.py` | Generic UART device |
+| `core/acquisition.py` | `AcquisitionEngine` + `ChannelBuffer` |
+| `core/signal_processor.py` | Filter chain + derived/virtual channels |
+| `core/profile.py` | `.labdaq` profile save/load |
+| `ui/main_window.py` | Top-level window, toolbar, demo device init |
+| `ui/control_panel.py` | Left panel output widgets (`OnOffSwitch`, `MotorControl`, etc.) |
+| `ui/strip_chart.py` | Live pyqtgraph chart, config-driven by `LayoutConfig` |
+| `ui/windows/` | Floating tool windows (Devices, Signals, Plot, Settings) |
+| `ui/style_dark.qss` / `style_light.qss` | Full app theme |
+
+### Arduino firmware
+
+Firmware embedded as `ARDUINO_FIRMWARE` in `api_layers/arduino_layer.py`. When editing: set `N_DIG_OUT` to match digital output pins in use (default 0 — leaves pins in INPUT mode, causes inverted write behaviour). Upload via Arduino IDE.
+
+Serial protocol: `A0:3.14,D2:1\n` stream from Arduino; `W:D7:1\n` / `P:D9:128\n` commands from PC.
+
+### Adding a new device type
+
+1. Subclass `BaseDevice` in new file under `devices/`
+2. Implement: `connect()`, `disconnect()`, `read_channels() → Dict[str, float]`, `write_channel(channel_id, value) → bool`, `get_config_widget() → QWidget`
+3. Include `switch_backend()` if device supports runtime reconfiguration
+4. `DeviceRegistry` discovers it automatically on next run \ No newline at end of file
diff --git a/CLAUDE.original.md b/CLAUDE.original.md
new file mode 100644
index 0000000..05c7092
--- /dev/null
+++ b/CLAUDE.original.md
@@ -0,0 +1,81 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Running the app
+
+```bash
+python main.py
+```
+
+No build step. Runs in simulation mode by default (no hardware needed).
+
+## Dependencies
+
+```bash
+pip install PyQt6 pyqtgraph numpy pyserial
+pip install nidaqmx # optional — only for real NI hardware
+```
+
+## Architecture
+
+### Data flow
+
+```
+Hardware / Simulation
+ ↓
+ api_layers/ ArduinoLayer, NidaqmxLayer — low-level I/O, background threads
+ ↓
+ devices/ BaseDevice subclasses — wrap api_layers, expose read_channels() / write_channel()
+ ↓
+ core/acquisition.py AcquisitionEngine — polls devices at 100 ms, fills ChannelBuffers, emits new_data signal
+ ↓
+ core/signal_processor.py SignalProcessor — filters + derived channels, re-emits processed_data
+ ↓
+ ui/strip_chart.py StripChartWidget — consumes processed_data, renders via pyqtgraph
+```
+
+Control widgets (left panel) go the other direction: UI → `ControlWidget._write()` → `DeviceRegistry.get_instance()` → `device.write_channel()` → api_layer.
+
+### Key design patterns
+
+**Shared serial port** — `api_layers/port_registry.py` holds a module-level `port_registry` singleton. When multiple devices share one Arduino (e.g. `AnalogInputDevice` + `DigitalIODevice` on the same port), both call `port_registry.get_layer(port, baud)` and get the same `ArduinoLayer` instance. Never construct `ArduinoLayer` directly in device code.
+
+**Device auto-discovery** — `DeviceRegistry._discover()` scans `devices/` with `pkgutil`, imports every module, and registers any class that subclasses `BaseDevice`. Adding a new device type = drop a file in `devices/`, no registration needed.
+
+**Backend switching at runtime** — `AnalogInputDevice` and `DigitalIODevice` both have `switch_backend(backend, simulate, ...)` that disconnects, reconfigures, and reconnects without restarting. Called from "Apply & Reconnect" in the config dialog.
+
+**Profile persistence** — `core/profile.py` serialises the full operator state (controls, plot layout, signal pipelines, derived channels) to `.labdaq` JSON files. `ProfileManager` handles save/load.
+
+### Directory map
+
+| Path | Purpose |
+|------|---------|
+| `api_layers/arduino_layer.py` | Serial protocol + simulation; `ARDUINO_FIRMWARE` string is the uploadable sketch |
+| `api_layers/nidaqmx_layer.py` | NI-DAQmx wrapper with simulation fallback |
+| `api_layers/port_registry.py` | Shared `ArduinoLayer` singleton per (port, baud) |
+| `devices/base_device.py` | `BaseDevice`, `ChannelConfig`, `DeviceInfo`, `DeviceStatus` |
+| `devices/analog_input.py` | Analog input — NI or Arduino backend |
+| `devices/digital_io.py` | Digital I/O — NI or Arduino backend |
+| `devices/serial_device.py` | Generic UART device |
+| `core/acquisition.py` | `AcquisitionEngine` + `ChannelBuffer` |
+| `core/signal_processor.py` | Filter chain + derived/virtual channels |
+| `core/profile.py` | `.labdaq` profile save/load |
+| `ui/main_window.py` | Top-level window, toolbar, demo device init |
+| `ui/control_panel.py` | Left panel output widgets (`OnOffSwitch`, `MotorControl`, etc.) |
+| `ui/strip_chart.py` | Live pyqtgraph chart, config-driven by `LayoutConfig` |
+| `ui/windows/` | Floating tool windows (Devices, Signals, Plot, Settings) |
+| `ui/style_dark.qss` / `style_light.qss` | Full app theme |
+
+### Arduino firmware
+
+The firmware is embedded as `ARDUINO_FIRMWARE` in `api_layers/arduino_layer.py`. When editing it, set `N_DIG_OUT` to match the number of digital output pins in use (default was 0 — leaving pins in INPUT mode causes inverted write behaviour). Upload via Arduino IDE.
+
+Serial protocol: `A0:3.14,D2:1\n` stream from Arduino; `W:D7:1\n` / `P:D9:128\n` commands from PC.
+
+### Adding a new device type
+
+1. Subclass `BaseDevice` in a new file under `devices/`
+2. Implement: `connect()`, `disconnect()`, `read_channels() → Dict[str, float]`, `write_channel(channel_id, value) → bool`, `get_config_widget() → QWidget`
+3. Include `switch_backend()` if the device supports runtime reconfiguration
+4. `DeviceRegistry` discovers it automatically on next run
diff --git a/api_layers/__pycache__/__init__.cpython-312.pyc b/api_layers/__pycache__/__init__.cpython-312.pyc
index 8aa354b..3c56ecd 100644
--- a/api_layers/__pycache__/__init__.cpython-312.pyc
+++ b/api_layers/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/api_layers/__pycache__/arduino_layer.cpython-312.pyc b/api_layers/__pycache__/arduino_layer.cpython-312.pyc
index f6c3ff9..a0191ad 100644
--- a/api_layers/__pycache__/arduino_layer.cpython-312.pyc
+++ b/api_layers/__pycache__/arduino_layer.cpython-312.pyc
Binary files differ
diff --git a/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc b/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc
index de2c034..93a5ef2 100644
--- a/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc
+++ b/api_layers/__pycache__/nidaqmx_layer.cpython-312.pyc
Binary files differ
diff --git a/api_layers/__pycache__/port_registry.cpython-312.pyc b/api_layers/__pycache__/port_registry.cpython-312.pyc
new file mode 100644
index 0000000..44a52e8
--- /dev/null
+++ b/api_layers/__pycache__/port_registry.cpython-312.pyc
Binary files differ
diff --git a/api_layers/arduino_layer.py b/api_layers/arduino_layer.py
index fb09a16..fed54f9 100644
--- a/api_layers/arduino_layer.py
+++ b/api_layers/arduino_layer.py
@@ -551,7 +551,7 @@ const int DIGITAL_IN[] = {2, 3, 4};
const int DIGITAL_OUT[] = {5, 6, 7, 9, 10, 11}; // 9,10,11 are PWM-capable
const int N_ANALOG = 1; // ← set to how many analog pins you use
const int N_DIG_IN = 0; // ← set to how many digital inputs you use
-const int N_DIG_OUT = 0; // ← set to how many digital outputs you use
+const int N_DIG_OUT = 6; // ← set to how many digital outputs you use
const int SEND_INTERVAL = 50; // ms between data frames (50 = 20 Hz)
const long BAUD_RATE = 115200;
@@ -666,16 +666,22 @@ void sendData() {
void sendFrame() {
String out = "";
+ bool first = true;
// Analog inputs — converted to 0.0–5.0 V
for (int i = 0; i < N_ANALOG; i++) {
float v = analogRead(ANALOG_PINS[i]) * (5.0 / 1023.0);
- if (i > 0) out += ",";
+ if (!first) out += ",";
out += "A" + String(i) + ":" + String(v, 3);
+ first = false;
}
// Digital inputs (INPUT_PULLUP — invert so pressed=1)
+ // These share the same output line as analog — one line per frame.
for (int i = 0; i < N_DIG_IN; i++) {
- out += ",D" + String(DIGITAL_IN[i]) + ":" + String(!digitalRead(DIGITAL_IN[i]));
+ if (!first) out += ",";
+ out += "D" + String(DIGITAL_IN[i]) + ":" + String(!digitalRead(DIGITAL_IN[i]));
+ first = false;
}
Serial.println(out);
+ // Example combined output: A0:3.142,D2:0,D3:1
}
"""
diff --git a/api_layers/port_registry.py b/api_layers/port_registry.py
new file mode 100644
index 0000000..e3c85fd
--- /dev/null
+++ b/api_layers/port_registry.py
@@ -0,0 +1,124 @@
+"""
+api_layers/port_registry.py
+
+PortRegistry — ensures one ArduinoLayer per physical serial port.
+
+Problem: if both an AnalogInputDevice and a DigitalIODevice are
+configured for the same Arduino (/dev/ttyUSB0), they would each
+create their own ArduinoLayer and both try to open the same serial
+port → "device reports readiness to read but returned no data".
+
+Solution: all devices call PortRegistry.get_layer(port, baud) instead
+of constructing ArduinoLayer directly. The registry returns an
+existing layer if one is already open on that port, or creates a new
+one. All reads/writes go through the single shared layer.
+
+The registry also merges analog_pins across devices so the layer
+caches values for all channels on the Arduino.
+
+Usage (handled automatically by AnalogInputDevice and DigitalIODevice):
+
+ from api_layers.port_registry import port_registry
+ layer = port_registry.get_layer("/dev/ttyUSB0", 115200, simulate=False)
+ layer.connect_if_needed()
+"""
+
+from __future__ import annotations
+import threading
+from typing import Dict, List, Optional, Tuple
+
+
+class PortRegistry:
+ """
+ Global registry of shared ArduinoLayer instances.
+ Thread-safe singleton accessed via the module-level `port_registry`.
+ """
+
+ def __init__(self):
+ self._lock: threading.Lock = threading.Lock()
+ self._layers: Dict[Tuple[str, int], object] = {} # (port, baud) → ArduinoLayer
+ self._ref_counts: Dict[Tuple[str, int], int] = {}
+
+ def get_layer(self, port: str, baud: int,
+ simulate: bool = False,
+ extra_pins: List[str] = None) -> "ArduinoLayer": # type: ignore
+ """
+ Return a shared ArduinoLayer for (port, baud).
+ Creates one if it doesn't exist yet.
+ If simulate=True, a simulation layer is returned (always separate,
+ so simulated devices don't share state with each other).
+ """
+ from api_layers.arduino_layer import ArduinoLayer
+
+ if simulate:
+ # Simulated devices get their own independent layer
+ layer = ArduinoLayer(port=port, baud=baud, simulate=True)
+ if extra_pins:
+ layer.analog_pins = list(extra_pins)
+ return layer
+
+ key = (port, baud)
+ with self._lock:
+ if key not in self._layers:
+ layer = ArduinoLayer(port=port, baud=baud, simulate=False)
+ if extra_pins:
+ layer.analog_pins = list(extra_pins)
+ self._layers[key] = layer
+ self._ref_counts[key] = 0
+ else:
+ layer = self._layers[key]
+ # Merge any new pin names so the shared cache covers them
+ if extra_pins:
+ existing = set(layer.analog_pins)
+ for p in extra_pins:
+ if p not in existing:
+ layer.analog_pins.append(p)
+ self._ref_counts[key] += 1
+ return layer
+
+ def release(self, port: str, baud: int) -> None:
+ """
+ Decrement ref count for a port. Disconnects and removes the
+ layer when no devices are using it any more.
+ """
+ key = (port, baud)
+ with self._lock:
+ if key not in self._ref_counts:
+ return
+ self._ref_counts[key] -= 1
+ if self._ref_counts[key] <= 0:
+ layer = self._layers.pop(key, None)
+ self._ref_counts.pop(key, None)
+ if layer:
+ try:
+ layer.disconnect()
+ except Exception:
+ pass
+
+ def connect_all(self) -> None:
+ """Connect any layers that haven't been connected yet."""
+ with self._lock:
+ layers = list(self._layers.values())
+ for layer in layers:
+ if not layer.is_connected:
+ layer.connect()
+
+ def all_layers(self):
+ with self._lock:
+ return list(self._layers.values())
+
+ def clear(self) -> None:
+ """Disconnect and remove all managed layers."""
+ with self._lock:
+ layers = list(self._layers.values())
+ self._layers.clear()
+ self._ref_counts.clear()
+ for layer in layers:
+ try:
+ layer.disconnect()
+ except Exception:
+ pass
+
+
+# Module-level singleton
+port_registry = PortRegistry()
diff --git a/core/__pycache__/__init__.cpython-312.pyc b/core/__pycache__/__init__.cpython-312.pyc
index 36a9dda..d1caea1 100644
--- a/core/__pycache__/__init__.cpython-312.pyc
+++ b/core/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/core/__pycache__/acquisition.cpython-312.pyc b/core/__pycache__/acquisition.cpython-312.pyc
index 3d036ae..a929bdf 100644
--- a/core/__pycache__/acquisition.cpython-312.pyc
+++ b/core/__pycache__/acquisition.cpython-312.pyc
Binary files differ
diff --git a/core/__pycache__/profile.cpython-312.pyc b/core/__pycache__/profile.cpython-312.pyc
index 8637365..1c5afbf 100644
--- a/core/__pycache__/profile.cpython-312.pyc
+++ b/core/__pycache__/profile.cpython-312.pyc
Binary files differ
diff --git a/core/__pycache__/signal_processor.cpython-312.pyc b/core/__pycache__/signal_processor.cpython-312.pyc
index fe7bba0..fc31c25 100644
--- a/core/__pycache__/signal_processor.cpython-312.pyc
+++ b/core/__pycache__/signal_processor.cpython-312.pyc
Binary files differ
diff --git a/devices/__init__.py b/devices/__init__.py
index f79b333..7cee4ac 100644
--- a/devices/__init__.py
+++ b/devices/__init__.py
@@ -1,5 +1,4 @@
-# devices/__init__.py
-from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus
+# 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-312.pyc b/devices/__pycache__/__init__.cpython-312.pyc
index bd9bb71..7c1bbbd 100644
--- a/devices/__pycache__/__init__.cpython-312.pyc
+++ b/devices/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/analog_input.cpython-312.pyc b/devices/__pycache__/analog_input.cpython-312.pyc
index 2ff8a31..fefede6 100644
--- a/devices/__pycache__/analog_input.cpython-312.pyc
+++ b/devices/__pycache__/analog_input.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/base_device.cpython-312.pyc b/devices/__pycache__/base_device.cpython-312.pyc
index f665ff1..bfab8b5 100644
--- a/devices/__pycache__/base_device.cpython-312.pyc
+++ b/devices/__pycache__/base_device.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/device_registry.cpython-312.pyc b/devices/__pycache__/device_registry.cpython-312.pyc
index 2694da3..d8b72e0 100644
--- a/devices/__pycache__/device_registry.cpython-312.pyc
+++ b/devices/__pycache__/device_registry.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/digital_io.cpython-312.pyc b/devices/__pycache__/digital_io.cpython-312.pyc
index 8c7375b..7fa10a5 100644
--- a/devices/__pycache__/digital_io.cpython-312.pyc
+++ b/devices/__pycache__/digital_io.cpython-312.pyc
Binary files differ
diff --git a/devices/__pycache__/serial_device.cpython-312.pyc b/devices/__pycache__/serial_device.cpython-312.pyc
index 7472878..5cacb6b 100644
--- a/devices/__pycache__/serial_device.cpython-312.pyc
+++ b/devices/__pycache__/serial_device.cpython-312.pyc
Binary files differ
diff --git a/devices/analog_input.py b/devices/analog_input.py
index 508804f..e1b8b09 100644
--- a/devices/analog_input.py
+++ b/devices/analog_input.py
@@ -90,11 +90,13 @@ class AnalogInputDevice(BaseDevice):
ni_device, ni_min_v, ni_max_v,
ard_port, ard_baud, channel_ids):
if backend == "arduino":
- return ArduinoLayer(
+ # Use shared layer so two devices on the same port don't conflict
+ from api_layers.port_registry import port_registry
+ return port_registry.get_layer(
port=ard_port,
baud=ard_baud,
- analog_pins=channel_ids,
simulate=simulate,
+ extra_pins=channel_ids,
)
else:
return NidaqmxLayer(
@@ -110,13 +112,17 @@ class AnalogInputDevice(BaseDevice):
def connect(self) -> bool:
self._last_error = ""
- # ArduinoLayer uses connect(); NidaqmxLayer uses start()
+ # ArduinoLayer: use connect_if_needed so shared layers aren't
+ # opened twice when multiple devices share the same port.
+ # NidaqmxLayer: uses start()
if hasattr(self._layer, "connect"):
- ok = self._layer.connect()
+ if self._layer.is_connected:
+ ok = True # already open — shared with another device
+ else:
+ ok = self._layer.connect()
else:
ok = self._layer.start()
- # Surface the underlying error message if available
if not ok and hasattr(self._layer, "last_error"):
self._last_error = self._layer.last_error
@@ -128,13 +134,19 @@ class AnalogInputDevice(BaseDevice):
return ok
def disconnect(self) -> None:
- try:
- if hasattr(self._layer, "disconnect"):
- self._layer.disconnect()
- elif hasattr(self._layer, "stop"):
- self._layer.stop()
- except Exception:
- pass
+ # For shared Arduino layers, release our ref count via port_registry.
+ # The layer stays open until the last device using it disconnects.
+ if self.backend == "arduino" and not self.simulate:
+ from api_layers.port_registry import port_registry
+ port_registry.release(self._ard_port, self._ard_baud)
+ else:
+ try:
+ if hasattr(self._layer, "stop"):
+ self._layer.stop()
+ elif hasattr(self._layer, "disconnect"):
+ self._layer.disconnect()
+ except Exception:
+ pass
self.status = DeviceStatus.DISCONNECTED
def read_channels(self) -> Dict[str, float]:
diff --git a/devices/digital_io.py b/devices/digital_io.py
index 53014b5..3d24192 100644
--- a/devices/digital_io.py
+++ b/devices/digital_io.py
@@ -126,12 +126,18 @@ class DigitalIODevice(BaseDevice):
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")],
+ from api_layers.port_registry import port_registry
+ # Get shared layer — won't open a second connection if AnalogInputDevice
+ # is already connected on the same port
+ self._ard_layer = port_registry.get_layer(
+ port=self._ard_port,
+ baud=self._ard_baud,
simulate=False,
)
+ if self._ard_layer.is_connected:
+ # Port already open — just register ourselves and return OK
+ self.status = DeviceStatus.CONNECTED
+ return True
ok = self._ard_layer.connect()
self.status = DeviceStatus.CONNECTED if ok else DeviceStatus.ERROR
return ok
@@ -143,8 +149,10 @@ class DigitalIODevice(BaseDevice):
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()
+ if self._ard_layer and self.backend == "arduino" and not self.simulate:
+ from api_layers.port_registry import port_registry
+ port_registry.release(self._ard_port, self._ard_baud)
+ self._ard_layer = None
self.status = DeviceStatus.DISCONNECTED
def read_channels(self) -> Dict[str, float]:
@@ -226,6 +234,24 @@ class DigitalIODevice(BaseDevice):
return self._ard_layer.set_parameter(name, value)
return False
+ def switch_backend(self, backend: str, simulate: bool,
+ ni_device: str, ard_port: str, ard_baud: int) -> None:
+ was_running = self.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED)
+ if was_running:
+ self.disconnect()
+
+ self.backend = backend
+ self.simulate = simulate
+ self._ni_device = ni_device
+ self._ard_port = ard_port
+ self._ard_baud = ard_baud
+ self._ard_layer = None
+ self._ni_in_task = None
+ self._ni_out_task = None
+
+ if was_running:
+ self.connect()
+
def get_config_widget(self) -> QWidget:
return DigitalIOConfigWidget(self)
@@ -251,7 +277,27 @@ class DigitalIOConfigWidget(QWidget):
self.sim_chk = QCheckBox("Simulate")
self.sim_chk.setChecked(self.device.simulate)
be_form.addRow(self.sim_chk)
+
+ self.ni_dev_edit = QLineEdit(self.device._ni_device)
+ be_form.addRow("NI Device:", self.ni_dev_edit)
+
+ self.ard_port_edit = QLineEdit(self.device._ard_port)
+ self.ard_port_edit.setPlaceholderText("e.g. /dev/ttyUSB0 or COM3")
+ be_form.addRow("Arduino Port:", self.ard_port_edit)
+
+ self.ard_baud_cb = QComboBox()
+ self.ard_baud_cb.addItems(["9600", "19200", "57600", "115200", "230400"])
+ self.ard_baud_cb.setCurrentText(str(self.device._ard_baud))
+ be_form.addRow("Baud Rate:", self.ard_baud_cb)
+
+ apply_btn = QPushButton("Apply & Reconnect")
+ apply_btn.setObjectName("applyButton")
+ apply_btn.clicked.connect(self._apply)
+ be_form.addRow(apply_btn)
+
+ self.be_cb.currentTextChanged.connect(self._update_visibility)
root.addWidget(be_grp)
+ self._update_visibility(self.device.backend)
out_grp = QGroupBox("Digital Outputs")
out_lay = QVBoxLayout(out_grp)
@@ -277,3 +323,18 @@ class DigitalIOConfigWidget(QWidget):
root.addWidget(out_grp)
root.addStretch()
+
+ def _update_visibility(self, backend: str = ""):
+ backend = backend or self.be_cb.currentText()
+ self.ni_dev_edit.setVisible(backend == "nidaqmx")
+ self.ard_port_edit.setVisible(backend == "arduino")
+ self.ard_baud_cb.setVisible(backend == "arduino")
+
+ def _apply(self):
+ self.device.switch_backend(
+ backend=self.be_cb.currentText(),
+ simulate=self.sim_chk.isChecked(),
+ ni_device=self.ni_dev_edit.text().strip() or "Dev1",
+ ard_port=self.ard_port_edit.text().strip(),
+ ard_baud=int(self.ard_baud_cb.currentText()),
+ )
diff --git a/ui/__pycache__/__init__.cpython-312.pyc b/ui/__pycache__/__init__.cpython-312.pyc
index 6ed2349..fb20d59 100644
--- a/ui/__pycache__/__init__.cpython-312.pyc
+++ b/ui/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/add_device_dialog.cpython-312.pyc b/ui/__pycache__/add_device_dialog.cpython-312.pyc
new file mode 100644
index 0000000..8af1f9c
--- /dev/null
+++ b/ui/__pycache__/add_device_dialog.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/config_dialog.cpython-312.pyc b/ui/__pycache__/config_dialog.cpython-312.pyc
new file mode 100644
index 0000000..5a051dc
--- /dev/null
+++ b/ui/__pycache__/config_dialog.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/control_editor.cpython-312.pyc b/ui/__pycache__/control_editor.cpython-312.pyc
index f1716c6..d12d50f 100644
--- a/ui/__pycache__/control_editor.cpython-312.pyc
+++ b/ui/__pycache__/control_editor.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/control_panel.cpython-312.pyc b/ui/__pycache__/control_panel.cpython-312.pyc
index c54cd25..834c948 100644
--- a/ui/__pycache__/control_panel.cpython-312.pyc
+++ b/ui/__pycache__/control_panel.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/main_window.cpython-312.pyc b/ui/__pycache__/main_window.cpython-312.pyc
index b99d7c2..c5383cb 100644
--- a/ui/__pycache__/main_window.cpython-312.pyc
+++ b/ui/__pycache__/main_window.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/profile_manager_ui.cpython-312.pyc b/ui/__pycache__/profile_manager_ui.cpython-312.pyc
index e66a754..c9fbb4a 100644
--- a/ui/__pycache__/profile_manager_ui.cpython-312.pyc
+++ b/ui/__pycache__/profile_manager_ui.cpython-312.pyc
Binary files differ
diff --git a/ui/__pycache__/strip_chart.cpython-312.pyc b/ui/__pycache__/strip_chart.cpython-312.pyc
index ba3ca5d..47dbea1 100644
--- a/ui/__pycache__/strip_chart.cpython-312.pyc
+++ b/ui/__pycache__/strip_chart.cpython-312.pyc
Binary files differ
diff --git a/ui/control_editor.py b/ui/control_editor.py
index 93b8110..4d18aef 100644
--- a/ui/control_editor.py
+++ b/ui/control_editor.py
@@ -233,7 +233,7 @@ class ControlEditorDialog(QDialog):
btn_row = QHBoxLayout(); btn_row.addStretch()
cancel = QPushButton("Cancel"); cancel.clicked.connect(self.reject)
- ok = QPushButton("Save Control")
+ ok = QPushButton("Create Control")
ok.setObjectName("applyButton"); ok.setDefault(True)
ok.clicked.connect(self._on_ok)
btn_row.addWidget(cancel); btn_row.addWidget(ok)
diff --git a/ui/main_window.py b/ui/main_window.py
index a82a389..a123677 100644
--- a/ui/main_window.py
+++ b/ui/main_window.py
@@ -59,10 +59,14 @@ class MainWindow(QMainWindow):
# ── Demo ──────────────────────────────────────────────────────────────
def _init_demo_devices(self):
+ # NOTE: if you have a real Arduino, configure ard_0 and dio_0 to use
+ # the same port — port_registry ensures only one serial connection
+ # is opened, and both devices share it automatically.
for dev in [
AnalogInputDevice(device_id="ai_0", num_channels=4, simulate=True, backend="nidaqmx"),
- AnalogInputDevice(device_id="ard_0", num_channels=4, simulate=True, backend="arduino"),
- DigitalIODevice (device_id="dio_0", num_inputs=4, num_outputs=4, simulate=True),
+ AnalogInputDevice(device_id="ard_0", num_channels=1, simulate=True, backend="arduino"),
+ DigitalIODevice (device_id="dio_0", num_inputs=2, num_outputs=4,
+ simulate=True, backend="arduino"),
SerialDevice (device_id="ser_0", num_channels=3, simulate=True),
]:
dev.connect()
diff --git a/ui/windows/__pycache__/__init__.cpython-312.pyc b/ui/windows/__pycache__/__init__.cpython-312.pyc
index 244f840..37c4a45 100644
--- a/ui/windows/__pycache__/__init__.cpython-312.pyc
+++ b/ui/windows/__pycache__/__init__.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/devices_window.cpython-312.pyc b/ui/windows/__pycache__/devices_window.cpython-312.pyc
index f2ce20a..ca01f31 100644
--- a/ui/windows/__pycache__/devices_window.cpython-312.pyc
+++ b/ui/windows/__pycache__/devices_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/plot_window.cpython-312.pyc b/ui/windows/__pycache__/plot_window.cpython-312.pyc
index 5c957ed..c2e3745 100644
--- a/ui/windows/__pycache__/plot_window.cpython-312.pyc
+++ b/ui/windows/__pycache__/plot_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/settings_window.cpython-312.pyc b/ui/windows/__pycache__/settings_window.cpython-312.pyc
index 7da4e1a..72f96e1 100644
--- a/ui/windows/__pycache__/settings_window.cpython-312.pyc
+++ b/ui/windows/__pycache__/settings_window.cpython-312.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/signals_window.cpython-312.pyc b/ui/windows/__pycache__/signals_window.cpython-312.pyc
index 6b423d8..0526a85 100644
--- a/ui/windows/__pycache__/signals_window.cpython-312.pyc
+++ b/ui/windows/__pycache__/signals_window.cpython-312.pyc
Binary files differ