Skip to content

sartoriuslib.devices

Device-layer reference: direct package re-exports (Session, BalanceFamily, Capability, SafetyTier, …) plus the Balance facade, public dataclasses (Reading, BalanceStatus, DeviceInfo, …), factory helpers, and discovery helpers by submodule. See Balances, Readings, and Design §5–§7.

Public surface

sartoriuslib.devices

Device-layer package exports.

The subpackage root keeps a deliberately small re-export set for capability and session primitives. The :class:Balance facade, factory helpers, models, and discovery helpers live in their own submodules and are re-exported from top-level :mod:sartoriuslib.

Availability

Bases: StrEnum

Derived state the session consults when dispatching a command.

See design doc §5.1, §6.1.1.

INAPPLICABLE class-attribute instance-attribute

INAPPLICABLE = 'inapplicable'

Device responded with xBPI 0x06. Retryable; state-dependent.

SUPPORTED class-attribute instance-attribute

SUPPORTED = 'supported'

Directly confirmed by a successful call or probe.

UNKNOWN class-attribute instance-attribute

UNKNOWN = 'unknown'

Never exercised; priors may exist but no device observation yet.

UNSUPPORTED class-attribute instance-attribute

UNSUPPORTED = 'unsupported'

Device responded with xBPI 0x04 / equivalent SBI refusal. Sticky per session.

BalanceFamily

Bases: StrEnum

Classification from the model string returned by xBPI 0x02 or SBI identify.

  • :attr:CUBIS — MSE and related Cubis strings; full xBPI plus Cubis extensions.
  • :attr:OEM_WEIGH_CELL — WZ/WZA; ships from the factory in SBI autoprint (1200-7-O-1) and requires a front-panel menu change to switch to xBPI. (MSE and BCE also ship in SBI by default — switching to xBPI is a front-panel menu change on every family.)
  • :attr:BASIC_LAB — BCE*; MSE opcode subset, no Cubis extensions.
  • :attr:UNKNOWN — anything we have not classified; every call becomes a live probe.

Capability

Bases: Flag

Feature capabilities derived from family defaults + live probing.

Flag bitmap carries capabilities currently believed SUPPORTED. Full tri/quad-state per capability lives in DeviceInfo.probe_report.

ProbeSource

Bases: StrEnum

Where an :class:Availability value came from.

FAMILY_TABLE class-attribute instance-attribute

FAMILY_TABLE = 'family_table'

Seeded prior from our captures.

LIVE_CALL class-attribute instance-attribute

LIVE_CALL = 'live_call'

Updated by the device's response to a normal command.

TARGETED_PROBE class-attribute instance-attribute

TARGETED_PROBE = 'targeted_probe'

Explicit probe during identify() / discovery.

USER_OVERRIDE class-attribute instance-attribute

USER_OVERRIDE = 'user_override'

Set explicitly by the caller.

SafetyTier

Bases: IntEnum

Per-command safety tier. See design doc §6.1.

DANGEROUS class-attribute instance-attribute

DANGEROUS = 3

Baud/SBN change, reset, calibration init, protocol switch. Requires confirm=True.

PERSISTENT class-attribute instance-attribute

PERSISTENT = 2

Parameter writes, save menu, communication settings. Requires confirm=True.

READ_ONLY class-attribute instance-attribute

READ_ONLY = 0

Weight, status, identity, capacity, increment, temperature, parameter reads.

STATEFUL class-attribute instance-attribute

STATEFUL = 1

Transient state change (tare, zero). No EEPROM write.

Session

Session(
    *,
    xbpi_client=None,
    sbi_client=None,
    active_protocol,
    family=BalanceFamily.UNKNOWN,
    capabilities=NO_CAPABILITY,
    firmware=None,
    src_sbn=1,
    dst_sbn=9,
    strict=False,
    default_timeout=1.0,
    serial_settings=None,
)

One balance, one serial port. Enforces gates, serialises I/O.

Parameters:

Name Type Description Default
xbpi_client XbpiProtocolClient | None

Client for xBPI dispatch, or None if this session is SBI-only.

None
active_protocol ProtocolKind

Which protocol this session currently speaks.

required
family BalanceFamily

Balance family discriminator (from DeviceInfo once identified; UNKNOWN means no prior).

UNKNOWN
capabilities Capability

Bitmap of capabilities believed present.

NO_CAPABILITY
firmware FirmwareVersion | None

Firmware version, if known.

None
src_sbn int

Host SBN address for xBPI frames.

1
dst_sbn int

Balance SBN address for xBPI frames.

9
strict bool

If True, family/capability prior mismatches refuse pre-I/O instead of emitting a warning.

False
default_timeout float

Per-call timeout when the caller passes None to :meth:execute.

1.0
Source code in src/sartoriuslib/devices/session.py
def __init__(
    self,
    *,
    xbpi_client: XbpiProtocolClient | None = None,
    sbi_client: SbiProtocolClient | None = None,
    active_protocol: ProtocolKind,
    family: BalanceFamily = BalanceFamily.UNKNOWN,
    capabilities: Capability = NO_CAPABILITY,
    firmware: FirmwareVersion | None = None,
    src_sbn: int = 0x01,
    dst_sbn: int = 0x09,
    strict: bool = False,
    default_timeout: float = 1.0,
    serial_settings: SerialSettings | None = None,
) -> None:
    if active_protocol is ProtocolKind.AUTO:
        raise SartoriusError(
            "Session cannot be constructed with ProtocolKind.AUTO; "
            "detection must resolve to XBPI or SBI first",
        )
    if active_protocol is ProtocolKind.XBPI and xbpi_client is None:
        raise SartoriusError(
            "Session: active_protocol=XBPI requires xbpi_client",
        )
    if active_protocol is ProtocolKind.SBI and sbi_client is None:
        raise SartoriusError(
            "Session: active_protocol=SBI requires sbi_client",
        )
    self._xbpi = xbpi_client
    self._sbi = sbi_client
    self._active = active_protocol
    self._family = family
    self._capabilities = capabilities
    self._firmware = firmware
    self._src_sbn = src_sbn
    self._dst_sbn = dst_sbn
    self._strict = strict
    self._default_timeout = default_timeout
    self._availability: dict[str, Availability] = {}
    self._warned_priors: set[str] = set()
    # Result cache keyed on caller-supplied cache_key → (counter_snapshot, value).
    # Populated by :meth:`cached_execute`; cleared by :meth:`invalidate_cache`
    # and on any ``0xBA`` mismatch at read time (design §6.3).
    self._result_cache: dict[str, tuple[int, Any]] = {}
    self._state: SessionState = SessionState.OPERATIONAL
    # Counter of recoverable errors the session has retried through
    # transparently. Visible to consumers via the property of the same
    # name and surfaced on :class:`DeviceSnapshot`. Incremented from
    # :func:`open_device` cold-open retries and from any in-flight
    # transient retry path the session implements.
    self._recoverable_error_count: int = 0
    # Serial settings the transport was opened with. Tracked here
    # (not on the transport) so :meth:`Balance.configure_protocol`
    # can roll back to the original framing without coupling the
    # rollback path to a specific Transport implementation.
    self._serial_settings = serial_settings

active_protocol property

active_protocol

Protocol the session currently dispatches through.

capabilities property

capabilities

Bitmap of capabilities believed present on the balance.

default_timeout property

default_timeout

Per-call timeout used when callers pass None.

dst_sbn property

dst_sbn

Balance-side SBN used as the destination in xBPI request frames.

family property

family

Family discriminator; UNKNOWN means no seeded prior.

firmware property

firmware

Firmware version if identified, else None.

recoverable_error_count property

recoverable_error_count

Number of recoverable errors swallowed-and-retried since open.

Bumped by :func:sartoriuslib.open_device's cold-open identify retry loop and by any other transparent retry path the session runs. Resets to 0 only on a fresh :class:Session. Consumers read it directly or via :class:DeviceSnapshot.recoverable_error_count.

sbi_autoprint_active property

sbi_autoprint_active

Whether the SBI session has observed unsolicited autoprint output.

sbi_client property

sbi_client

The SBI protocol client wired to this session, if any.

serial_settings property

serial_settings

Serial settings the transport was opened with, if known.

Set at construction time by :func:sartoriuslib.open_device and updated after a successful :meth:Balance.configure_protocol. None for sessions built from a pre-existing :class:Transport whose framing the library did not control.

src_sbn property

src_sbn

Host-side SBN included in xBPI request frames.

state property

state

Lifecycle state — BROKEN after a failed protocol/baud switch.

strict property

strict

Whether prior mismatches refuse pre-I/O (True) or warn.

transport property

transport

The underlying :class:Transport, regardless of active protocol.

Both protocol clients hold the same transport — return whichever is wired. The constructor enforces that the client matching active_protocol is non-None, so this always returns a real :class:Transport.

xbpi_client property

xbpi_client

The xBPI protocol client wired to this session, if any.

availability_of

availability_of(command_name)

Current availability for command_name (UNKNOWN if unseen).

Source code in src/sartoriuslib/devices/session.py
def availability_of(self, command_name: str) -> Availability:
    """Current availability for ``command_name`` (``UNKNOWN`` if unseen)."""
    return self._availability.get(command_name, Availability.UNKNOWN)

cache_snapshot

cache_snapshot()

Copy of cache_key → counter_snapshot for test assertions.

The actual cached values are intentionally not surfaced — tests assert on the presence and counter pinning of an entry, not its decoded content.

Source code in src/sartoriuslib/devices/session.py
def cache_snapshot(self) -> dict[str, int]:
    """Copy of ``cache_key → counter_snapshot`` for test assertions.

    The actual cached values are intentionally not surfaced —
    tests assert on the *presence* and *counter pinning* of an
    entry, not its decoded content.
    """
    return {key: counter for key, (counter, _value) in self._result_cache.items()}

cached_execute async

cached_execute(
    command,
    request,
    *,
    cache_key,
    confirm=False,
    timeout=None,
)

Dispatch command with a 0xBA-keyed result cache.

Sessions without :attr:Capability.CONFIG_COUNTER fall through to :meth:execute. When the capability is present, the session re-reads the config counter before returning a cached value; any change flushes the entry and the command re-runs.

cache_key is caller-supplied so a command that takes arguments (capacity(area=N), read_parameter(idx)) can cache separate entries per distinct call.

Source code in src/sartoriuslib/devices/session.py
async def cached_execute[Req, Resp](
    self,
    command: Command[Req, Resp],
    request: Req,
    *,
    cache_key: str,
    confirm: bool = False,
    timeout: float | None = None,
) -> Resp:
    """Dispatch ``command`` with a ``0xBA``-keyed result cache.

    Sessions without :attr:`Capability.CONFIG_COUNTER` fall through
    to :meth:`execute`. When the capability is present, the session
    re-reads the config counter before returning a cached value;
    any change flushes the entry and the command re-runs.

    ``cache_key`` is caller-supplied so a command that takes
    arguments (``capacity(area=N)``, ``read_parameter(idx)``) can
    cache separate entries per distinct call.
    """
    if Capability.CONFIG_COUNTER not in self._capabilities:
        return await self.execute(command, request, confirm=confirm, timeout=timeout)
    counter = await self._read_config_counter(timeout=timeout)
    cached = self._result_cache.get(cache_key)
    if cached is not None and cached[0] == counter:
        return cached[1]  # type: ignore[no-any-return]
    result = await self.execute(command, request, confirm=confirm, timeout=timeout)
    self._result_cache[cache_key] = (counter, result)
    return result

check_state

check_state()

Raise :class:SartoriusConnectionError if the session is BROKEN.

Public alias for the internal gate run by every dispatch path, so lifecycle helpers on :class:Balance can reuse the same guard without poking at private attributes.

Source code in src/sartoriuslib/devices/session.py
def check_state(self) -> None:
    """Raise :class:`SartoriusConnectionError` if the session is BROKEN.

    Public alias for the internal gate run by every dispatch path,
    so lifecycle helpers on :class:`Balance` can reuse the same
    guard without poking at private attributes.
    """
    self._check_state()

close async

close()

Close the underlying transport, if one is wired.

Idempotent — safe to call multiple times. The factory owns the transport's construction and hands it into the session via the protocol client; closing the session closes the transport.

Both clients hold the same transport, so close it once via the session's :attr:transport accessor rather than once per client slot — guards against a future caller that wires both clients simultaneously.

Source code in src/sartoriuslib/devices/session.py
async def close(self) -> None:
    """Close the underlying transport, if one is wired.

    Idempotent — safe to call multiple times. The factory owns the
    transport's construction and hands it into the session via the
    protocol client; closing the session closes the transport.

    Both clients hold the same transport, so close it once via the
    session's :attr:`transport` accessor rather than once per client
    slot — guards against a future caller that wires both clients
    simultaneously.
    """
    await self.transport.close()

execute async

execute(command, request, *, confirm=False, timeout=None)

Dispatch command with full pre-I/O gating.

Gates fire in the design doc §6.1 order; each raise happens before any byte is sent, so a gate failure is observably equivalent to the call never leaving the host.

Source code in src/sartoriuslib/devices/session.py
async def execute[Req, Resp](
    self,
    command: Command[Req, Resp],
    request: Req,
    *,
    confirm: bool = False,
    timeout: float | None = None,
) -> Resp:
    """Dispatch ``command`` with full pre-I/O gating.

    Gates fire in the design doc §6.1 order; each raise happens
    before any byte is sent, so a gate failure is observably
    equivalent to the call never leaving the host.
    """
    self._check_state()
    self._gate_safety(command, confirm)
    self._gate_protocol(command)
    self._gate_known_denied(command)
    self._gate_priors(command)
    if self._active is ProtocolKind.XBPI:
        return await self._execute_xbpi(command, request, timeout)
    if self._active is ProtocolKind.SBI:
        return await self._execute_sbi(command, request, timeout)
    raise SartoriusError(f"unreachable: session has no active protocol ({self._active!r})")

execute_raw_sbi async

execute_raw_sbi(
    command, *, confirm=False, timeout=None, expect_lines=1
)

Send an arbitrary SBI command token and return the parsed reply.

Source code in src/sartoriuslib/devices/session.py
async def execute_raw_sbi(
    self,
    command: bytes | str,
    *,
    confirm: bool = False,
    timeout: float | None = None,
    expect_lines: int = 1,
) -> SbiReply:
    """Send an arbitrary SBI command token and return the parsed reply."""
    self._check_state()
    if self._active is not ProtocolKind.SBI:
        raise SartoriusProtocolUnsupportedError(
            f"raw_sbi: session is in {self._active.value.upper()} mode",
            context=ErrorContext(protocol=str(self._active.value)),
        )
    if self._sbi is None:
        raise SartoriusError(
            "raw_sbi: session in SBI mode but no SBI client wired",
            context=ErrorContext(protocol="sbi"),
        )
    token = normalize_token(command)
    if token not in SBI_READ_ONLY_TOKENS and not confirm:
        raise SartoriusConfirmationRequiredError(
            f"raw_sbi: token {token!r} is not on the read-only safe-list; "
            "pass confirm=True to proceed",
            context=ErrorContext(
                sbi_token=token,
                protocol="sbi",
                extra={"safe_listed": False},
            ),
        )
    t = timeout if timeout is not None else self._default_timeout
    if self.sbi_autoprint_active and expect_lines > 0:
        self._raise_sbi_autoprint_active("raw_sbi", sbi_token=token)
    reply = await self._sbi.execute(
        token,
        timeout=t,
        command_name="raw_sbi",
        sbi_token=token,
        expect_lines=expect_lines,
    )
    if self._reply_is_surprise_autoprint(reply, sbi_token=token):
        self._sbi.mark_autoprint_active(pending=reply.raw)
        self._raise_sbi_autoprint_active("raw_sbi", sbi_token=token)
    return reply

execute_raw_xbpi async

execute_raw_xbpi(
    opcode, args=b"", *, confirm=False, timeout=None
)

Send an arbitrary xBPI opcode and return the raw reply frame.

Bypasses the declarative :class:Command layer — the opcode is a per-call parameter, so none of the prior / capability gating applies. The one hard gate is a safe-list check: opcodes in :data:sartoriuslib.commands.raw.SAFE_READ_ONLY_OPCODES run freely; anything else requires confirm=True because the library cannot know it is safe.

The availability cache is not updated — raw calls are opaque at the command-name level.

