Skip to content

sartoriuslib.transport

The Transport Protocol, SerialTransport for hardware, and FakeTransport for tests. See Design §8.1.

Public surface

sartoriuslib.transport

Transport layer — moves bytes only. No Sartorius command semantics.

See design doc §8.

FakeTransport

FakeTransport(
    script=None, *, label="fake://test", latency_s=0.0
)

Scripted :class:Transport for tests.

Parameters:

Name Type Description Default
script Mapping[bytes, ScriptedReply] | None

Mapping of write_bytes → reply. Every scripted write queues the corresponding reply into the read buffer. Unknown writes are recorded but produce no reply — subsequent reads will then hit a timeout, which is the intended failure mode (tests see a real timeout if they forgot to script a command).

None
label str

Identifier used in errors.

'fake://test'
latency_s float

Per-operation artificial delay, useful for simulating a slow device.

0.0
Source code in src/sartoriuslib/transport/fake.py
def __init__(
    self,
    script: Mapping[bytes, ScriptedReply] | None = None,
    *,
    label: str = "fake://test",
    latency_s: float = 0.0,
) -> None:
    self._script: dict[bytes, ScriptedReply] = dict(script or {})
    self._writes: list[bytes] = []
    self._read_buffer = bytearray()
    self._is_open = False
    self._label = label
    self._latency_s = latency_s
    self._force_read_timeout = False
    self._force_write_timeout = False
    # Track calls to ``reopen`` so protocol-flip tests can assert the
    # transport observed the reconfiguration. ``None`` until
    # :meth:`reopen` is called; then holds the last requested value
    # for each setting. ``reopen_count`` lets tests distinguish
    # "never called" from "called with the same settings".
    self._last_reopen_baud: int | None = None
    self._last_reopen_parity: Parity | None = None
    self._last_reopen_stopbits: StopBits | None = None
    self._reopen_count: int = 0
    self._force_reopen_error: bool = False

last_reopen_baud property

last_reopen_baud

Baud rate requested by the most recent :meth:reopen, or None.

last_reopen_parity property

last_reopen_parity

Parity requested by the most recent :meth:reopen, or None.

last_reopen_stopbits property

last_reopen_stopbits

Stop bits requested by the most recent :meth:reopen, or None.

reopen_count property

reopen_count

Number of :meth:reopen calls since construction.

writes property

writes

Every write payload recorded since construction, in order.

add_script

add_script(command, reply)

Register or overwrite a scripted reply for command.

Source code in src/sartoriuslib/transport/fake.py
def add_script(self, command: bytes, reply: ScriptedReply) -> None:
    """Register or overwrite a scripted reply for ``command``."""
    self._script[bytes(command)] = reply

feed

feed(data)

Push unsolicited bytes into the read buffer.

Useful for simulating a device that was left in SBI autoprint mode, or garbage on the line that the session must drain on recovery.

Source code in src/sartoriuslib/transport/fake.py
def feed(self, data: bytes) -> None:
    """Push unsolicited bytes into the read buffer.

    Useful for simulating a device that was left in SBI autoprint
    mode, or garbage on the line that the session must drain on
    recovery.
    """
    self._read_buffer.extend(data)

force_read_timeout

force_read_timeout(enabled=True)

Force the next read to raise SartoriusTimeoutError.

Source code in src/sartoriuslib/transport/fake.py
def force_read_timeout(self, enabled: bool = True) -> None:
    """Force the next read to raise ``SartoriusTimeoutError``."""
    self._force_read_timeout = enabled

force_reopen_error

force_reopen_error(enabled=True)

Force the next :meth:reopen to raise SartoriusConnectionError.

Source code in src/sartoriuslib/transport/fake.py
def force_reopen_error(self, enabled: bool = True) -> None:
    """Force the next :meth:`reopen` to raise ``SartoriusConnectionError``."""
    self._force_reopen_error = enabled

force_write_timeout

force_write_timeout(enabled=True)

Force the next :meth:write to raise SartoriusTimeoutError.

