Add BLE alerter daemon (passive scan, named-device tracking, Matrix alerts)
This commit is contained in:
Executable
+337
@@ -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())
|
||||
Reference in New Issue
Block a user