From acf2fc0266be3d7700d1ff89d274bfd8c9748893 Mon Sep 17 00:00:00 2001 From: "D. Petrov" Date: Thu, 11 Jun 2026 13:26:15 -0400 Subject: [PATCH 1/5] add --hci-id argument to matter_ble_proxy --- .../matter_ble_proxy/bleak_backend.py | 26 +++++++++---------- python_ble_proxy/matter_ble_proxy/cli.py | 9 +++++-- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/python_ble_proxy/matter_ble_proxy/bleak_backend.py b/python_ble_proxy/matter_ble_proxy/bleak_backend.py index a4bec086..d5d43cdf 100644 --- a/python_ble_proxy/matter_ble_proxy/bleak_backend.py +++ b/python_ble_proxy/matter_ble_proxy/bleak_backend.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: from collections.abc import Callable + from bleak.args.bluez import BlueZScannerArgs from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData as BleakAdvertisementData @@ -25,22 +26,11 @@ class BleakScanSource(BleScanSource): - """Scan source backed by a directly-managed `BleakScanner`. - - The scanner is created on each :meth:`start` and torn down on :meth:`stop`, - so the BLE adapter sits idle whenever the matter-server is not actively - scanning. The first `start_scan` after a process boot pays the native - cold-start cost (CoreBluetooth state transition to `powered_on`, DBus - handshake) — typically tens to hundreds of milliseconds. - """ - - def __init__(self) -> None: + def __init__(self, hci_device: int | None = None) -> None: """Initialize.""" + self._hci_device = hci_device self._scanner: BleakScanner | None = None self._callback: Callable[[AdvertisementData], None] | None = None - # Cache the most recent BLEDevice per address so BleakDeviceResolver - # can hand a fully-formed device to BleakClient (more reliable than - # connecting by address alone on some platforms). self._device_cache: dict[str, BLEDevice] = {} @property @@ -53,7 +43,15 @@ async def start(self, callback: Callable[[AdvertisementData], None]) -> None: self._callback = callback if self._scanner is not None: return - self._scanner = BleakScanner(detection_callback=self._on_detection) + + bluez_args: BlueZScannerArgs = {} + if self._hci_device: + bluez_args["adapter"] = f"hci{self._hci_device}" + + self._scanner = BleakScanner( + detection_callback=self._on_detection, + bluez=bluez_args, + ) await self._scanner.start() async def stop(self) -> None: diff --git a/python_ble_proxy/matter_ble_proxy/cli.py b/python_ble_proxy/matter_ble_proxy/cli.py index 9b216bba..063f2c23 100644 --- a/python_ble_proxy/matter_ble_proxy/cli.py +++ b/python_ble_proxy/matter_ble_proxy/cli.py @@ -37,6 +37,11 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace: default="ws://localhost:5580/ble", help="matter-server BLE proxy URL (default: %(default)s)", ) + parser.add_argument( + "--hci-id", + default=None, + help="Bluetooth adapter HCI ID (e.g., 0 for hci0)", + ) parser.add_argument( "--log-level", default="INFO", @@ -46,7 +51,7 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace: return parser.parse_args(argv) -async def _run(server_url: str) -> int: +async def _run(server_url: str, hci_id: int) -> int: scan_source = BleakScanSource() device_resolver = BleakDeviceResolver(scan_source) proxy = MatterBleProxy(server_url, scan_source, device_resolver) @@ -96,7 +101,7 @@ def main(argv: list[str] | None = None) -> int: datefmt="%H:%M:%S", ) try: - return asyncio.run(_run(args.server)) + return asyncio.run(_run(args.server, args.hci_id)) except KeyboardInterrupt: return 0 From 793dbc34297760eb6e4f6235cda5d67e7dda0496 Mon Sep 17 00:00:00 2001 From: "D. Petrov" Date: Thu, 11 Jun 2026 15:04:17 -0400 Subject: [PATCH 2/5] replace removed docstring/comment --- python_ble_proxy/matter_ble_proxy/bleak_backend.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/python_ble_proxy/matter_ble_proxy/bleak_backend.py b/python_ble_proxy/matter_ble_proxy/bleak_backend.py index d5d43cdf..a98b1a1d 100644 --- a/python_ble_proxy/matter_ble_proxy/bleak_backend.py +++ b/python_ble_proxy/matter_ble_proxy/bleak_backend.py @@ -26,11 +26,23 @@ class BleakScanSource(BleScanSource): + """Scan source backed by a directly-managed `BleakScanner`. + + The scanner is created on each :meth:`start` and torn down on :meth:`stop`, + so the BLE adapter sits idle whenever the matter-server is not actively + scanning. The first `start_scan` after a process boot pays the native + cold-start cost (CoreBluetooth state transition to `powered_on`, DBus + handshake) — typically tens to hundreds of milliseconds. + """ + def __init__(self, hci_device: int | None = None) -> None: """Initialize.""" self._hci_device = hci_device self._scanner: BleakScanner | None = None self._callback: Callable[[AdvertisementData], None] | None = None + # Cache the most recent BLEDevice per address so BleakDeviceResolver + # can hand a fully-formed device to BleakClient (more reliable than + # connecting by address alone on some platforms). self._device_cache: dict[str, BLEDevice] = {} @property From 00537d9be3d4c7e80cc46743bdd3ecfe6fefe2fa Mon Sep 17 00:00:00 2001 From: "D. Petrov" Date: Thu, 11 Jun 2026 16:15:12 -0400 Subject: [PATCH 3/5] Resolve type issues --- python_ble_proxy/matter_ble_proxy/bleak_backend.py | 2 +- python_ble_proxy/matter_ble_proxy/cli.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/python_ble_proxy/matter_ble_proxy/bleak_backend.py b/python_ble_proxy/matter_ble_proxy/bleak_backend.py index a98b1a1d..c2fc698c 100644 --- a/python_ble_proxy/matter_ble_proxy/bleak_backend.py +++ b/python_ble_proxy/matter_ble_proxy/bleak_backend.py @@ -57,7 +57,7 @@ async def start(self, callback: Callable[[AdvertisementData], None]) -> None: return bluez_args: BlueZScannerArgs = {} - if self._hci_device: + if self._hci_device is not None: bluez_args["adapter"] = f"hci{self._hci_device}" self._scanner = BleakScanner( diff --git a/python_ble_proxy/matter_ble_proxy/cli.py b/python_ble_proxy/matter_ble_proxy/cli.py index 063f2c23..9300ab20 100644 --- a/python_ble_proxy/matter_ble_proxy/cli.py +++ b/python_ble_proxy/matter_ble_proxy/cli.py @@ -40,6 +40,7 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace: parser.add_argument( "--hci-id", default=None, + type=int, help="Bluetooth adapter HCI ID (e.g., 0 for hci0)", ) parser.add_argument( @@ -51,8 +52,8 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace: return parser.parse_args(argv) -async def _run(server_url: str, hci_id: int) -> int: - scan_source = BleakScanSource() +async def _run(server_url: str, hci_id: int | None) -> int: + scan_source = BleakScanSource(hci_id) device_resolver = BleakDeviceResolver(scan_source) proxy = MatterBleProxy(server_url, scan_source, device_resolver) From b5dea1f566795abb793695e7d716fb09e50fb953 Mon Sep 17 00:00:00 2001 From: "D. Petrov" Date: Mon, 22 Jun 2026 11:13:40 -0400 Subject: [PATCH 4/5] improve clarity of OS limitations for --hci-device in python BLE proxy Note the OS/BLE backend limitation in the --hci-id argument description, and only pass bluez_args to BleakScanner if a hci id was provided --- .../matter_ble_proxy/bleak_backend.py | 15 +++++++++------ python_ble_proxy/matter_ble_proxy/cli.py | 4 ++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/python_ble_proxy/matter_ble_proxy/bleak_backend.py b/python_ble_proxy/matter_ble_proxy/bleak_backend.py index c2fc698c..e568bac4 100644 --- a/python_ble_proxy/matter_ble_proxy/bleak_backend.py +++ b/python_ble_proxy/matter_ble_proxy/bleak_backend.py @@ -56,14 +56,17 @@ async def start(self, callback: Callable[[AdvertisementData], None]) -> None: if self._scanner is not None: return - bluez_args: BlueZScannerArgs = {} + # Bleak only supports specifying a hci device on Linux with BlueZ; + # per https://github.com/hbldh/bleak/discussions/867 if self._hci_device is not None: - bluez_args["adapter"] = f"hci{self._hci_device}" + bluez_args: BlueZScannerArgs = {"adapter": f"hci{self._hci_device}"} + self._scanner = BleakScanner( + detection_callback=self._on_detection, + bluez=bluez_args, + ) + else: + self._scanner = BleakScanner(detection_callback=self._on_detection) - self._scanner = BleakScanner( - detection_callback=self._on_detection, - bluez=bluez_args, - ) await self._scanner.start() async def stop(self) -> None: diff --git a/python_ble_proxy/matter_ble_proxy/cli.py b/python_ble_proxy/matter_ble_proxy/cli.py index 9300ab20..94cdf0a1 100644 --- a/python_ble_proxy/matter_ble_proxy/cli.py +++ b/python_ble_proxy/matter_ble_proxy/cli.py @@ -41,7 +41,7 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace: "--hci-id", default=None, type=int, - help="Bluetooth adapter HCI ID (e.g., 0 for hci0)", + help="Bluetooth adapter HCI ID (e.g., 0 for hci0). Use only on Linux with BlueZ.", ) parser.add_argument( "--log-level", @@ -53,7 +53,7 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace: async def _run(server_url: str, hci_id: int | None) -> int: - scan_source = BleakScanSource(hci_id) + scan_source = BleakScanSource(hci_device=hci_id) device_resolver = BleakDeviceResolver(scan_source) proxy = MatterBleProxy(server_url, scan_source, device_resolver) From 156838f560148c18e1f248992bf4503c3224598e Mon Sep 17 00:00:00 2001 From: "D. Petrov" Date: Thu, 25 Jun 2026 09:59:35 -0400 Subject: [PATCH 5/5] Add version check for BLE adapter argument in bleak Pass hci id into the 'adapter' argument of BleakScanner for bleak versions < 3.0.0, and pass it into the 'adapter' key of BlueZScannerArgs for bleak versions >= 3.0.0. Move the creation of the BleakScanner to its own method. Use cast() to get past type-checker errors when type-checking on bleak < 3.0.0, where the 'adapter' key does not exist on BlueZScannerArgs. --- .../matter_ble_proxy/bleak_backend.py | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/python_ble_proxy/matter_ble_proxy/bleak_backend.py b/python_ble_proxy/matter_ble_proxy/bleak_backend.py index e568bac4..422e3d66 100644 --- a/python_ble_proxy/matter_ble_proxy/bleak_backend.py +++ b/python_ble_proxy/matter_ble_proxy/bleak_backend.py @@ -7,8 +7,9 @@ from __future__ import annotations +from importlib.metadata import version import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from bleak import BleakScanner @@ -56,16 +57,7 @@ async def start(self, callback: Callable[[AdvertisementData], None]) -> None: if self._scanner is not None: return - # Bleak only supports specifying a hci device on Linux with BlueZ; - # per https://github.com/hbldh/bleak/discussions/867 - if self._hci_device is not None: - bluez_args: BlueZScannerArgs = {"adapter": f"hci{self._hci_device}"} - self._scanner = BleakScanner( - detection_callback=self._on_detection, - bluez=bluez_args, - ) - else: - self._scanner = BleakScanner(detection_callback=self._on_detection) + self._scanner = self._get_bleak_scanner() await self._scanner.start() @@ -80,7 +72,37 @@ async def stop(self) -> None: except Exception: _LOGGER.debug("Error stopping BleakScanner", exc_info=True) - def _on_detection(self, device: BLEDevice, advertisement: BleakAdvertisementData) -> None: + def _get_bleak_scanner(self) -> BleakScanner: + # Bleak only supports specifying a hci device on Linux with BlueZ; + # per https://github.com/hbldh/bleak/discussions/867 + + if self._hci_device is None: + return BleakScanner(detection_callback=self._on_detection) + + bleak_major_version = int(version("bleak").split(".")[0]) + min_bleak_version_with_bluez_adapter = 3 + adapter = f"hci{self._hci_device}" + + # the "adapter" key of BlueZScannerArgs only exists on bleak >= 3.0.0; + # on older bleak, pass adapter= to BleakScanner directly + # (https://bleak.readthedocs.io/en/latest/history.html) + if bleak_major_version >= min_bleak_version_with_bluez_adapter: + bluez_args = cast( + "BlueZScannerArgs", {"adapter": adapter} + ) + return BleakScanner( + detection_callback=self._on_detection, + bluez=bluez_args, + ) + + return BleakScanner( + detection_callback=self._on_detection, + adapter=adapter, + ) + + def _on_detection( + self, device: BLEDevice, advertisement: BleakAdvertisementData + ) -> None: self._device_cache[device.address] = device cb = self._callback if cb is None: