From c422b7e3f6dc4e020dd45452b17357bccaacffeb Mon Sep 17 00:00:00 2001 From: Cobra Date: Wed, 15 Apr 2026 00:03:35 -0400 Subject: [PATCH] Add BLE GATT Device Name probing and WiFi+BLE arrival correlation ble_alerter.py: probe Apple BLE devices via GATT characteristic 0x2A00 to discover device names (iPhone, Apple TV, etc). Names persist to ble_names.json with last_seen timestamps. GATT_PROBE=1 enables active probing; without it, Apple device last_seen timestamps still tracked. net_alerter.py: on WiFi device arrival, read ble_names.json and log any Apple BLE devices seen within the last 60s as correlation candidates. Gives operator visibility into which BLE MACs co-arrived with a WiFi device. Shared state file: /opt/net_alerter/ble_names.json --- net_alerter/ble_alerter.py | 75 +++++++++++++++++++++++++++++++++++--- net_alerter/net_alerter.py | 22 +++++++++++ 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/net_alerter/ble_alerter.py b/net_alerter/ble_alerter.py index 9378544..d67c689 100755 --- a/net_alerter/ble_alerter.py +++ b/net_alerter/ble_alerter.py @@ -19,7 +19,7 @@ import urllib.error import urllib.parse import urllib.request from pathlib import Path -from bleak import BleakScanner +from bleak import BleakScanner, BleakClient # --- Global config (loaded from .env) --- @@ -32,11 +32,19 @@ 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(): @@ -61,9 +69,10 @@ 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") @@ -74,6 +83,7 @@ def load_env(): 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: @@ -110,7 +120,9 @@ def load_env(): 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}") @@ -208,6 +220,54 @@ def send_alert(msg): 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: @@ -256,7 +316,10 @@ async def scan_loop(): 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 @@ -266,12 +329,12 @@ async def scan_loop(): # 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 diff --git a/net_alerter/net_alerter.py b/net_alerter/net_alerter.py index 5566307..5938c3d 100644 --- a/net_alerter/net_alerter.py +++ b/net_alerter/net_alerter.py @@ -30,6 +30,8 @@ from pathlib import Path LOG_FILE = "/opt/net_alerter/net_alerter.log" OUI_DB_PATH = "/opt/net_alerter/oui.db" ENV_PATH = Path(os.getenv("NET_ALERTER_ENV", "/opt/net_alerter/.env")) +BLE_NAMES_FILE = Path("/opt/net_alerter/ble_names.json") +BLE_CORRELATION_WINDOW = 60 # seconds — how far back to look for nearby BLE devices known_devices = {} # {mac: {ip, hostname, vendor, first_seen, last_seen, infrastructure (opt)}} known_lock = threading.Lock() @@ -984,6 +986,25 @@ def update_occupancy_state() -> None: _update_occupancy_state_unlocked() +def _log_ble_correlation(wifi_mac: str) -> None: + """Log nearby Apple BLE devices seen within BLE_CORRELATION_WINDOW seconds of a WiFi arrival.""" + try: + if not BLE_NAMES_FILE.exists(): + return + data = json.loads(BLE_NAMES_FILE.read_text()) + now = time.time() + nearby = [] + for ble_mac, entry in data.items(): + age = now - entry.get("last_seen", 0) + if age <= BLE_CORRELATION_WINDOW: + name = entry.get("name", "") + nearby.append(f"{ble_mac}" + (f" ({name})" if name else "")) + if nearby: + logging.info(f"[BLE-CORR] WiFi arrival {wifi_mac} — nearby Apple BLE: {', '.join(nearby)}") + except Exception as e: + logging.debug(f"BLE correlation read failed: {e}") + + def on_arrival(mac: str, ip: str, hostname: str = "") -> None: """Handle device arrival.""" # Infrastructure IPs never trigger alerts @@ -1101,6 +1122,7 @@ def on_arrival(mac: str, ip: str, hostname: str = "") -> None: msg = format_arrival(resolved_hostname, resolved_ip, resolved_vendor or "unknown", mac) logging.info(msg) send_alert(msg) + _log_ble_correlation(mac) update_occupancy_state() t = threading.Timer(2.0, _send_arrival_alert)