Files
Button/collector/main.py
T
2026-11-02 01:23:50 +03:00

422 lines
14 KiB
Python

#!/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()