#!/usr/bin/env python3 """ Presence daemon — Multi-signal WiFi/ARP/DHCP/BLE person presence tracking. Passive sensors with exponential decay and presence anchoring. Zero active probing. No tool fingerprints in output. """ import asyncio import http.server import json import logging import math import os import socket import sqlite3 import struct import sys import threading import time import urllib.parse import urllib.request from dataclasses import dataclass, field from pathlib import Path from typing import Optional # --- Config (env-driven) --- MONITOR_IFACE = os.getenv("PRESENCE_MONITOR_IFACE", "wlan1") DATA_IFACE = os.getenv("PRESENCE_DATA_IFACE", "wlan0") DB_PATH = os.getenv("PRESENCE_DB_PATH", "/opt/presence/presence.db") MATRIX_WEBHOOK = os.getenv("PRESENCE_MATRIX_WEBHOOK", "") PRESENCE_THRESHOLD = 0.40 ABSENCE_THRESHOLD = 0.10 ANCHOR_MINUTES = 45 DECAY_LAMBDA = 0.08 # Signal bump constants PROBE_BUMP = 0.60 ARP_BUMP = 0.50 DHCP_BUMP = 0.80 BLE_BUMP = 0.40 # Logging logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[ logging.FileHandler("/var/log/sensor.log", encoding="utf-8"), logging.StreamHandler(sys.stdout) ] ) logger = logging.getLogger(__name__) # Top OUI prefixes (inline, no external DB) OUI_DICT = { "88A29E": "Apple", "001A7D": "Apple", "185F3F": "Apple", "A4C3F0": "Apple", "0899D8": "Apple", "004096": "Apple", "00219B": "Apple", "0021E9": "Apple", "005973": "Apple", "006377": "Apple", "0064B9": "Apple", "0084F3": "Apple", "00A04D": "Apple", "00D04B": "Apple", "28879F": "Google", "2887BA": "Google", "5427EB": "Google", "542758": "Google", "341513": "Amazon", "0C47C2": "Amazon", "B827EB": "Raspberry Pi", "2C56DC": "Raspberry Pi", "E45F01": "Raspberry Pi", } # Netlink constants NETLINK_ROUTE = 0 RTMGRP_NEIGH = 0x4 RTM_NEWNEIGH = 28 RTM_DELNEIGH = 29 NUD_REACHABLE = 0x02 NUD_STALE = 0x04 NUD_DELAY = 0x08 NUD_PROBE = 0x10 NDA_DST = 1 NDA_LLADDR = 2 @dataclass class DeviceState: """In-memory device state.""" mac: str certainty: float = 0.0 last_seen: float = field(default_factory=time.time) last_signal: str = "" person_id: Optional[int] = None @dataclass class PersonState: """In-memory person state.""" person_id: int name: str status: str = "UNKNOWN" last_change: float = field(default_factory=time.time) # Shared state with locks devices = {} devices_lock = threading.Lock() persons = {} persons_lock = threading.Lock() infrastructure_ips = set() infrastructure_lock = threading.Lock() def lookup_oui(mac: str) -> str: """Look up OUI vendor from MAC (first 3 octets, inline dict).""" if not mac or mac == "00:00:00:00:00:00": return "unknown" oui_prefix = mac.replace(":", "").upper()[:6] return OUI_DICT.get(oui_prefix, "unknown") def init_db() -> None: """Initialize SQLite database from schema if not exists.""" schema_path = Path(__file__).parent / "presence_schema.sql" if not schema_path.exists(): logger.error(f"Schema file not found: {schema_path}") return db_path = Path(DB_PATH) db_path.parent.mkdir(parents=True, exist_ok=True) try: conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() schema = schema_path.read_text() cursor.executescript(schema) conn.commit() conn.close() logger.info(f"Database initialized at {DB_PATH}") except Exception as e: logger.error(f"Failed to init database: {e}") def lookup_person(mac: str) -> Optional[int]: """Look up person_id for a MAC from database.""" try: conn = sqlite3.connect(DB_PATH, timeout=1) cursor = conn.cursor() result = cursor.execute( "SELECT person_id FROM devices WHERE mac = ?", (mac.lower(),) ).fetchone() conn.close() return result[0] if result else None except Exception as e: logger.debug(f"lookup_person error for {mac}: {e}") return None def seed_persons_from_db() -> None: """Load all persons from database into memory.""" try: conn = sqlite3.connect(DB_PATH, timeout=1) conn.row_factory = sqlite3.Row cursor = conn.cursor() rows = cursor.execute("SELECT id, name FROM persons").fetchall() conn.close() with persons_lock: for row in rows: person = PersonState( person_id=row["id"], name=row["name"] ) persons[row["id"]] = person logger.info(f"Loaded {len(rows)} persons from database") except Exception as e: logger.error(f"Failed to seed persons: {e}") def log_signal(mac: str, signal_type: str, certainty: float) -> None: """Insert signal into database (non-blocking via queue would be ideal, but inline for simplicity).""" try: conn = sqlite3.connect(DB_PATH, timeout=1) cursor = conn.cursor() cursor.execute( "INSERT INTO signals (mac, signal_type, certainty, ts) VALUES (?, ?, ?, ?)", (mac.lower(), signal_type, certainty, time.time()) ) conn.commit() conn.close() except Exception as e: logger.debug(f"Failed to log signal: {e}") def log_presence_change(person_id: int, old_status: str, new_status: str) -> None: """Insert occupancy change into database.""" try: conn = sqlite3.connect(DB_PATH, timeout=1) cursor = conn.cursor() cursor.execute( "INSERT INTO occupancy_log (person_id, old_status, new_status, ts) VALUES (?, ?, ?, ?)", (person_id, old_status, new_status, time.time()) ) conn.commit() conn.close() except Exception as e: logger.debug(f"Failed to log presence change: {e}") def send_alert(person_name: str, status: str, signal_type: str, certainty: float) -> None: """Send alert to Matrix webhook if configured.""" if not MATRIX_WEBHOOK: logger.info(f"Alert (no webhook): {person_name} — {status} ({signal_type}, certainty: {certainty:.2f})") return try: action = "ARRIVED" if status == "PRESENT" else "DEPARTED" message = f"SENSOR: {person_name} — {action} ({signal_type}, certainty: {certainty:.2f})" payload = json.dumps({"text": message}) req = urllib.request.Request( MATRIX_WEBHOOK, data=payload.encode("utf-8"), headers={"Content-Type": "application/json"} ) with urllib.request.urlopen(req, timeout=5) as response: logger.info(f"Alert sent: {message}") except Exception as e: logger.error(f"Failed to send alert: {e}") def on_signal(mac: str, signal_type: str, bump: float) -> None: """ Process a signal bump for a device. Decay old certainty, apply bump, update state. """ mac = mac.lower() with devices_lock: dev = devices.get(mac) if dev is None: dev = DeviceState( mac=mac, certainty=0.0, last_seen=time.time(), last_signal="", person_id=lookup_person(mac) ) devices[mac] = dev # Apply exponential decay since last signal elapsed_minutes = (time.time() - dev.last_seen) / 60.0 dev.certainty = dev.certainty * math.exp(-DECAY_LAMBDA * elapsed_minutes) # Apply bump dev.certainty = min(1.0, dev.certainty + bump) dev.last_seen = time.time() dev.last_signal = signal_type log_signal(mac, signal_type, dev.certainty) update_person_state(dev.person_id) def update_person_state(person_id: Optional[int]) -> None: """ Compute aggregate certainty for a person, update state machine. Fire alerts on state transitions. """ if person_id is None: return with devices_lock: # Get all devices for this person devices_for_person = [d for d in devices.values() if d.person_id == person_id] if not devices_for_person: return # Compute aggregate certainty (max + 0.08 * second_max) certainties = sorted([d.certainty for d in devices_for_person], reverse=True) if len(certainties) >= 2: agg_certainty = certainties[0] + 0.08 * certainties[1] else: agg_certainty = certainties[0] # Get max last_seen time max_last_seen = max(d.last_seen for d in devices_for_person) with persons_lock: person = persons.get(person_id) if person is None: return old_status = person.status elapsed_since_signal = (time.time() - max_last_seen) / 60.0 # State machine logic if person.status == "UNKNOWN": if agg_certainty >= PRESENCE_THRESHOLD: person.status = "PRESENT" person.last_change = time.time() log_presence_change(person_id, old_status, person.status) send_alert(person.name, "PRESENT", "signal", agg_certainty) elif person.status == "PRESENT": # Check presence anchor if elapsed_since_signal >= ANCHOR_MINUTES: person.status = "ABSENT" person.last_change = time.time() log_presence_change(person_id, old_status, person.status) send_alert(person.name, "ABSENT", "timeout", agg_certainty) elif person.status == "ABSENT": if agg_certainty >= PRESENCE_THRESHOLD: person.status = "PRESENT" person.last_change = time.time() log_presence_change(person_id, old_status, person.status) send_alert(person.name, "PRESENT", "signal", agg_certainty) def probe_sniffer(iface: str) -> None: """ WiFi probe request sniffer. Raw 802.11 frame capture on monitor mode interface. Extracts source MAC from probe requests. """ try: s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0003)) s.bind((iface, 0)) logger.info(f"Probe sniffer started on {iface}") while True: try: data, _ = s.recvfrom(65535) if len(data) < 24: continue # 802.11 frame: FC (2) + duration (2) + dest (6) + src (6) + BSSID (6) = 22 bytes min fc = struct.unpack("> 2) & 0x3 frame_subtype = (fc >> 4) & 0xf # Type 0 = management, Subtype 4 = probe request if frame_type == 0 and frame_subtype == 4: if len(data) >= 16: # Source MAC at bytes 10-16 mac_bytes = data[10:16] mac = ':'.join(f'{b:02x}' for b in mac_bytes) if mac != "ff:ff:ff:ff:ff:ff" and mac != "00:00:00:00:00:00": on_signal(mac, "probe", PROBE_BUMP) except Exception as e: logger.debug(f"Probe sniffer error: {e}") time.sleep(0.1) except Exception as e: logger.error(f"Failed to start probe sniffer: {e}") def arp_listener() -> None: """ ARP listener via netlink RTM_NEWNEIGH. Kernel pushes neighbor discovery events. """ try: s = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, NETLINK_ROUTE) s.bind((os.getpid(), RTMGRP_NEIGH)) logger.info("ARP listener started") while True: try: data = s.recv(65535) parse_netlink(data) except Exception as e: logger.debug(f"ARP listener error: {e}") time.sleep(0.1) except Exception as e: logger.error(f"Failed to start ARP listener: {e}") def parse_netlink(data: bytes) -> None: """Parse netlink messages for neighbor events.""" try: offset = 0 while offset < len(data): if offset + 16 > len(data): break nlmsg_len, nlmsg_type, _, _, _ = struct.unpack_from('=IHHII', data, offset) if nlmsg_len < 16: break payload = data[offset + 16:offset + nlmsg_len] if nlmsg_type in (RTM_NEWNEIGH, RTM_DELNEIGH): parse_ndmsg(payload, nlmsg_type) offset += (nlmsg_len + 3) & ~3 except Exception as e: logger.debug(f"Netlink parse error: {e}") def parse_ndmsg(data: bytes, msg_type: int) -> None: """Parse ndmsg (neighbor discovery message).""" try: if len(data) < 12: return state = struct.unpack_from('=H', data, 8)[0] mac = None ip = None offset = 12 while offset + 4 <= len(data): rta_len, rta_type = struct.unpack_from('=HH', data, offset) if rta_len < 4: break val = data[offset + 4:offset + rta_len] if rta_type == NDA_LLADDR and len(val) == 6: mac = ':'.join(f'{b:02x}' for b in val) elif rta_type == NDA_DST: if len(val) == 4: ip = socket.inet_ntoa(val) offset += (rta_len + 3) & ~3 if not mac or mac in ('00:00:00:00:00:00', 'ff:ff:ff:ff:ff:ff'): return # Ignore infrastructure IPs with infrastructure_lock: if ip and ip in infrastructure_ips: return if msg_type == RTM_NEWNEIGH and (state & (NUD_REACHABLE | NUD_STALE | NUD_DELAY | NUD_PROBE)): on_signal(mac, "arp", ARP_BUMP) except Exception as e: logger.debug(f"ndmsg parse error: {e}") def dhcp_sniffer(iface: str) -> None: """ DHCP sniffer via AF_PACKET raw socket. Captures DHCP traffic passively, extracts client MACs. """ try: s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(0x0003)) s.bind((iface, 0)) logger.info(f"DHCP sniffer started on {iface}") while True: try: data, _ = s.recvfrom(65535) parse_dhcp(data) except Exception as e: logger.debug(f"DHCP sniffer error: {e}") time.sleep(0.1) except Exception as e: logger.error(f"Failed to start DHCP sniffer: {e}") def parse_dhcp(data: bytes) -> None: """Parse DHCP packet and extract client MAC.""" try: if len(data) < 14: return # Ethernet frame eth_type = struct.unpack('!H', data[12:14])[0] if eth_type != 0x0800: # not IPv4 return # IP header if len(data) < 23: return proto = data[23] if proto != 17: # not UDP return # UDP ports if len(data) < 38: return src_port = struct.unpack('!H', data[34:36])[0] dst_port = struct.unpack('!H', data[36:38])[0] if not (src_port in (67, 68) and dst_port in (67, 68)): return # DHCP payload (starts at byte 42) if len(data) < 42: return dhcp = data[42:] if len(dhcp) < 236: return # MAC address (chaddr at offset 28, 6 bytes) mac_bytes = dhcp[28:34] mac = ':'.join(f'{b:02x}' for b in mac_bytes) if mac in ('00:00:00:00:00:00', 'ff:ff:ff:ff:ff:ff'): return # Parse DHCP options (start at byte 240) if len(dhcp) < 240: return msg_type = None i = 240 while i < len(dhcp): opt = dhcp[i] if opt == 255: break if opt == 0: i += 1 continue if i + 1 >= len(dhcp): break length = dhcp[i + 1] if i + 2 + length > len(dhcp): break val = dhcp[i + 2:i + 2 + length] if opt == 53 and length == 1: # DHCP message type msg_type = val[0] i += 2 + length # DISCOVER or REQUEST indicates presence if msg_type in (1, 3): on_signal(mac, "dhcp", DHCP_BUMP) except Exception as e: logger.debug(f"DHCP parse error: {e}") def ble_scanner() -> None: """ BLE passive scanner. Gracefully degrades if bleak is not available. """ try: from bleak import BleakScanner except ImportError: logger.warning("bleak not available; BLE scanning disabled") return async def run_ble_scan() -> None: """Run BLE scan in asyncio loop.""" def on_ble_detect(device, adv_data): """Callback for BLE device detection.""" if device and device.address: on_signal(device.address.lower(), "ble", BLE_BUMP) try: async with BleakScanner( detection_callback=on_ble_detect, scanning_mode="passive" ): await asyncio.sleep(float('inf')) except Exception as e: logger.error(f"BLE scanner error: {e}") try: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(run_ble_scan()) except Exception as e: logger.error(f"Failed to start BLE scanner: {e}") def decay_loop() -> None: """ Background thread: apply decay to all devices every 60 seconds. Prune devices not seen in 120+ minutes. """ while True: try: time.sleep(60) with devices_lock: now = time.time() to_prune = [] for mac, dev in devices.items(): elapsed_minutes = (now - dev.last_seen) / 60.0 # Apply decay dev.certainty = dev.certainty * math.exp(-DECAY_LAMBDA * elapsed_minutes) # Mark for pruning if not seen in 120+ minutes if elapsed_minutes >= 120: to_prune.append(mac) # Prune old devices for mac in to_prune: del devices[mac] logger.debug(f"Pruned device {mac}") # Update all person states due to decay for person_id in set(d.person_id for d in devices.values() if d.person_id): update_person_state(person_id) except Exception as e: logger.error(f"Decay loop error: {e}") def status_handler(request, client_address, server): """HTTP request handler for status endpoint.""" class StatusHandler(http.server.BaseHTTPRequestHandler): def do_GET(self): if self.path == "/": try: with persons_lock: person_list = [ { "id": p.person_id, "name": p.name, "status": p.status, "last_change": p.last_change } for p in persons.values() ] response = json.dumps({"persons": person_list}) self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(response.encode("utf-8")) except Exception as e: logger.error(f"Status handler error: {e}") self.send_response(500) self.end_headers() else: self.send_response(404) self.end_headers() def log_message(self, format, *args): """Suppress HTTP server logs.""" pass return StatusHandler(request, client_address, server) def status_server() -> None: """Start HTTP status server on 127.0.0.1:9191.""" try: server = http.server.HTTPServer( ("127.0.0.1", 9191), status_handler ) logger.info("Status server started on http://127.0.0.1:9191") server.serve_forever() except Exception as e: logger.error(f"Failed to start status server: {e}") def main() -> None: """Initialize and run all sensor threads.""" init_db() seed_persons_from_db() threads = [ threading.Thread(target=probe_sniffer, args=(MONITOR_IFACE,), daemon=True, name="probe-sniffer"), threading.Thread(target=arp_listener, daemon=True, name="arp-listener"), threading.Thread(target=dhcp_sniffer, args=(DATA_IFACE,), daemon=True, name="dhcp-sniffer"), threading.Thread(target=ble_scanner, daemon=True, name="ble-scanner"), threading.Thread(target=decay_loop, daemon=True, name="decay-loop"), threading.Thread(target=status_server, daemon=True, name="status-server"), ] for t in threads: t.start() logger.info("Sensor daemon started") # Handle shutdown gracefully import signal signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) signal.signal(signal.SIGINT, lambda *_: sys.exit(0)) for t in threads: t.join() if __name__ == "__main__": main()