Source code in src/sartoriuslib/transport/fake.py
def force_write_timeout(self, enabled: bool = True) -> None:
    """Force the next :meth:`write` to raise ``SartoriusTimeoutError``."""
    self._force_write_timeout = enabled

reopen async

reopen(*, baudrate=None, parity=None, stopbits=None)

Simulate a serial-setting change — close, record, reopen.

If force_reopen_error() has been called the reopen raises :class:SartoriusConnectionError after the close, leaving the transport closed. That is the "reopen wedged" path used by :meth:Balance.configure_protocol tests for the BROKEN-state transition.

Source code in src/sartoriuslib/transport/fake.py
async def reopen(
    self,
    *,
    baudrate: int | None = None,
    parity: Parity | None = None,
    stopbits: StopBits | None = None,
) -> None:
    """Simulate a serial-setting change — close, record, reopen.

    If ``force_reopen_error()`` has been called the reopen raises
    :class:`SartoriusConnectionError` after the close, leaving the
    transport closed. That is the "reopen wedged" path used by
    :meth:`Balance.configure_protocol` tests for the BROKEN-state
    transition.
    """
    await self.close()
    self._reopen_count += 1
    if baudrate is not None:
        self._last_reopen_baud = baudrate
    if parity is not None:
        self._last_reopen_parity = parity
    if stopbits is not None:
        self._last_reopen_stopbits = stopbits
    if self._force_reopen_error:
        raise SartoriusConnectionError(
            f"forced reopen error on {self._label}",
            context=ErrorContext(port=self._label),
        )
    await self.open()

SerialSettings dataclass

SerialSettings(
    port,
    baudrate=9600,
    bytesize=ByteSize.EIGHT,
    parity=Parity.ODD,
    stopbits=StopBits.ONE,
    rtscts=False,
    xonxoff=False,
    exclusive=True,
)

Serial-port configuration for :class:SerialTransport.

Mirrors :class:anyserial.SerialConfig plus a port path. Default framing is 8-O-1 because that is universal across every Sartorius family we have captures for (MSE, WZA, BCE) per docs/protocol.md §2.1. Baud defaults to 9600 because it matches the BCE default and sits in the middle of the supported range; the MSE uses 19200 and the WZA 1200, so callers that care will supply the right value or rely on :func:sartoriuslib.open_device auto-detection. Note: all three families ship from the factory in SBI mode (WZA in SBI autoprint at 1200-7-O-1); xBPI is reached by a front-panel menu change.

exclusive defaults True so two processes can't scribble over the same device — neither xBPI nor SBI is multi-master tolerant.

The bytesize / parity / stopbits fields are typed as the enums and that is what callers should statically pass. Runtime accepts an equivalent int/str shorthand (e.g. stopbits=1, parity="odd") — __post_init__ normalises the value to the enum and raises :class:SartoriusConfigurationError on anything the coercer can't recognise. The static types stay strict so type-checkers point bad callers at the enum form, while the runtime stays forgiving for ad-hoc scripts and front-panel-derived values (os.environ["SBI_PARITY"]"odd"). The widened constructor types are exposed only via the # type: ignore lines in :meth:__post_init__ — :class:SerialTransport and :mod:anyserial below this layer can always rely on the field being an enum member.

SerialTransport

SerialTransport(settings)

:class:Transport backed by a real serial port via anyserial.

Tests that don't need hardware can use :class:sartoriuslib.transport.fake.FakeTransport instead; the two conform to the same structural :class:Transport protocol.

Source code in src/sartoriuslib/transport/serial.py
def __init__(self, settings: SerialSettings) -> None:
    self._settings = settings
    self._port: SerialPort | None = None
    # Bytes read past a separator in :meth:`read_until` (or past ``n``
    # in :meth:`read_exact`) are held here so the next call sees them
    # first. Serial I/O is chunk-oriented — we can't hand the kernel
    # "give me up to separator" or "give me exactly n" without this.
    self._pushback = bytearray()

reopen async

reopen(*, baudrate=None, parity=None, stopbits=None)

Close and reopen the port with any subset of overrides.

