# 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.