alicatlib.devices¶
Device facades and identification. See Devices for
the prefix matrix, identification pipeline, and escape hatches;
Data frames for DataFrame / DataFrameFormat;
Design §5.9 / §5.9a for the class tree and Medium
model.
Public leaves¶
alicatlib.devices ¶
Device facades for every Alicat instrument family.
Facades: :class:Device, :class:FlowMeter, :class:FlowController,
:class:PressureMeter, :class:PressureController.
See docs/design.md §5.9 for the class tree and §5.9a for the
orthogonal :class:Medium model.
This package keeps __init__ minimal — only the zero-import leaf
modules (:class:DeviceKind, :class:Medium) re-export here. The
:class:Session layer (:mod:alicatlib.devices.session) and data-frame
models (:mod:alicatlib.devices.data_frame,
:mod:alicatlib.devices.models) are imported by protocol-layer
parsers and the command catalog; promoting them into this package's
__init__ would trigger a circular import when parsing helpers
reach back into the devices package. Users import those names from
their own modules (from alicatlib.devices.session import Session).
DeviceKind ¶
Bases: StrEnum
What kind of Alicat device we're talking to.
Coarser than :class:alicatlib.commands.base.Capability — a flow meter
might or might not have a barometer; a flow controller might have one,
two, or three valves. Per-feature gating is via Capability; this enum
just says "mass-flow meter vs mass-flow controller vs pressure meter ..."
so commands can declare a short list of compatible kinds.
UNKNOWN
class-attribute
instance-attribute
¶
Catch-all for models the factory's MODEL_RULES table doesn't match.
A device with this kind still gets a generic :class:Device facade
(poll() and execute() work); only commands whose
device_kinds explicitly list UNKNOWN will dispatch — the
session's kind-gating (§5.7) rejects the rest. This is the "loud
silence" path: we'd rather tell users "unknown model, try model_hint"
than silently classify a new MFC as a pressure controller.
Medium ¶
Bases: Flag
What kind of fluid a device moves.
Orthogonal to :class:~alicatlib.devices.kind.DeviceKind (function
× form). A :class:Flag rather than a plain :class:Enum so the
model can represent devices whose media is ambiguous at the prefix
level — either because the hardware truly supports both (some
Coriolis lines are reported this way) or because the prefix covers
multiple order-time configurations. Gating via bitwise intersection
keeps a single code path for every configuration:
.. code:: python
if not (device.info.media & command.media):
raise AlicatMediumMismatchError(...)
See design §5.9a for the full rationale on modelling medium as a
flag (not an enum), why the class tree stays kind-shaped rather
than medium-shaped, and why assume_media on the factory
replaces rather than unions.
GAS
class-attribute
instance-attribute
¶
Device is configured for gas. Gas-specific commands (GS, ??G*,
gas-mix edits) pass the media gate; liquid-specific commands fail pre-I/O.
LIQUID
class-attribute
instance-attribute
¶
Device is configured for liquid. Liquid-specific commands (fluid select / list, per-fluid reference density) pass the media gate; gas commands fail pre-I/O.
NONE
class-attribute
instance-attribute
¶
No medium resolved. Only valid as an intermediate during identification;
a live :class:~alicatlib.devices.models.DeviceInfo always carries at
least one of :attr:GAS / :attr:LIQUID.
Factory and lifecycle¶
alicatlib.devices.factory ¶
Device factory — identification pipeline + open_device context manager.
The factory implements design §5.9's staged identification flow:
- (Optional) stream recovery — passively read the transport for ~100 ms;
if any bytes arrive, the device was left streaming by a prior process,
so issue a stop-stream (
@@ {unit_id}) and drain before the real identify begins. VE— firmware version; works on every firmware family and is the anchor of identification.??M*— 10-line manufacturing-info table, only when firmware is numeric-family and ≥ 8v28. Parsed by the protocol layer into :class:ManufacturingInfo; the factory applies a best-guessM<NN>→ named-field mapping to synthesise :class:DeviceInfo.- Fallback — for GP / pre-8v28 devices
??M*isn't available, so the caller must supplymodel_hint. The factory raises :class:AlicatConfigurationErrorif identification reaches this branch without a hint. - Capability probing — :func:
probe_capabilitiesprobes the device for each :class:Capabilityflag, failing closed (default absent on timeout /?/ parse error). Outcomes are retained in :attr:DeviceInfo.probe_reportfor diagnostics; gating uses only the flag set. ??D*— cached on the session as :attr:Session.data_frame_format.- Model-rule dispatch — :func:
device_class_forpicks the correct :class:Devicesubclass via the :data:MODEL_RULEStable.
Stream recovery, capability probing, and the M-code → named-field mapping are all marked as best-effort and will be tightened against hardware captures.
Design reference: docs/design.md §5.9, §5.20.
ModelRule
dataclass
¶
One entry in the model-prefix dispatch table (design §5.9, §5.9a).
The factory walks :data:MODEL_RULES in declared order and returns
the first rule whose :attr:prefix matches the identified model.
Ordering matters: longer / more-specific prefixes must come before
their shorter kin (MCDW- before MCW- before MC-), so the
most-specific match wins.
Every currently-supported prefix resolves :attr:kind deterministically
— the published Alicat part-number decoders for the M, MC, P, PC, L,
LC, K-family (CODA), and B/BC (BASIS) lines all encode meter vs.
controller as a distinct part-number field. If a future prefix turns
out to be kind-ambiguous at the prefix level, this dataclass will
grow a kind_probe field back at that time; omitting it now keeps
the public shape minimal.
Attributes:
| Name | Type | Description |
|---|---|---|
prefix |
str
|
The model-string prefix this rule claims (e.g. |
kind |
DeviceKind
|
The :class: |
media |
Medium
|
The :class: |
device_cls_map |
Mapping[DeviceKind, type[Device]]
|
Per-kind facade class. Lookup is
|
device_class_for ¶
Return the concrete :class:Device subclass for info.
Routing is prefix-based via :data:MODEL_RULES. Every current rule
resolves kind deterministically, so the facade class is
rule.device_cls_map[rule.kind].
Unknown prefixes and kinds not in the rule's map fall back to the
generic :class:Device — the session's :class:DeviceKind /
:class:Medium gates still fire, so the fallback is safe: commands
that don't list UNKNOWN in device_kinds simply refuse to
dispatch.
Source code in src/alicatlib/devices/factory.py
identify_device
async
¶
Run VE → (optional) ??M* → classify; return :class:DeviceInfo.
Capabilities are not populated here; call :func:probe_capabilities
separately and merge via :func:dataclasses.replace. That split
matches design §5.9 and keeps the two concerns testable in isolation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
client
|
AlicatProtocolClient
|
A wired :class: |
required |
unit_id
|
str
|
Polling unit id ( |
'A'
|
model_hint
|
str | None
|
Required when |
None
|
Source code in src/alicatlib/devices/factory.py
692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 | |
open_device
async
¶
open_device(
port,
*,
unit_id="A",
serial=None,
timeout=0.5,
recover_from_stream=True,
model_hint=None,
assume_capabilities=Capability.NONE,
assume_media=None,
)
Open a fully-identified :class:Device for async with use.
The caller's port determines the lifecycle the context manager
takes ownership of:
str("/dev/ttyUSB0"etc.) — build a :class:SerialTransportfromserial(or defaults), open it, wrap in an :class:AlicatProtocolClient, close both on exit.- :class:
Transport— wrap in a new :class:AlicatProtocolClient; the transport's open/close is the caller's responsibility (we never close a transport we didn't open). - :class:
AlicatProtocolClient— use as-is; neither transport nor client is closed on exit. Stream recovery is skipped because the factory doesn't have access to the underlying transport.
The assume_capabilities override is union'd onto the probed set
per design §5.9 — the factory never subtracts flags, because
silently masking hardware the device reports as present is exactly
the failure mode capability probing exists to avoid.
The assume_media override replaces the prefix-derived media
(design §5.9a). Medium answers "how is this specific unit
configured," not "what can the hardware do" — the common correction
is to narrow from a permissive prefix default to the single medium
the unit was actually ordered locked to. The K-family CODA prefixes
default to Medium.GAS | Medium.LIQUID because the part-number
decoder encodes kind but not medium; other future order-configurable
prefixes can adopt the same pattern. A replace policy also
future-proofs the model: any new ambiguous prefix drops into
:data:MODEL_RULES with the widest default, and users narrow at
open time.
Source code in src/alicatlib/devices/factory.py
987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 | |
probe_capabilities
async
¶
Probe each :class:Capability flag on the device.
Real probes implemented so far:
- :attr:
Capability.BAROMETER—FPF 15on any numeric-family device. "Present" iff the reply hasvalue > 0andunit_label != "---"(design §16.6.3). The device emitsA <zero> 1 ---when the statistic is not supported, which has to be disambiguated from a real reading. Note that a positiveBAROMETERprobe on a flow controller does NOT imply :attr:Capability.TAREABLE_ABSOLUTE_PRESSURE— the two dissociate in practice (design §16.6.7 / Capability docstring). - :attr:
Capability.SECONDARY_PRESSURE— identical rule applied toFPF 344(second absolute pressure). Trying344covers the common second-pressure-sensor configuration; future work can extend to352/360if devices surface those instead.
Stubs still fail-closed:
-
:attr:
Capability.TAREABLE_ABSOLUTE_PRESSURE— no safe probe; test-writingPCwould re-zero the abs sensor. Users with a pressure meter/controller that supportsPCopt in viaassume_capabilities=Capability.TAREABLE_ABSOLUTE_PRESSURE. -
:attr:
Capability.MULTI_VALVE/ :attr:THIRD_VALVE—VDreturns four columns unconditionally across meter and single-valve-controller devices alike (design §16.6.6), so the earlier column-count plan is invalidated. Left absent until a valve-count signal surfaces. - :attr:
Capability.TOTALIZER, analog I/O flags, display, remote tare, bidirectional — no hardware-validated probe strategy yet.
GP family skips every probe: we have no GP capture and the primer
doesn't document FPF there, so assuming absence is the safe
default. Callers can still union capabilities in via
assume_capabilities=... on :func:open_device.
Fails closed on every flag the probe doesn't positively confirm — design §5.9's "default-absent" policy: a probe that can't answer should never falsely claim the hardware is present.
Source code in src/alicatlib/devices/factory.py
471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 | |
Data-frame models¶
alicatlib.devices.data_frame ¶
Data-frame format, parsing, and timing-wrapped result.
The Alicat A\r poll response is core to the polling path, yet its shape
is device-dependent — Alicat advertises it via ??D* at session start.
This module models that format explicitly (so positional parsing survives
conditional *-marked fields), keeps the byte-level parse pure (no
clocks), and layers timing provenance on top via :class:DataFrame.
The split between :class:ParsedFrame (pure bytes → typed values) and
:class:DataFrame (ParsedFrame + received_at / monotonic_ns) is
load-bearing: parser unit tests stay clock-free (no freeze-time mocking),
and the :class:~alicatlib.devices.session.Session owns the single place
that captures timing. See design doc §5.6.
DataFrame
dataclass
¶
Timing-wrapped :class:ParsedFrame — the public polling result.
Built by :meth:from_parsed. monotonic_ns is for drift analysis
and scheduling (never wall-clock); received_at is for data
provenance in sinks.
as_dict ¶
Flatten to a JSON/CSV-friendly dict.
Produces {field_name: value, "status": "HLD,OPL", "received_at": iso8601}
— status codes collapse into a single comma-joined sorted string
(empty when no codes are active) so downstream schema is stable
across rows. Callers that need per-code boolean columns should
wrap this themselves; the library picks the schema-stable form.
Source code in src/alicatlib/devices/data_frame.py
from_parsed
classmethod
¶
Wrap a :class:ParsedFrame with timing captured at read time.
Source code in src/alicatlib/devices/data_frame.py
get_float ¶
Return the float value at name, or None if absent or non-numeric.
This is the "forgiving" accessor used when a downstream consumer
wants a numeric value and accepts absence. Text-valued fields and
the -- sentinel both yield None; exceptions are never
raised. Callers that need strict behaviour should index
:attr:values directly.
Source code in src/alicatlib/devices/data_frame.py
get_statistic ¶
Return the value keyed by :class:Statistic, or None if absent.
Prefer this over :meth:get_float when the caller has a typed
:class:Statistic — it's IDE-completable and robust to wire-name
renames across firmware versions.
Source code in src/alicatlib/devices/data_frame.py
DataFrameField
dataclass
¶
One column in the ??D*-advertised data-frame format.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
Canonical field name, e.g. |
raw_name |
str
|
The exact name as reported by the device, preserved so a fixture diff can surface unexpected firmware-side renames. |
type_name |
str
|
Wire type as declared in |
statistic |
Statistic | None
|
Linkage back to :class: |
unit |
Unit | None
|
Engineering :class: |
conditional |
bool
|
|
parser |
Callable[[str], float | str | None]
|
Bytes-less (already-decoded string) parser that turns the
raw token into the typed value. Supplied by the factory that
builds the format — typically |
DataFrameFormat
dataclass
¶
Advertised data-frame layout with a pure :meth:parse method.
The format is cached on the :class:~alicatlib.devices.session.Session
at startup (via ??D*) and exposed via
session.refresh_data_frame_format() for the rare runtime-mutation
cases (e.g. after FDF or DCU). The format is immutable — any
change produces a new :class:DataFrameFormat.
names ¶
parse ¶
Parse a single data-frame line into a :class:ParsedFrame.
Strategy (per design §5.6):
- Tokenise on whitespace; first token is the device's unit ID.
- Match required (non-conditional) fields left-to-right against the leading tokens — they always appear.
- Walk the surplus tokens. Any token matching a
:class:
~alicatlib.devices.models.StatusCodevalue collapses into :attr:ParsedFrame.status; remaining tokens are assigned to conditional fields in declared order. - Conditional fields that never receive a token are simply absent
from :attr:
ParsedFrame.values— they are notNone. This matters for downstream sinks: an absent column is distinct from a column whose value is the--sentinel (which does land asNonevia :func:parse_optional_float).
Raises:
| Type | Description |
|---|---|
AlicatParseError
|
Empty frame, non-ASCII bytes, or not enough tokens to cover the required fields. |
Source code in src/alicatlib/devices/data_frame.py
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 | |
DataFrameFormatFlavor ¶
Bases: Enum
Wire-format generation for the ??D* data-frame advertisement.
Alicat firmware has used at least two distinct ??D* layouts over
the years (design §16.6 / §16.6.2 / §16.6.4). The flavor lives on
:class:~alicatlib.commands.base.DecodeContext so the dispatching
parser knows which line-shape to expect.
Captured-device map (2026-04-17 hardware validation):
DEFAULT— canonical Alicat layout. Column header<uid> D00 ID_ NAME... TYPE... WIDTH NOTES.... Field rows carry an explicit stat-code column and conditional fields are marked with a leading*<name>. Devices observed: 6v21, 8v17, 8v30, 10v04, 10v20.LEGACY— older shape from before the dialect transition. Column header<uid> D00 NAME... TYPE... MinVal MaxVal UNITS.... No stat-code column, no*marker,signed/chartypes instead ofs decimal/string, units inline in a single column. Devices observed: 5v12. The transition happened between firmware5v12and6v21— so the legacy shape is not family-correlated (both devices are V1_V7-family); it correlates with firmware version somewhere around the V5→V6 cut-over. The flavor used to be calledV1_V7but that was misleading; renamed toLEGACY.
SIGNED and VARIABLE_V8 are reserved for future use if a
captured device shows a third / fourth distinct dialect; currently
unused.
ParsedFrame
dataclass
¶
Byte-level parse result. Pure function of (raw, format); no timing.
The :class:~alicatlib.devices.session.Session wraps this into a
:class:DataFrame via :meth:DataFrame.from_parsed, at which point
received_at and monotonic_ns are captured from the
terminator-read call site. Keeping the two separate makes parser
unit tests clock-free.
Typed identity / measurement models¶
alicatlib.devices.models ¶
Typed device-identity and measurement models.
These are the frozen dataclasses returned by the session layer for
identification results (:class:DeviceInfo), capability probe outcomes
(:data:ProbeOutcome), individual statistic readings
(:class:MeasurementSet), and cached full-scale ranges
(:class:FullScaleValue). Together with :mod:alicatlib.devices.data_frame
they are the full set of public models referenced by the rest of the
package, per design doc §5.5.
Data-frame models (:class:~alicatlib.devices.data_frame.DataFrame,
:class:~alicatlib.devices.data_frame.DataFrameFormat, ...) live in
:mod:alicatlib.devices.data_frame to keep the wire-parsing machinery
separate from the identity models that cache it.
ProbeOutcome ¶
Per-:class:Capability probe result.
Retained on :attr:DeviceInfo.probe_report for diagnostics and user-facing
override guidance. The gating check in the session is binary — the flag
is either set in :attr:DeviceInfo.capabilities or not — but the per-flag
outcome is useful when a user needs to understand why a capability was
marked absent. A timeout looks the same as "hardware missing" at the gating
layer, but they imply very different remediations.
AnalogOutputChannel ¶
Bases: IntEnum
Analog-output channel selector for ASOCV.
Devices ship with a primary analog output (4-20 mA or
0-5 V per part-number suffix) and optionally a secondary.
ASOCV 0 targets primary; ASOCV 1 targets secondary.
AnalogOutputSourceSetting
dataclass
¶
Result of an ASOCV (analog-output-source) query or set.
value is the statistic code the output tracks — or the sentinel
0 (minimum) / 1 (maximum), in which case the device emits
a fixed min / max analog level instead of following a measurement.
When value is 0 or 1, the primer notes that
unit_code=1 and unit_label="---".
AutoTareState
dataclass
¶
Result of an auto-tare (ZCA) query or set.
delay_s is the configured settling delay in seconds; primer
constrains this to [0.1, 25.5] and the command encoder
validates range pre-I/O (see design §10).
AverageTimingSetting
dataclass
¶
Result of a DCA (flow/pressure average) query or set.
Averaging window in milliseconds for a specific statistic code
(primer p. 18 table: 1 = all pressures, 2 = absolute pressure,
4 = volumetric flow, 5 = mass flow, 6 = gauge pressure,
7 = differential pressure, 17 = external volumetric flow,
344/352/360 = secondary-sensor variants). averaging_ms=0
reports every-millisecond readings.
BlinkDisplayState
dataclass
¶
Result of an FFP (blink display) query or set.
flashing is the echo of the primer's 0/1 binary
response — True while the backlight is flashing, False
otherwise. Gated at the command layer by
:attr:Capability.DISPLAY (probed at :func:open_device).
DeadbandSetting
dataclass
¶
Result of an LCDB (deadband limit) query or set.
Wire shape: <uid> <deadband> <unit_code> <unit_label>.
Controllers apply the deadband around the setpoint in the
controlled variable's engineering units — a value of 0.5 with
unit_label="PSIA" means "allow ±0.5 PSIA drift before
re-correcting." A value of 0 disables the deadband.
DeviceInfo
dataclass
¶
DeviceInfo(
unit_id,
manufacturer,
model,
serial,
manufactured,
calibrated,
calibrated_by,
software,
firmware,
firmware_date,
kind,
media,
capabilities,
probe_report=_empty_probe_report(),
full_scale=_empty_full_scale(),
)
Everything known about a device after identification.
Built by :func:alicatlib.devices.factory.identify_device. The
probe_report preserves per-capability outcomes even when the
capability is absent from :attr:capabilities, so users can tell a
"device lacks the hardware" situation from a "probe timed out"
situation (see design §5.9 and :data:ProbeOutcome).
For GP-family or pre-8v28 devices the ??M* manufacturing-info
table is unavailable; the factory synthesises a :class:DeviceInfo
from the VE reply plus a caller-supplied model_hint, in which
case the string-shaped fields may all be None except model.
DisplayLockResult
dataclass
¶
Result of an L / U (lock / unlock display) command.
Both commands respond with a data frame — L sets the
:attr:StatusCode.LCK bit, U clears it. :attr:locked
exposes that for convenience.
FullScaleValue
dataclass
¶
Cached full-scale range for one statistic.
Populated by the session's capability-probe step (FPF queries) and
then used by :meth:Device.setpoint and similar facades for pre-I/O
range validation (design §5.20.2). unit is None when the
device's unit doesn't map to a known :class:Unit — the raw
unit_label is always preserved for diagnostics.
statistic is filled by the facade after dispatch — the device's
FPF reply doesn't echo the requested statistic (verified against
a V10 capture on 2026-04-17), so the decoder leaves it as
:attr:Statistic.NONE and the facade calls
:func:dataclasses.replace to populate it from the request.
LoopControlState
dataclass
¶
Result of an LV (loop-control variable) query or set.
variable is the typed :class:LoopControlVariable the
controller's loop is tracking. label preserves the device's
raw descriptor string for diagnostics.
ManufacturingInfo
dataclass
¶
Parsed ??M* manufacturing-info table.
Minimal, honest surface: the raw per-M-code payload keyed by the
M<NN> index. The parser pins only what the wire format guarantees
(<unit_id> M<NN> <payload>); the semantic mapping from M-code
number to named field (M04 → model, M05 → serial, etc.) is a
separate concern handled by the factory, which can adjust per firmware
version without rewriting the parser.
Only emitted by :func:alicatlib.protocol.parser.parse_manufacturing_info
when the firmware family and version support ??M* (numeric family,
≥ 8v28 per design §5.9). GP and pre-8v28 devices synthesise
:class:DeviceInfo directly from the VE reply plus a caller-supplied
model_hint.
MeasurementSet
dataclass
¶
Result of a :class:~alicatlib.commands.polling.RequestData (DV) query.
Unlike a :class:~alicatlib.devices.data_frame.DataFrame, which returns
the cached full set of fields, a DV query targets a specific list
of statistics (1–13 per call) and reports each with an averaging
window. Values that come back as the -- sentinel are None.
PowerUpTareState
dataclass
¶
Result of a ZCP (power-up tare) query or set.
True means the device performs a 0.25 s tare after sensors
stabilise on power-up. On controllers, closed-loop control is
delayed and valves stay closed until the tare completes.
RampRateSetting
dataclass
¶
RampRateSetting(
unit_id,
max_ramp,
setpoint_unit_code,
setpoint_unit,
time_unit,
rate_unit_label,
)
Result of an SR (max ramp rate) query or set.
Wire shape: <uid> <max_ramp> <setpoint_unit_code> <time_value> <rate_unit_label>.
max_ramp == 0.0 means ramping is disabled; the controller
jumps to the new setpoint instantly on the next write.
Attributes:
| Name | Type | Description |
|---|---|---|
unit_id |
str
|
Echoed unit id. |
max_ramp |
float
|
Ramp step size, in the device's current engineering
units for the loop-control variable. |
setpoint_unit_code |
int
|
Raw numeric unit code from primer
Appendix B. Preserved for diagnostics; the typed
:attr: |
setpoint_unit |
Unit | None
|
Resolved :class: |
time_unit |
TimeUnit
|
:class: |
rate_unit_label |
str
|
Device-reported units-over-time label
(e.g. |
SetpointState
dataclass
¶
Result of a :class:~alicatlib.commands.setpoint.Setpoint query or set.
current and requested are reported separately by the device
(modern LS reply: <uid> <current> <requested> <unit_code>
<unit_label>). They diverge briefly on a set while the controller's
loop closes on the new target; they track to the same value in steady
state. unit / unit_label come straight from the same reply.
frame is optional: legacy S (set-only, pre-9v00) responds
with a post-op data frame rather than the 5-field LS reply, so the
facade can attach the parsed frame on the legacy path. On the modern
LS path frame is always None.
StatusCode ¶
Bases: StrEnum
Device status codes that may appear in the data-frame tail.
The Alicat primer defines these as 3-letter tokens trailing the numeric
fields when the condition is active. Multiple codes may be present
simultaneously (e.g. MOV + TMF); :class:alicatlib.devices.data_frame.DataFrame
carries them as a :class:frozenset so ordering on the wire doesn't
matter downstream.
StpNtpMode ¶
Bases: StrEnum
Reference mode for DCFRP / DCFRT.
Standard conditions (STP → "S") underpin standard volumetric
flow units (SLPM / SCFM); normal conditions (NTP → "N")
underpin normal volumetric flow units (LPM / CFM). The two are
separate reference points that the device lets users retune — the
enum mirrors the primer's single-letter wire encoding.
StpNtpPressureSetting
dataclass
¶
Result of a DCFRP (standard / normal pressure reference) query or set.
Default on Alicat devices is 14.696 PSIA. Changing this
affects the density calculation for every standard / normal
volumetric flow reading the device reports.
StpNtpTemperatureSetting
dataclass
¶
Result of a DCFRT (standard / normal temperature reference) query or set.
Default on Alicat devices is 25 °C. Same density-calculation
story as :class:StpNtpPressureSetting.
TareResult
dataclass
¶
Result of a tare command (flow / gauge pressure / absolute pressure).
The device responds with a post-tare data frame; that frame is the most useful artifact (it reports the new zero-referenced reading), so the result surface is intentionally minimal: just the frame.
TimeUnit ¶
Bases: IntEnum
Time-unit code used by the SR (max ramp rate) command.
Primer p. 15 encodes the unit-over-time base for ramping as a
single integer: 3..7 for ms / s / m / hour / day. The enum
mirrors that encoding so callers write TimeUnit.SECOND instead
of a magic literal.
TotalizerConfig
dataclass
¶
TotalizerConfig(
unit_id,
totalizer,
flow_statistic_code,
mode,
limit_mode,
digits,
decimal_place,
)
Result of a TC (configure totalizer) query or set.
Attributes mirror the primer's wire order
(flow_statistic_code mode limit_mode digits decimal_place) —
callers inspect :attr:enabled rather than reading
flow_statistic_code == TOTALIZER_DISABLED_CODE themselves.
enabled
property
¶
True when the totalizer is tracking a flow statistic.
Primer: flow_statistic_code == 1 signals "disabled" — any
other code means the totalizer is enabled on that statistic.
TotalizerId ¶
Bases: IntEnum
Which totalizer to address — primer supports two (1 / 2).
TotalizerLimitMode ¶
Bases: IntEnum
Totalizer overflow behaviour for TC (primer p. 23 table).
-1 is the set-only "keep current" sentinel (same convention
as :class:TotalizerMode).
KEEP
class-attribute
instance-attribute
¶
Set-only: leave the current limit mode unchanged.
ROLLOVER
class-attribute
instance-attribute
¶
Reset to zero and keep counting. No TOV status bit.
ROLLOVER_WITH_TOV
class-attribute
instance-attribute
¶
Reset to zero and keep counting; set the TOV status bit.
STOP_AT_MAX
class-attribute
instance-attribute
¶
Stop counting at the maximum value. No TOV status bit.
STOP_AT_MAX_WITH_TOV
class-attribute
instance-attribute
¶
Stop counting at the maximum value; set the TOV status bit.
TotalizerMode ¶
Bases: IntEnum
Totalizer-accumulation mode for TC (primer p. 23 table).
The -1 KEEP sentinel is a set-time "don't change" marker
the primer admits; it is not a real config state the device ever
echoes back.
BIDIRECTIONAL
class-attribute
instance-attribute
¶
Accumulate positive and subtract negative flow.
NEGATIVE_ONLY
class-attribute
instance-attribute
¶
Accumulate negative flow only; ignore positive flow.
POSITIVE_ONLY
class-attribute
instance-attribute
¶
Accumulate positive flow only; ignore negative flow.
RESET_ON_STOP
class-attribute
instance-attribute
¶
Accumulate positive flow, reset to zero when flow stops.
TotalizerResetResult
dataclass
¶
Wraps the post-op data frame from T <n> or TP <n>.
The frame is the useful artifact (it carries the fresh totalizer reading); the result object exists so a future addition (observed totalizer reading extracted from the frame, timing, …) has a stable home.
TotalizerSaveState
dataclass
¶
Result of a TCR (save totalizer) query or set.
enabled=True means the device periodically persists totalizer
values to EEPROM and restores them at power-on.
UnitSetting
dataclass
¶
Result of an engineering-units (DCU) query or set.
unit is None when the device reports a code that does not
map to a known :class:Unit — the raw label is always
preserved so diagnostics can see the device's exact string.
statistic scopes the setting: DCU applies per-statistic
(or per-group when apply_to_group=True is requested at the
facade).
UserDataSetting
dataclass
¶
Result of a UD (user data) read or write.
Four slots (0..3) each hold up to 32 ASCII characters.
Encoded binary data (hex / base64) goes through the value field
unchanged — the library does not interpret user data.
ValveDriveState
dataclass
¶
Result of a VD (valve-drive query) command.
valves carries 1–3 drive percentages in primer-declared order:
single-valve controllers report one value; dual-valve controllers
report (upstream, downstream); tri-valve (exhaust) controllers
add a third entry. The wire-side shape is not a reliable signal
of device capability — design §9 warns against inferring valve
count from the reply. Multi-valve-specific logic should gate on
:attr:Capability.MULTI_VALVE / :attr:Capability.THIRD_VALVE,
not on len(valves).
ValveHoldResult
dataclass
¶
Result of a valve-hold command (HP / HC / C).
All three commands respond with a post-op data frame; the
discriminator is whether :attr:DataFrame.status carries
:attr:StatusCode.HLD. :attr:held captures that for convenience
— True after HP or HC, False after C.
Per design §9 Tier-2 controller scope.
ZeroBandSetting
dataclass
¶
Result of a DCZ (zero band) query or set.
Zero band is the minimum-reporting threshold expressed as a
percentage of full scale: values below it are reported as zero.
Primer constrains the range to 0..6.38 (percent); 0
disables the zero band. Device responds with
<uid> 0 <zero_band> — the literal 0 is the primer's
placeholder for a statistic argument that DCZ does not use.
Discovery¶
alicatlib.devices.discovery ¶
Device discovery — enumerate serial ports and identify Alicat devices.
Three entry points, each wider than the last:
- :func:
list_serial_ports— thin wrapper over :func:anyserial.list_serial_portsreturning device paths. - :func:
probe— open one port at one baudrate, run the full identification pipeline, return a :class:DiscoveryResult. - :func:
find_devices— run :func:probeover the cross-product ofports × unit_ids × baudrates, bounded by :class:anyio.CapacityLimiter, returning every result (ok or errored).
Real fleets are mixed — baud rates vary, units aren't always at A,
and a GP box sits next to a 10v05 one. :func:find_devices does not
raise on individual probe failure; every combination produces a
:class:DiscoveryResult and the caller decides what to do with the
errors. The library never prints — formatting a human-readable report
belongs to example scripts / CLIs, not the core (design §5.12).
Design reference: docs/design.md §5.12.
DiscoveryResult
dataclass
¶
Outcome of a single :func:probe attempt.
Exactly one of :attr:info / :attr:error is populated — ok results
carry a fully-identified :class:DeviceInfo, failed ones carry the
typed :class:AlicatError from the identification pipeline. The
:attr:ok convenience lets callers filter without hasattr.
find_devices
async
¶
find_devices(
ports=None,
*,
unit_ids=("A",),
baudrates=DEFAULT_DISCOVERY_BAUDRATES,
timeout=_DEFAULT_PROBE_TIMEOUT_S,
max_concurrency=_DEFAULT_MAX_CONCURRENCY,
stop_on_first_hit=False,
)
Probe the cross-product ports × unit_ids × baudrates concurrently.
When ports is None the sweep enumerates every port visible
via :func:list_serial_ports — convenient for "what's plugged in?"
but note that a large fleet plus multiple baudrates multiplies out
quickly (10 ports × 2 baud × 5 unit ids = 100 probes).
Concurrency is bounded two ways:
max_concurrencyvia :class:anyio.CapacityLimiter— at most that many serial handles are ever open simultaneously.- A per-port :class:
anyio.Lock— combinations targeting the same physical port serialise, because a serial port can only be held by one transport at a time. Without this, a sweep that tries two baud rates on one port would see the second probe fail withPortBusyError(or an unrelated transport error) even when the device is present at the correct baud — the two probes simply raced for the same handle.
Lock order is port-first, limiter-second: a probe waiting on its port lock does not consume a limiter slot, which keeps the overall concurrency ceiling meaningful.
When stop_on_first_hit is True, a successful probe at
(port, _, baud) records baud as that port's confirmed rate
and any pending same-port probe at a different baud is skipped.
Same-baud probes at other unit ids still run (important for RS-485
multi-drop buses where several devices share a port at a single
baud). Skipped combinations are simply omitted from the result
tuple, so the caller can expect len(result) ≤ len(combinations).
Default is False — every combination produces a result, in a
stable row-major order (ports × unit_ids × baudrates).
The function never raises — every probe's result lands in the
returned tuple, ok or not.
Source code in src/alicatlib/devices/discovery.py
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 273 | |
list_serial_ports
async
¶
Enumerate serial-port device paths visible to the OS.
Thin wrapper over :func:anyserial.list_serial_ports. Returns
device-path strings (/dev/ttyUSB0, COM3 …) in whatever order
the backend reports.
The native backend does not require the anyserial[discovery-pyserial]
extra; platforms where it misses devices can install that extra and
switch by setting the backend="pyserial" kwarg on
:func:anyserial.list_serial_ports directly.
Source code in src/alicatlib/devices/discovery.py
probe
async
¶
Probe one port at one baudrate for one unit id.
Never raises — every failure becomes :attr:DiscoveryResult.error
so that a bulk :func:find_devices call collects a uniform result
set. Opening errors (permission denied, port busy, no such device)
are caught here the same as identification errors; the caller sees
one shape whether the device is offline, misconfigured, or silent.
Source code in src/alicatlib/devices/discovery.py
Session¶
alicatlib.devices.session ¶
Session — the one object that dispatches commands.
A :class:Session owns a validated unit_id, the device's
:class:DeviceInfo, and (optionally) its cached :class:DataFrameFormat.
It holds no I/O lock of its own — the shared
:class:~alicatlib.protocol.client.AlicatProtocolClient serialises
traffic at the port level, so every session pointed at the same client
naturally serialises on the same lock (correct for multi-unit RS-485
buses per design §5.7).
:meth:Session.execute is the single pre-I/O gating path:
- Firmware family membership (
cmd.firmware_families). - Firmware min/max within the matching family.
- Device kind (
cmd.device_kinds). - Medium compatibility (
cmd.media∩info.media). - Required hardware capabilities (
cmd.required_capabilities). - Destructive-confirm (
cmd.destructive+request.confirm).
All six fail loudly (typed exceptions, ErrorContext populated) and
fail before any I/O — the library's "silence is unsafe" stance
(design §5.17).
Lifecycle-changing operations (change_unit_id / change_baud_rate
— design §5.7) use bounded cancellation shields to keep the device
and the client in sync across the write → verify → reconfigure
boundary. An unbounded shield would hang the process if the device
wedged; the bounded shield escalates to :attr:SessionState.BROKEN
instead, which is recoverable.
Design reference: docs/design.md §5.7, §5.10, §5.17, §5.20.
Session ¶
Single-device dispatch path.
Constructor validates unit_id eagerly — an invalid id is
:class:InvalidUnitIdError at construction, not at first use.
The session does not own the :class:AlicatProtocolClient; the
factory does. close() is a no-op placeholder; the
factory's context-manager unwind is what drops the transport.
Source code in src/alicatlib/devices/session.py
config
property
¶
The :class:AlicatConfig this session was constructed with.
Used by facades that need to read runtime knobs (e.g. the EEPROM-wear threshold) without plumbing a separate config through every call site.
data_frame_format
property
¶
Cached :class:DataFrameFormat, or None before it's been probed.
loop_control_variable
property
¶
Cached loop-control variable, or None if unprobed / unsupported.
:func:~alicatlib.devices.factory.open_device pre-populates this
for controllers whose firmware supports LV; the
:meth:FlowController.loop_control_variable facade refreshes it
on every query / set. Consumed by
:meth:FlowController.setpoint to pick the right
:class:~alicatlib.devices.models.FullScaleValue from
:attr:DeviceInfo.full_scale for pre-I/O range validation.
port_label
property
¶
Human-readable port identifier, surfaced on every :class:ErrorContext.
setpoint_source
property
¶
Cached setpoint source ("S" / "A" / "U"), or None if unprobed.
Populated by :meth:update_setpoint_source after an LSS
query or set. The :meth:FlowController.setpoint facade reads
this pre-I/O to detect the LSS=A failure mode — a serial
setpoint write is silently ignored when the source is analog
(design §5.20 risk table), so rather than let the write
disappear the facade raises :class:AlicatValidationError.
state
property
¶
Current lifecycle state.
Transitions to :attr:SessionState.BROKEN only when
:meth:change_baud_rate cannot reconcile the transport with
the device's new baud. A BROKEN session refuses every
subsequent :meth:execute with :class:AlicatConnectionError
so callers recognise the situation instead of hitting silent
timeouts.
change_baud_rate
async
¶
Change the device's baud rate and retune the transport.
Sends NCB <new_baud> at the current baud, reads the ack
(still at the old baud), tells the transport to
:meth:~alicatlib.transport.base.Transport.reopen at the new
baud, then verifies with a VE round-trip. All four steps
after the write happen inside a bounded
:func:anyio.move_on_after(_CHANGE_BAUD_RATE_SHIELD_S, shield=True).
confirm=True is required — a failed baud change splits
the adapter from the device until someone reopens the port.
new_baud must be in :data:SUPPORTED_BAUDRATES.
On any failure inside the shielded block (or the shield
timing out) the session transitions to
:attr:SessionState.BROKEN and raises
:class:AlicatConnectionError with remediation guidance.
Subsequent :meth:execute calls then fail fast instead of
hanging.
Source code in src/alicatlib/devices/session.py
799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 | |
change_unit_id
async
¶
Rename the device this session talks to.
Sends the primer's bus-level rename {old}@ {new}\r (no
$$ prefix on GP — this is a wire-level mode switch, not a
normal command). The device does not ack on the wire; the
session waits :data:_RENAME_GRACE_S and then verifies the
rename with a VE at the new unit id.
Argument rules (design §5.7, §5.20 pt 1):
confirm=Trueis required: a rename collision (two devices ending up on the same unit id) silently splits the bus, so the caller must opt in explicitly.new_unit_idmust beA..Z(the polling alphabet).new_unit_idmust differ from the current :attr:unit_id.
Cancellation semantics: the rename write happens outside the
shield (cancellation there leaves the device untouched). The
post-write verify runs inside a
:func:anyio.move_on_after(timeout, shield=True) of
:data:_CHANGE_UNIT_ID_SHIELD_S. If the shield fires the
device may or may not have accepted the rename — the session
raises :class:AlicatTimeoutError and the cached unit id is
not updated, leaving recovery to the caller.
Source code in src/alicatlib/devices/session.py
702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 | |
close
async
¶
execute
async
¶
Dispatch command with pre-I/O gating and error enrichment.
Gating order (cheapest first — every check is pre-I/O): firmware family → firmware min/max → device kind → capability → destructive-confirm. The first failed check raises; no later check runs.
Any :class:AlicatError raised from the encode / I/O / decode
path is re-raised with :attr:ErrorContext.command_name /
unit_id / port / firmware / elapsed_s populated
from this session — the pattern described in design §5.7.
Source code in src/alicatlib/devices/session.py
invalidate_data_frame_format ¶
Drop the cached :class:DataFrameFormat without re-probing.
After a command that changes the device's data-frame shape
(DCU engineering-units set, FDF field reorder, …) the
cached format is stale. Clearing it lets the next :meth:poll
lazily re-probe via :meth:refresh_data_frame_format — one
round-trip amortised over the next poll rather than immediately.
Sync + non-awaitable by design so facade set-paths don't pay a
second ??D* at every call.
Source code in src/alicatlib/devices/session.py
poll
async
¶
Convenience poll — execute POLL_DATA and wrap with read-site timing.
This is the one place the session owns timing capture; per design
§5.6 the :class:DataFrame is the session's job, not the
command's. Callers that want the pure (clock-free)
:class:ParsedFrame go through
session.execute(POLL_DATA, PollRequest()) instead.
Source code in src/alicatlib/devices/session.py
refresh_capabilities
async
¶
Re-probe the device's capability flags.
Implementation lives in the factory (:mod:alicatlib.devices.factory),
which owns the per-capability probe map
(FPF/VD/??D*-derived flags). This method is reserved
on the :class:Session surface for API stability; calling it now
raises :class:NotImplementedError pointing at the right place.
Source code in src/alicatlib/devices/session.py
refresh_data_frame_format
async
¶
Re-probe ??D* and update the cached :class:DataFrameFormat.
Source code in src/alicatlib/devices/session.py
refresh_firmware
async
¶
Re-probe VE and update the cached :class:FirmwareVersion.
Uses :data:alicatlib.commands.system.VE_QUERY; the session's
cached :class:DeviceInfo is updated in place via
:func:dataclasses.replace (the dataclass itself is frozen).
Source code in src/alicatlib/devices/session.py
update_loop_control_variable ¶
Record variable as the session's current loop-control variable.
update_setpoint_source ¶
Record source as the session's current setpoint source.
Called by the LSS command facade (:meth:FlowController.setpoint_source)
on every query / set so the cache tracks the device's state. Stays
a plain setter rather than a @setpoint_source.setter to keep
the mutation verb visible at call sites (session.update_setpoint_source("S")
reads differently from an assignment).
Source code in src/alicatlib/devices/session.py
SessionState ¶
Bases: Enum
Lifecycle state of a :class:Session.
OPERATIONAL is the normal state — commands dispatch freely.
BROKEN is entered when an atomic lifecycle operation
(change_baud_rate) cannot reconcile the transport with the
device's new state. A BROKEN session rejects every subsequent
:meth:Session.execute with :class:AlicatConnectionError and
the caller must construct a fresh session (typically by
re-running :func:open_device) to recover.
validate_unit_id ¶
Return unit_id if valid, otherwise raise :class:InvalidUnitIdError.
A plain polling id ("A".."Z") is always valid. The streaming
id "@" is accepted only when allow_streaming is True — callers
that are building a normal :class:Session should not pass this
flag, because a polling session on @ can never talk to a device.
Source code in src/alicatlib/devices/session.py
Streaming runtime¶
alicatlib.devices.streaming ¶
Streaming-mode runtime — :class:StreamingSession.
Streaming mode is a port-level state transition, not a
request/response command (design §5.8). The device stops responding to
prompts, overwrites its unit id with @, and pushes data frames
continuously until stopped. This module owns that runtime:
- Setup — optionally configures
NCS(streaming rate), marks the shared :class:~alicatlib.protocol.client.AlicatProtocolClientas streaming, writes the primer's{unit_id}@ @\rstart-stream bytes directly under the port lock (bypassing :meth:Session.executebecause we own the mode transition, not the command layer). - Producer — a background task reads frames from the transport into a
bounded :mod:
anyio.streams.memoryobject stream, parsing each line with the session's cached :class:~alicatlib.devices.data_frame.DataFrameFormat. Overflow is controlled by :class:OverflowPolicy(design §5.14 — reused from the sample recorder so the knob is one concept across acquisition surfaces). Parse errors are logged and skipped unlessstrict=True. - Teardown — always writes the primer's
@@ {unit_id}\rstop-stream bytes, drains any trailing frames, and clears the streaming latch.__aexit__does this even when the body raised, so a crashed consumer never leaves the device flooding the bus.
Shape:
.. code-block:: python
async with dev.stream(rate_ms=50) as stream:
async for frame in stream:
process(frame)
Design reference: docs/design.md §5.8.
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. The effective sample rate
drops to the consumer's drain rate; samples_late accrues once
the consumer catches up and the producer can check its schedule.
DROP_NEWEST
class-attribute
instance-attribute
¶
Drop the sample that was about to be enqueued. Counted as late.
DROP_OLDEST
class-attribute
instance-attribute
¶
Evict the oldest queued batch, then enqueue. Counted as late.
StreamingSession ¶
StreamingSession(
session,
*,
rate_ms=None,
strict=False,
overflow=OverflowPolicy.DROP_OLDEST,
buffer_size=_DEFAULT_BUFFER_SIZE,
)
Async context manager + async iterator for streaming data frames.
Users construct this via :meth:Device.stream, not directly. The
public contract is the dunder surface — __aenter__ /
__aexit__ for scope and __aiter__ / __anext__ for the
data. Once the context exits, the instance is not reusable; the
next stream requires a new call to :meth:Device.stream.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session
|
Session
|
The owning :class: |
required |
rate_ms
|
int | None
|
If not |
None
|
strict
|
bool
|
If |
False
|
overflow
|
OverflowPolicy
|
Back-pressure policy when the bounded producer buffer
is full. Defaults to :attr: |
DROP_OLDEST
|
buffer_size
|
int
|
Producer/consumer buffer depth. |
_DEFAULT_BUFFER_SIZE
|
Attributes:
| Name | Type | Description |
|---|---|---|
dropped_frames |
int
|
Count of frames the producer had to discard
because the consumer was behind and |
Source code in src/alicatlib/devices/streaming.py
__aenter__
async
¶
Enter streaming mode.
Sequence:
- Lazy-probe
??D*if the session has no cached :class:DataFrameFormat— streaming has to parse every frame, so a missing format is a hard error the moment the producer starts. - Optionally configure
NCSrate. Done before flipping the streaming latch so the command still runs as a normal request/response. - Acquire the port lock, verify the client isn't already streaming, flip the latch, write the start-stream bytes, release the lock. Holding the lock across the latch + write is what makes the mode transition atomic w.r.t. other sessions on the same client.
- Start the producer task inside a task group. The group
lives for the duration of the context and is cancelled by
:meth:
__aexit__.
Source code in src/alicatlib/devices/streaming.py
__aexit__
async
¶
Exit streaming mode — always sends stop-stream.
Order is load-bearing:
- Cancel the producer task group and close the send side of
the buffer so any pending
__anext__receivesStopAsyncIteration. - Send stop-stream bytes and drain. If the body raised this still has to happen — the device would otherwise keep pushing frames onto a bus no one is reading.
- Clear the streaming latch so other sessions on this client resume dispatching.
Source code in src/alicatlib/devices/streaming.py
__aiter__ ¶
__anext__
async
¶
Return the next buffered :class:DataFrame.
Raises :class:StopAsyncIteration when the producer has closed
the send side (either on context exit, or under
strict=True after a parse error tore the task group down).
A strict-mode parse error is re-raised here so the caller's
async for loop surfaces the real exception, not a silent
stop.
Source code in src/alicatlib/devices/streaming.py
Base facade¶
alicatlib.devices.base ¶
Device facade base.
:class:Device is the public, user-facing object returned by
:func:alicatlib.devices.factory.open_device. It is a thin veneer over
:class:alicatlib.devices.session.Session — every method delegates to
the session's :meth:~Session.execute (or :meth:~Session.poll for the
timing-wrapped poll), so all pre-I/O gating lives in one place.
:class:DeviceKind lives in its sibling :mod:alicatlib.devices.kind
module. That split is what lets :class:Device import command specs
(GAS_SELECT, ENGINEERING_UNITS, …) for its method bodies
without creating a cycle with :mod:alicatlib.commands, which needs
:class:DeviceKind at command-spec definition time. See design §15.1.
Subclasses in :mod:.flow_meter and :mod:.flow_controller add
family-specific methods (setpoint, valve drive, exhaust, ...) without
changing the dispatch model — they just expose additional commands from
the catalog.
Design reference: docs/design.md §5.9.
Device ¶
User-facing façade over a :class:Session.
Constructed by :func:alicatlib.devices.factory.open_device. Users do
not instantiate this class directly (the factory picks the correct
subclass based on the :class:DeviceInfo.model prefix via the
MODEL_RULES dispatch table — see design §5.9).
The device does not own the transport's lifecycle; the context manager
returned by open_device does. Entering the device as a context
manager is a no-op for nesting convenience.
Source code in src/alicatlib/devices/base.py
session
property
¶
Underlying :class:Session.
Exposed for advanced users who need :meth:Session.execute
directly or want to inspect the session's gating state.
__aenter__
async
¶
__aexit__
async
¶
Close the device on exit — aligns with the factory context manager.
Source code in src/alicatlib/devices/base.py
analog_output_source
async
¶
Query or set the analog-output source (ASOCV, V10 10v05+).
Gated on :attr:Capability.ANALOG_OUTPUT. channel selects
primary vs. secondary. value=None queries; value=0 /
value=1 set fixed min / max output; value>=2 pins the
output to a statistic.
Source code in src/alicatlib/devices/base.py
average_timing
async
¶
Query or set the per-statistic averaging window (DCA, V10 10v05+).
averaging_ms=None issues the query form; a value in
0..9999 sets the window (0 → update every millisecond).
statistic_code is the primer's numeric code (see
:data:DCA_ALLOWED_STATISTIC_CODES) — arbitrary
:class:Statistic codes are rejected pre-I/O because the
device only averages pressure / flow primary + secondary
readings.
Source code in src/alicatlib/devices/base.py
blink_display
async
¶
Query or trigger a display blink (FFP, 8v28+).
Gated on :attr:Capability.DISPLAY. None queries the
current flash state; a positive value flashes for that many
seconds; 0 stops an active flash; -1 flashes
indefinitely.
Source code in src/alicatlib/devices/base.py
close
async
¶
Release the session — idempotent.
The underlying transport is owned by the async context manager
returned from :func:open_device; closing the device only marks
the session as closed. Users should prefer
async with open_device(...) as dev: over calling close()
by hand.
Source code in src/alicatlib/devices/base.py
engineering_units
async
¶
Query or set the engineering unit for statistic (DCU).
unit=None issues the query form. Passing a :class:Unit,
its alias, or an explicit integer wire code sets the unit.
apply_to_group=True broadcasts the change to every
statistic in the target's group; override_special_rules=True
bypasses device-side restrictions on unusual statistic/unit
pairings.
A successful SET invalidates the session's cached
:class:DataFrameFormat: units affect display in the data
frame, so the next :meth:poll re-probes ??D* lazily via
:meth:Session.invalidate_data_frame_format. Query form is a
no-op for the cache.
Raises:
| Type | Description |
|---|---|
AlicatValidationError
|
Ambiguous :class: |
Source code in src/alicatlib/devices/base.py
execute
async
¶
Dispatch a catalog command directly.
Exposed for advanced users who want to reach commands that don't yet have a facade method, or to wrap session.execute with middleware. Same gating, same error context, same result types.
Source code in src/alicatlib/devices/base.py
full_scale
async
¶
Query the full-scale value for statistic (FPF).
Used by setpoint range validation (design §5.20.2) and as a
capability-probe signal (FPF on stat 15 → barometer
present). The session's factory-level probe populates
:attr:DeviceInfo.full_scale for common statistics at startup;
this method exposes the same command for ad-hoc queries.
Source code in src/alicatlib/devices/base.py
gas
async
¶
Query or set the active gas.
gas=None issues the query form and returns the current
selection without changing it. Passing a
:class:~alicatlib.registry.Gas (or any registered alias)
sets the active gas. save=True persists to EEPROM — beware
of the rate-warning guard (design §5.20.7) if you call this in
a loop.
Dispatch is firmware-aware:
- V10 ≥ 10v05 → :data:
GAS_SELECT(GS). Supports query, set, and save. - All other supported firmware (GP, V1_V7, V8_V9, V10 < 10v05)
→ :data:
GAS_SELECT_LEGACY(G). Set only; nosaveflag; the device replies with a post-op data frame rather than the modern 4-field form. The facade fabricates a :class:GasStatefrom the request and the frame's echoed unit id;label/long_nameare resolved from the gas registry.
The command's device_kinds gate rejects calls on device
kinds that don't have a selectable active gas (pressure-only
devices) pre-I/O with :class:AlicatUnsupportedCommandError.
Raises:
| Type | Description |
|---|---|
AlicatUnsupportedCommandError
|
|
AlicatValidationError
|
|
Source code in src/alicatlib/devices/base.py
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 | |
gas_list
async
¶
Enumerate gases available on the device (??G*).
Returns a mapping from Alicat gas code (primer Appendix C) to
the raw label the device reports. Codes in the 236..255
range correspond to custom-mixture slots; an empty / absent
slot is simply not included in the mapping.
Callers that want typed :class:Gas members should feed each
code through :func:alicatlib.registry.gas_registry.by_code;
unknown codes are preserved as labels so diagnostics still see
the device's exact string.
Source code in src/alicatlib/devices/base.py
lock_display
async
¶
Lock the front-panel display (L); reply is a post-op data frame.
Gated on :attr:Capability.DISPLAY. The result's
:attr:DisplayLockResult.locked is True after a successful
lock.
Source code in src/alicatlib/devices/base.py
poll
async
¶
Read one data frame.
Lazy-probes ??D* the first time it's called if the session
didn't have a cached :class:DataFrameFormat yet. Returns a
:class:DataFrame with read-site received_at and
monotonic_ns captured by the session.
Source code in src/alicatlib/devices/base.py
power_up_tare
async
¶
Query or set the power-up tare (ZCP, V10 10v05+).
None queries; True / False sets. On a controller
that enables this, closed-loop control is delayed and valves
stay closed until the ~0.25 s tare completes at power-on.
Source code in src/alicatlib/devices/base.py
request
async
¶
Request a specific list of statistics with an averaging window.
DV on the wire. Unlike :meth:poll, which returns the
device's cached data-frame fields, this targets 1–13 caller-chosen
:class:~alicatlib.registry.Statistic members and reports each
averaged over averaging_ms milliseconds.
Per-slot -- sentinels (invalid statistic code for this
device) map to None in the returned
:attr:MeasurementSet.values.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
statistics
|
Sequence[Statistic | str]
|
1–13 :class: |
required |
averaging_ms
|
int
|
Rolling averaging window in milliseconds,
1–9999. |
1
|
Returns:
| Name | Type | Description |
|---|---|---|
MeasurementSet
|
class: |
|
the |
MeasurementSet
|
class: |
MeasurementSet
|
caller repeats a statistic, the last occurrence wins in the |
|
MeasurementSet
|
mapping; the wire still carries every request (the devicce |
|
MeasurementSet
|
still averages over all slots). |
Source code in src/alicatlib/devices/base.py
stp_ntp_pressure
async
¶
Query or set the standard / normal pressure reference (DCFRP).
Mass-flow devices only (V10 10v05+). mode selects STP vs
NTP reference. pressure=None issues the query form;
unit_code=None or 0 on set leaves the engineering
unit unchanged. The device doesn't echo mode so the
facade fills it from the request.
Source code in src/alicatlib/devices/base.py
stp_ntp_temperature
async
¶
Query or set the standard / normal temperature reference (DCFRT).
Mirror of :meth:stp_ntp_pressure for temperature.
Source code in src/alicatlib/devices/base.py
stream ¶
Open a streaming-mode context for this device.
Returns a :class:StreamingSession — an async context manager
and an async iterator::
async with dev.stream(rate_ms=50) as stream:
async for frame in stream:
process(frame)
Streaming is a port-level state transition (design §5.8); while
the context is active, every other :meth:execute / :meth:poll
/ etc. on sessions sharing this client's port fails fast with
:class:~alicatlib.errors.AlicatStreamingModeError. One
streamer per port.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rate_ms
|
int | None
|
If not |
None
|
strict
|
bool
|
If |
False
|
overflow
|
OverflowPolicy | None
|
Back-pressure policy when the producer's buffer
fills. Defaults to
:attr: |
None
|
buffer_size
|
int
|
Producer/consumer buffer depth. Default 256 frames; at the default 50 ms rate that's ~13 s of backlog. |
256
|
Source code in src/alicatlib/devices/base.py
tare_absolute_pressure
async
¶
Calibrate absolute pressure against the onboard barometer (PC).
Gated on :attr:Capability.TAREABLE_ABSOLUTE_PRESSURE — NOT
on :attr:Capability.BAROMETER. The two dissociate in practice
(design §16.6.7): flow controllers expose a firmware-computed
barometer reading but lack a tareable process-port abs sensor.
Users with a pressure meter/controller that supports PC
opt in via assume_capabilities on
:func:~alicatlib.devices.factory.open_device; devices without
the capability raise :class:AlicatMissingHardwareError
pre-I/O. Same INFO-log + data-frame-wrap semantics as
:meth:tare_flow.
Source code in src/alicatlib/devices/base.py
tare_flow
async
¶
Zero the flow reading (T).
Caller's precondition: no gas flowing through the device. The
library cannot verify this — an INFO log records the
expectation on every call so the precondition is auditable
after the fact (design §5.18 pt 6). The device replies with a
post-op data frame; the returned :class:TareResult wraps it
as a :class:DataFrame with read-site timing.
Source code in src/alicatlib/devices/base.py
tare_gauge_pressure
async
¶
Zero the gauge-pressure reading (TP).
Caller's precondition: line depressurised to atmosphere.
Same INFO-log + data-frame-wrap semantics as :meth:tare_flow.
Source code in src/alicatlib/devices/base.py
totalizer_config
async
¶
totalizer_config(
totalizer=TotalizerId.FIRST,
*,
flow_statistic_code=None,
mode=None,
limit_mode=None,
digits=None,
decimal_place=None,
)
Query or set a totalizer's configuration (TC, V10 10v00+).
flow_statistic_code=None issues the query form. 1
disables the totalizer (other fields stay None). Any
other value enables / reconfigures — mode /
limit_mode / digits / decimal_place are required
together in that case. Use :attr:TotalizerMode.KEEP /
:attr:TotalizerLimitMode.KEEP (-1) to preserve the
current value of one field while changing others.
Returns:
| Type | Description |
|---|---|
TotalizerConfig
|
class: |
TotalizerConfig
|
attr: |
TotalizerConfig
|
the wire reply does not echo the id. |
Source code in src/alicatlib/devices/base.py
totalizer_reset
async
¶
Reset a totalizer's count (T <n>, 8v00+) — destructive.
Token-collision note: the command spec always emits the
numeric totalizer argument on the wire, so it can never
accidentally produce the flow-tare form (bare T\r). The
destructive-confirm gate on the session requires the caller
to pass confirm=True explicitly.
Source code in src/alicatlib/devices/base.py
totalizer_reset_peak
async
¶
Reset a totalizer's peak reading (TP <n>, 8v00+) — destructive.
Same token-collision protection as :meth:totalizer_reset —
the spec always emits the numeric argument so TP\r
(gauge-pressure tare) is unreachable from this path.
Source code in src/alicatlib/devices/base.py
totalizer_save
async
¶
Query or set persist-totalizer-on-power-cycle (TCR, V10 10v05+).
enable=None queries; True / False sets. save=True
persists the TCR config itself to EEPROM and feeds through
the session's EEPROM-wear monitor (design §5.20.7).
Source code in src/alicatlib/devices/base.py
unlock_display
async
¶
Unlock the front-panel display (U); reply is a post-op data frame.
Intentionally not gated on :attr:Capability.DISPLAY: this is
the safety escape for a device that got into a locked state.
Always callable. Hardware validation (2026-04-17) verified AU
works on V1_V7 (7v09), V8_V9, and V10. On a device without a
display, the command is a harmless no-op; on a locked device
it clears the LCK status bit.
Source code in src/alicatlib/devices/base.py
user_data
async
¶
Read or write a user-data slot (UD, 8v24+).
Four slots (0..3), 32 ASCII characters each. value=None
reads the slot; a string writes it. Values are validated
pre-I/O: ASCII-only, ≤ 32 characters, no \r / \n
(those would truncate the wire write).
Source code in src/alicatlib/devices/base.py
zero_band
async
¶
Query or set the zero band (DCZ, V10 10v05+).
Zero band is a percent-of-full-scale threshold: readings below
it are reported as zero. zero_band=None issues the query
form; a value in 0..6.38 sets it (0 disables).