diff --git a/net_alerter/net_alerter.py b/net_alerter/net_alerter.py index 542852f..a6d54c6 100644 --- a/net_alerter/net_alerter.py +++ b/net_alerter/net_alerter.py @@ -365,17 +365,23 @@ def setup_logging(): return logger +def _normalize_mac(mac: str) -> str: + """Normalize MAC address: remove colons/hyphens, uppercase.""" + return mac.replace(":", "").replace("-", "").upper() + + def load_personal_macs_from_env() -> None: """Parse NET_ALERTER_PERSONAL_MACS env var and add to personal_devices set.""" macs_str = os.getenv("NET_ALERTER_PERSONAL_MACS", "") if not macs_str.strip(): return - macs = [m.strip().upper() for m in macs_str.split(",") if m.strip()] + macs = [m.strip() for m in macs_str.split(",") if m.strip()] with personal_lock: for mac in macs: - personal_devices.add(mac) - logging.info(f"Added personal device MAC: {mac}") + normalized = _normalize_mac(mac) + personal_devices.add(normalized) + logging.info(f"Added personal device MAC: {normalized}") def load_env(): @@ -418,12 +424,12 @@ def is_personal_device(mac: str) -> bool: Returns True if device OUI is in MOBILE_DEVICE_OUIS OR mac is in personal_devices set. """ # Normalize MAC - mac_upper = mac.replace(":", "").upper() - oui = mac_upper[:6] + mac_normalized = _normalize_mac(mac) + oui = mac_normalized[:6] # Check personal_devices set first (manually configured) with personal_lock: - if mac_upper in personal_devices: + if mac_normalized in personal_devices: return True # Check against MOBILE_DEVICE_OUIS @@ -569,41 +575,41 @@ def format_departure(hostname: str, ip: str, vendor: str, mac: str, duration: st def _update_occupancy_state_unlocked() -> None: """ Internal: Update location occupancy state. - Assumes known_lock is already held by caller. + Assumes known_lock AND occupancy_lock are already held by caller. """ global location_occupancy - with occupancy_lock: - # Count active personal devices (in known_devices, not marked departing) - active_personal_count = 0 - for mac, dev in known_devices.items(): - if not dev.get('departing', False) and is_personal_device(mac): - active_personal_count += 1 + # Count active personal devices (in known_devices, not marked departing) + active_personal_count = 0 + for mac, dev in known_devices.items(): + if not dev.get('departing', False) and is_personal_device(mac): + active_personal_count += 1 - # Determine new occupancy state - new_occupancy = "OCCUPIED" if active_personal_count > 0 else "VACANT" + # Determine new occupancy state + new_occupancy = "OCCUPIED" if active_personal_count > 0 else "VACANT" - # Fire alert on transitions (but not UNKNOWN → OCCUPIED on startup) - if new_occupancy != location_occupancy: - if not (location_occupancy == "UNKNOWN" and new_occupancy == "OCCUPIED"): - msg = f"[NET] Location: {new_occupancy}" - logging.info(msg) - send_alert(msg) - else: - logging.debug(f"Suppressing startup occupancy alert: UNKNOWN → OCCUPIED") + # Fire alert on transitions (but not UNKNOWN → OCCUPIED on startup) + if new_occupancy != location_occupancy: + if not (location_occupancy == "UNKNOWN" and new_occupancy == "OCCUPIED"): + msg = f"[NET] Location: {new_occupancy}" + logging.info(msg) + send_alert(msg) + else: + logging.debug(f"Suppressing startup occupancy alert: UNKNOWN → OCCUPIED") - location_occupancy = new_occupancy - logging.info(f"Location occupancy updated to {location_occupancy} ({active_personal_count} personal devices active)") + location_occupancy = new_occupancy + logging.info(f"Location occupancy updated to {location_occupancy} ({active_personal_count} personal devices active)") def update_occupancy_state() -> None: """ Update location occupancy state based on presence of personal devices. Fires Matrix alerts on VACANT/OCCUPIED transitions (not on UNKNOWN transitions). - Thread-safe: acquires known_lock before checking devices. + Thread-safe: acquires locks in order: known_lock, then occupancy_lock. """ with known_lock: - _update_occupancy_state_unlocked() + with occupancy_lock: + _update_occupancy_state_unlocked() def on_arrival(mac: str, ip: str, hostname: str = "") -> None: @@ -622,34 +628,33 @@ def on_arrival(mac: str, ip: str, hostname: str = "") -> None: hostname = hostname or ip now = time.time() - # Silent re-arrival if device marked departing (rapid flap recovery) - with departure_lock: - if mac in known_devices and known_devices[mac].get('departing'): - # Device returned during departure window — cancel timer, clear flag, no alert + # Lock order: known_lock, then departure_lock, then occupancy_lock + with known_lock: + with departure_lock: + # Check if device marked departing (rapid flap recovery) + if mac in known_devices and known_devices[mac].get('departing'): + # Device returned during departure window — cancel timer, clear flag, no alert + if mac in departure_timers: + departure_timers[mac].cancel() + del departure_timers[mac] + known_devices[mac]['departing'] = False + with occupancy_lock: + _update_occupancy_state_unlocked() + last_departed_time.pop(mac, None) + logging.debug(f"Device {mac} re-arrived during departure window — suppressed alert") + return + + # ALWAYS cancel any pending departure timer when device re-arrives if mac in departure_timers: departure_timers[mac].cancel() del departure_timers[mac] - with known_lock: - known_devices[mac]['departing'] = False - _update_occupancy_state_unlocked() - last_departed_time.pop(mac, None) - logging.debug(f"Device {mac} re-arrived during departure window — suppressed alert") - return + logging.debug(f"Cancelled departure timer for {mac} — device re-arrived before debounce expired") - # Check re-arrival suppression BEFORE acquiring lock (avoid nested locks) - with departure_lock: - # ALWAYS cancel any pending departure timer when device re-arrives - if mac in departure_timers: - departure_timers[mac].cancel() - del departure_timers[mac] - logging.debug(f"Cancelled departure timer for {mac} — device re-arrived before debounce expired") - - if mac in last_departed_time: - time_since_departure = now - last_departed_time[mac] - if time_since_departure < 300: # 5 min - logging.debug(f"Suppressing re-arrival alert for {mac} (departed {int(time_since_departure)}s ago)") - # Re-add to known_devices to track presence - with known_lock: + if mac in last_departed_time: + time_since_departure = now - last_departed_time[mac] + if time_since_departure < 300: # 5 min + logging.debug(f"Suppressing re-arrival alert for {mac} (departed {int(time_since_departure)}s ago)") + # Re-add to known_devices to track presence if mac not in known_devices: vendor = lookup_oui(mac) known_devices[mac] = { @@ -661,10 +666,11 @@ def on_arrival(mac: str, ip: str, hostname: str = "") -> None: } else: known_devices[mac]['last_seen'] = now - _update_occupancy_state_unlocked() - return + with occupancy_lock: + _update_occupancy_state_unlocked() + return - with known_lock: + # Still inside known_lock, but not inside departure_lock now if mac in known_devices: # Device is already known dev = known_devices[mac] @@ -673,11 +679,13 @@ def on_arrival(mac: str, ip: str, hostname: str = "") -> None: # DHCP renewal dedup: if last_seen < 30 min, skip alert if now - dev['first_seen'] < 1800: # 30 min logging.debug(f"Skipping DHCP renewal alert for {mac} (seen {int(now - dev['first_seen'])}s ago)") - _update_occupancy_state_unlocked() + with occupancy_lock: + _update_occupancy_state_unlocked() return # Not a re-arrival, update last_seen and don't alert (already known) - _update_occupancy_state_unlocked() + with occupancy_lock: + _update_occupancy_state_unlocked() return # New device @@ -731,27 +739,27 @@ def on_departure(mac: str) -> None: def send_departure_alert(): """Callback to send alert after debounce expires.""" - # Check if device re-arrived (departing flag cleared by on_arrival) + # Lock order: known_lock, then departure_lock, then occupancy_lock with known_lock: if not known_devices.get(mac, {}).get('departing'): return # device re-arrived, departure was cancelled # Device still departed — send alert and remove from tracking known_devices.pop(mac, None) + with departure_lock: + last_departed_time[mac] = time.time() + if mac in departure_timers: + del departure_timers[mac] + + # Update occupancy state after device removal (still inside known_lock) + with occupancy_lock: + _update_occupancy_state_unlocked() + duration = fmt_duration(time.time() - dev_copy['first_seen']) msg = format_departure(dev_copy['hostname'], dev_copy['ip'], dev_copy['vendor'] or "unknown", mac, duration) logging.info(msg) send_alert(msg) - # Record departure time for re-arrival suppression - with departure_lock: - last_departed_time[mac] = time.time() - if mac in departure_timers: - del departure_timers[mac] - - # Update occupancy state after device removal - update_occupancy_state() - timer = threading.Timer(900.0, send_departure_alert) # 15 min = 900 sec timer.daemon = True timer.name = f'departure-debounce-{mac}'