Skip to content

API reference

Only names exported from ondotori_ble are considered public. Modules and members beginning with an underscore may change without notice.

ondotori_ble

Read current T&D Ondotori sensor values from BLE advertisements.

T_AND_D_COMPANY_ID module-attribute

T_AND_D_COMPANY_ID = 914

Bluetooth SIG company identifier assigned to T&D.

__version__ module-attribute

__version__ = version('ondotori-ble')

__all__ module-attribute

__all__ = [
    "T_AND_D_COMPANY_ID",
    "AdvertisementFormat",
    "DeviceModel",
    "EvidenceLevel",
    "MalformedAdvertisementError",
    "Measurement",
    "MeasurementKind",
    "NotOndotoriAdvertisementError",
    "OndotoriError",
    "OndotoriScanner",
    "Reading",
    "ReadingTimeoutError",
    "ScannerBackend",
    "ScannerFactory",
    "ScannerState",
    "ScannerStats",
    "ScannerUnavailableError",
    "Unit",
    "__version__",
    "decode_advertisement",
    "decode_manufacturer_data",
    "read",
    "read_all",
    "read_all_async",
    "read_async",
    "scan",
    "scan_async",
    "stream_readings",
]

MalformedAdvertisementError

Bases: OndotoriError, ValueError

Raised when T&D manufacturer data is too short or structurally invalid.

Source code in src/ondotori_ble/errors.py
class MalformedAdvertisementError(OndotoriError, ValueError):
    """Raised when T&D manufacturer data is too short or structurally invalid."""

NotOndotoriAdvertisementError

Bases: OndotoriError, ValueError

Raised when data explicitly carries a non-T&D Bluetooth company ID.

Source code in src/ondotori_ble/errors.py
class NotOndotoriAdvertisementError(OndotoriError, ValueError):
    """Raised when data explicitly carries a non-T&D Bluetooth company ID."""

OndotoriError

Bases: Exception

Base class for all library-specific exceptions.

Source code in src/ondotori_ble/errors.py
class OndotoriError(Exception):
    """Base class for all library-specific exceptions."""

ReadingTimeoutError

Bases: OndotoriError, TimeoutError

Raised when no matching BLE reading arrives before a requested timeout.

Source code in src/ondotori_ble/errors.py
class ReadingTimeoutError(OndotoriError, TimeoutError):
    """Raised when no matching BLE reading arrives before a requested timeout."""

ScannerUnavailableError

Bases: OndotoriError, RuntimeError

Raised when the operating system cannot start a BLE scan.

Source code in src/ondotori_ble/errors.py
class ScannerUnavailableError(OndotoriError, RuntimeError):
    """Raised when the operating system cannot start a BLE scan."""

AdvertisementFormat

Bases: StrEnum

Known layout of the T&D manufacturer-specific payload.

Source code in src/ondotori_ble/models.py
class AdvertisementFormat(StrEnum):
    """Known layout of the T&D manufacturer-specific payload."""

    RTR500B = "rtr500b"
    TR4 = "tr4"
    TR4A = "tr4a"
    UNKNOWN = "unknown"

DeviceModel

Bases: StrEnum

Ondotori model identified from verified packet evidence.

RTR500B L variants share their base model because the larger battery enclosure does not change the measurement layout. Enum membership records a known product name; it does not by itself mean that a decoder exists.

Source code in src/ondotori_ble/models.py
class DeviceModel(StrEnum):
    """Ondotori model identified from verified packet evidence.

    RTR500B ``L`` variants share their base model because the larger battery
    enclosure does not change the measurement layout. Enum membership records
    a known product name; it does not by itself mean that a decoder exists.
    """

    RTR501B = "RTR501B"
    RTR502B = "RTR502B"
    RTR503B = "RTR503B"
    RTR505B = "RTR505B"
    RTR507B = "RTR507B"
    TR32B = "TR32B"
    TR41 = "TR41"
    TR42 = "TR42"
    TR45 = "TR45"
    TR41A = "TR41A"
    TR42A = "TR42A"
    TR43A = "TR43A"
    TR71A = "TR71A"
    TR72A = "TR72A"
    TR72A_S = "TR72A-S"
    TR75A = "TR75A"
    TR71A2 = "TR71A2"
    TR72A2 = "TR72A2"
    TR72A2_S = "TR72A2-S"
    TR75A2 = "TR75A2"
    TR71WB = "TR-71wb"
    TR72WB = "TR-72wb"
    TR75WB = "TR-75wb"
    UNKNOWN = "unknown"

EvidenceLevel

Bases: StrEnum

Provenance of the model/layout interpretation for a packet.

Source code in src/ondotori_ble/models.py
class EvidenceLevel(StrEnum):
    """Provenance of the model/layout interpretation for a packet."""

    PUBLISHED = "published"
    OBSERVED = "observed"
    UNKNOWN = "unknown"

Measurement dataclass

One decoded sensor channel.

value is None when the device advertises an invalid/sensor-error marker. raw_value is retained so callers can audit the conversion.

Source code in src/ondotori_ble/models.py
@dataclass(frozen=True, slots=True)
class Measurement:
    """One decoded sensor channel.

    ``value`` is ``None`` when the device advertises an invalid/sensor-error
    marker. ``raw_value`` is retained so callers can audit the conversion.
    """

    channel: int
    kind: MeasurementKind
    value: float | None
    unit: Unit
    raw_value: int

    def __post_init__(self) -> None:
        """Validate invariants shared by decoded and user-created instances."""
        if self.channel < 1:
            message = "channel must be at least 1"
            raise ValueError(message)
        if not 0 <= self.raw_value <= 0xFFFFFFFF:
            message = "raw_value must fit in an unsigned 32-bit integer"
            raise ValueError(message)
        if self.value is not None and not math.isfinite(self.value):
            message = "value must be finite when present"
            raise ValueError(message)
        if self.unit is not _UNIT_BY_KIND[self.kind]:
            message = f"unit {self.unit.value!r} does not match {self.kind.value}"
            raise ValueError(message)

    @property
    def is_valid(self) -> bool:
        """Return whether the advertisement contained a usable value."""
        return self.value is not None

    def as_dict(self) -> dict[str, int | float | str | None]:
        """Return a JSON-serializable representation."""
        return {
            "channel": self.channel,
            "kind": self.kind.value,
            "value": self.value,
            "unit": self.unit.value,
            "raw_value": self.raw_value,
        }

is_valid property

is_valid: bool

Return whether the advertisement contained a usable value.

__post_init__

__post_init__() -> None

Validate invariants shared by decoded and user-created instances.

Source code in src/ondotori_ble/models.py
def __post_init__(self) -> None:
    """Validate invariants shared by decoded and user-created instances."""
    if self.channel < 1:
        message = "channel must be at least 1"
        raise ValueError(message)
    if not 0 <= self.raw_value <= 0xFFFFFFFF:
        message = "raw_value must fit in an unsigned 32-bit integer"
        raise ValueError(message)
    if self.value is not None and not math.isfinite(self.value):
        message = "value must be finite when present"
        raise ValueError(message)
    if self.unit is not _UNIT_BY_KIND[self.kind]:
        message = f"unit {self.unit.value!r} does not match {self.kind.value}"
        raise ValueError(message)

