Devices overview¶
Audience: config authors, plugin authors, contributors touching
src/capa/devices/.
Scope: the families capa supports, the
DeviceAdapter
contract every adapter implements, resource grouping, and the emission
shapes that downstream sinks key off.
The four sibling libraries¶
capa never talks to instruments directly. Every device family is wrapped
by a dedicated device library — a separately maintained Python
package, async-first, with its own test suite, that owns the on-wire
protocol. capa hosts a thin adapter that maps that library's native
emissions onto capa's universal DeviceAdapter contract.
| Family | Library | Adapter (real) | Adapter (sim) |
|---|---|---|---|
| Watlow EZ-Zone controllers | watlowlib |
capa.devices.watlow |
capa.devices.sim.watlow_sim |
| Alicat mass-flow controllers | alicatlib |
capa.devices.alicat |
capa.devices.sim.alicat_sim |
| Sartorius lab balances | sartoriuslib |
capa.devices.sartorius |
capa.devices.sim.sartorius_sim |
| NI cDAQ / DAQmx | nidaqlib |
capa.devices.nidaq |
capa.devices.sim.nidaq_polled_sim, nidaq_block_sim |
Cameras are peers of devices but not subtypes — they live under
capa.devices.camera.
The per-family pages (Watlow, Alicat,
Sartorius, NI-DAQ, Webcam,
FLIR, Simulators) document the
adapter-specific params schema and operational quirks.
The adapter contract¶
Every adapter — real or sim — implements the same Protocol:
class DeviceAdapter(Protocol):
name: str
capabilities: frozenset[Capability]
resource_id: str
async def open(self) -> None: ...
async def close(self) -> None: ...
async def start(self, ctx: AdapterStartContext) -> None: ...
async def stop(self) -> None: ...
async def snapshot(self) -> DeviceSnapshot: ...
def stream(self) -> AsyncIterable[DeviceEmission]: ...
async def command(self, cmd: DeviceCommand) -> CommandResult: ...
@property
def expected_emission_rate_hz(self) -> float | None: ...
Two operations are separated deliberately:
open/close— connection layer (open the serial port, negotiate protocol, query identity). Costs are paid once per config lifetime; the Sartorius "cold open" race lives here.start/stop— sampling layer. Arms hardware-clocked tasks, begins streaming. A transient I/O hiccup can be recovered withstop+startwithout renegotiating the device.
Both open and close are idempotent — calling them on an
already-open or already-closed adapter is a no-op, not an error. This
matters because the worker state machine relies on it during recovery.
Capabilities¶
Adapters advertise feature flags via the
Capability
Flag enum. The UI uses them to gate widgets ("show the ramp control
only if HAS_RAMP"); procedures use them via
required_capabilities to refuse runs that need flags an adapter
does not declare.
| Group | Flags |
|---|---|
| Control surface | HAS_SETPOINT, HAS_RAMP, HAS_GAS_SELECT, HAS_VALVE_HOLD, WRITES_DIGITAL |
| Acquisition | READS_PROCESS_VAR, HARDWARE_CLOCKED, EMITS_BLOCKS, EMITS_STABILITY_FLAG |
| Balance / mass | HAS_TARE, HAS_ZERO, HAS_INTERNAL_CAL, HAS_TOTALIZER |
| Discovery / lifecycle | SUPPORTS_DISCOVERY, SUPPORTS_AUTO_RECONNECT, HAS_PARAMETER_CONFIG, HAS_DISPLAY_CONTROL |
See the per-family pages for which flags each adapter actually declares.
Resource grouping (one worker per resource)¶
resource_id is the most consequential field on the protocol. Two
adapters that share a physical resource — an RS-485 multi-drop serial
bus, a DAQmx chassis, a single USB camera handle — must expose the
same resource_id. Two adapters that do not share a resource must
expose different ones.
The format is <scheme>:<body>:
| Scheme | Body | Example |
|---|---|---|
serial |
port name | serial:COM4 |
daqmx |
chassis/device | daqmx:cDAQ1 |
webcam |
OS index | webcam:0 |
sim |
per-adapter-instance | sim:watlow_sim-heater |
build_workers
groups adapters by resource_id and constructs one
Worker
thread per group. Sharing a worker is the only safe way to share a
serial bus: the worker serializes I/O so two Alicat MFCs on the same
COM port never collide.
The resource_id is computed from constructor inputs without I/O —
the runtime must be able to read it before open() runs.
What an adapter emits¶
stream() yields a tagged union called
DeviceEmission:
Most adapters emit one SourceRecord per poll, followed by zero or
more ChannelSamples derived from that record. The split exists so
the bundle can keep both representations:
SourceRecord— the library's native row, preserved without reshaping. Lands indevice_records/<adapter>.parquet. This is the re-analysis path: if a future researcher needs a field the channel binding did not surface, the data is still there.ChannelSample— the normalized scientific stream. Lands inscalars.parquet. This is what plots, alarms, procedures, and cross-device analyses key off.
DeviceEvent (alarms, comm errors) goes to events.sqlite;
DeviceSnapshot (periodic health pings) goes to status.sqlite.
Neither flows through the main fan-out queue.
Emission shapes¶
SourceRecord.shape is the layout tag that decides how the record
maps onto a device_records/<adapter>.parquet schema. The four
shapes match the four library row layouts:
| Shape | One row is… | Used by |
|---|---|---|
wide_row |
one poll, many fields | alicatlib Sample, nidaqlib polled DaqReading |
long_row |
one (device, parameter, instance) |
watlowlib Sample |
single_value_row |
one balance reading | sartoriuslib Sample |
block |
a rectangular (channels, samples_per_channel) chunk |
nidaqlib hardware-clocked DaqBlock |
Block records do not flow through the channel-samples sink at all —
row is empty and block_ref points at a sidecar (TDMS or in-bundle
Parquet block file). The kHz-rate hardware-clocked path is intentionally
separate from capa's normal 3–60 Hz channel pipeline.
Each shape maps onto one or more channel binding kinds:
| Shape | Compatible binding kinds |
|---|---|
wide_row (Alicat) |
alicat_frame_field |
wide_row (NI polled) |
nidaq_reading_field |
long_row (Watlow) |
watlow_parameter |
single_value_row (Sartorius) |
sartorius_reading |
block (NI hardware-clocked) |
nidaq_block_channel |
Commands¶
DeviceAdapter.command is the generic write path. Every command
carries issued_by, authorization_id, and confirmed_by so the
audit trail can pin every device write to either a run-arm
authorization or an explicit operator confirmation. Adapters refuse
commands that lack one of those. See Authorization
gates for the contract.
Concrete adapters also expose typed wrappers (e.g.
WatlowAdapter.set_setpoint) for IDE help and refactor safety; the
generic command() form exists so plugins that do not know the
concrete adapter type can still drive a device. Procedures should
issue commands through ctx.dispatcher, not directly on the adapter —
the dispatcher routes through the per-resource worker so commands
serialize correctly on shared buses.
Lifecycle and the worker state machine¶
The worker that hosts an adapter walks through:
The conductor drives transitions across the thread seam through a
bridge. An adapter's open runs during
CLOSED → IDLE; start runs during ARMED → SAMPLING; stop runs
during SAMPLING → DRAINING; close runs only when the
WorkerPool tears down. Per-run actions
(arm / disarm) never re-open the device.
The full state-machine reference is in Runtime topology §4.1; this page just establishes that the device contract is split for it.
Per-channel expected_emission_rate_hz¶
The expected_emission_rate_hz property is a hint to the engine
queue sizer. Adapters compute it after configure_channels runs —
typically (poll rate Hz) × (1 + bound_channel_count) for poll-based
adapters, or (block size / block period) for hardware-clocked
adapters. Returning None is acceptable; the engine falls back to a
conservative default and logs the fallback so the operator knows the
producer queue was sized blind.
See also¶
- Hardware TOML — how devices and channels are declared.
- Channel bindings — the selector-into-emission contract that bridges adapter and channel layers.
- Runtime topology — where Workers fit in the thread layout.
- Writing a device adapter — tutorial for adding a new family.