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.
@@ -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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ matter-ble-proxy = matter_ble_proxy.cli:main
@@ -0,0 +1 @@
1
+ matter_ble_proxy