a9d43ce4d3
When systemd sends SIGTERM, asyncio cancels the scan task before BleakScanner.__aexit__ runs. BlueZ already stopped discovery by then, so stop() throws 'No discovery started'. This was crashing the service and triggering a 22-restart loop before systemd settled. Catch the specific error string and treat it as a debug-level no-op.
432 lines
15 KiB
Python
Executable File
432 lines
15 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, BleakClient
|
|
|
|
|
|
# --- 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"
|
|
GATT_PROBE = False # Enable active GATT Device Name probing for Apple devices
|
|
GATT_NAMES_FILE = "/opt/net_alerter/ble_names.json"
|
|
|
|
APPLE_COMPANY_ID = 76 # 0x004C
|
|
|
|
# --- State ---
|
|
tracked = {} # {device_key: {name, mac, first_seen, last_seen, rssi}}
|
|
tracked_lock = asyncio.Lock()
|
|
shutdown_event = asyncio.Event()
|
|
gatt_cache = {} # {mac: {name, last_seen}} — persists discovered GATT Device Names
|
|
gatt_probed = set() # MACs already probed this session (avoid repeat attempts)
|
|
gatt_lock = asyncio.Lock()
|
|
apple_last_seen = {} # {mac: float} — last time each Apple BLE device was seen
|
|
|
|
|
|
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
|
|
global GATT_PROBE
|
|
|
|
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")
|
|
GATT_PROBE = os.getenv("GATT_PROBE", "0") == "1"
|
|
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
|
|
elif key == "GATT_PROBE":
|
|
GATT_PROBE = val == "1"
|
|
|
|
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 _probe_gatt_name(mac: str) -> str:
|
|
"""Try to read Device Name (0x2A00) via GATT. Returns name or '' on failure."""
|
|
try:
|
|
async with BleakClient(mac, timeout=5.0) as client:
|
|
data = await client.read_gatt_char("00002a00-0000-1000-8000-00805f9b34fb")
|
|
return data.decode("utf-8", errors="replace").strip()
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _write_gatt_file() -> None:
|
|
"""Persist gatt_cache to GATT_NAMES_FILE. Caller must hold gatt_lock."""
|
|
try:
|
|
Path(GATT_NAMES_FILE).parent.mkdir(parents=True, exist_ok=True)
|
|
Path(GATT_NAMES_FILE).write_text(json.dumps(gatt_cache, indent=2))
|
|
except Exception as e:
|
|
logging.debug(f"Could not write {GATT_NAMES_FILE}: {e}")
|
|
|
|
|
|
async def _maybe_probe_apple(device, adv_data) -> None:
|
|
"""Track Apple device last_seen; if GATT_PROBE enabled, attempt Device Name read."""
|
|
mac = device.address
|
|
if APPLE_COMPANY_ID not in adv_data.manufacturer_data:
|
|
return
|
|
|
|
now = time.time()
|
|
async with gatt_lock:
|
|
# Update last_seen for all Apple devices regardless of GATT
|
|
entry = gatt_cache.get(mac)
|
|
if entry:
|
|
entry["last_seen"] = now
|
|
else:
|
|
gatt_cache[mac] = {"name": "", "last_seen": now}
|
|
|
|
if not GATT_PROBE or mac in gatt_probed:
|
|
return
|
|
gatt_probed.add(mac)
|
|
|
|
name = await _probe_gatt_name(mac)
|
|
if not name:
|
|
return
|
|
|
|
logging.info(f"[BLE-GATT] {mac} → {name!r}")
|
|
async with gatt_lock:
|
|
gatt_cache[mac]["name"] = name
|
|
_write_gatt_file()
|
|
|
|
|
|
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
|
|
|
|
# Always try GATT probe on Apple devices (fire-and-forget, rate limited)
|
|
asyncio.ensure_future(_maybe_probe_apple(device, advertisement_data))
|
|
|
|
# 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()
|
|
elif "No discovery started" in str(e):
|
|
# BlueZ teardown race: SIGTERM cancelled the scanner before BleakScanner.__aexit__
|
|
# ran stop(). BlueZ already stopped the scan, so this is harmless.
|
|
logging.debug(f"BlueZ teardown race on shutdown (ignored): {e}")
|
|
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())
|