Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
19 changes: 19 additions & 0 deletions python_ble_proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
19 changes: 9 additions & 10 deletions python_ble_proxy/matter_ble_proxy/bleak_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
35 changes: 35 additions & 0 deletions python_ble_proxy/matter_ble_proxy/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
)
39 changes: 39 additions & 0 deletions python_ble_proxy/tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading