#!/usr/bin/env python3 """ presence_daemon.py — WiFi person presence intelligence daemon (Phase 1). Fuses mDNS + ARP signals with exponential decay and presence anchor. State machine per MAC: ABSENT → PRESENT → TRANSITIONING_OUT → ABSENT Certainty weights: mDNS=0.85, ARP=0.65, decay_k=0.08/min Presence anchor: 45 min from last signal >= 0.40 decayed Transition window: 15 min in TRANSITIONING_OUT """ import argparse import json import logging import math import os import signal import socket import sqlite3 import struct import subprocess import sys import threading import time import urllib.parse import urllib.request from pathlib import Path # Signal model MDNS_CERTAINTY = 0.85 ARP_CERTAINTY = 0.65 DECAY_K = 0.08 PRESENCE_ANCHOR_MINUTES = 45 TRANSITION_MINUTES = 15 CERTAINTY_THRESHOLD = 0.50 ANCHOR_THRESHOLD = 0.40 STATE_TICK_INTERVAL = 30 # seconds DB_BATCH_INTERVAL = 30 # seconds MDNS_MULTICAST_ADDR = "224.0.0.251" MDNS_PORT = 5353 # Netlink constants NETLINK_ROUTE = 0 RTMGRP_NEIGH = 4 RTM_NEWNEIGH = 16 RTM_DELNEIGH = 17 # OUI dictionary (common vendors, matches net_alerter.py) 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", "000D82": "Intel", "001025": "Intel", "001ABA": "Intel", "001F3B": "Intel", "001F3C": "Intel", "001BA9": "Intel", "001BFB": "Intel", "001D7A": "Intel", "001DB8": "Intel", "001E67": "Intel", "001F29": "Intel", "F44DA2": "Intel", "B03C1C": "Intel", "00166B": "Qualcomm", "001BF3": "Qualcomm", "001C47": "Qualcomm", "002000": "Qualcomm", "0026F2": "Qualcomm", "0026F3": "Qualcomm", "0026F4": "Qualcomm", "002618": "Qualcomm", "FFFFFF": "Espressif", "687DA8": "Espressif", "A01D48": "Espressif", "30AEA4": "Espressif", "B4E63B": "Espressif", "80E386": "TP-Link", "8CBF26": "TP-Link", "90843F": "TP-Link", "A838B4": "TP-Link", "C81F66": "TP-Link", "DCFEEE": "TP-Link", "F81A67": "TP-Link", "689B5A": "Netgear", "6C46CB": "Netgear", "78A21B": "Netgear", "94B3D5": "Netgear", "B0EE7B": "Netgear", "B4FBE4": "Netgear", "C8D7B4": "Netgear", "D8BB85": "Netgear", "F8AB05": "Netgear", "001D2F": "Cisco", "001930": "Cisco", "001A2F": "Cisco", "001C0E": "Cisco", "001CED": "Cisco", "001EBD": "Cisco", "001F6C": "Cisco", "0021A0": "Cisco", "002678": "Cisco", "0026CB": "Cisco", "00266E": "Cisco", "00269F": "Cisco", "0026A6": "Cisco", } # Global state persons = {} # {mac: {hostname, vendor, first_seen, last_seen, state, certainty, signals, anchor_time}} persons_lock = threading.Lock() infrastructure_ips = set() signals_queue = [] signals_queue_lock = threading.Lock() events_queue = [] events_queue_lock = threading.Lock() # Interface flap detection _flap_window = [] _flap_lock = threading.Lock() _interface_recovering = False _recovery_timer = None # Global shutdown shutdown_event = threading.Event() def setup_logging(log_file, log_level): """Configure logging.""" level = getattr(logging, log_level, logging.INFO) fmt = "%(asctime)s [%(levelname)s] %(message)s" if log_file: handler = logging.FileHandler(log_file) else: handler = logging.StreamHandler(sys.stderr) handler.setFormatter(logging.Formatter(fmt)) logging.root.handlers.clear() logging.root.addHandler(handler) logging.root.setLevel(level) def decay(certainty: float, age_minutes: float) -> float: """Apply exponential decay to certainty over time.""" return certainty * math.exp(-DECAY_K * age_minutes) def wifi_fusion(mdns_c: float, arp_c: float) -> float: """Corroboration bonus when both signals present.""" a, b = max(mdns_c, arp_c), min(mdns_c, arp_c) return a + 0.08 * b def get_primary_interface() -> str: """Get primary network interface (not lo). Matches net_alerter.py pattern.""" try: with open('/proc/net/route') as f: for line in f.readlines()[1:]: parts = line.split() if len(parts) > 1 and parts[0] != 'lo' and parts[1] == '00000000': return parts[0] with open('/proc/net/route') as f: for line in f.readlines()[1:]: parts = line.split() if parts[0] != 'lo': return parts[0] except Exception: pass return 'eth0' def get_local_ip(iface: str) -> str: """Get the local IP address for the given interface.""" try: result = subprocess.run(['ip', 'addr', 'show', iface], capture_output=True, text=True, timeout=5) if result.returncode == 0: for line in result.stdout.split('\n'): if 'inet ' in line: parts = line.strip().split() if len(parts) >= 2: ip_with_mask = parts[1] ip = ip_with_mask.split('/')[0] return ip except Exception as e: logging.warning(f"Failed to get local IP for {iface}: {e}") return "" def seed_infrastructure_ips(iface: str) -> None: """Seed infrastructure IPs that should never trigger alerts. Matches net_alerter.py.""" global infrastructure_ips infrastructure_ips.clear() self_ip = get_local_ip(iface) if self_ip: infrastructure_ips.add(self_ip) logging.info(f"Seeded self IP {self_ip} as infrastructure") try: result = subprocess.run(['ip', 'route', 'show', 'default'], capture_output=True, text=True, timeout=5) if result.returncode == 0: for line in result.stdout.strip().split('\n'): if 'via' in line: parts = line.split() for i, part in enumerate(parts): if part == 'via' and i + 1 < len(parts): gateway_ip = parts[i + 1] infrastructure_ips.add(gateway_ip) logging.info(f"Seeded gateway {gateway_ip} as infrastructure") break except Exception as e: logging.warning(f"Failed to read gateway: {e}") if self_ip: try: parts = self_ip.rsplit('.', 1) if len(parts) == 2: broadcast_ip = parts[0] + ".255" infrastructure_ips.add(broadcast_ip) logging.info(f"Seeded broadcast {broadcast_ip} as infrastructure") except Exception: pass logging.info(f"Infrastructure IPs: {infrastructure_ips}") def lookup_oui(mac: str) -> str: """Look up OUI vendor from MAC address (first 3 octets).""" if not mac or mac == "00:00:00:00:00:00": return "unknown" oui_prefix = mac[:8].lower() oui_prefix_upper = oui_prefix.replace(":", "").upper() return OUI_DICT.get(oui_prefix_upper, "unknown") def read_arp_cache() -> dict: """Read /proc/net/arp and return {ip: mac} mapping.""" arp_map = {} try: with open('/proc/net/arp') as f: for line in f.readlines()[1:]: fields = line.split() if len(fields) < 4: continue ip = fields[0] mac = fields[3].lower() if mac != "00:00:00:00:00:00": arp_map[ip] = mac except Exception as e: logging.debug(f"Failed to read ARP cache: {e}") return arp_map def _check_flap(mac: str) -> bool: """Check if interface is flapping; skip RTM_DELNEIGH if true.""" global _interface_recovering, _recovery_timer now = time.time() with _flap_lock: _flap_window[:] = [(t, m) for t, m in _flap_window if now - t < 3.0] _flap_window.append((now, mac)) if len(_flap_window) >= 3 and not _interface_recovering: _interface_recovering = True if _recovery_timer: _recovery_timer.cancel() _recovery_timer = threading.Timer(60.0, _clear_recovery) _recovery_timer.daemon = True _recovery_timer.start() logging.warning(f"Interface flap detected, entering recovery mode for 60s") return _interface_recovering def _clear_recovery(): """Clear recovery flag after timeout.""" global _interface_recovering _interface_recovering = False logging.info("Interface recovery timeout expired") def record_signal(mac: str, hostname: str = "", signal_type: str = "arp", certainty: float = ARP_CERTAINTY) -> None: """Record a signal and update person state.""" if not mac or mac == "00:00:00:00:00:00" or mac in ("ff:ff:ff:ff:ff:ff", "FF:FF:FF:FF:FF:FF"): return mac = mac.lower() # Check infrastructure exclusion if signal_type == "arp": arp_map = read_arp_cache() if mac in arp_map: ip = arp_map[mac] if ip in infrastructure_ips: return now = time.time() with persons_lock: if mac not in persons: vendor = lookup_oui(mac) persons[mac] = { 'hostname': hostname or "", 'vendor': vendor, 'first_seen': now, 'last_seen': now, 'state': 'ABSENT', 'certainty': 0.0, 'signals': [], 'anchor_time': None, } person = persons[mac] person['last_seen'] = now if hostname and not person['hostname']: person['hostname'] = hostname # Add signal to history (keep last 100) person['signals'].append((signal_type, certainty, now)) person['signals'] = person['signals'][-100:] # Queue signal for DB with signals_queue_lock: signals_queue.append((mac, signal_type, certainty, now)) def compute_person_certainty(mac: str) -> float: """Compute current certainty for a person based on decayed signals.""" with persons_lock: if mac not in persons: return 0.0 person = persons[mac] now = time.time() signals = person['signals'] if not signals: return 0.0 # Separate by type and get most recent mdns_signals = [(c, t) for st, c, t in signals if st == "mdns"] arp_signals = [(c, t) for st, c, t in signals if st == "arp"] mdns_c = 0.0 arp_c = 0.0 if mdns_signals: cert, timestamp = mdns_signals[-1] age_min = (now - timestamp) / 60.0 mdns_c = decay(cert, age_min) if arp_signals: cert, timestamp = arp_signals[-1] age_min = (now - timestamp) / 60.0 arp_c = decay(cert, age_min) # Fusion if mdns_c > 0 and arp_c > 0: return wifi_fusion(mdns_c, arp_c) else: return max(mdns_c, arp_c) def emit_event(mac: str, event_type: str, person: dict) -> None: """Emit a state transition event.""" now = time.time() event = { 'mac': mac, 'hostname': person.get('hostname', ''), 'vendor': person.get('vendor', 'unknown'), 'certainty': person.get('certainty', 0.0), 'state': person.get('state', 'ABSENT'), 'event': event_type, 'first_seen': person.get('first_seen', now), 'last_seen': person.get('last_seen', now), 'duration_min': (person.get('last_seen', now) - person.get('first_seen', now)) / 60.0, } with events_queue_lock: events_queue.append((mac, event_type, person.get('certainty', 0.0), now, event)) def state_machine_tick() -> None: """Run state machine: decay signals, compute certainty, check transitions.""" now = time.time() with persons_lock: for mac, person in list(persons.items()): old_state = person['state'] certainty = compute_person_certainty(mac) person['certainty'] = certainty if person['state'] == 'ABSENT': # ABSENT → PRESENT if certainty >= CERTAINTY_THRESHOLD: person['state'] = 'PRESENT' person['anchor_time'] = now logging.info(f"{mac} ({person['hostname']}): ABSENT → PRESENT (cert={certainty:.2f})") emit_event(mac, 'arrived', person) elif person['state'] == 'PRESENT': # Update anchor if recent strong signal if certainty >= ANCHOR_THRESHOLD: person['anchor_time'] = now # PRESENT → TRANSITIONING_OUT (anchor expired) if person['anchor_time'] and (now - person['anchor_time']) > PRESENCE_ANCHOR_MINUTES * 60: person['state'] = 'TRANSITIONING_OUT' person['transitioning_start'] = now logging.info(f"{mac} ({person['hostname']}): PRESENT → TRANSITIONING_OUT (anchor expired)") elif person['state'] == 'TRANSITIONING_OUT': # Check for re-entry if certainty >= ANCHOR_THRESHOLD: person['state'] = 'PRESENT' person['anchor_time'] = now logging.info(f"{mac} ({person['hostname']}): TRANSITIONING_OUT → PRESENT (re-entry)") # TRANSITIONING_OUT → ABSENT (timeout) if (now - person.get('transitioning_start', now)) > TRANSITION_MINUTES * 60: person['state'] = 'ABSENT' person['session_count'] = person.get('session_count', 1) + 1 person['total_minutes'] += (person['last_seen'] - person['first_seen']) / 60.0 logging.info(f"{mac} ({person['hostname']}): TRANSITIONING_OUT → ABSENT (timeout)") emit_event(mac, 'departed', person) def state_machine_thread(interval: float) -> None: """Periodic state machine tick.""" while not shutdown_event.is_set(): try: state_machine_tick() shutdown_event.wait(interval) except Exception as e: logging.error(f"State machine error: {e}") def parse_mdns_packet(data: bytes, src_ip: str) -> list: """ Parse raw mDNS packet and extract (hostname, signal_type, certainty). Returns list of (hostname, signal_type) tuples. """ signals = [] try: if len(data) < 12: return signals # DNS header: ID(2), Flags(2), QD(2), AN(2), NS(2), AR(2) = 12 bytes qd_count = struct.unpack('!H', data[4:6])[0] an_count = struct.unpack('!H', data[6:8])[0] # Skip questions offset = 12 for _ in range(qd_count): while offset < len(data) and data[offset] != 0: offset += data[offset] + 1 if offset < len(data): offset += 5 # null + type(2) + class(2) # Parse answers (RRs) for _ in range(an_count): if offset >= len(data): break # Parse name (skip) while offset < len(data) and data[offset] != 0: if data[offset] & 0xc0: # Compression pointer offset += 2 break else: offset += data[offset] + 1 if offset < len(data): offset += 1 # null terminator if offset + 10 > len(data): break rrtype = struct.unpack('!H', data[offset:offset+2])[0] rrclass = struct.unpack('!H', data[offset+2:offset+4])[0] rdlen = struct.unpack('!H', data[offset+8:offset+10])[0] rdata_offset = offset + 10 if rdata_offset + rdlen > len(data): break # Extract signals from PTR (12), A (1), SRV (33) if rrtype == 12: # PTR # PTR points to a hostname try: name_bytes = data[rdata_offset:rdata_offset+rdlen] hostname = parse_dns_name(name_bytes, data) if hostname and '_services._dns-sd._udp.local' in hostname.lower(): signals.append(('service_ptr', 'mdns')) except Exception: pass elif rrtype == 1: # A record if rdlen >= 4: signals.append(('a_record', 'mdns')) elif rrtype == 33: # SRV signals.append(('srv_record', 'mdns')) offset = rdata_offset + rdlen return signals except Exception as e: logging.debug(f"mDNS parse error: {e}") return signals def parse_dns_name(data: bytes, full_packet: bytes) -> str: """Parse DNS name from packet data with pointer support.""" try: parts = [] offset = 0 while offset < len(data): length = data[offset] if length == 0: break if length & 0xc0: # Compression pointer break offset += 1 if offset + length > len(data): break parts.append(data[offset:offset+length].decode('utf-8', errors='ignore')) offset += length return '.'.join(parts) except Exception: return "" def mdns_listener_thread(iface: str) -> None: """Listen for mDNS packets on 224.0.0.251:5353.""" try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Bind to MDNS port s.bind(('', MDNS_PORT)) # Join multicast group mreq = socket.inet_aton(MDNS_MULTICAST_ADDR) + socket.inet_aton('0.0.0.0') s.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) logging.info(f"mDNS listener started on {MDNS_MULTICAST_ADDR}:{MDNS_PORT}") while not shutdown_event.is_set(): try: s.settimeout(5.0) data, (src_ip, src_port) = s.recvfrom(4096) # Parse packet signals = parse_mdns_packet(data, src_ip) if signals: # Look up MAC from ARP cache arp_map = read_arp_cache() mac = arp_map.get(src_ip) if mac: # Extract hostname if possible hostname = f"mDNS_{src_ip}" record_signal(mac, hostname, 'mdns', MDNS_CERTAINTY) logging.debug(f"mDNS signal: {mac} from {src_ip}") except socket.timeout: pass except Exception as e: logging.error(f"mDNS listener error: {e}") time.sleep(1) except Exception as e: logging.error(f"Failed to start mDNS listener: {e}") def netlink_watcher_thread(iface: str) -> None: """Watch Netlink RTM_NEWNEIGH and RTM_DELNEIGH events.""" try: s = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, NETLINK_ROUTE) s.bind((os.getpid(), RTMGRP_NEIGH)) logging.info("Netlink neighbor watcher started") while not shutdown_event.is_set(): try: s.settimeout(5.0) data = s.recv(65535) parse_netlink(data) except socket.timeout: pass except Exception as e: logging.error(f"Netlink watcher error: {e}") time.sleep(1) except Exception as e: logging.error(f"Failed to start Netlink watcher: {e}") def parse_netlink(data: bytes) -> None: """Parse Netlink messages and extract ndmsg 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: logging.debug(f"Netlink parse error: {e}") def parse_ndmsg(data: bytes, msg_type: int) -> None: """Parse ndmsg (neighbor discovery message) and extract MAC/IP.""" try: if len(data) < 12: return # ndmsg: family(1), pad(1), ifindex(2), state(2), flags(1), type(1) family = data[0] if family not in (2, 10): # AF_INET (2) or AF_INET6 (10) return # Parse attributes (RTA_*) offset = 12 mac = None ip = None while offset < len(data): if offset + 4 > len(data): break rta_len = struct.unpack('!H', data[offset:offset+2])[0] rta_type = struct.unpack('!H', data[offset+2:offset+4])[0] if rta_len < 4: break payload_len = rta_len - 4 payload = data[offset+4:offset+4+payload_len] # RTA_DST (1) = IP, RTA_LLADDR (2) = MAC if rta_type == 1 and family == 2: # IPv4 if payload_len == 4: ip = '.'.join(str(b) for b in payload) elif rta_type == 2 and payload_len == 6: # MAC mac = ':'.join(f'{b:02x}' for b in payload) offset += (rta_len + 3) & ~3 if msg_type == RTM_NEWNEIGH and mac and ip: record_signal(mac, "", 'arp', ARP_CERTAINTY) logging.debug(f"ARP signal: {mac} ({ip})") elif msg_type == RTM_DELNEIGH and mac: if _check_flap(mac): logging.debug(f"Skipping RTM_DELNEIGH for {mac} (flap detected)") else: logging.debug(f"ARP departure candidate: {mac}") except Exception as e: logging.debug(f"ndmsg parse error: {e}") def init_db(db_path: str) -> None: """Initialize SQLite database with schema.""" try: conn = sqlite3.connect(db_path, timeout=10) cursor = conn.cursor() cursor.execute("PRAGMA journal_mode=WAL") # Load schema from file if it exists schema_file = Path(__file__).parent / "presence_schema.sql" if schema_file.exists(): schema = schema_file.read_text() cursor.executescript(schema) else: # Create inline cursor.execute(""" CREATE TABLE IF NOT EXISTS persons ( mac TEXT PRIMARY KEY, hostname TEXT, vendor TEXT, first_seen REAL NOT NULL, last_seen REAL NOT NULL, state TEXT NOT NULL DEFAULT 'ABSENT', certainty REAL NOT NULL DEFAULT 0.0, session_count INTEGER NOT NULL DEFAULT 1, total_minutes REAL NOT NULL DEFAULT 0.0 ) """) cursor.execute(""" CREATE TABLE IF NOT EXISTS signals ( id INTEGER PRIMARY KEY AUTOINCREMENT, mac TEXT NOT NULL, signal_type TEXT NOT NULL, certainty REAL NOT NULL, observed_at REAL NOT NULL ) """) cursor.execute(""" CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, mac TEXT NOT NULL, hostname TEXT, event_type TEXT NOT NULL, certainty REAL NOT NULL, timestamp REAL NOT NULL ) """) cursor.execute("CREATE INDEX IF NOT EXISTS idx_signals_mac ON signals(mac)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_signals_mac_time ON signals(mac, observed_at)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_events_mac ON events(mac)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp)") conn.commit() conn.close() logging.info(f"Database initialized: {db_path}") except Exception as e: logging.error(f"Failed to initialize database: {e}") def db_batch_writer_thread(db_path: str, interval: float) -> None: """Periodically write batched signals and events to SQLite.""" while not shutdown_event.is_set(): try: shutdown_event.wait(interval) # Flush signals with signals_queue_lock: if signals_queue: conn = sqlite3.connect(db_path, timeout=10) cursor = conn.cursor() cursor.executemany( "INSERT INTO signals (mac, signal_type, certainty, observed_at) VALUES (?, ?, ?, ?)", signals_queue ) conn.commit() conn.close() logging.debug(f"Wrote {len(signals_queue)} signals to DB") signals_queue.clear() # Flush events with events_queue_lock: if events_queue: conn = sqlite3.connect(db_path, timeout=10) cursor = conn.cursor() for mac, event_type, certainty, timestamp, _ in events_queue: cursor.execute( """INSERT INTO events (mac, hostname, event_type, certainty, timestamp) VALUES (?, ?, ?, ?, ?)""", (mac, "", event_type, certainty, timestamp) ) conn.commit() conn.close() logging.debug(f"Wrote {len(events_queue)} events to DB") events_queue.clear() except Exception as e: logging.error(f"DB batch writer error: {e}") def send_output(mode: str, output_file: str, matrix_webhook: str, event: dict) -> None: """Emit event in specified output mode.""" try: json_str = json.dumps(event) if mode == 'stdout': print(json_str) elif mode == 'file' and output_file: with open(output_file, 'a') as f: f.write(json_str + '\n') elif mode == 'matrix' and matrix_webhook: payload = json.dumps({'text': json_str}).encode('utf-8') req = urllib.request.Request(matrix_webhook, data=payload) req.add_header('Content-Type', 'application/json') urllib.request.urlopen(req, timeout=5) except Exception as e: logging.error(f"Output error ({mode}): {e}") def output_thread(mode: str, output_file: str, matrix_webhook: str) -> None: """Emit queued events.""" while not shutdown_event.is_set(): try: with events_queue_lock: if events_queue: for mac, event_type, certainty, timestamp, event in list(events_queue): send_output(mode, output_file, matrix_webhook, event) time.sleep(0.5) except Exception as e: logging.error(f"Output thread error: {e}") def handle_shutdown(signum, frame): """Handle SIGTERM/SIGINT.""" logging.info(f"Received signal {signum}, shutting down...") shutdown_event.set() def main(): parser = argparse.ArgumentParser( description='WiFi person presence intelligence daemon (Phase 1)' ) parser.add_argument('--iface', default=None, help='Network interface (auto-detect if not set)') parser.add_argument('--db', default='/tmp/presence.db', help='SQLite database path') parser.add_argument('--output', choices=['stdout', 'file', 'matrix'], default='stdout', help='Output mode') parser.add_argument('--output-file', help='File path for file output mode') parser.add_argument('--matrix-webhook', help='Matrix webhook URL for matrix output mode') parser.add_argument('--log-level', choices=['DEBUG', 'INFO', 'WARNING'], default='INFO', help='Log level') parser.add_argument('--log-file', help='Log file path (default: stderr)') args = parser.parse_args() setup_logging(args.log_file, args.log_level) logging.info("presence_daemon starting...") # Determine interface iface = args.iface or get_primary_interface() logging.info(f"Using interface: {iface}") # Initialize database init_db(args.db) # Seed infrastructure IPs seed_infrastructure_ips(iface) # Setup signal handlers signal.signal(signal.SIGTERM, handle_shutdown) signal.signal(signal.SIGINT, handle_shutdown) # Start threads threads = [] t_mdns = threading.Thread(target=mdns_listener_thread, args=(iface,), daemon=True) threads.append(t_mdns) t_mdns.start() t_netlink = threading.Thread(target=netlink_watcher_thread, args=(iface,), daemon=True) threads.append(t_netlink) t_netlink.start() t_state = threading.Thread(target=state_machine_thread, args=(STATE_TICK_INTERVAL,), daemon=True) threads.append(t_state) t_state.start() t_db = threading.Thread(target=db_batch_writer_thread, args=(args.db, DB_BATCH_INTERVAL), daemon=True) threads.append(t_db) t_db.start() t_output = threading.Thread(target=output_thread, args=(args.output, args.output_file, args.matrix_webhook), daemon=True) threads.append(t_output) t_output.start() logging.info("All threads started. Monitoring presence...") # Block until shutdown try: while not shutdown_event.is_set(): time.sleep(1) except KeyboardInterrupt: pass logging.info("Flushing final batch writes...") time.sleep(DB_BATCH_INTERVAL + 1) logging.info("presence_daemon stopped") if __name__ == '__main__': main()