Any argument left as None keeps the existing setting. Used for the WZA SBI→xBPI flip (swaps both baud and parity) and for set_baud_rate retuning after the device has already switched. The cached :class:SerialSettings is updated so the new settings survive subsequent close / open round-trips.

If :meth:open fails on the new settings the transport is left closed; the caller is responsible for surfacing that as a BROKEN session state with recovery guidance.

Source code in src/sartoriuslib/transport/serial.py
async def reopen(
    self,
    *,
    baudrate: int | None = None,
    parity: Parity | None = None,
    stopbits: StopBits | None = None,
) -> None:
    """Close and reopen the port with any subset of overrides.

    Any argument left as ``None`` keeps the existing setting. Used for
    the WZA SBI→xBPI flip (swaps both baud and parity) and for
    ``set_baud_rate`` retuning after the device has already switched.
    The cached :class:`SerialSettings` is updated so the new settings
    survive subsequent close / open round-trips.

    If :meth:`open` fails on the new settings the transport is left
    closed; the caller is responsible for surfacing that as a
    ``BROKEN`` session state with recovery guidance.
    """
    await self.close()
    new_settings = self._settings
    if baudrate is not None:
        new_settings = replace(new_settings, baudrate=baudrate)
    if parity is not None:
        new_settings = replace(new_settings, parity=parity)
    if stopbits is not None:
        new_settings = replace(new_settings, stopbits=stopbits)
    self._settings = new_settings
    await self.open()

Transport

Bases: Protocol

Byte-level transport.

Every I/O boundary takes an explicit timeout. On expiry, implementations raise :class:sartoriuslib.errors.SartoriusTimeoutError — never return an empty or partial bytes silently. Backend exceptions normalize to :class:sartoriuslib.errors.SartoriusTransportError (or a subclass) with __cause__ preserving the original exception.

Lifecycle is single-shot: :meth:open once, :meth:close once. :meth:reopen closes + reopens with any subset of serial-setting overrides, used for the WZA SBI→xBPI protocol flip that also swaps baud and parity.

is_open property

is_open

Whether :meth:open has run without a matching :meth:close.

label property

label

Short identifier (port path, URL, "fake://...") used in errors.

close async

close()

Close the underlying port. Safe to call when already closed.

Source code in src/sartoriuslib/transport/base.py
async def close(self) -> None:
    """Close the underlying port. Safe to call when already closed."""
    ...

drain_input async

drain_input()

Discard any buffered input bytes. Best-effort; never raises.

Source code in src/sartoriuslib/transport/base.py
async def drain_input(self) -> None:
    """Discard any buffered input bytes. Best-effort; never raises."""
    ...

open async

open()

Open the underlying port. Idempotent re-calls are an error.

Source code in src/sartoriuslib/transport/base.py
async def open(self) -> None:
    """Open the underlying port. Idempotent re-calls are an error."""
    ...

read_available async

read_available(*, idle_timeout, max_bytes=None)

Read until the line goes idle for idle_timeout seconds.

Never raises on idle expiry — an idle timeout is the expected exit. Returns whatever was accumulated (possibly empty). Used for best-effort drain, passive SBI autoprint sniffing during protocol detection, and stream-stop recovery.

Source code in src/sartoriuslib/transport/base.py
async def read_available(
    self,
    *,
    idle_timeout: float,
    max_bytes: int | None = None,
) -> bytes:
    """Read until the line goes idle for ``idle_timeout`` seconds.

    Never raises on idle expiry — an idle timeout is the *expected*
    exit. Returns whatever was accumulated (possibly empty). Used
    for best-effort drain, passive SBI autoprint sniffing during
    protocol detection, and stream-stop recovery.
    """
    ...

read_exact async

read_exact(n, *, timeout)

Read exactly n bytes.

The canonical shape for xBPI framing: read one byte to discover the length, then read that many bytes. Raises :class:sartoriuslib.errors.SartoriusTimeoutError if fewer than n bytes arrive before timeout. Partial buffers are retained for the next call — implementations must not discard them.

