diff options
| author | Christian Kolset <ckolset@colostate.edu> | 2026-08-11 10:16:19 -0600 |
|---|---|---|
| committer | Christian Kolset <ckolset@colostate.edu> | 2026-08-11 10:16:19 -0600 |
| commit | e81b74d7faf0ec884749795153124a2fa31411fc (patch) | |
| tree | 133bab1c0ea85e85a6cf22522aa1020040fc2ab1 | |
| parent | 33ee94676ded306124d1cd51b571b6be4589cf7e (diff) | |
Added documentation to creating new devices and protocols
| -rw-r--r-- | docs/developer-new-devices.md | 233 | ||||
| -rw-r--r-- | docs/developer-new-protocols.md | 146 |
2 files changed, 379 insertions, 0 deletions
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. diff --git a/docs/developer-new-protocols.md b/docs/developer-new-protocols.md new file mode 100644 index 0000000..724bdc7 --- /dev/null +++ b/docs/developer-new-protocols.md @@ -0,0 +1,146 @@ +# Adding a new serial protocol + +A "protocol" here means a wire format `SerialDevice` (`devices/serial_device.py`) +can speak to a serial instrument — SCPI, Mark-10, Modbus RTU, CML are the +existing ones, living in `api_layers/protocols/`. This is narrower than +[developer-new-devices.md](developer-new-devices.md): you're adding a new +option to the existing "Serial / UART" device type's Protocol/Format +dropdown, not a whole new device class. If your hardware isn't +serial-based, or doesn't fit the "poll a channel map" shape at all, write a +new `BaseDevice` instead. + +## The interface + +Every protocol subclasses `BaseProtocol` (`api_layers/protocols/base_protocol.py`), +which already handles the connection, the background poll thread, and the +simulate/real split — you only implement three methods: + +```python +class BaseProtocol(ABC): + @abstractmethod + def _poll(self) -> Dict[str, float]: + """Read the instrument once, return {channel_id: value}.""" + + @abstractmethod + def _simulate(self, t: float) -> Dict[str, float]: + """Return plausible fake values at elapsed time t (seconds).""" + + @abstractmethod + def write(self, channel_id: str, value) -> bool: + """Send a command. Return False for unknown/read-only channel_id.""" +``` + +`BaseProtocol.__init__(port, baud, poll_interval=0.1, simulate=True)` gives +you `self._ser` (a `pyserial` `Serial`, only when not simulating — opened by +the base class's `connect()`, don't open it yourself), `self._io_lock` (hold +this around every `self._ser` read/write — the poll thread and any +synchronous `write()` calls share the same serial handle), and `self.read()` +which any caller uses to get the latest `_poll()`/`_simulate()` result +(cached, not blocking). + +## Two real shapes to copy from + +**Fixed channel set, gauge reports its own units — `api_layers/protocols/mark10.py`.** +The instrument always exposes the same channels; you don't need per-channel +configuration. `_poll()` sends `?\r`, parses `<value> <unit>` with a regex, +tracks the unit so it can be exposed via a `current_unit` property (this is +what lets the Mark-10 gauge's live lb/kgF/N/ozF switch propagate through to +plot axis labels — see `devices/serial_device.py:145-152` and +`core/acquisition.py`'s `channel_unit_changed` signal). Good template when +your instrument has a small, fixed, well-known channel set. + +**Configurable per-channel queries — `api_layers/protocols/scpi.py`.** +Channels are a list of small dataclasses (`SCPIChannel(channel_id, name, +query, unit, scale, write_cmd)`) the *operator* fills in per-instrument, +since SCPI query strings vary by model. `_poll()` iterates the list, sends +each query, parses the numeric response. Good template when the channel map +can't be known ahead of time — the corresponding config panel +(`_SCPIPanel` in `serial_device.py`) is an editable table, not static text. + +Pick whichever shape matches your instrument; most new protocols are one or +the other. + +## Wiring it into SerialDevice + +Five places in `devices/serial_device.py`, all keyed by a short format +string you choose (e.g. `"mymeter"`): + +**1. Register the format** (`_FORMAT_LABELS`, line ~41 — human label → key) +and add the key to `_PROTOCOL_FORMATS` (line 53, marks it as an +instrument protocol rather than a generic key:val/csv/json stream) and +`_STACK_IDX` (line 77 — index into the config-widget stack, see step 4): +```python +_FORMAT_LABELS["My Meter"] = "mymeter" +_PROTOCOL_FORMATS.add("mymeter") +_STACK_IDX["mymeter"] = 5 # next free index +``` +(these are module-level dict/set literals in the real file — in practice +you edit the literals directly rather than mutating at import time, but the +effect is the same.) + +**2. Channel configs** — add a branch in `_build_channel_configs()` +(around line 289, `elif fmt == "scpi": ...`) returning the +`List[ChannelConfig]` for your format, same pattern as the Mark-10 branch +(fixed list) or SCPI branch (built from user-entered channel dicts). + +**3. Layer construction** — add a branch in `_make_layer()` (around line +364) that imports and instantiates your protocol class: +```python +elif fmt == "mymeter": + from api_layers.protocols.my_meter import MyMeterLayer + return MyMeterLayer(self._port, self._baud, simulate=self.simulate) +``` + +**4. Config widget panel** — add a `_MyMeterPanel(QWidget)` class (near +`_Mark10Panel`/`_SCPIPanel`, line ~601+) — static info text if your channel +set is fixed (copy `_Mark10Panel`), an editable table if it's configurable +(copy `_SCPIPanel`). Instantiate it alongside the others in +`SerialConfigWidget._build()` and add it to `self._proto_stack` (line ~501) +at the index you picked in `_STACK_IDX`. + +**5. Save/restore** — if your protocol has operator-configured state (like +SCPI's channel list), add a branch to `get_save_config()` (~line 561) and +accept the matching kwarg in `SerialDevice.__init__`/`reconfigure()`. Fixed- +channel protocols like Mark-10 need nothing here (`elif fmt == "mark10": +pass # no extra config`). + +## Things that aren't obvious from the abstract methods + +**`_poll()` runs on a background thread** (`BaseProtocol._poll_loop`) — don't +touch Qt widgets from it. Return the dict; `SerialDevice.read_channels()` on +the main-thread-adjacent acquisition poll picks it up via `self.read()`. + +**Units live on the *device's* `ChannelConfig`, not the protocol layer**, +if you want live unit changes to reach the plot. The protocol layer just +exposes whatever it parsed (e.g. `current_unit` property); `SerialDevice. +read_channels()` is what copies it onto `ChannelConfig.unit` each read — +see the Mark-10 branch at `devices/serial_device.py:145-152` for the exact +pattern, including the comment explaining why this alone isn't sufficient +(the unit-change-to-plot-axis propagation is a separate mechanism in +`core/acquisition.py`). + +**Discard vs. keep unit suffixes.** `api_layers/arduino_layer.py`'s generic +key:val/csv parser strips anything after a numeric value ("0.04 V" → 0.04) +and throws the unit away — that's correct for the generic Arduino stream, +but if your protocol's whole value is "parse a unit that can change live", +don't reuse that parser; write your own regex like Mark-10's `_RESP_RE` +that captures the unit instead of discarding it. + +**Write-only "action" channels** (zero a gauge, cycle units, enable a +motor) are just channels with `writable=True` and no corresponding read — +see Mark-10's `zero`/`cycle_units` or CML's `ME`/`MD`/`ST` channels in +`_build_channel_configs()`. `write()` dispatches on `channel_id` same as any +other write. + +## Checklist + +1. New file `api_layers/protocols/my_protocol.py`, subclass `BaseProtocol`, + implement `_poll()`, `_simulate()`, `write()`. +2. Register the format string in `serial_device.py`'s `_FORMAT_LABELS`, + `_PROTOCOL_FORMATS`, `_STACK_IDX`. +3. Add branches in `_build_channel_configs()` and `_make_layer()`. +4. Add a config panel class, wire it into `SerialConfigWidget._proto_stack`. +5. If there's operator-configured state, add it to `get_save_config()` / + `__init__` / `reconfigure()`. +6. Simulate first (`simulate=True` is the default) — `_simulate()` needs to + produce plausible values before you ever touch real hardware. |
