Add an instrument driver from a manual/API
This skill turns a vendor programming reference into a correct, tested,
registered driver PR. It follows the repo's existing conventions exactly — read
AGENTS.md ("How to add a vendor driver", "Patterns and constraints",
"DAQ driver state tracking") before deviating. When in doubt, duplicate
explicit code rather than abstract. This repo deliberately prefers duplication
over shared mixins/factories.
Prerequisites — gather before writing code
- The programming reference. A path to a PDF/doc/HTML file, or a URL. If the
user hasn't provided one, ask for it — do not guess SCPI commands or API code
from model knowledge.
- Vendor and model (e.g. "Siglent SPD3303"). Confirm the exact model family
the driver should cover; SCPI surfaces and APIs are often shared across a series.
- Category. One of
psu, dmm, eload, daq, i2c, modbus, scope, awg, ethernetip.
Infer from the instrument type; confirm with the user if ambiguous.
- Tracking issue + branch. Per
AGENTS.md, no untracked work. Confirm an
issue exists (or create one) and branch off main named after it
(e.g. issue-142-siglent-spd-driver).
- Core vs. contrib. Default to core (
instro/<category>/drivers/) if
the user is a maintainer, otherwise use contrib
(packages/instro-contrib/instro/contrib/<category>/drivers/). Some instruments
only exist in unstable (packages/instro-unstable/). See CONTRIBUTING.md for the bar.
Ask if unclear.
Step 1 — Extract a structured command spec from the manual
Manuals are large and noisy. Delegate parsing to the manual-spec-extractor
subagent (.claude/agents/manual-spec-extractor.md) so the heavy document
tokens stay out of this conversation. Give it:
- the manual location (path or URL),
- the target category and its required methods (read them from the category base
class — see Step 3),
- the vendor/model.
It returns a structured spec: per-method SCPI command(s), the error-query form
and OK-prefix, the channel model, value formatting (decimal places, units), and
which optional category methods the instrument supports. Review the spec against
the manual yourself for anything safety- or wire-critical before coding.
If the manual is small or already pasted into the conversation, you may extract
inline instead of spawning the agent — use judgment.
Step 2 — Read the reference driver for the category
The canonical shape is instro/psu/drivers/bk_9115.py. For other categories,
read an existing driver in instro/<category>/drivers/ first — the method set
and transport differ. Match the surrounding code's idiom, naming, and comment
density.
Let the category you're adding to decide layout and conventions — the paths
below are the core-category default, not universal. Categories in
instro-unstable (e.g. scope) diverge, and a real driver in the category is
the source of truth. Things that vary, observed in scope:
- Directory layout.
scope nests drivers under a vendor subdir
(drivers/<vendor>/<vendor>_<model>.py + a drivers/<vendor>/__init__.py),
not the flat drivers/<vendor>_<model>.py.
- Docstring style. A category's base class may carry multi-paragraph
docstrings (
ScopeDriverBase does) even though the one-line rule holds
elsewhere. Match the concrete sibling driver's idiom (Keysight/Tektronix
scope drivers use one-liners), not the base.
- Unsupported-feature exception. Core PSU/DMM raise
FeatureNotSupportedError; scope drivers raise plain NotImplementedError.
Use whatever the category's reference driver uses.
Step 3 — Read the category base class
instro/<category>/<category>.py defines <Category>DriverBase. Note:
- Required methods are
@abc.abstractmethod — you MUST implement all of them.
- Optional methods raise
NotImplementedError/FeatureNotSupportedError by
default. Override only the ones the instrument actually supports (per the
Step 1 spec). For unsupported optional methods, follow the reference driver's
pattern (e.g. BK9115 raises FeatureNotSupportedError).
Step 4 — Write the driver module
Create the driver at the path the category actually uses (see Step 2): core
categories use instro/<category>/drivers/<vendor>_<model>.py; vendor-subdir
categories like scope use
packages/instro-unstable/instro/unstable/<category>/drivers/<vendor>/<vendor>_<model>.py.
File name is the snake_case form (e.g. siglent_spd3303.py,
siglent_sds1000x_e.py). Class name is <Vendor><Model> (e.g. SiglentSPD3303,
SiglentSDS1000XE).
Rules (from AGENTS.md "Patterns and constraints"):
- Subclass the category base.
class SiglentSPD3303(PSUDriverBase):
- Compose, don't subclass, the transport. For SCPI/VISA instruments hold a
VisaDriver in __init__ and accept str | VisaConfig:
def __init__(self, visa_resource: str | VisaConfig) -> None: then
self._visa = VisaDriver(visa_resource). Keep the union — VisaConfig is the
canonical customization vehicle. Drivers on other transports (LabJack handle,
Aardvark module) take whatever their transport needs.
- Implement
open/close to delegate to the transport.
- Per-driver error helpers, only if the device supports
SYST:ERR?. Add
driver-local _write_checked / _check_errors / _query_checked_float like
BK9115. Do NOT extract these to a shared mixin. The OK-prefix
("0" for B&K/Rigol, "+0" for Siglent), command form (SYST:ERR? vs
:SYST:ERR?), and the vendor name in the raised message are all per-device —
take them from the Step 1 spec.
- Stateful sequences inline the lock. If a command needs channel-select
before write (e.g.
INST <n> then write), hold self._visa.lock() across the
whole sequence rather than using _write_checked — see bk_9140.py.
- Type hints on all public methods.
mypy is enforced.
- One-line docstrings max. No multi-paragraph docstrings, no comments that
restate the code.
DAQ drivers — extra rules
If the category is daq, follow "DAQ driver state tracking" in AGENTS.md
exactly (reference: instro/daq/drivers/keysight_34980a.py):
- Call
super().__init__() at the top of __init__ (the base initializes the
private channel/timing dicts and points_in_buffer).
- Record channels on the private dicts inside
configure_*
(self._ai_channels[channel.alias] = channel, etc.) — after programming the
device.
- Read driver-owned state via the private
self._<dict>, never the read-only
@property (which allocates a fresh snapshot per access).
- No
InstroDAQ reach-back / imports.
Step 5 — Register the driver
Registration follows the category's existing pattern — check how a sibling
driver is wired before assuming:
- Core categories: edit
instro/<category>/drivers/__init__.py, adding both
the import and the __all__ entry.
- Workspace vendor packages (e.g.
daq, i2c): the driver lives in its own
package under packages/; register it in that package's drivers/__init__.py
(import + __all__). The category's top-level drivers/__init__.py relies on
pkgutil.extend_path to make the vendor-package subpackage importable — don't
remove it.
- Contrib drivers: register in the corresponding contrib
drivers/__init__.py (the smoke test picks it up automatically).
Step 6 — Write tests
Put tests where the category's existing driver tests live — don't assume the
path. Core categories use tests/<category>/test_<category>_drivers.py; some
categories keep driver tests in a single top-level file (e.g. scope →
tests/test_instro_scope.py). Grep for an existing driver class name in tests/
to find the right file. The canonical pattern is in
tests/psu/test_psu_drivers.py:
- Keep tests targeted: cover each driver invariant or edge case with the least
necessary complexity. Do not add redundant matrices, broad helper layers, or
rewrites whose main effect is making tests look like candidates for shared
behavior. Do not add tests just to increase coverage numbers or case counts.
You're almost definitely working on a feature/bugfix that needs test
coverage, make sure that the tests that you write are actually testing what
you think they are.
- Patch the driver's
VisaDriver reference with autospec=True:
patch("instro.<category>.drivers.<vendor>_<model>.VisaDriver", autospec=True).
- Provide fixtures for the patched class and instance; default
visa.query.return_value to a no-error response (e.g. '0,"No error"' or
'+0,"No error"' matching the device's OK-prefix).
- Assert wire-level commands:
visa.write.assert_called_once_with("VOLT 5.000"),
and that the error query fires (visa.query.assert_called_once_with("SYST:ERR?")).
- Use
side_effect lists for query+error-check sequences.
- Cover: init builds the transport from both
str and VisaConfig; open/close
delegate; each implemented method's wire command; response parsing;
_check_errors raises on a non-OK prefix.
Step 7 — Update docs (same PR)
Per AGENTS.md "Documentation":
README.md — add the model to the "Supported devices" table for core
categories. Categories in instro-unstable (e.g. scope) are listed under
"Experimental modules" instead — update that prose, not the table.
docs/guides/instrumentation/<category>.mdx — add a guide entry only if
the device introduces a new user-facing workflow.
- If a new public API/category was introduced (rare for a single driver), update
docs/reference/src/ and docs/guides/docs.json navigation too.
- Do not hand-edit
CHANGELOG.md (release-please generates it).
Step 8 — Verify
Run the repo gates and fix anything they flag:
just fix # perform format and generate examples, intended to ensure just check passes
just check # ruff format, mypy, ruff lint, and compare examples against working dir
just test # mocked unit tests, no hardware
If both pass, CI will pass. Then summarize for the user: the new driver, what
optional capabilities it supports/omits (and why).
Step 9 — Offer hardware validation
Mocked unit tests prove the wire commands are shaped right; they cannot prove
the driver works against the real instrument. Once the driver is written and the
mocked gates pass, ask the user whether they have the physical device on hand:
The driver passes its mocked unit tests. Do you have the actual <vendor> <model> connected right now? If so, I can write a standalone
hardware-validation script that exercises every method against the real device
and self-correct the driver from what it finds.
- If yes: gather the device identifier/connection string, exact model, vendor,
channel count, and what's wired to each channel (stimulus/loopback), then invoke
the
validate-driver-hardware skill with those details. It writes a
hardware validation script, runs it against the device, and iterates on
the driver until every supported method passes.
- If no: stop here. Remind the user the wire-level behavior should still be
confirmed against real hardware before merge, and that they can run the
validate-driver-hardware skill later when the device is available.
Anti-patterns to refuse
- No vendor-string factory (
Instrument.create(vendor=...)). Construct drivers
explicitly: InstroPSU(name="x", driver=BK9115(...), num_channels=1).
- No shared error-handling/transport mixin or base helper.
- No driver-side facade or reference back to the category HAL.
- No
import-time dependency on optional vendor SDKs (defer in open() if the
transport needs it, like Aardvark).
- No guessing commands the manual doesn't show. If a required method's command is
unknown, flag it for the user rather than inventing one.
- No edits to shared/core library code. Scope is the driver module, its
registration, its tests, and docs — never
instro/lib/ (transports like
VisaDriver, instro/lib/types.py), the category base class, or the HAL. If
the driver seems to need a transport capability the public API doesn't expose,
use the VISA escape hatch (self._visa._inst under self._visa.lock()) from
within the driver, or flag the gap to the user — don't modify lib.
1---2name: add-instrument-driver3description: Scaffold a new instrument driver for a vendor/model from its programming reference (SCPI/programming manual/API SDK as a PDF, doc, HTML file, or website URL) and wire it into the repo per AGENTS.md conventions. Use when asked to "add a driver for <vendor> <model>", "write a driver from this manual/datasheet", or similar. Produces the driver module, registration, mocked-transport tests, and doc updates on a tracking branch.4---56# Add an instrument driver from a manual/API78This skill turns a vendor programming reference into a correct, tested,9registered driver PR. It follows the repo's existing conventions exactly — read10`AGENTS.md` ("How to add a vendor driver", "Patterns and constraints",11"DAQ driver state tracking") before deviating. **When in doubt, duplicate12explicit code rather than abstract.** This repo deliberately prefers duplication13over shared mixins/factories.1415## Prerequisites — gather before writing code16171. **The programming reference.** A path to a PDF/doc/HTML file, or a URL. If the18 user hasn't provided one, ask for it — do not guess SCPI commands or API code19 from model knowledge.202. **Vendor and model** (e.g. "Siglent SPD3303"). Confirm the exact model family21 the driver should cover; SCPI surfaces and APIs are often shared across a series.223. **Category.** One of `psu`, `dmm`, `eload`, `daq`, `i2c`, `modbus`, `scope`, `awg`, `ethernetip`.23 Infer from the instrument type; confirm with the user if ambiguous.244. **Tracking issue + branch.** Per `AGENTS.md`, no untracked work. Confirm an25 issue exists (or create one) and branch off `main` named after it26 (e.g. `issue-142-siglent-spd-driver`).275. **Core vs. contrib.** Default to **core** (`instro/<category>/drivers/`) if28 the user is a maintainer, otherwise use **contrib**29 (`packages/instro-contrib/instro/contrib/<category>/drivers/`). Some instruments30 only exist in **unstable** (`packages/instro-unstable/`). See `CONTRIBUTING.md` for the bar.31 Ask if unclear.3233## Step 1 — Extract a structured command spec from the manual3435Manuals are large and noisy. **Delegate parsing to the `manual-spec-extractor`36subagent** (`.claude/agents/manual-spec-extractor.md`) so the heavy document37tokens stay out of this conversation. Give it:3839- the manual location (path or URL),40- the target category and its required methods (read them from the category base41 class — see Step 3),42- the vendor/model.4344It returns a structured spec: per-method SCPI command(s), the error-query form45and OK-prefix, the channel model, value formatting (decimal places, units), and46which optional category methods the instrument supports. Review the spec against47the manual yourself for anything safety- or wire-critical before coding.4849If the manual is small or already pasted into the conversation, you may extract50inline instead of spawning the agent — use judgment.5152## Step 2 — Read the reference driver for the category5354The canonical shape is `instro/psu/drivers/bk_9115.py`. For other categories,55read an existing driver in `instro/<category>/drivers/` first — the method set56and transport differ. Match the surrounding code's idiom, naming, and comment57density.5859**Let the category you're adding to decide layout and conventions — the paths60below are the core-category default, not universal.** Categories in61`instro-unstable` (e.g. `scope`) diverge, and a real driver in the category is62the source of truth. Things that vary, observed in `scope`:6364- **Directory layout.** `scope` nests drivers under a vendor subdir65 (`drivers/<vendor>/<vendor>_<model>.py` + a `drivers/<vendor>/__init__.py`),66 not the flat `drivers/<vendor>_<model>.py`.67- **Docstring style.** A category's base class may carry multi-paragraph68 docstrings (`ScopeDriverBase` does) even though the one-line rule holds69 elsewhere. Match the *concrete* sibling driver's idiom (Keysight/Tektronix70 scope drivers use one-liners), not the base.71- **Unsupported-feature exception.** Core PSU/DMM raise72 `FeatureNotSupportedError`; `scope` drivers raise plain `NotImplementedError`.73 Use whatever the category's reference driver uses.7475## Step 3 — Read the category base class7677`instro/<category>/<category>.py` defines `<Category>DriverBase`. Note:7879- **Required methods** are `@abc.abstractmethod` — you MUST implement all of them.80- **Optional methods** raise `NotImplementedError`/`FeatureNotSupportedError` by81 default. Override only the ones the instrument actually supports (per the82 Step 1 spec). For unsupported optional methods, follow the reference driver's83 pattern (e.g. BK9115 raises `FeatureNotSupportedError`).8485## Step 4 — Write the driver module8687Create the driver at the path the category actually uses (see Step 2): core88categories use `instro/<category>/drivers/<vendor>_<model>.py`; vendor-subdir89categories like `scope` use90`packages/instro-unstable/instro/unstable/<category>/drivers/<vendor>/<vendor>_<model>.py`.91File name is the snake_case form (e.g. `siglent_spd3303.py`,92`siglent_sds1000x_e.py`). Class name is `<Vendor><Model>` (e.g. `SiglentSPD3303`,93`SiglentSDS1000XE`).9495Rules (from `AGENTS.md` "Patterns and constraints"):9697- **Subclass the category base.** `class SiglentSPD3303(PSUDriverBase):`98- **Compose, don't subclass, the transport.** For SCPI/VISA instruments hold a99 `VisaDriver` in `__init__` and accept `str | VisaConfig`:100 `def __init__(self, visa_resource: str | VisaConfig) -> None:` then101 `self._visa = VisaDriver(visa_resource)`. Keep the union — `VisaConfig` is the102 canonical customization vehicle. Drivers on other transports (LabJack handle,103 Aardvark module) take whatever their transport needs.104- **Implement `open`/`close`** to delegate to the transport.105- **Per-driver error helpers, only if the device supports `SYST:ERR?`.** Add106 driver-local `_write_checked` / `_check_errors` / `_query_checked_float` like107 BK9115. **Do NOT extract these to a shared mixin.** The OK-prefix108 (`"0"` for B&K/Rigol, `"+0"` for Siglent), command form (`SYST:ERR?` vs109 `:SYST:ERR?`), and the vendor name in the raised message are all per-device —110 take them from the Step 1 spec.111- **Stateful sequences inline the lock.** If a command needs channel-select112 before write (e.g. `INST <n>` then write), hold `self._visa.lock()` across the113 whole sequence rather than using `_write_checked` — see `bk_9140.py`.114- **Type hints on all public methods.** `mypy` is enforced.115- **One-line docstrings max.** No multi-paragraph docstrings, no comments that116 restate the code.117118### DAQ drivers — extra rules119120If the category is `daq`, follow "DAQ driver state tracking" in `AGENTS.md`121exactly (reference: `instro/daq/drivers/keysight_34980a.py`):122123- Call `super().__init__()` at the top of `__init__` (the base initializes the124 private channel/timing dicts and `points_in_buffer`).125- Record channels on the **private** dicts inside `configure_*`126 (`self._ai_channels[channel.alias] = channel`, etc.) — after programming the127 device.128- Read driver-owned state via the private `self._<dict>`, never the read-only129 `@property` (which allocates a fresh snapshot per access).130- No `InstroDAQ` reach-back / imports.131132## Step 5 — Register the driver133134Registration follows the category's existing pattern — check how a sibling135driver is wired before assuming:136137- **Core categories:** edit `instro/<category>/drivers/__init__.py`, adding both138 the import and the `__all__` entry.139- **Workspace vendor packages (e.g. `daq`, `i2c`):** the driver lives in its own140 package under `packages/`; register it in that package's `drivers/__init__.py`141 (import + `__all__`). The category's top-level `drivers/__init__.py` relies on142 `pkgutil.extend_path` to make the vendor-package subpackage importable — don't143 remove it.144- **Contrib drivers:** register in the corresponding contrib145 `drivers/__init__.py` (the smoke test picks it up automatically).146147## Step 6 — Write tests148149Put tests where the category's existing driver tests live — don't assume the150path. Core categories use `tests/<category>/test_<category>_drivers.py`; some151categories keep driver tests in a single top-level file (e.g. `scope` →152`tests/test_instro_scope.py`). Grep for an existing driver class name in `tests/`153to find the right file. The canonical pattern is in154`tests/psu/test_psu_drivers.py`:155156- Keep tests targeted: cover each driver invariant or edge case with the least157 necessary complexity. Do not add redundant matrices, broad helper layers, or158 rewrites whose main effect is making tests look like candidates for shared159 behavior. Do not add tests just to increase coverage numbers or case counts.160 You're _almost definitely_ working on a feature/bugfix that needs test161 coverage, make sure that the tests that you write are actually testing what162 you think they are.163- Patch the driver's `VisaDriver` reference with `autospec=True`:164 `patch("instro.<category>.drivers.<vendor>_<model>.VisaDriver", autospec=True)`.165- Provide fixtures for the patched class and instance; default166 `visa.query.return_value` to a no-error response (e.g. `'0,"No error"'` or167 `'+0,"No error"'` matching the device's OK-prefix).168- Assert **wire-level commands**: `visa.write.assert_called_once_with("VOLT 5.000")`,169 and that the error query fires (`visa.query.assert_called_once_with("SYST:ERR?")`).170- Use `side_effect` lists for query+error-check sequences.171- Cover: init builds the transport from both `str` and `VisaConfig`; open/close172 delegate; each implemented method's wire command; response parsing;173 `_check_errors` raises on a non-OK prefix.174175## Step 7 — Update docs (same PR)176177Per `AGENTS.md` "Documentation":178179- **`README.md`** — add the model to the **"Supported devices"** table for core180 categories. Categories in `instro-unstable` (e.g. `scope`) are listed under181 **"Experimental modules"** instead — update that prose, not the table.182- **`docs/guides/instrumentation/<category>.mdx`** — add a guide entry only if183 the device introduces a new user-facing workflow.184- If a new public API/category was introduced (rare for a single driver), update185 `docs/reference/src/` and `docs/guides/docs.json` navigation too.186- Do **not** hand-edit `CHANGELOG.md` (release-please generates it).187188## Step 8 — Verify189190Run the repo gates and fix anything they flag:191192```bash193just fix # perform format and generate examples, intended to ensure just check passes194just check # ruff format, mypy, ruff lint, and compare examples against working dir195just test # mocked unit tests, no hardware196```197198If both pass, CI will pass. Then summarize for the user: the new driver, what199optional capabilities it supports/omits (and why).200201## Step 9 — Offer hardware validation202203Mocked unit tests prove the wire commands are *shaped* right; they cannot prove204the driver works against the real instrument. Once the driver is written and the205mocked gates pass, **ask the user whether they have the physical device on hand**:206207> The driver passes its mocked unit tests. Do you have the actual `<vendor>208> <model>` connected right now? If so, I can write a standalone209> hardware-validation script that exercises every method against the real device210> and self-correct the driver from what it finds.211212- **If yes:** gather the device identifier/connection string, exact model, vendor,213 channel count, and what's wired to each channel (stimulus/loopback), then invoke214 the **`validate-driver-hardware` skill** with those details. It writes a215 hardware validation script, runs it against the device, and iterates on216 the driver until every supported method passes.217- **If no:** stop here. Remind the user the wire-level behavior should still be218 confirmed against real hardware before merge, and that they can run the219 `validate-driver-hardware` skill later when the device is available.220221## Anti-patterns to refuse222223- No vendor-string factory (`Instrument.create(vendor=...)`). Construct drivers224 explicitly: `InstroPSU(name="x", driver=BK9115(...), num_channels=1)`.225- No shared error-handling/transport mixin or base helper.226- No driver-side facade or reference back to the category HAL.227- No `import`-time dependency on optional vendor SDKs (defer in `open()` if the228 transport needs it, like Aardvark).229- No guessing commands the manual doesn't show. If a required method's command is230 unknown, flag it for the user rather than inventing one.231- No edits to shared/core library code. Scope is the driver module, its232 registration, its tests, and docs — never `instro/lib/` (transports like233 `VisaDriver`, `instro/lib/types.py`), the category base class, or the HAL. If234 the driver seems to need a transport capability the public API doesn't expose,235 use the VISA escape hatch (`self._visa._inst` under `self._visa.lock()`) from236 within the driver, or flag the gap to the user — don't modify lib.