Source code in src/sartoriuslib/transport/base.py
async def read_exact(self, n: int, *, timeout: float) -> bytes:
    """Read exactly ``n`` bytes.

    The canonical shape for xBPI framing: read one byte to discover
    the length, then read that many bytes. Raises
    :class:`sartoriuslib.errors.SartoriusTimeoutError` if fewer than
    ``n`` bytes arrive before ``timeout``. Partial buffers are retained
    for the next call — implementations must not discard them.
    """
    ...

read_until async

read_until(separator, *, timeout)

Read bytes up to and including the next occurrence of separator.

Raises :class:sartoriuslib.errors.SartoriusTimeoutError if the separator does not arrive before timeout. Bytes received after the separator remain buffered for the next call — implementations must not discard them.

Source code in src/sartoriuslib/transport/base.py
async def read_until(self, separator: bytes, *, timeout: float) -> bytes:
    """Read bytes up to and including the next occurrence of ``separator``.

    Raises :class:`sartoriuslib.errors.SartoriusTimeoutError` if the
    separator does not arrive before ``timeout``. Bytes received
    after the separator remain buffered for the next call —
    implementations must not discard them.
    """
    ...

reopen async

reopen(*, baudrate=None, parity=None, stopbits=None)

Close and re-open the port, optionally changing serial framing.

Used by :meth:Balance.configure_protocol for the WZA SBI→xBPI flip, which swaps both baud rate and parity, and by operations like set_baud_rate that retune the port after the device has already switched mid-sequence. Any argument left as None keeps the existing value.

Implementations must leave the transport in a consistent state: either fully reopened with the new settings, or clearly closed so callers can recognise a failure. Silent partial states are the worst failure mode for this method.

Non-serial transports (e.g. a future TCP adapter) may raise :class:NotImplementedError — baud/parity don't apply there.

Source code in src/sartoriuslib/transport/base.py
async def reopen(
    self,
    *,
    baudrate: int | None = None,
    parity: Parity | None = None,
    stopbits: StopBits | None = None,
) -> None:
    """Close and re-open the port, optionally changing serial framing.

    Used by :meth:`Balance.configure_protocol` for the WZA SBI→xBPI
    flip, which swaps both baud rate and parity, and by operations
    like ``set_baud_rate`` that retune the port after the device has
    already switched mid-sequence. Any argument left as ``None`` keeps
    the existing value.

    Implementations must leave the transport in a consistent state:
    either fully reopened with the new settings, or clearly closed so
    callers can recognise a failure. Silent partial states are the
    worst failure mode for this method.

    Non-serial transports (e.g. a future TCP adapter) may raise
    :class:`NotImplementedError` — baud/parity don't apply there.
    """
    ...

write async

write(data, *, timeout)

Write every byte of data.

Raises :class:sartoriuslib.errors.SartoriusTimeoutError on expiry. A bounded write timeout is mandatory because sends can block on RS-485 hardware flow control or a stuck device. Callers that block indefinitely hide real hangs.

Source code in src/sartoriuslib/transport/base.py
async def write(self, data: bytes, *, timeout: float) -> None:
    """Write every byte of ``data``.

    Raises :class:`sartoriuslib.errors.SartoriusTimeoutError` on
    expiry. A bounded write timeout is mandatory because sends can
    block on RS-485 hardware flow control or a stuck device. Callers
    that block indefinitely hide real hangs.
    """
    ...

Base Protocol + serial settings

sartoriuslib.transport.base

Transport layer abstraction — moves bytes, knows nothing about Sartorius.

The :class:Transport :pep:544 Protocol is the structural interface every backend implements. :class:SerialSettings is the port-configuration dataclass consumed by :class:sartoriuslib.transport.serial.SerialTransport.

Sartorius balances speak two wire protocols: xBPI (binary, length-prefixed) and SBI (ASCII, line-oriented). The transport surface exposes both shapes:

  • :meth:Transport.read_exact — fixed-count read for xBPI length-prefix framing (read 1 byte for len, then read len more bytes).
  • :meth:Transport.read_until — delimiter read for SBI's \r\n-terminated lines.
  • :meth:Transport.read_available — idle-bounded read for passive SBI autoprint sniffing during :func:sartoriuslib.open_device auto-detection.

