watlowlib¶
The top-level package re-exports the public surface from each
subpackage. Importing names from watlowlib directly is the supported
form; importing from submodules is supported but not stability-pinned.
Public surface¶
watlowlib ¶
watlowlib — Python library for Watlow temperature controllers.
Supports both wire protocols Watlow controllers expose:
- Standard Bus: BACnet MS/TP outer framing with a small Watlow attribute service inside.
- Modbus RTU: via the in-house
anymodbuspackage.
The public API is semantic and protocol-neutral — a caller asks for
read_pv(), set_setpoint(), read_parameter(); the session
dispatches the Standard Bus or Modbus variant selected at open time.
Both protocols decode into the same frozen Reading /
ParameterEntry / DeviceInfo models.
Core API is async (built on anyio); :mod:watlowlib.sync
provides a blocking facade for scripts, notebooks, and REPL use.
See docs/design.md for the architectural design.
AcquisitionSummary
dataclass
¶
AcquisitionSummary(
started_at,
finished_at=None,
samples_emitted=0,
samples_late=0,
max_drift_ms=0.0,
tick_duration_ms_p50=0.0,
tick_duration_ms_p99=0.0,
disconnects=0,
)
Per-run summary owned and mutated by the recorder.
Mutability contract (§M of UNIFIED_API_HANDOFF.md): the recorder is the only writer. Counters update in place during the run so progress-polling consumers (TUIs, dashboards) see live values. Consumers must treat this object as read-only.
finished_at is None while the recording is in flight and
is set on context-manager exit. Percentile fields
(tick_duration_ms_p50 / p99) are materialized at exit
only because percentiles are batch-computed; the in-flight
counters reflect the latest observation.
Attributes:
| Name | Type | Description |
|---|---|---|
started_at |
datetime
|
Wall-clock at the first scheduled tick. |
finished_at |
datetime | None
|
Wall-clock at producer shutdown, or |
samples_emitted |
int
|
Count of per-tick batches actually pushed onto the receive stream. A tick that produced zero samples (every device errored) still counts as one emitted batch. |
samples_late |
int
|
Count of ticks that missed their target slot (producer overran the previous tick, or overflow policy dropped the batch). Auto-reconnect ticks also count as late. |
max_drift_ms |
float
|
Largest observed positive drift of an emitted
batch relative to its absolute target, in milliseconds.
A healthy run stays well under one period; values
approaching |
tick_duration_ms_p50 |
float
|
Median wall-clock duration of a single
|
tick_duration_ms_p99 |
float
|
99th-percentile tick duration, in milliseconds. Set on exit only. Surfaces rare-but-bad cases where a tick stalled behind a contended port lock or a slow EEPROM commit. |
disconnects |
int
|
Count of WatlowConnectionError events the
producer absorbed under |
AlarmState
dataclass
¶
Decoded alarm bits for one loop.
Availability ¶
Bases: StrEnum
Per-command session state.
Sticky for the session: once a command transitions to
:attr:UNSUPPORTED, the session short-circuits subsequent
invocations with a typed error pre-I/O. The transition table
lives in docs/design.md §5b.
Capability ¶
Bases: Flag
Coarse hardware capability bits.
Bits are derived from a decoded part number when one is available
(see :func:watlowlib.registry.families.capabilities_for_part_number)
and fall back to a per-family prior otherwise. The session widens
the set at runtime when a command succeeds against a parameter
that proves the capability.
The vocabulary is small on purpose — most Watlow gating is by
:class:watlowlib.registry.families.ControllerFamily and by
:attr:watlowlib.registry.parameters.ParameterSpec.parameter_id,
not by per-feature bits. New bits are added when captured family
behaviour requires them.
Controller ¶
Async facade for a single Watlow controller.
Source code in src/watlowlib/devices/controller.py
capabilities
property
¶
Cached SKU capabilities (set after :meth:identify).
None pre-identify so capability-gated operations behave
permissively until the part number is captured. After
:meth:identify, callers can branch on
:attr:Capability.HAS_COOLING etc. without re-issuing
identify.
loops
property
¶
Cached loop count (set after :meth:identify).
None until the device's part number has been decoded;
:meth:loop accepts any 1-indexed value while loops is
None and falls back to per-spec validation at the first
wire call. After :meth:identify, loops reflects the
decoded value.
serial_settings
property
¶
Serial framing the controller was opened with.
Exposed so an identity strategy (see
:mod:watlowlib.devices.profile) can stamp it onto the
:class:DeviceInfo it builds.
close
async
¶
Close the underlying transport and dispose the protocol client.
Source code in src/watlowlib/devices/controller.py
identify
async
¶
Read the identity parameters and return a :class:DeviceInfo.
Reads (in order): part number (1009), hardware id (1001),
firmware id (1002), serial number. Missing secondary fields
stay None and the result's :attr:DeviceInfo.health is
promoted from :attr:DeviceHealth.OK to
:attr:DeviceHealth.PARTIAL. If the part-number read itself
fails, the result's health is :attr:DeviceHealth.FAILED and
capability decoding is skipped (the family prior still
applies).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
timeout
|
float | None
|
Per-read timeout override. |
None
|
strict
|
bool
|
If |
False
|
query_configured_protocol
|
bool
|
If |
False
|
Raises:
| Type | Description |
|---|---|
WatlowError
|
When |
Source code in src/watlowlib/devices/controller.py
loop ¶
Return a sub-facade bound to loop n (1-indexed).
n is validated eagerly when :attr:loops is known,
otherwise per-spec max_instance validation kicks in at the
first wire call. Multi-loop access is the public way to reach
loop 2 on dual-loop devices —
:meth:Controller.read_pv defaults to instance=1.
Source code in src/watlowlib/devices/controller.py
poll
async
¶
Read the active process value — the canonical no-arg snapshot.
Equivalent to :meth:read_pv. The no-arg form aligns with the
ecosystem poll() convention shared by alicatlib.Device,
sartoriuslib.Balance, and nidaqlib.DaqSession: a
single, default-shaped reading per call.
For multi-parameter polling use :meth:poll_many.
Source code in src/watlowlib/devices/controller.py
poll_many
async
¶
Read every (parameter × instance) and return them as :class:Sample\ s.
Satisfies the :class:watlowlib.streaming.PollSource Protocol so
a solo :class:Controller can drive :func:watlowlib.streaming.record
directly without a manager. names is accepted for Protocol
compatibility but ignored — a Controller has only one device.
Failed reads are dropped from the returned list and logged at WARN. The recorder treats absence as "drop this row from the batch" and continues with the next tick.
Source code in src/watlowlib/devices/controller.py
read_comms_unit_label
async
¶
Read (and cache) the value parameter 17050 reports.
Inspection / diagnostics helper. Does not drive
:class:Reading.unit: on at least one PM3 firmware revision
17050 is a label-only register that changes the enum the
device reports for itself but does not affect the scale of
temperature values exchanged over comms.
To tell watlowlib what scale temperatures actually travel in
over the wire, pass assert_wire_temperature_unit= to
:func:watlowlib.open_device. That assertion is what feeds
:class:Reading.unit.
Distinct from read_parameter("units"), which targets
parameter 3005 (front-panel display). The two can disagree on
a real device.
Returns None if the device doesn't report a known code.
Source code in src/watlowlib/devices/controller.py
read_parameter
async
¶
Read any registry parameter.
instance=1 is the default for single-loop devices and the
first loop / channel on multi-loop devices.
Source code in src/watlowlib/devices/controller.py
read_pv
async
¶
Read the process value for instance (loop number, 1-indexed).
Source code in src/watlowlib/devices/controller.py
read_setpoint
async
¶
Read the active setpoint for instance.
Source code in src/watlowlib/devices/controller.py
set_comms_unit_label
async
¶
Set parameter 17050 ("Communications - Display Units").
Accepts a :class:Unit or a case-insensitive string alias
("C" / "F" / "celsius" / "fahrenheit" /
"degC" / "degF" / "°C" / "°F").
:attr:Unit.PERCENT is rejected pre-I/O — the register is
temperature-only.
Raw enumeration codes (15 / 30) are not accepted here. Callers
who want the lower-level path use
write_parameter("display_units", 30).
.. warning::
On at least one PM3 firmware revision this register is
**label-only**: writing it changes the enum the device
reports when 17050 is read back, but does not change the
scale of temperature values exchanged over comms. This
setter therefore does **not** affect
:class:`Reading.unit`. To tell watlowlib what scale
temperatures are actually on, pass
``assert_wire_temperature_unit=`` to
:func:`watlowlib.open_device`.
Persistent write (parameter 17050 is RWE); pass confirm=True
to acknowledge the EEPROM write. The session raises
:class:WatlowConfirmationRequiredError pre-I/O if missing.
Returns the device-echoed label after the write. None if
the device's echo decodes outside the known codes.
Source code in src/watlowlib/devices/controller.py
set_persistent_writes
async
¶
Toggle whether subsequent writes persist to non-volatile memory.
Series-SD-specific. The SD persists every register write to
EEPROM by default, so a high-rate writer (ramping setpoints, a
tuning loop) can wear the EEPROM out and brick the controller.
Writing 0 to register 17 keeps subsequent writes in RAM
only; the device resets register 17 to 1 on every power
cycle, so call set_persistent_writes(False) once after each
power-up before a burst of writes (see sd_manual.txt p.84).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
enabled
|
bool
|
|
required |
confirm
|
bool
|
The write itself is gated like any other parameter
write — pass |
False
|
timeout
|
float | None
|
Per-write timeout override. |
None
|
Raises:
| Type | Description |
|---|---|
WatlowConfirmationRequiredError
|
|
WatlowValidationError
|
the bound profile's registry has no
|
Source code in src/watlowlib/devices/controller.py
set_setpoint
async
¶
Write the setpoint and return the device-echoed value as a :class:Reading.
Setpoint is RWES — pass confirm=True to acknowledge the
EEPROM write. The returned reading is the device's echo of
the value it accepted.
Source code in src/watlowlib/devices/controller.py
snapshot
async
¶
Return an I/O-free :class:WatlowDeviceSnapshot.
Built from cached identity (populated by :meth:identify,
which :func:watlowlib.open_device calls by default) plus
the session's last error and per-command availability cache.
Does not issue any reads — safe to call from monitoring
loops at high cadence.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str | None
|
Override the snapshot's |
None
|
Source code in src/watlowlib/devices/controller.py
write_parameter
async
¶
Write any registry parameter.
Persistent (RWE / RWES) writes require confirm=True;
the session raises :class:WatlowConfirmationRequiredError
before any I/O if the gate is missing.
Source code in src/watlowlib/devices/controller.py
ControllerFamily ¶
Bases: StrEnum
Watlow controller family discriminator.
Membership here is advisory — :class:watlowlib.devices.session.Session
treats family hints as priors, not gates. See docs/design.md §5b.
ControllerLoop ¶
A view over one control loop on a :class:Controller.
Construct via :meth:Controller.loop; never instantiated
directly by user code. The sub-facade lives only as long as the
parent controller's session — closing the controller is the only
cleanup needed.
Source code in src/watlowlib/devices/loop.py
read_alarms
async
¶
Read the alarm word for this loop.
Currently raises :class:watlowlib.errors.WatlowProtocolUnsupportedError —
see :func:watlowlib.commands.alarms.read_alarms for why the
decoder is not yet wired up.
Source code in src/watlowlib/devices/loop.py
read_output
async
¶
read_pid
async
¶
Read every PID gain for this loop. Missing gains return None.
Cool-side gains (cool_proportional_band, dead_band)
are skipped when the controller's identified capabilities
lack :attr:Capability.HAS_COOLING (e.g. PM output_2 ==
'A'). Pre-identify, the gate is permissive.
Source code in src/watlowlib/devices/loop.py
read_pv
async
¶
read_setpoint
async
¶
set_setpoint
async
¶
Write this loop's setpoint (RWES → confirm=True required).
Source code in src/watlowlib/devices/loop.py
write_pid
async
¶
Write the supplied gains for this loop.
Persistent — passing confirm=True is required. Fields
left None on gains skip the wire entirely. Setting a
cool-side field on a controller without
:attr:Capability.HAS_COOLING raises
:class:watlowlib.errors.WatlowConfigurationError.
Source code in src/watlowlib/devices/loop.py
CsvSink ¶
Single-run CSV writer with first-batch schema lock.
Each :meth:open truncates the destination and writes a fresh
header on the first :meth:write_many. Cross-run appending is
intentionally not supported: the column set is inferred from
the first batch and locked for the run, and a re-open against an
existing file with a different column shape would silently produce
a CSV with mismatched columns. For append semantics, use
:class:~watlowlib.sinks.jsonl.JsonlSink (no schema to coordinate)
or :class:~watlowlib.sinks.sqlite.SqliteSink (schema captured in
the table).
Attributes:
| Name | Type | Description |
|---|---|---|
path |
Path
|
Destination file. Created or overwritten on :meth: |
columns |
tuple[str, ...] | None
|
Locked column order after the first :meth: |
Source code in src/watlowlib/sinks/csv.py
close
async
¶
Flush and close the CSV file. Idempotent.
open
async
¶
Open the CSV file for writing.
Truncates any existing file: the first :meth:write_many will
write a fresh header row. Idempotent on already-open sinks.
Source code in src/watlowlib/sinks/csv.py
write_many
async
¶
Append samples as CSV rows.
On first call, infers the column set from the first sample and writes the header. Subsequent calls validate each row's keys against that locked set — unknown keys are dropped with a one-shot WARN log per unseen key.
Source code in src/watlowlib/sinks/csv.py
DeviceInfo
dataclass
¶
DeviceInfo(
part_number,
hardware_id,
firmware_id,
serial_number,
family,
protocol,
address,
capabilities,
serial_settings,
loops,
health=DeviceHealth.OK,
configured_protocol=None,
)
Identity + connection metadata for an open controller.
Returned by :meth:Controller.identify. Capabilities are decoded
from the part number when one is captured (see
:func:watlowlib.registry.families.capabilities_for_part_number)
and OR-ed with the family prior; unobserved bits stay zero rather
than being guessed.
protocol is the wire protocol the host is currently talking;
configured_protocol is what the device's persistent EEPROM
parameter (PM 17009) reports. They normally match, but when they
diverge the helper :attr:protocol_mismatch flags it — useful
for catching SKU/firmware combinations where the user wrote a new
protocol but the runtime stack didn't pick it up (e.g. comms
position-8 = 'A', no Modbus stack present even though 17009 reads
1057).
protocol_mismatch
property
¶
True when EEPROM says one protocol and we're talking another.
Always False when :attr:configured_protocol is None
(i.e. identify did not query parameter 17009).
DeviceProfile
dataclass
¶
DeviceProfile(
name,
family,
registry,
default_protocol,
default_serial,
identify,
wire_temperature_unit=None,
)
A first-class controller type.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
Stable short identifier ( |
family |
ControllerFamily
|
The controller family this profile describes. |
registry |
ParameterRegistry
|
Parameter registry used to decode this device's parameters. |
default_protocol |
ProtocolKind
|
Wire protocol opened when the caller does not pass one explicitly. |
default_serial |
SerialSettings
|
Factory serial framing for |
identify |
IdentifyStrategy
|
Strategy that produces a :class: |
wire_temperature_unit |
Unit | None
|
The scale temperatures travel in over the
wire, when the profile knows it for certain. |
DeviceResult
dataclass
¶
Per-device result container — value or error, never both.
The protocol that produced the failure is available via
result.error.context.protocol when the error carries context;
keeping it off the result keeps the success-path representation
clean and aligns with the ecosystem DeviceResult shape used by
:mod:alicatlib and :mod:sartoriuslib.
failure
classmethod
¶
DeviceSnapshot
dataclass
¶
DeviceSnapshot(
name,
model,
firmware,
serial,
connected,
last_error,
recoverable_error_count,
captured_at,
)
Cross-library identity + connection summary (no I/O).
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
Caller-supplied device name (manager-assigned, or the transport label for a solo controller). |
model |
str | None
|
Best-known model / part-number string, |
firmware |
str | None
|
Firmware id as a string, or |
serial |
str | None
|
Serial-number string, or |
connected |
bool
|
|
last_error |
ErrorContext | None
|
Most recent :class: |
recoverable_error_count |
int
|
Session counter for swallowed-and- retried transient errors. Watlow keeps this dormant until a transient transport class is introduced; the field stays at zero today. |
captured_at |
datetime
|
Wall-clock at snapshot construction (tz-aware UTC). |
DiscoveryResult
dataclass
¶
One probe attempt's outcome from :func:find_devices.
Cross-library shape (mirrors :mod:alicatlib, :mod:sartoriuslib,
:mod:nidaqlib) so GUI Discover dialogs and capa-style
adapters can filter responsive vs silent rows on a single
attribute and consume the same field set across vendors.
A populated :attr:device_info carries the full
:class:Controller.identify result, including
:attr:DeviceInfo.health and (when the scan queried it)
:attr:DeviceInfo.configured_protocol.
The address field is typed str | int | None to match the
cross-library spec; in practice every watlow probe carries an
int.
ErrorContext
dataclass
¶
ErrorContext(
command_name=None,
protocol=None,
port=None,
address=None,
parameter_id=None,
cls=None,
member=None,
instance=None,
register_address=None,
function_code=None,
request=None,
response=None,
elapsed_s=None,
extra=_empty_extra(),
)
Structured context attached to every :class:WatlowError.
Fields are best-effort — missing data is None rather than raising.
extra accepts any Mapping and is always frozen into a read-only
:class:types.MappingProxyType at construction so the shared empty
sentinel can never be mutated through error.context.extra[k] = v.
merged ¶
Return a new context with updates overlaid. Unknown keys go to extra.
Source code in src/watlowlib/errors.py
ErrorPolicy ¶
Bases: Enum
How the manager surfaces per-device failures.
Under :attr:RAISE, the manager collects every controller's result
and — if any call failed — raises an :class:ExceptionGroup
containing the per-device exceptions after the task group joins.
Under :attr:RETURN, each controller produces a
:class:DeviceResult and the caller inspects .error per entry.
FakeTransport ¶
Scripted :class:Transport for tests.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
script
|
Mapping[bytes, ScriptedReply] | None
|
Mapping of |
None
|
label
|
str
|
Identifier used in errors. |
'fake://test'
|
latency_s
|
float
|
Per-operation artificial delay, useful for simulating a slow device. |
0.0
|
Source code in src/watlowlib/transport/fake.py
unmatched_writes
property
¶
Writes that didn't match any scripted reply, in order.
A test can assert transport.unmatched_writes == () to catch
accidentally-unscripted traffic — the corresponding read would
have timed out, but a precise assertion fails faster and points
at the right call.
add_script ¶
extend_queue ¶
feed ¶
Push unsolicited bytes into the read buffer.
Useful for simulating a device that left chatter on the line which the protocol client has to drain on recovery.
Source code in src/watlowlib/transport/fake.py
force_read_timeout ¶
force_write_timeout ¶
FirmwareVersion
dataclass
¶
Semantic firmware version (major.minor.patch).
order=True makes the dataclass orderable on its tuple of fields,
which matches the major.minor.patch precedence callers expect.
parse
classmethod
¶
Parse a string like "1", "1.2", "1.2.3", or "v1.2".
Source code in src/watlowlib/firmware.py
IdentifyStrategy ¶
Bases: Protocol
How a profile turns an open controller into a :class:DeviceInfo.
Implementations are pure with respect to the controller's cached
identity — they read parameters and return a :class:DeviceInfo;
:meth:Controller.identify is responsible for caching the result.
__call__
async
¶
Return identity information for controller.
InMemorySink ¶
Collect every written :class:Sample in a single list.
:attr:samples is appended to (never re-assigned) so callers can
hold a reference across the sink's lifecycle. :meth:close does
not clear the buffer — the point of this sink is post-run
inspection.
Source code in src/watlowlib/sinks/memory.py
JsonlSink ¶
Append-only JSONL writer — one flattened sample per line.
The on-disk format is <sample-row-as-json>\n per sample;
reading back is just [json.loads(line) for line in f]. No
header, no schema declaration, no framing overhead beyond the
newline. Opening points at the same path twice extends the file —
useful for resumable acquisitions and crash-restart scripts.
Source code in src/watlowlib/sinks/jsonl.py
close
async
¶
open
async
¶
Open the JSONL file for writing in append mode.
Pre-existing content is preserved; new samples are appended. Idempotent on already-open sinks.
Source code in src/watlowlib/sinks/jsonl.py
write_many
async
¶
Serialise each sample as one JSON object per line.
Source code in src/watlowlib/sinks/jsonl.py
LoopState
dataclass
¶
Snapshot of one loop. Composed from several reads.
OverflowPolicy ¶
Bases: Enum
What record() does when the receive-stream buffer is full.
The producer runs on an absolute-target schedule; the consumer drains at its own pace. Slow consumers create backpressure — this knob picks how the recorder responds.
BLOCK
class-attribute
instance-attribute
¶
Await the slow consumer. Default. Silent drops are surprising in a data-acquisition setting, so the recorder blocks the producer rather than quietly discarding samples.
DROP_NEWEST
class-attribute
instance-attribute
¶
Drop the batch that was about to be enqueued. Counted as late.
DROP_OLDEST
class-attribute
instance-attribute
¶
Evict the oldest queued batch and enqueue the newest. Useful for real-time monitoring where the latest reading matters more than historical buffer contents. Each evicted batch is counted as late.
ParameterEntry
dataclass
¶
Generic registry-driven read/write result.
Returned by :data:watlowlib.commands.READ_PARAMETER and
:data:watlowlib.commands.WRITE_PARAMETER. The
:class:Controller translates an entry into a :class:Reading /
:class:PartNumber / etc. when the public API guarantees a
richer shape.
ParameterRegistry ¶
Indexed view over a sequence of :class:ParameterSpec rows.
Lookups are O(1) on canonical name, alias, and parameter_id.
Construction is O(N).
Source code in src/watlowlib/registry/parameters.py
has ¶
Return True if name_or_id resolves; never raises.
resolve ¶
Look up a spec by canonical name, alias, or parameter ID.
Raises:
| Type | Description |
|---|---|
WatlowValidationError
|
|
Source code in src/watlowlib/registry/parameters.py
validate_instance ¶
Raise if instance is out of range for spec.
Public so the variant layer can validate before encoding.
Source code in src/watlowlib/registry/parameters.py
validate_value ¶
Soft range check based on the spec's parsed range metadata.
Skipped silently if range_min / range_max couldn't be
parsed from the JSON range field — Watlow's range strings
are not always machine-readable. STRING parameters are not
range-checked.
Source code in src/watlowlib/registry/parameters.py
ParameterSpec
dataclass
¶
ParameterSpec(
parameter_id,
name,
aliases,
data_type,
unit_kind,
rwes,
safety,
cls,
member,
default_instance,
max_instance,
relative_addr,
absolute_addr,
register_count,
word_order=None,
range_min=None,
range_max=None,
default=None,
family_hints=_empty_family_hints(),
scale=1.0,
)
A single parameter row from pm_parameters.json.
Per-protocol fields:
- Std Bus selector: :attr:
cls, :attr:member, :attr:default_instance, :attr:max_instance. - Modbus selector: :attr:
relative_addr, :attr:absolute_addr, :attr:register_count, :attr:word_order(None→ client defaultHIGH_LOWper design §5a).
scale
class-attribute
instance-attribute
¶
Engineering-unit scale factor for the Modbus decode / encode path.
The wire stores raw integers (e.g. the Series SD reports a process
value of 68421 for 68.421 °F); scale is the multiplier
that turns the raw word into engineering units on read
(value * scale) and the divisor that turns engineering units
back into raw words on write (round(value / scale)).
1.0 (the default) means no scaling — and is applied as a
strict identity: the read path skips the multiply entirely when
scale == 1.0 so an integer parameter stays an int rather
than being promoted to float by int * 1.0. Std Bus rows are
never scaled (the Std Bus variant ignores this field).
ParquetSink ¶
Append-only Parquet writer with first-batch schema lock.
Attributes:
| Name | Type | Description |
|---|---|---|
path |
Path
|
Destination Parquet file. |
compression |
_Compression
|
Codec applied to every row group. |
columns |
tuple[ColumnSpec, ...] | None
|
Locked columns in order, or |
Source code in src/watlowlib/sinks/parquet.py
close
async
¶
Flush the footer and close the writer. Idempotent.
Source code in src/watlowlib/sinks/parquet.py
open
async
¶
Load pyarrow and create the parent directory. Idempotent.
The actual :class:pyarrow.parquet.ParquetWriter is opened
lazily on the first :meth:write_many call, because the
writer requires a concrete schema — which we don't have until
the first batch is inspected.
Source code in src/watlowlib/sinks/parquet.py
write_many
async
¶
Append samples as one Parquet row group.
On first call: infers the schema from the batch, locks it,
constructs the matching :mod:pyarrow schema, and opens the
underlying :class:~pyarrow.parquet.ParquetWriter.
Subsequent calls project each row onto the locked schema and
append the rows as a new row group. Unknown columns are
dropped with one-shot WARN (handled by :class:SchemaLock).
Source code in src/watlowlib/sinks/parquet.py
PartNumber
dataclass
¶
Parsed part-number string returned by read_part_number.
Per-family digit decoding is contributed by
:mod:watlowlib.registry.families. Decoded fragments live in
:attr:details as a free-form mapping so each family can populate
only what its ordering format defines, and so adding fragments to
the PM decoder later is non-breaking.
The EZ-ZONE PM decoder populates case size, control type, power
input, three output codes, and options string. Other families fall
through to a stub: only :attr:family is set, and :attr:details
is empty.
PollSource ¶
Bases: Protocol
Minimal shape the recorder needs from its dispatcher.
Both :class:~watlowlib.devices.controller.Controller (solo) and
:class:~watlowlib.manager.WatlowManager (multi-device) satisfy
this Protocol. Using a Protocol keeps :func:record testable
against a lightweight stub without standing up a full controller +
transport pipeline.
The contract is intentionally narrow: per call, return a flat
:class:~collections.abc.Sequence of :class:Sample\ s — one
per (device, parameter) read that succeeded. Failed reads are
dropped from the batch and logged by the source; the recorder
never sees them.
poll_many
async
¶
Read every parameters × instances combination on every device.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
parameters
|
Sequence[str | int]
|
Parameter names or registry IDs. |
required |
names
|
Sequence[str] | None
|
Subset of device names to poll (Manager-only;
Controller ignores). |
None
|
instances
|
Sequence[int]
|
1-indexed loop / channel numbers per device.
Single-loop devices use |
(1,)
|
Returns:
| Type | Description |
|---|---|
Sequence[Sample]
|
A flat :class: |
Sequence[Sample]
|
every poll failed. |
Source code in src/watlowlib/streaming/recorder.py
PollSourceAdapter ¶
Wrap one :class:Controller as a named :class:PollSource.
Implements poll_many(parameters, *, names=None, instances=(1,))
-> Sequence[Sample] — the watlow recorder Protocol. When
names is supplied and does not contain this adapter's name,
the call short-circuits to an empty list (cross-library Manager-
style filter semantics).
Sample relabeling: :meth:Controller.poll_many tags each
:class:Sample with the transport label by default. This adapter
rebuilds each sample via :func:dataclasses.replace to set
:attr:Sample.device to the caller-provided name. Cost is
negligible at typical watlow rates (1–5 Hz × small parameter
sets) and stays well under 1 ms/tick at high parameter counts.
Source code in src/watlowlib/streaming/adapter.py
poll_many
async
¶
Poll the wrapped controller and relabel each emitted sample.
Returns [] when names is supplied and this adapter's
:attr:name is not in it — same filter semantics as
:meth:WatlowManager.poll_many.
Source code in src/watlowlib/streaming/adapter.py
PostgresConfig
dataclass
¶
PostgresConfig(
dsn=None,
host=None,
port=5432,
user=None,
password=None,
database=None,
schema="public",
table="samples",
pool_min_size=1,
pool_max_size=4,
statement_timeout_ms=30000,
command_timeout_s=10.0,
create_table=False,
use_copy=True,
)
Connection + target settings for :class:PostgresSink.
Either dsn or the discrete host/user/database set
must be provided. Credentials are not logged.
Attributes:
| Name | Type | Description |
|---|---|---|
dsn |
str | None
|
Full libpq-style connection string (e.g.
|
host |
str | None
|
Database host. Required if |
port |
int
|
Database port. Defaults to |
user |
str | None
|
Database role. |
password |
str | None
|
Role password. Never logged. |
database |
str | None
|
Database name. |
schema |
str
|
Target schema. Validated against
|
table |
str
|
Target table. Validated against the same pattern. |
pool_min_size |
int
|
Minimum pool size. Defaults to |
pool_max_size |
int
|
Maximum pool size. Defaults to |
statement_timeout_ms |
int
|
|
command_timeout_s |
float
|
asyncpg's per-call command timeout. Defaults to 10 s. |
create_table |
bool
|
If |
use_copy |
bool
|
If |
target ¶
Return a log-safe description of the target: host:port/db.schema.table.
Source code in src/watlowlib/sinks/postgres.py
PostgresSink ¶
Append-only Postgres writer using pooled asyncpg connections.
Attributes:
| Name | Type | Description |
|---|---|---|
config |
PostgresConfig
|
Frozen :class: |
columns |
tuple[ColumnSpec, ...] | None
|
Locked columns in order, or |
Source code in src/watlowlib/sinks/postgres.py
close
async
¶
Close the pool. Idempotent.
Source code in src/watlowlib/sinks/postgres.py
open
async
¶
Load asyncpg, open the pool, and (optionally) introspect the table.
Idempotent. When create_table=False (the default), the
target's columns are read on open and the schema is locked
immediately. When create_table=True the lock happens
lazily on the first :meth:write_many.
Source code in src/watlowlib/sinks/postgres.py
write_many
async
¶
Append samples — one COPY (or executemany) per call.
Source code in src/watlowlib/sinks/postgres.py
ProtocolClient ¶
Bases: Protocol
Per-device protocol client.
Implementations own the wire codec and the per-port lock. The
:class:watlowlib.devices.session.Session is the only caller; it
holds lock for the duration of a single command (request +
reply).
lock
property
¶
Per-client lock acquired by :meth:Session.execute.
One lock per port — a single :class:Session serializes its
own traffic, and :class:watlowlib.manager.WatlowManager
enforces one protocol per port across sessions.
dispose ¶
Mark the client unusable. Subsequent execute calls raise.
Synchronous because dispose is called from teardown paths that
don't always have an event loop. The client is responsible for
closing its transport (or signalling the owning :class:Session
to do so) — this method just trips the flag.
Source code in src/watlowlib/protocol/base.py
execute
async
¶
Send request to address, return the typed reply.
address travels with every call so one client can serve
multiple devices on a multi-drop RS-485 segment without
re-construction. Std Bus accepts 1..16, Modbus RTU accepts
1..247.
timeout overrides :attr:watlowlib.config.DEFAULTS.io_timeout_s
for this call only. command_name is threaded into log
events and error contexts; it is informational, not load-bearing
for dispatch.
Source code in src/watlowlib/protocol/base.py
ProtocolKind ¶
Bases: StrEnum
Wire protocol selected for a session.
AUTO triggers the conservative Std Bus → Modbus probe.
Reading
dataclass
¶
A single timestamped value from the controller.
protocol is set by the variant decoder, not by the facade —
it reflects which wire protocol produced the value (per
docs/design.md invariant 7).
Recording
dataclass
¶
Container yielded by :func:record — stream + live summary + rate.
Cross-library shape (alicat / sartorius / watlow / nidaq) so
consumers consume the same recording.stream /
recording.summary / recording.rate_hz accessors regardless
of vendor.
Per-library payload (the T parameter):
- alicat / sartorius:
Recording[Mapping[str, Sample]] - watlow:
Recording[Sequence[Sample]]— per-tick batches - nidaq:
Recording[DaqReading](polled) /Recording[DaqBlock](block)
Attributes:
| Name | Type | Description |
|---|---|---|
stream |
AsyncIterator[T]
|
Async iterator of per-tick payloads. Consume with
|
summary |
AcquisitionSummary
|
Live :class: |
rate_hz |
float
|
The cadence the recorder is running at, captured at
|
RwesFlag ¶
Bases: StrEnum
Persistence + access flag from the EZ-ZONE register list.
R— read-only.W— write-only (rare; typically actions like "start autotune").RW— runtime read/write, not EEPROM-backed.RWE— RW + persisted to EEPROM.RWES— RWE + saved set ("save settings to user memory").
Mapping to :class:SafetyTier is in
:func:_safety_from_rwes; the registry binds the result to
:attr:ParameterSpec.safety at load time.
SafetyTier ¶
Bases: IntEnum
How dangerous a command is to invoke.
READ_ONLY(R) — no state change.STATEFUL— runtime state change but not EEPROM-backed. Reserved for commands like "start autotune"; no PM parameter maps here today, but the tier exists so future commands have a place to live.PERSISTENT(RW / RWE / RWES) — EEPROM-backed; requiresconfirm=Trueat the facade.
Sample
dataclass
¶
Sample(
device,
address,
protocol,
parameter,
parameter_id,
instance,
value,
unit,
t_mono_ns,
t_utc,
t_midpoint_mono_ns,
requested_at,
received_at,
latency_s,
raw,
)
One parameter read with full timing provenance.
Attributes:
| Name | Type | Description |
|---|---|---|
device |
str
|
Manager-assigned name (or controller label for solo recordings). Stable downstream identifier that follows the value into sinks. |
address |
int
|
Bus address of the polled device. Std Bus 1..16, Modbus RTU 1..247. |
protocol |
ProtocolKind
|
Wire protocol that decoded this read. Set from the session's protocol kind, not the reading metadata, so a mixed-protocol recording records the source per row. |
parameter |
str
|
Canonical parameter name (e.g. |
parameter_id |
int
|
Registry parameter id (e.g. |
instance |
int
|
1-indexed loop / channel selector used for the read. |
value |
float | int | str | bool | None
|
The decoded scalar. |
unit |
Unit | str | None
|
Concrete :class: |
t_mono_ns |
int
|
:func: |
t_utc |
datetime
|
Wall-clock midpoint of the request/reply round-trip (tz-aware UTC). Preferred point estimate when aligning Watlow samples against other sensor streams in human time. |
t_midpoint_mono_ns |
int | None
|
Optional integration-window midpoint in
monotonic nanoseconds. |
requested_at |
datetime
|
Wall-clock |
received_at |
datetime
|
Wall-clock |
latency_s |
float
|
|
raw |
bytes
|
The wire payload that produced the value. Available for diagnostics; tabular sinks drop it. |
SampleSink ¶
Bases: Protocol
Minimal shape of an acquisition sink.
Sinks own their storage handle lifecycle. Concrete implementations typically follow this call sequence:
await sink.open()— allocate file descriptors, DB connections, etc. Safe to call again on an already-open sink.await sink.write_many(samples)— one or more times.samplesis a :class:~collections.abc.Sequenceso the sink knows the full batch up front (CSV column inference, Parquet row groups, parameterised inserts).await sink.close()— flush and release the handle. Idempotent.
The async context-manager methods provide an async with sink:
shape for the common case of "open → write → close" in one block.
__aenter__
async
¶
__aexit__
async
¶
close
async
¶
open
async
¶
write_many
async
¶
Append samples to the sink.
Sequence (not Iterable) because every in-tree sink wants
len() — CSV schema inference, batched parameterised inserts,
Parquet row-group bookkeeping.
Source code in src/watlowlib/sinks/base.py
SerialSettings
dataclass
¶
SerialSettings(
port,
baudrate=38400,
bytesize=ByteSize.EIGHT,
parity=Parity.NONE,
stopbits=StopBits.ONE,
rtscts=False,
xonxoff=False,
exclusive=True,
)
Serial-port configuration for :class:SerialTransport.
Mirrors :class:anyserial.SerialConfig plus a port path. Default
framing is 38400 8-N-1, the EZ-ZONE PM Standard Bus factory
setting. exclusive defaults True because Standard Bus is
poll/response and won't tolerate a second writer.
The __post_init__ accepts int / float / str shorthand
at runtime for the framing fields (bytesize=8, parity="none",
stopbits=1) and normalises to the enum. The static field types
are the enums themselves so mypy --strict users must pass
:class:anyserial.ByteSize / :class:anyserial.Parity /
:class:anyserial.StopBits directly; the runtime shorthand is
primarily for CLI argument parsing and interactive scripts.
factory_for
classmethod
¶
Return the EZ-ZONE PM factory framing for protocol.
STDBUS→ 38400 8-N-1 (the Standard Bus factory default).MODBUS_RTU→ 9600 8-E-1 (the Modbus RTU factory default per the EZ-ZONE PM manual).
AUTO raises :class:WatlowConfigurationError — there is no
single factory framing for AUTO, the detector probes both.
Callers crossing protocol boundaries (the maintenance helpers
that switch protocol, watlow-discover --protocol both)
should rebuild settings per protocol via this method instead
of inheriting whatever framing the previous call used.
Source code in src/watlowlib/transport/base.py
SerialTransport ¶
:class:Transport backed by a real serial port via anyserial.
Tests that don't need hardware should use
:class:watlowlib.transport.fake.FakeTransport; the two conform to
the same structural :class:Transport Protocol.
Source code in src/watlowlib/transport/serial.py
Session ¶
Owns availability cache, gates, and the dispatch loop.
A :class:Session is bound to exactly one :class:ProtocolClient
for its lifetime — one protocol per port (invariant 1).
Source code in src/watlowlib/devices/session.py
client
property
¶
The bound protocol client.
Exposed for the watlow-raw escape hatch and for diagnostics
that need to issue an unframed wire op outside the registry.
Callers must acquire :attr:ProtocolClient.lock before
:meth:ProtocolClient.execute to honour the per-port
serialization invariant, and must pass this session's
:attr:address (or another concrete address for multi-drop
diagnostics) to execute.
registry
property
¶
Parameter registry bound to this session.
Exposed for the streaming layer so polling code can resolve a
name / id to a :class:ParameterSpec without an extra import
of the module-level :data:PARAMETERS.
availability ¶
availability_summary ¶
Return the current command availability cache.
Includes every command the session has dispatched; the snapshot path filters to the UNSUPPORTED entries.
Source code in src/watlowlib/devices/session.py
clear_last_error ¶
comms_unit_label
async
¶
Return the value parameter 17050 reports for this session.
Inspection helper, not a source of truth: on at least one PM3
firmware revision 17050 is a label-only register that does not
govern the wire scale. :class:Reading.unit is sourced from
:meth:wire_temperature_unit instead.
Reads the parameter on first call and caches the result for
the lifetime of the session. Returns None when the device
rejects the read or reports an unknown code; the cache
distinguishes "haven't asked yet" from "asked and got nothing"
so a rejection does not cost another wire turn-around.
Invalidated by :meth:invalidate_comms_unit_label (called by
:meth:Controller.set_comms_unit_label after a successful
write).
Source code in src/watlowlib/devices/session.py
dispose ¶
execute
async
¶
Dispatch command with request and return the typed response.
Source code in src/watlowlib/devices/session.py
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 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 | |
invalidate_comms_unit_label ¶
Drop the cached 17050 value so the next read re-queries the device.
set_wire_temperature_unit ¶
Set the wire temperature scale from an authoritative source.
Called by an identity strategy that has read the device's own unit register (e.g. the Series SD reg 18). Unlike the PM path — where the unit register can lie, so the value stays a user assertion — this is the device telling us its comms scale directly, so it is honest to adopt it. Resets the one-shot "trusting user-asserted unit" warning since this value is device-sourced, not user-asserted.
Source code in src/watlowlib/devices/session.py
wire_temperature_unit ¶
Return the user-asserted scale of temperature values on the wire.
This is what :class:Reading.unit / :class:Sample.unit get
tagged with for temperature parameters. None when the user
did not pass assert_wire_temperature_unit= to
:func:watlowlib.open_device; in that case temperature
readings carry unit=None rather than guess.
Pure accessor — no I/O. Logs a one-shot WARN the first time an asserted value is consumed so the user-assertion shows up plainly in capture logs.
Source code in src/watlowlib/devices/session.py
SqliteSink ¶
SqliteSink(
path,
*,
table="samples",
create_table=True,
journal_mode="WAL",
synchronous="NORMAL",
busy_timeout_ms=5000,
)
Append-only SQLite writer with WAL journaling and first-batch schema lock.
Attributes:
| Name | Type | Description |
|---|---|---|
path |
Path
|
Destination SQLite file. Created on :meth: |
table |
str
|
Target table name. |
columns |
tuple[ColumnSpec, ...] | None
|
The locked :class: |
Source code in src/watlowlib/sinks/sqlite.py
close
async
¶
Close the connection. Idempotent.
Source code in src/watlowlib/sinks/sqlite.py
open
async
¶
Open the SQLite connection, apply PRAGMAs, and introspect the target.
Idempotent: calling :meth:open on an already-open sink is a
no-op. Runs in a worker thread because sqlite3.connect and
PRAGMA execution are blocking I/O.
Source code in src/watlowlib/sinks/sqlite.py
write_many
async
¶
Append samples as rows in a single transaction.
On the first call (when create_table=True), infers the
schema from the batch and runs CREATE TABLE IF NOT EXISTS.
Subsequent calls insert directly. All values pass through
? placeholders — never string-formatted into SQL.
Source code in src/watlowlib/sinks/sqlite.py
Transport ¶
Bases: Protocol
Byte-level transport.
Every I/O boundary takes an explicit timeout. On expiry,
implementations raise :class:watlowlib.errors.WatlowTimeoutError
— never return an empty or partial bytes silently. Backend
exceptions normalise to
:class:watlowlib.errors.WatlowTransportError (or a subclass)
with __cause__ preserving the original exception.
Lifecycle is single-shot: :meth:open once, :meth:close once.
close
async
¶
drain_input
async
¶
open
async
¶
read_available
async
¶
Read until the line goes idle for idle_timeout seconds.
Never raises on idle expiry — an idle timeout is the expected
exit. Returns whatever was accumulated (possibly empty). Used
for best-effort drain and ProtocolKind.AUTO probe gaps.
Source code in src/watlowlib/transport/base.py
read_exact
async
¶
Read exactly n bytes.
Raises :class:watlowlib.errors.WatlowTimeoutError if fewer
than n bytes arrive before timeout. Partial buffers are
retained for the next call — implementations must not discard
them.
Source code in src/watlowlib/transport/base.py
Unit ¶
Bases: StrEnum
Concrete display unit attached to a :class:Reading value.
UnitKind ¶
Bases: StrEnum
Structural unit family of a parameter, as declared by the registry.
Maps to a concrete :class:Unit at read time via
:func:resolve_unit. TEMPERATURE resolves to °C or °F depending
on the device's comms display setting (parameter 17050); the rest
are independent of device state.
WatlowCapabilityError ¶
Bases: WatlowError
Command is not available on this device / firmware / family.
Reserved for the planned capability-gate hierarchy. The library
does not currently raise this — capability mismatches surface as
:class:WatlowConfigurationError today (see commands/loop.py).
Source code in src/watlowlib/errors.py
WatlowCapabilityWarning ¶
Bases: UserWarning
Reserved warning class — not currently emitted.
Planned use is non-strict family-prior mismatches (attempt the command, warn, update availability from the device's response). The library does not currently emit this; the warning class is exported so callers can pre-register filters.
WatlowConfigurationError ¶
Bases: WatlowError
Configuration-level error (bad args, conflicting settings).
Source code in src/watlowlib/errors.py
WatlowConfirmationRequiredError ¶
Bases: WatlowConfigurationError
A PERSISTENT command was attempted without confirm=True.
Source code in src/watlowlib/errors.py
WatlowConnectionError ¶
Bases: WatlowTransportError
Could not open / lost the connection to the device.
Source code in src/watlowlib/errors.py
WatlowDeviceSnapshot
dataclass
¶
WatlowDeviceSnapshot(
name,
model,
firmware,
serial,
connected,
last_error,
recoverable_error_count,
captured_at,
family,
capabilities,
availability_summary=(lambda: {})(),
)
Bases: DeviceSnapshot
Watlow-specific extension of :class:DeviceSnapshot.
Attributes:
| Name | Type | Description |
|---|---|---|
family |
ControllerFamily | None
|
Decoded :class: |
capabilities |
Capability
|
SKU-decoded :class: |
availability_summary |
Mapping[str, Availability]
|
Frozen mapping of command names that
the session has marked :attr: |
WatlowError ¶
Bases: Exception
Base class for every :mod:watlowlib exception.
Carries a typed :class:ErrorContext. The message is the human-readable
summary; the context is the machine-readable detail.
Source code in src/watlowlib/errors.py
with_context ¶
Return a copy of this error with its context updated.
Useful when an inner layer raises and an outer layer wants to enrich
the context (for instance adding port or elapsed_s).
Source code in src/watlowlib/errors.py
WatlowFirmwareError ¶
Bases: WatlowCapabilityError
Command is outside the supported firmware window.
Reserved alongside :class:WatlowCapabilityError; not currently
emitted by the library.
Source code in src/watlowlib/errors.py
WatlowFrameError ¶
Bases: WatlowProtocolError
Bad CRC, bad length, malformed framing, etc.
Source code in src/watlowlib/errors.py
WatlowManager ¶
Coordinator for many controllers across one or more serial ports.
Operations run concurrently across different physical ports (via
:func:anyio.create_task_group) and serialise on the same-port
client lock. Per-controller failures are surfaced per
:attr:error_policy:
- :attr:
ErrorPolicy.RAISE: the manager still collects results from every controller, then raises an :class:ExceptionGroupif any failed. - :attr:
ErrorPolicy.RETURN: per-name :class:DeviceResultcontainers carry.valueor.error.
Usage::
async with WatlowManager() as mgr:
await mgr.add("ctl1", "/dev/ttyUSB0", address=1)
await mgr.add("ctl2", "/dev/ttyUSB1", address=1)
samples = await mgr.poll_many(["process_value", "setpoint"])
Source code in src/watlowlib/manager.py
add
async
¶
add(
name,
source,
*,
protocol=ProtocolKind.STDBUS,
address=1,
serial_settings=None,
profile=EZZONE_PROFILE,
assert_wire_temperature_unit=None,
)
Register and open a controller under name.
The source discriminates lifecycle ownership:
- :class:
Controller— pre-built (via :func:watlowlib.open_deviceoutside the manager). The manager only tracks the name mapping; it does not take lifecycle ownership. str— serial port path ("/dev/ttyUSB0","COM3"). The manager creates a transport, canonicalises the port key, and shares the transport + client across controllers on the same bus. Mixing Std Bus and Modbus on a shared physical port is refused; one serial link has one active protocol.- :class:
Transport— duck-typed transport. The manager builds a session against it but does not take transport ownership.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Unique manager-level identifier. |
required |
source
|
Controller | str | Transport
|
One of the three lifecycle shapes above. |
required |
protocol
|
ProtocolKind
|
Wire protocol ( |
STDBUS
|
address
|
int
|
Bus address. Std Bus accepts |
1
|
serial_settings
|
SerialSettings | None
|
Override default serial framing. Only
honoured when |
None
|
profile
|
DeviceProfile
|
Device profile to open against. Defaults to
:data: |
EZZONE_PROFILE
|
assert_wire_temperature_unit
|
Unit | str | None
|
Same semantics as
:func: |
None
|
Returns:
| Type | Description |
|---|---|
Controller
|
The opened :class: |
Raises:
| Type | Description |
|---|---|
WatlowValidationError
|
|
WatlowConfigurationError
|
protocol mismatches an existing
lock on the same port, or |
WatlowConnectionError
|
Manager is closed. |
Source code in src/watlowlib/manager.py
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 | |
close
async
¶
Tear down every managed controller and port (LIFO).
Per-device teardown errors are collected; if any occurred,
they are raised after the close completes as an
:class:ExceptionGroup. This makes explicit await mgr.close()
calls fail loud on resource leaks. The async-CM exit path
swallows the errors instead so an in-flight exception still
wins (see :meth:__aexit__).
Source code in src/watlowlib/manager.py
execute_each
async
¶
Run op(controller) on every (or named) controller concurrently.
General-purpose dispatcher used for cross-device snapshots
(identify, read_pid, etc.) where each controller runs
the same coroutine and the result is keyed by name. Cross-port
runs concurrently; same-port serialises on the shared client
lock.
Under :attr:ErrorPolicy.RAISE the method still returns a
complete result mapping but re-raises an :class:ExceptionGroup
of every per-device error after the task group joins.
Source code in src/watlowlib/manager.py
get ¶
Return the controller registered under name.
Source code in src/watlowlib/manager.py
poll
async
¶
Read the active process value on every (or named) controller.
The canonical no-arg snapshot — aligns with the ecosystem
Manager.poll() shape shared by alicatlib.AlicatManager,
sartoriuslib.SartoriusManager, and
nidaqlib.DaqManager: one :class:DeviceResult per device,
keyed by name. Cross-port reads run concurrently; same-port
reads serialise on the shared client lock.
For multi-parameter / multi-instance polling use :meth:poll_many.
Source code in src/watlowlib/manager.py
poll_many
async
¶
Poll every (or named) controller concurrently across ports.
Returns a flat list of :class:Sample — one per (device,
parameter, instance) read that succeeded. Failed reads are
dropped from the list and logged at WARN. Cross-port reads run
concurrently; same-port reads serialise on the shared client
lock, which is acquired once per port-group batch so a
queued writer cannot land between two reads of the same poll.
Lock occupancy therefore scales O(devices × parameters × per-
read time) per port — the trade-off for a coherent multi-
device snapshot.
This satisfies the :class:watlowlib.streaming.PollSource
Protocol so a manager can drive :func:watlowlib.streaming.record
directly.
Source code in src/watlowlib/manager.py
remove
async
¶
Unregister and close the controller named name.
If name was the last controller on a shared port, the
transport for that port is closed too. A pre-built
:class:Controller source is only dropped from the manager's
registry — the caller retains lifecycle ownership.
Source code in src/watlowlib/manager.py
WatlowModbusError ¶
Bases: WatlowProtocolError
Base class for Modbus-layer errors.
Wraps every anymodbus exception so callers see one error
hierarchy regardless of protocol. __cause__ preserves the
original anymodbus exception for callers that need it.
Source code in src/watlowlib/errors.py
WatlowModbusIllegalDataAddressError ¶
Bases: WatlowModbusError, WatlowProtocolUnsupportedError
Modbus exception 0x02 — register address not allowed.
Maps to :class:Availability.UNSUPPORTED in the session cache.
Source code in src/watlowlib/errors.py
WatlowModbusIllegalDataValueError ¶
Bases: WatlowModbusError
Modbus exception 0x03 — bad argument, not absence.
Does not affect availability (the parameter exists; the write value was simply rejected).
Source code in src/watlowlib/errors.py
WatlowModbusIllegalFunctionError ¶
Bases: WatlowModbusError, WatlowProtocolUnsupportedError
Modbus exception 0x01 — slave does not implement the function.
Maps to :class:Availability.UNSUPPORTED in the session cache.
Inherits :class:WatlowProtocolUnsupportedError so the session's
sticky-unsupported handling treats it like Std Bus 0x81 /
0x83.
Source code in src/watlowlib/errors.py
WatlowModbusSlaveFailureError ¶
Bases: WatlowModbusError
Modbus exception 0x04 — unrecoverable slave-side error.
Does not affect availability — non-response is not a refusal of the parameter (per design §5b table).
Source code in src/watlowlib/errors.py
WatlowModbusTimeoutError ¶
Bases: WatlowModbusError, WatlowTimeoutError
Modbus request timed out at the protocol layer.
Inherits :class:WatlowTimeoutError so callers with existing
timeout-handling code see this as a transport timeout. Does not
affect availability.
Source code in src/watlowlib/errors.py
WatlowNoSuchAttributeError ¶
Bases: WatlowProtocolError
Standard Bus error 0x83 — valid class, invalid member.
Maps to :class:Availability.UNSUPPORTED in the session cache.
Source code in src/watlowlib/errors.py
WatlowNoSuchInstanceError ¶
Bases: WatlowProtocolError
Standard Bus error 0x84 — valid class+member, invalid instance.
The parameter exists but the requested loop / channel does not. Does not affect availability (a different instance may succeed).
Source code in src/watlowlib/errors.py
WatlowNoSuchObjectError ¶
Bases: WatlowProtocolError
Standard Bus error 0x81 — invalid class.
The device does not expose the requested object class. Maps to
:class:Availability.UNSUPPORTED in the session cache.
Source code in src/watlowlib/errors.py
WatlowProtocolError ¶
Bases: WatlowError
Protocol-level error (framing, parsing, unrecognised reply).
Source code in src/watlowlib/errors.py
WatlowProtocolUnsupportedError ¶
Bases: WatlowProtocolError
The active protocol cannot satisfy this command on this device.
Sticky for the session: subsequent attempts at the same command
short-circuit with this error pre-I/O. Set on Std Bus 0x81 /
0x83 and on Modbus IllegalFunction / IllegalDataAddress
per docs/design.md §5b.
Source code in src/watlowlib/errors.py
WatlowSinkDependencyError ¶
Bases: WatlowSinkError
An optional sink extra is not installed.
Raised by sinks behind a [parquet] / [postgres] extra when the
backing dependency (pyarrow, asyncpg) is missing at
:meth:open time. Instantiating the sink succeeds on bare-core
installs; the dependency check is deferred so import-time errors
don't break callers that never reach for the extra.
Source code in src/watlowlib/errors.py
WatlowSinkError ¶
Bases: WatlowError
Base class for :mod:watlowlib.sinks errors.
Source code in src/watlowlib/errors.py
WatlowSinkSchemaError ¶
Bases: WatlowSinkError
The target sink's existing schema does not match what the row carries.
Raised when a sink configured with create_table=False is pointed
at a missing or incompatible backing schema.
Source code in src/watlowlib/errors.py
WatlowSinkWriteError ¶
Bases: WatlowSinkError
A sink-backend write failed (CREATE TABLE, INSERT, file IO, ...).
__cause__ preserves the backend-native exception (sqlite3.Error,
OSError, ...) for callers that want to inspect it.
Source code in src/watlowlib/errors.py
WatlowTimeoutError ¶
Bases: WatlowTransportError
A transport read or write timed out.
Source code in src/watlowlib/errors.py
WatlowTransportError ¶
WatlowValidationError ¶
Bases: WatlowConfigurationError
Request validation failed before I/O (bad instance, bad value).
Source code in src/watlowlib/errors.py
classify_family ¶
Return the :class:ControllerFamily for a part-number string.
Only the leading family discriminator is parsed; per-family digit
decoding is in :func:decode_part_number.
Source code in src/watlowlib/registry/families.py
find_devices
async
¶
find_devices(
*,
ports=None,
addresses=None,
baudrates=None,
protocols=None,
profiles=None,
serial_template=None,
per_probe_timeout_s=_DEFAULT_PROBE_TIMEOUT_S,
)
Probe local serial ports for Watlow controllers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
ports
|
Sequence[str] | None
|
Serial-port paths to scan. |
None
|
addresses
|
Sequence[int] | None
|
Bus addresses to probe per (port, baudrate, protocol)
combination. Defaults to :data: |
None
|
baudrates
|
Sequence[int] | None
|
Baud rates to try. Defaults to
:data: |
None
|
protocols
|
Sequence[ProtocolKind] | None
|
Wire protocols to probe. Defaults to
:data: |
None
|
profiles
|
Sequence[DeviceProfile] | None
|
Device profiles to probe. When given, discovery
iterates profiles instead of |
None
|
serial_template
|
SerialSettings | None
|
Optional :class: |
None
|
per_probe_timeout_s
|
float
|
Per-probe budget. Bounds the
:meth: |
_DEFAULT_PROBE_TIMEOUT_S
|
Returns:
| Name | Type | Description |
|---|---|---|
One |
list[DiscoveryResult]
|
class: |
list[DiscoveryResult]
|
address) tuple, in input order. The cartesian product is |
|
list[DiscoveryResult]
|
iterated outermost-port, then baudrate, then protocol, then |
|
list[DiscoveryResult]
|
address — same input → same output ordering. |
Raises:
| Type | Description |
|---|---|
WatlowConfigurationError
|
|
Notes
- Read-only. Discovery never writes to the device; it
only calls :meth:
Controller.identify(four parameter reads). Safe to run on rigs that already have other software talking to the controller. - Per-port short-circuit. If a port fails to open with a
:class:
WatlowConnectionError, the rest of the scan for that port emitsok=Falserows without re-attempting the open. This avoids hammering a port the kernel won't give us.
Source code in src/watlowlib/devices/discovery.py
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 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 | |
open_device
async
¶
open_device(
port,
*,
profile=EZZONE_PROFILE,
protocol=None,
address=1,
serial_settings=None,
assert_wire_temperature_unit=None,
identify=True,
)
Open a controller on a serial port.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
port
|
str
|
Serial-port path ( |
required |
profile
|
DeviceProfile
|
The device profile to open against. Defaults to
:data: |
EZZONE_PROFILE
|
protocol
|
ProtocolKind | None
|
Wire protocol. |
None
|
address
|
int
|
Bus address. Std Bus accepts |
1
|
serial_settings
|
SerialSettings | None
|
Optional framing override. |
None
|
identify
|
bool
|
When |
True
|
assert_wire_temperature_unit
|
Unit | str | None
|
User-asserted scale of
temperature values on the wire. Sets
:class: |
None
|
Returns:
| Type | Description |
|---|---|
Controller
|
An opened :class: |
Controller
|
meth: |
Controller
|
Every protocol ( |
Controller
|
an opened controller; |
Controller
|
|
Raises:
| Type | Description |
|---|---|
WatlowConfigurationError
|
|
WatlowValidationError
|
|
WatlowProtocolUnsupportedError
|
|
Source code in src/watlowlib/devices/factory.py
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 | |
pipe
async
¶
Drain stream into sink with buffered flushes.
Reads per-tick batches from the recorder and accumulates the
individual :class:Sample\ s into a list. A flush happens when
either threshold is first crossed:
- the buffer reaches
batch_sizesamples, or flush_intervalseconds have elapsed since the last flush.
The time-based check fires on every incoming batch, so the actual
inter-flush latency is bounded below by the recorder's tick
period: effective_flush_period ≈ max(flush_interval,
1 / rate_hz). For low-rate acquisitions (rate_hz < 1 / flush_interval)
the recorder cadence dominates; for high-rate acquisitions the
configured flush_interval dominates. Either way, on stream
exhaustion any leftover buffer is flushed before the summary is
returned.
The samples_late / max_drift_ms fields on the returned
summary stay at zero — those are recorder-layer concepts. The
recorder emits its own summary on CM exit; this summary is the
sink-side view.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stream
|
AsyncIterator[Sequence[Sample]]
|
The async iterator yielded by
:func: |
required |
sink
|
SampleSink
|
Any :class: |
required |
batch_size
|
int
|
Buffer threshold in samples (not batches). |
64
|
flush_interval
|
float
|
Time threshold in seconds between flushes. |
1.0
|
Returns:
| Name | Type | Description |
|---|---|---|
An |
AcquisitionSummary
|
class: |
AcquisitionSummary
|
the count actually handed to the sink. |
Raises:
| Type | Description |
|---|---|
ValueError
|
|
Source code in src/watlowlib/sinks/base.py
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 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 | |
record
async
¶
record(
source,
*,
parameters,
rate_hz,
duration=None,
names=None,
instances=(1,),
overflow=OverflowPolicy.BLOCK,
buffer_size=64,
auto_reconnect=False,
reconnect_factory=None,
)
Record polled samples into a receive stream at an absolute cadence.
Usage::
async with record(
controller, parameters=["process_value", "setpoint"], rate_hz=2, duration=10
) as recording:
async for batch in recording.stream:
for sample in batch:
print(sample.parameter, sample.value)
# recording.summary is live; recording.summary.finished_at is None
# while running and set on CM exit.
The CM yields a :class:Recording[Sequence[Sample]] exposing
.stream (async iterator of per-tick :class:Sample batches),
.summary (live :class:AcquisitionSummary — recorder is sole
writer), and .rate_hz (the cadence the recorder is running
at).
Each batch is a flat :class:Sequence — one entry per (device,
parameter, instance) read that succeeded. Failed reads are dropped
by the source and logged at WARN.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
PollSource
|
Any :class: |
required |
parameters
|
Sequence[str | int]
|
Parameter names or registry IDs to poll each tick. |
required |
rate_hz
|
float
|
Target cadence. Absolute targets are computed
|
required |
duration
|
float | None
|
Total acquisition duration in seconds. |
None
|
names
|
Sequence[str] | None
|
Subset of device names to poll per tick. |
None
|
instances
|
Sequence[int]
|
1-indexed loop / channel numbers per device. Single-
loop devices use |
(1,)
|
overflow
|
OverflowPolicy
|
Backpressure policy when the receive-stream buffer
is full. See :class: |
BLOCK
|
buffer_size
|
int
|
Receive-stream capacity, in per-tick batches. |
64
|
auto_reconnect
|
bool
|
When |
False
|
reconnect_factory
|
Callable[[], Awaitable[PollSource]] | None
|
When supplied alongside |
None
|
Yields:
| Name | Type | Description |
|---|---|---|
A |
AsyncGenerator[Recording[Sequence[Sample]]]
|
class: |
AsyncGenerator[Recording[Sequence[Sample]]]
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
|
Source code in src/watlowlib/streaming/recorder.py
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 | |
sample_to_row ¶
Flatten a :class:Sample into a single row dict for tabular sinks.
Long-format schema (one row per parameter read), stable across all in-tree sinks:
device— manager-assigned name (or controller transport label).address— bus address.protocol— wire protocol that produced the read (string).parameter— canonical parameter name.parameter_id— registry parameter id.instance— 1-indexed loop / channel selector.value— decoded value, coerced to a sink-friendly scalar (bools become"true"/"false"strings so SQLite type inference doesn't pin the column to INTEGER for the run).unit— :class:Unit's.value("C"/"F"/"%") for Watlow rows, a free-form string for cross-vendor rows ("psia","sccm", ...), orNonewhen the parameter has no unit.t_mono_ns— monotonic-ns canonical join key.t_utc— wall-clock acquisition midpoint, ISO 8601 string.requested_at/received_at— ISO 8601 strings.latency_s— poll round-trip in seconds.
The sample's raw payload is intentionally not in the row:
bytes don't fit cleanly into CSV / JSONL / SQLite affinities, and
tabular sinks are for time-series queries, not byte-level
diagnostics. Callers that need raw consume :class:Sample
directly via :class:~watlowlib.sinks.memory.InMemorySink.