Add presence detection, person/vehicle AI detection, health monitoring

Task 1 — Presence: ping family phones, derive household state
(EMPTY/KIDS_HOME/ADULTS_HOME/ALL_HOME), configurable departure delay,
per-member roles, auto-arm actions via MQTT.

Task 2 — Detection: MobileNet-SSD v2 via OpenCV DNN for person/vehicle
classification. Vehicle color/size fingerprinting for known car matching.
Zone-based filtering per camera. Model download script.

Task 3 — Health: periodic disk/MQTT/subsystem checks, auto-prune oldest
non-starred recordings on disk pressure, daily digest builder.

126 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-04-03 00:06:45 -04:00
parent 8a65ac8c69
commit 8314a61815
19 changed files with 926 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
"""Tests for person and vehicle detection."""
import numpy as np
from vigilar.detection.person import Detection, PersonDetector
from vigilar.detection.vehicle import classify_dominant_color, classify_size
from vigilar.detection.zones import filter_detections_by_zone
class TestPersonDetector:
def test_detector_initializes_without_model(self):
detector = PersonDetector(model_path="nonexistent.pb", config_path="nonexistent.pbtxt")
assert not detector.is_loaded
def test_detect_returns_empty_when_not_loaded(self):
detector = PersonDetector(model_path="nonexistent.pb", config_path="nonexistent.pbtxt")
frame = np.zeros((480, 640, 3), dtype=np.uint8)
detections = detector.detect(frame)
assert detections == []
def test_detection_dataclass(self):
d = Detection(class_name="person", class_id=1, confidence=0.85, bbox=(10, 20, 100, 200))
assert d.class_name == "person"
assert d.confidence == 0.85
class TestVehicleColor:
def test_white_detection(self):
white_region = np.full((100, 100, 3), 240, dtype=np.uint8)
color = classify_dominant_color(white_region)
assert color == "white"
def test_black_detection(self):
black_region = np.full((100, 100, 3), 15, dtype=np.uint8)
color = classify_dominant_color(black_region)
assert color == "black"
def test_size_compact(self):
assert classify_size(bbox_area=5000, zone_area=100000) == "compact"
def test_size_midsize(self):
assert classify_size(bbox_area=15000, zone_area=100000) == "midsize"
def test_size_large(self):
assert classify_size(bbox_area=30000, zone_area=100000) == "large"
class TestZoneFiltering:
def test_detection_inside_zone(self):
detections = [Detection("person", 1, 0.9, (50, 50, 80, 80))]
zone_region = (0, 0, 200, 200)
filtered = filter_detections_by_zone(detections, zone_region, ["person"])
assert len(filtered) == 1
def test_detection_outside_zone(self):
detections = [Detection("person", 1, 0.9, (300, 300, 50, 50))]
zone_region = (0, 0, 200, 200)
filtered = filter_detections_by_zone(detections, zone_region, ["person"])
assert len(filtered) == 0
def test_filter_by_class(self):
detections = [
Detection("person", 1, 0.9, (50, 50, 80, 80)),
Detection("car", 3, 0.8, (50, 50, 80, 80)),
]
zone_region = (0, 0, 200, 200)
filtered = filter_detections_by_zone(detections, zone_region, ["person"])
assert len(filtered) == 1
assert filtered[0].class_name == "person"
def test_no_zone_returns_all(self):
detections = [Detection("person", 1, 0.9, (50, 50, 80, 80))]
filtered = filter_detections_by_zone(detections, None, ["person", "car"])
assert len(filtered) == 1

55
tests/unit/test_health.py Normal file
View File