Default serial framing is 8-O-1. Per docs/protocol.md §2.1 the balance's PC-USB receive path is parity-forgiving, but 8-O-1 is universal on TX so the default is the safe one.

Design reference: docs/design.md §8.1.

SerialSettings dataclass

SerialSettings(
    port,
    baudrate=9600,
    bytesize=ByteSize.EIGHT,
    parity=Parity.ODD,
    stopbits=StopBits.ONE,
    rtscts=False,
    xonxoff=False,
    exclusive=True,
)

Serial-port configuration for :class:SerialTransport.

Mirrors :class:anyserial.SerialConfig plus a port path. Default framing is 8-O-1 because that is universal across every Sartorius family we have captures for (MSE, WZA, BCE) per docs/protocol.md §2.1. Baud defaults to 9600 because it matches the BCE default and sits in the middle of the supported range; the MSE uses 19200 and the WZA 1200, so callers that care will supply the right value or rely on :func:sartoriuslib.open_device auto-detection. Note: all three families ship from the factory in SBI mode (WZA in SBI autoprint at 1200-7-O-1); xBPI is reached by a front-panel menu change.

exclusive defaults True so two processes can't scribble over the same device — neither xBPI nor SBI is multi-master tolerant.

The bytesize / parity / stopbits fields are typed as the enums and that is what callers should statically pass. Runtime accepts an equivalent int/str shorthand (e.g. stopbits=1, parity="odd") — __post_init__ normalises the value to the enum and raises :class:SartoriusConfigurationError on anything the coercer can't recognise. The static types stay strict so type-checkers point bad callers at the enum form, while the runtime stays forgiving for ad-hoc scripts and front-panel-derived values (os.environ["SBI_PARITY"]"odd"). The widened constructor types are exposed only via the # type: ignore lines in :meth:__post_init__ — :class:SerialTransport and :mod:anyserial below this layer can always rely on the field being an enum member.

Transport

Bases: Protocol

Byte-level transport.

Every I/O boundary takes an explicit timeout. On expiry, implementations raise :class:sartoriuslib.errors.SartoriusTimeoutError — never return an empty or partial bytes silently. Backend exceptions normalize to :class:sartoriuslib.errors.SartoriusTransportError (or a subclass) with __cause__ preserving the original exception.

Lifecycle is single-shot: :meth:open once, :meth:close once. :meth:reopen closes + reopens with any subset of serial-setting overrides, used for the WZA SBI→xBPI protocol flip that also swaps baud and parity.

is_open property

is_open

Whether :meth:open has run without a matching :meth:close.

label property

label

Short identifier (port path, URL, "fake://...") used in errors.

close async

close()

Close the underlying port. Safe to call when already closed.

Source code in src/sartoriuslib/transport/base.py
async def close(self) -> None:
    """Close the underlying port. Safe to call when already closed."""
    ...

drain_input async

drain_input()

Discard any buffered input bytes. Best-effort; never raises.

Source code in src/sartoriuslib/transport/base.py
async def drain_input(self) -> None:
    """Discard any buffered input bytes. Best-effort; never raises."""
    ...

open async

open()

Open the underlying port. Idempotent re-calls are an error.

Source code in src/sartoriuslib/transport/base.py
async def open(self) -> None:
    """Open the underlying port. Idempotent re-calls are an error."""
    ...

read_available async

read_available(*, idle_timeout, max_bytes=None)

Read until the line goes idle for idle_timeout seconds.

Never raises on idle expiry — an idle timeout is the expected exit. Returns whatever was accumulated (possibly empty). Used for best-effort drain, passive SBI autoprint sniffing during protocol detection, and stream-stop recovery.

Source code in src/sartoriuslib/transport/base.py
async def read_available(
    self,
    *,
    idle_timeout: float,
    max_bytes: int | None = None,
) -> bytes:
    """Read until the line goes idle for ``idle_timeout`` seconds.

    Never raises on idle expiry — an idle timeout is the *expected*
    exit. Returns whatever was accumulated (possibly empty). Used
    for best-effort drain, passive SBI autoprint sniffing during
    protocol detection, and stream-stop recovery.
    """
    ...

