blesession 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
blesession/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """One BLE session, instrumented.
2
+
3
+ from blesession import ble_session, Notifications, SessionTrace, stages
4
+
5
+ trace = SessionTrace(stage_map={"start": stages.AUTH})
6
+ async with ble_session(ble_device, trace=trace) as client:
7
+ async with Notifications(client, NOTIFY_UUID, settle=0.5) as replies:
8
+ with trace.timed("start"):
9
+ await client.write_gatt_char(WRITE_UUID, START, response=False)
10
+ await replies.next(timeout=5, step="start")
11
+ ...
12
+
13
+ See docs/design.md for what belongs here and what does not.
14
+ """
15
+
16
+ from . import stages
17
+ from .attempts import Attempt, default_retry_if, run_attempts
18
+ from .causes import generic_cause, placement
19
+ from .errors import (
20
+ AttemptTimedOut,
21
+ BleSessionError,
22
+ ConnectFailed,
23
+ NotificationTimeout,
24
+ SessionDropped,
25
+ Unreachable,
26
+ error_text,
27
+ )
28
+ from .link import LinkInfo, connected_via, is_proxy, probe_link
29
+ from .notifications import Notifications
30
+ from .report import FACT_KEYS, build_report, report_attempt
31
+ from .session import DISCONNECT_TIMEOUT_S, ble_session
32
+ from .trace import SessionTrace, traced
33
+
34
+ __version__ = "0.1.0"
35
+
36
+ __all__ = [
37
+ "DISCONNECT_TIMEOUT_S",
38
+ "FACT_KEYS",
39
+ "Attempt",
40
+ "AttemptTimedOut",
41
+ "BleSessionError",
42
+ "ConnectFailed",
43
+ "LinkInfo",
44
+ "NotificationTimeout",
45
+ "Notifications",
46
+ "SessionDropped",
47
+ "SessionTrace",
48
+ "Unreachable",
49
+ "ble_session",
50
+ "build_report",
51
+ "connected_via",
52
+ "default_retry_if",
53
+ "error_text",
54
+ "generic_cause",
55
+ "is_proxy",
56
+ "placement",
57
+ "probe_link",
58
+ "report_attempt",
59
+ "run_attempts",
60
+ "stages",
61
+ "traced",
62
+ ]
blesession/attempts.py ADDED
@@ -0,0 +1,147 @@
1
+ """Attempts, the lock, and the bound: the contract, with the policy left to you.
2
+
3
+ What the loop fixes, each learnt from an integration that got it wrong first:
4
+
5
+ - The lock is held for **one attempt**, not the whole retry sequence, so
6
+ between attempts (the pause, or after a timed-out attempt) other devices
7
+ go first.
8
+ - Every attempt has a bound, connecting included. A GATT write has no
9
+ timeout of its own; a proxy that dies mid-transfer leaves the attempt
10
+ hanging, and while it holds the lock every other device hangs with it.
11
+ - The checks that can change while waiting for the lock (a write lock, a
12
+ superseded job, a duplicate payload) run under the lock, before the
13
+ attempt, via `guard`.
14
+ - A timed-out attempt is not retried by default: the transport is dead, not
15
+ the device unwilling, and a retry would only hang the lock again.
16
+
17
+ What it leaves to the integration: the lock instance and its scope, how
18
+ many attempts, the bound, the pause, and — via `retry_if` — which failures
19
+ deserve another try.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import contextlib
26
+ import logging
27
+ from asyncio import sleep
28
+ from collections.abc import Awaitable, Callable
29
+ from dataclasses import dataclass, field
30
+ from typing import Any
31
+
32
+ from .errors import AttemptTimedOut
33
+ from .stages import StageMap
34
+ from .trace import SessionTrace
35
+
36
+ _LOGGER = logging.getLogger(__name__)
37
+
38
+
39
+ @dataclass
40
+ class Attempt[T]:
41
+ """One try: its number, its trace, and how it ended."""
42
+
43
+ number: int
44
+ trace: SessionTrace
45
+ result: T | None = None
46
+ error: BaseException | None = None
47
+ timed_out: bool = False
48
+ skipped: Any = None
49
+ """What `guard` returned when it declined to run this attempt."""
50
+ state: dict[str, Any] = field(default_factory=dict)
51
+ """Scratch for `retry_if` / the attempt function across attempts (pacing
52
+ counters, ...). Copied forward from the previous attempt."""
53
+
54
+ @property
55
+ def ok(self) -> bool:
56
+ return self.error is None and self.skipped is None
57
+
58
+ @property
59
+ def failed_stage(self) -> str | None:
60
+ """The primary stage the failure hit (see SessionTrace.failure)."""
61
+ return self.trace.failure(self.error)[0]
62
+
63
+ @property
64
+ def failed_detail(self) -> str | None:
65
+ return self.trace.failure(self.error)[1]
66
+
67
+
68
+ type AttemptFn[T] = Callable[[Attempt[T]], Awaitable[T]]
69
+ type RetryIf[T] = Callable[[Attempt[T]], bool]
70
+ type Guard = Callable[[], Awaitable[Any]]
71
+ type OnAttempt[T] = Callable[[Attempt[T]], None]
72
+
73
+
74
+ def default_retry_if(attempt: Attempt[Any]) -> bool:
75
+ """Retry anything but a timed-out attempt."""
76
+ return not attempt.timed_out
77
+
78
+
79
+ async def run_attempts[T](
80
+ attempt_fn: AttemptFn[T],
81
+ *,
82
+ lock: asyncio.Lock | None = None,
83
+ max_attempts: int = 1,
84
+ attempt_timeout_s: float | None = None,
85
+ pause_s: float = 1.0,
86
+ retry_if: RetryIf[T] = default_retry_if,
87
+ guard: Guard | None = None,
88
+ on_attempt: OnAttempt[T] | None = None,
89
+ stage_map: StageMap | None = None,
90
+ name: str = "",
91
+ ) -> Attempt[T]:
92
+ """Run `attempt_fn` up to `max_attempts` times and return the last Attempt.
93
+
94
+ Never raises for a failed attempt: the returned Attempt carries the
95
+ error, the trace and the stage, and the integration decides what to
96
+ raise or publish. `on_attempt` sees every attempt as it finishes (for
97
+ logging, or recording each one on a sensor).
98
+
99
+ `attempt_fn` receives the Attempt (use `attempt.trace` for its stages
100
+ and `attempt.state` for anything carried between attempts) and returns
101
+ the result or raises.
102
+ """
103
+ if max_attempts < 1:
104
+ raise ValueError("max_attempts must be at least 1")
105
+ state: dict[str, Any] = {}
106
+ attempt: Attempt[T] | None = None
107
+ for number in range(1, max_attempts + 1):
108
+ attempt = Attempt(number=number, trace=SessionTrace(stage_map), state=dict(state))
109
+ async with lock if lock is not None else contextlib.nullcontext():
110
+ if guard is not None and (skipped := await guard()) is not None:
111
+ attempt.skipped = skipped
112
+ return attempt
113
+ bound = asyncio.timeout(attempt_timeout_s)
114
+ try:
115
+ async with bound:
116
+ attempt.result = await attempt_fn(attempt)
117
+ except Exception as exc: # noqa: BLE001 - every failure is an attempt outcome
118
+ attempt.error = exc
119
+ if bound.expired():
120
+ attempt.timed_out = True
121
+ assert attempt_timeout_s is not None
122
+ timed_out = AttemptTimedOut(
123
+ attempt_timeout_s,
124
+ stage=attempt.trace.failed_primary,
125
+ detail=attempt.trace.failed_detail,
126
+ )
127
+ timed_out.__cause__ = exc
128
+ attempt.error = timed_out
129
+ _LOGGER.debug(
130
+ "%s attempt %d/%d failed in %s: %s",
131
+ name or "session",
132
+ number,
133
+ max_attempts,
134
+ attempt.failed_stage or "?",
135
+ attempt.error,
136
+ exc_info=attempt.error,
137
+ )
138
+ state = attempt.state
139
+ if on_attempt is not None:
140
+ on_attempt(attempt)
141
+ if attempt.ok or number == max_attempts or not retry_if(attempt):
142
+ return attempt
143
+ # Lock released: other devices go first. Module-level `sleep` so an
144
+ # integration's tests can stub the pause.
145
+ await sleep(pause_s)
146
+ assert attempt is not None
147
+ return attempt
blesession/causes.py ADDED
@@ -0,0 +1,103 @@
1
+ """One sentence on what a failed session most likely means — the generic part.
2
+
3
+ Read from where it died (the primary stage), the error text and the radio
4
+ situation. Best effort: the report keeps the exact `error` beside it. A
5
+ device's own sentences ("the tag rejected authentication: not a WOLINK
6
+ tag", "the cuff was not showing -P-") come from the integration's cause
7
+ callback, which build_report() consults first; these fill in when it has
8
+ nothing to say.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping
14
+ from typing import Any
15
+
16
+ from . import stages
17
+ from .errors import AttemptTimedOut
18
+
19
+ WEAK_RSSI_DBM = -85
20
+ """At or below this the placement advice is worth giving; above it the radio
21
+ is not the first suspect."""
22
+
23
+
24
+ def placement(facts: Mapping[str, Any], *, noun: str = "device") -> str:
25
+ """Placement advice when the signal is actually weak, else an empty string.
26
+
27
+ A single radio is the normal case and on its own says nothing about the
28
+ cause; it is mentioned only alongside a weak signal.
29
+ """
30
+ rssi = facts.get("rssi")
31
+ if not isinstance(rssi, int) or rssi > WEAK_RSSI_DBM:
32
+ return ""
33
+ text = f" The signal is weak ({rssi} dBm via {facts.get('via', 'unknown')})"
34
+ if facts.get("paths") == 1:
35
+ text += f" and no other radio reaches the {noun}"
36
+ return text + f" — move the {noun} or add a proxy."
37
+
38
+
39
+ def generic_cause(
40
+ stage: str | None,
41
+ error: str,
42
+ facts: Mapping[str, Any],
43
+ *,
44
+ exc: BaseException | None = None,
45
+ noun: str = "device",
46
+ ) -> str | None:
47
+ """The generic sentence for a failure, or None when there is none.
48
+
49
+ Keyed on the primary stage and a few error-text markers that every
50
+ transport produces the same way.
51
+ """
52
+ err = error.lower()
53
+ where = placement(facts, noun=noun)
54
+ if isinstance(exc, AttemptTimedOut):
55
+ return (
56
+ "The BLE stack stopped answering mid-session and the attempt was cut at "
57
+ "its bound: usually a wedged adapter or a proxy that died. Restart the "
58
+ "adapter / proxy if it repeats."
59
+ )
60
+ if stage == stages.UNREACHABLE:
61
+ return (
62
+ f"No radio currently sees the {noun}: out of range, asleep, its battery "
63
+ "flat, or the adapter / proxy is down."
64
+ )
65
+ if stage == stages.CONNECT:
66
+ if "slot" in err:
67
+ return (
68
+ "The proxy has no free connection slot; add a proxy or reduce the "
69
+ "BLE devices it serves."
70
+ )
71
+ if "settle" in err:
72
+ return (
73
+ f"The {noun} accepted the link and dropped it before encryption "
74
+ "settled: on a multi-proxy setup usually a proxy that does not hold "
75
+ "the bond, otherwise a stale bond — pair again if it repeats."
76
+ )
77
+ return f"The BLE link could not be established.{where}"
78
+ if stage == stages.SESSION:
79
+ return (
80
+ f"Connected, but the {noun} dropped or refused the session before the "
81
+ "protocol started (service discovery / notifications); usually transient "
82
+ "— if it repeats, the protocol or model may not match."
83
+ )
84
+ if stage == stages.AUTH:
85
+ if "no response" in err:
86
+ return (
87
+ f"The {noun} did not answer the handshake: not ready, or the link dropped.{where}"
88
+ )
89
+ return None
90
+ if stage == stages.TRANSFER:
91
+ if "no response" in err:
92
+ return f"The {noun} stopped answering mid-transfer: link dropped or reset.{where}"
93
+ return None
94
+ if stage == stages.FINISH:
95
+ if "no response" in err:
96
+ return f"The {noun} took the data but did not report completion in time.{where}"
97
+ return None
98
+ if stage == stages.DISCONNECT:
99
+ return (
100
+ "The work was done; only the session close failed. "
101
+ "Harmless unless the next connection is refused."
102
+ )
103
+ return None
blesession/const.py ADDED
@@ -0,0 +1,14 @@
1
+ """Option keys and defaults shared across integrations.
2
+
3
+ Exported so config flows and options can use the same names; the library
4
+ never reads configuration itself.
5
+ """
6
+
7
+ CONF_RETRY_COUNT = "retry_count"
8
+ CONF_KEEP_CONNECTION = "keep_connection"
9
+ CONF_SCAN_INTERVAL = "scan_interval"
10
+ CONF_ATTEMPT_TIMEOUT = "attempt_timeout"
11
+
12
+ DEFAULT_RETRY_COUNT = 3
13
+ DEFAULT_KEEP_CONNECTION = False
14
+ DEFAULT_RETRY_PAUSE_S = 1.0
blesession/errors.py ADDED
@@ -0,0 +1,86 @@
1
+ """Session errors: every one a ConnectionError, every one naming its stage.
2
+
3
+ A device that is off, out of range, asleep or unwilling is the ordinary case
4
+ for a BLE integration, and must reach Home Assistant as an expected failure
5
+ (`UpdateFailed`, `HomeAssistantError`) rather than a traceback that renders
6
+ as "this error originated from a custom integration". Deriving from
7
+ ConnectionError lets an integration make that mapping in one `except`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from . import stages
13
+
14
+
15
+ class BleSessionError(ConnectionError):
16
+ """A session failed in `stage` (a primary stage) with optional `detail`."""
17
+
18
+ stage: str | None = None
19
+ detail: str | None = None
20
+
21
+ def __init__(self, message: str = "", *, stage: str | None = None, detail: str | None = None):
22
+ super().__init__(message)
23
+ if stage is not None:
24
+ self.stage = stage
25
+ if detail is not None:
26
+ self.detail = detail
27
+
28
+
29
+ class Unreachable(BleSessionError):
30
+ """No connectable radio currently sees the device."""
31
+
32
+ stage = stages.UNREACHABLE
33
+
34
+ def __init__(self, address: str) -> None:
35
+ super().__init__(
36
+ f"No connectable radio sees {address} "
37
+ "(out of range, asleep, or the adapter / proxy is down)"
38
+ )
39
+
40
+
41
+ class ConnectFailed(BleSessionError):
42
+ """establish_connection raised, or the link dropped during the settle."""
43
+
44
+ stage = stages.CONNECT
45
+
46
+
47
+ class SessionDropped(BleSessionError):
48
+ """The link went away mid-session."""
49
+
50
+ stage = stages.SESSION
51
+
52
+
53
+ class NotificationTimeout(BleSessionError, TimeoutError):
54
+ """No notification arrived in time. Carries the `step` that was waiting.
55
+
56
+ Also a TimeoutError so protocol code that already catches one keeps
57
+ working; unlike asyncio's it always carries a message.
58
+ """
59
+
60
+ def __init__(self, timeout: float, *, step: str, message: str | None = None) -> None:
61
+ super().__init__(
62
+ message or f"No response from device within {timeout:g}s after {step}", detail=step
63
+ )
64
+ self.step = step
65
+ self.timeout = timeout
66
+
67
+
68
+ class AttemptTimedOut(BleSessionError, TimeoutError):
69
+ """The attempt bound fired: the transport is dead, not the device unwilling.
70
+
71
+ `stage` is the stage that was running when the bound hit, taken from
72
+ the trace by run_attempts().
73
+ """
74
+
75
+ def __init__(self, timeout: float, *, stage: str | None = None, detail: str | None = None):
76
+ super().__init__(
77
+ f"Attempt timed out after {timeout:g}s; the BLE stack stopped answering",
78
+ stage=stage,
79
+ detail=detail,
80
+ )
81
+ self.timeout = timeout
82
+
83
+
84
+ def error_text(exc: BaseException) -> str:
85
+ """The message, or the type name for exceptions that carry none."""
86
+ return str(exc) or type(exc).__name__
blesession/hass.py ADDED
@@ -0,0 +1,73 @@
1
+ """What only Home Assistant knows about a session: the radios.
2
+
3
+ Imported only from inside a running integration; `homeassistant` is not a
4
+ dependency of this package.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from .link import LinkInfo
12
+
13
+ if TYPE_CHECKING:
14
+ from homeassistant.core import HomeAssistant
15
+
16
+
17
+ def radio_facts(hass: HomeAssistant, address: str, link: LinkInfo | None = None) -> dict[str, Any]:
18
+ """Which radio the session went through, its RSSI and how many reach the device.
19
+
20
+ via the radio the link took (scanner name), when knowable;
21
+ else the scanner holding the strongest advertisement,
22
+ which is the one the client wrapper tries first
23
+ via_type "proxy" | "adapter"
24
+ rssi as seen by that radio
25
+ paths connectable radios that currently see the device;
26
+ 1 means no failover is possible
27
+ advertised_via the scanner whose advertisement was strongest, only
28
+ when it is not the radio the link took (a failover, or
29
+ on a bonded device the one proxy that holds the bond)
30
+
31
+ `link` is what ble_session() recorded on the trace; None (a session that
32
+ never connected) falls back to the advertising scanner.
33
+ """
34
+ from homeassistant.components.bluetooth import (
35
+ BaseHaRemoteScanner,
36
+ BaseHaScanner,
37
+ async_last_service_info,
38
+ async_scanner_by_source,
39
+ async_scanner_devices_by_address,
40
+ )
41
+
42
+ def scanner_for(value: Any) -> Any:
43
+ if isinstance(value, BaseHaScanner):
44
+ return value
45
+ if isinstance(value, str) and value:
46
+ return async_scanner_by_source(hass, value)
47
+ return None
48
+
49
+ advertised = scanner_for(link.source if link else None)
50
+ if advertised is None:
51
+ info = async_last_service_info(hass, address, connectable=True)
52
+ advertised = async_scanner_by_source(hass, info.source) if info else None
53
+ connected = scanner_for(link.via if link else None) or advertised
54
+
55
+ facts: dict[str, Any] = {}
56
+ if connected is not None:
57
+ facts["via"] = connected.name
58
+ facts["via_type"] = "proxy" if isinstance(connected, BaseHaRemoteScanner) else "adapter"
59
+ elif link is not None and link.via:
60
+ # A BlueZ D-Bus path or an id habluetooth does not know: still worth showing.
61
+ facts["via"] = str(link.via)
62
+ rssi_scanner = connected or advertised
63
+ if rssi_scanner is not None:
64
+ try:
65
+ seen = rssi_scanner.get_discovered_device_advertisement_data(address)
66
+ except Exception: # noqa: BLE001 - a scanner without the method
67
+ seen = None
68
+ if seen is not None:
69
+ facts["rssi"] = seen[1].rssi
70
+ facts["paths"] = len(async_scanner_devices_by_address(hass, address, connectable=True))
71
+ if advertised is not None and connected is not None and advertised is not connected:
72
+ facts["advertised_via"] = advertised.name
73
+ return facts
blesession/link.py ADDED
@@ -0,0 +1,96 @@
1
+ """Which radio a link went over — the one place that probes bleak internals.
2
+
3
+ Home Assistant may reach a device through the local adapter or any of
4
+ several Bluetooth proxies, and the scanner whose advertisement was
5
+ strongest is not always the radio the connection took (a failover, or on a
6
+ bonded device the only proxy that holds the bond). The real answer lives
7
+ only on the client wrapper / backend, in private attributes whose shapes
8
+ change between habluetooth and bleak-esphome releases. When they do, this
9
+ is the one function to fix.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ from bleak import BleakClient
18
+ from bleak.backends.device import BLEDevice
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class LinkInfo:
23
+ """What was learnt about the link at connect time.
24
+
25
+ `via` is the radio the connection actually took: a scanner object
26
+ (habluetooth's client wrapper records one), a habluetooth source id, a
27
+ BlueZ D-Bus path, or None when unknowable. `source` is the scanner that
28
+ advertised the device (habluetooth source id) — always a string on a
29
+ proxy route, None on a plain local adapter.
30
+
31
+ Both are opaque here; `blesession.hass.radio_facts()` turns them into
32
+ scanner names.
33
+ """
34
+
35
+ via: Any = None
36
+ source: str | None = None
37
+ proxy: bool | None = None
38
+ """Whether the advertising route was a remote scanner, when knowable."""
39
+
40
+
41
+ def advertising_source(ble_device: BLEDevice) -> str | None:
42
+ """The habluetooth source id the advertisement came from, if recorded.
43
+
44
+ Remote (ESPHome) scanners publish a plain dict with the proxy's source
45
+ address; local adapters carry backend-specific details (a BlueZ object
46
+ path, a WinRT / CoreBluetooth handle).
47
+ """
48
+ details = getattr(ble_device, "details", None)
49
+ if isinstance(details, dict):
50
+ for key in ("source", "scanner"):
51
+ if value := details.get(key):
52
+ return str(value)
53
+ return None
54
+
55
+
56
+ def is_proxy(ble_device: BLEDevice) -> bool:
57
+ """True when Home Assistant reached this device through a remote scanner.
58
+
59
+ Integrations that adapt pacing to the transport (a longer packet
60
+ interval over a proxy) read this; the report reads `via_type` instead,
61
+ which reflects the radio the link actually took.
62
+ """
63
+ details = getattr(ble_device, "details", None)
64
+ return isinstance(details, dict) and "source" in details
65
+
66
+
67
+ def connected_via(client: BleakClient) -> Any:
68
+ """The radio the connection took, from the client, or None.
69
+
70
+ Tried in order:
71
+ client._connected_scanner habluetooth's wrapper, newer versions
72
+ (a BaseHaScanner object)
73
+ client._backend._source bleak-esphome (a source id string)
74
+ client._backend.source
75
+ client._backend._device_path BlueZ (a D-Bus path)
76
+ """
77
+ if (scanner := getattr(client, "_connected_scanner", None)) is not None:
78
+ return scanner
79
+ backend = getattr(client, "_backend", None)
80
+ if backend is None:
81
+ return None
82
+ for attr in ("_source", "source"):
83
+ if value := getattr(backend, attr, None):
84
+ return str(value)
85
+ if path := getattr(backend, "_device_path", None):
86
+ return str(path)
87
+ return None
88
+
89
+
90
+ def probe_link(client: BleakClient, ble_device: BLEDevice) -> LinkInfo:
91
+ """Everything knowable about the link right after connecting."""
92
+ return LinkInfo(
93
+ via=connected_via(client),
94
+ source=advertising_source(ble_device),
95
+ proxy=is_proxy(ble_device),
96
+ )
@@ -0,0 +1,90 @@
1
+ """Notifications from one characteristic, queued for the session's duration.
2
+
3
+ Every protocol needs the same thing around its exchange: subscribe,
4
+ optionally let the link settle, read replies with a timeout, and
5
+ unsubscribe in `finally` even when the link has dropped. Only the
6
+ interpretation of the replies is protocol-specific.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ from collections.abc import Callable
14
+ from typing import Any
15
+
16
+ from bleak import BleakClient
17
+
18
+ from .errors import NotificationTimeout
19
+
20
+
21
+ class Notifications:
22
+ """Queued notifications from one characteristic.
23
+
24
+ async with Notifications(client, NOTIFY_UUID, settle=0.5) as replies:
25
+ await client.write_gatt_char(WRITE_UUID, cmd, response=False)
26
+ reply = await replies.next(timeout=5, step="start")
27
+ done = await replies.wait_for(is_done, timeout=120, step="finish")
28
+
29
+ A queue rather than an event: a reply that lands between two waits is
30
+ kept, not lost. Every wait names its `step`, so the NotificationTimeout
31
+ it raises already says which stage of the protocol went unanswered.
32
+ """
33
+
34
+ def __init__(self, client: BleakClient, characteristic: Any, *, settle: float = 0.0) -> None:
35
+ self._client = client
36
+ self._characteristic = characteristic
37
+ self._settle = settle
38
+ self._queue: asyncio.Queue[bytes] = asyncio.Queue()
39
+
40
+ async def __aenter__(self) -> Notifications:
41
+ await self._client.start_notify(self._characteristic, self._on_notify)
42
+ if self._settle:
43
+ # Some adapters/proxies drop a write issued right after the CCCD write.
44
+ await asyncio.sleep(self._settle)
45
+ return self
46
+
47
+ async def __aexit__(self, *exc_info: Any) -> None:
48
+ # Never let an unsubscribe failure on a dropped link mask the
49
+ # original error; ble_session() still disconnects.
50
+ with contextlib.suppress(Exception):
51
+ if self._client.is_connected:
52
+ await self._client.stop_notify(self._characteristic)
53
+
54
+ def _on_notify(self, _sender: Any, data: bytearray) -> None:
55
+ self._queue.put_nowait(bytes(data))
56
+
57
+ @property
58
+ def pending(self) -> int:
59
+ """Notifications received and not yet read."""
60
+ return self._queue.qsize()
61
+
62
+ def clear(self) -> list[bytes]:
63
+ """Drop (and return) notifications received so far."""
64
+ dropped = []
65
+ while not self._queue.empty():
66
+ dropped.append(self._queue.get_nowait())
67
+ return dropped
68
+
69
+ async def next(self, timeout: float, *, step: str) -> bytes:
70
+ """The next notification, or NotificationTimeout naming `step`."""
71
+ try:
72
+ return await asyncio.wait_for(self._queue.get(), timeout)
73
+ except TimeoutError as exc:
74
+ raise NotificationTimeout(timeout, step=step) from exc
75
+
76
+ async def wait_for(
77
+ self, accept: Callable[[bytes], bool], timeout: float, *, step: str
78
+ ) -> bytes:
79
+ """The first notification `accept` returns True for, within `timeout` overall.
80
+
81
+ `accept` may raise to turn an error frame into the session's failure.
82
+ """
83
+ try:
84
+ async with asyncio.timeout(timeout):
85
+ while True:
86
+ data = await self._queue.get()
87
+ if accept(data):
88
+ return data
89
+ except TimeoutError as exc:
90
+ raise NotificationTimeout(timeout, step=step) from exc
blesession/py.typed ADDED
File without changes