as_dict

as_dict() -> dict[str, int | float | str | None]

Return a JSON-serializable representation.

Source code in src/ondotori_ble/models.py
def as_dict(self) -> dict[str, int | float | str | None]:
    """Return a JSON-serializable representation."""
    return {
        "channel": self.channel,
        "kind": self.kind.value,
        "value": self.value,
        "unit": self.unit.value,
        "raw_value": self.raw_value,
    }

MeasurementKind

Bases: StrEnum

Physical quantity represented by a measurement channel.

Source code in src/ondotori_ble/models.py
class MeasurementKind(StrEnum):
    """Physical quantity represented by a measurement channel."""

    TEMPERATURE = "temperature"
    HUMIDITY = "humidity"
    VOLTAGE = "voltage"
    CURRENT = "current"
    PULSE = "pulse"

Reading dataclass

A decoded T&D BLE advertisement.

A T&D packet from an unknown family is still returned, with model=DeviceModel.UNKNOWN and no measurements. This makes protocol changes observable instead of silently discarding them.

Source code in src/ondotori_ble/models.py
@dataclass(frozen=True, slots=True)
class Reading:
    """A decoded T&D BLE advertisement.

    A T&D packet from an unknown family is still returned, with
    ``model=DeviceModel.UNKNOWN`` and no measurements. This makes protocol
    changes observable instead of silently discarding them.
    """

    identifier: str
    serial_number: str
    family_code: int
    model: DeviceModel
    measurements: tuple[Measurement, ...]
    rssi: int | None
    tx_power: int | None
    observed_at: datetime
    name: str | None
    raw_data: bytes
    advertisement_format: AdvertisementFormat
    evidence: EvidenceLevel

    def __post_init__(self) -> None:
        """Validate stable public invariants."""
        if len(self.serial_number) != 8:
            message = "serial_number must contain exactly eight hexadecimal digits"
            raise ValueError(message)
        try:
            int(self.serial_number, 16)
        except ValueError as error:
            message = "serial_number must be hexadecimal"
            raise ValueError(message) from error
        if not 0 <= self.family_code <= 0xFF:
            message = "family_code must fit in an unsigned byte"
            raise ValueError(message)
        if self.observed_at.tzinfo is None or self.observed_at.utcoffset() is None:
            message = "observed_at must be timezone-aware"
            raise ValueError(message)

    @property
    def serial(self) -> int:
        """Return the serial number as an integer."""
        return int(self.serial_number, 16)

    @property
    def is_decoded(self) -> bool:
        """Return whether this packet produced typed measurements."""
        return bool(self.measurements)

    @property
    def temperatures_c(self) -> tuple[float | None, ...]:
        """Return temperature channels in channel order."""
        return self.values(MeasurementKind.TEMPERATURE)

    @property
    def temperature_c(self) -> float | None:
        """Return the first temperature channel, if present and valid."""
        return next(iter(self.temperatures_c), None)

    @property
    def humidities_percent(self) -> tuple[float | None, ...]:
        """Return relative-humidity channels in channel order."""
        return self.values(MeasurementKind.HUMIDITY)

    def values(self, kind: MeasurementKind) -> tuple[float | None, ...]:
        """Return values of one measurement kind in channel order."""
        return tuple(
            measurement.value for measurement in self.measurements if measurement.kind is kind
        )

    def first_value(self, kind: MeasurementKind) -> float | None:
        """Return the first value of one measurement kind, if available."""
        return next(iter(self.values(kind)), None)

    @property
    def humidity_percent(self) -> float | None:
        """Return the first relative-humidity channel, if present and valid."""
        return self.first_value(MeasurementKind.HUMIDITY)

    def as_dict(self) -> dict[str, Any]:
        """Return a stable JSON-serializable representation."""
        return {
            "identifier": self.identifier,
            "serial_number": self.serial_number,
            "family_code": self.family_code,
            "model": self.model.value,
            "measurements": [measurement.as_dict() for measurement in self.measurements],
            "rssi": self.rssi,
            "tx_power": self.tx_power,
            "observed_at": self.observed_at.isoformat(),
            "name": self.name,
            "raw_data": self.raw_data.hex(),
            "advertisement_format": self.advertisement_format.value,
            "evidence": self.evidence.value,
            "is_decoded": self.is_decoded,
        }

serial property

serial: int

Return the serial number as an integer.

is_decoded property

is_decoded: bool

Return whether this packet produced typed measurements.

temperatures_c property

temperatures_c: tuple[float | None, ...]

Return temperature channels in channel order.

temperature_c property

temperature_c: float | None

Return the first temperature channel, if present and valid.

humidities_percent property

humidities_percent: tuple[float | None, ...]

Return relative-humidity channels in channel order.

humidity_percent property

humidity_percent: float | None

Return the first relative-humidity channel, if present and valid.

__post_init__

__post_init__() -> None

Validate stable public invariants.

Source code in src/ondotori_ble/models.py
def __post_init__(self) -> None:
    """Validate stable public invariants."""
    if len(self.serial_number) != 8:
        message = "serial_number must contain exactly eight hexadecimal digits"
        raise ValueError(message)
    try:
        int(self.serial_number, 16)
    except ValueError as error:
        message = "serial_number must be hexadecimal"
        raise ValueError(message) from error
    if not 0 <= self.family_code <= 0xFF:
        message = "family_code must fit in an unsigned byte"
        raise ValueError(message)
    if self.observed_at.tzinfo is None or self.observed_at.utcoffset() is None:
        message = "observed_at must be timezone-aware"
        raise ValueError(message)

values

values(kind: MeasurementKind) -> tuple[float | None, ...]

Return values of one measurement kind in channel order.

Source code in src/ondotori_ble/models.py
def values(self, kind: MeasurementKind) -> tuple[float | None, ...]:
    """Return values of one measurement kind in channel order."""
    return tuple(
        measurement.value for measurement in self.measurements if measurement.kind is kind
    )

first_value

first_value(kind: MeasurementKind) -> float | None

Return the first value of one measurement kind, if available.

Source code in src/ondotori_ble/models.py
def first_value(self, kind: MeasurementKind) -> float | None:
    """Return the first value of one measurement kind, if available."""
    return next(iter(self.values(kind)), None)

as_dict

as_dict() -> dict[str, Any]

Return a stable JSON-serializable representation.

Source code in src/ondotori_ble/models.py
def as_dict(self) -> dict[str, Any]:
    """Return a stable JSON-serializable representation."""
    return {
        "identifier": self.identifier,
        "serial_number": self.serial_number,
        "family_code": self.family_code,
        "model": self.model.value,
        "measurements": [measurement.as_dict() for measurement in self.measurements],
        "rssi": self.rssi,
        "tx_power": self.tx_power,
        "observed_at": self.observed_at.isoformat(),
        "name": self.name,
        "raw_data": self.raw_data.hex(),
        "advertisement_format": self.advertisement_format.value,
        "evidence": self.evidence.value,
        "is_decoded": self.is_decoded,
    }

Unit

Bases: StrEnum

Unit attached to a decoded sensor value.

Source code in src/ondotori_ble/models.py
class Unit(StrEnum):
    """Unit attached to a decoded sensor value."""

    CELSIUS = "°C"
    PERCENT_RH = "%RH"
    VOLT = "V"
    MILLIAMPERE = "mA"
    COUNT = "count"

OndotoriScanner

Continuously decode T&D advertisements without pairing or connecting.

The callback path performs only constant-time parsing and a non-blocking queue write. Identical payloads are de-duplicated by default, while :attr:latest is refreshed for every observation so its timestamp and RSSI stay current.

Parameters:

Name Type Description Default
queue_size int

Maximum number of unread updates. When full, the oldest update is dropped so a slow consumer cannot stall the BLE backend.

256
deduplicate bool

Emit only when a serial's manufacturer payload changes.

True
include_unknown bool

Emit packets with T&D's company ID even when their serial family does not yet have a decoder.

True
start_attempts int

Number of attempts to start the operating-system scan.

3
retry_delay float

Initial delay between start attempts. It doubles after each failure up to 30 seconds.

0.25
serial_numbers str | Iterable[str] | None

Optional serial number or iterable of serial numbers to emit. Matching is case-insensitive.

None
scanner_factory ScannerFactory

Optional Bleak-compatible scanner factory.