@@ -0,0 +1,55 @@
"""Tests for health monitoring."""
import shutil
import tempfile
from pathlib import Path
from unittest.mock import patch
from vigilar.health.pruner import find_prunable_recordings, calculate_disk_usage_pct
from vigilar.health.monitor import HealthCheck, HealthStatus, check_disk, check_mqtt_port
class TestDiskCheck:
@patch("vigilar.health.monitor.shutil.disk_usage")
def test_healthy(self, mock_usage):
mock_usage.return_value = type("U", (), {"total": 100_000_000_000, "used": 50_000_000_000, "free": 50_000_000_000})()
result = check_disk("/var/vigilar", warn_pct=85, critical_pct=95)
assert result.status == HealthStatus.HEALTHY
@patch("vigilar.health.monitor.shutil.disk_usage")
def test_warning(self, mock_usage):
mock_usage.return_value = type("U", (), {"total": 100_000_000_000, "used": 90_000_000_000, "free": 10_000_000_000})()
result = check_disk("/var/vigilar", warn_pct=85, critical_pct=95)
assert result.status == HealthStatus.WARNING
@patch("vigilar.health.monitor.shutil.disk_usage")
def test_critical(self, mock_usage):
mock_usage.return_value = type("U", (), {"total": 100_000_000_000, "used": 97_000_000_000, "free": 3_000_000_000})()
result = check_disk("/var/vigilar", warn_pct=85, critical_pct=95)
assert result.status == HealthStatus.CRITICAL
class TestPruner:
def test_calculate_disk_pct(self):
with tempfile.TemporaryDirectory() as d:
pct = calculate_disk_usage_pct(d)
assert 0 <= pct <= 100
def test_find_prunable_empty(self, test_db):
result = find_prunable_recordings(test_db, limit=10)
assert result == []
class TestMQTTCheck:
@patch("vigilar.health.monitor.socket.create_connection")
def test_mqtt_reachable(self, mock_conn):
mock_conn.return_value.__enter__ = lambda s: s
mock_conn.return_value.__exit__ = lambda s, *a: None
result = check_mqtt_port("127.0.0.1", 1883)
assert result.status == HealthStatus.HEALTHY
@patch("vigilar.health.monitor.socket.create_connection")
def test_mqtt_unreachable(self, mock_conn):
mock_conn.side_effect = ConnectionRefusedError()
result = check_mqtt_port("127.0.0.1", 1883)
assert result.status == HealthStatus.CRITICAL

View File

@@ -0,0 +1,81 @@
"""Tests for presence detection."""
import time
from unittest.mock import patch
from vigilar.config import PresenceConfig, PresenceMember
from vigilar.constants import HouseholdState
from vigilar.presence.models import MemberPresence, derive_household_state
class TestDeriveHouseholdState:
def test_empty_when_no_members_home(self):
members = [
MemberPresence(name="Dad", role="adult", is_home=False, last_seen=0),
MemberPresence(name="Mom", role="adult", is_home=False, last_seen=0),
]
assert derive_household_state(members) == HouseholdState.EMPTY
def test_all_home(self):
members = [
MemberPresence(name="Dad", role="adult", is_home=True, last_seen=0),
MemberPresence(name="Mom", role="adult", is_home=True, last_seen=0),
MemberPresence(name="Kid", role="child", is_home=True, last_seen=0),
]
assert derive_household_state(members) == HouseholdState.ALL_HOME
def test_kids_home_only(self):
members = [
MemberPresence(name="Dad", role="adult", is_home=False, last_seen=0),
MemberPresence(name="Kid", role="child", is_home=True, last_seen=0),
]
assert derive_household_state(members) == HouseholdState.KIDS_HOME
def test_adults_home_not_all(self):
members = [
MemberPresence(name="Dad", role="adult", is_home=True, last_seen=0),
MemberPresence(name="Kid", role="child", is_home=False, last_seen=0),
]
assert derive_household_state(members) == HouseholdState.ADULTS_HOME
def test_adults_home_no_children_configured(self):
members = [
MemberPresence(name="Dad", role="adult", is_home=True, last_seen=0),
]
assert derive_household_state(members) == HouseholdState.ALL_HOME
def test_empty_list(self):
assert derive_household_state([]) == HouseholdState.EMPTY
class TestPingHost:
@patch("vigilar.presence.monitor.subprocess.run")
def test_ping_success(self, mock_run):
from vigilar.presence.monitor import ping_host
mock_run.return_value = type("R", (), {"returncode": 0})()
assert ping_host("192.168.1.50") is True
@patch("vigilar.presence.monitor.subprocess.run")
def test_ping_failure(self, mock_run):
from vigilar.presence.monitor import ping_host
mock_run.return_value = type("R", (), {"returncode": 1})()
assert ping_host("192.168.1.50") is False
@patch("vigilar.presence.monitor.subprocess.run")
def test_ping_timeout(self, mock_run):
import subprocess as sp
from vigilar.presence.monitor import ping_host
mock_run.side_effect = sp.TimeoutExpired(cmd="ping", timeout=2)
assert ping_host("192.168.1.50") is False
class TestDepartureDelay:
def test_not_departed_within_delay(self):
from vigilar.presence.monitor import should_mark_away
last_seen = time.monotonic() - 300 # 5 min ago
assert should_mark_away(last_seen, departure_delay_m=10) is False
def test_departed_after_delay(self):
from vigilar.presence.monitor import should_mark_away
last_seen = time.monotonic() - 700 # 11+ min ago
assert should_mark_away(last_seen, departure_delay_m=10) is True