Skip to content

sartoriuslib.devices

Balance, Session, the public dataclasses (Reading, BalanceStatus, DeviceInfo, …), BalanceFamily, Capability, SafetyTier, open_device, and discovery helpers. See Balances, Readings, and Design §5–§7.

Public surface

sartoriuslib.devices

Device-layer public surface.

Re-exports the enums, capability bitmap, and (once implemented) the :class:Balance facade and session machinery. See design doc §5 and §6.

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
    # 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.

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.

aclose async

aclose()

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 aclose(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()

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()

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)

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 decoder and the Phase-7 SBI decoder both build the same shape. That is the whole point of the dual-protocol seam (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).

aclose async

aclose()

Close the underlying transport. Idempotent.

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

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)

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, which the device emits past the last valid index. 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`, which the device
    emits past the last valid index. 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,
    )

    discovered: list[int] = []
    for sensor in range(max_index + 1):
        try:
            reading = await self.temperature(sensor)
        except SartoriusIndexOutOfRangeError:
            # Past the last valid index — clean 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)

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.

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.
    """
    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())

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
    # 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.

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.

aclose async

aclose()

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 aclose(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()

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()

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)

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 name documented for cross-library uniformity with alicatlib; :func:open_balance is a friendly alias.

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.

open_balance async

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

Friendly alias for :func:open_device.

Returns the same :class:Balance as :func:open_device with identical arguments. open_device is documented as primary for cross-library uniformity with alicatlib; open_balance reads more naturally inside the sartoriuslib namespace.

Source code in src/sartoriuslib/devices/factory.py
async def open_balance(
    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:
    """Friendly alias for :func:`open_device`.

    Returns the same :class:`Balance` as :func:`open_device` with
    identical arguments. ``open_device`` is documented as primary for
    cross-library uniformity with ``alicatlib``; ``open_balance`` reads
    more naturally inside the sartoriuslib namespace.
    """
    return await open_device(
        port,
        protocol=protocol,
        serial_settings=serial_settings,
        timeout=timeout,
        src_sbn=src_sbn,
        dst_sbn=dst_sbn,
        strict=strict,
        identify=identify,
    )

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 balance.identify()
    except BaseException:
        await transport.close()
        raise
    return balance

Discovery

sartoriuslib.devices.discovery

Port discovery + :class:DiscoveryResult.

Wide baud/parity sweeps live here — not in open_device. See design doc §4.3 and §16 Q2.

A single helper, :func:discover_port, opens the caller's transport at the given serial settings, runs the conservative :func:sartoriuslib.protocol.detect_protocol probe, and returns a :class:DiscoveryResult. Wider serial-settings probing is deferred — the design doc explicitly leans toward "user supplies serial params" (§16 Q2) and adding sweeps now would commit to a sweep order before field evidence shapes it.

DiscoveryResult dataclass

DiscoveryResult(
    port,
    baudrate,
    parity,
    stopbits,
    protocol,
    autoprint_active=False,
    pending_lines=tuple(),
    error=None,
)

Outcome of probing one port at one serial-settings configuration.

Attributes:

Name Type Description
port str

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

baudrate / parity / stopbits

Effective framing during the probe.

protocol ProtocolKind | None

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

autoprint_active bool

Whether the probe observed unsolicited SBI autoprint output during the passive sniff window.

pending_lines tuple[bytes, ...]

Sniffed autoprint lines (with CRLF) the caller may want to re-queue on a follow-up open.

error str | None

Human-readable error description when protocol is None.

ok property

ok

True when detection resolved to a wire protocol.

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

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,
) -> DiscoveryResult:
    """Open ``port`` at ``serial_settings`` and run the conservative detect.

    Returns a :class:`DiscoveryResult` 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.
    """
    transport, settings = _resolve_transport(port, serial_settings)
    if not transport.is_open:
        await transport.open()
    try:
        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 DiscoveryResult(
                port=transport.label,
                baudrate=settings.baudrate,
                parity=settings.parity.value,
                stopbits=int(settings.stopbits.value),
                protocol=None,
                error=str(exc),
            )
        return DiscoveryResult(
            port=transport.label,
            baudrate=settings.baudrate,
            parity=settings.parity.value,
            stopbits=int(settings.stopbits.value),
            protocol=detection.protocol,
            autoprint_active=detection.autoprint_active,
            pending_lines=detection.pending_lines,
        )
    finally:
        with contextlib.suppress(SartoriusError):
            await transport.close()