e0086bce6f
Wrap BleakScanner context manager in try-except to detect adapter initialization errors. If 'No Bluetooth adapters found' or similar, log warning and set shutdown event to trigger graceful exit with code 0. Prevents systemd restart loop on hardware without BT adapter (e.g. OPi).
365 lines
12 KiB
Python
Executable File
365 lines
12 KiB
Python
Executable File
#!/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 re
|
|
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}")
|
|
|
|
|
|
_MAC_RE = re.compile(r'^[0-9A-Fa-f]{2}[:\-][0-9A-Fa-f]{2}[:\-][0-9A-Fa-f]{2}[:\-][0-9A-Fa-f]{2}[:\-][0-9A-Fa-f]{2}[:\-][0-9A-Fa-f]{2}$')
|
|
|
|
|
|
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
|
|
|
|
# MAC address format with colons or dashes (e.g. 45-48-39-7F-73-C7 or 45:48:39:7F:73:C7)
|
|
if _MAC_RE.match(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. Retries up to 3 times with exponential backoff on 429/5xx."""
|
|
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()
|
|
|
|
for attempt in range(3):
|
|
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("Alert sent to Matrix")
|
|
return
|
|
except urllib.error.HTTPError as e:
|
|
if e.code in (429, 500, 502, 503, 504) and attempt < 2:
|
|
wait = 2 ** attempt # 1s, 2s
|
|
logging.warning(f"Matrix HTTP {e.code}, retrying in {wait}s")
|
|
time.sleep(wait)
|
|
else:
|
|
logging.error(f"Matrix send failed with HTTP {e.code}")
|
|
return
|
|
except Exception as e:
|
|
logging.error(f"Matrix send exception: {e}")
|
|
return
|
|
|
|
|
|
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)
|
|
|
|
# Active scanner — explicitly required; bleak 0.20 BlueZ backend defaults to passive
|
|
try:
|
|
async with BleakScanner(detection_callback=detection_callback, scanning_mode="active") 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}")
|
|
except Exception as e:
|
|
# Catch no-adapter or other initialization errors
|
|
if "No Bluetooth adapters found" in str(e) or "No default Bluetooth adapter" in str(e):
|
|
logging.warning("No Bluetooth adapter found — BLE scanning disabled.")
|
|
shutdown_event.set()
|
|
else:
|
|
logging.error(f"Failed to initialize BLE scanner: {e}")
|
|
raise
|
|
|
|
|
|
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())
|