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

23
scripts/download_model.sh Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
MODEL_DIR="${1:-/var/vigilar/models}"
mkdir -p "$MODEL_DIR"
PB_URL="https://raw.githubusercontent.com/opencv/opencv_extra/master/testdata/dnn/ssd_mobilenet_v2_coco_2018_03_29.pb"
PBTXT_URL="https://raw.githubusercontent.com/opencv/opencv_extra/master/testdata/dnn/ssd_mobilenet_v2_coco_2018_03_29.pbtxt"
echo "Downloading MobileNet-SSD v2 model..."
if command -v curl &>/dev/null; then
curl -fSL -o "$MODEL_DIR/mobilenet_ssd_v2.pb" "$PB_URL"
curl -fSL -o "$MODEL_DIR/mobilenet_ssd_v2.pbtxt" "$PBTXT_URL"
elif command -v wget &>/dev/null; then
wget -q -O "$MODEL_DIR/mobilenet_ssd_v2.pb" "$PB_URL"
wget -q -O "$MODEL_DIR/mobilenet_ssd_v2.pbtxt" "$PBTXT_URL"
else
echo "ERROR: curl or wget required"
exit 1
fi
echo "Model downloaded to $MODEL_DIR"
ls -lh "$MODEL_DIR"/mobilenet_ssd_v2.*

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

View File

@ -42,6 +42,7 @@ class CameraConfig(BaseModel):
retention_days: int = Field(default=DEFAULT_RETENTION_DAYS, ge=1)
resolution_capture: list[int] = Field(default_factory=lambda: [1920, 1080])
resolution_motion: list[int] = Field(default_factory=lambda: [640, 360])
zones: list["CameraZone"] = Field(default_factory=list)
# --- Sensor Config ---
@ -160,6 +161,65 @@ class RemoteConfig(BaseModel):
tunnel_ip: str = "10.99.0.2"
# --- Detection Config ---
class CameraZone(BaseModel):
name: str
region: list[int] = Field(default_factory=list) # [x, y, w, h]
watch_for: list[str] = Field(default_factory=lambda: ["person", "vehicle"])
alert_unknown_vehicles: bool = False
class DetectionConfig(BaseModel):
person_detection: bool = False
model_path: str = "/var/vigilar/models/mobilenet_ssd_v2.pb"
model_config_path: str = "/var/vigilar/models/mobilenet_ssd_v2.pbtxt"
confidence_threshold: float = 0.5
cameras: list[str] = Field(default_factory=list) # empty = all
class KnownVehicle(BaseModel):
name: str
color_profile: str = "" # white, black, silver, red, blue, etc.
size_class: str = "" # compact, midsize, large
calibration_file: str = ""
class VehicleConfig(BaseModel):
known: list[KnownVehicle] = Field(default_factory=list)
# --- Presence Config ---
class PresenceMember(BaseModel):
name: str
ip: str
role: str = "adult" # adult | child
class PresenceConfig(BaseModel):
enabled: bool = False
ping_interval_s: int = 30
departure_delay_m: int = 10
method: str = "icmp" # icmp | arping
members: list[PresenceMember] = Field(default_factory=list)
actions: dict[str, str] = Field(default_factory=lambda: {
"EMPTY": "ARMED_AWAY",
"ADULTS_HOME": "DISARMED",
"KIDS_HOME": "ARMED_HOME",
"ALL_HOME": "DISARMED",
})
# --- Health Config ---
class HealthConfig(BaseModel):
enabled: bool = True
disk_warn_pct: int = 85
disk_critical_pct: int = 95
auto_prune: bool = True
auto_prune_target_pct: int = 80
daily_digest: bool = True
daily_digest_time: str = "08:00"
# --- Rule Config ---
class RuleCondition(BaseModel):
@ -201,6 +261,10 @@ class VigilarConfig(BaseModel):
storage: StorageConfig = Field(default_factory=StorageConfig)
alerts: AlertsConfig = Field(default_factory=AlertsConfig)
remote: RemoteConfig = Field(default_factory=RemoteConfig)
presence: PresenceConfig = Field(default_factory=PresenceConfig)
detection: DetectionConfig = Field(default_factory=DetectionConfig)
vehicles: VehicleConfig = Field(default_factory=VehicleConfig)
health: HealthConfig = Field(default_factory=HealthConfig)
cameras: list[CameraConfig] = Field(default_factory=list)
sensors: list[SensorConfig] = Field(default_factory=list)
sensor_gpio: SensorGPIOConfig = Field(default_factory=SensorGPIOConfig, alias="sensors.gpio")
@ -225,6 +289,10 @@ class VigilarConfig(BaseModel):
return self
# Resolve forward reference: CameraConfig.zones references CameraZone defined later
CameraConfig.model_rebuild()
def load_config(path: str | Path | None = None) -> VigilarConfig:
"""Load and validate config from a TOML file."""
import os