BleakScanner
Source code in src/ondotori_ble/scanner.py
class OndotoriScanner:
    """Continuously decode T&D advertisements without pairing or connecting.

    The callback path performs only constant-time parsing and a non-blocking
    queue write. Identical payloads are de-duplicated by default, while
    :attr:`latest` is refreshed for every observation so its timestamp and RSSI
    stay current.

    Args:
        queue_size: Maximum number of unread updates. When full, the oldest
            update is dropped so a slow consumer cannot stall the BLE backend.
        deduplicate: Emit only when a serial's manufacturer payload changes.
        include_unknown: Emit packets with T&D's company ID even when their
            serial family does not yet have a decoder.
        start_attempts: Number of attempts to start the operating-system scan.
        retry_delay: Initial delay between start attempts. It doubles after
            each failure up to 30 seconds.
        serial_numbers: Optional serial number or iterable of serial numbers to
            emit. Matching is case-insensitive.
        scanner_factory: Optional Bleak-compatible scanner factory.
    """

    def __init__(
        self,
        *,
        queue_size: int = 256,
        deduplicate: bool = True,
        include_unknown: bool = True,
        start_attempts: int = 3,
        retry_delay: float = 0.25,
        serial_numbers: str | Iterable[str] | None = None,
        scanner_factory: ScannerFactory = BleakScanner,
    ) -> None:
        if queue_size < 1:
            message = "queue_size must be at least 1"
            raise ValueError(message)
        if start_attempts < 1:
            message = "start_attempts must be at least 1"
            raise ValueError(message)
        if not math.isfinite(retry_delay) or retry_delay < 0:
            message = "retry_delay must be a finite non-negative number"
            raise ValueError(message)

        self._queue_size = queue_size
        self._queue: asyncio.Queue[Reading | object] = asyncio.Queue(maxsize=queue_size)
        self._deduplicate = deduplicate
        self._include_unknown = include_unknown
        self._start_attempts = start_attempts
        self._retry_delay = retry_delay
        self._serial_numbers = _normalize_serial_filter(serial_numbers)
        self._scanner_factory = scanner_factory
        self._scanner: ScannerBackend | None = None
        self._state = ScannerState.STOPPED
        self._state_lock = asyncio.Lock()
        self._latest: dict[str, Reading] = {}
        self._last_payload: dict[str, bytes] = {}
        self._advertisements = 0
        self._tandd_advertisements = 0
        self._parsed = 0
        self._decoded = 0
        self._raw_only = 0
        self._duplicates = 0
        self._malformed = 0
        self._dropped = 0
        self._last_advertisement_at: datetime | None = None
        self._last_tandd_at: datetime | None = None
        self._last_matching_at: datetime | None = None
        self._last_matching_monotonic: float | None = None
        self._last_error: Exception | None = None

    @property
    def is_running(self) -> bool:
        """Return whether the operating-system scan is active."""
        return self._state is ScannerState.RUNNING

    @property
    def state(self) -> ScannerState:
        """Return the scanner lifecycle state."""
        return self._state

    @property
    def last_advertisement_at(self) -> datetime | None:
        """Return when any BLE advertisement was most recently observed."""
        return self._last_advertisement_at

    @property
    def last_tandd_at(self) -> datetime | None:
        """Return when a T&D advertisement was most recently observed."""
        return self._last_tandd_at

    @property
    def last_matching_at(self) -> datetime | None:
        """Return when a packet matching this scanner's filters was last observed."""
        return self._last_matching_at

    @property
    def last_error(self) -> Exception | None:
        """Return the latest backend lifecycle error, if any."""
        return self._last_error

    @property
    def latest(self) -> tuple[Reading, ...]:
        """Return the newest reading per serial number, sorted by serial."""
        return tuple(self._latest[serial] for serial in sorted(self._latest))

    @property
    def stats(self) -> ScannerStats:
        """Return a consistent snapshot of scanner counters."""
        return ScannerStats(
            advertisements=self._advertisements,
            tandd_advertisements=self._tandd_advertisements,
            parsed=self._parsed,
            decoded=self._decoded,
            raw_only=self._raw_only,
            duplicates=self._duplicates,
            malformed=self._malformed,
            dropped=self._dropped,
        )

    def _reset_run_state(self) -> None:
        self._queue = asyncio.Queue(maxsize=self._queue_size)
        self._latest.clear()
        self._last_payload.clear()
        self._advertisements = 0
        self._tandd_advertisements = 0
        self._parsed = 0
        self._decoded = 0
        self._raw_only = 0
        self._duplicates = 0
        self._malformed = 0
        self._dropped = 0
        self._last_advertisement_at = None
        self._last_tandd_at = None
        self._last_matching_at = None
        self._last_matching_monotonic = None
        self._last_error = None

    async def start(self) -> None:
        """Start scanning, retrying transient backend failures.

        Repeated calls while running are safe and do nothing.
        """
        async with self._state_lock:
            if self._state is ScannerState.RUNNING:
                return
            self._reset_run_state()
            self._state = ScannerState.STARTING
            last_error: Exception | None = None
            for attempt in range(1, self._start_attempts + 1):
                scanner: ScannerBackend | None = None
                try:
                    scanner = self._scanner_factory(self._handle_advertisement)
                    await scanner.start()
                except asyncio.CancelledError:
                    self._state = ScannerState.STOPPED
                    if scanner is not None:
                        try:
                            await scanner.stop()
                        except Exception:
                            _LOGGER.debug(
                                "Unable to clean up cancelled scanner start", exc_info=True
                            )
                    raise
                except Exception as error:  # BLE backends expose platform exceptions.
                    last_error = error
                    self._last_error = error
                    _LOGGER.warning(
                        "Unable to start BLE scan (attempt %d/%d): %s",
                        attempt,
                        self._start_attempts,
                        error,
                    )
                    if scanner is not None:
                        try:
                            await scanner.stop()
                        except Exception:  # A partially started backend may not be stoppable.
                            _LOGGER.debug("Unable to clean up failed scanner start", exc_info=True)
                    if attempt < self._start_attempts and self._retry_delay:
                        exponent = min(attempt - 1, 16)
                        delay = min(self._retry_delay * 2**exponent, _MAX_RETRY_DELAY)
                        await asyncio.sleep(delay)
                else:
                    if scanner is None:  # pragma: no cover - protected by the successful await.
                        message = "scanner factory returned no backend"
                        raise RuntimeError(message)
                    self._scanner = scanner
                    self._state = ScannerState.RUNNING
                    return

            message = (
                "BLE scanning could not be started. Check that Bluetooth is on, "
                "this process has Bluetooth permission, and no platform service is blocked."
            )
            self._state = ScannerState.FAILED
            raise ScannerUnavailableError(message) from last_error

    async def stop(self) -> None:
        """Stop scanning and wake any stream consumers.

        Repeated calls are safe.
        """
        async with self._state_lock:
            if self._state is not ScannerState.RUNNING:
                return
            scanner = self._scanner
            self._state = ScannerState.STOPPED
            self._scanner = None
            try:
                if scanner is not None:
                    stop_task = asyncio.create_task(scanner.stop())
                    try:
                        await asyncio.shield(stop_task)
                    except asyncio.CancelledError:
                        try:
                            await stop_task
                        except Exception as error:
                            self._last_error = error
                            self._state = ScannerState.FAILED
                            _LOGGER.exception(
                                "BLE backend failed while cancellation awaited shutdown"
                            )
                        raise
            except Exception as error:
                self._last_error = error
                self._state = ScannerState.FAILED
                raise
            finally:
                self._put_nowait(_STOP, count_drop=False)

    async def __aenter__(self) -> OndotoriScanner:
        """Start scanning and return this scanner."""
        await self.start()
        return self

    async def __aexit__(
        self,
        exception_type: type[BaseException] | None,
        exception: BaseException | None,
        traceback: object | None,
    ) -> None:
        """Stop scanning regardless of how the context exits."""
        try:
            await self.stop()
        except Exception:
            if exception is None:
                raise
            _LOGGER.exception("Unable to stop BLE scanner while handling another exception")

    def _put_nowait(self, item: Reading | object, *, count_drop: bool = True) -> None:
        if self._queue.full():
            try:
                self._queue.get_nowait()
            except asyncio.QueueEmpty:  # pragma: no cover - guarded by full()
                pass
            else:
                if count_drop:
                    self._dropped += 1
        self._queue.put_nowait(item)

    def _handle_advertisement(self, device: BLEDevice, data: AdvertisementData) -> None:
        self._advertisements += 1
        self._last_advertisement_at = datetime.now(UTC)
        payload = data.manufacturer_data.get(T_AND_D_COMPANY_ID)
        if payload is None:
            return
        self._tandd_advertisements += 1
        self._last_tandd_at = self._last_advertisement_at
        try:
            reading = decode_advertisement(
                payload,
                identifier=device.address,
                rssi=data.rssi,
                tx_power=data.tx_power,
                name=data.local_name or device.name,
            )
        except MalformedAdvertisementError:
            self._malformed += 1
            _LOGGER.debug("Ignoring malformed T&D advertisement", exc_info=True)
            return

        self._parsed += 1
        if reading.is_decoded:
            self._decoded += 1
        else:
            self._raw_only += 1
        if self._serial_numbers is not None and reading.serial_number not in self._serial_numbers:
            return
        self._latest[reading.serial_number] = reading
        if reading.model is DeviceModel.UNKNOWN and not self._include_unknown:
            return
        self._last_matching_at = reading.observed_at
        self._last_matching_monotonic = time.monotonic()

        previous = self._last_payload.get(reading.serial_number)
        self._last_payload[reading.serial_number] = reading.raw_data
        if self._deduplicate and previous == reading.raw_data:
            self._duplicates += 1
            return
        self._put_nowait(reading)

    async def get(self, *, max_wait: float | None = None) -> Reading:
        """Wait for one emitted reading.

        Args:
            max_wait: Maximum seconds to wait. ``None`` waits indefinitely.

        Raises:
            TimeoutError: No update arrived before ``max_wait``.
            StopAsyncIteration: The scanner stopped and its queue is empty.
        """
        if max_wait is not None and (not math.isfinite(max_wait) or max_wait <= 0):
            message = "max_wait must be a finite positive number or None"
            raise ValueError(message)
        if self._state is not ScannerState.RUNNING and self._queue.empty():
            raise StopAsyncIteration
        operation = self._queue.get()
        item = await operation if max_wait is None else await asyncio.wait_for(operation, max_wait)
        if item is _STOP:
            self._put_nowait(_STOP, count_drop=False)
            raise StopAsyncIteration
        if not isinstance(item, Reading):  # pragma: no cover - internal invariant
            message = "unexpected scanner queue item"
            raise RuntimeError(message)
        return item

    async def stream(self, *, idle_timeout: float | None = None) -> AsyncIterator[Reading]:
        """Yield updates until stopped or no update arrives before ``idle_timeout``."""
        while True:
            try:
                yield await self.get(max_wait=idle_timeout)
            except (TimeoutError, StopAsyncIteration):
                return

    def __aiter__(self) -> AsyncIterator[Reading]:
        """Iterate over :meth:`stream` with no idle timeout."""
        return self.stream()

    def _matching_silence_seconds(self) -> float | None:
        if self._last_matching_monotonic is None:
            return None
        return time.monotonic() - self._last_matching_monotonic

is_running property

is_running: bool

Return whether the operating-system scan is active.

state property

state: ScannerState

Return the scanner lifecycle state.

last_advertisement_at property

last_advertisement_at: datetime | None

Return when any BLE advertisement was most recently observed.

last_tandd_at property

last_tandd_at: datetime | None

Return when a T&D advertisement was most recently observed.

last_matching_at property

last_matching_at: datetime | None

Return when a packet matching this scanner's filters was last observed.

last_error property

last_error: Exception | None

Return the latest backend lifecycle error, if any.

latest property

latest: tuple[Reading, ...]

Return the newest reading per serial number, sorted by serial.

stats property

stats: ScannerStats

