summaryrefslogtreecommitdiff
path: root/docs/developer-new-protocols.md
blob: 724bdc727381e097878d5db24838f3c09339379d (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
# 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.