#!/usr/bin/env python3 """ ButtonTask BLE Collector — GATT peripheral + advertiser on BlueZ 5.55. Accepts connections from sensors, receives 4-byte reports, keeps addr→slot registry, mirrors latest values in advertising. Pool size N=4. No scanning. ANTI-SCOPE (do not implement / do not "improve"): - encryption / pairing / bonding - provisioning of collector address - RPA resolving - web/CLI UI inside this process - multiple collectors - BLE scanning """ from __future__ import annotations import asyncio import logging import signal import sys import threading import time from logging.handlers import RotatingFileHandler from pathlib import Path # Allow `python3 main.py` from collector/ directory. _HERE = Path(__file__).resolve().parent if str(_HERE) not in sys.path: sys.path.insert(0, str(_HERE)) from adv import BlessAdvertiser, build_manufacturer_data # noqa: E402 from bluez_adv import ( # noqa: E402 disconnect_device, find_device_path, list_connected_addresses, read_device_rssi, system_bus, ) from config import ( # noqa: E402 LOCAL_NAME, N, POLL_PERIOD_S, TICK_INTERVAL_S, collector_log_path, registry_path, run_dir, ) from gatt import CollectorGatt, build_bless_server # noqa: E402 from protocol import CFG_JOIN_OPEN, CFG_POOL_FULL, unpack_report # noqa: E402 from registry import Registry # noqa: E402 from ui_io import append_ui_log, read_cmd, write_status # noqa: E402 log = logging.getLogger("collector") def setup_logging() -> None: run_dir() root = logging.getLogger("collector") root.setLevel(logging.INFO) fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") sh = logging.StreamHandler(sys.stderr) sh.setFormatter(fmt) root.addHandler(sh) try: fh = RotatingFileHandler( collector_log_path(), maxBytes=512_000, backupCount=2, encoding="utf-8" ) fh.setFormatter(fmt) root.addHandler(fh) except OSError as e: root.warning("file log unavailable: %s", e) def preflight() -> None: try: import bless # noqa: F401 except Exception as e: log.error("bless import failed: %s", e) raise SystemExit(2) from e try: bus = system_bus() bus.get_object("org.bluez", "/") except Exception as e: log.error("D-Bus / bluetoothd unavailable: %s", e) raise SystemExit(2) from e log.info("preflight ok (bless + SystemBus + bluez)") class CollectorApp: def __init__(self): self.registry = Registry(n=N, path=registry_path()) self.gatt = CollectorGatt(self.registry) self.bus = None self.advertiser: BlessAdvertiser | None = None self.server = None self._known_connected: set[str] = set() # threading.Event — safe across bless/dbus_next loop quirks (no asyncio loop bind). self._stop = threading.Event() self.gatt.on_write = self._on_write self.gatt.on_read_slot = self._on_read_slot self.gatt.on_connect = self._on_connect self.gatt.on_disconnect = self._on_disconnect def _sync_status(self) -> None: write_status(self.registry.status_dict()) def _mirror_bytes(self) -> bytes: bitmap, reports = self.registry.mirror_payload_parts() return build_manufacturer_data(bitmap, reports) def _refresh_mirror(self, force: bool = False) -> None: if self.advertiser is None: return self.advertiser.set_mirror(self._mirror_bytes(), force=force) def _on_connect(self, addr: str) -> bool: addr = self.registry.normalize_addr(addr) sensor = self.registry.get(addr) if sensor is None: sensor = self.registry.assign(addr) if sensor is None: log.warning("pool-full, rejecting %s", addr) append_ui_log("warn", "pool-full", addr=addr) self.registry.save() self._sync_status() return False append_ui_log("info", "assigned", addr=addr, slot=sensor.slot) log.info("assigned slot=%s addr=%s", sensor.slot, addr) else: self.registry.touch(addr) rssi = None if self.bus is not None: path = find_device_path(self.bus, addr) if path: rssi = read_device_rssi(self.bus, path) if rssi is not None: sensor.rssi = rssi self.gatt.set_current_addr(addr) self.registry.save() self._sync_status() self._refresh_mirror() return True def _on_disconnect(self, addr: str) -> None: addr = self.registry.normalize_addr(addr) sensor = self.registry.get(addr) if sensor is not None: sensor.last_seen = time.time() if sensor.state != "stale": sensor.state = "live" if self.gatt.current_addr() == addr: self.gatt.set_current_addr(None) append_ui_log("info", "disconnect", addr=addr) self.registry.save() self._sync_status() self._refresh_mirror(force=True) log.info("disconnect %s", addr) def _on_write(self, addr: str, data: bytes) -> None: addr = self.registry.normalize_addr(addr) or (self.gatt.current_addr() or "") if not addr: log.warning("write without address — discarded") return report = unpack_report(data) if report is None: log.warning("bad report from %s — discarded", addr) return sensor = self.registry.get(addr) if sensor is None: if not self._on_connect(addr): return sensor = self.registry.get(addr) rssi = None if self.bus is not None: path = find_device_path(self.bus, addr) if path: rssi = read_device_rssi(self.bus, path) self.registry.update_report( addr, report.battery, report.flags, report.dist, rssi=rssi ) append_ui_log( "info", "report", addr=addr, battery=report.battery, flags=report.flags, dist=report.dist, rssi=rssi, ) self.registry.save() self._sync_status() self._refresh_mirror() log.info( "report slot=%s addr=%s batt=%s dist=%s", sensor.slot if sensor else "?", addr, report.battery, report.dist, ) def _on_read_slot(self, addr: str) -> bytes: from protocol import pack_slot_config addr = self.registry.normalize_addr(addr) or (self.gatt.current_addr() or "") sensor = self.registry.get(addr) if addr else None slot = sensor.slot if sensor else 0xFF flags = CFG_JOIN_OPEN if not self.registry.pool_full() else CFG_POOL_FULL return pack_slot_config(slot, flags, POLL_PERIOD_S) def _poll_connections(self) -> None: if self.bus is None: return try: connected = set(list_connected_addresses(self.bus)) except Exception as e: log.debug("conn poll: %s", e) return for addr in connected - self._known_connected: ok = self._on_connect(addr) if not ok: disconnect_device(self.bus, addr) for addr in self._known_connected - connected: self._on_disconnect(addr) self._known_connected = connected def _handle_cmd(self) -> None: cmd = read_cmd() if not cmd: return op = str(cmd.get("op", "")).lower() if op == "advertise": append_ui_log("info", "manual-advertise") log.info("manual advertise requested") self._refresh_mirror(force=True) async def run(self) -> None: self.registry.load() self._sync_status() append_ui_log("info", "startup", sensors=len(self.registry.by_addr)) self.bus = system_bus() loop = asyncio.get_running_loop() self.server = await build_bless_server(self.gatt, loop, LOCAL_NAME) # bless 0.3.x: start() registers a default adv; we immediately replace # it with our ManufacturerData mirror via BlessAdvertiser. await self.server.start() log.info("GATT server started (%s)", LOCAL_NAME) self.advertiser = BlessAdvertiser(self.server) self._refresh_mirror(force=True) await self.advertiser.tick() while not self._stop.is_set(): try: self._poll_connections() if self.registry.reevaluate(): self.registry.save() self._refresh_mirror() self._handle_cmd() if self.advertiser: await self.advertiser.tick() self._sync_status() except Exception as e: log.error("tick error: %s", e) # Plain sleep — do not use asyncio.Event (conflicts with bless/dbus_next loops). try: await asyncio.sleep(TICK_INTERVAL_S) except asyncio.CancelledError: break await self._shutdown() async def _shutdown(self) -> None: log.info("shutdown") append_ui_log("info", "shutdown") try: if self.server is not None: await self.server.stop() except Exception as e: log.warning("server stop: %s", e) self.registry.save() self._sync_status() def request_stop(self) -> None: self._stop.set() def main() -> None: setup_logging() preflight() loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) app = CollectorApp() def _sig(*_a): log.info("signal → stop") app.request_stop() for task in asyncio.all_tasks(loop): task.cancel() for sig in (signal.SIGINT, signal.SIGTERM): try: loop.add_signal_handler(sig, _sig) except NotImplementedError: signal.signal(sig, lambda *_: _sig()) try: loop.run_until_complete(app.run()) except asyncio.CancelledError: pass finally: loop.close() if __name__ == "__main__": main()