Return a consistent snapshot of scanner counters.

start async

start() -> None

Start scanning, retrying transient backend failures.

Repeated calls while running are safe and do nothing.

Source code in src/ondotori_ble/scanner.py
async def start(self) -> None:
    """Start scanning, retrying transient backend failures.

    Repeated calls while running are safe and do nothing.
    """
    async with self._state_lock:
        if self._state is ScannerState.RUNNING:
            return
        self._reset_run_state()
        self._state = ScannerState.STARTING
        last_error: Exception | None = None
        for attempt in range(1, self._start_attempts + 1):
            scanner: ScannerBackend | None = None
            try:
                scanner = self._scanner_factory(self._handle_advertisement)
                await scanner.start()
            except asyncio.CancelledError:
                self._state = ScannerState.STOPPED
                if scanner is not None:
                    try:
                        await scanner.stop()
                    except Exception:
                        _LOGGER.debug(
                            "Unable to clean up cancelled scanner start", exc_info=True
                        )
                raise
            except Exception as error:  # BLE backends expose platform exceptions.
                last_error = error
                self._last_error = error
                _LOGGER.warning(
                    "Unable to start BLE scan (attempt %d/%d): %s",
                    attempt,
                    self._start_attempts,
                    error,
                )
                if scanner is not None:
                    try:
                        await scanner.stop()
                    except Exception:  # A partially started backend may not be stoppable.
                        _LOGGER.debug("Unable to clean up failed scanner start", exc_info=True)
                if attempt < self._start_attempts and self._retry_delay:
                    exponent = min(attempt - 1, 16)
                    delay = min(self._retry_delay * 2**exponent, _MAX_RETRY_DELAY)
                    await asyncio.sleep(delay)
            else:
                if scanner is None:  # pragma: no cover - protected by the successful await.
                    message = "scanner factory returned no backend"
                    raise RuntimeError(message)
                self._scanner = scanner
                self._state = ScannerState.RUNNING
                return

        message = (
            "BLE scanning could not be started. Check that Bluetooth is on, "
            "this process has Bluetooth permission, and no platform service is blocked."
        )
        self._state = ScannerState.FAILED
        raise ScannerUnavailableError(message) from last_error

stop async

stop() -> None

Stop scanning and wake any stream consumers.

Repeated calls are safe.

Source code in src/ondotori_ble/scanner.py
async def stop(self) -> None:
    """Stop scanning and wake any stream consumers.

    Repeated calls are safe.
    """
    async with self._state_lock:
        if self._state is not ScannerState.RUNNING:
            return
        scanner = self._scanner
        self._state = ScannerState.STOPPED
        self._scanner = None
        try:
            if scanner is not None:
                stop_task = asyncio.create_task(scanner.stop())
                try:
                    await asyncio.shield(stop_task)
                except asyncio.CancelledError:
                    try:
                        await stop_task
                    except Exception as error:
                        self._last_error = error
                        self._state = ScannerState.FAILED
                        _LOGGER.exception(
                            "BLE backend failed while cancellation awaited shutdown"
                        )
                    raise
        except Exception as error:
            self._last_error = error
            self._state = ScannerState.FAILED
            raise
        finally:
            self._put_nowait(_STOP, count_drop=False)

__aenter__ async

__aenter__() -> OndotoriScanner

Start scanning and return this scanner.

Source code in src/ondotori_ble/scanner.py
async def __aenter__(self) -> OndotoriScanner:
    """Start scanning and return this scanner."""
    await self.start()
    return self

__aexit__ async

__aexit__(
    exception_type: type[BaseException] | None,
    exception: BaseException | None,
    traceback: object | None,
) -> None

Stop scanning regardless of how the context exits.

Source code in src/ondotori_ble/scanner.py
async def __aexit__(
    self,
    exception_type: type[BaseException] | None,
    exception: BaseException | None,
    traceback: object | None,
) -> None:
    """Stop scanning regardless of how the context exits."""
    try:
        await self.stop()
    except Exception:
        if exception is None:
            raise
        _LOGGER.exception("Unable to stop BLE scanner while handling another exception")

get async

get(*, max_wait: float | None = None) -> Reading

Wait for one emitted reading.

Parameters:

Name Type Description Default
max_wait float | None

Maximum seconds to wait. None waits indefinitely.

None

Raises:

Type Description
TimeoutError

No update arrived before max_wait.

StopAsyncIteration

The scanner stopped and its queue is empty.

Source code in src/ondotori_ble/scanner.py
async def get(self, *, max_wait: float | None = None) -> Reading:
    """Wait for one emitted reading.

    Args:
        max_wait: Maximum seconds to wait. ``None`` waits indefinitely.

    Raises:
        TimeoutError: No update arrived before ``max_wait``.
        StopAsyncIteration: The scanner stopped and its queue is empty.
    """
    if max_wait is not None and (not math.isfinite(max_wait) or max_wait <= 0):
        message = "max_wait must be a finite positive number or None"
        raise ValueError(message)
    if self._state is not ScannerState.RUNNING and self._queue.empty():
        raise StopAsyncIteration
    operation = self._queue.get()
    item = await operation if max_wait is None else await asyncio.wait_for(operation, max_wait)
    if item is _STOP:
        self._put_nowait(_STOP, count_drop=False)
        raise StopAsyncIteration
    if not isinstance(item, Reading):  # pragma: no cover - internal invariant
        message = "unexpected scanner queue item"
        raise RuntimeError(message)
    return item

stream async

stream(
    *, idle_timeout: float | None = None
) -> AsyncIterator[Reading]

Yield updates until stopped or no update arrives before idle_timeout.

Source code in src/ondotori_ble/scanner.py
async def stream(self, *, idle_timeout: float | None = None) -> AsyncIterator[Reading]:
    """Yield updates until stopped or no update arrives before ``idle_timeout``."""
    while True:
        try:
            yield await self.get(max_wait=idle_timeout)
        except (TimeoutError, StopAsyncIteration):
            return

__aiter__

__aiter__() -> AsyncIterator[Reading]

Iterate over :meth:stream with no idle timeout.

Source code in src/ondotori_ble/scanner.py
def __aiter__(self) -> AsyncIterator[Reading]:
    """Iterate over :meth:`stream` with no idle timeout."""
    return self.stream()

ScannerBackend

Bases: Protocol

Minimal async scanner lifecycle required by :class:OndotoriScanner.

Source code in src/ondotori_ble/scanner.py
class ScannerBackend(Protocol):
    """Minimal async scanner lifecycle required by :class:`OndotoriScanner`."""

    async def start(self) -> None:
        """Start delivering advertisements to the registered callback."""
        ...

    async def stop(self) -> None:
        """Stop delivering advertisements and release backend resources."""
        ...

start async

start() -> None

Start delivering advertisements to the registered callback.

Source code in src/ondotori_ble/scanner.py
async def start(self) -> None:
    """Start delivering advertisements to the registered callback."""
    ...

