matter-ble-proxy 0.7.1__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.
- matter_ble_proxy/__init__.py +40 -0
- matter_ble_proxy/bleak_backend.py +108 -0
- matter_ble_proxy/cli.py +105 -0
- matter_ble_proxy/client.py +688 -0
- matter_ble_proxy/protocol.py +63 -0
- matter_ble_proxy/py.typed +0 -0
- matter_ble_proxy-0.7.1.dist-info/METADATA +128 -0
- matter_ble_proxy-0.7.1.dist-info/RECORD +11 -0
- matter_ble_proxy-0.7.1.dist-info/WHEEL +5 -0
- matter_ble_proxy-0.7.1.dist-info/entry_points.txt +2 -0
- matter_ble_proxy-0.7.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Python client library for the OHF Matter Server BLE proxy protocol.
|
|
2
|
+
|
|
3
|
+
This package implements the client side of the BLE proxy WebSocket protocol
|
|
4
|
+
exposed by the matter-server's `/ble` endpoint. It bridges a Matter
|
|
5
|
+
commissioning controller running on the server to a local BLE adapter on the
|
|
6
|
+
client side (Bleak directly, Home Assistant's bluetooth component, ESPHome BLE
|
|
7
|
+
proxies, etc.).
|
|
8
|
+
|
|
9
|
+
The core protocol logic lives in :mod:`matter_ble_proxy.client` and is BLE-
|
|
10
|
+
transport-agnostic via the :class:`BleScanSource` and :class:`BleDeviceResolver`
|
|
11
|
+
abstractions. A default :class:`BleakScanSource` + :class:`BleakDeviceResolver`
|
|
12
|
+
implementation is provided in :mod:`matter_ble_proxy.bleak_backend` for
|
|
13
|
+
standalone use; integrators (e.g. Home Assistant) supply their own backend.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .bleak_backend import BleakDeviceResolver, BleakScanSource
|
|
17
|
+
from .client import BleDeviceResolver, BleScanSource, ConnectionState, MatterBleProxy
|
|
18
|
+
from .protocol import (
|
|
19
|
+
BINARY_FRAME_HEADER,
|
|
20
|
+
BLE_PROXY_PROTOCOL_VERSION,
|
|
21
|
+
OPCODE_NOTIFICATION,
|
|
22
|
+
OPCODE_READ_RESPONSE,
|
|
23
|
+
OPCODE_WRITE_DATA,
|
|
24
|
+
AdvertisementData,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"BINARY_FRAME_HEADER",
|
|
29
|
+
"BLE_PROXY_PROTOCOL_VERSION",
|
|
30
|
+
"OPCODE_NOTIFICATION",
|
|
31
|
+
"OPCODE_READ_RESPONSE",
|
|
32
|
+
"OPCODE_WRITE_DATA",
|
|
33
|
+
"AdvertisementData",
|
|
34
|
+
"BleDeviceResolver",
|
|
35
|
+
"BleScanSource",
|
|
36
|
+
"BleakDeviceResolver",
|
|
37
|
+
"BleakScanSource",
|
|
38
|
+
"ConnectionState",
|
|
39
|
+
"MatterBleProxy",
|
|
40
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Default Bleak-based backend for the BLE proxy client.
|
|
2
|
+
|
|
3
|
+
Use these when the host process owns its own BLE adapter directly via Bleak
|
|
4
|
+
(CLI tools, integration tests, single-tenant servers). Home Assistant supplies
|
|
5
|
+
a different backend that wires into its bluetooth component.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from bleak import BleakScanner
|
|
14
|
+
|
|
15
|
+
from .client import BleDeviceResolver, BleScanSource
|
|
16
|
+
from .protocol import AdvertisementData
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
|
|
21
|
+
from bleak.backends.device import BLEDevice
|
|
22
|
+
from bleak.backends.scanner import AdvertisementData as BleakAdvertisementData
|
|
23
|
+
|
|
24
|
+
_LOGGER = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BleakScanSource(BleScanSource):
|
|
28
|
+
"""Scan source backed by a directly-managed `BleakScanner`.
|
|
29
|
+
|
|
30
|
+
The scanner is created on each :meth:`start` and torn down on :meth:`stop`,
|
|
31
|
+
so the BLE adapter sits idle whenever the matter-server is not actively
|
|
32
|
+
scanning. The first `start_scan` after a process boot pays the native
|
|
33
|
+
cold-start cost (CoreBluetooth state transition to `powered_on`, DBus
|
|
34
|
+
handshake) — typically tens to hundreds of milliseconds.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
"""Initialize."""
|
|
39
|
+
self._scanner: BleakScanner | None = None
|
|
40
|
+
self._callback: Callable[[AdvertisementData], None] | None = None
|
|
41
|
+
# Cache the most recent BLEDevice per address so BleakDeviceResolver
|
|
42
|
+
# can hand a fully-formed device to BleakClient (more reliable than
|
|
43
|
+
# connecting by address alone on some platforms).
|
|
44
|
+
self._device_cache: dict[str, BLEDevice] = {}
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def device_cache(self) -> dict[str, BLEDevice]:
|
|
48
|
+
"""Read-only access for paired :class:`BleakDeviceResolver`."""
|
|
49
|
+
return self._device_cache
|
|
50
|
+
|
|
51
|
+
async def start(self, callback: Callable[[AdvertisementData], None]) -> None:
|
|
52
|
+
"""Start the scanner if not already running."""
|
|
53
|
+
self._callback = callback
|
|
54
|
+
if self._scanner is not None:
|
|
55
|
+
return
|
|
56
|
+
self._scanner = BleakScanner(detection_callback=self._on_detection)
|
|
57
|
+
await self._scanner.start()
|
|
58
|
+
|
|
59
|
+
async def stop(self) -> None:
|
|
60
|
+
"""Stop the scanner and release the BLE adapter."""
|
|
61
|
+
scanner = self._scanner
|
|
62
|
+
self._scanner = None
|
|
63
|
+
self._callback = None
|
|
64
|
+
if scanner is not None:
|
|
65
|
+
try:
|
|
66
|
+
await scanner.stop()
|
|
67
|
+
except Exception:
|
|
68
|
+
_LOGGER.debug("Error stopping BleakScanner", exc_info=True)
|
|
69
|
+
|
|
70
|
+
def _on_detection(self, device: BLEDevice, advertisement: BleakAdvertisementData) -> None:
|
|
71
|
+
self._device_cache[device.address] = device
|
|
72
|
+
cb = self._callback
|
|
73
|
+
if cb is None:
|
|
74
|
+
return
|
|
75
|
+
ad = AdvertisementData(
|
|
76
|
+
address=device.address,
|
|
77
|
+
name=advertisement.local_name or device.name,
|
|
78
|
+
rssi=advertisement.rssi,
|
|
79
|
+
connectable=True, # Bleak does not expose this directly; assume true.
|
|
80
|
+
service_data=dict(advertisement.service_data),
|
|
81
|
+
manufacturer_data=dict(advertisement.manufacturer_data),
|
|
82
|
+
service_uuids=list(advertisement.service_uuids),
|
|
83
|
+
)
|
|
84
|
+
try:
|
|
85
|
+
cb(ad)
|
|
86
|
+
except Exception:
|
|
87
|
+
_LOGGER.exception("BLE proxy advertisement callback raised")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class BleakDeviceResolver(BleDeviceResolver):
|
|
91
|
+
"""Default resolver: prefer a cached `BLEDevice`, fall back to the address.
|
|
92
|
+
|
|
93
|
+
When paired with :class:`BleakScanSource`, the resolver reuses the device
|
|
94
|
+
object the scanner observed. Without a paired source it returns the raw
|
|
95
|
+
address — Bleak then performs its own short scan inside `connect()`.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, scan_source: BleakScanSource | None = None) -> None:
|
|
99
|
+
"""Initialize."""
|
|
100
|
+
self._scan_source = scan_source
|
|
101
|
+
|
|
102
|
+
async def resolve(self, address: str) -> BLEDevice | str | None:
|
|
103
|
+
"""Return cached `BLEDevice` if available, else the address."""
|
|
104
|
+
if self._scan_source is not None:
|
|
105
|
+
device = self._scan_source.device_cache.get(address)
|
|
106
|
+
if device is not None:
|
|
107
|
+
return device
|
|
108
|
+
return address
|
matter_ble_proxy/cli.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Reference Bleak-based CLI for the matter-ble-proxy client library.
|
|
2
|
+
|
|
3
|
+
Connects to a matter-server `/ble` WebSocket endpoint and bridges a local
|
|
4
|
+
Bleak adapter into it. Useful for:
|
|
5
|
+
|
|
6
|
+
- exercising the BLE proxy protocol end-to-end without Home Assistant
|
|
7
|
+
- reproducing protocol bugs against a controlled BLE adapter
|
|
8
|
+
- smoke-testing matter-server BLE commissioning from another machine
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
matter-ble-proxy --server ws://localhost:5580/ble
|
|
12
|
+
|
|
13
|
+
The same flag set as the JS `noble-ble-proxy` example.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import asyncio
|
|
20
|
+
import contextlib
|
|
21
|
+
import logging
|
|
22
|
+
import signal
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
from matter_ble_proxy.bleak_backend import BleakDeviceResolver, BleakScanSource
|
|
26
|
+
from matter_ble_proxy.client import MatterBleProxy
|
|
27
|
+
|
|
28
|
+
_LOGGER = logging.getLogger("matter_ble_proxy.cli")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
32
|
+
parser = argparse.ArgumentParser(
|
|
33
|
+
description="Bleak-based BLE proxy client for matter-server's /ble endpoint",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--server",
|
|
37
|
+
default="ws://localhost:5580/ble",
|
|
38
|
+
help="matter-server BLE proxy URL (default: %(default)s)",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--log-level",
|
|
42
|
+
default="INFO",
|
|
43
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
|
44
|
+
help="Logging level (default: %(default)s)",
|
|
45
|
+
)
|
|
46
|
+
return parser.parse_args(argv)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _run(server_url: str) -> int:
|
|
50
|
+
scan_source = BleakScanSource()
|
|
51
|
+
device_resolver = BleakDeviceResolver(scan_source)
|
|
52
|
+
proxy = MatterBleProxy(server_url, scan_source, device_resolver)
|
|
53
|
+
|
|
54
|
+
_LOGGER.info("Connecting to %s...", server_url)
|
|
55
|
+
try:
|
|
56
|
+
await proxy.connect()
|
|
57
|
+
except ConnectionError:
|
|
58
|
+
_LOGGER.exception("Failed to connect")
|
|
59
|
+
return 1
|
|
60
|
+
|
|
61
|
+
_LOGGER.info("Connected. BLE proxy active. Press Ctrl+C to stop.")
|
|
62
|
+
|
|
63
|
+
stop_event = asyncio.Event()
|
|
64
|
+
|
|
65
|
+
def _request_stop() -> None:
|
|
66
|
+
_LOGGER.info("Shutting down...")
|
|
67
|
+
stop_event.set()
|
|
68
|
+
|
|
69
|
+
loop = asyncio.get_running_loop()
|
|
70
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
71
|
+
# add_signal_handler raises NotImplementedError on Windows and inside
|
|
72
|
+
# some restricted async runtimes; fall back to KeyboardInterrupt there.
|
|
73
|
+
with contextlib.suppress(NotImplementedError):
|
|
74
|
+
loop.add_signal_handler(sig, _request_stop)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
await asyncio.wait(
|
|
78
|
+
[
|
|
79
|
+
asyncio.create_task(proxy.run_until_closed()),
|
|
80
|
+
asyncio.create_task(stop_event.wait()),
|
|
81
|
+
],
|
|
82
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
83
|
+
)
|
|
84
|
+
finally:
|
|
85
|
+
await proxy.disconnect()
|
|
86
|
+
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main(argv: list[str] | None = None) -> int:
|
|
91
|
+
"""Console entry point for the `matter-ble-proxy` script."""
|
|
92
|
+
args = _parse_args(argv)
|
|
93
|
+
logging.basicConfig(
|
|
94
|
+
level=args.log_level,
|
|
95
|
+
format="%(asctime)s.%(msecs)03d %(levelname)s %(name)s: %(message)s",
|
|
96
|
+
datefmt="%H:%M:%S",
|
|
97
|
+
)
|
|
98
|
+
try:
|
|
99
|
+
return asyncio.run(_run(args.server))
|
|
100
|
+
except KeyboardInterrupt:
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
sys.exit(main())
|
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
"""Core BLE proxy client.
|
|
2
|
+
|
|
3
|
+
Connects to a matter-server `/ble` WebSocket endpoint, performs the protocol
|
|
4
|
+
handshake, and dispatches commands to a pluggable BLE backend.
|
|
5
|
+
|
|
6
|
+
Two abstractions decouple the protocol logic from the BLE transport:
|
|
7
|
+
|
|
8
|
+
- :class:`BleScanSource` produces :class:`AdvertisementData` events from some
|
|
9
|
+
scan source (Bleak's `BleakScanner`, Home Assistant's bluetooth component,
|
|
10
|
+
ESPHome BLE proxies, etc.).
|
|
11
|
+
- :class:`BleDeviceResolver` turns an `address` string from the server's
|
|
12
|
+
`connect` command into something the local Bleak stack can connect to —
|
|
13
|
+
either a `bleak.BLEDevice` (preferred, carries advertisement context) or
|
|
14
|
+
the address string itself (falls back to Bleak's address-only connect).
|
|
15
|
+
|
|
16
|
+
The defaults in :mod:`matter_ble_proxy.bleak_backend` cover the standalone
|
|
17
|
+
case; Home Assistant supplies its own implementations.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from abc import ABC, abstractmethod
|
|
23
|
+
import asyncio
|
|
24
|
+
import base64
|
|
25
|
+
import logging
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
import aiohttp
|
|
29
|
+
|
|
30
|
+
from .protocol import (
|
|
31
|
+
BINARY_FRAME_HEADER,
|
|
32
|
+
BLE_PROXY_PROTOCOL_VERSION,
|
|
33
|
+
DEFAULT_CONNECT_TIMEOUT_MS,
|
|
34
|
+
HANDSHAKE_TIMEOUT_SECONDS,
|
|
35
|
+
OPCODE_NOTIFICATION,
|
|
36
|
+
OPCODE_WRITE_DATA,
|
|
37
|
+
AdvertisementData,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Callable, Coroutine
|
|
42
|
+
|
|
43
|
+
from bleak import BleakClient
|
|
44
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
|
45
|
+
from bleak.backends.device import BLEDevice
|
|
46
|
+
from bleak.backends.service import BleakGATTServiceCollection
|
|
47
|
+
|
|
48
|
+
_LOGGER = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
# Trailing 24 hex chars of the Bluetooth SIG Base UUID. A 128-bit UUID whose
|
|
51
|
+
# tail matches this is just a wrapped 16-bit (or 32-bit) standard UUID.
|
|
52
|
+
_BASE_UUID_TAIL = "00001000800000805f9b34fb"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _normalize_uuid(uuid: str) -> str:
|
|
56
|
+
"""Collapse standard Bluetooth UUIDs to their shortest comparable form.
|
|
57
|
+
|
|
58
|
+
Accepts short ("fff6", "FFF6"), 32-bit form ("0000fff6"), canonical dashed
|
|
59
|
+
("0000FFF6-…"), or compact 32-char hex. For any form embedded in the
|
|
60
|
+
Bluetooth Base UUID, returns the short 16-bit hex. Otherwise returns the
|
|
61
|
+
compact lowercase hex. Equivalent representations always normalize equal.
|
|
62
|
+
"""
|
|
63
|
+
compact = uuid.lower().replace("-", "")
|
|
64
|
+
# 128-bit canonical form wrapping a 16-bit base UUID (e.g. "0000fff6-0000-1000-8000-00805f9b34fb").
|
|
65
|
+
if len(compact) == 32 and compact[8:] == _BASE_UUID_TAIL and compact.startswith("0000"):
|
|
66
|
+
return compact[4:8]
|
|
67
|
+
# 32-bit form padded with leading zeros (e.g. "0000fff6"); the trailing 4 hex are the 16-bit UUID.
|
|
68
|
+
if len(compact) == 8 and compact.startswith("0000"):
|
|
69
|
+
return compact[4:]
|
|
70
|
+
return compact
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class BleScanSource(ABC):
|
|
74
|
+
"""Pluggable source of BLE advertisements.
|
|
75
|
+
|
|
76
|
+
Implementations call the registered callback for every advertisement the
|
|
77
|
+
underlying stack observes. The proxy filters / forwards them to the
|
|
78
|
+
matter-server.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
async def start(self, callback: Callable[[AdvertisementData], None]) -> None:
|
|
83
|
+
"""Begin scanning and route ads to `callback`. Idempotent."""
|
|
84
|
+
|
|
85
|
+
@abstractmethod
|
|
86
|
+
async def stop(self) -> None:
|
|
87
|
+
"""Stop scanning. Idempotent. After stop, no more callbacks fire."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class BleDeviceResolver(ABC):
|
|
92
|
+
"""Pluggable lookup from `address` to a Bleak connect target."""
|
|
93
|
+
|
|
94
|
+
@abstractmethod
|
|
95
|
+
async def resolve(self, address: str) -> BLEDevice | str | None:
|
|
96
|
+
"""Resolve an address to a Bleak connect target.
|
|
97
|
+
|
|
98
|
+
Return a `BLEDevice` if the stack has one cached, else the bare
|
|
99
|
+
address (Bleak will scan + connect by address), or `None` if the
|
|
100
|
+
device is definitively not present.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ConnectionState:
|
|
105
|
+
"""Tracks one open BLE connection."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, client: BleakClient, handle: int) -> None:
|
|
108
|
+
"""Initialize connection state."""
|
|
109
|
+
self.client = client
|
|
110
|
+
self.handle = handle
|
|
111
|
+
self.services: BleakGATTServiceCollection | None = None
|
|
112
|
+
self.subscriptions: dict[str, BleakGATTCharacteristic] = {}
|
|
113
|
+
self.last_write_characteristic: BleakGATTCharacteristic | None = None
|
|
114
|
+
self.intentional_disconnect = False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class MatterBleProxy:
|
|
118
|
+
"""Proxies BLE operations between a matter-server and a local BLE stack.
|
|
119
|
+
|
|
120
|
+
Usage:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
proxy = MatterBleProxy(
|
|
124
|
+
ws_url="ws://localhost:5580/ble",
|
|
125
|
+
scan_source=BleakScanSource(),
|
|
126
|
+
device_resolver=BleakDeviceResolver(),
|
|
127
|
+
)
|
|
128
|
+
await proxy.connect()
|
|
129
|
+
try:
|
|
130
|
+
# commissioning is driven by the matter-server; the proxy services
|
|
131
|
+
# incoming commands in the background until disconnect() is called.
|
|
132
|
+
await proxy.run_until_closed()
|
|
133
|
+
finally:
|
|
134
|
+
await proxy.disconnect()
|
|
135
|
+
```
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
ws_url: str,
|
|
141
|
+
scan_source: BleScanSource,
|
|
142
|
+
device_resolver: BleDeviceResolver,
|
|
143
|
+
*,
|
|
144
|
+
session: aiohttp.ClientSession | None = None,
|
|
145
|
+
task_factory: Callable[[Coroutine[Any, Any, Any]], asyncio.Task[Any]] | None = None,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Initialize the proxy.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
ws_url: URL of the matter-server `/ble` WebSocket endpoint.
|
|
151
|
+
scan_source: BLE advertisement source backend.
|
|
152
|
+
device_resolver: Resolver from address to Bleak connect target.
|
|
153
|
+
session: Optional pre-existing aiohttp session. If `None`, the
|
|
154
|
+
proxy creates and owns one (closed on disconnect).
|
|
155
|
+
task_factory: Optional task factory for fire-and-forget background
|
|
156
|
+
work (e.g. event forwarding). Defaults to
|
|
157
|
+
`asyncio.get_event_loop().create_task`. Pass
|
|
158
|
+
`hass.async_create_task` from Home Assistant.
|
|
159
|
+
|
|
160
|
+
"""
|
|
161
|
+
self._ws_url = ws_url
|
|
162
|
+
self._scan_source = scan_source
|
|
163
|
+
self._device_resolver = device_resolver
|
|
164
|
+
self._owns_session = session is None
|
|
165
|
+
self._session = session
|
|
166
|
+
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
|
167
|
+
self._connections: dict[int, ConnectionState] = {}
|
|
168
|
+
self._next_handle = 1
|
|
169
|
+
self._scanning = False
|
|
170
|
+
self._message_task: asyncio.Task[None] | None = None
|
|
171
|
+
self._closed_event = asyncio.Event()
|
|
172
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
173
|
+
self._task_factory = task_factory
|
|
174
|
+
|
|
175
|
+
async def connect(self) -> None:
|
|
176
|
+
"""Connect to the matter-server `/ble` endpoint and perform handshake."""
|
|
177
|
+
_LOGGER.debug("Connecting to BLE proxy endpoint: %s", self._ws_url)
|
|
178
|
+
if self._session is None:
|
|
179
|
+
self._session = aiohttp.ClientSession()
|
|
180
|
+
try:
|
|
181
|
+
self._ws = await self._session.ws_connect(self._ws_url)
|
|
182
|
+
except (aiohttp.ClientError, OSError) as err:
|
|
183
|
+
if self._owns_session:
|
|
184
|
+
await self._session.close()
|
|
185
|
+
self._session = None
|
|
186
|
+
raise ConnectionError(f"Failed to connect to BLE proxy endpoint {self._ws_url}: {err}") from err
|
|
187
|
+
|
|
188
|
+
await self._ws.send_json({"type": "hello", "version": BLE_PROXY_PROTOCOL_VERSION})
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
async with asyncio.timeout(HANDSHAKE_TIMEOUT_SECONDS):
|
|
192
|
+
msg = await self._ws.receive()
|
|
193
|
+
except TimeoutError as err:
|
|
194
|
+
await self.disconnect()
|
|
195
|
+
raise ConnectionError("BLE proxy handshake timeout") from err
|
|
196
|
+
|
|
197
|
+
if msg.type != aiohttp.WSMsgType.TEXT:
|
|
198
|
+
await self.disconnect()
|
|
199
|
+
raise ConnectionError(f"Expected text message for handshake, got {msg.type}")
|
|
200
|
+
|
|
201
|
+
response = msg.json()
|
|
202
|
+
if response.get("type") != "hello_response":
|
|
203
|
+
await self.disconnect()
|
|
204
|
+
raise ConnectionError(f"Expected hello_response, got {response}")
|
|
205
|
+
if "error" in response:
|
|
206
|
+
await self.disconnect()
|
|
207
|
+
raise ConnectionError(
|
|
208
|
+
f"BLE proxy handshake failed: {response.get('error')} - {response.get('message')}",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
_LOGGER.info("BLE proxy connected (protocol v%s)", response.get("version"))
|
|
212
|
+
|
|
213
|
+
self._loop = asyncio.get_running_loop()
|
|
214
|
+
self._closed_event.clear()
|
|
215
|
+
self._message_task = self._spawn_task(self._message_loop())
|
|
216
|
+
|
|
217
|
+
async def disconnect(self) -> None:
|
|
218
|
+
"""Disconnect and clean up all BLE connections."""
|
|
219
|
+
if self._message_task is not None:
|
|
220
|
+
self._message_task.cancel()
|
|
221
|
+
self._message_task = None
|
|
222
|
+
|
|
223
|
+
await self._release_ble_resources()
|
|
224
|
+
|
|
225
|
+
if self._ws is not None and not self._ws.closed:
|
|
226
|
+
await self._ws.close()
|
|
227
|
+
self._ws = None
|
|
228
|
+
|
|
229
|
+
if self._owns_session and self._session is not None and not self._session.closed:
|
|
230
|
+
await self._session.close()
|
|
231
|
+
self._session = None
|
|
232
|
+
|
|
233
|
+
self._closed_event.set()
|
|
234
|
+
|
|
235
|
+
async def _release_ble_resources(self) -> None:
|
|
236
|
+
"""Stop scanning and disconnect all BLE peripherals. Idempotent."""
|
|
237
|
+
if self._scanning:
|
|
238
|
+
try:
|
|
239
|
+
await self._scan_source.stop()
|
|
240
|
+
except Exception:
|
|
241
|
+
_LOGGER.debug("Error stopping scan source", exc_info=True)
|
|
242
|
+
self._scanning = False
|
|
243
|
+
|
|
244
|
+
for conn in list(self._connections.values()):
|
|
245
|
+
try:
|
|
246
|
+
if conn.client.is_connected:
|
|
247
|
+
conn.intentional_disconnect = True
|
|
248
|
+
await conn.client.disconnect()
|
|
249
|
+
except Exception:
|
|
250
|
+
_LOGGER.debug("Error disconnecting BLE client", exc_info=True)
|
|
251
|
+
self._connections.clear()
|
|
252
|
+
|
|
253
|
+
async def run_until_closed(self) -> None:
|
|
254
|
+
"""Block until the WebSocket connection is closed."""
|
|
255
|
+
await self._closed_event.wait()
|
|
256
|
+
|
|
257
|
+
# ─── Internals ───────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
def _spawn_task(self, coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]:
|
|
260
|
+
if self._task_factory is not None:
|
|
261
|
+
return self._task_factory(coro)
|
|
262
|
+
# Prefer the loop captured at `connect()` time; `get_event_loop()` is deprecated in 3.12+
|
|
263
|
+
# and raises in threads without a running loop. Fall back to `get_running_loop()` for
|
|
264
|
+
# the (synchronous) call path that runs before `connect()` sets `self._loop`.
|
|
265
|
+
loop = self._loop or asyncio.get_running_loop()
|
|
266
|
+
return loop.create_task(coro)
|
|
267
|
+
|
|
268
|
+
async def _message_loop(self) -> None:
|
|
269
|
+
if self._ws is None:
|
|
270
|
+
return
|
|
271
|
+
try:
|
|
272
|
+
async for msg in self._ws:
|
|
273
|
+
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
274
|
+
await self._handle_command(msg.json())
|
|
275
|
+
elif msg.type == aiohttp.WSMsgType.BINARY:
|
|
276
|
+
await self._handle_binary_frame(msg.data)
|
|
277
|
+
elif msg.type in (
|
|
278
|
+
aiohttp.WSMsgType.CLOSED,
|
|
279
|
+
aiohttp.WSMsgType.CLOSING,
|
|
280
|
+
aiohttp.WSMsgType.ERROR,
|
|
281
|
+
):
|
|
282
|
+
break
|
|
283
|
+
except asyncio.CancelledError:
|
|
284
|
+
return
|
|
285
|
+
except Exception:
|
|
286
|
+
_LOGGER.exception("Error in BLE proxy message loop")
|
|
287
|
+
finally:
|
|
288
|
+
_LOGGER.warning("BLE proxy WebSocket connection ended")
|
|
289
|
+
# Release scan + peripherals so an unexpected WS close doesn't leave the
|
|
290
|
+
# adapter scanning or peripherals connected until the caller calls disconnect().
|
|
291
|
+
await self._release_ble_resources()
|
|
292
|
+
self._closed_event.set()
|
|
293
|
+
|
|
294
|
+
async def _handle_command(self, msg: dict[str, Any]) -> None:
|
|
295
|
+
cmd_id = msg.get("id")
|
|
296
|
+
command = msg.get("command")
|
|
297
|
+
args = msg.get("args", {})
|
|
298
|
+
|
|
299
|
+
if cmd_id is None or command is None:
|
|
300
|
+
_LOGGER.warning("Received invalid command: %s", msg)
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
if _LOGGER.isEnabledFor(logging.INFO):
|
|
304
|
+
# Strip base64 payloads from INFO logs; full args remain at DEBUG via aiohttp's frame log.
|
|
305
|
+
summary = {k: v for k, v in args.items() if k not in {"value"}}
|
|
306
|
+
_LOGGER.info("[←CMD] id=%s %s%s", cmd_id, command, f" {summary}" if summary else "")
|
|
307
|
+
|
|
308
|
+
handler = {
|
|
309
|
+
"start_scan": self._handle_start_scan,
|
|
310
|
+
"stop_scan": self._handle_stop_scan,
|
|
311
|
+
"connect": self._handle_connect,
|
|
312
|
+
"disconnect": self._handle_disconnect,
|
|
313
|
+
"discover_services": self._handle_discover_services,
|
|
314
|
+
"discover_characteristics": self._handle_discover_characteristics,
|
|
315
|
+
"read_characteristic": self._handle_read_characteristic,
|
|
316
|
+
"write_characteristic": self._handle_write_characteristic,
|
|
317
|
+
"subscribe_characteristic": self._handle_subscribe_characteristic,
|
|
318
|
+
"write_and_subscribe": self._handle_write_and_subscribe,
|
|
319
|
+
"unsubscribe_characteristic": self._handle_unsubscribe_characteristic,
|
|
320
|
+
"request_mtu": self._handle_request_mtu,
|
|
321
|
+
}.get(command)
|
|
322
|
+
|
|
323
|
+
if handler is None:
|
|
324
|
+
await self._send_error(cmd_id, "internal_error", f"Unknown command: {command}")
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
await handler(cmd_id, args)
|
|
329
|
+
except Exception as err:
|
|
330
|
+
_LOGGER.exception("Error handling command %s", command)
|
|
331
|
+
await self._send_error(cmd_id, "internal_error", str(err))
|
|
332
|
+
|
|
333
|
+
# ─── Command Handlers ───────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
async def _handle_start_scan(self, cmd_id: int, args: dict[str, Any]) -> None:
|
|
336
|
+
if self._scanning:
|
|
337
|
+
await self._send_error(cmd_id, "already_scanning", "A scan is already in progress")
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
service_uuids: list[str] = args.get("service_uuids", [])
|
|
341
|
+
service_uuid_set = {_normalize_uuid(u) for u in service_uuids} if service_uuids else None
|
|
342
|
+
allow_duplicates: bool = bool(args.get("allow_duplicates", True))
|
|
343
|
+
|
|
344
|
+
# When `allow_duplicates` is false, dedup by content fingerprint — Matter peripherals
|
|
345
|
+
# broadcast at ~10 Hz and the server only needs state changes. When true, the server
|
|
346
|
+
# explicitly opts in to the full stream (e.g. to track RSSI updates).
|
|
347
|
+
last_fingerprint: dict[str, tuple[str | None, bool, tuple[str, ...], tuple[tuple[str, bytes], ...]]] = {}
|
|
348
|
+
|
|
349
|
+
def _on_advertisement(ad: AdvertisementData) -> None:
|
|
350
|
+
if service_uuid_set is not None:
|
|
351
|
+
advertised = {_normalize_uuid(u) for u in ad.service_uuids}
|
|
352
|
+
# Some stacks only surface the service UUID via its service_data key,
|
|
353
|
+
# so include those too. _normalize_uuid collapses Bleak's canonical
|
|
354
|
+
# 128-bit form and the server's short form to a comparable shape.
|
|
355
|
+
advertised.update(_normalize_uuid(k) for k in ad.service_data)
|
|
356
|
+
if not service_uuid_set.intersection(advertised):
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
if not allow_duplicates:
|
|
360
|
+
# Fingerprint intentionally ignores rssi — two ads differing only in signal
|
|
361
|
+
# strength are not interesting when duplicates are suppressed.
|
|
362
|
+
fingerprint = (
|
|
363
|
+
ad.name,
|
|
364
|
+
ad.connectable,
|
|
365
|
+
tuple(sorted(ad.service_uuids)),
|
|
366
|
+
tuple(sorted(ad.service_data.items())),
|
|
367
|
+
)
|
|
368
|
+
if last_fingerprint.get(ad.address) == fingerprint:
|
|
369
|
+
return
|
|
370
|
+
last_fingerprint[ad.address] = fingerprint
|
|
371
|
+
|
|
372
|
+
service_data: dict[str, str] = {
|
|
373
|
+
uuid: base64.b64encode(data).decode("ascii") for uuid, data in ad.service_data.items()
|
|
374
|
+
}
|
|
375
|
+
manufacturer_data: dict[str, str] = {
|
|
376
|
+
str(mid): base64.b64encode(data).decode("ascii") for mid, data in ad.manufacturer_data.items()
|
|
377
|
+
}
|
|
378
|
+
event_data: dict[str, Any] = {
|
|
379
|
+
"address": ad.address,
|
|
380
|
+
"name": ad.name,
|
|
381
|
+
"rssi": ad.rssi,
|
|
382
|
+
"connectable": ad.connectable,
|
|
383
|
+
"service_data": service_data,
|
|
384
|
+
"manufacturer_data": manufacturer_data,
|
|
385
|
+
"service_uuids": list(ad.service_uuids),
|
|
386
|
+
}
|
|
387
|
+
self._spawn_task(self._send_event("device_discovered", event_data))
|
|
388
|
+
|
|
389
|
+
await self._scan_source.start(_on_advertisement)
|
|
390
|
+
self._scanning = True
|
|
391
|
+
await self._send_success(cmd_id)
|
|
392
|
+
|
|
393
|
+
async def _handle_stop_scan(self, cmd_id: int, _args: dict[str, Any]) -> None:
|
|
394
|
+
if not self._scanning:
|
|
395
|
+
await self._send_error(cmd_id, "not_scanning", "No scan is currently active")
|
|
396
|
+
return
|
|
397
|
+
await self._scan_source.stop()
|
|
398
|
+
self._scanning = False
|
|
399
|
+
await self._send_success(cmd_id)
|
|
400
|
+
|
|
401
|
+
async def _handle_connect(self, cmd_id: int, args: dict[str, Any]) -> None:
|
|
402
|
+
from bleak import BleakClient
|
|
403
|
+
|
|
404
|
+
address: str = args["address"]
|
|
405
|
+
timeout_ms: int = args.get("timeout", DEFAULT_CONNECT_TIMEOUT_MS)
|
|
406
|
+
|
|
407
|
+
target = await self._device_resolver.resolve(address)
|
|
408
|
+
if target is None:
|
|
409
|
+
await self._send_error(cmd_id, "device_not_found", f"No BLE device found for address {address}")
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
handle = self._next_handle
|
|
413
|
+
self._next_handle = (self._next_handle + 1) & 0xFFFF
|
|
414
|
+
|
|
415
|
+
def _on_disconnect(_client: BleakClient) -> None:
|
|
416
|
+
conn = self._connections.get(handle)
|
|
417
|
+
if conn is None or conn.intentional_disconnect:
|
|
418
|
+
return
|
|
419
|
+
self._connections.pop(handle, None)
|
|
420
|
+
loop = self._loop
|
|
421
|
+
if loop is None:
|
|
422
|
+
return
|
|
423
|
+
loop.call_soon_threadsafe(
|
|
424
|
+
self._spawn_task,
|
|
425
|
+
self._send_event("disconnected", {"connection_handle": handle}),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
client = BleakClient(target, disconnected_callback=_on_disconnect)
|
|
429
|
+
try:
|
|
430
|
+
async with asyncio.timeout(timeout_ms / 1000):
|
|
431
|
+
await client.connect()
|
|
432
|
+
except TimeoutError:
|
|
433
|
+
await self._send_error(cmd_id, "connection_failed", f"Timeout connecting to {address}")
|
|
434
|
+
return
|
|
435
|
+
except Exception as err:
|
|
436
|
+
await self._send_error(cmd_id, "connection_failed", f"Failed to connect to {address}: {err}")
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
conn = ConnectionState(client, handle)
|
|
440
|
+
self._connections[handle] = conn
|
|
441
|
+
|
|
442
|
+
mtu = getattr(client, "mtu_size", 23)
|
|
443
|
+
await self._send_success(cmd_id, {"connection_handle": handle, "mtu": mtu})
|
|
444
|
+
|
|
445
|
+
async def _handle_disconnect(self, cmd_id: int, args: dict[str, Any]) -> None:
|
|
446
|
+
handle: int = args["connection_handle"]
|
|
447
|
+
conn = self._connections.pop(handle, None)
|
|
448
|
+
if conn is None:
|
|
449
|
+
await self._send_error(cmd_id, "not_connected", f"No connection with handle {handle}")
|
|
450
|
+
return
|
|
451
|
+
conn.intentional_disconnect = True
|
|
452
|
+
if conn.client.is_connected:
|
|
453
|
+
await conn.client.disconnect()
|
|
454
|
+
await self._send_success(cmd_id)
|
|
455
|
+
|
|
456
|
+
async def _handle_discover_services(self, cmd_id: int, args: dict[str, Any]) -> None:
|
|
457
|
+
conn = self._get_connection(args["connection_handle"])
|
|
458
|
+
if conn is None:
|
|
459
|
+
await self._send_error(cmd_id, "not_connected", f"No connection with handle {args['connection_handle']}")
|
|
460
|
+
return
|
|
461
|
+
# Bleak's stub claims `services` is always populated after `connect()`, but in
|
|
462
|
+
# practice it can be `None` if the cache hasn't populated or the connection broke
|
|
463
|
+
# before this command; cast to Optional so the None guard isn't unreachable.
|
|
464
|
+
services: BleakGATTServiceCollection | None = conn.client.services
|
|
465
|
+
if services is None:
|
|
466
|
+
await self._send_error(cmd_id, "discover_failed", "Service discovery has not completed")
|
|
467
|
+
return
|
|
468
|
+
conn.services = services
|
|
469
|
+
services_list = [{"uuid": service.uuid} for service in services]
|
|
470
|
+
await self._send_success(cmd_id, {"services": services_list})
|
|
471
|
+
|
|
472
|
+
async def _handle_discover_characteristics(self, cmd_id: int, args: dict[str, Any]) -> None:
|
|
473
|
+
conn = self._get_connection(args["connection_handle"])
|
|
474
|
+
if conn is None:
|
|
475
|
+
await self._send_error(cmd_id, "not_connected", f"No connection with handle {args['connection_handle']}")
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
service_uuid: str = args["service_uuid"]
|
|
479
|
+
if conn.services is None:
|
|
480
|
+
await self._send_error(cmd_id, "service_not_found", "Services not discovered yet")
|
|
481
|
+
return
|
|
482
|
+
|
|
483
|
+
service = conn.services.get_service(service_uuid)
|
|
484
|
+
if service is None:
|
|
485
|
+
await self._send_error(cmd_id, "service_not_found", f"Service {service_uuid} not found")
|
|
486
|
+
return
|
|
487
|
+
|
|
488
|
+
characteristics = [
|
|
489
|
+
{"uuid": char.uuid, "properties": list(char.properties)} for char in service.characteristics
|
|
490
|
+
]
|
|
491
|
+
await self._send_success(cmd_id, {"characteristics": characteristics})
|
|
492
|
+
|
|
493
|
+
async def _handle_read_characteristic(self, cmd_id: int, args: dict[str, Any]) -> None:
|
|
494
|
+
conn = self._get_connection(args["connection_handle"])
|
|
495
|
+
if conn is None:
|
|
496
|
+
await self._send_error(cmd_id, "not_connected", f"No connection with handle {args['connection_handle']}")
|
|
497
|
+
return
|
|
498
|
+
char_uuid: str = args["characteristic_uuid"]
|
|
499
|
+
try:
|
|
500
|
+
data = await conn.client.read_gatt_char(char_uuid)
|
|
501
|
+
except Exception as err:
|
|
502
|
+
await self._send_error(cmd_id, "read_failed", f"read_gatt_char({char_uuid}): {err}")
|
|
503
|
+
return
|
|
504
|
+
await self._send_success(cmd_id, {"value": base64.b64encode(data).decode("ascii")})
|
|
505
|
+
|
|
506
|
+
async def _handle_write_characteristic(self, cmd_id: int, args: dict[str, Any]) -> None:
|
|
507
|
+
conn = self._get_connection(args["connection_handle"])
|
|
508
|
+
if conn is None:
|
|
509
|
+
await self._send_error(cmd_id, "not_connected", f"No connection with handle {args['connection_handle']}")
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
char_uuid: str = args["characteristic_uuid"]
|
|
513
|
+
data = base64.b64decode(args["value"])
|
|
514
|
+
response = args.get("response", False)
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
await conn.client.write_gatt_char(char_uuid, data, response=response)
|
|
518
|
+
except Exception as err:
|
|
519
|
+
await self._send_error(cmd_id, "write_failed", f"write_gatt_char({char_uuid}): {err}")
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
# Remember the last-written characteristic so subsequent binary
|
|
523
|
+
# WRITE_DATA frames know where to dispatch payload.
|
|
524
|
+
if conn.services is not None:
|
|
525
|
+
char_obj = conn.services.get_characteristic(char_uuid)
|
|
526
|
+
if char_obj is not None:
|
|
527
|
+
conn.last_write_characteristic = char_obj
|
|
528
|
+
|
|
529
|
+
await self._send_success(cmd_id)
|
|
530
|
+
|
|
531
|
+
async def _handle_subscribe_characteristic(self, cmd_id: int, args: dict[str, Any]) -> None:
|
|
532
|
+
conn = self._get_connection(args["connection_handle"])
|
|
533
|
+
if conn is None:
|
|
534
|
+
await self._send_error(cmd_id, "not_connected", f"No connection with handle {args['connection_handle']}")
|
|
535
|
+
return
|
|
536
|
+
|
|
537
|
+
char_uuid: str = args["characteristic_uuid"]
|
|
538
|
+
handle = conn.handle
|
|
539
|
+
|
|
540
|
+
def _on_notification(_char: BleakGATTCharacteristic, data: bytearray) -> None:
|
|
541
|
+
payload = bytes(data)
|
|
542
|
+
_LOGGER.debug("[NTFY] handle=%d char=%s len=%d head=%s", handle, char_uuid, len(payload), payload[:8].hex())
|
|
543
|
+
frame = BINARY_FRAME_HEADER.pack(OPCODE_NOTIFICATION, handle) + payload
|
|
544
|
+
loop = self._loop
|
|
545
|
+
if loop is None:
|
|
546
|
+
return
|
|
547
|
+
loop.call_soon_threadsafe(self._spawn_task, self._send_binary(frame))
|
|
548
|
+
|
|
549
|
+
try:
|
|
550
|
+
await conn.client.start_notify(char_uuid, _on_notification)
|
|
551
|
+
except Exception as err:
|
|
552
|
+
await self._send_error(cmd_id, "subscribe_failed", f"start_notify({char_uuid}): {err}")
|
|
553
|
+
return
|
|
554
|
+
|
|
555
|
+
# Track the subscription so `unsubscribe_characteristic` can detect
|
|
556
|
+
# not-subscribed errors locally without leaning on Bleak's exception.
|
|
557
|
+
if conn.services is not None:
|
|
558
|
+
char_obj = conn.services.get_characteristic(char_uuid)
|
|
559
|
+
if char_obj is not None:
|
|
560
|
+
conn.subscriptions[char_uuid] = char_obj
|
|
561
|
+
await self._send_success(cmd_id)
|
|
562
|
+
|
|
563
|
+
async def _handle_write_and_subscribe(self, cmd_id: int, args: dict[str, Any]) -> None:
|
|
564
|
+
"""Atomic write-then-subscribe.
|
|
565
|
+
|
|
566
|
+
Eliminates the WS round-trip between Write Response and CCCD enable that can lose
|
|
567
|
+
an indication if the peripheral pushes it immediately after Write Response (e.g.
|
|
568
|
+
Matter BTP handshake response on C2).
|
|
569
|
+
"""
|
|
570
|
+
conn = self._get_connection(args["connection_handle"])
|
|
571
|
+
if conn is None:
|
|
572
|
+
await self._send_error(cmd_id, "not_connected", f"No connection with handle {args['connection_handle']}")
|
|
573
|
+
return
|
|
574
|
+
|
|
575
|
+
write_uuid: str = args["write_uuid"]
|
|
576
|
+
write_data = base64.b64decode(args["write_value"])
|
|
577
|
+
write_response = args.get("write_response", False)
|
|
578
|
+
subscribe_uuid: str = args["subscribe_uuid"]
|
|
579
|
+
handle = conn.handle
|
|
580
|
+
|
|
581
|
+
def _on_notification(_char: BleakGATTCharacteristic, data: bytearray) -> None:
|
|
582
|
+
payload = bytes(data)
|
|
583
|
+
_LOGGER.debug(
|
|
584
|
+
"[NTFY] handle=%d char=%s len=%d head=%s",
|
|
585
|
+
handle,
|
|
586
|
+
subscribe_uuid,
|
|
587
|
+
len(payload),
|
|
588
|
+
payload[:8].hex(),
|
|
589
|
+
)
|
|
590
|
+
frame = BINARY_FRAME_HEADER.pack(OPCODE_NOTIFICATION, handle) + payload
|
|
591
|
+
loop = self._loop
|
|
592
|
+
if loop is None:
|
|
593
|
+
return
|
|
594
|
+
loop.call_soon_threadsafe(self._spawn_task, self._send_binary(frame))
|
|
595
|
+
|
|
596
|
+
try:
|
|
597
|
+
await conn.client.write_gatt_char(write_uuid, write_data, response=write_response)
|
|
598
|
+
except Exception as err:
|
|
599
|
+
await self._send_error(cmd_id, "write_failed", f"write_gatt_char({write_uuid}): {err}")
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
if conn.services is not None:
|
|
603
|
+
char_obj = conn.services.get_characteristic(write_uuid)
|
|
604
|
+
if char_obj is not None:
|
|
605
|
+
conn.last_write_characteristic = char_obj
|
|
606
|
+
|
|
607
|
+
try:
|
|
608
|
+
await conn.client.start_notify(subscribe_uuid, _on_notification)
|
|
609
|
+
except Exception as err:
|
|
610
|
+
await self._send_error(cmd_id, "subscribe_failed", f"start_notify({subscribe_uuid}): {err}")
|
|
611
|
+
return
|
|
612
|
+
|
|
613
|
+
if conn.services is not None:
|
|
614
|
+
sub_obj = conn.services.get_characteristic(subscribe_uuid)
|
|
615
|
+
if sub_obj is not None:
|
|
616
|
+
conn.subscriptions[subscribe_uuid] = sub_obj
|
|
617
|
+
|
|
618
|
+
await self._send_success(cmd_id)
|
|
619
|
+
|
|
620
|
+
async def _handle_unsubscribe_characteristic(self, cmd_id: int, args: dict[str, Any]) -> None:
|
|
621
|
+
conn = self._get_connection(args["connection_handle"])
|
|
622
|
+
if conn is None:
|
|
623
|
+
await self._send_error(cmd_id, "not_connected", f"No connection with handle {args['connection_handle']}")
|
|
624
|
+
return
|
|
625
|
+
char_uuid: str = args["characteristic_uuid"]
|
|
626
|
+
if char_uuid not in conn.subscriptions:
|
|
627
|
+
await self._send_error(cmd_id, "not_subscribed", f"Not subscribed to {char_uuid}")
|
|
628
|
+
return
|
|
629
|
+
try:
|
|
630
|
+
await conn.client.stop_notify(char_uuid)
|
|
631
|
+
except Exception as err:
|
|
632
|
+
await self._send_error(cmd_id, "internal_error", f"stop_notify({char_uuid}): {err}")
|
|
633
|
+
return
|
|
634
|
+
conn.subscriptions.pop(char_uuid, None)
|
|
635
|
+
await self._send_success(cmd_id)
|
|
636
|
+
|
|
637
|
+
async def _handle_request_mtu(self, cmd_id: int, args: dict[str, Any]) -> None:
|
|
638
|
+
conn = self._get_connection(args["connection_handle"])
|
|
639
|
+
if conn is None:
|
|
640
|
+
await self._send_error(cmd_id, "not_connected", f"No connection with handle {args['connection_handle']}")
|
|
641
|
+
return
|
|
642
|
+
# Bleak does not expose an explicit MTU request — return the current MTU.
|
|
643
|
+
mtu = getattr(conn.client, "mtu_size", 23)
|
|
644
|
+
await self._send_success(cmd_id, {"mtu": mtu})
|
|
645
|
+
|
|
646
|
+
# ─── Binary frame handling ──────────────────────────────────────────────
|
|
647
|
+
|
|
648
|
+
async def _handle_binary_frame(self, data: bytes) -> None:
|
|
649
|
+
if len(data) < BINARY_FRAME_HEADER.size:
|
|
650
|
+
_LOGGER.warning("Binary frame too short: %d bytes", len(data))
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
opcode, connection_handle = BINARY_FRAME_HEADER.unpack_from(data)
|
|
654
|
+
payload = data[BINARY_FRAME_HEADER.size :]
|
|
655
|
+
|
|
656
|
+
if opcode == OPCODE_WRITE_DATA:
|
|
657
|
+
conn = self._get_connection(connection_handle)
|
|
658
|
+
if conn is None or conn.last_write_characteristic is None:
|
|
659
|
+
return
|
|
660
|
+
try:
|
|
661
|
+
# Matter BTP writes on C1 use ATT Write Request (with response).
|
|
662
|
+
# C1 typically does not advertise write-without-response, so a
|
|
663
|
+
# response=False here is silently dropped by the peripheral and
|
|
664
|
+
# the BTP session stalls.
|
|
665
|
+
await conn.client.write_gatt_char(conn.last_write_characteristic, payload, response=True)
|
|
666
|
+
except Exception:
|
|
667
|
+
_LOGGER.warning("Binary write error", exc_info=True)
|
|
668
|
+
|
|
669
|
+
# ─── Helpers ─────────────────────────────────────────────────────────────
|
|
670
|
+
|
|
671
|
+
def _get_connection(self, handle: int) -> ConnectionState | None:
|
|
672
|
+
return self._connections.get(handle)
|
|
673
|
+
|
|
674
|
+
async def _send_success(self, cmd_id: int, result: dict[str, Any] | None = None) -> None:
|
|
675
|
+
if self._ws is not None and not self._ws.closed:
|
|
676
|
+
await self._ws.send_json({"id": cmd_id, "success": True, "result": result or {}})
|
|
677
|
+
|
|
678
|
+
async def _send_error(self, cmd_id: int, error: str, message: str) -> None:
|
|
679
|
+
if self._ws is not None and not self._ws.closed:
|
|
680
|
+
await self._ws.send_json({"id": cmd_id, "success": False, "error": error, "message": message})
|
|
681
|
+
|
|
682
|
+
async def _send_event(self, event: str, data: dict[str, Any]) -> None:
|
|
683
|
+
if self._ws is not None and not self._ws.closed:
|
|
684
|
+
await self._ws.send_json({"event": event, "data": data})
|
|
685
|
+
|
|
686
|
+
async def _send_binary(self, data: bytes) -> None:
|
|
687
|
+
if self._ws is not None and not self._ws.closed:
|
|
688
|
+
await self._ws.send_bytes(data)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Protocol constants, types, and codec for the BLE proxy WebSocket protocol.
|
|
2
|
+
|
|
3
|
+
See `docs/ble-proxy-protocol.md` in the matter-server repository for the full
|
|
4
|
+
specification.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
import struct
|
|
11
|
+
|
|
12
|
+
# Current protocol version. Must match the matter-server's
|
|
13
|
+
# `BLE_PROXY_PROTOCOL_VERSION` constant; the server rejects clients that send
|
|
14
|
+
# a different version in the `hello` handshake.
|
|
15
|
+
BLE_PROXY_PROTOCOL_VERSION = 1
|
|
16
|
+
|
|
17
|
+
# Binary frame opcodes. See `docs/ble-proxy-protocol.md` § Binary Frame Protocol.
|
|
18
|
+
OPCODE_WRITE_DATA = 0x01
|
|
19
|
+
OPCODE_NOTIFICATION = 0x02
|
|
20
|
+
OPCODE_READ_RESPONSE = 0x03
|
|
21
|
+
|
|
22
|
+
# Binary frame header: opcode (1 byte) + connection_handle (2 bytes big-endian).
|
|
23
|
+
BINARY_FRAME_HEADER = struct.Struct(">BH")
|
|
24
|
+
|
|
25
|
+
# Handshake must complete within this many seconds or the connection is closed.
|
|
26
|
+
HANDSHAKE_TIMEOUT_SECONDS = 10.0
|
|
27
|
+
|
|
28
|
+
# Default connect timeout for a BLE peripheral if the server's `connect` command
|
|
29
|
+
# does not include an explicit `timeout`. Matter BLE commissioning windows are
|
|
30
|
+
# 15 minutes; this only caps a single connect attempt.
|
|
31
|
+
DEFAULT_CONNECT_TIMEOUT_MS = 30_000
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class AdvertisementData:
|
|
36
|
+
"""One BLE advertisement reported through `device_discovered` events.
|
|
37
|
+
|
|
38
|
+
The fields mirror the JSON `device_discovered` event payload defined in
|
|
39
|
+
the protocol spec. Backend implementations build this from their native
|
|
40
|
+
scan source (Bleak, Home Assistant bluetooth, ESPHome BLE proxy, etc.)
|
|
41
|
+
and hand it to :class:`MatterBleProxy` which forwards it to the server.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
address: str
|
|
45
|
+
"""Peripheral MAC address (Linux/Windows) or CoreBluetooth UUID (macOS)."""
|
|
46
|
+
|
|
47
|
+
name: str | None = None
|
|
48
|
+
"""Local device name from the advertisement payload, if any."""
|
|
49
|
+
|
|
50
|
+
rssi: int | None = None
|
|
51
|
+
"""Signal strength in dBm, if available."""
|
|
52
|
+
|
|
53
|
+
connectable: bool = False
|
|
54
|
+
"""True if the peripheral advertises that it accepts connections."""
|
|
55
|
+
|
|
56
|
+
service_data: dict[str, bytes] = field(default_factory=dict)
|
|
57
|
+
"""Service-data map keyed by service UUID (any form: short, dashed, compact)."""
|
|
58
|
+
|
|
59
|
+
manufacturer_data: dict[int, bytes] = field(default_factory=dict)
|
|
60
|
+
"""Manufacturer-specific data keyed by company id."""
|
|
61
|
+
|
|
62
|
+
service_uuids: list[str] = field(default_factory=list)
|
|
63
|
+
"""List of advertised service UUIDs."""
|
|
File without changes
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: matter-ble-proxy
|
|
3
|
+
Version: 0.7.1
|
|
4
|
+
Summary: Python client library for the OHF Matter Server BLE proxy protocol
|
|
5
|
+
Author-email: Open Home Foundation <hello@openhomefoundation.io>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/matter-js/matterjs-server
|
|
8
|
+
Project-URL: Source, https://github.com/matter-js/matterjs-server/tree/main/python_ble_proxy
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/matter-js/matterjs-server/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/matter-js/matterjs-server/blob/main/CHANGELOG.md
|
|
11
|
+
Platform: any
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Home Automation
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: aiohttp
|
|
20
|
+
Requires-Dist: bleak
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: codespell==2.4.1; extra == "test"
|
|
23
|
+
Requires-Dist: isort==7.0.0; extra == "test"
|
|
24
|
+
Requires-Dist: mypy==1.19.1; extra == "test"
|
|
25
|
+
Requires-Dist: pylint==4.0.4; extra == "test"
|
|
26
|
+
Requires-Dist: pytest>=9.0; extra == "test"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "test"
|
|
28
|
+
Requires-Dist: pytest-aiohttp>=1.0; extra == "test"
|
|
29
|
+
Requires-Dist: pytest-cov>=7.0; extra == "test"
|
|
30
|
+
Requires-Dist: ruff==0.14.9; extra == "test"
|
|
31
|
+
|
|
32
|
+
# matter-ble-proxy
|
|
33
|
+
|
|
34
|
+
Python client library for the [OHF Matter Server](https://github.com/matter-js/matterjs-server)
|
|
35
|
+
BLE proxy WebSocket protocol.
|
|
36
|
+
|
|
37
|
+
The matter-server can run on a host with no BLE adapter and delegate every BLE
|
|
38
|
+
operation to a separate process or device. This library implements the client
|
|
39
|
+
side of that protocol so that any Python process with access to a BLE adapter
|
|
40
|
+
(via [Bleak](https://github.com/hbldh/bleak), Home Assistant's bluetooth
|
|
41
|
+
component, an ESPHome BLE proxy, ...) can act as the BLE bridge.
|
|
42
|
+
|
|
43
|
+
The protocol is documented in [`docs/ble-proxy-protocol.md`](https://github.com/matter-js/matterjs-server/blob/main/docs/ble-proxy-protocol.md).
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install matter-ble-proxy
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Python 3.12+ required.
|
|
52
|
+
|
|
53
|
+
## Standalone CLI
|
|
54
|
+
|
|
55
|
+
The package ships a reference CLI mirroring the JS `noble-ble-proxy` example.
|
|
56
|
+
Useful for testing the matter-server's `/ble` endpoint without Home Assistant
|
|
57
|
+
in the loop.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Start the matter-server with --ble-proxy in one terminal, then:
|
|
61
|
+
matter-ble-proxy --server ws://localhost:5580/ble
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The CLI uses Bleak directly against the local OS bluetooth adapter.
|
|
65
|
+
|
|
66
|
+
## Library API
|
|
67
|
+
|
|
68
|
+
For integrators (Home Assistant, custom add-ons, etc.) wire your own BLE source
|
|
69
|
+
in by implementing two ABCs:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from matter_ble_proxy import (
|
|
73
|
+
AdvertisementData,
|
|
74
|
+
BleDeviceResolver,
|
|
75
|
+
BleScanSource,
|
|
76
|
+
MatterBleProxy,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
class MyScanSource(BleScanSource):
|
|
80
|
+
async def start(self, callback): ... # call `callback(AdvertisementData(...))`
|
|
81
|
+
async def stop(self): ...
|
|
82
|
+
|
|
83
|
+
class MyDeviceResolver(BleDeviceResolver):
|
|
84
|
+
async def resolve(self, address): ... # return a bleak.BLEDevice / address / None
|
|
85
|
+
|
|
86
|
+
proxy = MatterBleProxy(
|
|
87
|
+
ws_url="ws://localhost:5580/ble",
|
|
88
|
+
scan_source=MyScanSource(),
|
|
89
|
+
device_resolver=MyDeviceResolver(),
|
|
90
|
+
)
|
|
91
|
+
await proxy.connect()
|
|
92
|
+
await proxy.run_until_closed()
|
|
93
|
+
await proxy.disconnect()
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The default Bleak-backed implementations (`BleakScanSource`,
|
|
97
|
+
`BleakDeviceResolver`) live in `matter_ble_proxy.bleak_backend`.
|
|
98
|
+
|
|
99
|
+
### Reconnection
|
|
100
|
+
|
|
101
|
+
`MatterBleProxy` does not reconnect on its own. When the WebSocket closes — server
|
|
102
|
+
restart, network blip, or the caller calling `disconnect()` — `run_until_closed()`
|
|
103
|
+
returns after the library releases all BLE resources (active scan stopped, every
|
|
104
|
+
peripheral disconnected). The caller decides whether to reconnect:
|
|
105
|
+
|
|
106
|
+
- The bundled CLI exits on disconnect; restart it manually.
|
|
107
|
+
- Home Assistant ties the BLE proxy lifecycle to the matter-server WebSocket: when
|
|
108
|
+
HA reconnects to the matter-server it constructs and connects a fresh
|
|
109
|
+
`MatterBleProxy` for the new session.
|
|
110
|
+
- A custom integration can wrap `connect()` + `run_until_closed()` in a retry loop
|
|
111
|
+
with whatever backoff and cancellation policy fits its supervisor.
|
|
112
|
+
|
|
113
|
+
The library deliberately stays out of this decision so it can plug into hosts
|
|
114
|
+
that already own reconnect logic (HA, systemd, etc.) without fighting them.
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
This package lives inside the
|
|
119
|
+
[matter-js/matterjs-server](https://github.com/matter-js/matterjs-server) repo
|
|
120
|
+
and shares its release pipeline. From the repo root:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npm run python-ble-proxy:install # create venv + install editable + test deps
|
|
124
|
+
npm run python-ble-proxy:lint # ruff
|
|
125
|
+
npm run python-ble-proxy:typecheck # mypy
|
|
126
|
+
npm run python-ble-proxy:test # pytest
|
|
127
|
+
npm run python-ble-proxy:build # build sdist+wheel
|
|
128
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
matter_ble_proxy/__init__.py,sha256=2E57gBoqDuD1Uhitld7LgDWvFo1SfGlyROEH3ck3cS8,1431
|
|
2
|
+
matter_ble_proxy/bleak_backend.py,sha256=z_bpC_3jLbp4hT_DSjalp8Y9YNn-sIH6zhH-iJrA7UI,4181
|
|
3
|
+
matter_ble_proxy/cli.py,sha256=dHu1jOc4Gqx58evgcWesHSm3LaqowO5aD0h4KqkdqtU,3107
|
|
4
|
+
matter_ble_proxy/client.py,sha256=ttetZpoIi7S_0prK7WzR4Rxq1vZH-yp2AjZhND6SH8U,30329
|
|
5
|
+
matter_ble_proxy/protocol.py,sha256=IkLvxlFEajyyA8ikXSBOvdgm7nehqdUd4EcHjd5mBGo,2336
|
|
6
|
+
matter_ble_proxy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
matter_ble_proxy-0.7.1.dist-info/METADATA,sha256=eJB4MGd4i76PH69Jt6QXAtyfNFkKIY5x0uVZflLm6ko,4746
|
|
8
|
+
matter_ble_proxy-0.7.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
matter_ble_proxy-0.7.1.dist-info/entry_points.txt,sha256=ww4umBV6Z-BRZl4ihfeNDqWJx0-INswlpXCvOD0Xe9A,63
|
|
10
|
+
matter_ble_proxy-0.7.1.dist-info/top_level.txt,sha256=0DzlE97B4CTNRPxxnwDd8EHY_leewOO0sw229-ce32k,17
|
|
11
|
+
matter_ble_proxy-0.7.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
matter_ble_proxy
|