Skip to content

sartoriuslib

Top-level re-exports. Every name listed here is importable directly from the package: from sartoriuslib import open_device, Balance, Reading, ....

sartoriuslib

sartoriuslib — Python library for Sartorius balances.

Supports both wire protocols the hardware speaks:

  • xBPI: binary, length-prefixed, checksum-protected, SBN-addressed.
  • SBI: ASCII command/response and autoprint.

The public API is semantic and protocol-neutral — a caller asks for poll(), tare(), status(); the session dispatches the xBPI or SBI variant selected at open time. Both protocols decode into the same frozen :class:Reading / :class:BalanceStatus / :class:DeviceInfo models.

Core API is async (built on anyio); a sync facade is available at :mod:sartoriuslib.sync for scripts, notebooks, and REPL use.

The public surface tracks the cross-library unified API (UNIFIED_API_HANDOFF.md): open_device(...), :class:SartoriusManager, :func:find_devices, :class:DiscoveryResult (per-probe row) plus the sartorius-typed :class:SartoriusDiscoveryResult subclass, :class:Sample with the §C timestamp contract, :class:DeviceResult with success() / failure() factories, :class:PollSourceAdapter, :class:Recording, and :func:sartoriuslib.units.to_pint.

See docs/design.md for the architectural design.

AcquisitionSummary dataclass

AcquisitionSummary(
    started_at,
    finished_at=None,
    samples_emitted=0,
    samples_late=0,
    max_drift_ms=0.0,
    target_total_samples=None,
)

Mutable acquisition totals owned by recorder / pipe drivers.

The recorder is the sole writer — counters update in place during the run so progress polling (TUIs, dashboards) works without a separate API. Consumers MUST treat the summary as read-only; mutating it from the consumer side is a contract violation that will produce wrong totals on shutdown.

finished_at is None while the producer is running and is set on context-manager exit.

Unified spec §M: every sibling library follows the same mutability rule; the field set is library-specific.

Attributes:

Name Type Description
started_at datetime

Wall-clock at the first scheduled tick.

finished_at datetime | None

Wall-clock at producer shutdown (None until then).

samples_emitted int

Count of per-tick batches actually pushed onto the receive stream for recorder summaries, or individual samples handed to the sink for pipe() summaries.

samples_late int

Count of ticks that missed their target slot (producer overran the previous tick, or overflow policy dropped the batch).

max_drift_ms float

Largest observed positive drift of an emitted batch relative to its absolute target, in milliseconds.

target_total_samples int | None

Number of scheduled ticks for finite duration recorder runs, or None for open-ended runs and pipe() summaries.

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.

Balance

Balance(session, info=None)

Protocol-neutral balance facade.

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

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

info property

info

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

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

session property

session

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

capacity async

capacity(area=0)

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

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

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

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

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

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

close async

close()

Close the underlying transport. Idempotent.

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

configure_protocol async

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

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

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

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

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

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

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

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

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

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

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

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

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

    session = self._session
    session.check_state()

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

    transport = session.transport

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

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

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

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

discover_temperature_sensors async

discover_temperature_sensors(
    *, max_index=_TEMPERATURE_DISCOVERY_MAX_INDEX
)

Probe the device for installed temperature sensors at runtime.

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

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

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

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

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

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

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

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

get_auto_zero async

get_auto_zero()

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

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

get_display_unit async

get_display_unit()

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

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

get_filter_mode async

get_filter_mode()

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

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

get_isocal_mode async

get_isocal_mode()

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

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

get_menu_access async

get_menu_access()

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

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

get_tare_behavior async

get_tare_behavior()

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

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

identify async

identify()

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

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

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

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

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

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

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

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

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

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

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

increment async

increment(area=0)

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

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

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

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

internal_adjust async

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

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

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

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

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

last_cal_record async

last_cal_record()

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

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

poll async

poll()

Read the live net weight at standard resolution.

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

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

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

raw_sbi async

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

Send an arbitrary SBI command and return parsed line replies.

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

raw_xbpi async

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

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

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

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

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

read_gross async

read_gross(*, hires=0)

Read the gross weight.

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

read_net async

read_net(*, hires=0)

Read the net weight.

Parameters:

Name Type Description Default
hires int

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

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

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

read_parameter async

read_parameter(index)

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

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

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

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

read_tare_value async

read_tare_value()

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

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

refresh_sbi_autoprint_state async

refresh_sbi_autoprint_state(*, timeout=None)

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

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

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

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

reload_menu async

reload_menu(*, confirm=False)

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

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

save_menu async

save_menu(*, confirm=False)

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

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

set_auto_zero async

set_auto_zero(mode, *, confirm=False)

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

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

set_baud_rate async

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

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

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

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

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

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

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

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

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

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

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

set_display_unit async

set_display_unit(unit, *, confirm=False)

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

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

set_filter_mode async

set_filter_mode(mode, *, confirm=False)

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

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

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

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

set_isocal_mode async

set_isocal_mode(mode, *, confirm=False)

Write p15.

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

set_menu_access async

set_menu_access(mode, *, confirm=False)

Write p40.

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

set_tare_behavior async

set_tare_behavior(mode, *, confirm=False)

Write p05.

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

snapshot async

snapshot()

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

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

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

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

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

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

status async

status()

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

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

stream

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

Create a per-balance streaming session.

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

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

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

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

tare async

tare()

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

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

temperature async

temperature(sensor=0)

Read one temperature sensor (xBPI 0x76).

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

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

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

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

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

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

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

write_parameter async

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

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

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

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

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

write_sbn_address async

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

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

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

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

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

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

zero async

zero()

Run the zeroing command (xBPI 0x18).

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

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.

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.

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.

DetectionResult dataclass

DetectionResult(
    protocol, autoprint_active=False, pending_lines=tuple()
)

Outcome of :func:detect_protocol.

Attributes:

Name Type Description
protocol ProtocolKind

The resolved :class:ProtocolKind. Always XBPI or SBI — never AUTO (a successful detect has resolved it).

autoprint_active bool

True only when the SBI passive sniff observed an unsolicited autoprint/status line.

pending_lines tuple[bytes, ...]

Complete CRLF-terminated SBI lines consumed during the sniff that the caller may want to re-queue on the live client. Empty unless autoprint_active is True.

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.

DeviceResult dataclass

DeviceResult(value, error)

Bases: Generic[T_co]

Per-device result container — value or error, never both.

The protocol that produced the failure is available via result.error.context.protocol when the error carries context; keeping it off the result keeps the success-path representation clean and aligns with the ecosystem DeviceResult shape used by :mod:alicatlib and :mod:watlowlib.

Use the :meth:success / :meth:failure classmethod factories at call sites that branch on success/failure — they make the intent obvious. The keyword-construction path (DeviceResult(value=v, error=None)) stays valid for internal call sites that already know both fields.

ok property

ok

True when the balance produced a value (error is None).

failure staticmethod

failure(error)

Build a failed result wrapping error.

Source code in src/sartoriuslib/manager.py
@staticmethod
def failure(error: SartoriusError) -> DeviceResult[Never]:
    """Build a failed result wrapping ``error``."""
    return DeviceResult(value=None, error=error)

success staticmethod

