106 lines
3.4 KiB
Python
106 lines
3.4 KiB
Python
#!/usr/bin/env python3
|
||
"""Advertising mirror: unregister → register with debounce."""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import struct
|
||
import time
|
||
from typing import Any, Callable, List, Optional
|
||
|
||
from config import (
|
||
ADV_DEBOUNCE_S,
|
||
ADV_VERSION,
|
||
COMPANY_ID,
|
||
N,
|
||
SERVICE_UUID_16,
|
||
)
|
||
|
||
log = logging.getLogger("collector.adv")
|
||
|
||
|
||
def build_manufacturer_data(bitmap: int, reports: List[bytes]) -> bytes:
|
||
"""ver=1 + for N=4: bitmap + 4×4 B reports (fits in 31-byte AD with UUID)."""
|
||
if N == 1 and reports:
|
||
# ver + single report (legacy compact form from spec sketch)
|
||
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:
|
||
"""Full AD structure excluding Flags (BlueZ/bless adds Flags itself).
|
||
|
||
03 03 00 A0 ; Complete list of 16-bit UUIDs
|
||
LL FF FF FF <mfr...> ; Manufacturer Specific Data
|
||
"""
|
||
uuid_ad = bytes((0x03, 0x03, SERVICE_UUID_16 & 0xFF, (SERVICE_UUID_16 >> 8) & 0xFF))
|
||
# company id little-endian + payload
|
||
mfr_body = struct.pack("<H", COMPANY_ID) + mfr
|
||
mfr_ad = bytes((1 + len(mfr_body), 0xFF)) + mfr_body
|
||
return uuid_ad + mfr_ad
|
||
|
||
|
||
class Advertiser:
|
||
"""Debounced advertising re-registration wrapper."""
|
||
|
||
def __init__(
|
||
self,
|
||
register_fn: Callable[[bytes], Any],
|
||
unregister_fn: Callable[[], Any],
|
||
debounce_s: float = ADV_DEBOUNCE_S,
|
||
):
|
||
self._register = register_fn
|
||
self._unregister = unregister_fn
|
||
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 tick(self) -> bool:
|
||
"""Apply pending update if debounce elapsed. Returns True if re-registered."""
|
||
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
|
||
try:
|
||
self._unregister()
|
||
except Exception as e:
|
||
log.warning("adv unregister: %s", e)
|
||
try:
|
||
ad = build_ad_payload(payload)
|
||
self._register(ad)
|
||
self._current = payload
|
||
self._pending = None
|
||
self._force = False
|
||
self._last_apply = now
|
||
log.debug("adv re-registered (%d bytes mfr)", len(payload))
|
||
return True
|
||
except Exception as e:
|
||
log.error("adv register failed: %s", e)
|
||
self._force = False
|
||
return False
|
||
|
||
def force_reregister(self) -> None:
|
||
self._force = True
|
||
if self._pending is None:
|
||
self._pending = self._current
|