Source code in src/sartoriuslib/devices/session.py
async def execute_raw_xbpi(
    self,
    opcode: int,
    args: bytes = b"",
    *,
    confirm: bool = False,
    timeout: float | None = None,
) -> XbpiFrame:
    """Send an arbitrary xBPI opcode and return the raw reply frame.

    Bypasses the declarative :class:`Command` layer — the opcode is
    a per-call parameter, so none of the prior / capability gating
    applies. The one hard gate is a safe-list check: opcodes in
    :data:`sartoriuslib.commands.raw.SAFE_READ_ONLY_OPCODES` run
    freely; anything else requires ``confirm=True`` because the
    library cannot know it is safe.

    The availability cache is *not* updated — raw calls are opaque
    at the command-name level.
    """
    self._check_state()
    if self._active is not ProtocolKind.XBPI:
        raise SartoriusProtocolUnsupportedError(
            f"raw_xbpi: session is in {self._active.value.upper()} mode",
            context=ErrorContext(
                opcode=opcode,
                protocol=str(self._active.value),
            ),
        )
    if self._xbpi is None:
        raise SartoriusError(
            "raw_xbpi: session in XBPI mode but no xBPI client wired",
            context=ErrorContext(opcode=opcode, protocol="xbpi"),
        )
    if opcode not in SAFE_READ_ONLY_OPCODES and not confirm:
        raise SartoriusConfirmationRequiredError(
            f"raw_xbpi: opcode 0x{opcode:02x} is not on the read-only safe-list; "
            "pass confirm=True to proceed",
            context=ErrorContext(
                opcode=opcode,
                protocol="xbpi",
                extra={"safe_listed": False},
            ),
        )
    request_bytes = build_command(
        opcode,
        args,
        src_sbn=self._src_sbn,
        dst_sbn=self._dst_sbn,
    )
    t = timeout if timeout is not None else self._default_timeout
    return await self._xbpi.execute(
        request_bytes,
        timeout=t,
        command_name=f"raw_xbpi[0x{opcode:02x}]",
        opcode=opcode,
    )

invalidate_cache

invalidate_cache(cache_key=None)

Drop one cached entry, or clear all when cache_key is None.

The :class:Balance facade calls this after writes whose 0xBA bump isn't guaranteed (the §6.3 caveat). Unknown keys are a silent no-op — idempotent.

Source code in src/sartoriuslib/devices/session.py
def invalidate_cache(self, cache_key: str | None = None) -> None:
    """Drop one cached entry, or clear all when ``cache_key`` is ``None``.

    The :class:`Balance` facade calls this after writes whose
    ``0xBA`` bump isn't guaranteed (the §6.3 caveat). Unknown
    keys are a silent no-op — idempotent.
    """
    if cache_key is None:
        self._result_cache.clear()
    else:
        self._result_cache.pop(cache_key, None)

mark_broken

mark_broken()

Transition the session to :attr:SessionState.BROKEN.

Called only from lifecycle operations (Balance.configure_protocol) when a rollback fails. Once broken, every subsequent dispatch refuses with :class:SartoriusConnectionError.

Source code in src/sartoriuslib/devices/session.py
def mark_broken(self) -> None:
    """Transition the session to :attr:`SessionState.BROKEN`.

    Called only from lifecycle operations
    (``Balance.configure_protocol``) when a rollback fails. Once
    broken, every subsequent dispatch refuses with
    :class:`SartoriusConnectionError`.
    """
    self._state = SessionState.BROKEN

read_sbi_autoprint_reading async

read_sbi_autoprint_reading(*, timeout=None)

Read the next valid SBI autoprint weight line without writing.

Source code in src/sartoriuslib/devices/session.py
async def read_sbi_autoprint_reading(
    self,
    *,
    timeout: float | None = None,
) -> Reading:
    """Read the next valid SBI autoprint weight line without writing."""
    if self._active is not ProtocolKind.SBI:
        raise SartoriusProtocolUnsupportedError(
            f"read_sbi_autoprint_reading: session is in {self._active.value.upper()} mode",
            context=ErrorContext(protocol=str(self._active.value)),
        )
    if self._sbi is None:
        raise SartoriusError(
            "read_sbi_autoprint_reading: session in SBI mode but no SBI client wired",
            context=ErrorContext(protocol="sbi"),
        )
    t = timeout if timeout is not None else self._default_timeout
    deadline = anyio.current_time() + t
    while True:
        remaining = max(0.001, deadline - anyio.current_time())
        reply = await self._sbi.read_line(timeout=remaining)
        try:
            reading = require_reading(reply)
        except SartoriusParseError:
            if anyio.current_time() >= deadline:
                raise
            continue
        self._sbi.mark_autoprint_active()
        return reading

read_sbi_line async

read_sbi_line(*, timeout=None)

Read one unsolicited SBI line, used by autoprint streaming.

Source code in src/sartoriuslib/devices/session.py
async def read_sbi_line(self, *, timeout: float | None = None) -> SbiReply:
    """Read one unsolicited SBI line, used by autoprint streaming."""
    if self._active is not ProtocolKind.SBI:
        raise SartoriusProtocolUnsupportedError(
            f"read_sbi_line: session is in {self._active.value.upper()} mode",
            context=ErrorContext(protocol=str(self._active.value)),
        )
    if self._sbi is None:
        raise SartoriusError(
            "read_sbi_line: session in SBI mode but no SBI client wired",
            context=ErrorContext(protocol="sbi"),
        )
    t = timeout if timeout is not None else self._default_timeout
    return await self._sbi.read_line(timeout=t)

record_recoverable_error

record_recoverable_error()

Increment the recoverable-error counter.

Called from retry sites — :func:sartoriuslib.open_device's cold-open swallow loop, future inline transient retries. Public because the counter is observed by :class:DeviceSnapshot; callers should not normally mutate it.

Source code in src/sartoriuslib/devices/session.py
def record_recoverable_error(self) -> None:
    """Increment the recoverable-error counter.

    Called from retry sites — :func:`sartoriuslib.open_device`'s
    cold-open swallow loop, future inline transient retries. Public
    because the counter is observed by :class:`DeviceSnapshot`;
    callers should not normally mutate it.
    """
    self._recoverable_error_count += 1

refresh_sbi_autoprint_state async

refresh_sbi_autoprint_state(*, timeout=None)

Passively re-sniff whether SBI autoprint is currently active.

Source code in src/sartoriuslib/devices/session.py
async def refresh_sbi_autoprint_state(self, *, timeout: float | None = None) -> bool:
    """Passively re-sniff whether SBI autoprint is currently active."""
    if self._active is not ProtocolKind.SBI:
        raise SartoriusProtocolUnsupportedError(
            "refresh_sbi_autoprint_state requires an SBI session",
            context=ErrorContext(protocol=str(self._active.value)),
        )
    if self._sbi is None:
        raise SartoriusError(
            "refresh_sbi_autoprint_state: session in SBI mode but no SBI client wired",
            context=ErrorContext(protocol="sbi"),
        )
    return await self._sbi.refresh_autoprint_state(timeout=timeout)

replace_clients

replace_clients(
    *,
    xbpi_client,
    sbi_client,
    active_protocol,
    serial_settings=None,
)

Swap protocol clients atomically — used by configure_protocol.

The host-side flip closes the old protocol client, reopens the transport at new serial framing, builds a new client, and verifies. On verification success this method installs the new clients and the new active protocol; the availability cache, prior warnings, and result cache are all cleared because the command surface changes when the protocol does (xBPI-only commands have no SBI variant and vice versa, and any 0xBA-pinned cache entries belong to the old session).

Refuses to install ProtocolKind.AUTO — detection must resolve to XBPI or SBI first. Refuses if the corresponding client for active_protocol is missing.

Source code in src/sartoriuslib/devices/session.py
def replace_clients(
    self,
    *,
    xbpi_client: XbpiProtocolClient | None,
    sbi_client: SbiProtocolClient | None,
    active_protocol: ProtocolKind,
    serial_settings: SerialSettings | None = None,
) -> None:
    """Swap protocol clients atomically — used by ``configure_protocol``.

    The host-side flip closes the old protocol client, reopens the
    transport at new serial framing, builds a new client, and
    verifies. On verification success this method installs the new
    clients and the new active protocol; the availability cache,
    prior warnings, and result cache are all
    cleared because the command surface changes when the protocol
    does (xBPI-only commands have no SBI variant and vice versa,
    and any ``0xBA``-pinned cache entries belong to the old session).

    Refuses to install ``ProtocolKind.AUTO`` — detection must
    resolve to ``XBPI`` or ``SBI`` first. Refuses if the
    corresponding client for ``active_protocol`` is missing.
    """
    if active_protocol is ProtocolKind.AUTO:
        raise SartoriusError(
            "replace_clients: cannot install ProtocolKind.AUTO; "
            "detection must resolve to XBPI or SBI first",
        )
    if active_protocol is ProtocolKind.XBPI and xbpi_client is None:
        raise SartoriusError(
            "replace_clients: active_protocol=XBPI but xbpi_client is None",
        )
    if active_protocol is ProtocolKind.SBI and sbi_client is None:
        raise SartoriusError(
            "replace_clients: active_protocol=SBI but sbi_client is None",
        )
    self._xbpi = xbpi_client
    self._sbi = sbi_client
    self._active = active_protocol
    if serial_settings is not None:
        self._serial_settings = serial_settings
    # Cross-protocol availability and prior warnings do not
    # transfer — clear them so the new protocol starts clean.
    self._availability.clear()
    self._warned_priors.clear()
    self._result_cache.clear()

set_dst_sbn

set_dst_sbn(dst_sbn)

Update the destination SBN.

Used after :meth:Balance.write_sbn_address on multidrop links where the new address must address the device going forward.

Source code in src/sartoriuslib/devices/session.py
def set_dst_sbn(self, dst_sbn: int) -> None:
    """Update the destination SBN.

    Used after :meth:`Balance.write_sbn_address` on multidrop links
    where the new address must address the device going forward.
    """
    self._dst_sbn = dst_sbn

update_identity

update_identity(
    *, family=None, capabilities=None, firmware=None
)

Replace session-level identity state after a live identify call.

Called by :func:sartoriuslib.devices.factory.open_device after running the identify commands, so subsequent prior gating sees the discovered family and capabilities instead of the placeholder UNKNOWN / empty values.

Each argument left as None keeps the existing value.

Source code in src/sartoriuslib/devices/session.py
def update_identity(
    self,
    *,
    family: BalanceFamily | None = None,
    capabilities: Capability | None = None,
    firmware: FirmwareVersion | None = None,
) -> None:
    """Replace session-level identity state after a live identify call.

    Called by :func:`sartoriuslib.devices.factory.open_device` after
    running the identify commands, so subsequent prior gating sees
    the discovered family and capabilities instead of the
    placeholder ``UNKNOWN`` / empty values.

    Each argument left as ``None`` keeps the existing value.
    """
    if family is not None:
        self._family = family
    if capabilities is not None:
        self._capabilities = capabilities
    if firmware is not None:
        self._firmware = firmware

Public dataclasses

sartoriuslib.devices.models

Public frozen dataclasses returned by the :class:Balance facade.

See design doc §7. All types are immutable (frozen=True, slots=True) so they are safe to share, pass across task boundaries, and log.

Reading and BalanceStatus are protocol-neutral: the xBPI and SBI decoders both build the same shape. That is the whole point of the dual-protocol API (design §4).

BalanceState

Bases: StrEnum

High-level weighing state derived from the status block.

BalanceStatus dataclass

BalanceStatus(
    stable,
    state,
    isocal_due,
    adc_trusted,
    sequence,
    raw_state,
    raw_status,
    raw,
)

Status-block snapshot from xBPI 0x30 (or SBI equivalent).

adc_trusted and isocal_due are MSE-only signals; on WZA/BCE they decode to None. raw_state and raw_status are the untouched wire bytes (as integers for xBPI, strings for SBI where applicable) so callers can cross-check against docs/protocol.md §8.2 without re-decoding.

CalRecord dataclass

CalRecord(
    temperature_celsius, signature, counters, padding, raw
)

Last-calibration snapshot from 0xB9.

Layout per docs/protocol.md §7.12. The 17-byte RAM buffer is cleared on cold boot, so :attr:temperature_celsius can be present (the kernel maintains it separately) while :attr:signature and :attr:counters are all-zero. Callers that just want "was there a cal?" should check :attr:has_metadata.

has_metadata property

has_metadata

True if any metadata byte is non-zero.

All-zero :attr:signature + :attr:counters means the balance has never recorded a cal in the current RAM buffer (post cold boot). :attr:temperature_celsius can still be valid in that state — see §7.12's three-tier storage note.

DeviceInfo dataclass

DeviceInfo(
    manufacturer,
    model,
    serial,
    factory_number,
    software,
    firmware,
    family,
    protocol,
    capacity,
    increment,
    sbn,
    serial_settings,
    capabilities,
    probe_report=_empty_probe_report(),
    temperature_sensor_indices=None,
)

Identity snapshot produced by :meth:Balance.identify.

Populated at :func:open_device time when identify=True and cached on the :class:Balance. Most fields are None for balances we have not yet RE'd beyond the model-string classifier.

capacity and increment are populated by the metrology probe and otherwise default to None. capabilities is seeded from the family discriminator at identify time and refined as commands probe the device.

temperature_sensor_indices is populated only when a caller has explicitly run :meth:Balance.discover_temperature_sensors, which probes the device at runtime and records exactly which indices replied. None (the default) means "not yet probed" — no assumption baked in. Some firmwares expose sparse indices (the MSE1203S we tested replies at 0, 1, 3 and the 7f ff ff ff sentinel at 2), some expose contiguous, some expose none at all; the device is the source of truth.

ParameterEntry dataclass

ParameterEntry(index, current, max, raw)

One parameter-table entry from 0x55.

current and max are the two u8 TLVs returned in the reply. Callers normally route through the typed Balance.get_X() / Balance.set_X() accessors which decode current through the :class:sartoriuslib.registry.parameters.ParameterSpec table.

ProbeOutcome dataclass

ProbeOutcome(availability, source, at, detail)

One capability's current availability plus provenance.

See design §5.1: Availability is the derived state, while ProbeOutcome is the observation record that produced it.

Quantity dataclass

Quantity(value, unit)

Scalar value with its unit. Used for capacity, increment, etc.

Reading dataclass

Reading(
    value,
    unit,
    sign,
    stable,
    overload,
    underload,
    decimals,
    sequence,
    status_flags,
    protocol,
    received_at,
    monotonic_ns,
    raw,
)

One decoded weight reading.

value is None on the off-scale sentinel; the measurement body alone cannot disambiguate overload from underload, so callers that need that distinction should invoke :meth:Balance.status.

stable comes from the universal measurement-frame flag bit 0x40 (design §7 note) — more portable across MSE/WZA/BCE than the family-specific status-block state byte.

status_flags carries a bag of protocol-specific signals ("stable", "off_scale", and in long-frame reads "isocal_due" / "adc_trusted") so power users can inspect without reaching for the raw bytes.

__format__

__format__(format_spec)

Delegate format specs to :attr:value so f"{r:.4f}" works.

The empty spec falls back to :func:str (the frozen dataclass default) so f"{r}" still prints the structured repr. Off-scale readings (value is None) format as "None" for any non-empty numeric spec rather than raising — a stream of mixed valid/None readings is the common case during a tare or zero settling window and crashing in a log-line f-string would be a surprising failure mode.

Source code in src/sartoriuslib/devices/models.py
def __format__(self, format_spec: str) -> str:
    """Delegate format specs to :attr:`value` so ``f"{r:.4f}"`` works.

    The empty spec falls back to :func:`str` (the ``frozen``
    dataclass default) so ``f"{r}"`` still prints the structured
    repr. Off-scale readings (``value`` is ``None``) format as
    ``"None"`` for any non-empty numeric spec rather than raising —
    a stream of mixed valid/None readings is the common case during
    a tare or zero settling window and crashing in a log-line
    f-string would be a surprising failure mode.
    """
    if format_spec == "":
        return str(self)
    if self.value is None:
        return "None"
    return format(self.value, format_spec)

as_dict

as_dict()

Flatten the reading into a row-shaped dict for tabular sinks.

Content-only — timing provenance (received_at, monotonic_ns) lives on the surrounding :class:~sartoriuslib.streaming.sample.Sample because sample- level send/receive boundaries are the authoritative timeline (design §10). Booleans render as 0 / 1 so SQLite picks INTEGER affinity and CSV / JSONL round-trip cleanly through every stdlib reader.

Source code in src/sartoriuslib/devices/models.py
def as_dict(self) -> dict[str, float | int | str | None]:
    """Flatten the reading into a row-shaped dict for tabular sinks.

    Content-only — timing provenance (``received_at``,
    ``monotonic_ns``) lives on the surrounding
    :class:`~sartoriuslib.streaming.sample.Sample` because sample-
    level send/receive boundaries are the authoritative timeline
    (design §10). Booleans render as ``0`` / ``1`` so SQLite picks
    INTEGER affinity and CSV / JSONL round-trip cleanly through
    every stdlib reader.
    """
    return {
        "value": self.value,
        "unit": self.unit.value,
        "sign": self.sign.value,
        "stable": int(self.stable),
        "overload": int(self.overload),
        "underload": int(self.underload),
        "decimals": self.decimals,
        "sequence": self.sequence,
        "protocol": self.protocol.value,
        "raw": self.raw.hex(),
    }

TemperatureReading dataclass

TemperatureReading(sensor, celsius, raw)

One sensor's temperature read (xBPI 0x76).