stop async

stop() -> None

Stop delivering advertisements and release backend resources.

Source code in src/ondotori_ble/scanner.py
async def stop(self) -> None:
    """Stop delivering advertisements and release backend resources."""
    ...

ScannerFactory

Bases: Protocol

Create a scanner backend for a Bleak-compatible detection callback.

Source code in src/ondotori_ble/scanner.py
class ScannerFactory(Protocol):
    """Create a scanner backend for a Bleak-compatible detection callback."""

    def __call__(self, callback: AdvertisementDataCallback, /) -> ScannerBackend:
        """Return a new, initially stopped backend."""
        ...

__call__

__call__(
    callback: AdvertisementDataCallback,
) -> ScannerBackend

Return a new, initially stopped backend.

Source code in src/ondotori_ble/scanner.py
def __call__(self, callback: AdvertisementDataCallback, /) -> ScannerBackend:
    """Return a new, initially stopped backend."""
    ...

ScannerState

Bases: StrEnum

Lifecycle state of an :class:OndotoriScanner.

Source code in src/ondotori_ble/scanner.py
class ScannerState(StrEnum):
    """Lifecycle state of an :class:`OndotoriScanner`."""

    STOPPED = "stopped"
    STARTING = "starting"
    RUNNING = "running"
    FAILED = "failed"

ScannerStats dataclass

Point-in-time counters for the current scanner run.

Source code in src/ondotori_ble/scanner.py
@dataclass(frozen=True, slots=True)
class ScannerStats:
    """Point-in-time counters for the current scanner run."""

    advertisements: int
    tandd_advertisements: int
    parsed: int
    decoded: int
    raw_only: int
    duplicates: int
    malformed: int
    dropped: int

decode_advertisement

decode_advertisement(
    data: bytes | bytearray | memoryview,
    *,
    identifier: str = "",
    rssi: int | None = None,
    tx_power: int | None = None,
    name: str | None = None,
    observed_at: datetime | None = None,
    company_id_included: bool = False,
) -> Reading

Decode one T&D manufacturer-specific data value.

Parameters:

Name Type Description Default
data bytes | bytearray | memoryview

Manufacturer payload. By default this is the value from AdvertisementData.manufacturer_data[0x0392], where Bleak has already separated the company ID.

required
identifier str

Platform BLE identifier (MAC address on most systems, CoreBluetooth UUID on macOS).

''
rssi int | None

Received signal strength in dBm.

None
tx_power int | None

Advertised transmit power in dBm, when present.

None
name str | None

Local or cached BLE name.

None
observed_at datetime | None

Time of receipt. Defaults to the current UTC time.

None
company_id_included bool

Set to True when data begins with the little-endian company identifier bytes 92 03.

False

Returns:

Type Description
Reading

An immutable :class:~ondotori_ble.models.Reading. Unknown T&D model

Reading

families are returned without measurements.

Raises:

Type Description
NotOndotoriAdvertisementError

The included company ID is not T&D's.

MalformedAdvertisementError

The serial or expected values are truncated.

Source code in src/ondotori_ble/parser.py
def decode_advertisement(
    data: bytes | bytearray | memoryview,
    *,
    identifier: str = "",
    rssi: int | None = None,
    tx_power: int | None = None,
    name: str | None = None,
    observed_at: datetime | None = None,
    company_id_included: bool = False,
) -> Reading:
    """Decode one T&D manufacturer-specific data value.

    Args:
        data: Manufacturer payload. By default this is the value from
            ``AdvertisementData.manufacturer_data[0x0392]``, where Bleak has
            already separated the company ID.
        identifier: Platform BLE identifier (MAC address on most systems,
            CoreBluetooth UUID on macOS).
        rssi: Received signal strength in dBm.
        tx_power: Advertised transmit power in dBm, when present.
        name: Local or cached BLE name.
        observed_at: Time of receipt. Defaults to the current UTC time.
        company_id_included: Set to ``True`` when ``data`` begins with the
            little-endian company identifier bytes ``92 03``.

    Returns:
        An immutable :class:`~ondotori_ble.models.Reading`. Unknown T&D model
        families are returned without measurements.

    Raises:
        NotOndotoriAdvertisementError: The included company ID is not T&D's.
        MalformedAdvertisementError: The serial or expected values are truncated.
    """
    payload = bytes(data)
    if company_id_included:
        if len(payload) < 2:
            message = "manufacturer data is missing its two-byte company identifier"
            raise MalformedAdvertisementError(message)
        if payload[:2] != _COMPANY_ID_BYTES:
            actual = int.from_bytes(payload[:2], "little")
            message = f"expected T&D company ID 0x0392, got 0x{actual:04X}"
            raise NotOndotoriAdvertisementError(message)
        payload = payload[2:]

    if len(payload) < 4:
        message = f"T&D payload needs at least 4 serial bytes; got {len(payload)}"
        raise MalformedAdvertisementError(message)

    serial = int.from_bytes(payload[:4], "little")
    serial_number = f"{serial:08X}"
    family = payload[2]
    spec = _MODEL_SPECS.get(family)
    timestamp = observed_at or datetime.now(UTC)

    if spec is None:
        return Reading(
            identifier=identifier,
            serial_number=serial_number,
            family_code=family,
            model=DeviceModel.UNKNOWN,
            measurements=(),
            rssi=rssi,
            tx_power=tx_power,
            observed_at=timestamp,
            name=name,
            raw_data=payload,
            advertisement_format=AdvertisementFormat.UNKNOWN,
            evidence=(
                EvidenceLevel.OBSERVED if family in _OBSERVED_FAMILIES else EvidenceLevel.UNKNOWN
            ),
        )

    if len(payload) < spec.minimum_length:
        message = (
            f"{spec.model.value} payload needs at least {spec.minimum_length} bytes; "
            f"got {len(payload)}"
        )
        raise MalformedAdvertisementError(message)

    measurements = spec.decode(payload)
    return Reading(
        identifier=identifier,
        serial_number=serial_number,
        family_code=family,
        model=spec.model,
        measurements=measurements,
        rssi=rssi,
        tx_power=tx_power,
        observed_at=timestamp,
        name=name,
        raw_data=payload,
        advertisement_format=spec.packet_format,
        evidence=spec.evidence,
    )

decode_manufacturer_data

decode_manufacturer_data(
    manufacturer_data: Mapping[int, bytes],
    *,
    identifier: str = "",
    rssi: int | None = None,
    tx_power: int | None = None,
    name: str | None = None,
    observed_at: datetime | None = None,
) -> Reading | None

Decode T&D data from a Bleak-style manufacturer-data mapping.

None is returned when the mapping contains no T&D company entry. The remaining keyword arguments attach receipt metadata to the reading.