read_exact async

read_exact(n, *, timeout)

Read exactly n bytes.

The canonical shape for xBPI framing: read one byte to discover the length, then read that many bytes. Raises :class:sartoriuslib.errors.SartoriusTimeoutError if fewer than n bytes arrive before timeout. Partial buffers are retained for the next call — implementations must not discard them.

Source code in src/sartoriuslib/transport/base.py
async def read_exact(self, n: int, *, timeout: float) -> bytes:
    """Read exactly ``n`` bytes.

    The canonical shape for xBPI framing: read one byte to discover
    the length, then read that many bytes. Raises
    :class:`sartoriuslib.errors.SartoriusTimeoutError` if fewer than
    ``n`` bytes arrive before ``timeout``. Partial buffers are retained
    for the next call — implementations must not discard them.
    """
    ...

read_until async

read_until(separator, *, timeout)

Read bytes up to and including the next occurrence of separator.

Raises :class:sartoriuslib.errors.SartoriusTimeoutError if the separator does not arrive before timeout. Bytes received after the separator remain buffered for the next call — implementations must not discard them.

Source code in src/sartoriuslib/transport/base.py
async def read_until(self, separator: bytes, *, timeout: float) -> bytes:
    """Read bytes up to and including the next occurrence of ``separator``.

    Raises :class:`sartoriuslib.errors.SartoriusTimeoutError` if the
    separator does not arrive before ``timeout``. Bytes received
    after the separator remain buffered for the next call —
    implementations must not discard them.
    """
    ...

reopen async

reopen(*, baudrate=None, parity=None, stopbits=None)

Close and re-open the port, optionally changing serial framing.

Used by :meth:Balance.configure_protocol for the WZA SBI→xBPI flip, which swaps both baud rate and parity, and by operations like set_baud_rate that retune the port after the device has already switched mid-sequence. Any argument left as None keeps the existing value.

Implementations must leave the transport in a consistent state: either fully reopened with the new settings, or clearly closed so callers can recognise a failure. Silent partial states are the worst failure mode for this method.

Non-serial transports (e.g. a future TCP adapter) may raise :class:NotImplementedError — baud/parity don't apply there.

Source code in src/sartoriuslib/transport/base.py
async def reopen(
    self,
    *,
    baudrate: int | None = None,
    parity: Parity | None = None,
    stopbits: StopBits | None = None,
) -> None:
    """Close and re-open the port, optionally changing serial framing.

    Used by :meth:`Balance.configure_protocol` for the WZA SBI→xBPI
    flip, which swaps both baud rate and parity, and by operations
    like ``set_baud_rate`` that retune the port after the device has
    already switched mid-sequence. Any argument left as ``None`` keeps
    the existing value.

    Implementations must leave the transport in a consistent state:
    either fully reopened with the new settings, or clearly closed so
    callers can recognise a failure. Silent partial states are the
    worst failure mode for this method.

    Non-serial transports (e.g. a future TCP adapter) may raise
    :class:`NotImplementedError` — baud/parity don't apply there.
    """
    ...

write async

write(data, *, timeout)

Write every byte of data.

Raises :class:sartoriuslib.errors.SartoriusTimeoutError on expiry. A bounded write timeout is mandatory because sends can block on RS-485 hardware flow control or a stuck device. Callers that block indefinitely hide real hangs.

Source code in src/sartoriuslib/transport/base.py
async def write(self, data: bytes, *, timeout: float) -> None:
    """Write every byte of ``data``.

    Raises :class:`sartoriuslib.errors.SartoriusTimeoutError` on
    expiry. A bounded write timeout is mandatory because sends can
    block on RS-485 hardware flow control or a stuck device. Callers
    that block indefinitely hide real hangs.
    """
    ...

Serial transport

sartoriuslib.transport.serial

Serial-port transport backed by :mod:anyserial.

