Files
Button/winDeployScripts/software/collector/bluez_adv.py
T
2026-10-16 07:19:06 +03:00

193 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""BlueZ LE advertisement registration (custom AD payload via D-Bus)."""
from __future__ import annotations
import logging
from typing import Optional
from config import COMPANY_ID, LOCAL_NAME, SERVICE_UUID_16
log = logging.getLogger("collector.bluez_adv")
dbus = None
try:
import dbus as _dbus
import dbus.mainloop.glib
import dbus.service
from gi.repository import GLib # type: ignore
dbus = _dbus
except Exception: # pragma: no cover
GLib = None
dbus = None
BLUEZ = "org.bluez"
ADAPTER_IFACE = "org.bluez.Adapter1"
LE_ADVERTISING_MANAGER = "org.bluez.LEAdvertisingManager1"
LE_ADVERTISEMENT = "org.bluez.LEAdvertisement1"
PROPS = "org.freedesktop.DBus.Properties"
OBJECT_MANAGER = "org.freedesktop.DBus.ObjectManager"
def _ensure_mainloop():
if dbus is None:
raise RuntimeError("python3-dbus / PyGObject required")
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
def find_adapter(bus) -> Optional[str]:
obj = bus.get_object(BLUEZ, "/")
mgr = dbus.Interface(obj, OBJECT_MANAGER)
for path, ifaces in mgr.GetManagedObjects().items():
if LE_ADVERTISING_MANAGER in ifaces:
return str(path)
return None
def _make_advertisement_cls():
class Advertisement(dbus.service.Object):
def __init__(self, bus, index: int, mfr_payload: bytes):
self.path = f"/org/buttontask/collector/advertisement{index}"
self.mfr_payload = mfr_payload
dbus.service.Object.__init__(self, bus, self.path)
@dbus.service.method(LE_ADVERTISEMENT, in_signature="", out_signature="")
def Release(self):
log.debug("advertisement released %s", self.path)
def get_properties(self):
mfr = {
dbus.UInt16(COMPANY_ID): dbus.Array(self.mfr_payload, signature="y")
}
return {
LE_ADVERTISEMENT: {
"Type": "peripheral",
"ServiceUUIDs": dbus.Array(
[f"{SERVICE_UUID_16:04x}"], signature="s"
),
"ManufacturerData": dbus.Dictionary(mfr, signature="qv"),
"LocalName": LOCAL_NAME,
"IncludeTxPower": dbus.Boolean(False),
}
}
@dbus.service.method(PROPS, in_signature="s", out_signature="a{sv}")
def GetAll(self, interface):
if interface != LE_ADVERTISEMENT:
raise dbus.exceptions.DBusException(
"org.freedesktop.DBus.Error.InvalidArgs"
)
return self.get_properties()[LE_ADVERTISEMENT]
return Advertisement
class BlueZAdvertiser:
def __init__(self):
_ensure_mainloop()
self.bus = dbus.SystemBus()
self.adapter_path = find_adapter(self.bus)
if not self.adapter_path:
raise RuntimeError("no BlueZ LEAdvertisingManager1 adapter")
self._adv = None
self._index = 0
self._AdvCls = _make_advertisement_cls()
adapter = self.bus.get_object(BLUEZ, self.adapter_path)
self._manager = dbus.Interface(adapter, LE_ADVERTISING_MANAGER)
try:
props = dbus.Interface(adapter, PROPS)
props.Set(ADAPTER_IFACE, "Powered", dbus.Boolean(True))
except Exception as e:
log.warning("adapter power: %s", e)
def unregister(self) -> None:
if self._adv is None:
return
try:
self._manager.UnregisterAdvertisement(self._adv.path)
except Exception as e:
log.debug("unregister adv: %s", e)
try:
self._adv.remove_from_connection()
except Exception:
pass
self._adv = None
def register(self, ad_bytes: bytes) -> None:
"""Register advertisement. `ad_bytes` may be full AD or raw mfr payload."""
mfr = _extract_mfr_payload(ad_bytes)
self.unregister()
self._index += 1
self._adv = self._AdvCls(self.bus, self._index, mfr)
self._manager.RegisterAdvertisement(
self._adv.path, dbus.Dictionary({}, signature="sv")
)
log.info("advertisement registered (mfr %d bytes)", len(mfr))
def _extract_mfr_payload(ad_bytes: bytes) -> bytes:
if len(ad_bytes) >= 4 and ad_bytes[1] == 0x03:
rest = ad_bytes[4:]
if len(rest) >= 4 and rest[1] == 0xFF:
return bytes(rest[4:])
return bytes(rest)
return bytes(ad_bytes)
def read_device_rssi(bus, device_path: str) -> Optional[int]:
if dbus is None:
return None
try:
obj = bus.get_object(BLUEZ, device_path)
props = dbus.Interface(obj, PROPS)
return int(props.Get("org.bluez.Device1", "RSSI"))
except Exception:
return None
def find_device_path(bus, addr: str) -> Optional[str]:
if dbus is None:
return None
addr_u = (addr or "").upper()
try:
obj = bus.get_object(BLUEZ, "/")
mgr = dbus.Interface(obj, OBJECT_MANAGER)
for path, ifaces in mgr.GetManagedObjects().items():
dev = ifaces.get("org.bluez.Device1")
if not dev:
continue
if str(dev.get("Address", "")).upper() == addr_u:
return str(path)
except Exception as e:
log.debug("find_device: %s", e)
return None
def disconnect_device(bus, addr: str) -> None:
path = find_device_path(bus, addr)
if not path:
return
try:
obj = bus.get_object(BLUEZ, path)
dbus.Interface(obj, "org.bluez.Device1").Disconnect()
except Exception as e:
log.warning("disconnect %s: %s", addr, e)
def list_connected_addresses(bus) -> list:
if dbus is None:
return []
out = []
try:
obj = bus.get_object(BLUEZ, "/")
mgr = dbus.Interface(obj, OBJECT_MANAGER)
for _path, ifaces in mgr.GetManagedObjects().items():
dev = ifaces.get("org.bluez.Device1")
if not dev:
continue
if bool(dev.get("Connected", False)):
out.append(str(dev.get("Address", "")).upper())
except Exception as e:
log.debug("list_connected: %s", e)
return out