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)
|
retention_days: int = Field(default=DEFAULT_RETENTION_DAYS, ge=1)
|
||||||
resolution_capture: list[int] = Field(default_factory=lambda: [1920, 1080])
|
resolution_capture: list[int] = Field(default_factory=lambda: [1920, 1080])
|
||||||
resolution_motion: list[int] = Field(default_factory=lambda: [640, 360])
|
resolution_motion: list[int] = Field(default_factory=lambda: [640, 360])
|
||||||
|
zones: list["CameraZone"] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# --- Sensor Config ---
|
# --- Sensor Config ---
|
||||||
@ -160,6 +161,65 @@ class RemoteConfig(BaseModel):
|
|||||||
tunnel_ip: str = "10.99.0.2"
|
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 ---
|
# --- Rule Config ---
|
||||||
|
|
||||||
class RuleCondition(BaseModel):
|
class RuleCondition(BaseModel):
|
||||||
@ -201,6 +261,10 @@ class VigilarConfig(BaseModel):
|
|||||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||||
alerts: AlertsConfig = Field(default_factory=AlertsConfig)
|
alerts: AlertsConfig = Field(default_factory=AlertsConfig)
|
||||||
remote: RemoteConfig = Field(default_factory=RemoteConfig)
|
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)
|
cameras: list[CameraConfig] = Field(default_factory=list)
|
||||||
sensors: list[SensorConfig] = Field(default_factory=list)
|
sensors: list[SensorConfig] = Field(default_factory=list)
|
||||||
sensor_gpio: SensorGPIOConfig = Field(default_factory=SensorGPIOConfig, alias="sensors.gpio")
|
sensor_gpio: SensorGPIOConfig = Field(default_factory=SensorGPIOConfig, alias="sensors.gpio")
|
||||||
@ -225,6 +289,10 @@ class VigilarConfig(BaseModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
# Resolve forward reference: CameraConfig.zones references CameraZone defined later
|
||||||
|
CameraConfig.model_rebuild()
|
||||||
|
|
||||||
|
|
||||||
def load_config(path: str | Path | None = None) -> VigilarConfig:
|
def load_config(path: str | Path | None = None) -> VigilarConfig:
|
||||||
"""Load and validate config from a TOML file."""
|
"""Load and validate config from a TOML file."""
|
||||||
import os
|
import os
|
||||||
|
|||||||
@ -36,6 +36,10 @@ class EventType(StrEnum):
|
|||||||
CAMERA_RECONNECTED = "CAMERA_RECONNECTED"
|
CAMERA_RECONNECTED = "CAMERA_RECONNECTED"
|
||||||
SYSTEM_STARTUP = "SYSTEM_STARTUP"
|
SYSTEM_STARTUP = "SYSTEM_STARTUP"
|
||||||
SYSTEM_SHUTDOWN = "SYSTEM_SHUTDOWN"
|
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 ---
|
# --- Sensor Types ---
|
||||||
@ -62,6 +66,8 @@ class RecordingTrigger(StrEnum):
|
|||||||
MOTION = "MOTION"
|
MOTION = "MOTION"
|
||||||
CONTINUOUS = "CONTINUOUS"
|
CONTINUOUS = "CONTINUOUS"
|
||||||
MANUAL = "MANUAL"
|
MANUAL = "MANUAL"
|
||||||
|
PERSON = "PERSON"
|
||||||
|
VEHICLE = "VEHICLE"
|
||||||
|
|
||||||
|
|
||||||
# --- Alert Channels ---
|
# --- Alert Channels ---
|
||||||
@ -88,6 +94,15 @@ class UPSStatus(StrEnum):
|
|||||||
UNKNOWN = "UNKNOWN"
|
UNKNOWN = "UNKNOWN"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Household Presence ---
|
||||||
|
|
||||||
|
class HouseholdState(StrEnum):
|
||||||
|
EMPTY = "EMPTY"
|
||||||
|
KIDS_HOME = "KIDS_HOME"
|
||||||
|
ADULTS_HOME = "ADULTS_HOME"
|
||||||
|
ALL_HOME = "ALL_HOME"
|
||||||
|
|
||||||
|
|
||||||
# --- MQTT Topics ---
|
# --- MQTT Topics ---
|
||||||
|
|
||||||
class Topics:
|
class Topics:
|
||||||
@ -126,6 +141,13 @@ class Topics:
|
|||||||
UPS_CRITICAL = "vigilar/ups/critical"
|
UPS_CRITICAL = "vigilar/ups/critical"
|
||||||
UPS_RESTORED = "vigilar/ups/restored"
|
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
|
||||||
SYSTEM_ARM_STATE = "vigilar/system/arm_state"
|
SYSTEM_ARM_STATE = "vigilar/system/arm_state"
|
||||||
SYSTEM_ALERT = "vigilar/system/alert"
|
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:
|
if cfg.ups.enabled:
|
||||||
subsystems.append(SubsystemProcess("ups-monitor", _run_ups_monitor, (cfg,)))
|
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
|
# Handle signals for clean shutdown
|
||||||
shutdown_requested = False
|
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("event_id", Integer),
|
||||||
Column("encrypted", Integer, nullable=False, default=1),
|
Column("encrypted", Integer, nullable=False, default=1),
|
||||||
Column("thumbnail_path", String),
|
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())
|
Index("idx_recordings_camera_ts", recordings.c.camera_id, recordings.c.started_at.desc())
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user