:class:SerialTransport wraps :class:anyserial.SerialPort. Every I/O call is bounded by :func:anyio.fail_after (reads, writes) or :func:anyio.move_on_after (idle-timeout reads). Backend exceptions normalize to :mod:sartoriuslib.errors types with __cause__ preserved.

Design reference: docs/design.md §8.1.

SerialTransport

SerialTransport(settings)

:class:Transport backed by a real serial port via anyserial.

Tests that don't need hardware can use :class:sartoriuslib.transport.fake.FakeTransport instead; the two conform to the same structural :class:Transport protocol.

Source code in src/sartoriuslib/transport/serial.py
def __init__(self, settings: SerialSettings) -> None:
    self._settings = settings
    self._port: SerialPort | None = None
    # Bytes read past a separator in :meth:`read_until` (or past ``n``
    # in :meth:`read_exact`) are held here so the next call sees them
    # first. Serial I/O is chunk-oriented — we can't hand the kernel
    # "give me up to separator" or "give me exactly n" without this.
    self._pushback = bytearray()

reopen async

reopen(*, baudrate=None, parity=None, stopbits=None)

Close and reopen the port with any subset of overrides.

Any argument left as None keeps the existing setting. Used for the WZA SBI→xBPI flip (swaps both baud and parity) and for set_baud_rate retuning after the device has already switched. The cached :class:SerialSettings is updated so the new settings survive subsequent close / open round-trips.

If :meth:open fails on the new settings the transport is left closed; the caller is responsible for surfacing that as a BROKEN session state with recovery guidance.

Source code in src/sartoriuslib/transport/serial.py
async def reopen(
    self,
    *,
    baudrate: int | None = None,
    parity: Parity | None = None,
    stopbits: StopBits | None = None,
) -> None:
    """Close and reopen the port with any subset of overrides.

    Any argument left as ``None`` keeps the existing setting. Used for
    the WZA SBI→xBPI flip (swaps both baud and parity) and for
    ``set_baud_rate`` retuning after the device has already switched.
    The cached :class:`SerialSettings` is updated so the new settings
    survive subsequent close / open round-trips.

    If :meth:`open` fails on the new settings the transport is left
    closed; the caller is responsible for surfacing that as a
    ``BROKEN`` session state with recovery guidance.
    """
    await self.close()
    new_settings = self._settings
    if baudrate is not None:
        new_settings = replace(new_settings, baudrate=baudrate)
    if parity is not None:
        new_settings = replace(new_settings, parity=parity)
    if stopbits is not None:
        new_settings = replace(new_settings, stopbits=stopbits)
    self._settings = new_settings
    await self.open()

Fake transport

sartoriuslib.transport.fake

In-process fake transport for tests.

:class:FakeTransport implements the :class:Transport Protocol without touching a serial port. Tests script the expected write→response mapping and assert the recorded command bytes.

Re-exported from :mod:sartoriuslib.testing alongside fixture-parsing helpers.

Design reference: docs/design.md §8.2.

FakeTransport

FakeTransport(
    script=None, *, label="fake://test", latency_s=0.0
)

Scripted :class:Transport for tests.

Parameters:

Name Type Description Default
script Mapping[bytes, ScriptedReply] | None

Mapping of write_bytes → reply. Every scripted write queues the corresponding reply into the read buffer. Unknown writes are recorded but produce no reply — subsequent reads will then hit a timeout, which is the intended failure mode (tests see a real timeout if they forgot to script a command).

None
label str

Identifier used in errors.

'fake://test'
latency_s float

Per-operation artificial delay, useful for simulating a slow device.

0.0
Source code in src/sartoriuslib/transport/fake.py
def __init__(
    self,
    script: Mapping[bytes, ScriptedReply] | None = None,
    *,
    label: str = "fake://test",
    latency_s: float = 0.0,
) -> None:
    self._script: dict[bytes, ScriptedReply] = dict(script or {})
    self._writes: list[bytes] = []
    self._read_buffer = bytearray()
    self._is_open = False
    self._label = label
    self._latency_s = latency_s
    self._force_read_timeout = False
    self._force_write_timeout = False
    # Track calls to ``reopen`` so protocol-flip tests can assert the
    # transport observed the reconfiguration. ``None`` until
    # :meth:`reopen` is called; then holds the last requested value
    # for each setting. ``reopen_count`` lets tests distinguish
    # "never called" from "called with the same settings".
    self._last_reopen_baud: int | None = None
    self._last_reopen_parity: Parity | None = None
    self._last_reopen_stopbits: StopBits | None = None
    self._reopen_count: int = 0
    self._force_reopen_error: bool = False

