321 lines
10 KiB
Python
321 lines
10 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 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()
|