View File

@ -36,6 +36,10 @@ class EventType(StrEnum):
CAMERA_RECONNECTED = "CAMERA_RECONNECTED"
SYSTEM_STARTUP = "SYSTEM_STARTUP"
SYSTEM_SHUTDOWN = "SYSTEM_SHUTDOWN"
PERSON_DETECTED = "PERSON_DETECTED"
VEHICLE_DETECTED = "VEHICLE_DETECTED"
KNOWN_VEHICLE_ARRIVED = "KNOWN_VEHICLE_ARRIVED"
UNKNOWN_VEHICLE_DETECTED = "UNKNOWN_VEHICLE_DETECTED"
# --- Sensor Types ---
@ -62,6 +66,8 @@ class RecordingTrigger(StrEnum):
MOTION = "MOTION"
CONTINUOUS = "CONTINUOUS"
MANUAL = "MANUAL"
PERSON = "PERSON"
VEHICLE = "VEHICLE"
# --- Alert Channels ---
@ -88,6 +94,15 @@ class UPSStatus(StrEnum):
UNKNOWN = "UNKNOWN"
# --- Household Presence ---
class HouseholdState(StrEnum):
EMPTY = "EMPTY"
KIDS_HOME = "KIDS_HOME"
ADULTS_HOME = "ADULTS_HOME"
ALL_HOME = "ALL_HOME"
# --- MQTT Topics ---
class Topics:
@ -126,6 +141,13 @@ class Topics:
UPS_CRITICAL = "vigilar/ups/critical"
UPS_RESTORED = "vigilar/ups/restored"
# Presence
@staticmethod
def presence_member(name: str) -> str:
return f"vigilar/presence/{name}"
PRESENCE_STATUS = "vigilar/presence/status"
# System
SYSTEM_ARM_STATE = "vigilar/system/arm_state"
SYSTEM_ALERT = "vigilar/system/alert"

View File

View File

@ -0,0 +1,76 @@
"""Person detection using MobileNet-SSD v2 via OpenCV DNN."""
import logging
from dataclasses import dataclass
from pathlib import Path
import cv2
import numpy as np
log = logging.getLogger(__name__)
COCO_CLASSES = {
1: "person", 2: "bicycle", 3: "car", 4: "motorcycle", 5: "airplane",
6: "bus", 7: "train", 8: "truck", 9: "boat",
}
DETECT_CLASSES = {1, 3, 8} # person, car, truck
@dataclass
class Detection:
class_name: str
class_id: int
confidence: float
bbox: tuple[int, int, int, int] # x, y, w, h
class PersonDetector:
def __init__(self, model_path: str, config_path: str, confidence_threshold: float = 0.5):
self._threshold = confidence_threshold
self._net = None
self.is_loaded = False
if Path(model_path).exists() and Path(config_path).exists():
try:
self._net = cv2.dnn.readNetFromTensorflow(model_path, config_path)
self.is_loaded = True
log.info("Person detection model loaded from %s", model_path)
except cv2.error as e:
log.error("Failed to load detection model: %s", e)
else:
log.warning("Detection model not found at %s — detection disabled", model_path)
def detect(self, frame: np.ndarray) -> list[Detection]:
if not self.is_loaded or self._net is None:
return []
h, w = frame.shape[:2]
blob = cv2.dnn.blobFromImage(frame, size=(300, 300), swapRB=True, crop=False)
self._net.setInput(blob)
output = self._net.forward()
detections = []
for i in range(output.shape[2]):
confidence = output[0, 0, i, 2]
if confidence < self._threshold:
continue
class_id = int(output[0, 0, i, 1])
if class_id not in DETECT_CLASSES:
continue
x1 = int(output[0, 0, i, 3] * w)
y1 = int(output[0, 0, i, 4] * h)
x2 = int(output[0, 0, i, 5] * w)
y2 = int(output[0, 0, i, 6] * h)
bw, bh = x2 - x1, y2 - y1
if bw <= 0 or bh <= 0:
continue
detections.append(Detection(
class_name=COCO_CLASSES.get(class_id, f"class_{class_id}"),
class_id=class_id,
confidence=float(confidence),
bbox=(x1, y1, bw, bh),
))
return detections