last_reopen_baud property

last_reopen_baud

Baud rate requested by the most recent :meth:reopen, or None.

last_reopen_parity property

last_reopen_parity

Parity requested by the most recent :meth:reopen, or None.

last_reopen_stopbits property

last_reopen_stopbits

Stop bits requested by the most recent :meth:reopen, or None.

reopen_count property

reopen_count

Number of :meth:reopen calls since construction.

writes property

writes

Every write payload recorded since construction, in order.

add_script

add_script(command, reply)

Register or overwrite a scripted reply for command.

Source code in src/sartoriuslib/transport/fake.py
def add_script(self, command: bytes, reply: ScriptedReply) -> None:
    """Register or overwrite a scripted reply for ``command``."""
    self._script[bytes(command)] = reply

feed

feed(data)

Push unsolicited bytes into the read buffer.

Useful for simulating a device that was left in SBI autoprint mode, or garbage on the line that the session must drain on recovery.

Source code in src/sartoriuslib/transport/fake.py
def feed(self, data: bytes) -> None:
    """Push unsolicited bytes into the read buffer.

    Useful for simulating a device that was left in SBI autoprint
    mode, or garbage on the line that the session must drain on
    recovery.
    """
    self._read_buffer.extend(data)

force_read_timeout

force_read_timeout(enabled=True)

Force the next read to raise SartoriusTimeoutError.

Source code in src/sartoriuslib/transport/fake.py
def force_read_timeout(self, enabled: bool = True) -> None:
    """Force the next read to raise ``SartoriusTimeoutError``."""
    self._force_read_timeout = enabled

force_reopen_error

force_reopen_error(enabled=True)

Force the next :meth:reopen to raise SartoriusConnectionError.

Source code in src/sartoriuslib/transport/fake.py
def force_reopen_error(self, enabled: bool = True) -> None:
    """Force the next :meth:`reopen` to raise ``SartoriusConnectionError``."""
    self._force_reopen_error = enabled

force_write_timeout

force_write_timeout(enabled=True)

Force the next :meth:write to raise SartoriusTimeoutError.

Source code in src/sartoriuslib/transport/fake.py
def force_write_timeout(self, enabled: bool = True) -> None:
    """Force the next :meth:`write` to raise ``SartoriusTimeoutError``."""
    self._force_write_timeout = enabled

reopen async

reopen(*, baudrate=None, parity=None, stopbits=None)

Simulate a serial-setting change — close, record, reopen.

If force_reopen_error() has been called the reopen raises :class:SartoriusConnectionError after the close, leaving the transport closed. That is the "reopen wedged" path used by :meth:Balance.configure_protocol tests for the BROKEN-state transition.

Source code in src/sartoriuslib/transport/fake.py
async def reopen(
    self,
    *,
    baudrate: int | None = None,
    parity: Parity | None = None,
    stopbits: StopBits | None = None,
) -> None:
    """Simulate a serial-setting change — close, record, reopen.

    If ``force_reopen_error()`` has been called the reopen raises
    :class:`SartoriusConnectionError` after the close, leaving the
    transport closed. That is the "reopen wedged" path used by
    :meth:`Balance.configure_protocol` tests for the BROKEN-state
    transition.
    """
    await self.close()
    self._reopen_count += 1
    if baudrate is not None:
        self._last_reopen_baud = baudrate
    if parity is not None:
        self._last_reopen_parity = parity
    if stopbits is not None:
        self._last_reopen_stopbits = stopbits
    if self._force_reopen_error:
        raise SartoriusConnectionError(
            f"forced reopen error on {self._label}",
            context=ErrorContext(port=self._label),
        )
    await self.open()