fujilib.protocol¶
The Modbus layer (design §4): the codec and planner are pure; the port owns one bus per serial line, and the client of one station moves words with retries, counters and timing.
fujilib.protocol.base ¶
The :class:ProtocolKind enum and the :class:ProtocolClient contract.
ProtocolKind has the Kind suffix to avoid colliding with
:class:typing.Protocol at import sites. It has one member today: the ZP
series speaks only MODBUS RTU. It is kept for family harmony and for the
protocol column in rows (design §7.1, §13.1 #6).
:class:ProtocolClient is what the device layer needs from a station's
client: block reads with their timing, the two write primitives, and the
traffic counters. :class:~fujilib.protocol.modbus.client.ModbusClient is
the implementation; the read functions in :mod:fujilib.devices.reads accept
anything that satisfies it.
ProtocolClient ¶
Bases: Protocol
One station's client, as the device layer uses it.
recoverable_error_count
property
¶
Failed read attempts that a retry recovered.
read
async
¶
read_plan
async
¶
Read every block of plan without other traffic in between.
write_register
async
¶
FC06: write one word inside the write envelope. Never retried.
write_registers
async
¶
FC10: write consecutive words inside the write envelope. Never retried.
Source code in src/fujilib/protocol/base.py
ProtocolKind ¶
Bases: StrEnum
The wire protocol of a session.
fujilib.protocol.modbus.codec ¶
Register-word codecs for the ZP series (design §2.5).
Pure functions between 16-bit register words and Python values. Integer work
is delegated to :mod:anymodbus.decoders; this module adds what the ZP series
needs and anymodbus does not have:
- BCD words (the undocumented clock; the manual also says schedule start times, which the bench unit contradicts, design §2.6);
- one ASCII character per register, in the low byte (type code and serial);
- decimal-point scaling: a concentration is a signed integer with its decimal places in another register (0–3);
- total enum decoding, so an undocumented value can be kept as a plain
intinstead of failing a whole read.
Long words are low word first (:attr:anymodbus.WordOrder.LOW_HIGH), big
endian inside each word. A value that fails to decode raises
:class:~fujilib.errors.FujiDecodeError; a value that cannot be encoded raises
:class:~fujilib.errors.FujiValidationError (design §4.4).
DataType ¶
Bases: StrEnum
How a register's words encode its value.
BOOL
class-attribute
instance-attribute
¶
A whole-register flag: 0 off, anything else on.
CHAR
class-attribute
instance-attribute
¶
One ASCII character per register, in the low byte.
ENUM
class-attribute
instance-attribute
¶
An unsigned code with a documented meaning per value.
INT16
class-attribute
instance-attribute
¶
Signed 16-bit integer, two's complement (concentrations, deviations).
UINT32_LH
class-attribute
instance-attribute
¶
Unsigned 32-bit integer over two words, low word first.
fixed_width
property
¶
Words per value, or None for :attr:CHAR, whose width is per register.
as_decimal ¶
Return the exact decimal value of raw at decimals places.
Raises:
| Type | Description |
|---|---|
FujiDecodeError
|
|
Source code in src/fujilib/protocol/modbus/codec.py
decode_bcd ¶
Decode four BCD digits (0x2359 → 2359).
Raises:
| Type | Description |
|---|---|
FujiDecodeError
|
a nibble is above 9, or |
Source code in src/fujilib/protocol/modbus/codec.py
decode_bool ¶
Decode a whole-register flag: 0 is off, anything else on.
Any non-zero value counts as set, so an unexpected value in a status flag errs towards "held" or "calibrating", which marks data invalid rather than valid.
Raises:
| Type | Description |
|---|---|
FujiDecodeError
|
|
Source code in src/fujilib/protocol/modbus/codec.py
decode_chars ¶
Decode one ASCII character per register, from the low byte (design §2.5).
A zero low byte is a blank and becomes a space, so positions are kept (the
type code is decoded by digit position). With strip the result has
trailing blanks removed.
Raises:
| Type | Description |
|---|---|
FujiDecodeError
|
a high byte is non-zero or a character is not printable ASCII. Either means the block was not a character field, which a probe treats as invalid data rather than a string. |
Source code in src/fujilib/protocol/modbus/codec.py
decode_enum ¶
Decode raw as a member of enum.
A value the manual does not define is returned as a plain int so it is
kept, not lost; with strict it raises instead.
Raises:
| Type | Description |
|---|---|
FujiDecodeError
|
|
Source code in src/fujilib/protocol/modbus/codec.py
decode_int ¶
Decode one register as a 16-bit integer (two's complement when signed).
Raises:
| Type | Description |
|---|---|
FujiDecodeError
|
|
Source code in src/fujilib/protocol/modbus/codec.py
decode_raw ¶
Decode words by data_type without scaling or enum lookup.
:attr:DataType.ENUM returns the raw code. Scaling and enum meaning need
the register's spec and live above the codec.
Raises:
| Type | Description |
|---|---|
FujiDecodeError
|
wrong width or an undecodable value. |
Source code in src/fujilib/protocol/modbus/codec.py
decode_uint32_lh ¶
Decode a long word: two registers, low-order word first (design §2.5).
Raises:
| Type | Description |
|---|---|
FujiDecodeError
|
not exactly two 16-bit words. |
Source code in src/fujilib/protocol/modbus/codec.py
encode_bcd ¶
Encode 0–9999 as four BCD digits (2359 → 0x2359).
Raises:
| Type | Description |
|---|---|
FujiValidationError
|
|
Source code in src/fujilib/protocol/modbus/codec.py
encode_chars ¶
Encode text as one character per register, padded with blanks to width.
Raises:
| Type | Description |
|---|---|
FujiValidationError
|
|
Source code in src/fujilib/protocol/modbus/codec.py
encode_int ¶
Encode value as one register word.
Raises:
| Type | Description |
|---|---|
FujiValidationError
|
|
Source code in src/fujilib/protocol/modbus/codec.py
encode_uint32_lh ¶
Encode value as a long word, low-order word first.
Raises:
| Type | Description |
|---|---|
FujiValidationError
|
|
Source code in src/fujilib/protocol/modbus/codec.py
scale ¶
Apply a decimal-point position: scale(2029, 2) → 20.29.
Always returns a float, so a concentration column never flips between
int and float when the decimal point is 0.
Raises:
| Type | Description |
|---|---|
FujiDecodeError
|
|
Source code in src/fujilib/protocol/modbus/codec.py
unscale ¶
Invert :func:scale: unscale(20.29, 2) → 2029.
Raises:
| Type | Description |
|---|---|
FujiValidationError
|
|
FujiDecodeError
|
|
Source code in src/fujilib/protocol/modbus/codec.py
fujilib.protocol.modbus.read_plan ¶
Region-aware block-read planning (design §4.3).
Turns a set of registers into the fewest block reads such that:
- every block lies inside a single region (a block that crosses a region end draws exception 03 on the bench unit);
- no block exceeds the per-request word limit (64 on the ZP series);
- a multi-word value is never split across blocks;
- gaps are bridged only inside a region and only up to
max_gapwords.
Planning is pure and used only for reads; the write path never coalesces or bridges (design §6.3). The hot paths are planned once, at import, and tested against exact transaction lists.
BlockRead
dataclass
¶
One block read, and the registers it covers.
to_bank ¶
Map each address of the block to its word from reply words.
Raises:
| Type | Description |
|---|---|
FujiValidationError
|
|
Source code in src/fujilib/protocol/modbus/read_plan.py
words_for ¶
Slice spec's words out of this block's reply words.
Raises:
| Type | Description |
|---|---|
FujiValidationError
|
|
Source code in src/fujilib/protocol/modbus/read_plan.py
ReadPolicy
dataclass
¶
calibration_log_plan ¶
The block reads of one channel's calibration log (design §4.3).
plan_log_reads ¶
Plan reads of one channel's region of log, in whole records.
Each block holds as many whole records as fit, so a block ends exactly at a record boundary (the calibration log reads as 5 × 63 + 45 words).
Raises:
| Type | Description |
|---|---|
FujiValidationError
|
|
FujiConfigurationError
|
one record is wider than a request. |
Source code in src/fujilib/protocol/modbus/read_plan.py
plan_reads ¶
Plan the fewest block reads covering specs.
Blocks come out holding table first, then input, each in address order.
Raises:
| Type | Description |
|---|---|
FujiConfigurationError
|
a register lies outside every region, or is wider than one request. |
Source code in src/fujilib/protocol/modbus/read_plan.py
fujilib.protocol.modbus.port ¶
One Modbus bus per serial port (design §4.2).
:class:ModbusPort owns the single anymodbus.Bus of a port and hands out
one :class:~fujilib.protocol.modbus.client.ModbusClient per station. It
never exposes a raw anymodbus.Slave: only the client holds one (design
§5.4).
Timing is anymodbus's. The bus waits out the one-shot startup settle
and the inter-frame gap, measured from the end of every attempt. It retries
reads (never writes) that were lost or damaged in transit. After an attempt
whose outcome is uncertain (a timeout, a cancellation, a damaged or mismatched
reply) it keeps a quiet window: it sends nothing until the window has passed,
reading and discarding whatever arrives, so a late reply cannot be taken as
the answer to the next request. A reply to an FC03/04 read carries no
address, so one of the same length would otherwise be accepted.
What the port adds. Every attempt is reported to the port's observer. The
port records the attempts of the call in progress for its client, which
takes timestamps and counts from them (design §4.5), and it tracks the quiet
window, so a request whose deadline ends inside it is refused before
anything is sent (:class:~fujilib.errors.FujiResyncRequiredError).
Two locks. anymodbus's internal lock serializes transactions.
:attr:ModbusPort.lock serializes operations: sequences such as the two
blocks of a poll that must not interleave with other traffic on the port. It
is taken with :func:fujilib._lock.maybe_acquire, so a caller can hold it
across a batch.
Ownership. A transport carries at most one open port; opening a second
one on it is refused. The port closes the transport on :meth:ModbusPort.aclose
only when it was created owning it, so a caller's transport is never closed
from under them.
ModbusPort ¶
ModbusPort(
transport,
*,
request_timeout=DEFAULTS.request_timeout_s,
inter_frame_idle=DEFAULTS.inter_frame_idle_s,
startup_settle=DEFAULTS.startup_settle_s,
read_retries=DEFAULTS.read_retries,
resync_window=DEFAULTS.resync_window_s,
owns_transport=False,
)
The Modbus side of one serial port. Internal; the facade builds it.
Bind a bus to transport.stream.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
transport
|
Transport
|
The open transport. It must not already carry an open port. |
required |
request_timeout
|
float
|
Seconds to wait for each reply. |
request_timeout_s
|
inter_frame_idle
|
float
|
Idle seconds before each request, from the end of the previous attempt. |
inter_frame_idle_s
|
startup_settle
|
float
|
One-shot idle seconds before the first request. |
startup_settle_s
|
read_retries
|
int
|
Extra attempts after a read fails in transit. |
read_retries
|
resync_window
|
float
|
Quiet seconds after an uncertain attempt
( |
resync_window_s
|
owns_transport
|
bool
|
Close |
False
|
Raises:
| Type | Description |
|---|---|
FujiValidationError
|
a timing value or retry count is out of range. |
FujiConfigurationError
|
|
Source code in src/fujilib/protocol/modbus/port.py
aclose
async
¶
Close the port, and its transport if the port owns it. Idempotent.
Waits for an operation in progress to finish (each transaction is bounded by the request timeout), so no request of this port is still waiting for its reply when the transport is released or closed. Completes even when the caller is cancelled.
Source code in src/fujilib/protocol/modbus/port.py
check_ready ¶
Refuse, before any I/O, a request that cannot go out.
Raises:
| Type | Description |
|---|---|
FujiConnectionError
|
the port or its transport is closed. |
FujiResyncRequiredError
|
|
Source code in src/fujilib/protocol/modbus/port.py
client ¶
The client for station address, created on first use.
Raises:
| Type | Description |
|---|---|
FujiValidationError
|
|
Source code in src/fujilib/protocol/modbus/port.py
quiet_remaining ¶
Seconds until the quiet window after an uncertain attempt ends; 0 if none.
record_attempts ¶
Collect the reports of the attempts made inside the block.
Used by a client under the operation lock around each call, so every attempt reported meanwhile is its own; no attempt happens outside one.
Source code in src/fujilib/protocol/modbus/port.py
fujilib.protocol.modbus.client ¶
The Modbus client of one station: block reads, counters and write primitives.
A :class:ModbusClient is the only holder of an anymodbus.Slave (design
§5.4). It moves words; it knows nothing about what they mean. Every call:
- validates its arguments before
anymodbussees them (design §4.4); - takes the port's operation lock (reentrantly), inside the operation deadline, so queue time counts against the deadline (design §6.4);
- refuses, before any I/O, a request the port cannot send in time;
- calls
anymodbus, which checks each reply against the request (its function code, its length, a write's echo), retries reads that were lost or damaged in transit, and reports every attempt to the port; - takes the request's timing and the traffic counters from those reports, and translates failures at this single boundary (design §4.6).
Timing. anymodbus reports when each request had been sent (after the
inter-frame gap, the write and the drain) and when its attempt ended. A
:class:~fujilib.devices.models.TransferTiming is those two moments, so it
never includes time spent waiting for the line (design §4.2).
Reads are retried by anymodbus when a reply was lost, damaged,
mismatched or of the wrong length (design §4.5). An exception reply is an
answer and is not retried. Each failed attempt is counted by kind; failed
attempts that a later attempt of the same read recovered make up
:attr:ModbusClient.recoverable_error_count (unified API §J).
Writes are never retried. The two write primitives check the frozen write
envelope as the last step before anymodbus, independently of the registry
(design §5.4). Once a write request may have been sent, every failure except an
exception reply makes its outcome unknown
(:class:~fujilib.errors.FujiWriteOutcomeUnknownError, design §6.4): a lost,
damaged or mismatched reply, a port that fails while waiting, or a deadline
that expires. An exception reply is a definite refusal.
BlockReply
dataclass
¶
The words one block read returned, and when.
ClientCounters
dataclass
¶
FailureKind ¶
Bases: StrEnum
Why one transaction attempt failed.
CANCELLED
class-attribute
instance-attribute
¶
The attempt was cancelled, by a deadline or by the caller.
CONNECTION
class-attribute
instance-attribute
¶
The port closed or failed.
FRAME
class-attribute
instance-attribute
¶
A damaged or malformed reply: a bad CRC, a truncated frame.
TIMEOUT
class-attribute
instance-attribute
¶
No reply within the request timeout.
UNEXPECTED
class-attribute
instance-attribute
¶
A well-formed reply that does not answer the request: another function code, another word count, or a write echo that differs.
ModbusClient ¶
One station on a :class:~fujilib.protocol.modbus.port.ModbusPort.
Created by :meth:ModbusPort.client, never directly.
Bind slave (station address) to port; internal.
Source code in src/fujilib/protocol/modbus/client.py
recoverable_error_count
property
¶
Failed read attempts that a retry recovered (unified API §J).
read
async
¶
Read one block.
Raises:
| Type | Description |
|---|---|
FujiValidationError
|
the block is not a read of 1-64 words; nothing was sent. |
FujiModbusError
|
the analyzer answered with an exception. |
FujiModbusTimeoutError
|
no reply, after every retry. |
FujiFrameError
|
damaged or malformed replies, after every retry. |
FujiProtocolError
|
replies that did not answer the request (another function code or word count), after every retry. |
FujiTimeoutError
|
|
FujiResyncRequiredError
|
|
FujiConnectionError
|
the port is closed or failed. |
Source code in src/fujilib/protocol/modbus/client.py
read_plan
async
¶
Read every block of plan, holding the operation lock throughout.
A failure after the first block carries the blocks already read in its
context's extra["completed"], as ((fc, address, count), words)
pairs (design §4.3).
Raises:
| Type | Description |
|---|---|
FujiError
|
as :meth: |
Source code in src/fujilib/protocol/modbus/client.py
write_register
async
¶
FC06: write one word. Never retried.
Raises:
| Type | Description |
|---|---|
FujiValidationError
|
a bad argument, or outside the write envelope; nothing was sent. |
FujiModbusError
|
the analyzer refused the write with an exception reply. |
FujiWriteOutcomeUnknownError
|
the request may have been applied, but no valid reply confirmed it. |
FujiTimeoutError
|
|
FujiResyncRequiredError
|
|
FujiConnectionError
|
the port is closed; nothing was sent. |
Source code in src/fujilib/protocol/modbus/client.py
write_registers
async
¶
FC10: write 1-64 consecutive words. Never retried.
Raises:
| Type | Description |
|---|---|
FujiError
|
as :meth: |
Source code in src/fujilib/protocol/modbus/client.py
fujilib.protocol.modbus.errors ¶
Translation of anymodbus and anyserial exceptions to :mod:fujilib.errors (design §4.6).
Applied at a single boundary, in the Modbus client, always as
raise mapped from exc so the original exception stays reachable.
Order matters. anymodbus's FrameTimeoutError is a TimeoutError, and
so an OSError, and its TransportError (a failing port) is an OSError
too, so the Modbus classes are matched before the built-in ones. anymodbus
translates every stream failure into a ModbusError; the anyio and
OSError row remains for a failure outside a transaction.
Anything else is not translated: an exception of another kind, such as a bare
ValueError, is a bug and propagates as it is.
map_modbus_error ¶
The fujilib error for exc, carrying context.
An exception response adds its exception_code to the context's
extra. The caller raises the result from exc.
Raises:
| Type | Description |
|---|---|
TypeError
|
|