Add Phase 1 WiFi presence daemon — mDNS + ARP fusion with exponential decay and presence anchor

This commit is contained in:
Cobra
2026-04-10 14:22:55 -04:00
parent 0994385f7c
commit f7460b0416
+853
View File
@@ -0,0 +1,853 @@
#!/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()