Add Garmin/Fitbit OUIs; consolidate personal device detection into is_personal_device()

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.
This commit is contained in:
Cobra
2026-04-14 15:01:49 -04:00
parent 23c4f15df8
commit de61ce89e3
+20 -20
View File
@@ -116,6 +116,10 @@ MOBILE_DEVICE_OUIS = {
"001555", "001620", "001645", "001693", "0016B8", "001730", "0017F6", "001555", "001620", "001645", "001693", "0016B8", "001730", "0017F6",
"001822", "001888", "0018F8", "001913", "001985", "001A45", "001A92", "001822", "001888", "0018F8", "001913", "001985", "001A45", "001A92",
"001AFC", "001AFC",
# Garmin (GPS watches/wearables with WiFi — do not use private MACs)
"086698", "F8B5AB", "E87941", "1430C6", "801F02", "68F3B1", "283B96", "24DEC6",
# Fitbit (wearables with WiFi sync — do not use private MACs)
"E890AC", "88A70E", "E407AA", "A41566", "008B17",
} }
# Personal device tracking # Personal device tracking
@@ -728,26 +732,31 @@ def load_env():
def is_personal_device(mac: str) -> bool: def is_personal_device(mac: str) -> bool:
""" """
Check if MAC is a personal device (mobile OUI or manually configured). Check if MAC belongs to a phone or wearable.
Returns True if device OUI is in MOBILE_DEVICE_OUIS OR mac is in personal_devices set.
Three signals, checked in order:
1. Explicit config — device_labels or personal_devices (operator-configured)
2. OUI table — MOBILE_DEVICE_OUIS covers phones + Garmin/Fitbit (no private MACs)
3. LAA bit — locally-administered bit set = iOS 14+/Android 10+ private MAC = phone/watch
""" """
# Normalize MAC
mac_normalized = _normalize_mac(mac) mac_normalized = _normalize_mac(mac)
if not mac_normalized: # Skip invalid MACs if not mac_normalized:
return False return False
oui = mac_normalized[:6] oui = mac_normalized[:6]
# Check personal_devices set first (manually configured)
with personal_lock: with personal_lock:
if mac_normalized in personal_devices: if mac_normalized in personal_devices or mac_normalized in device_labels:
return True return True
# Check against MOBILE_DEVICE_OUIS
if oui in MOBILE_DEVICE_OUIS: if oui in MOBILE_DEVICE_OUIS:
return True return True
return False # Locally-administered bit: modern phones randomize MACs; TVs/infra don't
try:
return bool(int(mac_normalized[:2], 16) & 0x02)
except ValueError:
return False
def lookup_oui(mac: str) -> str: def lookup_oui(mac: str) -> str:
@@ -901,18 +910,9 @@ def _update_occupancy_state_unlocked() -> None:
""" """
global location_occupancy global location_occupancy
# Detect phones/wearables via LAA MAC (locally-administered bit set in first octet). # Delegate to is_personal_device: checks explicit config, OUI table (phones + Garmin/Fitbit),
# iOS 14+, Android 10+ all use private/random MACs — this bit is always set. # and LAA bit (iOS 14+/Android 10+ private MACs). No pre-config needed for modern phones.
# TVs, gateways, smart home devices use globally-assigned OUI MACs (bit clear). _is_personal = is_personal_device
# Explicit device_labels / personal_devices also qualify (older phones, pre-config).
def _is_personal(mac: str) -> bool:
n = _normalize_mac(mac)
if n in device_labels or n in personal_devices:
return True
try:
return bool(int(n[:2], 16) & 0x02) # locally-administered bit
except ValueError:
return False
active_enrolled_count = 0 active_enrolled_count = 0
for mac, dev in known_devices.items(): for mac, dev in known_devices.items():