:attr:celsius is None when the sensor index is not installed — the balance returns the 7f ff ff ff sentinel in that case (docs/protocol.md §9). :attr:sensor is the TLV-21 index the caller passed; :attr:raw is the 5-byte typed-float body.

Family taxonomy

sartoriuslib.devices.kind

Balance family taxonomy. See design doc §5.

BalanceFamily

Bases: StrEnum

Classification from the model string returned by xBPI 0x02 or SBI identify.

  • :attr:CUBIS — MSE and related Cubis strings; full xBPI plus Cubis extensions.
  • :attr:OEM_WEIGH_CELL — WZ/WZA; ships from the factory in SBI autoprint (1200-7-O-1) and requires a front-panel menu change to switch to xBPI. (MSE and BCE also ship in SBI by default — switching to xBPI is a front-panel menu change on every family.)
  • :attr:BASIC_LAB — BCE*; MSE opcode subset, no Cubis extensions.
  • :attr:UNKNOWN — anything we have not classified; every call becomes a live probe.

classify_family

classify_family(model)

Classify a balance family by its model-string prefix.

Rules (design §5):

  • MSE* and related Cubis strings → :attr:BalanceFamily.CUBIS
  • WZ* / WZA* → :attr:BalanceFamily.OEM_WEIGH_CELL
  • BCE* → :attr:BalanceFamily.BASIC_LAB
  • anything else → :attr:BalanceFamily.UNKNOWN

Case-insensitive and whitespace-tolerant. Returns UNKNOWN for empty input — every call becomes a live probe for unclassified devices (design §5.1).

Source code in src/sartoriuslib/devices/kind.py
def classify_family(model: str) -> BalanceFamily:
    """Classify a balance family by its model-string prefix.

    Rules (design §5):

    - ``MSE*`` and related Cubis strings → :attr:`BalanceFamily.CUBIS`
    - ``WZ*`` / ``WZA*`` → :attr:`BalanceFamily.OEM_WEIGH_CELL`
    - ``BCE*`` → :attr:`BalanceFamily.BASIC_LAB`
    - anything else → :attr:`BalanceFamily.UNKNOWN`

    Case-insensitive and whitespace-tolerant. Returns ``UNKNOWN`` for
    empty input — every call becomes a live probe for unclassified
    devices (design §5.1).
    """
    stripped = model.strip().upper()
    if stripped.startswith("MSE"):
        return BalanceFamily.CUBIS
    if stripped.startswith("WZ"):
        return BalanceFamily.OEM_WEIGH_CELL
    if stripped.startswith("BCE"):
        return BalanceFamily.BASIC_LAB
    return BalanceFamily.UNKNOWN

Capability flags + safety tier

sartoriuslib.devices.capability

Capability flags, safety tiers, and probe/availability enums.

See design doc §5 (families + capabilities) and §6.1 (gates).

Availability

Bases: StrEnum

Derived state the session consults when dispatching a command.

See design doc §5.1, §6.1.1.

INAPPLICABLE class-attribute instance-attribute

INAPPLICABLE = 'inapplicable'

Device responded with xBPI 0x06. Retryable; state-dependent.

SUPPORTED class-attribute instance-attribute

SUPPORTED = 'supported'

Directly confirmed by a successful call or probe.

UNKNOWN class-attribute instance-attribute

UNKNOWN = 'unknown'

Never exercised; priors may exist but no device observation yet.

UNSUPPORTED class-attribute instance-attribute

UNSUPPORTED = 'unsupported'

Device responded with xBPI 0x04 / equivalent SBI refusal. Sticky per session.

Capability

Bases: Flag

Feature capabilities derived from family defaults + live probing.

Flag bitmap carries capabilities currently believed SUPPORTED. Full tri/quad-state per capability lives in DeviceInfo.probe_report.

ProbeSource

Bases: StrEnum

Where an :class:Availability value came from.

FAMILY_TABLE class-attribute instance-attribute

FAMILY_TABLE = 'family_table'

Seeded prior from our captures.

LIVE_CALL class-attribute instance-attribute

LIVE_CALL = 'live_call'

Updated by the device's response to a normal command.

TARGETED_PROBE class-attribute instance-attribute

TARGETED_PROBE = 'targeted_probe'

Explicit probe during identify() / discovery.

USER_OVERRIDE class-attribute instance-attribute

USER_OVERRIDE = 'user_override'

Set explicitly by the caller.

SafetyTier

Bases: IntEnum

Per-command safety tier. See design doc §6.1.

DANGEROUS class-attribute instance-attribute

DANGEROUS = 3

Baud/SBN change, reset, calibration init, protocol switch. Requires confirm=True.

PERSISTENT class-attribute instance-attribute

PERSISTENT = 2

Parameter writes, save menu, communication settings. Requires confirm=True.

READ_ONLY class-attribute instance-attribute

READ_ONLY = 0

Weight, status, identity, capacity, increment, temperature, parameter reads.

STATEFUL class-attribute instance-attribute

STATEFUL = 1

Transient state change (tare, zero). No EEPROM write.

Balance facade

sartoriuslib.devices.balance

The :class:Balance facade.

One protocol-neutral class — design §5 ("no family subclasses"). The balance dispatches every call through :meth:Session.execute so all gates run before any byte leaves the host, and runtime behaviour stays identical whether the device is on xBPI or SBI.

Surfaces:

Weight & state: :meth:poll, :meth:read_net, :meth:read_gross, :meth:read_tare_value, :meth:tare, :meth:zero, :meth:status, :meth:identify, :meth:raw_xbpi.

Metrology, parameters, cal, persistence: :meth:capacity, :meth:increment, :meth:temperature, :meth:read_parameter, :meth:write_parameter, :meth:save_menu, :meth:reload_menu, :meth:last_cal_record, :meth:internal_adjust.

Typed parameter pairs

:meth:get_filter_mode / :meth:set_filter_mode, :meth:get_display_unit / :meth:set_display_unit, :meth:get_auto_zero / :meth:set_auto_zero, :meth:get_isocal_mode / :meth:set_isocal_mode, :meth:get_tare_behavior / :meth:set_tare_behavior, :meth:get_menu_access / :meth:set_menu_access.

Balance

Balance(session, info=None)

Protocol-neutral balance facade.

Construct via :func:sartoriuslib.open_device. Every method is a thin wrapper around :meth:Session.execute (or :meth:Session.cached_execute for repeat-read metrology / parameter accessors); the session owns the I/O lock and runs the pre-I/O safety / protocol / availability / prior gates (design §6.1).

Source code in src/sartoriuslib/devices/balance.py
def __init__(
    self,
    session: Session,
    info: DeviceInfo | None = None,
) -> None:
    self._session = session
    self._info = info

info property

info

Identity snapshot from the last :meth:identify call (or None).

Populated automatically by :func:open_device when identify=True.

session property

session

Underlying :class:Session (for advanced use / gate inspection).

capacity async

capacity(area=0)

Read the weighing capacity for area (xBPI 0x0C).

Cached by the session when :attr:Capability.CONFIG_COUNTER is present — the balance's 0xBA counter bumps on display- accuracy changes (p08) which is the only thing that would move this value in practice.

The wire's typed-float reply does not carry a unit byte (contrast the 8-byte measurement body's byte [6]). To return a complete :class:Quantity we read the current display unit (p07) and fold it in here. get_display_unit() is itself cached on 0xBA via the parameter-table cache, so two successive capacity() calls only re-read 0xBA (twice, once per call) and not 0x0C or 0x55. If get_display_unit() itself fails (e.g. the parameter table is unreachable), the unit falls back to :attr:Unit.UNKNOWN and the numeric value still returns — fail-open is more useful than a hard error for a metadata read.

Source code in src/sartoriuslib/devices/balance.py
async def capacity(self, area: int = 0) -> Quantity:
    """Read the weighing capacity for ``area`` (xBPI ``0x0C``).

    Cached by the session when :attr:`Capability.CONFIG_COUNTER`
    is present — the balance's ``0xBA`` counter bumps on display-
    accuracy changes (``p08``) which is the only thing that would
    move this value in practice.

    The wire's typed-float reply does **not** carry a unit byte
    (contrast the 8-byte measurement body's byte [6]). To return
    a complete :class:`Quantity` we read the current display unit
    (``p07``) and fold it in here. ``get_display_unit()`` is itself
    cached on ``0xBA`` via the parameter-table cache, so two
    successive ``capacity()`` calls only re-read ``0xBA`` (twice,
    once per call) and not ``0x0C`` or ``0x55``. If
    ``get_display_unit()`` itself fails (e.g. the parameter table
    is unreachable), the unit falls back to :attr:`Unit.UNKNOWN`
    and the numeric value still returns — fail-open is more useful
    than a hard error for a metadata read.
    """
    raw = await self._session.cached_execute(
        READ_CAPACITY,
        MetrologyRequest(area=area),
        cache_key=f"capacity:{area}",
    )
    return await self._resolve_metrology_unit(raw)

close async

close()

Close the underlying transport. Idempotent.

Source code in src/sartoriuslib/devices/balance.py
async def close(self) -> None:
    """Close the underlying transport. Idempotent."""
    await self._session.close()

configure_protocol async

configure_protocol(
    target,
    *,
    baudrate=None,
    parity=None,
    stopbits=None,
    timeout=None,
    confirm=False,
)

Switch this balance's active wire protocol (and optionally framing).

DANGEROUS — requires confirm=True. The flip is purely host-side: per docs/protocol.md §2.1 the device's protocol mode changes via the front-panel menu, never via xBPI for the PC-USB port (verified empirically 2026-04-25 on MSE1203S — the PC-USB protocol selector is not in the xBPI parameter table at all; only Device → PC-USB → Dat.Rec. on the front panel flips it). On the 9-pin peripheral port, p35 write + SAVE_MENU + cold boot does work programmatically, but most users are on PC-USB.

This method reconciles the host with the user's front-panel change by closing the current protocol client, reopening the transport at the new serial framing (any None argument keeps the existing value), and building the new client. It then verifies via an identity probe and refreshes :attr:info from the new protocol.

If the user has NOT actually flipped the front panel before calling this method, the post-switch identity probe will fail — typically with a :class:SartoriusFrameError ("bad marker byte 0x20") when xBPI is requested but the wire is still emitting SBI autoprint, or a timeout when SBI is requested but the wire is still on xBPI. Treat that error as a strong signal that the front-panel mode does not match target.

On any failure during the switch the method attempts to roll back to the original serial framing. If that rollback also fails the underlying :class:Session transitions to :attr:SessionState.BROKEN and a :class:SartoriusConnectionError is raised — the caller must close this balance and re-open via :func:sartoriuslib.open_device to recover.

Same-protocol no-op: when target equals the current protocol and no framing override is supplied, the call returns the cached :class:DeviceInfo (or runs :meth:identify if none has been cached yet) without touching the transport.

Source code in src/sartoriuslib/devices/balance.py
async def configure_protocol(
    self,
    target: ProtocolKind,
    *,
    baudrate: int | None = None,
    parity: Parity | None = None,
    stopbits: StopBits | None = None,
    timeout: float | None = None,
    confirm: bool = False,
) -> DeviceInfo:
    """Switch this balance's active wire protocol (and optionally framing).

    ``DANGEROUS`` — requires ``confirm=True``. The flip is purely
    host-side: per ``docs/protocol.md`` §2.1 the device's protocol
    mode changes via the front-panel menu, never via xBPI for the
    PC-USB port (verified empirically 2026-04-25 on MSE1203S — the
    PC-USB protocol selector is not in the xBPI parameter table at
    all; only `Device → PC-USB → Dat.Rec.` on the front panel
    flips it). On the 9-pin peripheral port, `p35` write +
    SAVE_MENU + cold boot does work programmatically, but most
    users are on PC-USB.

    This method reconciles the host with the user's front-panel
    change by closing the current protocol client, reopening the
    transport at the new serial framing (any ``None`` argument keeps
    the existing value), and building the new client. It then
    verifies via an identity probe and refreshes :attr:`info` from
    the new protocol.

    If the user has NOT actually flipped the front panel before
    calling this method, the post-switch identity probe will fail
    — typically with a :class:`SartoriusFrameError` ("bad marker
    byte 0x20") when xBPI is requested but the wire is still
    emitting SBI autoprint, or a timeout when SBI is requested
    but the wire is still on xBPI. Treat that error as a strong
    signal that the front-panel mode does not match ``target``.

    On any failure during the switch the method attempts to roll
    back to the original serial framing. If that rollback also
    fails the underlying :class:`Session` transitions to
    :attr:`SessionState.BROKEN` and a
    :class:`SartoriusConnectionError` is raised — the caller must
    close this balance and re-open via
    :func:`sartoriuslib.open_device` to recover.

    Same-protocol no-op: when ``target`` equals the current
    protocol and no framing override is supplied, the call returns
    the cached :class:`DeviceInfo` (or runs :meth:`identify` if
    none has been cached yet) without touching the transport.
    """
    if target is ProtocolKind.AUTO:
        raise SartoriusValidationError(
            "configure_protocol: target must be XBPI or SBI, not AUTO",
            context=ErrorContext(
                command_name="configure_protocol",
                extra={"target": "auto"},
            ),
        )
    if not confirm:
        raise SartoriusConfirmationRequiredError(
            "configure_protocol is DANGEROUS; pass confirm=True to execute",
            context=ErrorContext(
                command_name="configure_protocol",
                extra={"target": target.value},
            ),
        )

    session = self._session
    session.check_state()

    no_framing_change = baudrate is None and parity is None and stopbits is None
    if target is session.active_protocol and no_framing_change:
        return self._info if self._info is not None else await self.identify()

    transport = session.transport

    old_settings = session.serial_settings
    old_xbpi = session.xbpi_client
    old_sbi = session.sbi_client
    old_active = session.active_protocol
    active_lock = (
        old_xbpi.lock
        if old_active is ProtocolKind.XBPI and old_xbpi is not None
        else old_sbi.lock
        if old_sbi is not None
        else None
    )
    if active_lock is None:
        raise SartoriusError(
            "configure_protocol: active protocol has no client lock",
            context=ErrorContext(command_name="configure_protocol"),
        )

    t = timeout if timeout is not None else session.default_timeout

    async with active_lock:
        try:
            await transport.drain_input()
            await transport.reopen(
                baudrate=baudrate,
                parity=parity,
                stopbits=stopbits,
            )
            new_xbpi: XbpiProtocolClient | None = None
            new_sbi: SbiProtocolClient | None = None
            if target is ProtocolKind.XBPI:
                new_xbpi = XbpiProtocolClient(transport, default_timeout=t)
            else:
                new_sbi = SbiProtocolClient(transport, default_timeout=t)
                await new_sbi.detect_autoprint(timeout=min(t, 0.25))
            if not (
                target is ProtocolKind.SBI and new_sbi is not None and new_sbi.autoprint_active
            ):
                await _verify_identity_probe(
                    target,
                    new_xbpi,
                    new_sbi,
                    src_sbn=session.src_sbn,
                    dst_sbn=session.dst_sbn,
                    timeout=t,
                )
            new_settings = _overlay_settings(
                old_settings,
                baudrate=baudrate,
                parity=parity,
                stopbits=stopbits,
            )
            session.replace_clients(
                xbpi_client=new_xbpi,
                sbi_client=new_sbi,
                active_protocol=target,
                serial_settings=new_settings,
            )
            _dispose_replaced_clients(
                old_xbpi=old_xbpi,
                old_sbi=old_sbi,
                new_xbpi=new_xbpi,
                new_sbi=new_sbi,
            )
        except Exception as switch_error:
            await self._rollback_configure_protocol(
                transport=transport,
                old_settings=old_settings,
                target=target,
                cause=switch_error,
            )
            raise

    # Identify outside the old client's lock — Session.execute will
    # acquire the new client's lock itself.
    return await self.identify()

discover_temperature_sensors async

discover_temperature_sensors(
    *, max_index=_TEMPERATURE_DISCOVERY_MAX_INDEX
)

Probe the device for installed temperature sensors at runtime.

Iterates indices 0..max_index calling :meth:temperature on each. Records every index that replies — both real readings (celsius is a float) and sentinel slots (celsius is None because the firmware returned 7f ff ff ff). Stops early on :class:SartoriusIndexOutOfRangeError (0x10), which the device emits past the last valid index. A 0x03 value-out-of-range reply (how a WZ8202 reports an absent sensor slot, instead of the sentinel) is treated as an empty slot and skipped — probing continues to max_index so a sparse map is not truncated at the first gap. Updates the cached :class:DeviceInfo's :attr:temperature_sensor_indices with the discovered tuple and returns it.

Device-agnostic by design — no family table is consulted, so a balance we have never tested still produces an honest sensor map.

max_index is a safety cap, not a contract: 8 is the default headroom (Cubis MSE captures stop at 3). Probing a device with no sensors produces an empty tuple. Each probe is one round-trip; the result is not cached on 0xBA because per-call temperature reads are not cached either — callers re-discover on demand.

