Files
Button/winDeployScripts/software/collector/main.py
T
2026-10-16 07:19:06 +03:00

342 lines
11 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
# 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 Advertiser, build_manufacturer_data # noqa: E402
from bluez_adv import ( # noqa: E402
BlueZAdvertiser,
disconnect_device,
find_device_path,
list_connected_addresses,
read_device_rssi,
)
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:
import dbus
bus = dbus.SystemBus()
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.bluez_adv: BlueZAdvertiser | None = None
self.advertiser: Advertiser | None = None
self.server = None
self._known_connected: set[str] = set()
self._stop = asyncio.Event()
self._glib_thread: threading.Thread | None = None
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 _start_glib(self) -> None:
try:
from gi.repository import GLib # type: ignore
loop = GLib.MainLoop()
def _run():
loop.run()
self._glib_thread = threading.Thread(
target=_run, name="glib-dbus", daemon=True
)
self._glib_thread.start()
except Exception as e:
log.warning("GLib mainloop not started: %s", e)
def _sync_status(self) -> None:
write_status(self.registry.status_dict())
def _refresh_mirror(self, force: bool = False) -> None:
if self.advertiser is None:
return
bitmap, reports = self.registry.mirror_payload_parts()
mfr = build_manufacturer_data(bitmap, reports)
self.advertiser.set_mirror(mfr, 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.bluez_adv is not None:
path = find_device_path(self.bluez_adv.bus, addr)
if path:
rssi = read_device_rssi(self.bluez_adv.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()
# Ensure advertising is back after connection ends.
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:
# Late write without prior connect handling — try assign.
if not self._on_connect(addr):
return
sensor = self.registry.get(addr)
rssi = None
if self.bluez_adv is not None:
path = find_device_path(self.bluez_adv.bus, addr)
if path:
rssi = read_device_rssi(self.bluez_adv.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.bluez_adv is None:
return
try:
connected = set(list_connected_addresses(self.bluez_adv.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.bluez_adv.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)
if self.advertiser:
self.advertiser.tick()
async def run(self) -> None:
self.registry.load()
self._sync_status()
append_ui_log("info", "startup", sensors=len(self.registry.by_addr))
self._start_glib()
self.bluez_adv = BlueZAdvertiser()
self.advertiser = Advertiser(
register_fn=self.bluez_adv.register,
unregister_fn=self.bluez_adv.unregister,
)
self._refresh_mirror(force=True)
self.advertiser.tick()
loop = asyncio.get_event_loop()
self.server = await build_bless_server(self.gatt, loop, LOCAL_NAME)
await self.server.start()
log.info("GATT server started (%s)", LOCAL_NAME)
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:
self.advertiser.tick()
self._sync_status()
except Exception as e:
log.error("tick error: %s", e)
try:
await asyncio.wait_for(self._stop.wait(), timeout=TICK_INTERVAL_S)
except asyncio.TimeoutError:
pass
await self._shutdown()
async def _shutdown(self) -> None:
log.info("shutdown")
append_ui_log("info", "shutdown")
try:
if self.advertiser:
self.bluez_adv.unregister()
except Exception:
pass
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()
app = CollectorApp()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
def _sig(*_a):
log.info("signal → stop")
app.request_stop()
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())
finally:
loop.close()
if __name__ == "__main__":
main()