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 +62 -0
- blesession/attempts.py +147 -0
- blesession/causes.py +103 -0
- blesession/const.py +14 -0
- blesession/errors.py +86 -0
- blesession/hass.py +73 -0
- blesession/link.py +96 -0
- blesession/notifications.py +90 -0
- blesession/py.typed +0 -0
- blesession/report.py +113 -0
- blesession/session.py +125 -0
- blesession/stages.py +59 -0
- blesession/testing.py +110 -0
- blesession/trace.py +177 -0
- blesession-0.1.0.dist-info/METADATA +114 -0
- blesession-0.1.0.dist-info/RECORD +19 -0
- blesession-0.1.0.dist-info/WHEEL +5 -0
- blesession-0.1.0.dist-info/licenses/LICENSE +21 -0
- blesession-0.1.0.dist-info/top_level.txt +1 -0
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
|