Source code in src/ondotori_ble/parser.py
def decode_manufacturer_data(
    manufacturer_data: Mapping[int, bytes],
    *,
    identifier: str = "",
    rssi: int | None = None,
    tx_power: int | None = None,
    name: str | None = None,
    observed_at: datetime | None = None,
) -> Reading | None:
    """Decode T&D data from a Bleak-style manufacturer-data mapping.

    ``None`` is returned when the mapping contains no T&D company entry.
    The remaining keyword arguments attach receipt metadata to the reading.
    """
    payload = manufacturer_data.get(T_AND_D_COMPANY_ID)
    if payload is None:
        return None
    return decode_advertisement(
        payload,
        identifier=identifier,
        rssi=rssi,
        tx_power=tx_power,
        name=name,
        observed_at=observed_at,
    )

read

read(
    serial_number: str | None = None,
    *,
    timeout: float = 75.0,
    decoded_only: bool = False,
    start_attempts: int = 3,
    retry_delay: float = 0.25,
) -> Reading

Synchronous convenience wrapper around :func:read_async.

Async callers should use await read_async(...) instead.

Source code in src/ondotori_ble/scanner.py
def read(
    serial_number: str | None = None,
    *,
    timeout: float = 75.0,
    decoded_only: bool = False,
    start_attempts: int = 3,
    retry_delay: float = 0.25,
) -> Reading:
    """Synchronous convenience wrapper around :func:`read_async`.

    Async callers should use ``await read_async(...)`` instead.
    """
    try:
        asyncio.get_running_loop()
    except RuntimeError:
        return asyncio.run(
            read_async(
                serial_number,
                timeout=timeout,
                decoded_only=decoded_only,
                start_attempts=start_attempts,
                retry_delay=retry_delay,
            )
        )
    message = "read() cannot run inside an event loop; use 'await read_async(...)'"
    raise RuntimeError(message)

read_all

read_all(
    *,
    duration: float = 75.0,
    decoded_only: bool = False,
    start_attempts: int = 3,
    retry_delay: float = 0.25,
) -> tuple[Reading, ...]

Synchronous convenience wrapper around :func:read_all_async.

Async callers should use await read_all_async(...) instead.

Source code in src/ondotori_ble/scanner.py
def read_all(
    *,
    duration: float = 75.0,
    decoded_only: bool = False,
    start_attempts: int = 3,
    retry_delay: float = 0.25,
) -> tuple[Reading, ...]:
    """Synchronous convenience wrapper around :func:`read_all_async`.

    Async callers should use ``await read_all_async(...)`` instead.
    """
    try:
        asyncio.get_running_loop()
    except RuntimeError:
        return asyncio.run(
            read_all_async(
                duration=duration,
                decoded_only=decoded_only,
                start_attempts=start_attempts,
                retry_delay=retry_delay,
            )
        )
    message = "read_all() cannot run inside an event loop; use 'await read_all_async(...)'"
    raise RuntimeError(message)

read_all_async async

read_all_async(
    *,
    duration: float = 75.0,
    decoded_only: bool = False,
    start_attempts: int = 3,
    retry_delay: float = 0.25,
    scanner_factory: ScannerFactory = BleakScanner,
) -> tuple[Reading, ...]

Collect the latest reading from every T&D BLE device in range.

Unlike :func:read_async, this function waits for the full collection window so slower advertisers also have a chance to appear. An empty tuple is returned when no matching devices are observed.

Parameters:

Name Type Description Default
duration float

Collection window in seconds. The default covers the roughly one-minute interval observed from an RTR500B unit.

75.0
decoded_only bool

Exclude packets whose measurement mode is not decoded.

False
start_attempts int

Number of attempts to start the BLE backend.

3
retry_delay float

Initial exponential-backoff delay between start attempts.

0.25
scanner_factory ScannerFactory

Optional Bleak-compatible scanner factory.

BleakScanner
Source code in src/ondotori_ble/scanner.py
async def read_all_async(
    *,
    duration: float = 75.0,
    decoded_only: bool = False,
    start_attempts: int = 3,
    retry_delay: float = 0.25,
    scanner_factory: ScannerFactory = BleakScanner,
) -> tuple[Reading, ...]:
    """Collect the latest reading from every T&D BLE device in range.

    Unlike :func:`read_async`, this function waits for the full collection
    window so slower advertisers also have a chance to appear. An empty tuple is
    returned when no matching devices are observed.

    Args:
        duration: Collection window in seconds. The default covers the roughly
            one-minute interval observed from an RTR500B unit.
        decoded_only: Exclude packets whose measurement mode is not decoded.
        start_attempts: Number of attempts to start the BLE backend.
        retry_delay: Initial exponential-backoff delay between start attempts.
        scanner_factory: Optional Bleak-compatible scanner factory.
    """
    readings = await scan_async(
        duration,
        include_unknown=True,
        start_attempts=start_attempts,
        retry_delay=retry_delay,
        scanner_factory=scanner_factory,
    )
    if decoded_only:
        return tuple(reading for reading in readings if reading.is_decoded)
    return readings

read_async async

read_async(
    serial_number: str | None = None,
    *,
    timeout: float = 75.0,
    decoded_only: bool = False,
    start_attempts: int = 3,
    retry_delay: float = 0.25,
    scanner_factory: ScannerFactory = BleakScanner,
) -> Reading

Return the first matching T&D reading observed before timeout.

Parameters:

Name Type Description Default
serial_number str | None

Optional eight-digit serial to wait for. When omitted, return the first T&D advertisement.

None
timeout float

Maximum scan time in seconds. The 75-second default covers the approximately one-minute interval observed from an RTR500B unit.

75.0
decoded_only bool

Ignore recognized-but-undecoded and unknown packets.

False
start_attempts int

Number of attempts to start the BLE backend.

3
retry_delay float

Initial exponential-backoff delay between start attempts.

0.25
scanner_factory ScannerFactory

Optional Bleak-compatible scanner factory.

BleakScanner

Raises:

Type Description
ReadingTimeoutError

No matching reading arrived before timeout.

ValueError

A serial number or timeout is invalid.

Source code in src/ondotori_ble/scanner.py
async def read_async(
    serial_number: str | None = None,
    *,
    timeout: float = 75.0,  # noqa: ASYNC109 - public API names the user's deadline.
    decoded_only: bool = False,
    start_attempts: int = 3,
    retry_delay: float = 0.25,
    scanner_factory: ScannerFactory = BleakScanner,
) -> Reading:
    """Return the first matching T&D reading observed before ``timeout``.

    Args:
        serial_number: Optional eight-digit serial to wait for. When omitted,
            return the first T&D advertisement.
        timeout: Maximum scan time in seconds. The 75-second default covers the
            approximately one-minute interval observed from an RTR500B unit.
        decoded_only: Ignore recognized-but-undecoded and unknown packets.
        start_attempts: Number of attempts to start the BLE backend.
        retry_delay: Initial exponential-backoff delay between start attempts.
        scanner_factory: Optional Bleak-compatible scanner factory.

    Raises:
        ReadingTimeoutError: No matching reading arrived before ``timeout``.
        ValueError: A serial number or timeout is invalid.
    """
    _validate_duration(timeout)
    normalized = None if serial_number is None else _normalize_serial_number(serial_number)
    scanner = OndotoriScanner(
        include_unknown=True,
        start_attempts=start_attempts,
        retry_delay=retry_delay,
        serial_numbers=normalized,
        scanner_factory=scanner_factory,
    )
    try:
        async with scanner:
            async with asyncio.timeout(timeout):
                while True:
                    reading = await scanner.get()
                    if not decoded_only or reading.is_decoded:
                        return reading
    except TimeoutError as error:
        target = "a decoded reading" if decoded_only else "a T&D reading"
        if normalized is not None:
            target = f"{target} from serial {normalized}"
        message = f"Timed out after {timeout:g}s waiting for {target}"
        raise ReadingTimeoutError(message) from error

