Fix three presence monitoring bugs: Tailscale exclusion, mDNS arrival, ARP personal device arrival

Tailscale peers (travel router b6:cc) were kept perpetually REACHABLE in the
kernel ARP cache by OPi's WireGuard keepalives, refreshing last_seen and
blocking departure alerts. seed_tailscale_peers() excludes their MACs.

mDNS was only refreshing the watchdog timer for already-known devices. Apple
devices send mDNS on WiFi join as the primary signal — now calls on_arrival()
so first-seen mDNS triggers ARRIVED alert and occupancy update.

ARP opcode 1 for personal devices never called on_arrival(), so devices that
joined without DHCP (c6:37 My iPhone at 01:25) got no alert. Now fires
on_arrival() for personal devices not yet in known_devices.
This commit is contained in:
Cobra
2026-04-14 21:51:02 -04:00
parent 54b389e767
commit 83894bf84d
+70 -1
View File
@@ -42,6 +42,11 @@ last_departed_time = {} # {mac: timestamp} when device was actually alerted as
last_seen = {} # {mac: float} — updated by passive ARP/mDNS capture
last_seen_lock = threading.Lock()
# Tailscale peer MACs — excluded from presence monitoring
# OPi's WireGuard session generates bidirectional L2 traffic that would
# otherwise keep peer MACs perpetually REACHABLE in the kernel ARP cache.
tailscale_peer_macs: set = set()
# Interface flap detection — suppresses departure storms
_flap_window = [] # [(timestamp, mac)]
_flap_lock = threading.Lock()
@@ -831,6 +836,48 @@ def seed_from_arp_cache():
logging.error(f"Failed to seed from ARP cache: {e}")
def seed_tailscale_peers() -> None:
"""Populate tailscale_peer_macs with MACs of active Tailscale peer endpoints.
OPi has a WireGuard session to each Tailscale peer. The peer's LAN IP is the
direct endpoint, so bidirectional L2 traffic keeps it REACHABLE in the kernel
ARP cache — generating ARP opcode 1 frames that would refresh last_seen and
falsely prevent departure alerts. Exclude them entirely.
"""
global tailscale_peer_macs
try:
result = subprocess.run(
['tailscale', 'status', '--json'],
capture_output=True, text=True, timeout=5
)
if result.returncode != 0:
logging.warning("tailscale status failed — no Tailscale peer exclusions")
return
status = json.loads(result.stdout)
peer_ips = set()
for peer in status.get('Peer', {}).values():
for ep in peer.get('Endpoints', []):
ip = ep.split(':')[0]
if ip:
peer_ips.add(ip)
if not peer_ips:
return
# Resolve peer IPs → MACs via kernel neighbor cache
neigh = Path('/proc/net/arp').read_text()
found = set()
for line in neigh.splitlines():
fields = line.split()
if len(fields) < 4:
continue
ip, _, flags, mac = fields[0], fields[1], fields[2], fields[3]
if ip in peer_ips and mac != '00:00:00:00:00:00' and flags != '0x0':
found.add(mac.lower())
tailscale_peer_macs = found
logging.info(f"Tailscale peer MACs excluded from presence monitoring: {found}")
except Exception as e:
logging.warning(f"seed_tailscale_peers failed: {e}")
def send_alert(msg: str) -> None:
"""Send alert to Matrix room.
@@ -1557,7 +1604,19 @@ def parse_frame(data: bytes) -> None:
# ARP replies (opcode 2) may be AP ARP proxy forgeries: the AP answers
# on behalf of disconnected clients with the client MAC as Ethernet source.
if len(data) >= 22 and struct.unpack('!H', data[20:22])[0] == 1:
_update_last_seen(src_mac)
# Tailscale peers: OPi has a live WireGuard session to each peer's LAN
# endpoint. Bidirectional keepalives keep the peer MAC REACHABLE in the
# kernel ARP cache indefinitely — exclude to prevent false presence.
if src_mac not in tailscale_peer_macs:
_update_last_seen(src_mac)
# Personal devices first seen via ARP get no DHCP/Netlink event.
# Fire on_arrival so they get ARRIVED alert and occupancy counts.
if is_personal_device(src_mac) and len(data) >= 32:
with known_lock:
already_known = src_mac in known_devices
if not already_known:
spa = socket.inet_ntoa(data[28:32])
on_arrival(src_mac, spa)
# Ingest ARP signal for multi-source device identity
_ingest_signal(src_mac, 'arp')
return
@@ -1582,6 +1641,15 @@ def parse_frame(data: bytes) -> None:
# Branch B: mDNS frame (IPv4 UDP dst port 5353)
if dst_port == 5353:
_update_last_seen(src_mac)
# mDNS is the primary arrival indicator for Apple devices — they send it
# on WiFi join before or instead of DHCP. Fire on_arrival so the device
# gets an ARRIVED alert and is counted in occupancy.
if len(data) >= 30:
src_ip = socket.inet_ntoa(data[26:30])
with known_lock:
already_known = src_mac in known_devices
if not already_known:
on_arrival(src_mac, src_ip)
# Ingest mDNS signal for multi-source device identity
_ingest_signal(src_mac, 'mdns')
# Parse mDNS DNS message for device names
@@ -1808,6 +1876,7 @@ def main() -> None:
logging.info(f"Net alerter starting on interface {iface}")
seed_infrastructure_ips(iface)
seed_tailscale_peers()
seed_from_arp_cache()
# Start monitor threads