Skip to content

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.

address property

address

The station number.

counters property

counters

The station's traffic counters.

label property

label

The port's canonical name.

recoverable_error_count property

recoverable_error_count

Failed read attempts that a retry recovered.

read async

read(block, *, deadline=None, command='read')

Read one block.

Source code in src/fujilib/protocol/base.py
async def read(
    self, block: BlockRead, *, deadline: Deadline | None = None, command: str = "read"
) -> BlockReply:
    """Read one block."""
    ...

read_plan async

read_plan(plan, *, deadline=None, command='read')

Read every block of plan without other traffic in between.

Source code in src/fujilib/protocol/base.py
async def read_plan(
    self,
    plan: Sequence[BlockRead],
    *,
    deadline: Deadline | None = None,
    command: str = "read",
) -> PlanReply:
    """Read every block of ``plan`` without other traffic in between."""
    ...

write_register async

write_register(
    address,
    value,
    *,
    deadline=None,
    command="write_register",
)

FC06: write one word inside the write envelope. Never retried.

Source code in src/fujilib/protocol/base.py
async def write_register(
    self,
    address: int,
    value: int,
    *,
    deadline: Deadline | None = None,
    command: str = "write_register",
) -> TransferTiming:
    """FC06: write one word inside the write envelope. Never retried."""
    ...

write_registers async

write_registers(
    address,
    values,
    *,
    deadline=None,
    command="write_registers",
)

FC10: write consecutive words inside the write envelope. Never retried.

Source code in src/fujilib/protocol/base.py
async def write_registers(
    self,
    address: int,
    values: Sequence[int],
    *,
    deadline: Deadline | None = None,
    command: str = "write_registers",
) -> TransferTiming:
    """FC10: write consecutive words inside the write envelope. Never retried."""
    ...

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 int instead 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.

BCD class-attribute instance-attribute

BCD = 'bcd'

Four BCD digits in one word (0x23 = 23).

BOOL class-attribute instance-attribute

BOOL = 'bool'

A whole-register flag: 0 off, anything else on.

CHAR class-attribute instance-attribute

CHAR = 'char'

One ASCII character per register, in the low byte.

ENUM class-attribute instance-attribute

ENUM = 'enum'

An unsigned code with a documented meaning per value.

INT16 class-attribute instance-attribute

INT16 = 'int16'

Signed 16-bit integer, two's complement (concentrations, deviations).

UINT16 class-attribute instance-attribute

UINT16 = 'uint16'

Unsigned 16-bit integer.

UINT32_LH class-attribute instance-attribute

UINT32_LH = 'uint32_lh'

Unsigned 32-bit integer over two words, low word first.

fixed_width property

fixed_width

Words per value, or None for :attr:CHAR, whose width is per register.

as_decimal

as_decimal(raw, decimals)

Return the exact decimal value of raw at decimals places.

Raises:

Type Description
FujiDecodeError

decimals is outside 0–3.

Source code in src/fujilib/protocol/modbus/codec.py
def as_decimal(raw: int, decimals: int) -> Decimal:
    """Return the exact decimal value of ``raw`` at ``decimals`` places.

    Raises:
        FujiDecodeError: ``decimals`` is outside 0–3.
    """
    _check_decimals(decimals)
    return Decimal(raw).scaleb(-decimals)

decode_bcd

decode_bcd(word)

Decode four BCD digits (0x2359 → 2359).

Raises:

Type Description
FujiDecodeError

a nibble is above 9, or word is not a 16-bit value.

Source code in src/fujilib/protocol/modbus/codec.py
def decode_bcd(word: int) -> int:
    """Decode four BCD digits (``0x2359`` → 2359).

    Raises:
        FujiDecodeError: a nibble is above 9, or ``word`` is not a 16-bit value.
    """
    _check_words((word,), 1, "BCD")
    value = 0
    for shift in (12, 8, 4, 0):
        digit = (word >> shift) & 0xF
        if digit > 9:  # noqa: PLR2004
            msg = f"0x{word:04X} is not a BCD value"
            raise FujiDecodeError(msg, context=_ctx(word=word))
        value = value * 10 + digit
    return value