scan

scan(
    duration: float = 10.0,
    *,
    include_unknown: bool = True,
    start_attempts: int = 3,
    retry_delay: float = 0.25,
    serial_numbers: str | Iterable[str] | None = None,
) -> tuple[Reading, ...]

Synchronous convenience wrapper around :func:scan_async.

This function intentionally refuses to nest an event loop. Async callers should use await scan_async(...) instead.

Source code in src/ondotori_ble/scanner.py
def scan(
    duration: float = 10.0,
    *,
    include_unknown: bool = True,
    start_attempts: int = 3,
    retry_delay: float = 0.25,
    serial_numbers: str | Iterable[str] | None = None,
) -> tuple[Reading, ...]:
    """Synchronous convenience wrapper around :func:`scan_async`.

    This function intentionally refuses to nest an event loop. Async callers
    should use ``await scan_async(...)`` instead.
    """
    try:
        asyncio.get_running_loop()
    except RuntimeError:
        return asyncio.run(
            scan_async(
                duration,
                include_unknown=include_unknown,
                start_attempts=start_attempts,
                retry_delay=retry_delay,
                serial_numbers=serial_numbers,
            )
        )
    message = "scan() cannot run inside an event loop; use 'await scan_async(...)'"
    raise RuntimeError(message)

scan_async async

scan_async(
    duration: float = 10.0,
    *,
    include_unknown: bool = True,
    start_attempts: int = 3,
    retry_delay: float = 0.25,
    serial_numbers: str | Iterable[str] | None = None,
    scanner_factory: ScannerFactory = BleakScanner,
) -> tuple[Reading, ...]

Scan for a fixed duration and return the latest packet per serial.

This is the simplest API for async applications. Use :class:OndotoriScanner when you need a continuous stream.

Source code in src/ondotori_ble/scanner.py
async def scan_async(
    duration: float = 10.0,
    *,
    include_unknown: bool = True,
    start_attempts: int = 3,
    retry_delay: float = 0.25,
    serial_numbers: str | Iterable[str] | None = None,
    scanner_factory: ScannerFactory = BleakScanner,
) -> tuple[Reading, ...]:
    """Scan for a fixed duration and return the latest packet per serial.

    This is the simplest API for async applications. Use
    :class:`OndotoriScanner` when you need a continuous stream.
    """
    _validate_duration(duration)
    scanner = OndotoriScanner(
        include_unknown=include_unknown,
        start_attempts=start_attempts,
        retry_delay=retry_delay,
        serial_numbers=serial_numbers,
        scanner_factory=scanner_factory,
    )
    async with scanner:
        await asyncio.sleep(duration)
    return scanner.latest

stream_readings async

stream_readings(
    *,
    queue_size: int = 256,
    deduplicate: bool = True,
    include_unknown: bool = True,
    serial_numbers: str | Iterable[str] | None = None,
    start_attempts: int = 3,
    retry_delay: float = 0.25,
    silence_timeout: float | None = None,
    restart_delay: float | None = 5.0,
    scanner_factory: ScannerFactory = BleakScanner,
) -> AsyncGenerator[Reading]

Continuously yield readings while managing scanner lifecycle.

This is the simplest API for long-running services. A configured silence_timeout restarts the backend when no filter-matching advertisement arrives in that interval, including deduplicated packets. Silence can also mean that no matching logger is nearby, so the default is None and does not infer failure from radio quietness.

When scanner startup remains unavailable after start_attempts, a non-None restart_delay waits and retries until the consumer cancels iteration. Set it to None to propagate :class:ScannerUnavailableError.

Source code in src/ondotori_ble/scanner.py
async def stream_readings(
    *,
    queue_size: int = 256,
    deduplicate: bool = True,
    include_unknown: bool = True,
    serial_numbers: str | Iterable[str] | None = None,
    start_attempts: int = 3,
    retry_delay: float = 0.25,
    silence_timeout: float | None = None,
    restart_delay: float | None = 5.0,
    scanner_factory: ScannerFactory = BleakScanner,
) -> AsyncGenerator[Reading]:
    """Continuously yield readings while managing scanner lifecycle.

    This is the simplest API for long-running services. A configured
    ``silence_timeout`` restarts the backend when no filter-matching
    advertisement arrives in that interval, including deduplicated packets.
    Silence can also mean that no matching logger is nearby, so
    the default is ``None`` and does not infer failure from radio quietness.

    When scanner startup remains unavailable after ``start_attempts``, a
    non-``None`` ``restart_delay`` waits and retries until the consumer cancels
    iteration. Set it to ``None`` to propagate :class:`ScannerUnavailableError`.
    """
    if silence_timeout is not None:
        _validate_duration(silence_timeout)
    if restart_delay is not None and (not math.isfinite(restart_delay) or restart_delay <= 0):
        message = "restart_delay must be a finite positive number or None"
        raise ValueError(message)

    while True:
        scanner = OndotoriScanner(
            queue_size=queue_size,
            deduplicate=deduplicate,
            include_unknown=include_unknown,
            serial_numbers=serial_numbers,
            start_attempts=start_attempts,
            retry_delay=retry_delay,
            scanner_factory=scanner_factory,
        )
        try:
            async with scanner:
                while True:
                    max_wait = silence_timeout
                    elapsed = scanner._matching_silence_seconds()
                    if silence_timeout is not None and elapsed is not None:
                        max_wait = max(silence_timeout - elapsed, 0.001)
                    try:
                        yield await scanner.get(max_wait=max_wait)
                    except TimeoutError:
                        elapsed = scanner._matching_silence_seconds()
                        if (
                            silence_timeout is not None
                            and elapsed is not None
                            and elapsed < silence_timeout
                        ):
                            continue
                        _LOGGER.warning(
                            "No matching T&D reading for %.1fs; restarting BLE scan",
                            silence_timeout,
                        )
                        break
        except ScannerUnavailableError:
            if restart_delay is None:
                raise
            _LOGGER.exception(
                "BLE scanner unavailable; retrying in %.1fs",
                restart_delay,
            )
        if restart_delay:
            await asyncio.sleep(restart_delay)