AP ARP proxy sends REPLY frames with the disconnected client's MAC as
the Ethernet source. Treating those as genuine device signals kept
last_seen fresh, preventing the watchdog from detecting departure.
Only ARP REQUEST (opcode 1) frames originate from the device itself.
Skip _update_last_seen() for opcode 2 (REPLY) frames.
ARP proxy on WiFi APs causes the kernel neighbor table to show devices
as REACHABLE even after they disconnect. This made last_seen refresh
continuously from on_arrival() (netlink path), preventing the watchdog
from ever detecting departure.
Two fixes:
1. Remove _update_last_seen() from on_arrival() — netlink events are
not reliable as a genuine-traffic source on ARP-proxy networks.
last_seen is now only updated by the raw packet capture path (ARP
requests, mDNS, DHCP) where src_mac must be the device itself.
2. Watchdog now covers all is_personal_device() entries in known_devices,
not just the personal_devices enrollment set. device_labels entries
(like named phones) were previously unmonitored by the watchdog.
Kernel fires RTM_NEWNEIGH for NUD_STALE, NUD_DELAY, and NUD_PROBE states
as it probes a potentially-departed device. Each probe was calling on_arrival()
which refreshed last_seen, preventing the watchdog from ever detecting departure.
Only NUD_REACHABLE confirms a device is actually on the network.
Garmin and Fitbit wearables use globally-assigned OUI MACs (no private addressing)
so the LAA bit check misses them. Added their OUIs to MOBILE_DEVICE_OUIS.
Consolidated all three detection signals into is_personal_device():
1. Explicit config (device_labels, personal_devices)
2. OUI table (phones + Garmin/Fitbit)
3. LAA bit (iOS 14+/Android 10+ private MACs)
Occupancy _is_personal now delegates to is_personal_device instead of duplicating logic.
Modern phones use private/random MACs (iOS 14+, Android 10+) which
always have the locally-administered bit set (bit 1 of first octet).
TVs, gateways, and smart home devices use globally-assigned OUI MACs
where this bit is clear.
No configuration needed — any LAA MAC counts toward occupancy.
Explicit device_labels/personal_devices still work as fallback.
Previously counted every non-infrastructure device toward OCCUPIED state,
causing TVs and other stationary equipment to falsely indicate occupancy.
Now only MACs in device_labels or personal_devices count. Everything else
is observed but ignored for presence purposes.
device_labels keys are normalized (no colons, uppercase) but known_devices keys
are colon-lowercase. device_labels.get(mac) always returned None.
Also extend label display to arrival and departure alerts — if a MAC has a
configured label, show that instead of vendor/hostname.
NET_ALERTER_DEVICE_LABELS=MAC=Name,MAC=Name maps known devices to
friendly names. OCCUPIED alerts now show which devices are present.
Falls back to vendor string then MAC tail if no label configured.
Previously the service would suppress the startup OCCUPIED alert, meaning
if phones were already present when the service started, no Matrix alert
ever fired until a departure+return cycle. Users never received confirmation
the sensor was working.
Changes:
1. mDNS as separate signal category: Map 'mdns' signal_type to its own signal_category (not grouped with 'wifi'). Enrollment now triggers when EITHER (a) len(signal_types) >= 2 OR (b) 'mdns' in signal_types.
2. Active probes on device discovery: When new identity is created, fire non-blocking probes to collect additional signals. Uses arping or ping (no new deps) to probe IP if available. Runs in daemon thread to avoid stalling correlation loop.
This allows devices announcing via mDNS to be enrolled immediately without waiting for a second signal type, while still collecting additional signals for richer device profiles.
Strategy 0 now checks if a MAC already exists in any identity's wifi_macs/ble_macs
sets before creating a new record. Prevents duplicate identity creation on repeated
mDNS/ARP signals.
Strategy 3 now conditionally updates existing records instead of unconditionally
overwriting them. Preserves signal_count and signal_types accumulation needed for
auto-enrollment (2+ signal types).
Fixes issue where devices seen only via mDNS/ARP never reached enrollment threshold.
Wrap BleakScanner context manager in try-except to detect adapter
initialization errors. If 'No Bluetooth adapters found' or similar, log
warning and set shutdown event to trigger graceful exit with code 0.
Prevents systemd restart loop on hardware without BT adapter (e.g. OPi).
P1 Fixes:
- Signal count enrollment logic: Changed from broken signal_count increment to tracking distinct signal types (BLE vs WiFi) using a set. Device enrolls when len(signal_types) >= 2, ensuring multi-source correlation.
- DNS mDNS pointer endianness: Added bounds check to prevent out-of-bounds reads when following DNS compression pointers. Checks pointer_offset < offset and pointer_offset < len(payload) before recursing.
- Nested RLock fragility: Refactored enrollment callback to not acquire lock (caller _ingest_signal holds it). Renamed _on_device_enrolled() to _fire_enrollment_callback() and removed lock acquisition.
P2 Fixes:
- BLE Handoff parsing: Implemented full HCI packet parsing to extract Apple Company ID (0x004C), Handoff message type (0x0C), and sequence number (bytes 4-5, big-endian). Calls _ingest_signal() with handoff_seq parameter.
- DNS record count overflow: Capped total_records at 1000 to prevent unbounded loop DoS on crafted mDNS packets.
- device_store unbounded growth: Added simple eviction when store exceeds 500 entries - evicts 100 oldest by first_seen timestamp. No LRU needed for MVP.
All 40 existing tests continue to pass.
Track devices across MAC rotations using BLE Handoff sequence numbers
and DHCP Option 55 fingerprinting. Auto-enroll personal devices when
seen on 2+ independent signal types. Handle iOS MAC rotation silently.
Confidence-based departure only fires for enrolled devices.
Changes:
- New device_store dict for stable device identity tracking (keyed by
handoff seq anchor or DHCP fingerprint hash)
- _dhcp_fingerprint_hash() computes stable DHCP Option 55 fingerprint
- _create_identity_record() initializes device records
- _on_device_enrolled() triggers silent enrollment when signal_count >= 2
- _correlate_or_create_identity() finds or creates identities via BLE seq
or DHCP fingerprint, handles MAC rotation detection
- _ingest_signal() ingests signals from multiple sources (BLE, DHCP, ARP,
mDNS) and triggers enrollment when criteria met
- ble_sniffer() thread for Apple Continuity Protocol parsing (HCI socket +
hcidump fallback); graceful failure on missing hardware
- Modified parse_frame() to extract DHCP Option 55 and ingest signals for
ARP, mDNS, DHCP packets
- Updated main() to start BLE sniffer thread and log device_store stats
Implement passive observation tracking for iOS devices:
- Add last_seen dict and lock for tracking passive frame observations
- Parse ARP frames (ethertype 0x0806) for MAC observation
- Parse mDNS frames (UDP port 5353) for device name auto-discovery
- Rename parse_dhcp to parse_frame to reflect broader scope
- Add personal_names set for device name matching
- Parse NET_ALERTER_PERSONAL_NAMES env var (comma-separated device names)
- Add mDNS DNS message parsing to extract PTR/SRV/A record names
- Auto-add MACs to personal_devices when mDNS name matches personal_names
- Call _update_last_seen on all frame types (ARP, mDNS, DHCP)
- Add personal_watchdog thread to fire on_departure after WATCHDOG_TIMEOUT_SEC
- Add NET_ALERTER_WATCHDOG_TIMEOUT env config (default 900s)
- Fix load_personal_macs_from_env logging to show counts instead of MACs
Changes are minimal and focused:
- No socket changes (existing AF_PACKET socket already sees all frames)
- No new dependencies (stdlib only)
- Deadlock prevention (watchdog does not hold locks when calling on_departure)
- Backward compatible (personal_names optional, watchdog is passive)
on_departure() holds known_lock when calling _check_churn(). When churn
threshold is hit, the nested `with known_lock:` inside _check_churn tried
to reacquire an already-held non-reentrant lock → permanent deadlock.
This killed the production process on the OPi Zero 3 after ~15 hours of
runtime on 2026-04-12 at ~02:12 UTC. infrastructure_ips.add() is safe
without the inner lock since the caller already holds known_lock.
Also: reset _churn_tracker between tests to prevent cross-test state
accumulation that triggered the churn threshold in test isolation.
Fix#605: Remove MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, MATRIX_ROOM_ID from module-level globals.
Credentials are now read at call time in send_alert() via _read_env_value() to minimize
credential lifetime in process memory.
Fix#606: Update deploy.sh to use Infisical creds CLI instead of .secrets file.
Fetches NET_ALERTER_PASSWORD from matrix folder in Infisical vault, then generates
access token at deploy time.
All 16 net_alerter tests pass when run individually or in groups.
Two P1 bugs fixed:
1. Timer cancel deadlock: timer.cancel() was called while holding locks, which would deadlock if the callback was running/paused waiting for those same locks. Fixed by extracting timers outside of all locks before calling cancel().
2. MAC validation: _normalize_mac() now validates format with regex and returns empty string for invalid MACs. Callers (load_personal_macs_from_env, is_personal_device) skip empty results and log warnings.
All 16 tests pass.
- P1-1: Fix lock ordering deadlock by establishing canonical order (known_lock → departure_lock → occupancy_lock). Refactor on_arrival() to acquire locks in correct order. Fix send_departure_alert() callback to hold occupancy_lock while updating state.
- P1-2: Add _normalize_mac() helper to strip colons/hyphens, apply to load_personal_macs_from_env() and is_personal_device() for consistent MAC comparison.
- P1-3: Remove occupancy_lock acquisition from _update_occupancy_state_unlocked() - callers must hold lock. Update all callers to acquire occupancy_lock before calling.
All individual tests pass. Fixes allow on_arrival() and timer callbacks to execute without deadlock.
- Add MOBILE_DEVICE_OUIS set with 55+ OUI prefixes for personal devices
- Add is_personal_device() function to detect personal devices by OUI or manual config
- Add load_personal_macs_from_env() to parse NET_ALERTER_PERSONAL_MACS
- Add location_occupancy state tracking (VACANT/OCCUPIED/UNKNOWN)
- Add update_occupancy_state() function with thread-safe alerts on transitions
- Add _update_occupancy_state_unlocked() helper to prevent deadlock
- Fix deadlock in on_arrival() by using unlocked version when holding known_lock
- Update test suite with 5 new occupancy tests
- Fix test assertions to exclude occupancy alerts for device arrival tests
Tests now call cleanup_flap_state() at the start of each test to properly
reset the interface flap detection state (_flap_window, _interface_recovering,
_recovery_timer) between tests, preventing state carryover failures.
- Fix 1: Interface flap detection suppresses departure storms when wlan0 briefly loses association and triggers simultaneous RTM_DELNEIGH events for all cached neighbors
- Tracks 3-second window of departure MACs
- Locks recovery state for 60 seconds if 3+ departures occur in rapid succession
- Prevents false positive alerts during interface recovery
- Fix 2: Additional infrastructure IPs via NET_ALERTER_INFRA_IPS env var
- Allows operator to suppress alerts for devices like access points that cycle regularly
- Comma-separated list of IP addresses to be seeded as infrastructure at startup
- Example: NET_ALERTER_INFRA_IPS=192.168.1.214,192.168.1.220
Addresses DevTrack #467 (net_alerter false positive alerts)
- Reject dash/colon MAC-format device names (e.g. 45-48-39-7F-73-C7)
from open mode — were slipping past is_generic_name()
- Add exponential backoff (1s, 2s) on Matrix 429/5xx, max 3 attempts —
startup burst was rate-limiting and dropping alerts
- Commit active scanner flag (scanning_mode=active) that was in working
tree but not in prior commit 8686eb5 despite the commit message
- Add 3 tests: MAC format filter, 429 retry success, 429 give-up
Fixes DevTrack #518
Passive mode fails on Debian bookworm bleak 0.20.2 without or_patterns filter.
Active mode is functionally equivalent for building-level presence detection
and actually yields more complete advertisement data including device names.
When on_departure popped the device from known_devices, rapid RTM_NEWNEIGH
events during ARP cache rebuild each triggered on_arrival as if the device
was brand new, flooding the Matrix room with 5-10+ duplicate ARRIVED alerts.
Fix: keep device in known_devices with departing=True flag. on_arrival
silently cancels pending departure timers and returns without alerting when
it sees this flag. Timer callback checks flag before sending the alert and
only pops the device when departure is confirmed after 15 minutes.
- Bug: device departs → timer starts, but last_departed_time not set yet
If device re-arrives before timer fires, timer never cancelled
Result: false DEPARTED alert sent after re-arrival debounce expires
- Fix: Cancel any pending departure timer immediately on re-arrival,
before checking last_departed_time
- Test: Added regression test that verifies timer is cancelled when
device re-arrives before 15-min debounce expires, preventing false alerts
- All 10 tests passing
- lookup_oui() now tries SQLite first (/opt/net_alerter/oui.db)
- Falls back to inline OUI_DICT if DB missing or query fails
- No network calls, local SQLite reads only
- Debug-level logging for failures (no output noise)
- Infrastructure IPs (gateway, self, broadcast) silently ignored in
on_arrival() and on_departure(); seeded at startup from 'ip route'
- 15-minute departure debounce: alert only fires if device is still
absent after 900s, eliminating ARP cache flush false positives
- 5-minute re-arrival suppression: ARRIVED alert skipped when device
returns within 5 min of a recorded departure (covers interface flap)
- DHCP renewal dedup: known device re-announcing within 30 min skips
ARRIVED alert
- Tests added for all four suppression paths (9 total, all passing)
- Replace broken OUI file lookup with inline dict of 200+ common vendors (works on Armbian)
- Fix hostname/IP duplication when reverse DNS fails (show IP once, not twice)
- Add format_arrival() and format_departure() helpers for consistent alert formatting
- Update on_arrival/on_departure to use new formatting functions
- Add comprehensive tests for hostname dedup and OUI lookup