matter-ble-proxy 0.7.1__tar.gz

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,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,97 @@
1
+ # matter-ble-proxy
2
+
3
+ Python client library for the [OHF Matter Server](https://github.com/matter-js/matterjs-server)
4
+ BLE proxy WebSocket protocol.
5
+
6
+ The matter-server can run on a host with no BLE adapter and delegate every BLE
7
+ operation to a separate process or device. This library implements the client
8
+ side of that protocol so that any Python process with access to a BLE adapter
9
+ (via [Bleak](https://github.com/hbldh/bleak), Home Assistant's bluetooth
10
+ component, an ESPHome BLE proxy, ...) can act as the BLE bridge.
11
+
12
+ The protocol is documented in [`docs/ble-proxy-protocol.md`](https://github.com/matter-js/matterjs-server/blob/main/docs/ble-proxy-protocol.md).
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install matter-ble-proxy
18
+ ```
19
+
20
+ Python 3.12+ required.
21
+
22
+ ## Standalone CLI
23
+
24
+ The package ships a reference CLI mirroring the JS `noble-ble-proxy` example.
25
+ Useful for testing the matter-server's `/ble` endpoint without Home Assistant
26
+ in the loop.
27
+
28
+ ```bash
29
+ # Start the matter-server with --ble-proxy in one terminal, then:
30
+ matter-ble-proxy --server ws://localhost:5580/ble
31
+ ```
32
+
33
+ The CLI uses Bleak directly against the local OS bluetooth adapter.
34
+
35
+ ## Library API
36
+
37
+ For integrators (Home Assistant, custom add-ons, etc.) wire your own BLE source
38
+ in by implementing two ABCs:
39
+
40
+ ```python
41
+ from matter_ble_proxy import (
42
+ AdvertisementData,
43
+ BleDeviceResolver,
44
+ BleScanSource,
45
+ MatterBleProxy,
46
+ )
47
+
48
+ class MyScanSource(BleScanSource):
49
+ async def start(self, callback): ... # call `callback(AdvertisementData(...))`
50
+ async def stop(self): ...
51
+
52
+ class MyDeviceResolver(BleDeviceResolver):
53
+ async def resolve(self, address): ... # return a bleak.BLEDevice / address / None
54
+
55
+ proxy = MatterBleProxy(
56
+ ws_url="ws://localhost:5580/ble",
57
+ scan_source=MyScanSource(),
58
+ device_resolver=MyDeviceResolver(),
59
+ )
60
+ await proxy.connect()
61
+ await proxy.run_until_closed()
62
+ await proxy.disconnect()
63
+ ```
64
+
65
+ The default Bleak-backed implementations (`BleakScanSource`,
66
+ `BleakDeviceResolver`) live in `matter_ble_proxy.bleak_backend`.
67
+
68
+ ### Reconnection
69
+
70
+ `MatterBleProxy` does not reconnect on its own. When the WebSocket closes — server
71
+ restart, network blip, or the caller calling `disconnect()` — `run_until_closed()`
72
+ returns after the library releases all BLE resources (active scan stopped, every
73
+ peripheral disconnected). The caller decides whether to reconnect:
74
+
75
+ - The bundled CLI exits on disconnect; restart it manually.
76
+ - Home Assistant ties the BLE proxy lifecycle to the matter-server WebSocket: when
77
+ HA reconnects to the matter-server it constructs and connects a fresh
78
+ `MatterBleProxy` for the new session.
79
+ - A custom integration can wrap `connect()` + `run_until_closed()` in a retry loop
80
+ with whatever backoff and cancellation policy fits its supervisor.
81
+
82
+ The library deliberately stays out of this decision so it can plug into hosts
83
+ that already own reconnect logic (HA, systemd, etc.) without fighting them.
84
+
85
+ ## Development
86
+
87
+ This package lives inside the
88
+ [matter-js/matterjs-server](https://github.com/matter-js/matterjs-server) repo
89
+ and shares its release pipeline. From the repo root:
90
+
91
+ ```bash
92
+ npm run python-ble-proxy:install # create venv + install editable + test deps
93
+ npm run python-ble-proxy:lint # ruff
94
+ npm run python-ble-proxy:typecheck # mypy
95
+ npm run python-ble-proxy:test # pytest
96
+ npm run python-ble-proxy:build # build sdist+wheel
97
+ ```
@@ -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())