diff options
Diffstat (limited to 'docs/developer-new-protocols.md')
| -rw-r--r-- | docs/developer-new-protocols.md | 146 |
1 files changed, 146 insertions, 0 deletions
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. |
