#!/usr/bin/env python3 """Advertising mirror for bless 0.3.x (BlueZ): stop → register with ManufacturerData.""" from __future__ import annotations import logging import time from typing import Any, List, Optional from config import ( ADV_DEBOUNCE_S, ADV_VERSION, COMPANY_ID, LOCAL_NAME, N, SERVICE_UUID_16, ) log = logging.getLogger("collector.adv") def build_manufacturer_data(bitmap: int, reports: List[bytes]) -> bytes: """ver=1 + bitmap + N×4 B reports.""" if N == 1 and reports: return bytes((ADV_VERSION,)) + reports[0][:4] body = bytearray() body.append(ADV_VERSION) body.append(bitmap & 0xFF) for i in range(N): r = reports[i] if i < len(reports) else bytes((0xFF, 0, 0xFF, 0xFF)) body.extend(r[:4]) return bytes(body) def build_ad_payload(mfr: bytes) -> bytes: """Legacy AD bytes (debug).""" uuid_ad = bytes((0x03, 0x03, SERVICE_UUID_16 & 0xFF, (SERVICE_UUID_16 >> 8) & 0xFF)) mfr_body = COMPANY_ID.to_bytes(2, "little") + mfr mfr_ad = bytes((1 + len(mfr_body), 0xFF)) + mfr_body return uuid_ad + mfr_ad class BlessAdvertiser: """Debounced advertising updates through a running BlessServer (0.3.x).""" def __init__(self, server: Any, debounce_s: float = ADV_DEBOUNCE_S): self._server = server self._debounce_s = debounce_s self._last_apply = 0.0 self._pending: Optional[bytes] = None self._current: Optional[bytes] = None self._force = False def set_mirror(self, mfr_payload: bytes, force: bool = False) -> None: self._pending = mfr_payload if force: self._force = True def force_reregister(self) -> None: self._force = True if self._pending is None: self._pending = self._current async def tick(self) -> bool: if self._pending is None and not self._force: return False now = time.monotonic() if not self._force and (now - self._last_apply) < self._debounce_s: return False payload = self._pending if self._pending is not None else self._current if payload is None: self._force = False return False if not self._force and payload == self._current: self._pending = None return False ok = await self._apply(payload) if ok: self._current = payload self._pending = None self._force = False self._last_apply = now else: self._force = False return ok async def _apply(self, mfr_payload: bytes) -> bool: server = self._server try: setup = getattr(server, "setup_task", None) if setup is not None: await setup except Exception as e: log.debug("setup_task: %s", e) app = getattr(server, "app", None) adapter = getattr(server, "adapter", None) if app is None or adapter is None: log.error("bless server has no app/adapter — cannot update adv") return False try: from dbus_next.signature import Variant # type: ignore from bless.backends.bluezdbus.dbus.advertisement import ( # type: ignore BlueZLEAdvertisement, Type, ) except Exception as e: log.error("bless bluez advertisement import failed: %s", e) return False # Drop previous advertisement(s) owned by this app. try: while getattr(app, "advertisements", None): await app.stop_advertising(adapter) except Exception as e: log.debug("stop_advertising: %s", e) try: await app.set_name(adapter, LOCAL_NAME) advertisement = BlueZLEAdvertisement( Type.PERIPHERAL, len(app.advertisements) + 1, app ) app.advertisements.append(advertisement) if app.services: advertisement._service_uuids.append(app.services[0].UUID) else: advertisement._service_uuids.append(f"{SERVICE_UUID_16:04x}") advertisement._local_name = LOCAL_NAME advertisement._manufacturer_data = { COMPANY_ID: Variant("ay", bytes(mfr_payload)) } # Avoid experimental interval props on BlueZ 5.55 if possible — # leave bless defaults; they are ignored without -E. app.bus.export(advertisement.path, advertisement) iface = adapter.get_interface("org.bluez.LEAdvertisingManager1") await iface.call_register_advertisement(advertisement.path, {}) log.info( "advertisement registered (mfr %d bytes, path=%s)", len(mfr_payload), advertisement.path, ) return True except Exception as e: log.error("adv register failed: %s", e) return False # Back-compat Advertiser = BlessAdvertiser