From e81b74d7faf0ec884749795153124a2fa31411fc Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Tue, 11 Aug 2026 10:16:19 -0600 Subject: Added documentation to creating new devices and protocols --- docs/developer-new-devices.md | 233 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 docs/developer-new-devices.md (limited to 'docs/developer-new-devices.md') diff --git a/docs/developer-new-devices.md b/docs/developer-new-devices.md new file mode 100644 index 0000000..cc03a35 --- /dev/null +++ b/docs/developer-new-devices.md @@ -0,0 +1,233 @@ +# Adding a new device type + +A "device" is anything that reads and/or writes channels — NI-DAQ hardware, an +Arduino, a generic serial instrument, or something entirely new. This guide +walks through building one from scratch. Related but different: a *protocol* +is the wire format a serial device speaks (see +[developer-new-protocols.md](developer-new-protocols.md)) — you only need +this guide if you're adding a new kind of hardware, not a new serial format +for the existing `SerialDevice`. + +## The interface + +Every device subclasses `BaseDevice` (`devices/base_device.py`): + +```python +class BaseDevice(ABC): + def __init__(self, device_info: DeviceInfo): ... + + @abstractmethod + def connect(self) -> bool: ... + @abstractmethod + def disconnect(self) -> None: ... + @abstractmethod + def read_channels(self) -> Dict[str, float]: ... + @abstractmethod + def write_channel(self, channel_id: str, value: Any) -> bool: ... + @abstractmethod + def get_config_widget(self): ... +``` + +Plus two dataclasses you build up front: + +```python +ChannelConfig( + channel_id, name, unit="", min_value=0.0, max_value=100.0, + enabled=True, color="#00d4ff", + writable=False, # True = this channel accepts write_channel() calls; + # drives the READ/WRITE badge in Configuration > Channels + extra={}, # anything protocol/device-specific you need later +) + +DeviceInfo( + device_id, name, device_type, # device_type is a short slug, e.g. "serial" + description="", manufacturer="", model="", version="1.0.0", + icon="⚙", channels=[...], # built once in __init__, before super().__init__() +) +``` + +## Minimal skeleton + +```python +# devices/my_device.py +from typing import Any, Dict +from devices.base_device import BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus + +class MyDevice(BaseDevice): + DEVICE_TYPE = "my_device" # matches the key you'll add to profile.py's factories + ICON = "⚡" + + def __init__(self, device_id: str = "my_0", simulate: bool = True, **kw): + self.simulate = simulate + channels = [ + ChannelConfig("temp", "Temperature", unit="C", min_value=-40, max_value=150), + ChannelConfig("heater", "Heater", writable=True, min_value=0, max_value=1), + ] + info = DeviceInfo( + device_id=device_id, name="My Device", device_type=self.DEVICE_TYPE, + icon=self.ICON, channels=channels, + ) + super().__init__(info) + + def connect(self) -> bool: + self.status = DeviceStatus.SIMULATED if self.simulate else DeviceStatus.CONNECTED + return True + + def disconnect(self) -> None: + self.status = DeviceStatus.DISCONNECTED + + def read_channels(self) -> Dict[str, float]: + return {"temp": 23.5} # only include channels you actually have fresh data for + + def write_channel(self, channel_id: str, value: Any) -> bool: + if channel_id == "heater": + return True # actually drive the hardware here + return False + + def get_config_widget(self): + from PyQt6.QtWidgets import QLabel + return QLabel("No configuration needed.") # see below for a real example + + def get_save_config(self) -> dict: + return { + "device_type": self.DEVICE_TYPE, + "device_id": self.info.device_id, + "name": self.info.name, + "simulate": self.simulate, + } +``` + +Drop the file in `devices/` — `DeviceRegistry._discover()` (`devices/device_registry.py:25-39`) +scans that directory with `pkgutil` on every startup and registers any class +subclassing `BaseDevice`. No registration step, no import to add anywhere. + +## Things that aren't obvious from the abstract methods + +**`connect()` sets `self.status`, not just a bool.** `DeviceStatus` is +`DISCONNECTED | CONNECTING | CONNECTED | ERROR | SIMULATED`. The rest of the +app (poll loop, control-widget staleness check, status dot in Configuration > +Devices) reads `self.status`, not the return value. Every device follows the +same pattern: `SIMULATED` if `self.simulate`, else `CONNECTED` on success / +`ERROR` on failure. + +**`read_channels()` return value is sparse, not padded.** `core/acquisition.py`'s +poll loop only records a value for a channel if it appears in the dict you +return — omit a `channel_id` on a given tick (sensor didn't respond, channel +disabled) and it's simply skipped that tick, not zeroed. + +**`write_channel()` is only ever called for channels with `writable=True`** — +enforced both by `ControlEditorDialog`'s target picker (`ui/control_editor.py`) +and `ControlWidget._write()`'s guard (`ui/control_panel.py`). You don't need +to re-check `writable` yourself, but do return `False` for any `channel_id` +you don't recognize rather than raising. + +**Auto-naming.** If your device connects over a port the OS can describe +(serial, USB), prefer that over a hardcoded string — +`ArduinoLayer.describe_port(port)` (`api_layers/arduino_layer.py`) returns +the same description Windows Device Manager / Linux udev shows, so +`devices/serial_device.py`'s `_auto_name()` and `devices/arduino_device.py`'s +constructor both do: +```python +desc = ArduinoLayer.describe_port(port) +name = desc if (desc and port in desc) else f"{desc} ({port})" if desc else "My Device" +``` +(the `port in desc` check matters — some OS-reported descriptions already +embed the port string, e.g. `"USB Serial Device (COM7)"`, so blindly +appending `(port)` again produces a doubled-up name.) + +**Profile persistence.** Implement `get_save_config() -> dict` (not part of +the abstract interface, but every built-in device has one — it's how +`core/profile.py` reconstructs your device on load). Always include +`device_type` and `device_id`; everything else is whatever kwargs your +`__init__` needs to rebuild the same device. Then register a factory in +`core/profile.py::ProfileManager.apply()` (`_DEVICE_FACTORIES` dict, around +line 271): +```python +try: + from devices.my_device import MyDevice + _DEVICE_FACTORIES["my_device"] = MyDevice +except Exception: + pass +``` +This is the one place you *do* have to add a manual registration — device +auto-discovery finds the class for the "Add Device" dialog, but profile +restore needs to know which class a saved `device_type` string maps back to. +(If you're shipping a device as a plugin instead of a core device, skip +editing `profile.py` — call `ProfileManager.register_device_factory()` from +your plugin's `on_load()` instead; see `docs/plugin-development.md`.) + +## Real config widget example + +`get_config_widget()` is shown in the "Hardware / Backend" tab of +`ui/config_dialog.py::DeviceConfigDialog` when the operator clicks +"Configure" on your device's row. Simplest real pattern, adapted from +`devices/arduino_device.py`'s `ArduinoConfigWidget`: + +```python +class MyConfigWidget(QWidget): + def __init__(self, device: MyDevice): + super().__init__() + self.device = device + form = QFormLayout(self) + + self.sim_chk = QCheckBox("Simulation Mode (no hardware)") + self.sim_chk.setChecked(device.simulate) + form.addRow(self.sim_chk) + + apply_btn = QPushButton("Apply & Reconnect") + apply_btn.clicked.connect(self._apply) + form.addRow(apply_btn) + + def _apply(self): + was_on = self.device.status in (DeviceStatus.CONNECTED, DeviceStatus.SIMULATED) + if was_on: + self.device.disconnect() + self.device.simulate = self.sim_chk.isChecked() + if was_on: + self.device.connect() +``` + +If your device needs to change its channel list at runtime (different pin +count, different protocol), give it a `reconfigure(**kwargs)` method that +rebuilds `self.info.channels` and re-creates its backend layer — see +`SerialDevice.reconfigure()` for the fullest example (handles five different +backend layer types depending on `parse_format`). + +## Making it reachable from the Add Device dialog + +Auto-discovery registers your class with `DeviceRegistry` so it's +constructible, but the "+ Add Device" dialog (`ui/add_device_dialog.py`) +has its own hardcoded `_PANELS` dict and `_on_add()` if/elif chain — a brand +new *core* device type needs a small panel class added there (same shape as +`SerialPanel`/`ArduinoPanel`/`NidaqmxPanel`) plus a branch in `_on_add()` +that constructs it. This is deliberately not auto-wired, since the dialog +needs to know what per-type fields to show before any instance exists. + +If you're building this as a plugin instead, you don't touch +`add_device_dialog.py` at all — extend `ui.add_device_dialog._PANELS` +directly from your plugin's `on_load()`, per `docs/plugin-development.md`. + +## Multiple devices sharing one physical connection + +If several device instances can legitimately share one serial port (this is +why `AnalogInputDevice`/`DigitalIODevice` exist as separate classes that +both talk to one Arduino), don't construct your transport layer directly — +route through `api_layers/port_registry.py`'s `port_registry.get_layer(port, +baud, simulate, extra_pins)` singleton, which returns the same instance for +repeated calls with the same `(port, baud)` and reference-counts +`release()` calls so the port isn't closed while another device is still +using it. + +## Checklist + +1. Subclass `BaseDevice` in a new file under `devices/` — implement the 5 + abstract methods. +2. Add `get_save_config()` and register a factory in + `core/profile.py::ProfileManager.apply()` (`_DEVICE_FACTORIES`). +3. If runtime reconfiguration matters, add `reconfigure(**kwargs)`. +4. If it needs to show up in "+ Add Device", add a panel to + `ui/add_device_dialog.py`'s `_PANELS` + `_on_add()`. +5. If multiple instances might share one physical port, go through + `port_registry` instead of opening the connection yourself. +6. Run the app — `DeviceRegistry` picks the class up automatically, no + import to add. -- cgit v1.2.3