From 77b0cd6512f0cd84086a17ec580ad65ef9cfce65 Mon Sep 17 00:00:00 2001 From: Cobra Date: Fri, 10 Apr 2026 06:31:38 -0400 Subject: [PATCH] Add BLE alerter daemon (passive scan, named-device tracking, Matrix alerts) --- net_alerter/ble_alerter.py | 337 +++++++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100755 net_alerter/ble_alerter.py diff --git a/net_alerter/ble_alerter.py b/net_alerter/ble_alerter.py new file mode 100755 index 0000000..52442f7 --- /dev/null +++ b/net_alerter/ble_alerter.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +""" +ble_alerter.py — BLE presence alerter daemon. +Passively scans for BLE devices by name, sends arrival/departure alerts to Matrix. +Replaces net_alerter's physical presence detection via passive BLE instead of ARP. + +Zero active scanning. No SCAN_REQ packets transmitted. No DNS lookups. +""" + +import asyncio +import json +import logging +import os +import signal +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from bleak import BleakScanner + + +# --- Global config (loaded from .env) --- +MODE = "open" # open | known +KNOWN_DEVICES = [] # Only used if MODE=="known" +DEPARTURE_TIMEOUT = 60 +RSSI_MIN = -100 +MATRIX_HOMESERVER = "" +MATRIX_ACCESS_TOKEN = "" +MATRIX_ROOM_ID = "" +LOG_FILE = "/opt/ble_alerter/ble_alerter.log" +LOG_LEVEL = "INFO" + +# --- State --- +tracked = {} # {device_key: {name, mac, first_seen, last_seen, rssi}} +tracked_lock = asyncio.Lock() +shutdown_event = asyncio.Event() + + +def setup_logging(): + """Configure logging to file and stderr.""" + log_format = "%(asctime)s [%(levelname)s] %(message)s" + log_level = getattr(logging, LOG_LEVEL.upper(), logging.INFO) + + handlers = [logging.StreamHandler(sys.stderr)] + try: + handlers.append(logging.FileHandler(LOG_FILE, mode='a')) + except Exception as e: + logging.warning(f"Could not open {LOG_FILE}: {e}") + + logging.basicConfig( + level=log_level, + format=log_format, + handlers=handlers + ) + + +def load_env(): + """Parse .env file manually (key=value), no dependency required.""" + global MODE, KNOWN_DEVICES, DEPARTURE_TIMEOUT, RSSI_MIN + global MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, MATRIX_ROOM_ID, LOG_LEVEL + + env_path = Path(os.getenv("BLE_ALERTER_ENV", "/opt/ble_alerter/.env")) + + if not env_path.exists(): + logging.warning(f".env not found at {env_path}, using env vars only") + MODE = os.getenv("MODE", "open") + KNOWN_DEVICES = [d.strip() for d in os.getenv("KNOWN_DEVICES", "").split(",") if d.strip()] + DEPARTURE_TIMEOUT = int(os.getenv("DEPARTURE_TIMEOUT", "60")) + RSSI_MIN = int(os.getenv("RSSI_MIN", "-100")) + MATRIX_HOMESERVER = os.getenv("MATRIX_HOMESERVER", "https://m.example.org") + MATRIX_ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN", "") + MATRIX_ROOM_ID = os.getenv("MATRIX_ROOM_ID", "") + LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") + return + + try: + for line in env_path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, val = line.split("=", 1) + val = val.strip() + + if key == "MODE": + MODE = val + elif key == "KNOWN_DEVICES": + KNOWN_DEVICES = [d.strip() for d in val.split(",") if d.strip()] + elif key == "DEPARTURE_TIMEOUT": + try: + DEPARTURE_TIMEOUT = int(val) + except ValueError: + logging.warning(f"Invalid DEPARTURE_TIMEOUT: {val}, using default 60") + DEPARTURE_TIMEOUT = 60 + elif key == "RSSI_MIN": + try: + RSSI_MIN = int(val) + except ValueError: + logging.warning(f"Invalid RSSI_MIN: {val}, using default -100") + RSSI_MIN = -100 + elif key == "MATRIX_HOMESERVER": + MATRIX_HOMESERVER = val + elif key == "MATRIX_ACCESS_TOKEN": + MATRIX_ACCESS_TOKEN = val + elif key == "MATRIX_ROOM_ID": + MATRIX_ROOM_ID = val + elif key == "LOG_LEVEL": + LOG_LEVEL = val + + logging.info(f"Loaded config from {env_path}") + except Exception as e: + logging.error(f"Failed to load .env: {e}") + + +def is_generic_name(name): + """ + Filter out unhelpful device names (generic manufacturer defaults, hex-only, too short). + Return True if name should be ignored. + """ + if not name or not isinstance(name, str): + return True + + name = name.strip() + + # Too short + if len(name) < 3: + return True + + # All hex characters (looks like MAC address fragment) + if all(c in "0123456789ABCDEFabcdef" for c in name): + return True + + # Common generic patterns + generic_patterns = [ + "LE-", "BLE-", "BT-", "BT ", "Bluetooth", "Unknown", + "Device", "Object", "Beacon", "Sensor", "Module" + ] + for pattern in generic_patterns: + if pattern.lower() in name.lower(): + return True + + return False + + +def fmt_duration(secs): + """Format duration in seconds to human-readable format.""" + if secs < 60: + return f"{int(secs)}s" + elif secs < 3600: + m = int(secs // 60) + s = int(secs % 60) + return f"{m}m {s}s" if s else f"{m}m" + else: + h = int(secs // 3600) + m = int((secs % 3600) // 60) + return f"{h}h {m}m" if m else f"{h}h" + + +def send_alert(msg): + """Send alert to Matrix room.""" + if not MATRIX_ACCESS_TOKEN: + logging.warning("No MATRIX_ACCESS_TOKEN set, skipping alert") + return + + url = ( + f"{MATRIX_HOMESERVER}/_matrix/client/v3/rooms/" + f"{urllib.parse.quote(MATRIX_ROOM_ID, safe='')}/send/m.room.message" + ) + payload = json.dumps({"msgtype": "m.text", "body": msg}).encode() + req = urllib.request.Request( + url, + data=payload, + headers={ + "Authorization": f"Bearer {MATRIX_ACCESS_TOKEN}", + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10) as r: + pass + logging.debug(f"Alert sent to Matrix") + except urllib.error.HTTPError as e: + logging.error(f"Matrix send failed with HTTP {e.code}") + except Exception as e: + logging.error(f"Matrix send exception: {e}") + + +async def on_arrival(name, mac, rssi): + """Handle BLE device arrival.""" + async with tracked_lock: + now = time.time() + key = name if name else mac + + if key not in tracked: + # New device + tracked[key] = { + "name": name, + "mac": mac, + "first_seen": now, + "last_seen": now, + "rssi": rssi, + } + msg = f"[BLE] ARRIVED: {name} (RSSI: {rssi})" if name else f"[BLE] ARRIVED: {mac} (RSSI: {rssi})" + logging.info(msg) + send_alert(msg) + else: + # Update existing device + tracked[key]["last_seen"] = now + tracked[key]["rssi"] = rssi + + +async def on_departure(device_key): + """Handle BLE device departure.""" + async with tracked_lock: + if device_key in tracked: + device = tracked[device_key] + duration = time.time() - device["first_seen"] + duration_str = fmt_duration(duration) + name = device.get("name") or device.get("mac") + msg = f"[BLE] DEPARTED: {name} — present {duration_str}" + logging.info(msg) + send_alert(msg) + del tracked[device_key] + + +async def scan_loop(): + """Passive BLE scanner. Continuously monitors for new advertisements.""" + logging.info(f"Starting BLE scan loop (mode={MODE}, timeout={DEPARTURE_TIMEOUT}s)") + + async def detection_callback(device, advertisement_data): + """Called on each BLE advertisement.""" + # Get device name (from device or advertisement) + name = device.name or advertisement_data.local_name + mac = device.address + rssi = device.rssi + + # Apply detection filters + if MODE == "known": + # Only track devices in KNOWN_DEVICES list + if not name or name not in KNOWN_DEVICES: + return + else: # open mode + # Filter generic names + if is_generic_name(name): + return + + # Apply RSSI filter (if specified) + if rssi < RSSI_MIN: + logging.debug(f"RSSI too weak: {name or mac} ({rssi})") + return + + await on_arrival(name, mac, rssi) + + # Passive scanner — no SCAN_REQ packets sent + async with BleakScanner(detection_callback=detection_callback, scanning_mode="passive") as scanner: + try: + while not shutdown_event.is_set(): + await asyncio.sleep(1) + except asyncio.CancelledError: + logging.info("BLE scanner cancelled") + except Exception as e: + logging.error(f"BLE scanner exception: {e}") + + +async def timeout_checker(): + """Check for departed devices (no advertisements for DEPARTURE_TIMEOUT seconds).""" + logging.info(f"Starting timeout checker (interval={DEPARTURE_TIMEOUT}s)") + + check_interval = max(10, DEPARTURE_TIMEOUT // 6) # Check 6x per timeout + + while not shutdown_event.is_set(): + try: + await asyncio.sleep(check_interval) + + async with tracked_lock: + now = time.time() + departed = [] + + for device_key, device in list(tracked.items()): + time_since_last_seen = now - device["last_seen"] + if time_since_last_seen > DEPARTURE_TIMEOUT: + departed.append(device_key) + + # Send departure alerts (outside lock to avoid contention) + for device_key in departed: + await on_departure(device_key) + + except asyncio.CancelledError: + logging.info("Timeout checker cancelled") + except Exception as e: + logging.error(f"Timeout checker exception: {e}") + + +async def main(): + """Main entry point. Run scanner and timeout checker concurrently.""" + setup_logging() + load_env() + + logging.info(f"BLE Alerter starting (mode={MODE}, departure_timeout={DEPARTURE_TIMEOUT}s)") + + # Set up signal handlers for graceful shutdown + def signal_handler(signum, frame): + logging.info(f"Received signal {signum}, shutting down") + shutdown_event.set() + + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + # Run scanner and timeout checker together + try: + scan_task = asyncio.create_task(scan_loop()) + timeout_task = asyncio.create_task(timeout_checker()) + + await shutdown_event.wait() + + # Cancel tasks and wait for cleanup + scan_task.cancel() + timeout_task.cancel() + + try: + await asyncio.gather(scan_task, timeout_task) + except asyncio.CancelledError: + pass + + logging.info("BLE Alerter shutdown complete") + except Exception as e: + logging.error(f"Fatal error: {e}", exc_info=True) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main())