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:
Aaron D. Lee
2026-04-02 23:11:27 -04:00
commit 845a85d618
69 changed files with 7061 additions and 0 deletions

35
tests/unit/test_config.py Normal file
View 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)

View 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
View 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

View 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
View 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
View 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