View File

@ -0,0 +1,76 @@
"""Vehicle color and size fingerprinting."""
import logging
from dataclasses import dataclass
import cv2
import numpy as np
log = logging.getLogger(__name__)
@dataclass
class VehicleProfile:
name: str
color_profile: str
size_class: str
histogram: np.ndarray | None = None
def classify_dominant_color(region: np.ndarray) -> str:
hsv = cv2.cvtColor(region, cv2.COLOR_BGR2HSV)
h, s, v = cv2.split(hsv)
mean_s = float(np.mean(s))
mean_v = float(np.mean(v))
if mean_v < 40:
return "black"
if mean_s < 30 and mean_v > 180:
return "white"
if mean_s < 40:
return "silver"
mean_h = float(np.mean(h))
if mean_h < 10 or mean_h > 170:
return "red"
if 10 <= mean_h < 25:
return "orange"
if 25 <= mean_h < 35:
return "yellow"
if 35 <= mean_h < 85:
return "green"
if 85 <= mean_h < 130:
return "blue"
return "unknown"
def classify_size(bbox_area: int, zone_area: int) -> str:
if zone_area <= 0:
return "unknown"
ratio = bbox_area / zone_area
if ratio < 0.1:
return "compact"
if ratio < 0.25:
return "midsize"
return "large"
class VehicleFingerprint:
def __init__(self, known_vehicles: list[VehicleProfile] | None = None):
self._known = known_vehicles or []
def match(self, region: np.ndarray, bbox_area: int, zone_area: int) -> VehicleProfile | None:
color = classify_dominant_color(region)
size = classify_size(bbox_area, zone_area)
for profile in self._known:
if profile.color_profile == color and profile.size_class == size:
return profile
return None
def add_profile(self, profile: VehicleProfile) -> None:
self._known.append(profile)
@property
def known_count(self) -> int:
return len(self._known)

View File

@ -0,0 +1,31 @@
"""Zone-based detection filtering."""
from vigilar.detection.person import Detection
def _bbox_center(bbox: tuple[int, int, int, int]) -> tuple[int, int]:
x, y, w, h = bbox
return x + w // 2, y + h // 2
def _point_in_rect(point: tuple[int, int], rect: tuple[int, int, int, int]) -> bool:
px, py = point
rx, ry, rw, rh = rect
return rx <= px <= rx + rw and ry <= py <= ry + rh
def filter_detections_by_zone(
detections: list[Detection],
zone_region: tuple[int, int, int, int] | None,
watch_for: list[str],
) -> list[Detection]:
result = []
for d in detections:
if d.class_name not in watch_for:
continue
if zone_region is not None:
center = _bbox_center(d.bbox)
if not _point_in_rect(center, zone_region):
continue
result.append(d)
return result

View File

61
vigilar/health/digest.py Normal file
View File