decode_bool

decode_bool(word)

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

word is not a 16-bit value.

Source code in src/fujilib/protocol/modbus/codec.py
def decode_bool(word: int) -> 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:
        FujiDecodeError: ``word`` is not a 16-bit value.
    """
    _check_words((word,), 1, "bool")
    return word != 0

decode_chars

decode_chars(words, *, strip=True)

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
def decode_chars(words: Sequence[int], *, strip: bool = True) -> str:
    """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:
        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.
    """
    _check_words(words, None, "characters")
    chars: list[str] = []
    for word in words:
        high, low = word >> 8, word & 0xFF
        if high != 0 or not (low == 0 or low in _ASCII_PRINTABLE):
            msg = f"register 0x{word:04X} is not one ASCII character in the low byte"
            raise FujiDecodeError(msg, context=_ctx(words=tuple(words)))
        chars.append(" " if low == 0 else chr(low))
    text = "".join(chars)
    return text.rstrip() if strip else text

decode_enum

decode_enum(enum, raw, *, strict=False)

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

strict and raw is not a member of enum.

Source code in src/fujilib/protocol/modbus/codec.py
def decode_enum[E: IntEnum](enum: type[E], raw: int, *, strict: bool = False) -> E | int:
    """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:
        FujiDecodeError: ``strict`` and ``raw`` is not a member of ``enum``.
    """
    try:
        return enum(raw)
    except ValueError:
        if strict:
            msg = f"{raw!r} is not a documented {enum.__name__} value"
            raise FujiDecodeError(msg, context=_ctx(value=raw)) from None
        return raw

decode_int

decode_int(word, *, signed)

Decode one register as a 16-bit integer (two's complement when signed).

Raises:

Type Description
FujiDecodeError

word is not a 16-bit value.

Source code in src/fujilib/protocol/modbus/codec.py
def decode_int(word: int, *, signed: bool) -> int:
    """Decode one register as a 16-bit integer (two's complement when ``signed``).

    Raises:
        FujiDecodeError: ``word`` is not a 16-bit value.
    """
    _check_words((word,), 1, "int16" if signed else "uint16")
    return decode_int16((word,), signed=signed, byte_order=ByteOrder.BIG)

decode_raw

decode_raw(words, data_type)

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
def decode_raw(words: Sequence[int], data_type: DataType) -> int | bool | str:
    """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:
        FujiDecodeError: wrong width or an undecodable value.
    """
    width = data_type.fixed_width
    _check_words(words, width, data_type.value)
    match data_type:
        case DataType.UINT16 | DataType.ENUM:
            return decode_int(words[0], signed=False)
        case DataType.INT16:
            return decode_int(words[0], signed=True)
        case DataType.UINT32_LH:
            return decode_uint32_lh(words)
        case DataType.BCD:
            return decode_bcd(words[0])
        case DataType.BOOL:
            return decode_bool(words[0])
        case DataType.CHAR:
            return decode_chars(words)
        case _:  # pragma: no cover — every DataType has a case
            assert_never(data_type)

decode_uint32_lh

decode_uint32_lh(words)

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
def decode_uint32_lh(words: Sequence[int]) -> int:
    """Decode a long word: two registers, low-order word first (design §2.5).

    Raises:
        FujiDecodeError: not exactly two 16-bit words.
    """
    _check_words(words, 2, "uint32 (low word first)")
    return decode_int32(
        tuple(words), signed=False, word_order=WordOrder.LOW_HIGH, byte_order=ByteOrder.BIG
    )

encode_bcd

encode_bcd(value)

Encode 0–9999 as four BCD digits (2359 → 0x2359).

Raises:

Type Description
FujiValidationError

value is outside 0–9999.

Source code in src/fujilib/protocol/modbus/codec.py
def encode_bcd(value: int) -> int:
    """Encode 0–9999 as four BCD digits (2359 → ``0x2359``).

    Raises:
        FujiValidationError: ``value`` is outside 0–9999.
    """
    if not 0 <= value <= _BCD_MAX:
        msg = f"{value!r} is outside the BCD range 0-{_BCD_MAX}"
        raise FujiValidationError(msg, context=_ctx(value=value))
    word = 0
    for shift, digit in zip((12, 8, 4, 0), f"{value:04d}", strict=True):
        word |= int(digit) << shift
    return word

encode_chars

encode_chars(text, width)

Encode text as one character per register, padded with blanks to width.

Raises:

Type Description
FujiValidationError

text is longer than width or not printable ASCII.

Source code in src/fujilib/protocol/modbus/codec.py
def encode_chars(text: str, width: int) -> tuple[int, ...]:
    """Encode ``text`` as one character per register, padded with blanks to ``width``.

    Raises:
        FujiValidationError: ``text`` is longer than ``width`` or not printable ASCII.
    """
    if len(text) > width or any(ord(c) not in _ASCII_PRINTABLE for c in text):
        msg = f"{text!r} is not up to {width} printable ASCII characters"
        raise FujiValidationError(msg, context=_ctx(text=text))
    return tuple(ord(c) for c in text) + (0,) * (width - len(text))

encode_int

encode_int(value, *, signed)

Encode value as one register word.

Raises:

Type Description
FujiValidationError

value does not fit in 16 bits.

Source code in src/fujilib/protocol/modbus/codec.py
def encode_int(value: int, *, signed: bool) -> int:
    """Encode ``value`` as one register word.

    Raises:
        FujiValidationError: ``value`` does not fit in 16 bits.
    """
    try:
        (word,) = encode_int16(value, signed=signed, byte_order=ByteOrder.BIG)
    except (ValueError, TypeError, OverflowError) as exc:
        kind = "int16" if signed else "uint16"
        msg = f"{value!r} does not fit in an {kind} register"
        raise FujiValidationError(msg, context=_ctx(value=value)) from exc
    return word

encode_uint32_lh

encode_uint32_lh(value)

Encode value as a long word, low-order word first.

Raises:

Type Description
FujiValidationError

value does not fit in 32 unsigned bits.

Source code in src/fujilib/protocol/modbus/codec.py
def encode_uint32_lh(value: int) -> tuple[int, int]:
    """Encode ``value`` as a long word, low-order word first.

    Raises:
        FujiValidationError: ``value`` does not fit in 32 unsigned bits.
    """
    try:
        return encode_int32(
            value, signed=False, word_order=WordOrder.LOW_HIGH, byte_order=ByteOrder.BIG
        )
    except (ValueError, TypeError, OverflowError) as exc:
        msg = f"{value!r} does not fit in an unsigned long word"
        raise FujiValidationError(msg, context=_ctx(value=value)) from exc

scale

scale(raw, decimals)

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

decimals is outside 0–3.

Source code in src/fujilib/protocol/modbus/codec.py
def scale(raw: int, decimals: int) -> float:
    """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:
        FujiDecodeError: ``decimals`` is outside 0–3.
    """
    _check_decimals(decimals)
    return raw / _POWERS_OF_TEN[decimals]

unscale

unscale(value, decimals)

Invert :func:scale: unscale(20.29, 2) → 2029.

Raises:

Type Description
FujiValidationError

value is not finite, or has more precision than decimals places can hold.

FujiDecodeError

decimals is outside 0–3.

Source code in src/fujilib/protocol/modbus/codec.py
def unscale(value: float | int | Decimal | str, decimals: int) -> int:
    """Invert :func:`scale`: ``unscale(20.29, 2)`` → ``2029``.

    Raises:
        FujiValidationError: ``value`` is not finite, or has more precision
            than ``decimals`` places can hold.
        FujiDecodeError: ``decimals`` is outside 0–3.
    """
    _check_decimals(decimals)
    try:
        exact = Decimal(repr(value)) if isinstance(value, float) else Decimal(value)
    except (InvalidOperation, ValueError) as exc:
        msg = f"{value!r} is not a number"
        raise FujiValidationError(msg, context=_ctx(value=value)) from exc
    if not exact.is_finite():
        msg = f"{value!r} is not finite"
        raise FujiValidationError(msg, context=_ctx(value=value))
    shifted = exact.scaleb(decimals)
    rounded = shifted.to_integral_value(rounding=ROUND_HALF_EVEN)
    if abs(shifted - rounded) > _UNSCALE_TOLERANCE:
        msg = f"{value!r} has more than {decimals} decimal place(s)"
        raise FujiValidationError(msg, context=_ctx(value=value, decimals=decimals))
    return int(rounded)

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_gap words.

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

BlockRead(function, address, count, specs=())

One block read, and the registers it covers.

key property

key

(function, address, count), as a transaction log records it.

last_address property

last_address

The last address the block reads.

to_bank

to_bank(words)

Map each address of the block to its word from reply words.

Raises:

Type Description
FujiValidationError

words is not the block's length.

Source code in src/fujilib/protocol/modbus/read_plan.py
def to_bank(self, words: Sequence[int]) -> dict[int, int]:
    """Map each address of the block to its word from reply ``words``.

    Raises:
        FujiValidationError: ``words`` is not the block's length.
    """
    self._check_length(words)
    return dict(zip(range(self.address, self.address + self.count), words, strict=True))

words_for

words_for(spec, words)

Slice spec's words out of this block's reply words.

Raises:

Type Description
FujiValidationError

spec is not inside the block, or words is not the block's length.

Source code in src/fujilib/protocol/modbus/read_plan.py
def words_for(self, spec: RegisterSpec, words: Sequence[int]) -> tuple[int, ...]:
    """Slice ``spec``'s words out of this block's reply ``words``.

    Raises:
        FujiValidationError: ``spec`` is not inside the block, or ``words``
            is not the block's length.
    """
    if not self.address <= spec.address <= spec.last_address <= self.last_address:
        msg = f"{spec.name} is not inside block 0x{self.address:04X}+{self.count}"
        raise FujiValidationError(msg)
    self._check_length(words)
    offset = spec.address - self.address
    return tuple(words[offset : offset + spec.count])

ReadPolicy dataclass

ReadPolicy(max_words=ZP_MAX_WORDS, max_gap=8)

How aggressively adjacent registers are merged into one read.

strict

strict()

The gap-free variant of this policy.

Source code in src/fujilib/protocol/modbus/read_plan.py
def strict(self) -> ReadPolicy:
    """The gap-free variant of this policy."""
    return replace(self, max_gap=0)

calibration_log_plan

calibration_log_plan(channel)

The block reads of one channel's calibration log (design §4.3).

Source code in src/fujilib/protocol/modbus/read_plan.py
def calibration_log_plan(channel: int) -> tuple[BlockRead, ...]:
    """The block reads of one channel's calibration log (design §4.3)."""
    return plan_log_reads(CALIBRATION_LOG, channel)

plan_log_reads

plan_log_reads(
    log, channel=1, *, policy=DEFAULT_READ_POLICY
)

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

channel is not a channel of log.

FujiConfigurationError

one record is wider than a request.

Source code in src/fujilib/protocol/modbus/read_plan.py
def plan_log_reads(
    log: LogSpec,
    channel: int = 1,
    *,
    policy: ReadPolicy = DEFAULT_READ_POLICY,
) -> tuple[BlockRead, ...]:
    """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:
        FujiValidationError: ``channel`` is not a channel of ``log``.
        FujiConfigurationError: one record is wider than a request.
    """
    per_block = policy.max_words // log.record_words
    if per_block < 1:
        msg = f"a {log.name} record does not fit in one request"
        raise FujiConfigurationError(msg)
    fc = log.table.read_function
    blocks: list[BlockRead] = []
    for first in range(0, log.records, per_block):
        records = min(per_block, log.records - first)
        address = log.record_address(channel, first)
        blocks.append(BlockRead(function=fc, address=address, count=records * log.record_words))
    return tuple(blocks)

plan_reads

plan_reads(
    specs, *, regions=ZP_REGIONS, policy=DEFAULT_READ_POLICY
)

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
def plan_reads(
    specs: Iterable[RegisterSpec],
    *,
    regions: RegionMap = ZP_REGIONS,
    policy: ReadPolicy = DEFAULT_READ_POLICY,
) -> tuple[BlockRead, ...]:
    """Plan the fewest block reads covering ``specs``.

    Blocks come out holding table first, then input, each in address order.

    Raises:
        FujiConfigurationError: a register lies outside every region, or is
            wider than one request.
    """
    unique = {s.name: s for s in specs}.values()
    blocks: list[BlockRead] = []
    for table in RegisterTable:
        fc = table.read_function
        ordered = sorted((s for s in unique if s.table is table), key=lambda s: s.address)
        run: list[RegisterSpec] = []
        run_region = None
        for spec in ordered:
            region = regions.region_for(fc, spec.address, spec.count)
            if region is None or spec.count > policy.max_words:
                msg = f"{spec.name} cannot be read in one FC{fc:02X} request inside a region"
                raise FujiConfigurationError(
                    msg, context=ErrorContext(function_code=fc, register_address=spec.address)
                )
            if run:
                start, end = run[0].address, max(s.last_address for s in run)
                gap = spec.address - end - 1
                merged = max(end, spec.last_address) - start + 1
                if region is run_region and gap <= policy.max_gap and merged <= policy.max_words:
                    run.append(spec)
                    continue
                blocks.append(_block(fc, run))
            run, run_region = [spec], region
        if run:
            blocks.append(_block(fc, run))
    return tuple(blocks)

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 (anymodbus's late-reply window).

resync_window_s
owns_transport bool

Close transport when the port is closed.

False

Raises:

Type Description
FujiValidationError

a timing value or retry count is out of range.

FujiConfigurationError

transport already carries an open port.

Source code in src/fujilib/protocol/modbus/port.py
def __init__(
    self,
    transport: Transport,
    *,
    request_timeout: float = DEFAULTS.request_timeout_s,
    inter_frame_idle: float = DEFAULTS.inter_frame_idle_s,
    startup_settle: float = DEFAULTS.startup_settle_s,
    read_retries: int = DEFAULTS.read_retries,
    resync_window: float = DEFAULTS.resync_window_s,
    owns_transport: bool = False,
) -> None:
    """Bind a bus to ``transport.stream``.

    Args:
        transport: The open transport. It must not already carry an open port.
        request_timeout: Seconds to wait for each reply.
        inter_frame_idle: Idle seconds before each request, from the end
            of the previous attempt.
        startup_settle: One-shot idle seconds before the first request.
        read_retries: Extra attempts after a read fails in transit.
        resync_window: Quiet seconds after an uncertain attempt
            (``anymodbus``'s late-reply window).
        owns_transport: Close ``transport`` when the port is closed.

    Raises:
        FujiValidationError: a timing value or retry count is out of range.
        FujiConfigurationError: ``transport`` already carries an open port.
    """
    self._request_timeout = _check_seconds("request_timeout", request_timeout, positive=True)
    if self._request_timeout > _MAX_REQUEST_TIMEOUT:
        msg = (
            f"request_timeout must be at most {_MAX_REQUEST_TIMEOUT:g} s, got {request_timeout}"
        )
        raise FujiValidationError(msg)
    self._inter_frame_idle = _check_seconds("inter_frame_idle", inter_frame_idle)
    self._startup_settle = _check_seconds("startup_settle", startup_settle)
    self._resync_window = _check_seconds("resync_window", resync_window)
    if not _is_count(read_retries):
        msg = f"read_retries must be a non-negative integer, got {read_retries!r}"
        raise FujiValidationError(msg)
    self._read_retries = read_retries

    existing = _CLAIMS.get(id(transport))
    if existing is not None and not existing.closed:
        msg = f"{transport.label} already has an open Modbus port"
        raise FujiConfigurationError(msg, context=ErrorContext(port=transport.label))

    self._transport = transport
    self._owns_transport = owns_transport
    # The default retry policy retries timeouts and damaged or mismatched
    # replies, and only for idempotent function codes: reads, never writes
    # (design §4.5).
    self._bus = Bus(
        transport.stream,
        config=BusConfig(
            request_timeout=self._request_timeout,
            retries=RetryPolicy(retries=read_retries),
            timing=TimingConfig(
                inter_frame_idle=self._inter_frame_idle,
                startup_settle=self._startup_settle,
                late_reply_window=self._resync_window,
            ),
        ),
        on_transaction=self._observe,
    )
    self._lock = anyio.Lock()
    self._clients: dict[int, ModbusClient] = {}
    self._quiet_until = -math.inf
    self._attempts: list[TransactionInfo] = []
    self._closed = False
    # Claimed last, so a port that failed to build never holds the transport.
    _CLAIMS[id(transport)] = self

closed property

closed

Whether :meth:aclose has been called.

inter_frame_idle property

inter_frame_idle

Idle seconds before each request.

label property

label

The transport's canonical port name.

lock property

lock

The operation lock; take it with :func:fujilib._lock.maybe_acquire.

protocol property

protocol

Always :attr:ProtocolKind.MODBUS_RTU.

read_retries property

read_retries

Extra attempts after a read fails in transit.

request_timeout property

request_timeout

Seconds to wait for each reply.

resync_window property

resync_window

Quiet seconds after an uncertain transaction.

transport property

transport

The transport the bus is bound to.

aclose async

aclose()

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
async def aclose(self) -> None:
    """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.
    """
    if self._closed:
        return
    with anyio.CancelScope(shield=True):
        async with maybe_acquire(self._lock):
            await self._close_locked()

check_ready

check_ready(deadline, *, context)

Refuse, before any I/O, a request that cannot go out.

Raises:

Type Description
FujiConnectionError

the port or its transport is closed.

FujiResyncRequiredError

deadline ends before the quiet window does.

Source code in src/fujilib/protocol/modbus/port.py
def check_ready(self, deadline: Deadline, *, context: ErrorContext) -> None:
    """Refuse, before any I/O, a request that cannot go out.

    Raises:
        FujiConnectionError: the port or its transport is closed.
        FujiResyncRequiredError: ``deadline`` ends before the quiet window
            does.
    """
    if self._closed:
        msg = f"the Modbus port on {self.label} is closed"
        raise FujiConnectionError(msg, context=context)
    if not self._transport.is_open:
        # Checked here, before anything is sent, so a request on a closed
        # port is a definite failure rather than a write of unknown outcome.
        msg = f"the transport of {self.label} is closed"
        raise FujiConnectionError(msg, context=context)
    if self._quiet_until > deadline.expires:
        msg = (
            f"{self.label} is waiting out a possible late reply for "
            f"{self.quiet_remaining():.3f} s, longer than the time left"
        )
        raise FujiResyncRequiredError(msg, context=context)

client

client(address)

The client for station address, created on first use.

Raises:

Type Description
FujiValidationError

address is not a station number, 1-31.

Source code in src/fujilib/protocol/modbus/port.py
def client(self, address: int) -> ModbusClient:
    """The client for station ``address``, created on first use.

    Raises:
        FujiValidationError: ``address`` is not a station number, 1-31.
    """
    if not _is_count(address):
        msg = f"station address must be an integer, got {address!r}"
        raise FujiValidationError(msg, context=ErrorContext(port=self.label))
    if not MIN_STATION <= address <= MAX_STATION:
        msg = f"station address must be {MIN_STATION}-{MAX_STATION}, got {address}"
        raise FujiValidationError(msg, context=ErrorContext(port=self.label, address=address))
    client = self._clients.get(address)
    if client is None:
        client = ModbusClient(self, address, self._bus.slave(address))
        self._clients[address] = client
    return client

quiet_remaining

quiet_remaining()

Seconds until the quiet window after an uncertain attempt ends; 0 if none.

Source code in src/fujilib/protocol/modbus/port.py
def quiet_remaining(self) -> float:
    """Seconds until the quiet window after an uncertain attempt ends; 0 if none."""
    return max(0.0, self._quiet_until - anyio.current_time())

record_attempts

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
@contextmanager
def record_attempts(self) -> Generator[list[TransactionInfo]]:
    """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.
    """
    attempts: list[TransactionInfo] = []
    self._attempts = attempts
    yield attempts

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:

  1. validates its arguments before anymodbus sees them (design §4.4);
  2. takes the port's operation lock (reentrantly), inside the operation deadline, so queue time counts against the deadline (design §6.4);
  3. refuses, before any I/O, a request the port cannot send in time;
  4. 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;
  5. 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

BlockReply(block, words, timing, attempts)

The words one block read returned, and when.

attempts instance-attribute

attempts

1 when the first attempt succeeded.

raw property

raw

The words as on the wire: big endian, two bytes each.

to_bank

to_bank()

Each address of the block mapped to its word.

Source code in src/fujilib/protocol/modbus/client.py
def to_bank(self) -> dict[int, int]:
    """Each address of the block mapped to its word."""
    return self.block.to_bank(self.words)

ClientCounters dataclass

ClientCounters(
    requests=0,
    retries=0,
    recovered=0,
    failures=dict[FailureKind, int](),
)

Traffic counters of one station's client. Mutable; read them, don't write them.

failures class-attribute instance-attribute

failures = field(default_factory=dict[FailureKind, int])

Failed attempts, by kind.

recovered class-attribute instance-attribute

recovered = 0

Failed read attempts that a later attempt of the same read recovered.

requests class-attribute instance-attribute

requests = 0

Attempts anymodbus made.

retries class-attribute instance-attribute

retries = 0

Read attempts that repeated a failed one.

count_failure

count_failure(kind)

Count one failed attempt.

Source code in src/fujilib/protocol/modbus/client.py
def count_failure(self, kind: FailureKind) -> None:
    """Count one failed attempt."""
    self.failures[kind] = self.failures.get(kind, 0) + 1

FailureKind

Bases: StrEnum

Why one transaction attempt failed.

CANCELLED class-attribute instance-attribute

CANCELLED = 'cancelled'

The attempt was cancelled, by a deadline or by the caller.

CONNECTION class-attribute instance-attribute

CONNECTION = 'connection'

The port closed or failed.

EXCEPTION class-attribute instance-attribute

EXCEPTION = 'exception'

A Modbus exception reply.

FRAME class-attribute instance-attribute

FRAME = 'frame'

A damaged or malformed reply: a bad CRC, a truncated frame.

OTHER class-attribute instance-attribute

OTHER = 'other'

Anything else anymodbus raised.

TIMEOUT class-attribute instance-attribute

TIMEOUT = 'timeout'

No reply within the request timeout.

UNEXPECTED class-attribute instance-attribute

UNEXPECTED = 'unexpected'

A well-formed reply that does not answer the request: another function code, another word count, or a write echo that differs.

ModbusClient

ModbusClient(port, address, slave)

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
def __init__(self, port: ModbusPort, address: int, slave: Slave) -> None:
    """Bind ``slave`` (station ``address``) to ``port``; internal."""
    self._port = port
    self._address = address
    self._slave = slave
    self.counters = ClientCounters()
    """This station's traffic counters."""

address property

address

The station number, 1-31.

counters instance-attribute

counters = ClientCounters()

This station's traffic counters.

label property

label

The port's canonical name.

port property

port

The port the station is on.

recoverable_error_count property

recoverable_error_count

Failed read attempts that a retry recovered (unified API §J).

read async

read(block, *, deadline=None, command='read')

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

deadline expired.

FujiResyncRequiredError

deadline ends inside a quiet window.

FujiConnectionError

the port is closed or failed.

Source code in src/fujilib/protocol/modbus/client.py
async def read(
    self, block: BlockRead, *, deadline: Deadline | None = None, command: str = "read"
) -> BlockReply:
    """Read one block.

    Raises:
        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: ``deadline`` expired.
        FujiResyncRequiredError: ``deadline`` ends inside a quiet window.
        FujiConnectionError: the port is closed or failed.
    """
    reply = await self.read_plan((block,), deadline=deadline, command=command)
    return reply.replies[0]

read_plan async

read_plan(plan, *, deadline=None, command='read')

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:read.

Source code in src/fujilib/protocol/modbus/client.py
async def read_plan(
    self,
    plan: Sequence[BlockRead],
    *,
    deadline: Deadline | None = None,
    command: str = "read",
) -> PlanReply:
    """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:
        FujiError: as :meth:`read`.
    """
    for block in plan:
        self._check_read(block, command)
    dl = deadline if deadline is not None else Deadline.after(None, operation=command)
    replies: list[BlockReply] = []
    try:
        with dl.enforce():
            async with maybe_acquire(self._port.lock):
                for block in plan:
                    # One at a time: a failure reports the blocks already read.
                    replies.append(await self._read_block(block, dl, command))  # noqa: PERF401
    except FujiError as exc:
        raise self._located(exc, replies) from exc.__cause__
    except anyio.get_cancelled_exc_class():
        # A caller enforcing the same deadline around this call catches its
        # expiry first (the outermost cancelled scope does); report it here,
        # with what was read, rather than as a bare timeout outside.
        if dl.remaining() <= 0:
            raise self._located(dl.expired_error(), replies) from None
        raise
    return PlanReply(tuple(replies))

write_register async

write_register(
    address,
    value,
    *,
    deadline=None,
    command="write_register",
)

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

deadline expired before anything was sent.

FujiResyncRequiredError

deadline ends inside a quiet window.

FujiConnectionError

the port is closed; nothing was sent.

Source code in src/fujilib/protocol/modbus/client.py
async def write_register(
    self,
    address: int,
    value: int,
    *,
    deadline: Deadline | None = None,
    command: str = "write_register",
) -> TransferTiming:
    """FC06: write one word. Never retried.

    Raises:
        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: ``deadline`` expired before anything was sent.
        FujiResyncRequiredError: ``deadline`` ends inside a quiet window.
        FujiConnectionError: the port is closed; nothing was sent.
    """
    values = (value,)
    self._check_write(FC_WRITE_SINGLE, address, values, command)
    return await self._write(
        FC_WRITE_SINGLE,
        address,
        values,
        lambda: self._slave.write_register(address, value),
        deadline=deadline,
        command=command,
    )

write_registers async

write_registers(
    address,
    values,
    *,
    deadline=None,
    command="write_registers",
)

FC10: write 1-64 consecutive words. Never retried.

Raises:

Type Description
FujiError

as :meth:write_register.

Source code in src/fujilib/protocol/modbus/client.py
async def write_registers(
    self,
    address: int,
    values: Sequence[int],
    *,
    deadline: Deadline | None = None,
    command: str = "write_registers",
) -> TransferTiming:
    """FC10: write 1-64 consecutive words. Never retried.

    Raises:
        FujiError: as :meth:`write_register`.
    """
    words = tuple(values)
    self._check_write(FC_WRITE_MULTIPLE, address, words, command)
    return await self._write(
        FC_WRITE_MULTIPLE,
        address,
        words,
        lambda: self._slave.write_registers(address, words),
        deadline=deadline,
        command=command,
    )

PlanReply dataclass

PlanReply(replies)

The replies to a read plan, in plan order.

holding property

holding

The holding-register words read, by address.

input property

input

The input-register words read, by address.

raw property

raw

Every block's words, concatenated in plan order (the shape of Frame.raw).

timings property

timings

The timing of each block.

bank

bank(table)

The words read from table, by address.

Source code in src/fujilib/protocol/modbus/client.py
def bank(self, table: RegisterTable) -> Mapping[int, int]:
    """The words read from ``table``, by address."""
    bank: dict[int, int] = {}
    for reply in self.replies:
        if reply.block.function == table.read_function:
            bank.update(reply.to_bank())
    return MappingProxyType(bank)

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

map_modbus_error(exc, *, context)

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

exc is not one of :data:MAPPED_EXCEPTIONS.

Source code in src/fujilib/protocol/modbus/errors.py
def map_modbus_error(exc: BaseException, *, context: ErrorContext) -> FujiError:
    """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:
        TypeError: ``exc`` is not one of :data:`MAPPED_EXCEPTIONS`.
    """
    if isinstance(exc, ModbusExceptionResponse):
        context = context.merged(exception_code=exc.exception_code)
    for types, fuji_type in _RULES:
        if isinstance(exc, types):
            return fuji_type(f"{context.command_name or 'modbus'}: {exc}", context=context)
    msg = f"{type(exc).__name__} is not a Modbus or serial error"
    raise TypeError(msg)