summaryrefslogtreecommitdiff
path: root/docs/developer-new-devices.md
blob: cc03a3590c3a155306a9d863acac08ee53c17eff (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
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.