@ -0,0 +1,61 @@
"""Daily digest notification builder."""
import logging
import shutil
import time
from sqlalchemy import func, select
from sqlalchemy.engine import Engine
from vigilar.storage.schema import events, recordings
log = logging.getLogger(__name__)
# Event type constants — these may be defined in constants.EventType
# once detection subsystem (Task 2) is integrated.
_PERSON_DETECTED = "PERSON_DETECTED"
_UNKNOWN_VEHICLE_DETECTED = "UNKNOWN_VEHICLE_DETECTED"
def build_digest(engine: Engine, data_dir: str, since_hours: int = 12) -> dict:
since_ts = int((time.time() - since_hours * 3600) * 1000)
with engine.connect() as conn:
person_count = conn.execute(
select(func.count()).select_from(events)
.where(events.c.type == _PERSON_DETECTED, events.c.ts >= since_ts)
).scalar() or 0
vehicle_count = conn.execute(
select(func.count()).select_from(events)
.where(events.c.type == _UNKNOWN_VEHICLE_DETECTED, events.c.ts >= since_ts)
).scalar() or 0
recording_count = conn.execute(
select(func.count()).select_from(recordings)
.where(recordings.c.started_at >= since_ts // 1000)
).scalar() or 0
usage = shutil.disk_usage(data_dir)
disk_pct = usage.used / usage.total * 100
disk_gb = usage.used / (1024**3)
return {
"person_detections": person_count,
"unknown_vehicles": vehicle_count,
"recordings": recording_count,
"disk_used_gb": round(disk_gb, 1),
"disk_used_pct": round(disk_pct, 0),
"since_hours": since_hours,
}
def format_digest(data: dict) -> str:
return (
f"Vigilar Daily Summary\n"
f"Last {data['since_hours']}h: "
f"{data['person_detections']} person detections, "
f"{data['unknown_vehicles']} unknown vehicles, "
f"{data['recordings']} recordings\n"
f"Storage: {data['disk_used_gb']} GB ({data['disk_used_pct']:.0f}%)"
)

127
vigilar/health/monitor.py Normal file
View File

@ -0,0 +1,127 @@
"""Health monitoring — periodic subsystem checks."""
import logging
import shutil
import signal
import socket
import time
from dataclasses import dataclass, field
from enum import StrEnum
from vigilar.bus import MessageBus
from vigilar.config import VigilarConfig
log = logging.getLogger(__name__)
class HealthStatus(StrEnum):
HEALTHY = "healthy"
WARNING = "warning"
CRITICAL = "critical"
@dataclass
class HealthCheck:
name: str
status: HealthStatus
message: str = ""
value: float = 0
def check_disk(path: str, warn_pct: int, critical_pct: int) -> HealthCheck:
try:
usage = shutil.disk_usage(path)
pct = usage.used / usage.total * 100
if pct >= critical_pct:
return HealthCheck("disk", HealthStatus.CRITICAL, f"{pct:.0f}% used", pct)
if pct >= warn_pct:
return HealthCheck("disk", HealthStatus.WARNING, f"{pct:.0f}% used", pct)
return HealthCheck("disk", HealthStatus.HEALTHY, f"{pct:.0f}% used", pct)
except OSError as e:
return HealthCheck("disk", HealthStatus.CRITICAL, str(e))
def check_mqtt_port(host: str, port: int) -> HealthCheck:
try:
with socket.create_connection((host, port), timeout=5):
return HealthCheck("mqtt", HealthStatus.HEALTHY, "reachable")
except (ConnectionRefusedError, TimeoutError, OSError) as e:
return HealthCheck("mqtt", HealthStatus.CRITICAL, str(e))
class HealthMonitor:
def __init__(self, config: VigilarConfig):
self._cfg = config
self._bus: MessageBus | None = None
self._checks: list[HealthCheck] = []
def run(self) -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [health] %(levelname)s: %(message)s",
)
self._bus = MessageBus(self._cfg.mqtt, client_id="health-monitor")
self._bus.connect()
shutdown = False
def handle_signal(signum, frame):
nonlocal shutdown
shutdown = True
signal.signal(signal.SIGTERM, handle_signal)
log.info("Health monitor started")
last_disk_check = 0
last_mqtt_check = 0
while not shutdown:
now = time.monotonic()
if now - last_disk_check >= 300: # 5 min
disk = check_disk(self._cfg.system.data_dir, self._cfg.health.disk_warn_pct, self._cfg.health.disk_critical_pct)
self._update_check(disk)
last_disk_check = now
if disk.status == HealthStatus.CRITICAL and self._cfg.health.auto_prune:
from vigilar.health.pruner import auto_prune
auto_prune(self._cfg)
if now - last_mqtt_check >= 30:
mqtt = check_mqtt_port(self._cfg.mqtt.host, self._cfg.mqtt.port)
self._update_check(mqtt)
last_mqtt_check = now
self._publish_status()
time.sleep(10)
if self._bus:
self._bus.disconnect()
def _update_check(self, check: HealthCheck) -> None:
for i, c in enumerate(self._checks):
if c.name == check.name:
self._checks[i] = check
return
self._checks.append(check)
def _publish_status(self) -> None:
if not self._bus:
return
overall = HealthStatus.HEALTHY
for c in self._checks:
if c.status == HealthStatus.CRITICAL:
overall = HealthStatus.CRITICAL
break
if c.status == HealthStatus.WARNING:
overall = HealthStatus.WARNING
self._bus.publish_event(
"vigilar/system/health",
status=overall.value,
checks={c.name: {"status": c.status.value, "message": c.message} for c in self._checks},
)
def run_health_monitor(config: VigilarConfig) -> None:
monitor = HealthMonitor(config)
monitor.run()

