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:
parent
8a65ac8c69
commit
8314a61815
23
scripts/download_model.sh
Executable file
23
scripts/download_model.sh
Executable 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.*
|
||||
74
tests/unit/test_detection.py
Normal file
74
tests/unit/test_detection.py
Normal 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
55
tests/unit/test_health.py
Normal 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
|
||||
81
tests/unit/test_presence.py
Normal file
81
tests/unit/test_presence.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
0
vigilar/detection/__init__.py
Normal file
0
vigilar/detection/__init__.py
Normal file
76
vigilar/detection/person.py
Normal file
76
vigilar/detection/person.py
Normal 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
|
||||
76
vigilar/detection/vehicle.py
Normal file
76
vigilar/detection/vehicle.py
Normal 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)
|
||||
31
vigilar/detection/zones.py
Normal file
31
vigilar/detection/zones.py
Normal 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
|
||||
0
vigilar/health/__init__.py
Normal file
0
vigilar/health/__init__.py
Normal file
61
vigilar/health/digest.py
Normal file
61
vigilar/health/digest.py
Normal 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
127
vigilar/health/monitor.py
Normal 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
73
vigilar/health/pruner.py
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
0
vigilar/presence/__init__.py
Normal file
0
vigilar/presence/__init__.py
Normal file
34
vigilar/presence/models.py
Normal file
34
vigilar/presence/models.py
Normal 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
113
vigilar/presence/monitor.py
Normal 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()
|
||||
@ -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())
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user