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
This commit is contained in:
@@ -19,7 +19,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from bleak import BleakScanner
|
from bleak import BleakScanner, BleakClient
|
||||||
|
|
||||||
|
|
||||||
# --- Global config (loaded from .env) ---
|
# --- Global config (loaded from .env) ---
|
||||||
@@ -32,11 +32,19 @@ MATRIX_ACCESS_TOKEN = ""
|
|||||||
MATRIX_ROOM_ID = ""
|
MATRIX_ROOM_ID = ""
|
||||||
LOG_FILE = "/opt/ble_alerter/ble_alerter.log"
|
LOG_FILE = "/opt/ble_alerter/ble_alerter.log"
|
||||||
LOG_LEVEL = "INFO"
|
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 ---
|
# --- State ---
|
||||||
tracked = {} # {device_key: {name, mac, first_seen, last_seen, rssi}}
|
tracked = {} # {device_key: {name, mac, first_seen, last_seen, rssi}}
|
||||||
tracked_lock = asyncio.Lock()
|
tracked_lock = asyncio.Lock()
|
||||||
shutdown_event = asyncio.Event()
|
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():
|
def setup_logging():
|
||||||
@@ -61,9 +69,10 @@ def load_env():
|
|||||||
"""Parse .env file manually (key=value), no dependency required."""
|
"""Parse .env file manually (key=value), no dependency required."""
|
||||||
global MODE, KNOWN_DEVICES, DEPARTURE_TIMEOUT, RSSI_MIN
|
global MODE, KNOWN_DEVICES, DEPARTURE_TIMEOUT, RSSI_MIN
|
||||||
global MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, MATRIX_ROOM_ID, LOG_LEVEL
|
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"))
|
env_path = Path(os.getenv("BLE_ALERTER_ENV", "/opt/ble_alerter/.env"))
|
||||||
|
|
||||||
if not env_path.exists():
|
if not env_path.exists():
|
||||||
logging.warning(f".env not found at {env_path}, using env vars only")
|
logging.warning(f".env not found at {env_path}, using env vars only")
|
||||||
MODE = os.getenv("MODE", "open")
|
MODE = os.getenv("MODE", "open")
|
||||||
@@ -74,6 +83,7 @@ def load_env():
|
|||||||
MATRIX_ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN", "")
|
MATRIX_ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN", "")
|
||||||
MATRIX_ROOM_ID = os.getenv("MATRIX_ROOM_ID", "")
|
MATRIX_ROOM_ID = os.getenv("MATRIX_ROOM_ID", "")
|
||||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||||
|
GATT_PROBE = os.getenv("GATT_PROBE", "0") == "1"
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -110,7 +120,9 @@ def load_env():
|
|||||||
MATRIX_ROOM_ID = val
|
MATRIX_ROOM_ID = val
|
||||||
elif key == "LOG_LEVEL":
|
elif key == "LOG_LEVEL":
|
||||||
LOG_LEVEL = val
|
LOG_LEVEL = val
|
||||||
|
elif key == "GATT_PROBE":
|
||||||
|
GATT_PROBE = val == "1"
|
||||||
|
|
||||||
logging.info(f"Loaded config from {env_path}")
|
logging.info(f"Loaded config from {env_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to load .env: {e}")
|
logging.error(f"Failed to load .env: {e}")
|
||||||
@@ -208,6 +220,54 @@ def send_alert(msg):
|
|||||||
return
|
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):
|
async def on_arrival(name, mac, rssi):
|
||||||
"""Handle BLE device arrival."""
|
"""Handle BLE device arrival."""
|
||||||
async with tracked_lock:
|
async with tracked_lock:
|
||||||
@@ -256,7 +316,10 @@ async def scan_loop():
|
|||||||
name = device.name or advertisement_data.local_name
|
name = device.name or advertisement_data.local_name
|
||||||
mac = device.address
|
mac = device.address
|
||||||
rssi = device.rssi
|
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
|
# Apply detection filters
|
||||||
if MODE == "known":
|
if MODE == "known":
|
||||||
# Only track devices in KNOWN_DEVICES list
|
# Only track devices in KNOWN_DEVICES list
|
||||||
@@ -266,12 +329,12 @@ async def scan_loop():
|
|||||||
# Filter generic names
|
# Filter generic names
|
||||||
if is_generic_name(name):
|
if is_generic_name(name):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Apply RSSI filter (if specified)
|
# Apply RSSI filter (if specified)
|
||||||
if rssi < RSSI_MIN:
|
if rssi < RSSI_MIN:
|
||||||
logging.debug(f"RSSI too weak: {name or mac} ({rssi})")
|
logging.debug(f"RSSI too weak: {name or mac} ({rssi})")
|
||||||
return
|
return
|
||||||
|
|
||||||
await on_arrival(name, mac, rssi)
|
await on_arrival(name, mac, rssi)
|
||||||
|
|
||||||
# Active scanner — explicitly required; bleak 0.20 BlueZ backend defaults to passive
|
# Active scanner — explicitly required; bleak 0.20 BlueZ backend defaults to passive
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ from pathlib import Path
|
|||||||
LOG_FILE = "/opt/net_alerter/net_alerter.log"
|
LOG_FILE = "/opt/net_alerter/net_alerter.log"
|
||||||
OUI_DB_PATH = "/opt/net_alerter/oui.db"
|
OUI_DB_PATH = "/opt/net_alerter/oui.db"
|
||||||
ENV_PATH = Path(os.getenv("NET_ALERTER_ENV", "/opt/net_alerter/.env"))
|
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_devices = {} # {mac: {ip, hostname, vendor, first_seen, last_seen, infrastructure (opt)}}
|
||||||
known_lock = threading.Lock()
|
known_lock = threading.Lock()
|
||||||
@@ -984,6 +986,25 @@ def update_occupancy_state() -> None:
|
|||||||
_update_occupancy_state_unlocked()
|
_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:
|
def on_arrival(mac: str, ip: str, hostname: str = "") -> None:
|
||||||
"""Handle device arrival."""
|
"""Handle device arrival."""
|
||||||
# Infrastructure IPs never trigger alerts
|
# 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)
|
msg = format_arrival(resolved_hostname, resolved_ip, resolved_vendor or "unknown", mac)
|
||||||
logging.info(msg)
|
logging.info(msg)
|
||||||
send_alert(msg)
|
send_alert(msg)
|
||||||
|
_log_ble_correlation(mac)
|
||||||
update_occupancy_state()
|
update_occupancy_state()
|
||||||
|
|
||||||
t = threading.Timer(2.0, _send_arrival_alert)
|
t = threading.Timer(2.0, _send_arrival_alert)
|
||||||
|
|||||||
Reference in New Issue
Block a user