Source code in src/sartoriuslib/devices/balance.py
async def discover_temperature_sensors(
    self,
    *,
    max_index: int = _TEMPERATURE_DISCOVERY_MAX_INDEX,
) -> tuple[int, ...]:
    """Probe the device for installed temperature sensors at runtime.

    Iterates indices ``0..max_index`` calling :meth:`temperature`
    on each. Records every index that replies — both real
    readings (``celsius`` is a ``float``) **and** sentinel slots
    (``celsius`` is ``None`` because the firmware returned
    ``7f ff ff ff``). Stops early on
    :class:`SartoriusIndexOutOfRangeError` (``0x10``), which the
    device emits past the last valid index. A ``0x03``
    value-out-of-range reply (how a WZ8202 reports an absent sensor
    slot, instead of the sentinel) is treated as an empty slot and
    skipped — probing continues to ``max_index`` so a sparse map is
    not truncated at the first gap. Updates the cached
    :class:`DeviceInfo`'s
    :attr:`temperature_sensor_indices` with the discovered tuple
    and returns it.

    Device-agnostic by design — no family table is consulted, so
    a balance we have never tested still produces an honest
    sensor map.

    ``max_index`` is a safety cap, not a contract: 8 is the
    default headroom (Cubis MSE captures stop at 3). Probing a
    device with no sensors produces an empty tuple. Each probe
    is one round-trip; the result is not cached on ``0xBA``
    because per-call temperature reads are not cached either —
    callers re-discover on demand.
    """
    if max_index < 0:
        raise ValueError(f"max_index must be >= 0, got {max_index}")
    # Local import — lazy to avoid pulling errors module at
    # construction time. The class is intentionally module-level.
    from sartoriuslib.errors import (  # noqa: PLC0415
        SartoriusIndexOutOfRangeError,
        SartoriusUnsupportedCommandError,
        SartoriusValueOutOfRangeError,
    )

    discovered: list[int] = []
    for sensor in range(max_index + 1):
        try:
            reading = await self.temperature(sensor)
        except SartoriusValueOutOfRangeError:
            # 0x03 — this firmware signals "no sensor at this index"
            # with value-out-of-range rather than the 7f-ff-ff-ff
            # sentinel (observed on a WZ8202: sensors at 0/1, 0x03 at
            # 2+). Unlike 0x10, it is a per-slot signal, not a
            # definitive end-of-list, so we skip the empty slot and
            # keep probing — a sparse map with a real sensor past the
            # gap (bounded by ``max_index``) is still discovered.
            # ``temperature`` is parameterized, so the availability
            # cache is not poisoned for in-range indices.
            continue
        except SartoriusIndexOutOfRangeError:
            # 0x10 — past the last valid index. Authoritative end stop.
            break
        except SartoriusUnsupportedCommandError:
            # Some firmwares mis-report end-of-list as 0x04 instead
            # of 0x10. ``temperature`` is parameterized so the
            # availability cache is not poisoned for in-range
            # indices; we still treat it as the end signal here.
            break
        discovered.append(reading.sensor)
    result = tuple(discovered)
    if self._info is not None:
        self._info = dataclasses.replace(
            self._info,
            temperature_sensor_indices=result,
        )
    return result

get_auto_zero async

get_auto_zero()

Read p06 (auto-zero tracking) as an :class:AutoZeroMode.

Source code in src/sartoriuslib/devices/balance.py
async def get_auto_zero(self) -> AutoZeroMode:
    """Read ``p06`` (auto-zero tracking) as an :class:`AutoZeroMode`."""
    spec = PARAMETER_TABLE[_TYPED_ACCESSOR_INDICES["auto_zero"]]
    current = await self._get_typed(spec.index)
    return cast("AutoZeroMode", spec.decode(current))

get_display_unit async

get_display_unit()

Read p07 (display unit) as a :class:Unit.

Source code in src/sartoriuslib/devices/balance.py
async def get_display_unit(self) -> Unit:
    """Read ``p07`` (display unit) as a :class:`Unit`."""
    spec = PARAMETER_TABLE[_TYPED_ACCESSOR_INDICES["display_unit"]]
    current = await self._get_typed(spec.index)
    # p07 is the only unit-valued spec; decode() returns a Unit.
    return cast("Unit", spec.decode(current))

get_filter_mode async

get_filter_mode()

Read p01 (filter mode) as a :class:FilterMode.

Source code in src/sartoriuslib/devices/balance.py
async def get_filter_mode(self) -> FilterMode:
    """Read ``p01`` (filter mode) as a :class:`FilterMode`."""
    spec = PARAMETER_TABLE[_TYPED_ACCESSOR_INDICES["filter_mode"]]
    current = await self._get_typed(spec.index)
    # The spec's enum class is FilterMode — decode() is guaranteed
    # to return a FilterMode member (or FilterMode.UNKNOWN). The
    # signature is the union across all specs so we narrow here.
    return cast("FilterMode", spec.decode(current))

get_isocal_mode async

get_isocal_mode()

Read p15 (isoCAL mode) as an :class:IsoCalMode (Cubis only).

Source code in src/sartoriuslib/devices/balance.py
async def get_isocal_mode(self) -> IsoCalMode:
    """Read ``p15`` (isoCAL mode) as an :class:`IsoCalMode` (Cubis only)."""
    spec = PARAMETER_TABLE[_TYPED_ACCESSOR_INDICES["isocal_mode"]]
    current = await self._get_typed(spec.index)
    return cast("IsoCalMode", spec.decode(current))

get_menu_access async

get_menu_access()

Read p40 (front-panel menu lock) as :class:MenuAccessMode.

Source code in src/sartoriuslib/devices/balance.py
async def get_menu_access(self) -> MenuAccessMode:
    """Read ``p40`` (front-panel menu lock) as :class:`MenuAccessMode`."""
    spec = PARAMETER_TABLE[_TYPED_ACCESSOR_INDICES["menu_access"]]
    current = await self._get_typed(spec.index)
    return cast("MenuAccessMode", spec.decode(current))

get_tare_behavior async

get_tare_behavior()

Read p05 (tare-on-stability behaviour) as :class:TareBehavior.

Source code in src/sartoriuslib/devices/balance.py
async def get_tare_behavior(self) -> TareBehavior:
    """Read ``p05`` (tare-on-stability behaviour) as :class:`TareBehavior`."""
    spec = PARAMETER_TABLE[_TYPED_ACCESSOR_INDICES["tare_behavior"]]
    current = await self._get_typed(spec.index)
    return cast("TareBehavior", spec.decode(current))

identify async

identify()

Read every identity opcode and compose a :class:DeviceInfo.

Runs in sequence — the session's I/O lock keeps them serialised on the wire. The session carries the serial framing it was opened with (the device doesn't expose its own baud/parity), so :class:DeviceInfo reports those settings directly; sessions constructed without framing fall back to a placeholder.

After the textual identity primitives, the factory probes capacity and increment via :meth:capacity / :meth:increment and writes them onto the returned :class:DeviceInfo for WZG-family balances where the metrology commands are known to respond. Failures are swallowed — a balance that refuses 0x0C keeps the old behaviour of capacity=None.

Source code in src/sartoriuslib/devices/balance.py
async def identify(self) -> DeviceInfo:
    """Read every identity opcode and compose a :class:`DeviceInfo`.

    Runs in sequence — the session's I/O lock keeps them serialised
    on the wire. The session carries the serial framing it was
    opened with (the device doesn't expose its own baud/parity), so
    :class:`DeviceInfo` reports those settings directly; sessions
    constructed without framing fall back to a placeholder.

    After the textual identity primitives, the factory probes
    capacity and increment via :meth:`capacity` / :meth:`increment`
    and writes them onto the returned :class:`DeviceInfo` for
    WZG-family balances where the metrology commands are known to
    respond. Failures are swallowed — a balance that refuses
    ``0x0C`` keeps the old behaviour of ``capacity=None``.
    """
    session = self._session
    req = IdentityRequest()
    info_settings = session.serial_settings or _placeholder_serial_settings()

    if session.active_protocol is ProtocolKind.SBI:
        model = await session.execute(READ_MODEL, req)
        serial_bytes = await session.execute(READ_FACTORY_NUMBER, req)
        software_bytes = await session.execute(READ_SW_VERSION, req)
        sbi_serial = _decode_ascii_identity(serial_bytes)
        sbi_software = _decode_ascii_identity(software_bytes)
        family = classify_family(model)
        caps_seed = _FAMILY_DEFAULT_CAPABILITIES[family] | Capability.SBI_SUPPORT
        session.update_identity(family=family, capabilities=caps_seed)
        info = DeviceInfo(
            manufacturer=None,
            model=model,
            serial=sbi_serial or None,
            factory_number=sbi_serial or serial_bytes or None,
            software=sbi_software or None,
            firmware=None,
            family=family,
            protocol=session.active_protocol,
            capacity=None,
            increment=None,
            sbn=None,
            serial_settings=info_settings,
            capabilities=caps_seed,
        )
        self._info = info
        return info

    model = await session.execute(READ_MODEL, req)
    manufacturer = await session.execute(READ_MANUFACTURER, req)
    software = await session.execute(READ_SW_VERSION, req)
    factory_number = await session.execute(READ_FACTORY_NUMBER, req)
    sbn = await session.execute(READ_SBN, req)

    family = classify_family(model)
    caps_seed = _FAMILY_DEFAULT_CAPABILITIES[family]
    # Capabilities that gate dispatch behaviour are runtime-probed
    # here so the family table cannot lie to the dispatch layer
    # (design §5.1: priors do not assert before observation).
    caps_seed |= await self._probe_dispatch_capabilities()
    # Propagate family + observed caps into the session so subsequent
    # prior gates (and the cache capability check in
    # :meth:`capacity`/:meth:`increment` below) see a real family.
    session.update_identity(family=family, capabilities=caps_seed)

    # Capability probes — a balance that refuses 0x0C / 0x0D leaves
    # the fields as None rather than failing identify(). Narrow to
    # SartoriusError so programmer bugs (KeyError from a registry
    # miss, TypeError in a decoder) still surface as identify
    # failures instead of silent ``capacity=None``. Suppress the
    # prior-mismatch warning the same way :meth:`_probe_dispatch_capabilities`
    # does — capacity()/increment() route through the parameter
    # table to resolve the display unit, which warns on families
    # without ``Capability.PARAMETER_TABLE`` and would be promoted
    # to an error under ``filterwarnings=error`` (e.g. WZA).
    capacity_q: Quantity | None = None
    increment_q: Quantity | None = None
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", SartoriusCapabilityWarning)
        try:
            capacity_q = await self.capacity()
        except SartoriusError:
            capacity_q = None
        try:
            increment_q = await self.increment()
        except SartoriusError:
            increment_q = None

    info = DeviceInfo(
        manufacturer=manufacturer or None,
        model=model,
        serial=None,
        factory_number=factory_number,
        software=software.hex() if software else None,
        firmware=None,
        family=family,
        protocol=session.active_protocol,
        capacity=capacity_q,
        increment=increment_q,
        sbn=sbn,
        serial_settings=info_settings,
        capabilities=caps_seed,
    )
    self._info = info
    return info

increment async

increment(area=0)

Read the display increment for area (xBPI 0x0D).

See :meth:capacity for the caching contract and the composite-unit fold.

Source code in src/sartoriuslib/devices/balance.py
async def increment(self, area: int = 0) -> Quantity:
    """Read the display increment for ``area`` (xBPI ``0x0D``).

    See :meth:`capacity` for the caching contract and the
    composite-unit fold.
    """
    raw = await self._session.cached_execute(
        READ_INCREMENT,
        MetrologyRequest(area=area),
        cache_key=f"increment:{area}",
    )
    return await self._resolve_metrology_unit(raw)

internal_adjust async

internal_adjust(*, cal_type=None, confirm=False)

Start an internal adjustment (xBPI 0x28). DANGEROUS.

cal_type defaults to the canonical internal-adjust selector (0x78) — see :data:sartoriuslib.commands.calibration.INTERNAL_ADJUST_CAL_TYPE. Callers can pass another value in the 0x70..0x7B range to drive external cal / linearization variants per docs/protocol.md §7.7.

Source code in src/sartoriuslib/devices/balance.py
async def internal_adjust(
    self,
    *,
    cal_type: int | None = None,
    confirm: bool = False,
) -> None:
    """Start an internal adjustment (xBPI ``0x28``). ``DANGEROUS``.

    ``cal_type`` defaults to the canonical internal-adjust selector
    (``0x78``) — see
    :data:`sartoriuslib.commands.calibration.INTERNAL_ADJUST_CAL_TYPE`.
    Callers can pass another value in the ``0x70..0x7B`` range to
    drive external cal / linearization variants per
    ``docs/protocol.md`` §7.7.
    """
    req = (
        InternalAdjustRequest()
        if cal_type is None
        else InternalAdjustRequest(cal_type=cal_type)
    )
    await self._session.execute(INTERNAL_ADJUST, req, confirm=confirm)

last_cal_record async

last_cal_record()

Read the last-calibration snapshot (xBPI 0xB9, §7.12).

Source code in src/sartoriuslib/devices/balance.py
async def last_cal_record(self) -> CalRecord:
    """Read the last-calibration snapshot (xBPI ``0xB9``, §7.12)."""
    return await self._session.execute(LAST_CAL_RECORD, LastCalRecordRequest())

poll async

poll()

Read the live net weight at standard resolution.

Short-cut for :meth:read_net with no arguments. One-shot request/response; to stream at a cadence use :func:sartoriuslib.streaming.record.

Source code in src/sartoriuslib/devices/balance.py
async def poll(self) -> Reading:
    """Read the live net weight at standard resolution.

    Short-cut for :meth:`read_net` with no arguments. One-shot
    request/response; to stream at a cadence use
    :func:`sartoriuslib.streaming.record`.
    """
    if self._session.sbi_autoprint_active:
        return await self._session.read_sbi_autoprint_reading()
    return await self._session.execute(READ_NET, ReadWeightRequest())

raw_sbi async

raw_sbi(
    command, *, confirm=False, timeout=None, expect_lines=1
)

Send an arbitrary SBI command and return parsed line replies.

Source code in src/sartoriuslib/devices/balance.py
async def raw_sbi(
    self,
    command: bytes | str,
    *,
    confirm: bool = False,
    timeout: float | None = None,
    expect_lines: int = 1,
) -> SbiReply:
    """Send an arbitrary SBI command and return parsed line replies."""
    return await self._session.execute_raw_sbi(
        command,
        confirm=confirm,
        timeout=timeout,
        expect_lines=expect_lines,
    )

raw_xbpi async

raw_xbpi(opcode, args=b'', *, confirm=False, timeout=None)

Send an arbitrary xBPI opcode and return the raw reply frame.

Opcodes in the built-in read-only safe-list (:data:sartoriuslib.commands.raw.SAFE_READ_ONLY_OPCODES) run freely; anything else requires confirm=True. Intended for RE and one-off probes — typed commands are the preferred path for everything the library already models.

Source code in src/sartoriuslib/devices/balance.py
async def raw_xbpi(
    self,
    opcode: int,
    args: bytes = b"",
    *,
    confirm: bool = False,
    timeout: float | None = None,
) -> XbpiFrame:
    """Send an arbitrary xBPI opcode and return the raw reply frame.

    Opcodes in the built-in read-only safe-list
    (:data:`sartoriuslib.commands.raw.SAFE_READ_ONLY_OPCODES`) run
    freely; anything else requires ``confirm=True``. Intended for
    RE and one-off probes — typed commands are the preferred path
    for everything the library already models.
    """
    return await self._session.execute_raw_xbpi(
        opcode,
        args,
        confirm=confirm,
        timeout=timeout,
    )

read_gross async

read_gross(*, hires=0)

Read the gross weight.

Source code in src/sartoriuslib/devices/balance.py
async def read_gross(self, *, hires: int = 0) -> Reading:
    """Read the gross weight."""
    if hires == 0:
        return await self._session.execute(READ_GROSS, ReadWeightRequest())
    return await self._session.execute(
        READ_GROSS_HIRES,
        ReadWeightHiresRequest(resolution=hires),
    )

read_net async

read_net(*, hires=0)

Read the net weight.

Parameters:

Name Type Description Default
hires int

0 = standard resolution (xBPI 0x1E), 1 = 10× resolution (0x1F TLV-21 arg 0x01), 2 = 100× resolution (0x1F TLV-21 arg 0x02).

0
Source code in src/sartoriuslib/devices/balance.py
async def read_net(self, *, hires: int = 0) -> Reading:
    """Read the net weight.

    Arguments:
        hires: ``0`` = standard resolution (xBPI ``0x1E``),
            ``1`` = 10× resolution (``0x1F`` TLV-21 arg ``0x01``),
            ``2`` = 100× resolution (``0x1F`` TLV-21 arg ``0x02``).
    """
    if hires == 0:
        if self._session.sbi_autoprint_active:
            return await self._session.read_sbi_autoprint_reading()
        return await self._session.execute(READ_NET, ReadWeightRequest())
    return await self._session.execute(
        READ_NET_HIRES,
        ReadWeightHiresRequest(resolution=hires),
    )

