diff --git a/CHANGELOG.md b/CHANGELOG.md index 10892b32..54e6f5b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ This page shows a detailed overview of the changes between versions without the - Removed invalid FabricIndex field requirements for some command types and models - Standardized log levels - Fix: Event reports are now decoded in wire (EventNumber) order +- Enhancement: `matter_ble_proxy` Python library: add `AdvertisementData.from_bleak(address, connectable, bleak_advertisement)` factory so integrators with a `bleak.backends.scanner.AdvertisementData` can skip the field-by-field translation step ## 0.7.1 (2026-05-21) diff --git a/python_ble_proxy/README.md b/python_ble_proxy/README.md index b3f6ce1e..1d96bd3e 100644 --- a/python_ble_proxy/README.md +++ b/python_ble_proxy/README.md @@ -51,7 +51,26 @@ class MyScanSource(BleScanSource): class MyDeviceResolver(BleDeviceResolver): async def resolve(self, address): ... # return a bleak.BLEDevice / address / None +``` + +If your backend already has a `bleak.backends.scanner.AdvertisementData` +(e.g. via `BluetoothServiceInfoBleak.advertisement` in Home Assistant), +build the library's dataclass with the `from_bleak` factory instead of +copying fields manually: + +```python +callback( + AdvertisementData.from_bleak( + service_info.address, + service_info.connectable, + service_info.advertisement, + ) +) +``` +Wire it up: + +```python proxy = MatterBleProxy( ws_url="ws://localhost:5580/ble", scan_source=MyScanSource(), diff --git a/python_ble_proxy/matter_ble_proxy/bleak_backend.py b/python_ble_proxy/matter_ble_proxy/bleak_backend.py index a4bec086..b25ba346 100644 --- a/python_ble_proxy/matter_ble_proxy/bleak_backend.py +++ b/python_ble_proxy/matter_ble_proxy/bleak_backend.py @@ -72,17 +72,16 @@ def _on_detection(self, device: BLEDevice, advertisement: BleakAdvertisementData cb = self._callback if cb is None: return - ad = AdvertisementData( - address=device.address, - name=advertisement.local_name or device.name, - rssi=advertisement.rssi, - connectable=True, # Bleak does not expose this directly; assume true. - service_data=dict(advertisement.service_data), - manufacturer_data=dict(advertisement.manufacturer_data), - service_uuids=list(advertisement.service_uuids), - ) + # Bleak's per-scan advertisement carries the local_name only when it is + # actually in the current packet; fall back to the BLEDevice's cached + # name so peripherals that alternate name-bearing and name-less ads + # still surface a name to the matter-server. + local_name = advertisement.local_name or device.name + if local_name and local_name != advertisement.local_name: + advertisement = advertisement._replace(local_name=local_name) try: - cb(ad) + # Bleak does not expose `connectable` at scan time; assume True. + cb(AdvertisementData.from_bleak(device.address, True, advertisement)) except Exception: _LOGGER.exception("BLE proxy advertisement callback raised") diff --git a/python_ble_proxy/matter_ble_proxy/protocol.py b/python_ble_proxy/matter_ble_proxy/protocol.py index ac018514..8d74ed74 100644 --- a/python_ble_proxy/matter_ble_proxy/protocol.py +++ b/python_ble_proxy/matter_ble_proxy/protocol.py @@ -8,6 +8,10 @@ from dataclasses import dataclass, field import struct +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bleak.backends.scanner import AdvertisementData as BleakAdvertisementData # Current protocol version. Must match the matter-server's # `BLE_PROXY_PROTOCOL_VERSION` constant; the server rejects clients that send @@ -61,3 +65,34 @@ class AdvertisementData: service_uuids: list[str] = field(default_factory=list) """List of advertised service UUIDs.""" + + @classmethod + def from_bleak( + cls, + address: str, + connectable: bool, + advertisement: BleakAdvertisementData, + ) -> AdvertisementData: + """Build from a `bleak.backends.scanner.AdvertisementData` plus the two fields it lacks. + + :class:`bleak.backends.scanner.AdvertisementData` intentionally carries + neither the peripheral address (it lives on the paired ``BLEDevice``) + nor the connectable bit (it lives on the scan context), so both are + passed explicitly. ``tx_power`` and ``platform_data`` are not part of + the wire schema and are dropped. + + Use this helper from any Bleak-backed :class:`BleScanSource` to avoid a + field-by-field translation step in the integrator. Home Assistant's + ``BluetoothServiceInfoBleak.advertisement`` already materializes a + :class:`bleak.backends.scanner.AdvertisementData`, so the HA backend can + forward it directly. + """ + return cls( + address=address, + name=advertisement.local_name, + rssi=advertisement.rssi, + connectable=connectable, + service_data=dict(advertisement.service_data), + manufacturer_data=dict(advertisement.manufacturer_data), + service_uuids=list(advertisement.service_uuids), + ) diff --git a/python_ble_proxy/tests/test_protocol.py b/python_ble_proxy/tests/test_protocol.py index e78eef8d..bae19048 100644 --- a/python_ble_proxy/tests/test_protocol.py +++ b/python_ble_proxy/tests/test_protocol.py @@ -60,3 +60,42 @@ def test_advertisement_data_defaults(): assert ad.service_data == {} assert ad.manufacturer_data == {} assert ad.service_uuids == [] + + +def test_advertisement_data_from_bleak(): + from bleak.backends.scanner import AdvertisementData as BleakAdvertisementData + + bleak_ad = BleakAdvertisementData( + local_name="dev", + manufacturer_data={0x004C: b"\x01\x02"}, + service_data={"0000fff6-0000-1000-8000-00805f9b34fb": b"\xaa"}, + service_uuids=["0000fff6-0000-1000-8000-00805f9b34fb"], + tx_power=-12, + rssi=-50, + platform_data=(), + ) + ad = AdvertisementData.from_bleak("aa:bb:cc:dd:ee:ff", True, bleak_ad) + assert ad.address == "aa:bb:cc:dd:ee:ff" + assert ad.connectable is True + assert ad.name == "dev" + assert ad.rssi == -50 + assert ad.service_data == {"0000fff6-0000-1000-8000-00805f9b34fb": b"\xaa"} + assert ad.manufacturer_data == {0x004C: b"\x01\x02"} + assert ad.service_uuids == ["0000fff6-0000-1000-8000-00805f9b34fb"] + + +def test_advertisement_data_from_bleak_uses_local_name(): + from bleak.backends.scanner import AdvertisementData as BleakAdvertisementData + + bleak_ad = BleakAdvertisementData( + local_name=None, + manufacturer_data={}, + service_data={}, + service_uuids=[], + tx_power=None, + rssi=-72, + platform_data=(), + ) + ad = AdvertisementData.from_bleak("aa:bb:cc:dd:ee:ff", False, bleak_ad) + assert ad.name is None + assert ad.connectable is False