success(value)

Build a successful result wrapping value.

Source code in src/sartoriuslib/manager.py
@staticmethod
def success[U](value: U) -> DeviceResult[U]:
    """Build a successful result wrapping ``value``."""
    return DeviceResult(value=value, error=None)

DeviceSnapshot dataclass

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

Cross-library identity + health snapshot.

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

Attributes:

Name Type Description
name str

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

model str | None

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

firmware str | None

Cached firmware version string, or None.

serial str | None

Cached serial / factory-number string, or None.

connected bool

Whether the underlying session is operational.

last_error ErrorContext | None

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

recoverable_error_count int

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

captured_at datetime

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

DiscoveryResult dataclass

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

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

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

Attributes:

Name Type Description
ok bool

True when the probe resolved to a wire protocol.

port str

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

address str | int | None

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

baudrate int | None

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

protocol ProtocolKind | None

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

device_info DeviceInfo | None

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

error SartoriusError | None

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

elapsed_s float

Probe wall-clock duration in seconds.

DiscoverySummary dataclass

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

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

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

Attributes:

Name Type Description
port str

The port label.

ok bool

True when at least one probe attempt resolved a protocol.

baudrate int | None

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

protocol ProtocolKind | None

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

autoprint_active bool

Carried from the winning probe on hits.

error SartoriusError | None

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

elapsed_s float

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

ErrorContext dataclass

ErrorContext(
    command_name=None,
    command_bytes=None,
    opcode=None,
    sbi_token=None,
    raw_response=None,
    protocol=None,
    port=None,
    model=None,
    family=None,
    sbn_address=None,
    elapsed_s=None,
    extra=_empty_extra(),
)

Structured context attached to every :class:SartoriusError.

Fields are best-effort — missing data is None rather than raising.

extra accepts any Mapping and is always frozen into a read-only :class:types.MappingProxyType at construction so the shared empty sentinel can never be mutated through error.context.extra[k] = v.

address property

address

Unified cross-library accessor for the device address.

For sartoriuslib this is the xBPI SBN address (sbn_address). Consumers that work across sibling libraries (alicatlib, watlowlib, nidaqlib) read ctx.address uniformly; sartorius-internal code keeps using sbn_address because it carries protocol-layer semantics.

merged

merged(**updates)

Return a new context with updates overlaid. Unknown keys go to extra.

Source code in src/sartoriuslib/errors.py
def merged(self, **updates: Any) -> Self:
    """Return a new context with ``updates`` overlaid. Unknown keys go to ``extra``."""
    known: dict[str, Any] = {}
    extra_updates: dict[str, Any] = {}
    for key, value in updates.items():
        if key in _CONTEXT_KNOWN_FIELDS:
            known[key] = value
        else:
            extra_updates[key] = value

    new_extra: Mapping[str, Any] = (
        MappingProxyType({**self.extra, **extra_updates}) if extra_updates else self.extra
    )
    return replace(self, **known, extra=new_extra)

ErrorPolicy

Bases: Enum

How the manager surfaces per-device failures.

Under :attr:RAISE, the manager collects every balance's result and — if any call failed — raises an :class:ExceptionGroup containing the per-device exceptions after the task group joins. Under :attr:RETURN, each balance produces a :class:DeviceResult and the caller inspects .error per entry.

FirmwareVersion dataclass

FirmwareVersion(major, minor=0, patch=0, raw=None)

Immutable firmware version. Ordering is tuple-lexicographic.

Exact numbering conventions differ per family; see design doc §16 Q5.

InvalidParameterIndexError

InvalidParameterIndexError(message='', *, context=None)

Bases: SartoriusConfigurationError

Parameter-table index is out of range for this device.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

InvalidSbnError

InvalidSbnError(message='', *, context=None)

Bases: SartoriusConfigurationError

SBN bus address is invalid.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

OverflowPolicy

Bases: Enum

What record() does when the receive-stream buffer is full.

The producer runs on an absolute-target schedule; the consumer drains at its own pace. Slow consumers create backpressure — this knob picks how the recorder responds.

BLOCK class-attribute instance-attribute

BLOCK = 'block'

Await the slow consumer. Default. Silent drops are surprising in a data-acquisition setting, so the recorder blocks the producer rather than quietly discarding samples. The effective sample rate drops to the consumer's drain rate; samples_late accrues once the consumer catches up and the producer can check its schedule.

DROP_NEWEST class-attribute instance-attribute

DROP_NEWEST = 'drop_newest'

Drop the sample that was about to be enqueued. Counted as late.

DROP_OLDEST class-attribute instance-attribute

DROP_OLDEST = 'drop_oldest'

Evict the oldest queued batch, then enqueue. Counted as late.

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.

PollSource

Bases: Protocol

Minimal shape the recorder needs from its dispatcher.

:class:~sartoriuslib.manager.SartoriusManager satisfies this: its poll(names) returns a Mapping[str, DeviceResult[Reading]]. Using a Protocol keeps :func:record testable against a lightweight stub without pulling in the whole manager + transport stack.

poll async

poll(names=None)

Poll every named balance (or all under management) concurrently.

Must return a mapping keyed by the manager-assigned device name. Successful polls carry the :class:Reading as .value; failed ones carry the :class:~sartoriuslib.errors.SartoriusError as .error (per :class:~sartoriuslib.manager.ErrorPolicy.RETURN).

Source code in src/sartoriuslib/streaming/recorder.py
async def poll(
    self,
    names: Sequence[str] | None = None,
) -> Mapping[str, DeviceResult[Reading]]:
    """Poll every named balance (or all under management) concurrently.

    Must return a mapping keyed by the manager-assigned device name.
    Successful polls carry the :class:`Reading` as ``.value``;
    failed ones carry the :class:`~sartoriuslib.errors.SartoriusError`
    as ``.error`` (per :class:`~sartoriuslib.manager.ErrorPolicy.RETURN`).
    """
    ...

PollSourceAdapter

PollSourceAdapter(name, device)

Wrap one :class:Balance as a :class:PollSource for :func:record.

Construction takes a name (the manager-style identifier the sample carries downstream) and the :class:Balance to poll. Every :meth:poll invocation returns either a single-entry mapping containing the poll outcome wrapped in :class:DeviceResult, or an empty mapping when names is supplied and does not include this adapter's name.

Usage::

adapter = PollSourceAdapter("bal1", balance)
async with record(adapter, rate_hz=10) as recording:
    async for batch in recording.stream:
        ...
Source code in src/sartoriuslib/streaming/poll_source.py
def __init__(self, name: str, device: Balance) -> None:
    self._name = name
    self._device = device

device property

device

The wrapped :class:Balance.

name property

name

The manager-style identifier this adapter publishes samples under.

poll async

poll(names=None)

Poll the wrapped balance and return a one-entry result mapping.

When names is supplied and excludes :attr:name, returns an empty mapping (the consumer asked for a different device). Otherwise returns {name: DeviceResult.success(reading)} on success or {name: DeviceResult.failure(error)} on a typed sartoriuslib failure.

Source code in src/sartoriuslib/streaming/poll_source.py
async def poll(
    self,
    names: Iterable[str] | None = None,
) -> Mapping[str, DeviceResult[Reading]]:
    """Poll the wrapped balance and return a one-entry result mapping.

    When ``names`` is supplied and excludes :attr:`name`, returns
    an empty mapping (the consumer asked for a different device).
    Otherwise returns ``{name: DeviceResult.success(reading)}`` on
    success or ``{name: DeviceResult.failure(error)}`` on a typed
    sartoriuslib failure.
    """
    if names is not None and self._name not in set(names):
        return {}
    try:
        reading = await self._device.poll()
    except SartoriusError as exc:
        return {self._name: DeviceResult.failure(exc)}
    return {self._name: DeviceResult.success(reading)}

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.

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.

ProtocolKind

Bases: StrEnum

Which wire protocol is active on a session.

AUTO is only valid at open_device call time; by the time a session exists, AUTO has resolved to XBPI or SBI.

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

Recording dataclass

Recording(stream, summary, rate_hz, observed_rate_hz=None)

The context-manager payload returned by :func:record.

Bundles the per-tick stream, the live :class:AcquisitionSummary, and the configured / observed rates so consumers can poll progress without reaching into recorder internals. Cross-library spec §M: every sibling library yields Recording[T] from its record CM; T is what the recorder actually emits per tick (for sartoriuslib that's Mapping[str, Sample]).

Attributes:

Name Type Description
stream StreamT

The async iterator the recorder publishes per-tick payloads into. Drain by async for batch in recording.stream.

summary AcquisitionSummary

Live :class:AcquisitionSummary. Mutates in place; summary.finished_at is set on CM exit.

rate_hz float

Configured cadence the recorder is running at, as passed to :func:record.

observed_rate_hz float | None

Rolling mean inter-frame rate over the last 10 SBI autoprint frames. None until the buffer fills, and None for non-autoprint runs.

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.

Sample dataclass

Sample(
    device,
    reading,
    t_mono_ns,
    t_utc,
    requested_at,
    received_at,
    latency_s,
    protocol,
    t_midpoint_mono_ns=None,
    metadata=_empty_metadata(),
    error=None,
)

One balance poll with full timing provenance.

Attributes:

Name Type Description
device str

The manager-assigned name (from SartoriusManager.add). Stable downstream identifier that follows the value into sinks.

reading Reading | None

The :class:Reading decoded from the balance's reply. None when the poll failed — inspect :attr:error.

t_mono_ns int

Canonical monotonic acquisition timestamp in nanoseconds since OS boot. The midpoint of the request / response monotonic timestamps for request/response polling; the receive-side monotonic for SBI autoprint frames. This is the join key downstream tooling correlates against sibling-library samples.

t_utc datetime

Wall-clock acquisition instant (UTC, tz-aware) for the same moment :attr:t_mono_ns records. For poll: midpoint of :attr:requested_at and :attr:received_at. For autoprint: :attr:received_at.

t_midpoint_mono_ns int | None

Optional integration-window midpoint in monotonic nanoseconds. None for single polled or autoprint samples (sartorius balances do not expose integration semantics); reserved for forward compatibility with sensors that do.

requested_at datetime

Wall-clock datetime (UTC) captured just before the poll bytes leave the host. None for autoprint samples where the host did not send a request.

received_at datetime

Wall-clock datetime (UTC) captured just after the reply is read.

latency_s float

(received_at - requested_at).total_seconds() — precomputed for convenience. 0.0 for autoprint samples.

protocol ProtocolKind | None

Which wire protocol produced this sample. Duplicates reading.protocol on successful polls and preserves the value for error rows where reading is None. None only when an error-path sample arrives from a :class:PollSource that did not supply a protocol hint — in practice the manager always supplies one.

metadata Mapping[str, str]

Free-form per-sample annotations. Populated by stream(mode=...) to record which streaming mode produced the sample ("poll" or "autoprint").

error SartoriusError | None

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

SartoriusAutoprintActiveError

SartoriusAutoprintActiveError(message='', *, context=None)

Bases: SartoriusProtocolError

SBI autoprint is active, so command/reply traffic is not reliable.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusCapabilityError

SartoriusCapabilityError(message='', *, context=None)

Bases: SartoriusError

Command is not available on this device / firmware / family.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusCapabilityWarning

Bases: UserWarning

Emitted when a command's family/capability priors do not match the device.

In non-strict mode (the default) the library attempts the command anyway and updates availability from the device's response. See design doc §6.1.

SartoriusCommandRejectedError

SartoriusCommandRejectedError(message='', *, context=None)

Bases: SartoriusProtocolError

The device returned an xBPI subtype 0x01 / SBI refusal response.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusConfigurationError

SartoriusConfigurationError(message='', *, context=None)

Bases: SartoriusError

Configuration-level error (bad args, wrong confirm flag, etc.).

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusConfirmationRequiredError

SartoriusConfirmationRequiredError(
    message="", *, context=None
)

Bases: SartoriusConfigurationError

A PERSISTENT / DANGEROUS command was attempted without confirm=True.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusConnectionError

SartoriusConnectionError(message='', *, context=None)

Bases: SartoriusTransportError

Could not open / lost the connection to the balance.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusDeviceSnapshot dataclass

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

Bases: DeviceSnapshot

Sartorius-typed snapshot extras.

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

Attributes:

Name Type Description
family BalanceFamily | None

Cached :class:BalanceFamily classification.

capabilities Capability

Bitmap of capabilities the session believes the balance has.

protocol ProtocolKind

Active wire protocol on the underlying session.

mode str | None

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

SartoriusDiscoveryResult dataclass

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

Bases: DiscoveryResult

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

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

Attributes:

Name Type Description
parity str

Effective parity during the probe.

stopbits int

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

autoprint_active bool

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

pending_lines tuple[bytes, ...]

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

SartoriusError

SartoriusError(message='', *, context=None)

Bases: Exception

Base class for every :mod:sartoriuslib exception.

Carries a typed :class:ErrorContext. The message is the human-readable summary; the context is the machine-readable detail.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

with_context

with_context(**updates)

Return a copy of this error with its context updated.

Useful when an inner layer raises and an outer layer wants to enrich the context (for instance adding port or elapsed_s).

Source code in src/sartoriuslib/errors.py
def with_context(self, **updates: Any) -> Self:
    """Return a copy of this error with its context updated.

    Useful when an inner layer raises and an outer layer wants to enrich
    the context (for instance adding ``port`` or ``elapsed_s``).
    """
    cls = type(self)
    new = cls.__new__(cls)
    new.args = self.args
    try:
        new.__dict__.update(self.__dict__)
    except AttributeError:  # pragma: no cover — no slotted subclass today
        for slot in getattr(cls, "__slots__", ()):
            if hasattr(self, slot):
                object.__setattr__(new, slot, getattr(self, slot))
    new.context = self.context.merged(**updates)
    new.__cause__ = self.__cause__
    new.__context__ = self.__context__
    new.__traceback__ = self.__traceback__
    return new

SartoriusFirmwareError

SartoriusFirmwareError(message='', *, context=None)

Bases: SartoriusCapabilityError

Command is outside the supported firmware window.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusFrameError

SartoriusFrameError(message='', *, context=None)

Bases: SartoriusProtocolError

Bad checksum, bad length, malformed TLV, etc.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusIndexOutOfRangeError

SartoriusIndexOutOfRangeError(message='', *, context=None)

Bases: SartoriusCapabilityError, SartoriusCommandRejectedError

Device returned xBPI err 0x10 (index out of range).

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusManager

SartoriusManager(*, error_policy=ErrorPolicy.RAISE)

Coordinator for many balances across one or more serial ports.

Operations run concurrently across different physical ports (via :func:anyio.create_task_group) and serialise on the same-port client lock. Per-balance failures are surfaced per :attr:error_policy:

  • :attr:ErrorPolicy.RAISE: the manager still collects results from every balance, then raises an :class:ExceptionGroup if any failed.
  • :attr:ErrorPolicy.RETURN: the mapping's values carry :class:DeviceResult containers with .value or .error.

Usage::

async with SartoriusManager() as mgr:
    await mgr.add("bal1", "/dev/ttyUSB0")
    await mgr.add("bal2", "/dev/ttyUSB1")
    readings = await mgr.poll()
Source code in src/sartoriuslib/manager.py
def __init__(self, *, error_policy: ErrorPolicy = ErrorPolicy.RAISE) -> None:
    self._error_policy = error_policy
    self._devices: dict[str, _DeviceEntry] = {}
    self._ports: dict[str, _PortEntry] = {}
    self._state_lock = anyio.Lock()
    self._closed = False

closed property

closed

True once :meth:close has been called.

error_policy property

error_policy

The :class:ErrorPolicy this manager was constructed with.

names property

names

Insertion-ordered tuple of managed balance names.

add async

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

Register and open a balance under name.

The source discriminates lifecycle ownership:

  • :class:Balance — pre-built (via :func:open_device outside the manager). The manager only tracks the name mapping; it does not take lifecycle ownership.
  • str — serial port path ("/dev/ttyUSB0", "COM3"). The manager creates a :class:SerialTransport, canonicalises the port key, and shares the transport across balances on the same bus. Mixing xBPI and SBI sessions on a shared physical port is refused; one serial link has one active protocol.
  • :class:Transport — duck-typed transport. The manager invokes :func:open_device against it but does not take transport ownership.

Parameters:

Name Type Description Default
name str

Unique manager-level identifier.

required
source Balance | str | Transport

One of the three lifecycle shapes above.

required
protocol ProtocolKind

Which wire protocol to speak (per :func:sartoriuslib.open_device). Ignored when source is a pre-built :class:Balance.

XBPI
serial_settings SerialSettings | None

Override default serial framing. Only honoured when source is a port-string.

None
timeout float

Per-call default timeout.

1.0
src_sbn int

Host xBPI bus address.

1
dst_sbn int

Balance xBPI bus address.

9
strict bool

Strict prior gating (see design §6.1).

False
identify bool

Run identify on open and cache :class:DeviceInfo.

True

Returns:

Type Description
Balance

The opened :class:Balance.

Raises:

Type Description
SartoriusValidationError

name already exists or an invalid combination of kwargs was supplied.

SartoriusConnectionError

Manager is closed.

Source code in src/sartoriuslib/manager.py
async def add(
    self,
    name: str,
    source: Balance | 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:
    """Register and open a balance under ``name``.

    The ``source`` discriminates lifecycle ownership:

    - :class:`Balance` — pre-built (via :func:`open_device` outside
      the manager). The manager only tracks the name mapping; it
      does *not* take lifecycle ownership.
    - ``str`` — serial port path (``"/dev/ttyUSB0"``, ``"COM3"``).
      The manager creates a :class:`SerialTransport`,
      canonicalises the port key, and shares the transport across
      balances on the same bus. Mixing xBPI and SBI sessions on a shared
      physical port is refused; one serial link has one active protocol.
    - :class:`Transport` — duck-typed transport. The manager
      invokes :func:`open_device` against it but does *not* take
      transport ownership.

    Args:
        name: Unique manager-level identifier.
        source: One of the three lifecycle shapes above.
        protocol: Which wire protocol to speak (per
            :func:`sartoriuslib.open_device`). Ignored when
            ``source`` is a pre-built :class:`Balance`.
        serial_settings: Override default serial framing. Only
            honoured when ``source`` is a port-string.
        timeout: Per-call default timeout.
        src_sbn: Host xBPI bus address.
        dst_sbn: Balance xBPI bus address.
        strict: Strict prior gating (see design §6.1).
        identify: Run identify on open and cache :class:`DeviceInfo`.

    Returns:
        The opened :class:`Balance`.

    Raises:
        SartoriusValidationError: ``name`` already exists or an
            invalid combination of kwargs was supplied.
        SartoriusConnectionError: Manager is closed.
    """
    async with self._state_lock:
        self._check_open()
        if name in self._devices:
            raise SartoriusValidationError(
                f"manager: name {name!r} already in use",
                context=ErrorContext(extra={"name": name}),
            )
        if serial_settings is not None and not isinstance(source, str):
            raise SartoriusValidationError(
                "manager.add(serial_settings=...) only applies to string port "
                "sources; pre-built Transport / Balance carry their own settings",
                context=ErrorContext(extra={"name": name}),
            )

        port_key, port_entry, balance = await self._resolve_source(
            source,
            protocol=protocol,
            serial_settings=serial_settings,
            timeout=timeout,
            src_sbn=src_sbn,
            dst_sbn=dst_sbn,
            strict=strict,
            identify=identify,
        )

        self._devices[name] = _DeviceEntry(
            name=name,
            balance=balance,
            port_key=port_key,
        )
        if port_entry is not None:
            port_entry.refs.add(name)

        info = balance.info
        _logger.info(
            "manager.add",
            extra={
                "device_name": name,
                "port_key": port_key,
                "model": info.model if info is not None else None,
                "protocol": balance.session.active_protocol.value,
            },
        )
        return balance

close async

close()

Tear down every managed balance and port (LIFO).

Source code in src/sartoriuslib/manager.py
async def close(self) -> None:
    """Tear down every managed balance and port (LIFO)."""
    async with self._state_lock:
        if self._closed:
            return
        for name in reversed(list(self._devices.keys())):
            entry = self._devices.pop(name)
            try:
                await self._teardown_device(entry)
            except Exception as err:
                _logger.warning(
                    "manager.close_device_failed",
                    extra={"device_name": name, "error": repr(err)},
                )
        self._closed = True

execute async

execute(command, requests_by_name)

Dispatch a per-device Command across the requested names.

requests_by_name chooses both which balances participate and what arguments each gets — supporting the common case of "same command, different argument per balance".

Source code in src/sartoriuslib/manager.py
async def execute[Req, Resp](
    self,
    command: Command[Req, Resp],
    requests_by_name: Mapping[str, Req],
) -> Mapping[str, DeviceResult[Resp]]:
    """Dispatch a per-device ``Command`` across the requested names.

    ``requests_by_name`` chooses both which balances participate and
    what arguments each gets — supporting the common case of
    "same command, different argument per balance".
    """
    for name in requests_by_name:
        if name not in self._devices:
            raise SartoriusValidationError(
                f"manager.execute: no balance named {name!r}",
                context=ErrorContext(command_name=command.name, extra={"name": name}),
            )
    targets = tuple(requests_by_name.keys())
    name_by_balance_id = {id(entry.balance): entry.name for entry in self._devices.values()}

    async def _execute(balance: Balance) -> Resp:
        return await balance.session.execute(
            command,
            requests_by_name[name_by_balance_id[id(balance)]],
        )

    return await self._dispatch(command.name, targets, _execute)

get

get(name)

Return the balance registered under name.

Source code in src/sartoriuslib/manager.py
def get(self, name: str) -> Balance:
    """Return the balance registered under ``name``."""
    try:
        return self._devices[name].balance
    except KeyError:
        raise SartoriusValidationError(
            f"manager: no balance named {name!r}",
            context=ErrorContext(extra={"name": name}),
        ) from None

poll async

poll(names=None)

Poll every (or named) balance concurrently across ports.

Returns a mapping from balance name to :class:DeviceResult even under :attr:ErrorPolicy.RAISE — but under that policy, any failed balance's error is re-raised as an :class:ExceptionGroup after all balances have completed.

Source code in src/sartoriuslib/manager.py
async def poll(
    self,
    names: Sequence[str] | None = None,
) -> Mapping[str, DeviceResult[Reading]]:
    """Poll every (or named) balance concurrently across ports.

    Returns a mapping from balance name to :class:`DeviceResult`
    even under :attr:`ErrorPolicy.RAISE` — but under that policy,
    any failed balance's error is re-raised as an
    :class:`ExceptionGroup` after all balances have completed.
    """
    targets = self._resolve_names(names)

    async def _poll(balance: Balance) -> Reading:
        return await balance.poll()

    return await self._dispatch("poll", targets, _poll)

remove async

remove(name)

Unregister and close the balance named name.

If name was the last balance on a shared port, the transport for that port is closed too. A pre-built :class:Balance source is only dropped from the manager's registry — the caller retains lifecycle ownership.

Source code in src/sartoriuslib/manager.py
async def remove(self, name: str) -> None:
    """Unregister and close the balance named ``name``.

    If ``name`` was the last balance on a shared port, the
    transport for that port is closed too. A pre-built
    :class:`Balance` source is only dropped from the manager's
    registry — the caller retains lifecycle ownership.
    """
    async with self._state_lock:
        self._check_open()
        if name not in self._devices:
            raise SartoriusValidationError(
                f"manager: no balance named {name!r}",
                context=ErrorContext(extra={"name": name}),
            )
        entry = self._devices.pop(name)
        await self._teardown_device(entry)
        _logger.info("manager.remove", extra={"device_name": name})

SartoriusMissingArgsError

SartoriusMissingArgsError(message='', *, context=None)

Bases: SartoriusCapabilityError, SartoriusCommandRejectedError

Device returned xBPI err 0x07 (invalid or missing args).

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusOperationNotApplicableError

SartoriusOperationNotApplicableError(
    message="", *, context=None
)

Bases: SartoriusCapabilityError, SartoriusCommandRejectedError

Device returned xBPI err 0x06 (operation not applicable).

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusParseError

SartoriusParseError(message='', *, context=None)

Bases: SartoriusProtocolError

Unknown xBPI subtype or unparseable SBI line.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusProtocolError

SartoriusProtocolError(message='', *, context=None)

Bases: SartoriusError

Protocol-level error (framing, parsing, device refusal).

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusProtocolUnsupportedError

SartoriusProtocolUnsupportedError(
    message="", *, context=None
)

Bases: SartoriusProtocolError

Command has no variant defined for the active protocol.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusSinkDependencyError

SartoriusSinkDependencyError(message='', *, context=None)

Bases: SartoriusSinkError, SartoriusConfigurationError

A sink's optional backing library is not installed.

Raised when the user instantiates (or calls open() on) a sink whose extras have not been installed — e.g. ParquetSink without sartoriuslib[parquet] or PostgresSink without sartoriuslib[postgres]. The message names the exact extra to install so the remediation is copy-pasteable.

Multi-inherits :class:SartoriusConfigurationError because callers that already branch on configuration errors (missing extras being a configuration problem from their perspective) keep working without changes.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusSinkError

SartoriusSinkError(message='', *, context=None)

Bases: SartoriusError

Base class for errors raised by sinks (CSV, JSONL, SQLite, Parquet, Postgres).

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusSinkSchemaError

SartoriusSinkSchemaError(message='', *, context=None)

Bases: SartoriusSinkError

A batch's shape is incompatible with the sink's locked schema.

Raised when a sink has locked its schema on the first batch (or validated against an existing table) and a subsequent batch carries rows whose shape can't be reconciled — for example, a Postgres target table that's missing a required column.

Dropping unknown optional columns is handled by a per-sink WARN log and does not raise.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusSinkWriteError

SartoriusSinkWriteError(message='', *, context=None)

Bases: SartoriusSinkError

The backing store rejected a write.

Wraps the underlying driver exception (sqlite3, asyncpg, pyarrow) so downstream error handlers don't need to import optional dependencies. The original exception is preserved via raise ... from original so tracebacks remain intact.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusTimeoutError

SartoriusTimeoutError(message='', *, context=None)

Bases: SartoriusTransportError

A transport read or write timed out.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusTransientTransportError

SartoriusTransientTransportError(
    message="", *, context=None
)

Bases: SartoriusTransportError

Transport-layer hiccup that is safe to retry without reopening.

Raised in the cold-open window when the device is still settling and a read returns 0 bytes (transport layer) or the first frame arrives short of MIN_FRAME_SIZE (protocol layer underrun). Callers may retry the same operation up to 3 times before escalating to :class:SartoriusConnectionError; :func:sartoriuslib.open_device swallows up to 3 inside the first identify so cold-open is invisible to most callers.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusTransportError

SartoriusTransportError(message='', *, context=None)

Bases: SartoriusError

I/O-layer error from the serial transport.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusUnsupportedCommandError

SartoriusUnsupportedCommandError(
    message="", *, context=None
)

Bases: SartoriusCapabilityError, SartoriusCommandRejectedError

Device returned xBPI err 0x04 (unsupported/unknown opcode).

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusValidationError

SartoriusValidationError(message='', *, context=None)

Bases: SartoriusConfigurationError

Request validation failed before I/O.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

SartoriusValueOutOfRangeError

SartoriusValueOutOfRangeError(message='', *, context=None)

Bases: SartoriusCapabilityError, SartoriusCommandRejectedError

Device returned xBPI err 0x03 (value out of range).

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

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.

Sign

Bases: StrEnum

Sign of a measurement as encoded on the wire.

StreamingSession

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

Async context manager + iterator for one balance.

mode="poll" performs request/response polling at an absolute cadence. mode="autoprint" consumes already-enabled SBI autoprint lines and fails on entry if no line is available within timeout. The temporary_autoprint=True path is reserved for a future persistent SBI parameter-write flow and currently raises :class:NotImplementedError.

Source code in src/sartoriuslib/streaming/stream_session.py
def __init__(
    self,
    balance: Balance,
    *,
    rate_hz: float | None = None,
    mode: StreamMode = "poll",
    temporary_autoprint: bool = False,
    confirm: bool = False,
    timeout: float | None = None,
) -> None:
    if mode not in ("poll", "autoprint"):
        raise ValueError(f"unknown stream mode {mode!r}")
    if mode == "poll" and (rate_hz is None or rate_hz <= 0):
        raise ValueError("poll stream requires rate_hz > 0")
    self._balance = balance
    self._rate_hz = rate_hz
    self._mode: StreamMode = mode
    self._temporary_autoprint = temporary_autoprint
    self._confirm = confirm
    self._timeout = timeout
    self._entered = False
    self._tick = 0
    self._start: float = 0.0
    self._pending: Sample | None = None

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.

Unit

Bases: StrEnum

Physical unit of a measurement / display unit.

Values are the short symbol (or short tag for units without a single canonical symbol) so str(Unit.G) == "g" for log lines and CSV columns.

Membership covers the 24-entry p07 display-unit table plus :attr:UNKNOWN for forward-compat on measurement-frame decoding.

BAHT class-attribute instance-attribute

BAHT = 'baht'

Thai baht weight.

CT class-attribute instance-attribute

CT = 'ct'

Metric carat (1 ct = 0.2 g).

CT_AU class-attribute instance-attribute

CT_AU = 'ct_au'

Austrian carat — non-metric; used in p07 idx 17.

DWT class-attribute instance-attribute

DWT = 'dwt'

Pennyweight (1 dwt = 24 gr).

GR class-attribute instance-attribute

GR = 'gr'

Grain (1 gr ≈ 64.79891 mg).

LB_OZ class-attribute instance-attribute

LB_OZ = 'lb_oz'

Pounds-and-ounces combined display.

MESGAL class-attribute instance-attribute

MESGAL = 'mesgal'

Mesghal (p07 idx 20).

MOMME class-attribute instance-attribute

MOMME = 'momme'

Japanese momme.

OZT class-attribute instance-attribute

OZT = 'ozt'

Troy ounce.

PARTS_PER_POUND class-attribute instance-attribute

PARTS_PER_POUND = '/lb'

Parts per pound (p07 ptplb).

T class-attribute instance-attribute

T = 't'

Metric ton.

TAEL_CN class-attribute instance-attribute

TAEL_CN = 'tl.cn'

Chinese tael.

TAEL_HK class-attribute instance-attribute

TAEL_HK = 'tl.hk'

Hong Kong tael.

TAEL_SG class-attribute instance-attribute

TAEL_SG = 'tl.sg'

Singapore / Malaysia tael.

TAEL_TW class-attribute instance-attribute

TAEL_TW = 'tl.tw'

Taiwan tael.

TOLA class-attribute instance-attribute

TOLA = 'tola'

South Asian tola.

UG class-attribute instance-attribute

UG = 'µg'

Microgram.

USERDEF class-attribute instance-attribute

USERDEF = 'userdef'

User-defined unit (p07 idx 1). A multiplier + label live in a separate register not yet located; the balance displays it scaled from grams.

UnknownUnitError

UnknownUnitError(message='', *, context=None)

Bases: SartoriusConfigurationError

The unit code is not recognised.

Source code in src/sartoriuslib/errors.py
def __init__(self, message: str = "", *, context: ErrorContext | None = None) -> None:
    super().__init__(message)
    self.context = context if context is not None else _EMPTY_CONTEXT

detect_protocol async

detect_protocol(
    transport,
    *,
    timeout=_DEFAULT_PROBE_TIMEOUT,
    sniff_window=_DEFAULT_SNIFF_WINDOW,
    src_sbn=HOST_SBN_DEFAULT,
    dst_sbn=BALANCE_SBN_DEFAULT,
)

Detect xBPI vs SBI on an already-open transport.

Runs the conservative sequence from design §4.3 in order — drain → passive sniff → xBPI probe → SBI probe → fail. Each probe writes at most one frame. The transport's serial settings are never changed.

Parameters:

Name Type Description Default
transport Transport

An open :class:Transport. Caller owns lifecycle.

required
timeout float

Per-probe timeout for the xBPI and SBI identity probes.

_DEFAULT_PROBE_TIMEOUT
sniff_window float

Passive listen window for SBI autoprint, in seconds.

_DEFAULT_SNIFF_WINDOW
src_sbn int

Source SBN for the xBPI probe frame (0x01 host convention by default).

HOST_SBN_DEFAULT
dst_sbn int

Destination SBN for the xBPI probe frame (0x09 balance factory default by default).

BALANCE_SBN_DEFAULT

Returns:

Name Type Description
A DetectionResult

class:DetectionResult whose protocol is XBPI or

DetectionResult

SBI. When autoprint_active is True, pending_lines

DetectionResult

carries the sniffed bytes for re-queue.

Raises:

Type Description
SartoriusError

No xBPI or SBI device responded — neither the passive sniff nor either probe produced a recognisable reply. Hard transport faults (e.g. the port closed mid-detect) propagate as :class:SartoriusConnectionError unchanged.

Source code in src/sartoriuslib/protocol/detect.py
async def detect_protocol(
    transport: Transport,
    *,
    timeout: float = _DEFAULT_PROBE_TIMEOUT,
    sniff_window: float = _DEFAULT_SNIFF_WINDOW,
    src_sbn: int = HOST_SBN_DEFAULT,
    dst_sbn: int = BALANCE_SBN_DEFAULT,
) -> DetectionResult:
    """Detect xBPI vs SBI on an already-open ``transport``.

    Runs the conservative sequence from design §4.3 in order — drain →
    passive sniff → xBPI probe → SBI probe → fail. Each probe writes at
    most one frame. The transport's serial settings are never changed.

    Arguments:
        transport: An open :class:`Transport`. Caller owns lifecycle.
        timeout: Per-probe timeout for the xBPI and SBI identity probes.
        sniff_window: Passive listen window for SBI autoprint, in seconds.
        src_sbn: Source SBN for the xBPI probe frame (``0x01`` host
            convention by default).
        dst_sbn: Destination SBN for the xBPI probe frame (``0x09``
            balance factory default by default).

    Returns:
        A :class:`DetectionResult` whose ``protocol`` is ``XBPI`` or
        ``SBI``. When ``autoprint_active`` is ``True``, ``pending_lines``
        carries the sniffed bytes for re-queue.

    Raises:
        SartoriusError: No xBPI or SBI device responded — neither the
            passive sniff nor either probe produced a recognisable reply.
            Hard transport faults (e.g. the port closed mid-detect)
            propagate as :class:`SartoriusConnectionError` unchanged.
    """
    label = transport.label

    # 1. Passive autoprint sniff. We deliberately do NOT drain first: a
    #    balance left in autoprint mode may have a complete line already
    #    sitting in the OS buffer when we connect, and the design promises
    #    we will not drop the first sample. Stale partial bytes (no CRLF)
    #    can't fool the sniff — read_until times out without consuming them
    #    and the pre-probe drain below clears them before xBPI runs.
    autoprint, sniffed = await _sniff_for_autoprint(transport, sniff_window)
    if autoprint:
        return DetectionResult(
            protocol=ProtocolKind.SBI,
            autoprint_active=True,
            pending_lines=sniffed,
        )

    # 2. Drain anything left over from the sniff (partial lines, stray
    #    bytes that didn't form CRLF) so the xBPI length-prefix read starts
    #    from a clean buffer.
    with contextlib.suppress(SartoriusError):
        await transport.drain_input()

    # 3. xBPI probe — READ_MODEL. A valid frame (even one carrying an error
    #    subtype) confirms xBPI: only an xBPI device builds a length-prefixed
    #    marker-tagged frame.
    if await _probe_xbpi(transport, timeout=timeout, src_sbn=src_sbn, dst_sbn=dst_sbn):
        return DetectionResult(protocol=ProtocolKind.XBPI)

    with contextlib.suppress(SartoriusError):
        await transport.drain_input()

    # 4. SBI identity probe. Any CRLF-terminated reply counts as evidence —
    #    content interpretation is the SBI parser's job, not detection's.
    if await _probe_sbi(transport, timeout=timeout):
        return DetectionResult(protocol=ProtocolKind.SBI)

    # 5. Clear failure. No opcode sweeps, no fallback baud rates.
    raise SartoriusError(
        f"auto-detect: no responsive xBPI or SBI device on {label!r} "
        f"(sniff {sniff_window}s, probe timeout {timeout}s)",
        context=ErrorContext(
            port=label,
            command_name="auto_detect",
            extra={"sniff_window_s": sniff_window, "probe_timeout_s": timeout},
        ),
    )

discover_port async

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

Open port at serial_settings and run the conservative detect.

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

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

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

find_devices async

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

Probe local serial ports for Sartorius balances, sweeping baudrates.

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

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

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

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

Parameters:

Name Type Description Default
ports Sequence[str] | None

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

None
baudrates Sequence[int] | None

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

None
per_probe_timeout_s float

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

0.5
sniff_window_s float

Per-probe passive SBI autoprint sniff window.

0.25

Returns:

Name Type Description
One list[SartoriusDiscoveryResult]

class:SartoriusDiscoveryResult per probe attempt, in

list[SartoriusDiscoveryResult]

port-then-baud order.

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

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

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

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

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

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

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

open_device async

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

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

Parameters:

Name Type Description Default
port str | Transport

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

required
protocol ProtocolKind

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

XBPI
serial_settings SerialSettings | None

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

None
timeout float

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

1.0
src_sbn int

Host xBPI bus address (default 0x01).

1
dst_sbn int

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

9
strict bool

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

False
identify bool

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

True

Raises:

Type Description
SartoriusError

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

SartoriusConnectionError

Transport failed to open.

Returns:

Name Type Description
A Balance

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

Balance

closes the transport.

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

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

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

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

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

record async

record(
    source,
    *,
    rate_hz,
    duration=None,
    names=None,
    overflow=OverflowPolicy.BLOCK,
    buffer_size=64,
)

Record polled samples into a receive stream at an absolute cadence.

Usage::

async with record(mgr, rate_hz=10, duration=60) as recording:
    async for batch in recording.stream:
        process(batch)
    print(recording.summary.samples_emitted)

The CM yields a :class:Recording whose :attr:Recording.stream is an async iterator of per-tick sample batches. Each batch is a Mapping[name, Sample] — one entry per device that participated on that tick. Successful polls produce a :class:Sample carrying a :class:Reading; failed polls produce a :class:Sample with reading=None and error set. The bundled :attr:Recording.summary updates live during the run and finalises (finished_at set) on CM exit.

Parameters:

Name Type Description Default
source PollSource

Any :class:PollSource (typically a :class:~sartoriuslib.manager.SartoriusManager).

required
rate_hz float

Target cadence. Absolute targets are computed target[n] = start + n * (1 / rate_hz). Must be > 0.

required
duration float | None

Total acquisition duration in seconds. Finite runs schedule round(duration * rate_hz) ticks. If the producer overruns or the overflow policy drops a batch, samples_emitted may be lower than this target and the missed/dropped ticks are counted on samples_late. None means "until the caller exits the CM".

None
names Sequence[str] | None

Subset of device names to poll per tick. None polls everything the source manages.

None
overflow OverflowPolicy

Backpressure policy when the receive-stream buffer is full. See :class:OverflowPolicy.

BLOCK
buffer_size int

Receive-stream capacity, in per-tick batches.

64

Yields:

Name Type Description
A AsyncGenerator[Recording[Mapping[str, Sample], AsyncIterator[Mapping[str, Sample]]]]

class:Recording parameterised on Mapping[str, Sample].

Raises:

Type Description
ValueError

If rate_hz <= 0 or duration <= 0 or buffer_size < 1.

Source code in src/sartoriuslib/streaming/recorder.py
@asynccontextmanager
async def record(
    source: PollSource,
    *,
    rate_hz: float,
    duration: float | None = None,
    names: Sequence[str] | None = None,
    overflow: OverflowPolicy = OverflowPolicy.BLOCK,
    buffer_size: int = 64,
) -> AsyncGenerator[Recording[Mapping[str, Sample], AsyncIterator[Mapping[str, Sample]]]]:
    """Record polled samples into a receive stream at an absolute cadence.

    Usage::

        async with record(mgr, rate_hz=10, duration=60) as recording:
            async for batch in recording.stream:
                process(batch)
            print(recording.summary.samples_emitted)

    The CM yields a :class:`Recording` whose :attr:`Recording.stream`
    is an async iterator of per-tick sample batches. Each batch is a
    ``Mapping[name, Sample]`` — one entry per device that
    participated on that tick. Successful polls produce a
    :class:`Sample` carrying a :class:`Reading`; failed polls produce
    a :class:`Sample` with ``reading=None`` and ``error`` set. The
    bundled :attr:`Recording.summary` updates live during the run and
    finalises (``finished_at`` set) on CM exit.

    Args:
        source: Any :class:`PollSource` (typically a
            :class:`~sartoriuslib.manager.SartoriusManager`).
        rate_hz: Target cadence. Absolute targets are computed
            ``target[n] = start + n * (1 / rate_hz)``. Must be > 0.
        duration: Total acquisition duration in seconds. Finite runs
            schedule ``round(duration * rate_hz)`` ticks. If the
            producer overruns or the overflow policy drops a batch,
            ``samples_emitted`` may be lower than this target and the
            missed/dropped ticks are counted on ``samples_late``.
            ``None`` means "until the caller exits the CM".
        names: Subset of device names to poll per tick. ``None`` polls
            everything the source manages.
        overflow: Backpressure policy when the receive-stream buffer
            is full. See :class:`OverflowPolicy`.
        buffer_size: Receive-stream capacity, in per-tick batches.

    Yields:
        A :class:`Recording` parameterised on ``Mapping[str, Sample]``.

    Raises:
        ValueError: If ``rate_hz <= 0`` or ``duration <= 0`` or
            ``buffer_size < 1``.
    """
    if rate_hz <= 0:
        raise ValueError(f"rate_hz must be > 0, got {rate_hz!r}")
    if duration is not None and duration <= 0:
        raise ValueError(f"duration must be > 0 or None, got {duration!r}")
    if buffer_size < 1:
        raise ValueError(f"buffer_size must be >= 1, got {buffer_size!r}")
    if overflow is OverflowPolicy.DROP_OLDEST:
        # Fail at call site (not deep inside the producer task) so the
        # exception type doesn't come back wrapped in an ExceptionGroup.
        raise NotImplementedError(
            "OverflowPolicy.DROP_OLDEST is not yet implemented; use BLOCK "
            "or DROP_NEWEST for now (design §10).",
        )

    period = 1.0 / rate_hz
    total_ticks = None if duration is None else max(1, round(duration * rate_hz))

    send_stream, receive_stream = anyio.create_memory_object_stream[Mapping[str, Sample]](
        max_buffer_size=buffer_size,
    )

    started_at = datetime.now(UTC)
    summary = AcquisitionSummary(
        started_at=started_at,
        target_total_samples=total_ticks,
    )
    recording: Recording[Mapping[str, Sample], AsyncIterator[Mapping[str, Sample]]] = Recording(
        stream=receive_stream,
        summary=summary,
        rate_hz=rate_hz,
    )
    _logger.info(
        "recorder.start",
        extra={
            "rate_hz": rate_hz,
            "duration_s": duration,
            "overflow": overflow.value,
            "buffer_size": buffer_size,
            "names": list(names) if names is not None else None,
        },
    )

    async with anyio.create_task_group() as tg, receive_stream:
        _ = tg.start_soon(
            _run_producer,
            source,
            send_stream,
            period,
            total_ticks,
            names,
            overflow,
            summary,
        )
        try:
            yield recording
        finally:
            tg.cancel_scope.cancel()

    finished_at = datetime.now(UTC)
    summary.finished_at = finished_at
    _logger.info(
        "recorder.stop",
        extra={
            "samples_emitted": summary.samples_emitted,
            "samples_late": summary.samples_late,
            "max_drift_ms": summary.max_drift_ms,
            "target_total_samples": summary.target_total_samples,
            "duration_s": (finished_at - started_at).total_seconds(),
        },
    )

sample_to_row

sample_to_row(sample)

Flatten a :class:Sample into a single row dict for tabular sinks.

Schema layout (stable across samples; design §10, unified spec §C):

  • device — manager-assigned name.
  • t_mono_ns — canonical monotonic acquisition timestamp.
  • t_utc — wall-clock acquisition instant (ISO 8601).
  • t_midpoint_mono_ns — integration-window midpoint (None for poll/autoprint samples).
  • requested_at / received_at — ISO 8601 I/O provenance.
  • latency_s — poll round-trip time, seconds.
  • reading fields — from :meth:Reading.as_dict: value, unit, sign, stable, overload, underload, decimals, sequence, protocol, raw. On error samples (reading is None) these all appear as None.
  • error_type — fully qualified exception class on a failed sample, otherwise None.
  • error_messagestr(error) on a failed sample, otherwise None.

Reading.protocol is the authoritative protocol column on success rows; on error rows the row's protocol column falls back to :attr:Sample.protocol (populated by the manager from the session's active protocol) so sinks never see a missing column.

Source code in src/sartoriuslib/sinks/base.py
def sample_to_row(sample: Sample) -> dict[str, float | int | str | bool | None]:
    """Flatten a :class:`Sample` into a single row dict for tabular sinks.

    Schema layout (stable across samples; design §10, unified spec §C):

    - ``device`` — manager-assigned name.
    - ``t_mono_ns`` — canonical monotonic acquisition timestamp.
    - ``t_utc`` — wall-clock acquisition instant (ISO 8601).
    - ``t_midpoint_mono_ns`` — integration-window midpoint (``None`` for
      poll/autoprint samples).
    - ``requested_at`` / ``received_at`` — ISO 8601 I/O provenance.
    - ``latency_s`` — poll round-trip time, seconds.
    - *reading fields* — from :meth:`Reading.as_dict`: ``value``,
      ``unit``, ``sign``, ``stable``, ``overload``, ``underload``,
      ``decimals``, ``sequence``, ``protocol``, ``raw``. On error
      samples (``reading is None``) these all appear as ``None``.
    - ``error_type`` — fully qualified exception class on a failed
      sample, otherwise ``None``.
    - ``error_message`` — ``str(error)`` on a failed sample, otherwise
      ``None``.

    ``Reading.protocol`` is the authoritative protocol column on
    success rows; on error rows the row's ``protocol`` column falls
    back to :attr:`Sample.protocol` (populated by the manager from
    the session's active protocol) so sinks never see a missing
    column.
    """
    row: dict[str, float | int | str | bool | None] = {
        "device": sample.device,
        "t_mono_ns": sample.t_mono_ns,
        "t_utc": sample.t_utc.isoformat(),
        "t_midpoint_mono_ns": sample.t_midpoint_mono_ns,
        "requested_at": sample.requested_at.isoformat(),
        "received_at": sample.received_at.isoformat(),
        "latency_s": sample.latency_s,
    }
    reading = sample.reading
    if reading is not None:
        row.update(reading.as_dict())
    else:
        # Keep the schema stable on error rows so the first batch
        # of mixed results doesn't accidentally lock a narrower
        # schema when the first sample is a failure.
        row.update(
            {
                "value": None,
                "unit": None,
                "sign": None,
                "stable": None,
                "overload": None,
                "underload": None,
                "decimals": None,
                "sequence": None,
                "protocol": sample.protocol.value if sample.protocol is not None else None,
                "raw": None,
            }
        )
    err = sample.error
    if err is not None:
        cls = type(err)
        row["error_type"] = f"{cls.__module__}.{cls.__qualname__}"
        row["error_message"] = str(err)
    else:
        row["error_type"] = None
        row["error_message"] = None
    return row

summarize_discovery

summarize_discovery(results)

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

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

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

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

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

to_pint

to_pint(unit)

Return a pint-compatible unit string for unit, or None.

Accepts a :class:Unit enum member, the matching string value ("g", "kg", ...), or None. Unknown strings and units pint cannot model return None — never raise.

Example::

>>> from sartoriuslib.units import to_pint
>>> to_pint(Unit.G)
'gram'
>>> to_pint("kg")
'kilogram'
>>> to_pint("tl.hk")  # Hong Kong tael — pint can't model
>>> to_pint(None)
Source code in src/sartoriuslib/units.py
def to_pint(unit: Unit | str | None) -> str | None:
    """Return a pint-compatible unit string for ``unit``, or ``None``.

    Accepts a :class:`Unit` enum member, the matching string value
    (``"g"``, ``"kg"``, ...), or ``None``. Unknown strings and units
    pint cannot model return ``None`` — never raise.

    Example::

        >>> from sartoriuslib.units import to_pint
        >>> to_pint(Unit.G)
        'gram'
        >>> to_pint("kg")
        'kilogram'
        >>> to_pint("tl.hk")  # Hong Kong tael — pint can't model
        >>> to_pint(None)
    """
    if unit is None:
        return None
    # ``Unit`` is a :class:`StrEnum`, so it satisfies ``isinstance(_, str)``.
    # Match enum first so we don't re-coerce members back through ``Unit(...)``.
    if isinstance(unit, Unit):
        return _UNIT_TO_PINT.get(unit)
    try:
        return _UNIT_TO_PINT.get(Unit(unit))
    except ValueError:
        return None