read_parameter async

read_parameter(index)

Read one parameter-table entry (xBPI 0x55).

Returns the (current, max) u8 pair untouched; typed accessors layer on top to decode via the :class:sartoriuslib.registry.parameters.ParameterSpec table. Cached on 0xBA.

Source code in src/sartoriuslib/devices/balance.py
async def read_parameter(self, index: int) -> ParameterEntry:
    """Read one parameter-table entry (xBPI ``0x55``).

    Returns the ``(current, max)`` u8 pair untouched; typed
    accessors layer on top to decode via the
    :class:`sartoriuslib.registry.parameters.ParameterSpec` table.
    Cached on ``0xBA``.
    """
    entry = await self._session.cached_execute(
        READ_PARAMETER,
        ReadParameterRequest(index=index),
        cache_key=_cache_key_parameter(index),
    )
    return dataclasses.replace(entry, index=index)

read_tare_value async

read_tare_value()

Read the stored tare value (the reference, not a live operation).

Source code in src/sartoriuslib/devices/balance.py
async def read_tare_value(self) -> Reading:
    """Read the stored tare value (the reference, not a live operation)."""
    return await self._session.execute(READ_TARE_VALUE, ReadWeightRequest())

refresh_sbi_autoprint_state async

refresh_sbi_autoprint_state(*, timeout=None)

Re-sniff whether an SBI session is currently in autoprint mode.

Use this after changing autoprint from the balance front panel during an open session. A quiet sniff clears autoprint mode so command/reply SBI APIs become available again; observed output keeps the session in consume-only autoprint mode.

Source code in src/sartoriuslib/devices/balance.py
async def refresh_sbi_autoprint_state(self, *, timeout: float | None = None) -> bool:
    """Re-sniff whether an SBI session is currently in autoprint mode.

    Use this after changing autoprint from the balance front panel during
    an open session. A quiet sniff clears autoprint mode so command/reply
    SBI APIs become available again; observed output keeps the session in
    consume-only autoprint mode.
    """
    return await self._session.refresh_sbi_autoprint_state(timeout=timeout)

reload_menu async

reload_menu(*, confirm=False)

Reload the saved menu from EEPROM (xBPI 0x46).

Source code in src/sartoriuslib/devices/balance.py
async def reload_menu(self, *, confirm: bool = False) -> None:
    """Reload the saved menu from EEPROM (xBPI ``0x46``)."""
    await self._session.execute(RELOAD_MENU, SystemRequest(), confirm=confirm)
    self._session.invalidate_cache()

save_menu async

save_menu(*, confirm=False)

Persist the current runtime menu to EEPROM (xBPI 0x47).

Source code in src/sartoriuslib/devices/balance.py
async def save_menu(self, *, confirm: bool = False) -> None:
    """Persist the current runtime menu to EEPROM (xBPI ``0x47``)."""
    await self._session.execute(SAVE_MENU, SystemRequest(), confirm=confirm)
    # Any persistent write may change values the cache relied on;
    # flush everything defensively.
    self._session.invalidate_cache()

set_auto_zero async

set_auto_zero(mode, *, confirm=False)

Write p06. Accepts :class:AutoZeroMode, a string, or wire int.

Source code in src/sartoriuslib/devices/balance.py
async def set_auto_zero(
    self,
    mode: AutoZeroMode | str | int,
    *,
    confirm: bool = False,
) -> None:
    """Write ``p06``. Accepts :class:`AutoZeroMode`, a string, or wire int."""
    spec = PARAMETER_TABLE[_TYPED_ACCESSOR_INDICES["auto_zero"]]
    resolved = resolve_auto_zero(mode)
    wire = spec.encode(resolved)
    await self._set_typed(spec.index, wire, confirm=confirm)

set_baud_rate async

set_baud_rate(
    wire_code,
    *,
    baudrate,
    parity=None,
    stopbits=None,
    timeout=None,
    confirm=False,
)

Send xBPI 0x5C and reopen the transport at the new baud.

