Initial commit: Vigilar DIY home security system
Phase 1 (Foundation): project skeleton, TOML config + Pydantic validation, MQTT bus wrapper, SQLite schema (9 tables), Click CLI, process supervisor. Phase 2 (Camera): RTSP capture via OpenCV, MOG2 motion detection with configurable sensitivity/zones, adaptive FPS recording (2fps idle/30fps motion) via FFmpeg subprocess, HLS live streaming, pre-motion ring buffer. Phase 3 (Web UI): Flask + Bootstrap 5 dark theme, 6 blueprints, Jinja2 templates (dashboard, kiosk 2x2 grid, events, sensors, recordings, settings), PWA with service worker + Web Push, full admin settings UI with config persistence. Remote Access: WireGuard tunnel configs, nginx reverse proxy with HLS caching + rate limiting, bandwidth-optimized remote HLS stream (426x240 @ 500kbps), DO droplet setup script, certbot TLS. 29 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
35
tests/conftest.py
Normal file
35
tests/conftest.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Pytest fixtures for Vigilar tests."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from vigilar.config import VigilarConfig, load_config
|
||||
from vigilar.storage.db import init_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_data_dir(tmp_path):
|
||||
"""Temporary data directory for tests."""
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
return data_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_db(tmp_data_dir):
|
||||
"""Initialize a test database and return the engine."""
|
||||
db_path = tmp_data_dir / "test.db"
|
||||
return init_db(db_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_config():
|
||||
"""Return a minimal VigilarConfig for testing."""
|
||||
return VigilarConfig(
|
||||
cameras=[],
|
||||
sensors=[],
|
||||
rules=[],
|
||||
)
|
||||
35
tests/unit/test_config.py
Normal file
35
tests/unit/test_config.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Tests for config loading and validation."""
|
||||
|
||||
from vigilar.config import CameraConfig, VigilarConfig
|
||||
|
||||
|
||||
def test_default_config():
|
||||
cfg = VigilarConfig()
|
||||
assert cfg.system.name == "Vigilar Home Security"
|
||||
assert cfg.web.port == 49735
|
||||
assert cfg.mqtt.port == 1883
|
||||
assert cfg.cameras == []
|
||||
|
||||
|
||||
def test_camera_config_defaults():
|
||||
cam = CameraConfig(id="test", display_name="Test", rtsp_url="rtsp://localhost")
|
||||
assert cam.idle_fps == 2
|
||||
assert cam.motion_fps == 30
|
||||
assert cam.pre_motion_buffer_s == 5
|
||||
assert cam.post_motion_buffer_s == 30
|
||||
assert cam.motion_sensitivity == 0.7
|
||||
|
||||
|
||||
def test_duplicate_camera_ids_rejected():
|
||||
import pytest
|
||||
with pytest.raises(ValueError, match="Duplicate camera IDs"):
|
||||
VigilarConfig(cameras=[
|
||||
CameraConfig(id="cam1", display_name="A", rtsp_url="rtsp://a"),
|
||||
CameraConfig(id="cam1", display_name="B", rtsp_url="rtsp://b"),
|
||||
])
|
||||
|
||||
|
||||
def test_camera_sensitivity_bounds():
|
||||
import pytest
|
||||
with pytest.raises(Exception):
|
||||
CameraConfig(id="test", display_name="Test", rtsp_url="rtsp://localhost", motion_sensitivity=1.5)
|
||||
62
tests/unit/test_config_writer.py
Normal file
62
tests/unit/test_config_writer.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Tests for config writer."""
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from vigilar.config import CameraConfig, VigilarConfig
|
||||
from vigilar.config_writer import (
|
||||
save_config,
|
||||
update_camera_config,
|
||||
update_config_section,
|
||||
)
|
||||
|
||||
|
||||
def test_update_config_section():
|
||||
cfg = VigilarConfig()
|
||||
assert cfg.system.timezone == "UTC"
|
||||
|
||||
new_cfg = update_config_section(cfg, "system", {"timezone": "America/New_York"})
|
||||
assert new_cfg.system.timezone == "America/New_York"
|
||||
# Original unchanged
|
||||
assert cfg.system.timezone == "UTC"
|
||||
|
||||
|
||||
def test_update_camera_config():
|
||||
cfg = VigilarConfig(cameras=[
|
||||
CameraConfig(id="cam1", display_name="Cam 1", rtsp_url="rtsp://a"),
|
||||
])
|
||||
assert cfg.cameras[0].motion_sensitivity == 0.7
|
||||
|
||||
new_cfg = update_camera_config(cfg, "cam1", {"motion_sensitivity": 0.9})
|
||||
assert new_cfg.cameras[0].motion_sensitivity == 0.9
|
||||
|
||||
|
||||
def test_save_and_reload(tmp_path):
|
||||
cfg = VigilarConfig(cameras=[
|
||||
CameraConfig(id="test", display_name="Test Cam", rtsp_url="rtsp://localhost"),
|
||||
])
|
||||
|
||||
path = tmp_path / "test.toml"
|
||||
save_config(cfg, path)
|
||||
|
||||
assert path.exists()
|
||||
with open(path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
assert data["system"]["name"] == "Vigilar Home Security"
|
||||
assert len(data["cameras"]) == 1
|
||||
assert data["cameras"][0]["id"] == "test"
|
||||
|
||||
|
||||
def test_save_creates_backup(tmp_path):
|
||||
path = tmp_path / "test.toml"
|
||||
cfg = VigilarConfig()
|
||||
|
||||
# First save
|
||||
save_config(cfg, path)
|
||||
assert path.exists()
|
||||
|
||||
# Second save should create backup
|
||||
save_config(cfg, path)
|
||||
backups = list(tmp_path.glob("*.bak.*"))
|
||||
assert len(backups) == 1
|
||||
63
tests/unit/test_motion.py
Normal file
63
tests/unit/test_motion.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Tests for motion detection."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from vigilar.camera.motion import MotionDetector
|
||||
|
||||
|
||||
def test_no_motion_on_static_scene():
|
||||
detector = MotionDetector(sensitivity=0.7, min_area_px=100, resolution=(160, 90))
|
||||
|
||||
# Feed identical frames to build background model
|
||||
static_frame = np.full((360, 640, 3), 128, dtype=np.uint8)
|
||||
for _ in range(80): # exceed warmup
|
||||
detected, rects, conf = detector.detect(static_frame)
|
||||
|
||||
# After warmup, static scene should have no motion
|
||||
detected, rects, conf = detector.detect(static_frame)
|
||||
assert not detected
|
||||
assert len(rects) == 0
|
||||
|
||||
|
||||
def test_motion_detected_on_scene_change():
|
||||
detector = MotionDetector(sensitivity=0.9, min_area_px=50, resolution=(160, 90))
|
||||
|
||||
# Build background with static scene
|
||||
static = np.full((360, 640, 3), 128, dtype=np.uint8)
|
||||
for _ in range(80):
|
||||
detector.detect(static)
|
||||
|
||||
# Introduce a large change
|
||||
changed = static.copy()
|
||||
changed[50:200, 100:300] = 255 # large white rectangle
|
||||
|
||||
detected, rects, conf = detector.detect(changed)
|
||||
assert detected
|
||||
assert len(rects) > 0
|
||||
assert conf > 0
|
||||
|
||||
|
||||
def test_sensitivity_update():
|
||||
detector = MotionDetector(sensitivity=0.5)
|
||||
assert detector._sensitivity == 0.5
|
||||
|
||||
detector.update_sensitivity(0.9)
|
||||
assert detector._sensitivity == 0.9
|
||||
|
||||
# Clamps to valid range
|
||||
detector.update_sensitivity(1.5)
|
||||
assert detector._sensitivity == 1.0
|
||||
detector.update_sensitivity(-0.1)
|
||||
assert detector._sensitivity == 0.0
|
||||
|
||||
|
||||
def test_reset_clears_background():
|
||||
detector = MotionDetector(resolution=(80, 45))
|
||||
frame = np.zeros((360, 640, 3), dtype=np.uint8)
|
||||
|
||||
for _ in range(80):
|
||||
detector.detect(frame)
|
||||
|
||||
assert detector._frame_count >= 80
|
||||
detector.reset()
|
||||
assert detector._frame_count == 0
|
||||
74
tests/unit/test_ring_buffer.py
Normal file
74
tests/unit/test_ring_buffer.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Tests for the ring buffer."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from vigilar.camera.ring_buffer import RingBuffer
|
||||
|
||||
|
||||
def test_push_and_count():
|
||||
buf = RingBuffer(duration_s=1, max_fps=10)
|
||||
assert buf.count == 0
|
||||
assert buf.capacity == 10
|
||||
|
||||
frame = np.zeros((480, 640, 3), dtype=np.uint8)
|
||||
for _ in range(5):
|
||||
buf.push(frame)
|
||||
assert buf.count == 5
|
||||
|
||||
|
||||
def test_capacity_limit():
|
||||
buf = RingBuffer(duration_s=1, max_fps=5)
|
||||
frame = np.zeros((100, 100, 3), dtype=np.uint8)
|
||||
|
||||
for i in range(10):
|
||||
buf.push(frame)
|
||||
|
||||
assert buf.count == 5 # maxlen enforced
|
||||
|
||||
|
||||
def test_flush_returns_all_and_clears():
|
||||
buf = RingBuffer(duration_s=1, max_fps=10)
|
||||
frame = np.zeros((100, 100, 3), dtype=np.uint8)
|
||||
|
||||
for _ in range(7):
|
||||
buf.push(frame)
|
||||
|
||||
frames = buf.flush()
|
||||
assert len(frames) == 7
|
||||
assert buf.count == 0
|
||||
|
||||
|
||||
def test_flush_preserves_order():
|
||||
buf = RingBuffer(duration_s=1, max_fps=10)
|
||||
|
||||
for i in range(5):
|
||||
frame = np.full((10, 10, 3), i, dtype=np.uint8)
|
||||
buf.push(frame)
|
||||
|
||||
frames = buf.flush()
|
||||
for i, tsf in enumerate(frames):
|
||||
assert tsf.frame[0, 0, 0] == i
|
||||
|
||||
|
||||
def test_peek_latest():
|
||||
buf = RingBuffer(duration_s=1, max_fps=10)
|
||||
assert buf.peek_latest() is None
|
||||
|
||||
frame1 = np.full((10, 10, 3), 1, dtype=np.uint8)
|
||||
frame2 = np.full((10, 10, 3), 2, dtype=np.uint8)
|
||||
buf.push(frame1)
|
||||
buf.push(frame2)
|
||||
|
||||
latest = buf.peek_latest()
|
||||
assert latest is not None
|
||||
assert latest.frame[0, 0, 0] == 2
|
||||
assert buf.count == 2 # peek doesn't remove
|
||||
|
||||
|
||||
def test_clear():
|
||||
buf = RingBuffer(duration_s=1, max_fps=10)
|
||||
frame = np.zeros((10, 10, 3), dtype=np.uint8)
|
||||
buf.push(frame)
|
||||
buf.push(frame)
|
||||
buf.clear()
|
||||
assert buf.count == 0
|
||||
21
tests/unit/test_schema.py
Normal file
21
tests/unit/test_schema.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Tests for database schema creation."""
|
||||
|
||||
from vigilar.storage.db import init_db
|
||||
from vigilar.storage.schema import metadata
|
||||
|
||||
|
||||
def test_tables_created(tmp_path):
|
||||
db_path = tmp_path / "test.db"
|
||||
engine = init_db(db_path)
|
||||
assert db_path.exists()
|
||||
|
||||
from sqlalchemy import inspect
|
||||
inspector = inspect(engine)
|
||||
table_names = inspector.get_table_names()
|
||||
|
||||
expected = [
|
||||
"cameras", "sensors", "sensor_states", "events", "recordings",
|
||||
"system_events", "arm_state_log", "alert_log", "push_subscriptions",
|
||||
]
|
||||
for name in expected:
|
||||
assert name in table_names, f"Missing table: {name}"
|
||||
104
tests/unit/test_web.py
Normal file
104
tests/unit/test_web.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Tests for Flask web application."""
|
||||
|
||||
from vigilar.config import CameraConfig, VigilarConfig
|
||||
from vigilar.web.app import create_app
|
||||
|
||||
|
||||
def test_app_creates():
|
||||
cfg = VigilarConfig()
|
||||
app = create_app(cfg)
|
||||
assert app is not None
|
||||
|
||||
|
||||
def test_index_loads():
|
||||
cfg = VigilarConfig(cameras=[
|
||||
CameraConfig(id="test", display_name="Test", rtsp_url="rtsp://localhost"),
|
||||
])
|
||||
app = create_app(cfg)
|
||||
with app.test_client() as client:
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert b"Vigilar" in resp.data
|
||||
assert b"Test" in resp.data
|
||||
|
||||
|
||||
def test_settings_loads():
|
||||
cfg = VigilarConfig()
|
||||
app = create_app(cfg)
|
||||
with app.test_client() as client:
|
||||
resp = client.get("/system/settings")
|
||||
assert resp.status_code == 200
|
||||
assert b"Settings" in resp.data
|
||||
|
||||
|
||||
def test_kiosk_loads():
|
||||
cfg = VigilarConfig(cameras=[
|
||||
CameraConfig(id="cam1", display_name="Cam 1", rtsp_url="rtsp://a"),
|
||||
])
|
||||
app = create_app(cfg)
|
||||
with app.test_client() as client:
|
||||
resp = client.get("/kiosk/")
|
||||
assert resp.status_code == 200
|
||||
assert b"Cam 1" in resp.data
|
||||
assert b"kiosk-grid" in resp.data
|
||||
|
||||
|
||||
def test_system_status_api():
|
||||
cfg = VigilarConfig()
|
||||
app = create_app(cfg)
|
||||
with app.test_client() as client:
|
||||
resp = client.get("/system/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "arm_state" in data
|
||||
|
||||
|
||||
def test_config_api():
|
||||
cfg = VigilarConfig()
|
||||
app = create_app(cfg)
|
||||
with app.test_client() as client:
|
||||
resp = client.get("/system/api/config")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "system" in data
|
||||
assert "cameras" in data
|
||||
# Secrets should be redacted
|
||||
assert "password_hash" not in data.get("web", {})
|
||||
|
||||
|
||||
def test_camera_status_api():
|
||||
cfg = VigilarConfig(cameras=[
|
||||
CameraConfig(id="test", display_name="Test", rtsp_url="rtsp://localhost"),
|
||||
])
|
||||
app = create_app(cfg)
|
||||
with app.test_client() as client:
|
||||
resp = client.get("/cameras/api/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["id"] == "test"
|
||||
|
||||
|
||||
def test_events_page_loads():
|
||||
cfg = VigilarConfig()
|
||||
app = create_app(cfg)
|
||||
with app.test_client() as client:
|
||||
resp = client.get("/events/")
|
||||
assert resp.status_code == 200
|
||||
assert b"Event Log" in resp.data
|
||||
|
||||
|
||||
def test_sensors_page_loads():
|
||||
cfg = VigilarConfig()
|
||||
app = create_app(cfg)
|
||||
with app.test_client() as client:
|
||||
resp = client.get("/sensors/")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_recordings_page_loads():
|
||||
cfg = VigilarConfig()
|
||||
app = create_app(cfg)
|
||||
with app.test_client() as client:
|
||||
resp = client.get("/recordings/")
|
||||
assert resp.status_code == 200
|
||||
Reference in New Issue
Block a user