alicatlib.protocol¶
Single-in-flight protocol client, wire parsers, and framing helpers. See Design §5.2.
Public surface¶
alicatlib.protocol ¶
Protocol layer — frames commands and parses responses.
See docs/design.md §5.2, §5.11.
AlicatProtocolClient ¶
AlicatProtocolClient(
transport,
*,
eol=EOL,
default_timeout=0.5,
multiline_timeout=1.0,
multiline_idle_timeout=0.1,
write_timeout=0.5,
drain_before_write=False,
)
Request/response client that serialises commands over a transport.
Each method acquires an internal :class:anyio.Lock for its duration, so
callers from different tasks may invoke methods concurrently — the lock
queues them.
Source code in src/alicatlib/protocol/client.py
idle_timeout_exits
property
¶
Number of :meth:query_lines calls that exited via idle-timeout.
Rises when commands don't declare is_complete / max_lines —
treat a growing counter for a specific command as a bug report against
that command's spec (see design §5.2, §5.4).
is_streaming
property
¶
True while a :class:StreamingSession owns this client.
Set by :class:~alicatlib.devices.streaming.StreamingSession on
entry and cleared on exit. The
:class:~alicatlib.devices.session.Session dispatch path
consults this and fails fast with
:class:~alicatlib.errors.AlicatStreamingModeError rather than
writing a command onto a bus the device is already flooding
with unsolicited frames (design §5.8).
lock
property
¶
Port-level command lock, shared across every :class:Session.
Normal command dispatch goes through :meth:query_line /
:meth:query_lines / :meth:write_only, which acquire this
lock internally. Lifecycle-changing operations on
:class:Session (change_unit_id, change_baud_rate) need
to hold the lock for a multi-step sequence — they borrow it
directly so the device and client stay in sync across the
write → verify → reconfigure boundary. See design §5.7.
transport
property
¶
Underlying :class:Transport.
Exposed for lifecycle operations that need direct byte-level
access under the shared lock (change_baud_rate needs
:meth:Transport.reopen). Normal command dispatch should
stay on the public query_* / write_only API.
guard_response ¶
Public alias for :meth:_guard_response.
Session lifecycle paths that bypass :meth:query_line still
need the ?-rejection / empty-response guards; exposing the
check lets them get the same error shape without duplicating
the regex.
Source code in src/alicatlib/protocol/client.py
query_line
async
¶
Send a single-line command and return the single-line response.
The returned bytes have the EOL already stripped. A bare ? /
unit-id-prefixed ? surfaces as :class:AlicatCommandRejectedError;
an empty response surfaces as :class:AlicatProtocolError.
Source code in src/alicatlib/protocol/client.py
query_lines
async
¶
query_lines(
command,
*,
first_timeout=None,
idle_timeout=None,
max_lines=None,
is_complete=None,
write_timeout=None,
)
Send a multiline command and collect lines until termination.
Termination priority (design §5.2):
is_complete(lines)returnsTrue— caller-supplied predicate for tables with a computable end condition.len(lines) >= max_lines— hard cap, useful for fixed-shape tables like??M*(10 lines).idle_timeoutexpires — fallback for unknown-length responses. Increments :attr:idle_timeout_exits; the slow path.
Returned bytes have EOL stripped.
Source code in src/alicatlib/protocol/client.py
reset_idle_timeout_metric ¶
write_only
async
¶
Send a command with no expected reply (e.g. @@ stop-stream).
Source code in src/alicatlib/protocol/client.py
parse_fields ¶
Split a whitespace-delimited response into fields.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw
|
str
|
Response text (already ASCII-decoded). |
required |
command
|
str
|
Command name, for the error message. |
required |
expected_count
|
int | None
|
If given, enforce exactly this many fields. |
None
|
Returns:
| Type | Description |
|---|---|
list[str]
|
The list of non-empty fields. |
Raises:
| Type | Description |
|---|---|
AlicatParseError
|
If |
Source code in src/alicatlib/protocol/parser.py
parse_float ¶
Parse value as a float, raising on failure.
Source code in src/alicatlib/protocol/parser.py
parse_int ¶
Parse value as a base-10 integer, raising on failure.
Source code in src/alicatlib/protocol/parser.py
strip_eol ¶
Return data without a trailing eol.
Idempotent: if data already lacks the EOL, it is returned unchanged.
Source code in src/alicatlib/protocol/framing.py
Protocol client¶
alicatlib.protocol.client ¶
One-in-flight request/response client over a :class:Transport.
:class:AlicatProtocolClient is the narrow waist between the command layer
(which knows Alicat semantics) and the transport layer (which knows bytes).
It enforces:
- Exactly one command in flight per client, via :class:
anyio.Lock. - Every write bounded by an explicit
write_timeout— read vs write timeouts are tagged distinctly in :class:ErrorContextso observability can tell a jammed bus from a non-responsive device. - Multiline termination priority:
is_complete(lines)→max_lines→ idle-timeout fallback. The fallback is the slow path; a metric counts how often each command falls through to it so we can find commands missing their termination contract.
Design reference: docs/design.md §5.2.
AlicatProtocolClient ¶
AlicatProtocolClient(
transport,
*,
eol=EOL,
default_timeout=0.5,
multiline_timeout=1.0,
multiline_idle_timeout=0.1,
write_timeout=0.5,
drain_before_write=False,
)
Request/response client that serialises commands over a transport.
Each method acquires an internal :class:anyio.Lock for its duration, so
callers from different tasks may invoke methods concurrently — the lock
queues them.
Source code in src/alicatlib/protocol/client.py
idle_timeout_exits
property
¶
Number of :meth:query_lines calls that exited via idle-timeout.
Rises when commands don't declare is_complete / max_lines —
treat a growing counter for a specific command as a bug report against
that command's spec (see design §5.2, §5.4).
is_streaming
property
¶
True while a :class:StreamingSession owns this client.
Set by :class:~alicatlib.devices.streaming.StreamingSession on
entry and cleared on exit. The
:class:~alicatlib.devices.session.Session dispatch path
consults this and fails fast with
:class:~alicatlib.errors.AlicatStreamingModeError rather than
writing a command onto a bus the device is already flooding
with unsolicited frames (design §5.8).
lock
property
¶
Port-level command lock, shared across every :class:Session.
Normal command dispatch goes through :meth:query_line /
:meth:query_lines / :meth:write_only, which acquire this
lock internally. Lifecycle-changing operations on
:class:Session (change_unit_id, change_baud_rate) need
to hold the lock for a multi-step sequence — they borrow it
directly so the device and client stay in sync across the
write → verify → reconfigure boundary. See design §5.7.
transport
property
¶
Underlying :class:Transport.
Exposed for lifecycle operations that need direct byte-level
access under the shared lock (change_baud_rate needs
:meth:Transport.reopen). Normal command dispatch should
stay on the public query_* / write_only API.
guard_response ¶
Public alias for :meth:_guard_response.
Session lifecycle paths that bypass :meth:query_line still
need the ?-rejection / empty-response guards; exposing the
check lets them get the same error shape without duplicating
the regex.
Source code in src/alicatlib/protocol/client.py
query_line
async
¶
Send a single-line command and return the single-line response.
The returned bytes have the EOL already stripped. A bare ? /
unit-id-prefixed ? surfaces as :class:AlicatCommandRejectedError;
an empty response surfaces as :class:AlicatProtocolError.
Source code in src/alicatlib/protocol/client.py
query_lines
async
¶
query_lines(
command,
*,
first_timeout=None,
idle_timeout=None,
max_lines=None,
is_complete=None,
write_timeout=None,
)
Send a multiline command and collect lines until termination.
Termination priority (design §5.2):
is_complete(lines)returnsTrue— caller-supplied predicate for tables with a computable end condition.len(lines) >= max_lines— hard cap, useful for fixed-shape tables like??M*(10 lines).idle_timeoutexpires — fallback for unknown-length responses. Increments :attr:idle_timeout_exits; the slow path.
Returned bytes have EOL stripped.
Source code in src/alicatlib/protocol/client.py
reset_idle_timeout_metric ¶
write_only
async
¶
Send a command with no expected reply (e.g. @@ stop-stream).
Source code in src/alicatlib/protocol/client.py
Parsers¶
alicatlib.protocol.parser ¶
Shared response parsing helpers.
Covers the full parser surface per design §5.11: primitive decoders
(parse_ascii, parse_fields, parse_int, parse_float,
parse_optional_float, parse_bool_code, parse_enum_code), the
all-firmware VE decoder (parse_ve_response), the status-code helper
(parse_status_codes), and the table / frame parsers used by
identification and polling (parse_manufacturing_info,
parse_data_frame_table, parse_data_frame).
Every helper raises :class:alicatlib.errors.AlicatParseError with the raw
response preserved in :class:alicatlib.errors.ErrorContext so debugging a
bad reply never requires adding print statements.
parse_bool_code ¶
Parse a boolean-coded field (default: "1" → True, "0" → False).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
str
|
The wire-level field. |
required |
field
|
str
|
Field name, for the error message. |
required |
mapping
|
Mapping[str, bool]
|
Accepted string-to-bool pairs. Override for commands that use
non-standard codes (e.g. some commands use |
_DEFAULT_BOOL_MAPPING
|
Raises:
| Type | Description |
|---|---|
AlicatParseError
|
If |
Source code in src/alicatlib/protocol/parser.py
parse_data_frame ¶
Parse raw against fmt into a :class:ParsedFrame.
Thin delegator to :meth:DataFrameFormat.parse. Provided as a
free-function alias so all low-level parsers share one import site
(:mod:alicatlib.protocol.parser), matching the rest of design §5.11.
Pure — no clocks; the session captures received_at /
monotonic_ns and wraps via :meth:DataFrame.from_parsed.
Source code in src/alicatlib/protocol/parser.py
parse_data_frame_table ¶
Parse a ??D* response into a :class:DataFrameFormat.
Auto-detects the dialect by sniffing the column-header row, then dispatches to the appropriate per-dialect parser:
- V8+ dialect (DEFAULT) — V8/V9 + V10 captures (design §16.6).
Header:
<uid> D00 ID_ NAME... TYPE... WIDTH NOTES.... Field rows carry a stat-code column and conditional rows are marked with a leading*on the name. - V1_V7 dialect — 5v12 capture (design §16.6.2). Header:
<uid> D00 NAME... TYPE... MinVal MaxVal UNITS.... No stat-code column, no*marker; engineering units sit in the trailing column.
Per-field :attr:DataFrameField.unit is bound inline when the
dialect carries a recognisable unit label.
Raises:
| Type | Description |
|---|---|
AlicatParseError
|
Non-ASCII bytes, or no field lines were recognised in either dialect. |
Source code in src/alicatlib/protocol/parser.py
parse_enum_code ¶
Parse a numeric code and resolve it against registry.
Wraps :meth:alicatlib.registry.aliases.AliasRegistry.by_code so that
unknown codes coming from the device surface as :class:AlicatParseError
(a protocol-layer problem) rather than :class:UnknownGasError /
:class:UnknownStatisticError (config-layer problems from user input).
The original registry error is preserved as __cause__.
Only usable with :class:AliasRegistry (unique-code enums: Gas, Statistic).
Unit lookups require a :class:UnitCategory disambiguator and are handled
at the data-frame-field parsing layer where the category is known.
Source code in src/alicatlib/protocol/parser.py
parse_fields ¶
Split a whitespace-delimited response into fields.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw
|
str
|
Response text (already ASCII-decoded). |
required |
command
|
str
|
Command name, for the error message. |
required |
expected_count
|
int | None
|
If given, enforce exactly this many fields. |
None
|
Returns:
| Type | Description |
|---|---|
list[str]
|
The list of non-empty fields. |
Raises:
| Type | Description |
|---|---|
AlicatParseError
|
If |
Source code in src/alicatlib/protocol/parser.py
parse_float ¶
Parse value as a float, raising on failure.
Source code in src/alicatlib/protocol/parser.py
parse_gas_list ¶
Parse a ??G* response into {gas_code: short_name}.
Real V10 wire shape (verified 2026-04-17 on a MC-500SCCM-D, design §16.6)::
<unit_id> G<NN> <short_name>
The integer in the G<NN> row label is the device-side gas code
(which coincides with the canonical Appendix-C code for the built-in
gases G00..G29 and continues with mixture/specialty slots beyond).
The short name (e.g. Air, CH4, N2) is right-aligned in a
fixed-width column; we collapse the leading whitespace.
Invariants:
- A consistent
unit_idacross every parsed line — mismatch raises :class:AlicatUnitIdMismatchError. - Duplicate gas codes raise :class:
AlicatParseErrorrather than silently overwriting — a duplicate would mask firmware oddities. - Empty responses or responses with no recognisable gas lines raise
:class:
AlicatParseError; the??G*command always has at least one built-in gas on any supported device.
Returns:
| Type | Description |
|---|---|
dict[int, str]
|
Mapping from Alicat gas code (per-device, often matching primer |
dict[int, str]
|
Appendix C for built-ins) to the wire short name. |
dict[int, str]
|
meth: |
dict[int, str]
|
class: |
Source code in src/alicatlib/protocol/parser.py
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 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 | |
parse_int ¶
Parse value as a base-10 integer, raising on failure.
Source code in src/alicatlib/protocol/parser.py
parse_manufacturing_info ¶
Parse a ??M* response into :class:ManufacturingInfo.
Expected shape (per primer — verified against hardware fixtures as
they're captured): a series of <unit_id> M<NN> <payload> lines,
one per code. The parser does not pin a line count (firmware versions
have varied) but does enforce:
- A consistent
unit_idacross every line — a mismatch is an :class:AlicatUnitIdMismatchError(a sign the bus has bled frames from another device). - No duplicate
M<NN>codes within the response — a duplicate is an :class:AlicatParseErrorrather than a silent overwrite. - Every non-empty line matches the
<uid> M<NN> <payload>shape; a malformed line raises rather than being skipped, so firmware-side format drift surfaces instead of being swallowed.
The semantic mapping (M04 → model, M05 → serial, etc.) is
intentionally not applied here — it belongs in the factory
(:mod:alicatlib.devices.factory) where it can be firmware-version
aware and validated against real captures.
Source code in src/alicatlib/protocol/parser.py
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 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 | |
parse_optional_float ¶
Parse value as a float, returning None for the "--" sentinel.
Alicat emits -- in a data frame when a field is unavailable on the
current device or in the current mode (e.g. setpoint on a flow meter).
Callers that want a strict parse should use :func:parse_float directly.
Source code in src/alicatlib/protocol/parser.py
parse_status_codes ¶
Collect :class:StatusCode members from a token sequence.
Any token whose value is not a known status code is silently skipped — callers that want "status-only" semantics should pre-slice the tail of their token stream, since status codes are the trailing run of a data frame per primer convention.
Order on the wire is not preserved (returned as a :class:frozenset);
the primer does not specify a canonical ordering for multi-code runs.
Source code in src/alicatlib/protocol/parser.py
parse_ve_response ¶
Parse a VE (firmware version) response.
VE is the one identification command that works on every firmware
family — it is the anchor of the identification pipeline (design §5.9).
The response shape varies across families: at minimum it contains a
firmware token (GP, GP-10v05, 10v05, 1v00, ...); some
devices additionally report a firmware date in ISO YYYY-MM-DD form.
This parser is deliberately tolerant: it scans the decoded response for (a) the first firmware-shaped token and (b) the first ISO-date token, regardless of their relative position or surrounding text.
Returns:
| Type | Description |
|---|---|
FirmwareVersion
|
A |
date | None
|
the device's firmware does not include one. |
Raises:
| Type | Description |
|---|---|
AlicatParseError
|
If no firmware token can be found. A malformed date (present but unparseable) also raises, rather than silently dropping the field — a garbled date is a sign of line corruption that the caller should see. |
Source code in src/alicatlib/protocol/parser.py
ASCII framing¶
alicatlib.protocol.framing ¶
Framing primitives shared by the client, command, and device layers.
Alicat responses are carriage-return delimited and ASCII-encoded. Keeping
these two facts — EOL handling and ASCII decoding — in a single small,
dependency-free module lets higher layers (protocol client, parsers,
data-frame format) import from here without risking an import cycle
through :mod:alicatlib.protocol.parser.
Design reference: docs/design.md §5.2.
decode_ascii ¶
Decode raw as ASCII, raising :class:AlicatParseError on non-ASCII.
The Alicat wire format is ASCII-only — non-ASCII bytes indicate line
noise or a framing error, not a legitimate extended-charset response,
so raising with the raw bytes preserved is the right behaviour.
Re-exported as :func:alicatlib.protocol.parser.parse_ascii for
callers that already import from the parser module; implementation
lives here so :mod:alicatlib.devices.data_frame can use it without
introducing a parser-layer import cycle.
Source code in src/alicatlib/protocol/framing.py
strip_eol ¶
Return data without a trailing eol.
Idempotent: if data already lacks the EOL, it is returned unchanged.