DANGEROUS — requires confirm=True. wire_code is the device-side encoding from docs/protocol.md §7.10 (0x00=9600, 0x01=19200, 0x02=38400, 0x03=57600); baudrate is the matching host-side baud (the transport's framing is reopened at this value). The library does not map between the two automatically because the encoding is documented as "different from p31 / p63" and we have no RE captures verifying the mapping yet — the caller passes both so the on-wire byte and the host framing stay explicit.

After the device-side ACK the transport is reopened, identity is reprobed for verification, and the cached :class:DeviceInfo is refreshed. Verification failure rolls the transport back to the original baud; if rollback fails the session enters :attr:SessionState.BROKEN.

SBI sessions are not supported here — the 0x5C opcode is xBPI-only. Call :meth:configure_protocol first to switch to xBPI if needed.

Source code in src/sartoriuslib/devices/balance.py
async def set_baud_rate(
    self,
    wire_code: int,
    *,
    baudrate: int,
    parity: Parity | None = None,
    stopbits: StopBits | None = None,
    timeout: float | None = None,
    confirm: bool = False,
) -> DeviceInfo:
    """Send xBPI ``0x5C`` and reopen the transport at the new baud.

    ``DANGEROUS`` — requires ``confirm=True``. ``wire_code`` is the
    device-side encoding from ``docs/protocol.md`` §7.10
    (``0x00=9600``, ``0x01=19200``, ``0x02=38400``, ``0x03=57600``);
    ``baudrate`` is the matching host-side baud (the transport's
    framing is reopened at this value). The library does not map
    between the two automatically because the encoding is
    documented as "different from p31 / p63" and we have no RE
    captures verifying the mapping yet — the caller passes both so
    the on-wire byte and the host framing stay explicit.

    After the device-side ACK the transport is reopened, identity
    is reprobed for verification, and the cached :class:`DeviceInfo`
    is refreshed. Verification failure rolls the transport back to
    the original baud; if rollback fails the session enters
    :attr:`SessionState.BROKEN`.

    SBI sessions are not supported here — the ``0x5C`` opcode is
    xBPI-only. Call :meth:`configure_protocol` first to switch to
    xBPI if needed.
    """
    if not confirm:
        raise SartoriusConfirmationRequiredError(
            "set_baud_rate is DANGEROUS; pass confirm=True to execute",
            context=ErrorContext(
                command_name="set_baud_rate",
                extra={"wire_code": wire_code, "baudrate": baudrate},
            ),
        )
    if self._session.active_protocol is not ProtocolKind.XBPI:
        raise SartoriusError(
            "set_baud_rate requires an xBPI session; "
            "call configure_protocol(ProtocolKind.XBPI, ...) first",
            context=ErrorContext(
                command_name="set_baud_rate",
                protocol=str(self._session.active_protocol.value),
            ),
        )
    if not 0 <= wire_code <= _MAX_U8:
        raise SartoriusValidationError(
            f"set_baud_rate: wire_code must be 0..0xFF, got {wire_code!r}",
            context=ErrorContext(
                command_name="set_baud_rate",
                extra={"wire_code": wire_code},
            ),
        )

    # Send the on-wire change at the OLD baud. The device ACKs at
    # the old baud, then takes the new baud effective. Some firmware
    # may swallow the ACK across the change — treat ACK timeout as
    # a non-fatal signal and proceed to reopen + verify.
    with contextlib.suppress(SartoriusError):
        await self._session.execute_raw_xbpi(
            0x5C,
            encode_tlv(0x21, wire_code),
            confirm=True,
            timeout=timeout,
        )

    # Reuse configure_protocol's reopen + verify + rollback machinery
    # by switching to the same protocol with new framing.
    return await self.configure_protocol(
        ProtocolKind.XBPI,
        baudrate=baudrate,
        parity=parity,
        stopbits=stopbits,
        timeout=timeout,
        confirm=True,
    )

set_display_unit async

set_display_unit(unit, *, confirm=False)

Write p07. Accepts :class:Unit, a fuzzy string, or wire code.

Source code in src/sartoriuslib/devices/balance.py
async def set_display_unit(
    self,
    unit: Unit | str | int,
    *,
    confirm: bool = False,
) -> None:
    """Write ``p07``. Accepts :class:`Unit`, a fuzzy string, or wire code."""
    spec = PARAMETER_TABLE[_TYPED_ACCESSOR_INDICES["display_unit"]]
    if isinstance(unit, int):
        # Raw wire code — encode does the range check.
        wire = spec.encode(unit)
    else:
        resolved = resolve_unit(unit)
        wire = spec.encode(resolved)
    await self._set_typed(spec.index, wire, confirm=confirm)

set_filter_mode async

set_filter_mode(mode, *, confirm=False)

Write p01. Accepts :class:FilterMode, a fuzzy string, or wire int.

Fuzzy strings ("stable" / "very stable" / "vs") route through :func:resolve_filter_mode.

Source code in src/sartoriuslib/devices/balance.py
async def set_filter_mode(
    self,
    mode: FilterMode | str | int,
    *,
    confirm: bool = False,
) -> None:
    """Write ``p01``. Accepts :class:`FilterMode`, a fuzzy string, or wire int.

    Fuzzy strings (``"stable"`` / ``"very stable"`` / ``"vs"``)
    route through :func:`resolve_filter_mode`.
    """
    spec = PARAMETER_TABLE[_TYPED_ACCESSOR_INDICES["filter_mode"]]
    resolved = resolve_filter_mode(mode)
    wire = spec.encode(resolved)
    await self._set_typed(spec.index, wire, confirm=confirm)

set_isocal_mode async

set_isocal_mode(mode, *, confirm=False)

Write p15.

Source code in src/sartoriuslib/devices/balance.py
async def set_isocal_mode(
    self,
    mode: IsoCalMode | str | int,
    *,
    confirm: bool = False,
) -> None:
    """Write ``p15``."""
    spec = PARAMETER_TABLE[_TYPED_ACCESSOR_INDICES["isocal_mode"]]
    resolved = resolve_isocal_mode(mode)
    wire = spec.encode(resolved)
    await self._set_typed(spec.index, wire, confirm=confirm)

set_menu_access async

set_menu_access(mode, *, confirm=False)

Write p40.

Source code in src/sartoriuslib/devices/balance.py
async def set_menu_access(
    self,
    mode: MenuAccessMode | str | int,
    *,
    confirm: bool = False,
) -> None:
    """Write ``p40``."""
    spec = PARAMETER_TABLE[_TYPED_ACCESSOR_INDICES["menu_access"]]
    resolved = resolve_menu_access(mode)
    wire = spec.encode(resolved)
    await self._set_typed(spec.index, wire, confirm=confirm)

set_tare_behavior async

set_tare_behavior(mode, *, confirm=False)

Write p05.

Source code in src/sartoriuslib/devices/balance.py
async def set_tare_behavior(
    self,
    mode: TareBehavior | str | int,
    *,
    confirm: bool = False,
) -> None:
    """Write ``p05``."""
    spec = PARAMETER_TABLE[_TYPED_ACCESSOR_INDICES["tare_behavior"]]
    resolved = resolve_tare_behavior(mode)
    wire = spec.encode(resolved)
    await self._set_typed(spec.index, wire, confirm=confirm)

snapshot async

snapshot()

Return a cached identity + health snapshot — no I/O.

Builds the snapshot from :attr:info (the cached :class:DeviceInfo) and the underlying :class:Session counters. Safe to call any time, including from a hot path — the cost is the dataclass construction.

family, capabilities, protocol are sourced from the session (so they reflect the live identity even when info is None). mode is reserved for a future mode-tracking hook; today it is always None (the snapshot is no-I/O by contract, so it cannot probe).

Source code in src/sartoriuslib/devices/balance.py
async def snapshot(self) -> SartoriusDeviceSnapshot:
    """Return a cached identity + health snapshot — **no I/O**.

    Builds the snapshot from :attr:`info` (the cached
    :class:`DeviceInfo`) and the underlying :class:`Session`
    counters. Safe to call any time, including from a hot path —
    the cost is the dataclass construction.

    ``family``, ``capabilities``, ``protocol`` are sourced from the
    session (so they reflect the live identity even when ``info``
    is ``None``). ``mode`` is reserved for a future mode-tracking
    hook; today it is always ``None`` (the snapshot is no-I/O by
    contract, so it cannot probe).
    """
    info = self._info
    session = self._session
    name = info.model if info is not None else "balance"
    return SartoriusDeviceSnapshot(
        name=name,
        model=info.model if info is not None else None,
        firmware=str(info.firmware) if info is not None and info.firmware is not None else None,
        serial=(info.serial if info is not None else None),
        connected=session.state.value == "operational",
        last_error=None,
        recoverable_error_count=session.recoverable_error_count,
        captured_at=datetime.now(UTC),
        family=info.family if info is not None else session.family,
        capabilities=info.capabilities if info is not None else session.capabilities,
        protocol=session.active_protocol,
        mode=None,
    )

status async

status()

Read the full 8-byte status block (xBPI 0x30).

Source code in src/sartoriuslib/devices/balance.py
async def status(self) -> BalanceStatus:
    """Read the full 8-byte status block (xBPI ``0x30``)."""
    return await self._session.execute(STATUS_BLOCK, StatusRequest())

stream

stream(
    *,
    rate_hz=None,
    mode="poll",
    temporary_autoprint=False,
    confirm=False,
    timeout=None,
)

Create a per-balance streaming session.

mode="poll" is the default and requires rate_hz. mode="autoprint" consumes existing SBI autoprint output without changing device settings. temporary_autoprint=True is reserved for the future "enable on entry, restore on exit" SBI parameter flow and currently raises :class:NotImplementedError.

Source code in src/sartoriuslib/devices/balance.py
def stream(
    self,
    *,
    rate_hz: float | None = None,
    mode: StreamMode = "poll",
    temporary_autoprint: bool = False,
    confirm: bool = False,
    timeout: float | None = None,
) -> StreamingSession:
    """Create a per-balance streaming session.

    ``mode="poll"`` is the default and requires ``rate_hz``.
    ``mode="autoprint"`` consumes existing SBI autoprint output without
    changing device settings. ``temporary_autoprint=True`` is reserved
    for the future "enable on entry, restore on exit" SBI parameter flow
    and currently raises :class:`NotImplementedError`.
    """
    from sartoriuslib.streaming.stream_session import StreamingSession  # noqa: PLC0415

    return StreamingSession(
        self,
        rate_hz=rate_hz,
        mode=mode,
        temporary_autoprint=temporary_autoprint,
        confirm=confirm,
        timeout=timeout,
    )

tare async

tare()

Run the combined-tare command (xBPI 0x14 / SBI ESC T).

Source code in src/sartoriuslib/devices/balance.py
async def tare(self) -> None:
    """Run the combined-tare command (xBPI ``0x14`` / SBI ``ESC T``)."""
    await self._session.execute(TARE, TareRequest())

temperature async

temperature(sensor=0)

Read one temperature sensor (xBPI 0x76).

Returns a :class:TemperatureReading with celsius=None when sensor is not installed (balance returns the 7f ff ff ff sentinel per docs/protocol.md §9).

Not cached — temperature changes continuously and callers expect a fresh read every call.

Sensor indexing is device-specific — some firmwares are contiguous, some are sparse with reserved slots (the MSE1203S we tested has sensors at 0/1/3 and a sentinel at 2). Use :meth:discover_temperature_sensors to enumerate. READ_TEMPERATURE is :attr:Command.parameterized, so an out-of-range index raises :class:SartoriusIndexOutOfRangeError without poisoning the availability cache for in-range indices.

Source code in src/sartoriuslib/devices/balance.py
async def temperature(self, sensor: int = 0) -> TemperatureReading:
    """Read one temperature sensor (xBPI ``0x76``).

    Returns a :class:`TemperatureReading` with ``celsius=None``
    when ``sensor`` is not installed (balance returns the
    ``7f ff ff ff`` sentinel per ``docs/protocol.md`` §9).

    Not cached — temperature changes continuously and callers
    expect a fresh read every call.

    Sensor indexing is **device-specific** — some firmwares are
    contiguous, some are sparse with reserved slots (the MSE1203S
    we tested has sensors at ``0/1/3`` and a sentinel at ``2``).
    Use :meth:`discover_temperature_sensors` to enumerate.
    ``READ_TEMPERATURE`` is :attr:`Command.parameterized`, so an
    out-of-range index raises
    :class:`SartoriusIndexOutOfRangeError` without poisoning the
    availability cache for in-range indices.
    """
    raw = await self._session.execute(
        READ_TEMPERATURE,
        TemperatureRequest(sensor=sensor),
    )
    # Variant decode can't see the request — fill the sensor
    # field here so the returned dataclass round-trips.
    return dataclasses.replace(raw, sensor=sensor)

write_parameter async

write_parameter(index, value, *, confirm=False)

Write one parameter-table entry (xBPI 0x56). PERSISTENT.

Requires confirm=True. Invalidates the cached entry for index afterwards — conservative so the §6.3 caveat rows (p13 / p50, whose writes don't bump 0xBA) stay consistent.

Source code in src/sartoriuslib/devices/balance.py
async def write_parameter(
    self,
    index: int,
    value: int,
    *,
    confirm: bool = False,
) -> None:
    """Write one parameter-table entry (xBPI ``0x56``). ``PERSISTENT``.

    Requires ``confirm=True``. Invalidates the cached entry for
    ``index`` afterwards — conservative so the §6.3 caveat rows
    (``p13`` / ``p50``, whose writes don't bump ``0xBA``) stay
    consistent.
    """
    await self._session.execute(
        WRITE_PARAMETER,
        WriteParameterRequest(index=index, value=value),
        confirm=confirm,
    )
    self._session.invalidate_cache(_cache_key_parameter(index))

write_sbn_address async

write_sbn_address(
    sbn,
    *,
    update_session_dst=False,
    timeout=None,
    confirm=False,
)

Send xBPI 0x72 to change the balance's SBN address.

DANGEROUS — requires confirm=True. Returns the value read back via 0x71 after the write so the caller can verify. The session's dst_sbn is left unchanged by default because the balance accepts dst_sbn=0x09 regardless of its configured SBN on a direct point-to-point link (docs/protocol.md §2.2). Pass update_session_dst=True on multidrop links where the new SBN must address the device going forward.

Source code in src/sartoriuslib/devices/balance.py
async def write_sbn_address(
    self,
    sbn: int,
    *,
    update_session_dst: bool = False,
    timeout: float | None = None,
    confirm: bool = False,
) -> int:
    """Send xBPI ``0x72`` to change the balance's SBN address.

    ``DANGEROUS`` — requires ``confirm=True``. Returns the value
    read back via ``0x71`` after the write so the caller can
    verify. The session's ``dst_sbn`` is left unchanged by default
    because the balance accepts ``dst_sbn=0x09`` regardless of its
    configured SBN on a direct point-to-point link
    (``docs/protocol.md`` §2.2). Pass ``update_session_dst=True``
    on multidrop links where the new SBN must address the device
    going forward.
    """
    if not confirm:
        raise SartoriusConfirmationRequiredError(
            "write_sbn_address is DANGEROUS; pass confirm=True to execute",
            context=ErrorContext(
                command_name="write_sbn_address",
                extra={"sbn": sbn},
            ),
        )
    if not 0 <= sbn <= _MAX_U8:
        raise SartoriusValidationError(
            f"write_sbn_address: sbn must be 0..0xFF, got {sbn!r}",
            context=ErrorContext(
                command_name="write_sbn_address",
                extra={"sbn": sbn},
            ),
        )
    if self._session.active_protocol is not ProtocolKind.XBPI:
        raise SartoriusError(
            "write_sbn_address requires an xBPI session; "
            "call configure_protocol(ProtocolKind.XBPI, ...) first",
            context=ErrorContext(
                command_name="write_sbn_address",
                protocol=str(self._session.active_protocol.value),
            ),
        )

    await self._session.execute_raw_xbpi(
        0x72,
        encode_tlv(0x21, sbn),
        confirm=True,
        timeout=timeout,
    )
    readback = await self._session.execute(
        READ_SBN,
        IdentityRequest(),
        timeout=timeout,
    )
    if update_session_dst:
        self._session.set_dst_sbn(sbn)
    return readback

zero async

zero()

Run the zeroing command (xBPI 0x18).

Source code in src/sartoriuslib/devices/balance.py
async def zero(self) -> None:
    """Run the zeroing command (xBPI ``0x18``)."""
    await self._session.execute(ZERO, TareRequest())

DeviceSnapshot dataclass

DeviceSnapshot(
    name,
    model,
    firmware,
    serial,
    connected,
    last_error,
    recoverable_error_count,
    captured_at,
)

Cross-library identity + health snapshot.

Built from cached state — :meth:Balance.snapshot never performs I/O. Sibling libraries (alicat, watlow, nidaq) expose the same base shape per unified spec §H so multi-adapter consumers can render every device's snapshot uniformly.

Attributes:

Name Type Description
name str

Device identifier (manager-style name; model fallback when the balance is not under a manager).

model str | None

Cached model string, or None if identify has not run.

firmware str | None

Cached firmware version string, or None.

serial str | None

Cached serial / factory-number string, or None.

connected bool

Whether the underlying session is operational.

last_error ErrorContext | None

Last error context the session attached to a failure, or None when no failure has been observed.

recoverable_error_count int

How many transient errors the session has retried through transparently since open.

captured_at datetime

Wall-clock instant the snapshot was taken (UTC, tz-aware).

SartoriusDeviceSnapshot dataclass

SartoriusDeviceSnapshot(
    name,
    model,
    firmware,
    serial,
    connected,
    last_error,
    recoverable_error_count,
    captured_at,
    family,
    capabilities,
    protocol,
    mode,
)

Bases: DeviceSnapshot

Sartorius-typed snapshot extras.

Adds the family classification, capability bitmap, active protocol, and last-observed mode (None if mode has never been observed on this session — :meth:Balance.snapshot does not probe to find out, by design).

Attributes:

Name Type Description
family BalanceFamily | None

Cached :class:BalanceFamily classification.

capabilities Capability

Bitmap of capabilities the session believes the balance has.

protocol ProtocolKind

Active wire protocol on the underlying session.

mode str | None

Last-observed application mode if the session has tracked one. None until something explicitly sets it; reserved for future mode-aware streaming.

Session

sartoriuslib.devices.session

Session: I/O lock, safety gates, availability cache, prior checks.

One :class:Session wraps one balance on one serial port. It is the single dispatch point between the :class:Balance facade and the protocol clients. Every :meth:Session.execute call walks the gates in the design-doc §6.1 order, then runs the command via the active protocol's client, then updates the per-command availability cache per §6.1.1.

What lives here - Active-protocol selection (xBPI vs SBI). - Safety-tier gate (PERSISTENT / DANGEROUS need confirm=True). - Protocol gate (command's variant for the active protocol must be set). - Availability gate (commands known UNSUPPORTED short-circuit pre-I/O). - Prior gate (family / capability hints; soft by default, hard under strict). - Per-call availability update (success → SUPPORTED, 0x04 → UNSUPPORTED, 0x06 → INAPPLICABLE, else unchanged).

What does not live here - Transport I/O: delegated to the protocol client. - Device identification: the factory pre-computes family / capabilities / firmware and hands them in at construction.

Result cache :meth:cached_execute fronts results keyed on command + caller- supplied cache_key and xBPI's 0xBA config counter (docs/protocol.md §7.11 and design §6.3). Before returning a cached value, the session re-reads 0xBA; a mismatch flushes the entry. Writes invalidate the affected key explicitly — the §6.3 caveat says p13 / p50 writes don't bump the counter, so the cache stays correct only if the :class:Balance facade clears those entries on its own.

Sessions without :attr:`Capability.CONFIG_COUNTER` (WZA, SBI) fall
back to un-cached dispatch transparently — ``cached_execute``
behaves identically to :meth:`execute` in that mode.

Session

Session(
    *,
    xbpi_client=None,
    sbi_client=None,
    active_protocol,
    family=BalanceFamily.UNKNOWN,
    capabilities=NO_CAPABILITY,
    firmware=None,
    src_sbn=1,
    dst_sbn=9,
    strict=False,
    default_timeout=1.0,
    serial_settings=None,
)

One balance, one serial port. Enforces gates, serialises I/O.

Parameters:

Name Type Description Default
xbpi_client XbpiProtocolClient | None

Client for xBPI dispatch, or None if this session is SBI-only.

None
active_protocol ProtocolKind

Which protocol this session currently speaks.

required
family BalanceFamily

Balance family discriminator (from DeviceInfo once identified; UNKNOWN means no prior).

UNKNOWN
capabilities Capability

Bitmap of capabilities believed present.

NO_CAPABILITY
firmware FirmwareVersion | None

Firmware version, if known.

None
src_sbn int

Host SBN address for xBPI frames.

1
dst_sbn int

Balance SBN address for xBPI frames.

9
strict bool

If True, family/capability prior mismatches refuse pre-I/O instead of emitting a warning.

False
default_timeout float

Per-call timeout when the caller passes None to :meth:execute.

1.0
Source code in src/sartoriuslib/devices/session.py
def __init__(
    self,
    *,
    xbpi_client: XbpiProtocolClient | None = None,
    sbi_client: SbiProtocolClient | None = None,
    active_protocol: ProtocolKind,
    family: BalanceFamily = BalanceFamily.UNKNOWN,
    capabilities: Capability = NO_CAPABILITY,
    firmware: FirmwareVersion | None = None,
    src_sbn: int = 0x01,
    dst_sbn: int = 0x09,
    strict: bool = False,
    default_timeout: float = 1.0,
    serial_settings: SerialSettings | None = None,
) -> None:
    if active_protocol is ProtocolKind.AUTO:
        raise SartoriusError(
            "Session cannot be constructed with ProtocolKind.AUTO; "
            "detection must resolve to XBPI or SBI first",
        )
    if active_protocol is ProtocolKind.XBPI and xbpi_client is None:
        raise SartoriusError(
            "Session: active_protocol=XBPI requires xbpi_client",
        )
    if active_protocol is ProtocolKind.SBI and sbi_client is None:
        raise SartoriusError(
            "Session: active_protocol=SBI requires sbi_client",
        )
    self._xbpi = xbpi_client
    self._sbi = sbi_client
    self._active = active_protocol
    self._family = family
    self._capabilities = capabilities
    self._firmware = firmware
    self._src_sbn = src_sbn
    self._dst_sbn = dst_sbn
    self._strict = strict
    self._default_timeout = default_timeout
    self._availability: dict[str, Availability] = {}
    self._warned_priors: set[str] = set()
    # Result cache keyed on caller-supplied cache_key → (counter_snapshot, value).
    # Populated by :meth:`cached_execute`; cleared by :meth:`invalidate_cache`
    # and on any ``0xBA`` mismatch at read time (design §6.3).
    self._result_cache: dict[str, tuple[int, Any]] = {}
    self._state: SessionState = SessionState.OPERATIONAL
    # Counter of recoverable errors the session has retried through
    # transparently. Visible to consumers via the property of the same
    # name and surfaced on :class:`DeviceSnapshot`. Incremented from
    # :func:`open_device` cold-open retries and from any in-flight
    # transient retry path the session implements.
    self._recoverable_error_count: int = 0
    # Serial settings the transport was opened with. Tracked here
    # (not on the transport) so :meth:`Balance.configure_protocol`
    # can roll back to the original framing without coupling the
    # rollback path to a specific Transport implementation.
    self._serial_settings = serial_settings

active_protocol property

active_protocol

Protocol the session currently dispatches through.

capabilities property

capabilities

Bitmap of capabilities believed present on the balance.

default_timeout property

default_timeout

Per-call timeout used when callers pass None.

dst_sbn property

dst_sbn

Balance-side SBN used as the destination in xBPI request frames.

family property

family

Family discriminator; UNKNOWN means no seeded prior.

firmware property

firmware

Firmware version if identified, else None.

recoverable_error_count property

recoverable_error_count

Number of recoverable errors swallowed-and-retried since open.

Bumped by :func:sartoriuslib.open_device's cold-open identify retry loop and by any other transparent retry path the session runs. Resets to 0 only on a fresh :class:Session. Consumers read it directly or via :class:DeviceSnapshot.recoverable_error_count.

sbi_autoprint_active property

sbi_autoprint_active

Whether the SBI session has observed unsolicited autoprint output.

sbi_client property

sbi_client

The SBI protocol client wired to this session, if any.

serial_settings property

serial_settings

Serial settings the transport was opened with, if known.

Set at construction time by :func:sartoriuslib.open_device and updated after a successful :meth:Balance.configure_protocol. None for sessions built from a pre-existing :class:Transport whose framing the library did not control.

src_sbn property

src_sbn

Host-side SBN included in xBPI request frames.

state property

state

Lifecycle state — BROKEN after a failed protocol/baud switch.

strict property

strict

Whether prior mismatches refuse pre-I/O (True) or warn.

transport property

transport

The underlying :class:Transport, regardless of active protocol.

Both protocol clients hold the same transport — return whichever is wired. The constructor enforces that the client matching active_protocol is non-None, so this always returns a real :class:Transport.

xbpi_client property

xbpi_client

The xBPI protocol client wired to this session, if any.

availability_of

availability_of(command_name)

Current availability for command_name (UNKNOWN if unseen).

Source code in src/sartoriuslib/devices/session.py
def availability_of(self, command_name: str) -> Availability:
    """Current availability for ``command_name`` (``UNKNOWN`` if unseen)."""
    return self._availability.get(command_name, Availability.UNKNOWN)

cache_snapshot

cache_snapshot()

Copy of cache_key → counter_snapshot for test assertions.

The actual cached values are intentionally not surfaced — tests assert on the presence and counter pinning of an entry, not its decoded content.

Source code in src/sartoriuslib/devices/session.py
def cache_snapshot(self) -> dict[str, int]:
    """Copy of ``cache_key → counter_snapshot`` for test assertions.

    The actual cached values are intentionally not surfaced —
    tests assert on the *presence* and *counter pinning* of an
    entry, not its decoded content.
    """
    return {key: counter for key, (counter, _value) in self._result_cache.items()}

cached_execute async

cached_execute(
    command,
    request,
    *,
    cache_key,
    confirm=False,
    timeout=None,
)

Dispatch command with a 0xBA-keyed result cache.

Sessions without :attr:Capability.CONFIG_COUNTER fall through to :meth:execute. When the capability is present, the session re-reads the config counter before returning a cached value; any change flushes the entry and the command re-runs.

cache_key is caller-supplied so a command that takes arguments (capacity(area=N), read_parameter(idx)) can cache separate entries per distinct call.

Source code in src/sartoriuslib/devices/session.py
async def cached_execute[Req, Resp](
    self,
    command: Command[Req, Resp],
    request: Req,
    *,
    cache_key: str,
    confirm: bool = False,
    timeout: float | None = None,
) -> Resp:
    """Dispatch ``command`` with a ``0xBA``-keyed result cache.

    Sessions without :attr:`Capability.CONFIG_COUNTER` fall through
    to :meth:`execute`. When the capability is present, the session
    re-reads the config counter before returning a cached value;
    any change flushes the entry and the command re-runs.

    ``cache_key`` is caller-supplied so a command that takes
    arguments (``capacity(area=N)``, ``read_parameter(idx)``) can
    cache separate entries per distinct call.
    """
    if Capability.CONFIG_COUNTER not in self._capabilities:
        return await self.execute(command, request, confirm=confirm, timeout=timeout)
    counter = await self._read_config_counter(timeout=timeout)
    cached = self._result_cache.get(cache_key)
    if cached is not None and cached[0] == counter:
        return cached[1]  # type: ignore[no-any-return]
    result = await self.execute(command, request, confirm=confirm, timeout=timeout)
    self._result_cache[cache_key] = (counter, result)
    return result

check_state

check_state()

Raise :class:SartoriusConnectionError if the session is BROKEN.

Public alias for the internal gate run by every dispatch path, so lifecycle helpers on :class:Balance can reuse the same guard without poking at private attributes.

Source code in src/sartoriuslib/devices/session.py
def check_state(self) -> None:
    """Raise :class:`SartoriusConnectionError` if the session is BROKEN.

    Public alias for the internal gate run by every dispatch path,
    so lifecycle helpers on :class:`Balance` can reuse the same
    guard without poking at private attributes.
    """
    self._check_state()

close async

close()

Close the underlying transport, if one is wired.

Idempotent — safe to call multiple times. The factory owns the transport's construction and hands it into the session via the protocol client; closing the session closes the transport.

Both clients hold the same transport, so close it once via the session's :attr:transport accessor rather than once per client slot — guards against a future caller that wires both clients simultaneously.

Source code in src/sartoriuslib/devices/session.py
async def close(self) -> None:
    """Close the underlying transport, if one is wired.

    Idempotent — safe to call multiple times. The factory owns the
    transport's construction and hands it into the session via the
    protocol client; closing the session closes the transport.

    Both clients hold the same transport, so close it once via the
    session's :attr:`transport` accessor rather than once per client
    slot — guards against a future caller that wires both clients
    simultaneously.
    """
    await self.transport.close()

execute async

execute(command, request, *, confirm=False, timeout=None)

Dispatch command with full pre-I/O gating.

Gates fire in the design doc §6.1 order; each raise happens before any byte is sent, so a gate failure is observably equivalent to the call never leaving the host.

Source code in src/sartoriuslib/devices/session.py
async def execute[Req, Resp](
    self,
    command: Command[Req, Resp],
    request: Req,
    *,
    confirm: bool = False,
    timeout: float | None = None,
) -> Resp:
    """Dispatch ``command`` with full pre-I/O gating.

    Gates fire in the design doc §6.1 order; each raise happens
    before any byte is sent, so a gate failure is observably
    equivalent to the call never leaving the host.
    """
    self._check_state()
    self._gate_safety(command, confirm)
    self._gate_protocol(command)
    self._gate_known_denied(command)
    self._gate_priors(command)
    if self._active is ProtocolKind.XBPI:
        return await self._execute_xbpi(command, request, timeout)
    if self._active is ProtocolKind.SBI:
        return await self._execute_sbi(command, request, timeout)
    raise SartoriusError(f"unreachable: session has no active protocol ({self._active!r})")

execute_raw_sbi async

execute_raw_sbi(
    command, *, confirm=False, timeout=None, expect_lines=1
)

Send an arbitrary SBI command token and return the parsed reply.

Source code in src/sartoriuslib/devices/session.py
async def execute_raw_sbi(
    self,
    command: bytes | str,
    *,
    confirm: bool = False,
    timeout: float | None = None,
    expect_lines: int = 1,
) -> SbiReply:
    """Send an arbitrary SBI command token and return the parsed reply."""
    self._check_state()
    if self._active is not ProtocolKind.SBI:
        raise SartoriusProtocolUnsupportedError(
            f"raw_sbi: session is in {self._active.value.upper()} mode",
            context=ErrorContext(protocol=str(self._active.value)),
        )
    if self._sbi is None:
        raise SartoriusError(
            "raw_sbi: session in SBI mode but no SBI client wired",
            context=ErrorContext(protocol="sbi"),
        )
    token = normalize_token(command)
    if token not in SBI_READ_ONLY_TOKENS and not confirm:
        raise SartoriusConfirmationRequiredError(
            f"raw_sbi: token {token!r} is not on the read-only safe-list; "
            "pass confirm=True to proceed",
            context=ErrorContext(
                sbi_token=token,
                protocol="sbi",
                extra={"safe_listed": False},
            ),
        )
    t = timeout if timeout is not None else self._default_timeout
    if self.sbi_autoprint_active and expect_lines > 0:
        self._raise_sbi_autoprint_active("raw_sbi", sbi_token=token)
    reply = await self._sbi.execute(
        token,
        timeout=t,
        command_name="raw_sbi",
        sbi_token=token,
        expect_lines=expect_lines,
    )
    if self._reply_is_surprise_autoprint(reply, sbi_token=token):
        self._sbi.mark_autoprint_active(pending=reply.raw)
        self._raise_sbi_autoprint_active("raw_sbi", sbi_token=token)
    return reply

execute_raw_xbpi async

execute_raw_xbpi(
    opcode, args=b"", *, confirm=False, timeout=None
)

Send an arbitrary xBPI opcode and return the raw reply frame.

Bypasses the declarative :class:Command layer — the opcode is a per-call parameter, so none of the prior / capability gating applies. The one hard gate is a safe-list check: opcodes in :data:sartoriuslib.commands.raw.SAFE_READ_ONLY_OPCODES run freely; anything else requires confirm=True because the library cannot know it is safe.

The availability cache is not updated — raw calls are opaque at the command-name level.

Source code in src/sartoriuslib/devices/session.py
async def execute_raw_xbpi(
    self,
    opcode: int,
    args: bytes = b"",
    *,
    confirm: bool = False,
    timeout: float | None = None,
) -> XbpiFrame:
    """Send an arbitrary xBPI opcode and return the raw reply frame.

    Bypasses the declarative :class:`Command` layer — the opcode is
    a per-call parameter, so none of the prior / capability gating
    applies. The one hard gate is a safe-list check: opcodes in
    :data:`sartoriuslib.commands.raw.SAFE_READ_ONLY_OPCODES` run
    freely; anything else requires ``confirm=True`` because the
    library cannot know it is safe.

    The availability cache is *not* updated — raw calls are opaque
    at the command-name level.
    """
    self._check_state()
    if self._active is not ProtocolKind.XBPI:
        raise SartoriusProtocolUnsupportedError(
            f"raw_xbpi: session is in {self._active.value.upper()} mode",
            context=ErrorContext(
                opcode=opcode,
                protocol=str(self._active.value),
            ),
        )
    if self._xbpi is None:
        raise SartoriusError(
            "raw_xbpi: session in XBPI mode but no xBPI client wired",
            context=ErrorContext(opcode=opcode, protocol="xbpi"),
        )
    if opcode not in SAFE_READ_ONLY_OPCODES and not confirm:
        raise SartoriusConfirmationRequiredError(
            f"raw_xbpi: opcode 0x{opcode:02x} is not on the read-only safe-list; "
            "pass confirm=True to proceed",
            context=ErrorContext(
                opcode=opcode,
                protocol="xbpi",
                extra={"safe_listed": False},
            ),
        )
    request_bytes = build_command(
        opcode,
        args,
        src_sbn=self._src_sbn,
        dst_sbn=self._dst_sbn,
    )
    t = timeout if timeout is not None else self._default_timeout
    return await self._xbpi.execute(
        request_bytes,
        timeout=t,
        command_name=f"raw_xbpi[0x{opcode:02x}]",
        opcode=opcode,
    )

invalidate_cache

invalidate_cache(cache_key=None)

Drop one cached entry, or clear all when cache_key is None.

The :class:Balance facade calls this after writes whose 0xBA bump isn't guaranteed (the §6.3 caveat). Unknown keys are a silent no-op — idempotent.

Source code in src/sartoriuslib/devices/session.py
def invalidate_cache(self, cache_key: str | None = None) -> None:
    """Drop one cached entry, or clear all when ``cache_key`` is ``None``.

    The :class:`Balance` facade calls this after writes whose
    ``0xBA`` bump isn't guaranteed (the §6.3 caveat). Unknown
    keys are a silent no-op — idempotent.
    """
    if cache_key is None:
        self._result_cache.clear()
    else:
        self._result_cache.pop(cache_key, None)

mark_broken

mark_broken()

Transition the session to :attr:SessionState.BROKEN.

Called only from lifecycle operations (Balance.configure_protocol) when a rollback fails. Once broken, every subsequent dispatch refuses with :class:SartoriusConnectionError.

Source code in src/sartoriuslib/devices/session.py
def mark_broken(self) -> None:
    """Transition the session to :attr:`SessionState.BROKEN`.

    Called only from lifecycle operations
    (``Balance.configure_protocol``) when a rollback fails. Once
    broken, every subsequent dispatch refuses with
    :class:`SartoriusConnectionError`.
    """
    self._state = SessionState.BROKEN

read_sbi_autoprint_reading async

read_sbi_autoprint_reading(*, timeout=None)

Read the next valid SBI autoprint weight line without writing.

Source code in src/sartoriuslib/devices/session.py
async def read_sbi_autoprint_reading(
    self,
    *,
    timeout: float | None = None,
) -> Reading:
    """Read the next valid SBI autoprint weight line without writing."""
    if self._active is not ProtocolKind.SBI:
        raise SartoriusProtocolUnsupportedError(
            f"read_sbi_autoprint_reading: session is in {self._active.value.upper()} mode",
            context=ErrorContext(protocol=str(self._active.value)),
        )
    if self._sbi is None:
        raise SartoriusError(
            "read_sbi_autoprint_reading: session in SBI mode but no SBI client wired",
            context=ErrorContext(protocol="sbi"),
        )
    t = timeout if timeout is not None else self._default_timeout
    deadline = anyio.current_time() + t
    while True:
        remaining = max(0.001, deadline - anyio.current_time())
        reply = await self._sbi.read_line(timeout=remaining)
        try:
            reading = require_reading(reply)
        except SartoriusParseError:
            if anyio.current_time() >= deadline:
                raise
            continue
        self._sbi.mark_autoprint_active()
        return reading

read_sbi_line async

read_sbi_line(*, timeout=None)

Read one unsolicited SBI line, used by autoprint streaming.

Source code in src/sartoriuslib/devices/session.py
async def read_sbi_line(self, *, timeout: float | None = None) -> SbiReply:
    """Read one unsolicited SBI line, used by autoprint streaming."""
    if self._active is not ProtocolKind.SBI:
        raise SartoriusProtocolUnsupportedError(
            f"read_sbi_line: session is in {self._active.value.upper()} mode",
            context=ErrorContext(protocol=str(self._active.value)),
        )
    if self._sbi is None:
        raise SartoriusError(
            "read_sbi_line: session in SBI mode but no SBI client wired",
            context=ErrorContext(protocol="sbi"),
        )
    t = timeout if timeout is not None else self._default_timeout
    return await self._sbi.read_line(timeout=t)

record_recoverable_error

record_recoverable_error()

Increment the recoverable-error counter.

Called from retry sites — :func:sartoriuslib.open_device's cold-open swallow loop, future inline transient retries. Public because the counter is observed by :class:DeviceSnapshot; callers should not normally mutate it.

Source code in src/sartoriuslib/devices/session.py
def record_recoverable_error(self) -> None:
    """Increment the recoverable-error counter.

    Called from retry sites — :func:`sartoriuslib.open_device`'s
    cold-open swallow loop, future inline transient retries. Public
    because the counter is observed by :class:`DeviceSnapshot`;
    callers should not normally mutate it.
    """
    self._recoverable_error_count += 1

refresh_sbi_autoprint_state async

refresh_sbi_autoprint_state(*, timeout=None)

Passively re-sniff whether SBI autoprint is currently active.

Source code in src/sartoriuslib/devices/session.py
async def refresh_sbi_autoprint_state(self, *, timeout: float | None = None) -> bool:
    """Passively re-sniff whether SBI autoprint is currently active."""
    if self._active is not ProtocolKind.SBI:
        raise SartoriusProtocolUnsupportedError(
            "refresh_sbi_autoprint_state requires an SBI session",
            context=ErrorContext(protocol=str(self._active.value)),
        )
    if self._sbi is None:
        raise SartoriusError(
            "refresh_sbi_autoprint_state: session in SBI mode but no SBI client wired",
            context=ErrorContext(protocol="sbi"),
        )
    return await self._sbi.refresh_autoprint_state(timeout=timeout)

replace_clients

replace_clients(
    *,
    xbpi_client,
    sbi_client,
    active_protocol,
    serial_settings=None,
)

Swap protocol clients atomically — used by configure_protocol.

The host-side flip closes the old protocol client, reopens the transport at new serial framing, builds a new client, and verifies. On verification success this method installs the new clients and the new active protocol; the availability cache, prior warnings, and result cache are all cleared because the command surface changes when the protocol does (xBPI-only commands have no SBI variant and vice versa, and any 0xBA-pinned cache entries belong to the old session).

Refuses to install ProtocolKind.AUTO — detection must resolve to XBPI or SBI first. Refuses if the corresponding client for active_protocol is missing.

Source code in src/sartoriuslib/devices/session.py
def replace_clients(
    self,
    *,
    xbpi_client: XbpiProtocolClient | None,
    sbi_client: SbiProtocolClient | None,
    active_protocol: ProtocolKind,
    serial_settings: SerialSettings | None = None,
) -> None:
    """Swap protocol clients atomically — used by ``configure_protocol``.

    The host-side flip closes the old protocol client, reopens the
    transport at new serial framing, builds a new client, and
    verifies. On verification success this method installs the new
    clients and the new active protocol; the availability cache,
    prior warnings, and result cache are all
    cleared because the command surface changes when the protocol
    does (xBPI-only commands have no SBI variant and vice versa,
    and any ``0xBA``-pinned cache entries belong to the old session).

    Refuses to install ``ProtocolKind.AUTO`` — detection must
    resolve to ``XBPI`` or ``SBI`` first. Refuses if the
    corresponding client for ``active_protocol`` is missing.
    """
    if active_protocol is ProtocolKind.AUTO:
        raise SartoriusError(
            "replace_clients: cannot install ProtocolKind.AUTO; "
            "detection must resolve to XBPI or SBI first",
        )
    if active_protocol is ProtocolKind.XBPI and xbpi_client is None:
        raise SartoriusError(
            "replace_clients: active_protocol=XBPI but xbpi_client is None",
        )
    if active_protocol is ProtocolKind.SBI and sbi_client is None:
        raise SartoriusError(
            "replace_clients: active_protocol=SBI but sbi_client is None",
        )
    self._xbpi = xbpi_client
    self._sbi = sbi_client
    self._active = active_protocol
    if serial_settings is not None:
        self._serial_settings = serial_settings
    # Cross-protocol availability and prior warnings do not
    # transfer — clear them so the new protocol starts clean.
    self._availability.clear()
    self._warned_priors.clear()
    self._result_cache.clear()

set_dst_sbn

set_dst_sbn(dst_sbn)

Update the destination SBN.

Used after :meth:Balance.write_sbn_address on multidrop links where the new address must address the device going forward.

Source code in src/sartoriuslib/devices/session.py
def set_dst_sbn(self, dst_sbn: int) -> None:
    """Update the destination SBN.

    Used after :meth:`Balance.write_sbn_address` on multidrop links
    where the new address must address the device going forward.
    """
    self._dst_sbn = dst_sbn

update_identity

update_identity(
    *, family=None, capabilities=None, firmware=None
)

Replace session-level identity state after a live identify call.

Called by :func:sartoriuslib.devices.factory.open_device after running the identify commands, so subsequent prior gating sees the discovered family and capabilities instead of the placeholder UNKNOWN / empty values.

Each argument left as None keeps the existing value.

Source code in src/sartoriuslib/devices/session.py
def update_identity(
    self,
    *,
    family: BalanceFamily | None = None,
    capabilities: Capability | None = None,
    firmware: FirmwareVersion | None = None,
) -> None:
    """Replace session-level identity state after a live identify call.

    Called by :func:`sartoriuslib.devices.factory.open_device` after
    running the identify commands, so subsequent prior gating sees
    the discovered family and capabilities instead of the
    placeholder ``UNKNOWN`` / empty values.

    Each argument left as ``None`` keeps the existing value.
    """
    if family is not None:
        self._family = family
    if capabilities is not None:
        self._capabilities = capabilities
    if firmware is not None:
        self._firmware = firmware

SessionState

Bases: Enum

Lifecycle state of a :class:Session.

OPERATIONAL is the normal state — commands dispatch freely. BROKEN is entered when an atomic lifecycle operation (Balance.configure_protocol) cannot reconcile the transport with the device's new state. A BROKEN session refuses every subsequent :meth:execute with :class:SartoriusConnectionError; the caller must construct a fresh session (typically via :func:sartoriuslib.open_device) to recover.

Factory — open_device / open_balance

sartoriuslib.devices.factory

open_device — primary async entry point for the library.

Supports forced xBPI, forced SBI, and ProtocolKind.AUTO opens. open_device is the canonical name for cross-library uniformity with alicatlib, watlowlib, and nidaqlib (unified spec §A).

The factory owns the transport's construction and wires it through the xBPI protocol client and the :class:Session into the returned :class:Balance. The balance's async-context-manager exit closes the transport. If any step between open and balance-construction fails, the factory closes the transport before propagating the exception.

Cold-open USB races where the device drops the first byte or two of its reply surface as :class:SartoriusTransientTransportError (unified spec §F). :func:open_device swallows up to three such transients during the first identify with a 50 ms backoff so consumers do not need to know about cold-open at all. Post-open transients still surface to callers — the only invisibly-retried window is the open itself.

open_device async

open_device(
    port,
    *,
    protocol=ProtocolKind.XBPI,
    serial_settings=None,
    timeout=1.0,
    src_sbn=1,
    dst_sbn=9,
    strict=False,
    identify=True,
)

Open a serial port, wire up the protocol stack, and return a :class:Balance.

Parameters:

Name Type Description Default
port str | Transport

Serial-port path (e.g. "/dev/ttyUSB0") or a pre-built :class:Transport (useful for tests — supply a :class:FakeTransport to drive a session without hardware).

required
protocol ProtocolKind

Which wire protocol to speak. :attr:AUTO runs the conservative detector from :func:sartoriuslib.protocol.detect_protocol (passive SBI autoprint sniff → xBPI 0x02 probe → SBI ESC x1_ probe → fail clearly) at the caller's serial settings.

XBPI
serial_settings SerialSettings | None

Override the default 8-O-1 @ 9600 baud configuration. Ignored when port is already a :class:Transport.

None
timeout float

Per-call default timeout for both transport I/O and :class:XbpiProtocolClient requests.

1.0
src_sbn int

Host xBPI bus address (default 0x01).

1
dst_sbn int

Balance xBPI bus address (default 0x09 — factory default).

9
strict bool

If True, family / capability prior mismatches refuse pre-I/O on the :class:Session (design §6.1).

False
identify bool

Run the identify commands on open and cache :class:DeviceInfo on the balance. Propagates family + seeded capabilities back into the session for subsequent prior gating.

True

Raises:

Type Description
SartoriusError

AUTO detection found no responsive xBPI or SBI device on the line.

SartoriusConnectionError

Transport failed to open.

Returns:

Name Type Description
A Balance

class:Balance async-context-manager. Exiting the context

Balance

closes the transport.

Source code in src/sartoriuslib/devices/factory.py
async def open_device(
    port: str | Transport,
    *,
    protocol: ProtocolKind = ProtocolKind.XBPI,
    serial_settings: SerialSettings | None = None,
    timeout: float = 1.0,
    src_sbn: int = 0x01,
    dst_sbn: int = 0x09,
    strict: bool = False,
    identify: bool = True,
) -> Balance:
    """Open a serial port, wire up the protocol stack, and return a :class:`Balance`.

    Arguments:
        port: Serial-port path (e.g. ``"/dev/ttyUSB0"``) or a pre-built
            :class:`Transport` (useful for tests — supply a
            :class:`FakeTransport` to drive a session without hardware).
        protocol: Which wire protocol to speak. :attr:`AUTO` runs the
            conservative detector from
            :func:`sartoriuslib.protocol.detect_protocol` (passive SBI
            autoprint sniff → xBPI ``0x02`` probe → SBI ``ESC x1_``
            probe → fail clearly) at the caller's serial settings.
        serial_settings: Override the default 8-O-1 @ 9600 baud
            configuration. Ignored when ``port`` is already a
            :class:`Transport`.
        timeout: Per-call default timeout for both transport I/O and
            :class:`XbpiProtocolClient` requests.
        src_sbn: Host xBPI bus address (default ``0x01``).
        dst_sbn: Balance xBPI bus address (default ``0x09`` — factory
            default).
        strict: If ``True``, family / capability prior mismatches refuse
            pre-I/O on the :class:`Session` (design §6.1).
        identify: Run the identify commands on open and cache
            :class:`DeviceInfo` on the balance. Propagates family +
            seeded capabilities back into the session for subsequent
            prior gating.

    Raises:
        SartoriusError: ``AUTO`` detection found no responsive xBPI or
            SBI device on the line.
        SartoriusConnectionError: Transport failed to open.

    Returns:
        A :class:`Balance` async-context-manager. Exiting the context
        closes the transport.
    """
    transport, settings = _resolve_transport(port, serial_settings)
    if not transport.is_open:
        await transport.open()

    try:
        resolved_protocol, detection = await _resolve_protocol(
            protocol,
            transport,
            timeout=timeout,
            src_sbn=src_sbn,
            dst_sbn=dst_sbn,
        )
        xbpi_client, sbi_client = _build_clients(
            resolved_protocol,
            transport,
            timeout=timeout,
        )
        if sbi_client is not None:
            await _prime_sbi_autoprint_state(
                sbi_client,
                detection,
                timeout=timeout,
            )
        session = Session(
            xbpi_client=xbpi_client,
            sbi_client=sbi_client,
            active_protocol=resolved_protocol,
            src_sbn=src_sbn,
            dst_sbn=dst_sbn,
            strict=strict,
            default_timeout=timeout,
            serial_settings=settings,
        )
        balance = Balance(session)
        if identify and session.sbi_autoprint_active:
            raise SartoriusAutoprintActiveError(
                "SBI autoprint is active; identify() replies are not reliable. "
                "Open with identify=False and use stream(mode='autoprint') or poll(), "
                "or disable autoprint on the balance before opening with identify=True.",
                context=ErrorContext(
                    command_name="identify",
                    protocol="sbi",
                    extra={"autoprint_active": True},
                ),
            )
        if identify:
            await _identify_with_cold_open_retry(balance, session)
    except BaseException:
        await transport.close()
        raise
    return balance

Discovery

sartoriuslib.devices.discovery

Port discovery + :class:DiscoveryResult + :func:find_devices.

The unified spec (UNIFIED_API_HANDOFF.md §B) defines a cross-library DiscoveryResult base shape that every sibling adapter (alicat, watlow, nidaq, sartorius) returns one row of per probe attempt. Sartorius-specific framing extras (parity, stopbits, SBI autoprint state) live on :class:SartoriusDiscoveryResult — a subclass that satisfies list[DiscoveryResult] for the cross-lib contract while preserving sartoriuslib-specific metadata callers depend on.

Two layers, two helpers:

  • :func:discover_port — open one port at one serial-settings configuration, run the conservative :func:sartoriuslib.protocol.detect_protocol probe, return a :class:SartoriusDiscoveryResult. The caller picks the framing.
  • :func:find_devices — sweep a set of baudrates against one or more ports (default: every port :func:anyserial.list_serial_ports enumerates) and return one :class:SartoriusDiscoveryResult per probe attempt. Callers wanting a per-port best-hit answer use :func:summarize_discovery to fold attempts into :class:DiscoverySummary rows.

Both helpers are READ-ONLY — neither writes anything beyond what :func:detect_protocol already sends (an xBPI READ_MODEL and optionally ESC x1_ / ESC P). No tare, no zero, no autoprint toggling.

Design reference: docs/design.md §4.3, §16 Q2.

DiscoveryResult dataclass

DiscoveryResult(
    ok,
    port,
    address,
    baudrate,
    protocol,
    device_info,
    error,
    elapsed_s,
)

Outcome of one probe attempt — the cross-library base shape.

The unified spec (§B) pins these fields across sibling libraries. Use :class:SartoriusDiscoveryResult (a subclass) for the typed sartorius-specific extras; treat this base shape as the lowest common denominator multi-adapter consumers can rely on.

Attributes:

Name Type Description
ok bool

True when the probe resolved to a wire protocol.

port str

The port label (path or pre-built transport's label).

address str | int | None

SBN address for xBPI hits, None for SBI (which is point-to-point) or for failed probes.

baudrate int | None

Effective baudrate during the probe; None when the port could not be opened at all.

protocol ProtocolKind | None

Resolved :class:ProtocolKind on success, None when no responsive device was found.

device_info DeviceInfo | None

Identity snapshot from a successful probe. None for failures or for probes that resolved a wire protocol but did not run identify (e.g. SBI autoprint).

error SartoriusError | None

The :class:SartoriusError captured on a failed probe, None on success.

elapsed_s float

Probe wall-clock duration in seconds.

DiscoverySummary dataclass

DiscoverySummary(
    port,
    ok,
    baudrate,
    protocol,
    autoprint_active,
    error,
    elapsed_s,
)

Per-port roll-up of one or more :class:SartoriusDiscoveryResult probes.

Returned by :func:summarize_discovery. The lowest-cost ergonomic "give me one row per port" shape for callers (Setup-editor Discover dialog, sarto-discover print output) that don't want to fold multi-baud attempts themselves.

Attributes:

Name Type Description
port str

The port label.

ok bool

True when at least one probe attempt resolved a protocol.

baudrate int | None

First successful baudrate on a hit; the last attempted baudrate on a miss; None when no probe ran (port open failure short-circuited).

protocol ProtocolKind | None

Resolved :class:ProtocolKind on a hit, None otherwise.

autoprint_active bool

Carried from the winning probe on hits.

error SartoriusError | None

First non-None SartoriusError from the sweep — either the port-open exception (always wins) or the last per-baud miss reason.

elapsed_s float

Sum of every per-probe elapsed time for the port.

SartoriusDiscoveryResult dataclass

SartoriusDiscoveryResult(
    ok,
    port,
    address,
    baudrate,
    protocol,
    device_info,
    error,
    elapsed_s,
    parity="O",
    stopbits=1,
    autoprint_active=False,
    pending_lines=tuple(),
)

Bases: DiscoveryResult

Sartorius-typed probe result with serial-framing + autoprint extras.

Adds the per-probe framing details and SBI autoprint state that the cross-lib base shape doesn't carry. Consumers reading the unified surface use DiscoveryResult fields uniformly; sartoriuslib callers (capa Discover dialog, sarto-discover) read the subclass extras directly.

Attributes:

Name Type Description
parity str

Effective parity during the probe.

stopbits int

Effective stop bits during the probe (1, 1.5, 2).

autoprint_active bool

True when the passive sniff window observed unsolicited SBI autoprint output.

pending_lines tuple[bytes, ...]

CRLF-terminated SBI lines consumed during the sniff that the caller may want to re-queue when opening a live SBI client (so the first autoprint sample is not lost).

discover_port async

discover_port(
    port,
    *,
    serial_settings=None,
    timeout=1.0,
    sniff_window=0.25,
    src_sbn=1,
    dst_sbn=9,
)

Open port at serial_settings and run the conservative detect.

Returns a :class:SartoriusDiscoveryResult capturing the chosen framing and the detector's verdict. The transport is closed before returning. Failures during detect_protocol (no responsive device, hard transport faults) surface in the result's error field rather than being raised — discovery is meant to be safe to call against unknown ports without crashing the caller. Port-open failures (busy port, missing device) likewise return a non-ok result rather than raising.

Source code in src/sartoriuslib/devices/discovery.py
async def discover_port(
    port: str | Transport,
    *,
    serial_settings: SerialSettings | None = None,
    timeout: float = 1.0,
    sniff_window: float = 0.25,
    src_sbn: int = 0x01,
    dst_sbn: int = 0x09,
) -> SartoriusDiscoveryResult:
    """Open ``port`` at ``serial_settings`` and run the conservative detect.

    Returns a :class:`SartoriusDiscoveryResult` capturing the chosen
    framing and the detector's verdict. The transport is closed before
    returning. Failures during ``detect_protocol`` (no responsive
    device, hard transport faults) surface in the result's ``error``
    field rather than being raised — discovery is meant to be safe to
    call against unknown ports without crashing the caller. Port-open
    failures (busy port, missing device) likewise return a non-``ok``
    result rather than raising.
    """
    transport, settings = _resolve_transport(port, serial_settings)
    label = transport.label
    start = anyio.current_time()
    try:
        if not transport.is_open:
            try:
                await transport.open()
            except SartoriusError as exc:
                return SartoriusDiscoveryResult(
                    ok=False,
                    port=label,
                    address=None,
                    baudrate=settings.baudrate,
                    protocol=None,
                    device_info=None,
                    error=exc,
                    elapsed_s=anyio.current_time() - start,
                    parity=settings.parity.value,
                    stopbits=int(settings.stopbits.value),
                )
        try:
            detection = await detect_protocol(
                transport,
                timeout=timeout,
                sniff_window=sniff_window,
                src_sbn=src_sbn,
                dst_sbn=dst_sbn,
            )
        except SartoriusError as exc:
            return SartoriusDiscoveryResult(
                ok=False,
                port=label,
                address=None,
                baudrate=settings.baudrate,
                protocol=None,
                device_info=None,
                error=exc,
                elapsed_s=anyio.current_time() - start,
                parity=settings.parity.value,
                stopbits=int(settings.stopbits.value),
            )
        return SartoriusDiscoveryResult(
            ok=True,
            port=label,
            address=dst_sbn if detection.protocol.value == "xbpi" else None,
            baudrate=settings.baudrate,
            protocol=detection.protocol,
            device_info=None,
            error=None,
            elapsed_s=anyio.current_time() - start,
            parity=settings.parity.value,
            stopbits=int(settings.stopbits.value),
            autoprint_active=detection.autoprint_active,
            pending_lines=detection.pending_lines,
        )
    finally:
        with contextlib.suppress(SartoriusError):
            await transport.close()

find_devices async

find_devices(
    *,
    ports=None,
    baudrates=None,
    per_probe_timeout_s=0.5,
    sniff_window_s=0.25,
)

Probe local serial ports for Sartorius balances, sweeping baudrates.

Returns one :class:SartoriusDiscoveryResult per probe attempt — one port × one baudrate. Callers wanting a per-port best-hit answer fold the list via :func:summarize_discovery.

For each port in ports (or every port :func:anyserial.list_serial_ports enumerates when ports is None), call :func:discover_port once per baudrate in baudrates (or :data:DEFAULT_DISCOVERY_BAUDRATES when None) until either:

  • a probe reports ok=True (first hit wins for that port — the sweep short-circuits remaining bauds), or
  • a probe's port-open failure short-circuits the port (other bauds would fail the same way), or
  • every baud has been tried without a hit.

The function is read-only and never raises: it only calls :func:discover_port, which only sends the conservative READ_MODEL / ESC x1_ / ESC P probes, and captures every exception into a non-ok result. No tare, no zero, no autoprint toggling.

Parameters:

Name Type Description Default
ports Sequence[str] | None

Explicit ports to probe. None enumerates via :func:anyserial.list_serial_ports. Order is preserved in the result.

None
baudrates Sequence[int] | None

Baudrates to sweep per port, in the order to try them. None uses :data:DEFAULT_DISCOVERY_BAUDRATES.

None
per_probe_timeout_s float

Per-probe :func:detect_protocol timeout. A wrong-baud probe times out fast at this bound, so a 5-baud × 5-port sweep is ~12.5 s wall-clock.

0.5
sniff_window_s float

Per-probe passive SBI autoprint sniff window.

0.25

Returns:

Name Type Description
One list[SartoriusDiscoveryResult]

class:SartoriusDiscoveryResult per probe attempt, in

list[SartoriusDiscoveryResult]

port-then-baud order.

Source code in src/sartoriuslib/devices/discovery.py
async def find_devices(
    *,
    ports: Sequence[str] | None = None,
    baudrates: Sequence[int] | None = None,
    per_probe_timeout_s: float = 0.5,
    sniff_window_s: float = 0.25,
) -> list[SartoriusDiscoveryResult]:
    """Probe local serial ports for Sartorius balances, sweeping baudrates.

    Returns one :class:`SartoriusDiscoveryResult` per *probe attempt* —
    one port × one baudrate. Callers wanting a per-port best-hit
    answer fold the list via :func:`summarize_discovery`.

    For each port in ``ports`` (or every port
    :func:`anyserial.list_serial_ports` enumerates when ``ports`` is
    ``None``), call :func:`discover_port` once per baudrate in
    ``baudrates`` (or :data:`DEFAULT_DISCOVERY_BAUDRATES` when ``None``)
    until either:

    - a probe reports ``ok=True`` (first hit wins for that port — the
      sweep short-circuits remaining bauds), or
    - a probe's port-open failure short-circuits the port (other bauds
      would fail the same way), or
    - every baud has been tried without a hit.

    The function is **read-only** and **never raises**: it only calls
    :func:`discover_port`, which only sends the conservative
    ``READ_MODEL`` / ``ESC x1_`` / ``ESC P`` probes, and captures every
    exception into a non-``ok`` result. No tare, no zero, no autoprint
    toggling.

    Arguments:
        ports: Explicit ports to probe. ``None`` enumerates via
            :func:`anyserial.list_serial_ports`. Order is preserved
            in the result.
        baudrates: Baudrates to sweep per port, in the order to try
            them. ``None`` uses :data:`DEFAULT_DISCOVERY_BAUDRATES`.
        per_probe_timeout_s: Per-probe :func:`detect_protocol`
            timeout. A wrong-baud probe times out fast at this bound,
            so a 5-baud × 5-port sweep is ~12.5 s wall-clock.
        sniff_window_s: Per-probe passive SBI autoprint sniff window.

    Returns:
        One :class:`SartoriusDiscoveryResult` per probe attempt, in
        port-then-baud order.
    """
    bauds = tuple(baudrates) if baudrates is not None else DEFAULT_DISCOVERY_BAUDRATES
    port_list = await _resolve_ports(ports)
    results: list[SartoriusDiscoveryResult] = []
    for port in port_list:
        for baud in bauds:
            settings = SerialSettings(port=port, baudrate=baud)
            result = await discover_port(
                port,
                serial_settings=settings,
                timeout=per_probe_timeout_s,
                sniff_window=sniff_window_s,
            )
            results.append(result)
            if result.ok:
                # First hit per port wins.
                break
            if _is_port_open_failure(result):
                # A port that fails to open at one baud will fail the
                # same way at every other baud — short-circuit.
                break
    return results

summarize_discovery

summarize_discovery(results)

Fold per-probe results into one :class:DiscoverySummary per port.

Port order is preserved (first-appearance wins). For each port the summary picks the first ok probe as the winning row; if no probe succeeded the port's last probe contributes the failure reason.

Source code in src/sartoriuslib/devices/discovery.py
def summarize_discovery(
    results: Iterable[SartoriusDiscoveryResult],
) -> list[DiscoverySummary]:
    """Fold per-probe results into one :class:`DiscoverySummary` per port.

    Port order is preserved (first-appearance wins). For each port the
    summary picks the first ``ok`` probe as the winning row; if no probe
    succeeded the port's last probe contributes the failure reason.
    """
    by_port: dict[str, list[SartoriusDiscoveryResult]] = {}
    for r in results:
        by_port.setdefault(r.port, []).append(r)

    summaries: list[DiscoverySummary] = []
    for port, probes in by_port.items():
        elapsed = sum(p.elapsed_s for p in probes)
        hit = next((p for p in probes if p.ok), None)
        if hit is not None:
            summaries.append(
                DiscoverySummary(
                    port=port,
                    ok=True,
                    baudrate=hit.baudrate,
                    protocol=hit.protocol,
                    autoprint_active=hit.autoprint_active,
                    error=None,
                    elapsed_s=elapsed,
                ),
            )
            continue
        last = probes[-1]
        first_error = next((p.error for p in probes if p.error is not None), None)
        summaries.append(
            DiscoverySummary(
                port=port,
                ok=False,
                baudrate=last.baudrate,
                protocol=None,
                autoprint_active=False,
                error=first_error,
                elapsed_s=elapsed,
            ),
        )
    return summaries