73
vigilar/health/pruner.py Normal file
View File

@ -0,0 +1,73 @@
"""Auto-prune old recordings when disk usage is high."""
import logging
import os
import shutil
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.engine import Engine
from vigilar.config import VigilarConfig
from vigilar.storage.schema import recordings
log = logging.getLogger(__name__)
def calculate_disk_usage_pct(path: str) -> float:
usage = shutil.disk_usage(path)
return usage.used / usage.total * 100
def find_prunable_recordings(engine: Engine, limit: int = 50) -> list[dict]:
query = (
select(recordings)
.where(recordings.c.starred == 0)
.order_by(recordings.c.started_at.asc())
.limit(limit)
)
with engine.connect() as conn:
return [dict(r) for r in conn.execute(query).mappings().all()]
def auto_prune(config: VigilarConfig) -> int:
from vigilar.storage.db import get_db_path, get_engine
target = config.health.auto_prune_target_pct
data_dir = config.system.data_dir
current_pct = calculate_disk_usage_pct(data_dir)
if current_pct <= target:
return 0
engine = get_engine(get_db_path(data_dir))
pruned = 0
total_bytes = 0
while current_pct > target:
candidates = find_prunable_recordings(engine, limit=20)
if not candidates:
break
for rec in candidates:
file_path = rec.get("file_path", "")
if file_path and Path(file_path).exists():
size = Path(file_path).stat().st_size
Path(file_path).unlink()
total_bytes += size
thumb = rec.get("thumbnail_path", "")
if thumb and Path(thumb).exists():
Path(thumb).unlink()
with engine.begin() as conn:
conn.execute(recordings.delete().where(recordings.c.id == rec["id"]))
pruned += 1
current_pct = calculate_disk_usage_pct(data_dir)
if pruned:
mb = total_bytes / (1024 * 1024)
log.info("Auto-pruned %d recordings (%.1f MB), disk now at %.0f%%", pruned, mb, current_pct)
return pruned

View File

@ -121,6 +121,16 @@ def run_supervisor(cfg: VigilarConfig) -> None:
if cfg.ups.enabled:
subsystems.append(SubsystemProcess("ups-monitor", _run_ups_monitor, (cfg,)))
# Presence monitor
if cfg.presence.enabled and cfg.presence.members:
from vigilar.presence.monitor import run_presence_monitor
subsystems.append(SubsystemProcess("presence-monitor", run_presence_monitor, (cfg,)))
# Health monitor
if cfg.health.enabled:
from vigilar.health.monitor import run_health_monitor
subsystems.append(SubsystemProcess("health-monitor", run_health_monitor, (cfg,)))
# Handle signals for clean shutdown
shutdown_requested = False

View File

View File

