#!/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 from typing import Any, Dict, List, Optional # 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, TICK_INTERVAL_S, collector_log_path, registry_path, run_dir, ) from gatt import CollectorGatt, build_bless_server # noqa: E402 from protocol import ( # noqa: E402 CFG_JOIN_OPEN, CFG_POOL_FULL, pack_interval_min, pack_slot_config, unpack_report, ) from registry import Registry # noqa: E402 from settings import ( # noqa: E402 load_names, load_report_interval_min, save_report_interval_min, ) 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() self._stop = threading.Event() self._names: Dict[str, str] = {} self._dirty_registry = False self._dirty_status = False self._dirty_adv = False self._pending_logs: List[Dict[str, Any]] = [] self._lock = threading.Lock() self.gatt.on_write = self._on_write self.gatt.on_read_slot = self._on_read_slot self.gatt.on_read_interval = self._on_read_interval self.gatt.on_connect = self._on_connect self.gatt.on_disconnect = self._on_disconnect def _reload_settings(self, *, force_log: bool = False) -> None: interval = load_report_interval_min() names = load_names() changed = ( interval != self.gatt.report_interval_min or names != self._names ) self.gatt.report_interval_min = interval self._names = names if force_log or changed: log.info( "settings: report_interval_min=%s names=%d", self.gatt.report_interval_min, len(self._names), ) def _queue_log(self, level: str, message: str, **extra: Any) -> None: with self._lock: self._pending_logs.append({"level": level, "msg": message, **extra}) def _mark_dirty(self, registry: bool = False, status: bool = True, adv: bool = False) -> None: with self._lock: if registry: self._dirty_registry = True if status: self._dirty_status = True if adv: self._dirty_adv = True def _connected(self) -> bool: return bool(self._known_connected) def _sync_status(self) -> None: write_status( self.registry.status_dict( names=self._names, report_interval_min=self.gatt.report_interval_min, ) ) 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 if not force and self._connected(): # Do not tear down advertising while a central is connected. 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) self._queue_log("warn", "pool-full", addr=addr) self._mark_dirty(registry=True, status=True) return False self._queue_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._mark_dirty(registry=True, status=True) 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: # Keep last_seen from last report; only bump state. if sensor.state != "stale": sensor.state = "live" if self.gatt.current_addr() == addr: self.gatt.set_current_addr(None) self._queue_log("info", "disconnect", addr=addr) self._mark_dirty(registry=True, status=True, adv=True) log.info("disconnect %s", addr) def _on_write(self, addr: str, data: bytes) -> None: """GATT hot-path: RAM only — no disk / D-Bus / adv / UI I/O.""" addr = self.registry.normalize_addr(addr) or (self.gatt.current_addr() or "") if not addr: if len(self._known_connected) == 1: addr = next(iter(self._known_connected)) else: 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: sensor = self.registry.assign(addr) if sensor is None: log.warning("pool-full on write from %s", addr) self._queue_log("warn", "pool-full", addr=addr) self._mark_dirty(registry=True, status=True) return self.gatt.set_current_addr(addr) self._queue_log("info", "assigned", addr=addr, slot=sensor.slot) self.registry.update_report( addr, report.battery, report.flags, report.dist, rssi=None ) self._queue_log( "info", "report", addr=addr, battery=report.battery, flags=report.flags, dist=report.dist, ) self._mark_dirty(registry=True, status=True, adv=True) log.debug( "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: 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, self.gatt.period_s()) def _on_read_interval(self) -> bytes: return pack_interval_min(self.gatt.report_interval_min) 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 if connected and self.gatt.current_addr() is None: # Prefer the sole connected peer as current writer context. if len(connected) == 1: self.gatt.set_current_addr(next(iter(connected))) def _handle_cmd(self) -> None: cmd = read_cmd() if not cmd: return op = str(cmd.get("op", "")).lower() if op == "advertise": self._queue_log("info", "manual-advertise") log.info("manual advertise requested") self._refresh_mirror(force=True) elif op == "reload_settings": self._reload_settings() self._mark_dirty(status=True) if "report_interval_min" in cmd: try: self.gatt.report_interval_min = save_report_interval_min( int(cmd["report_interval_min"]) ) except (TypeError, ValueError): pass log.info("reload_settings done interval=%s", self.gatt.report_interval_min) elif op == "set_interval": try: minutes = int(cmd.get("report_interval_min", self.gatt.report_interval_min)) except (TypeError, ValueError): return self.gatt.report_interval_min = save_report_interval_min(minutes) self._mark_dirty(status=True) self._queue_log("info", "set-interval", minutes=self.gatt.report_interval_min) log.info("set_interval %s min", self.gatt.report_interval_min) def _flush_dirty(self) -> None: with self._lock: do_reg = self._dirty_registry do_st = self._dirty_status do_adv = self._dirty_adv logs = list(self._pending_logs) self._dirty_registry = False self._dirty_status = False self._dirty_adv = False self._pending_logs.clear() for entry in logs: level = entry.pop("level", "info") msg = entry.pop("msg", "") append_ui_log(level, msg, **entry) if do_reg: self.registry.save() if do_st: self._sync_status() if do_adv: # Only when idle (no connected centrals), unless already forced earlier. self._refresh_mirror(force=False) async def run(self) -> None: self._reload_settings(force_log=True) 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) 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: # Pick up web/Qt edits to names & interval without a separate watcher. self._reload_settings() self._poll_connections() if self.registry.reevaluate(period_s=self.gatt.period_s()): self._mark_dirty(registry=True, status=True, adv=True) self._handle_cmd() self._flush_dirty() if self.advertiser and not self._connected(): await self.advertiser.tick() # Refresh age_s even when nothing else changed. self._sync_status() except Exception as e: log.error("tick error: %s", e) 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._flush_dirty() 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()