@ -0,0 +1,34 @@
"""Presence detection data models."""
from dataclasses import dataclass
from vigilar.constants import HouseholdState
@dataclass
class MemberPresence:
name: str
role: str # "adult" | "child"
is_home: bool
last_seen: float # monotonic timestamp of last successful ping
def derive_household_state(members: list[MemberPresence]) -> HouseholdState:
if not members:
return HouseholdState.EMPTY
home = [m for m in members if m.is_home]
if not home:
return HouseholdState.EMPTY
all_home = len(home) == len(members)
adults_home = any(m.role == "adult" for m in home)
kids_home = any(m.role == "child" for m in home)
if all_home:
return HouseholdState.ALL_HOME
if adults_home:
return HouseholdState.ADULTS_HOME
if kids_home:
return HouseholdState.KIDS_HOME
return HouseholdState.EMPTY

113
vigilar/presence/monitor.py Normal file
View File

@ -0,0 +1,113 @@
"""Presence monitor — pings family phones to determine who's home."""
import logging
import signal
import subprocess
import time
from vigilar.bus import MessageBus
from vigilar.config import VigilarConfig
from vigilar.constants import HouseholdState, Topics
from vigilar.presence.models import MemberPresence, derive_household_state
log = logging.getLogger(__name__)
def ping_host(ip: str, timeout_s: int = 2) -> bool:
try:
result = subprocess.run(
["ping", "-c", "1", "-W", str(timeout_s), ip],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=timeout_s + 1,
)
return result.returncode == 0
except subprocess.TimeoutExpired:
return False
except FileNotFoundError:
log.error("ping command not found")
return False
def should_mark_away(last_seen: float, departure_delay_m: int) -> bool:
elapsed_m = (time.monotonic() - last_seen) / 60
return elapsed_m >= departure_delay_m
class PresenceMonitor:
def __init__(self, config: VigilarConfig):
self._cfg = config.presence
self._mqtt_cfg = config.mqtt
self._members: list[MemberPresence] = []
self._last_household_state = HouseholdState.EMPTY
self._bus: MessageBus | None = None
for m in self._cfg.members:
self._members.append(MemberPresence(
name=m.name, role=m.role, is_home=False, last_seen=0,
))
def run(self) -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [presence] %(levelname)s: %(message)s",
)
self._bus = MessageBus(self._mqtt_cfg, client_id="presence-monitor")
self._bus.connect()
shutdown = False
def handle_signal(signum, frame):
nonlocal shutdown
shutdown = True
signal.signal(signal.SIGTERM, handle_signal)
log.info("Presence monitor started, tracking %d members", len(self._members))
while not shutdown:
self._poll_once()
for _ in range(self._cfg.ping_interval_s):
if shutdown:
break
time.sleep(1)
if self._bus:
self._bus.disconnect()
log.info("Presence monitor stopped")
def _poll_once(self) -> None:
for member in self._members:
reachable = ping_host(member.ip)
if reachable:
member.is_home = True
member.last_seen = time.monotonic()
elif member.is_home:
if should_mark_away(member.last_seen, self._cfg.departure_delay_m):
member.is_home = False
log.info("%s departed", member.name)
if self._bus:
self._bus.publish_event(
Topics.presence_member(member.name),
state="HOME" if member.is_home else "AWAY",
name=member.name,
role=member.role,
)
new_state = derive_household_state(self._members)
if new_state != self._last_household_state:
log.info("Household state: %s -> %s", self._last_household_state, new_state)
self._last_household_state = new_state
if self._bus:
members_dict = {m.name: "HOME" if m.is_home else "AWAY" for m in self._members}
self._bus.publish_event(
Topics.PRESENCE_STATUS,
household=new_state.value,
members=members_dict,
)
def run_presence_monitor(config: VigilarConfig) -> None:
monitor = PresenceMonitor(config)
monitor.run()

View File

@ -75,6 +75,8 @@ recordings = Table(
Column("event_id", Integer),
Column("encrypted", Integer, nullable=False, default=1),
Column("thumbnail_path", String),
Column("detection_type", String), # person, vehicle, motion, None
Column("starred", Integer, nullable=False, default=0),
)
Index("idx_recordings_camera_ts", recordings.c.camera_id, recordings.c.started_at.desc())