Merge feature/foundation-plumbing: implement all 5 feature groups

Group A (Foundation): Web Push notifications, AES-256-CTR recording
encryption/playback, HLS.js v1.5.17, PIN verification on arm/disarm

Group C (Detection Intelligence): Activity heatmaps, wildlife journal
with Open-Meteo weather correlation, package delivery detection with
NOAA sunset-aware reminders

Group E (Pet Lifestyle): Composable per-pet rules engine with 6
condition types, cooldown management, CRUD API, 6 quick-add templates

Group D (Visitor Recognition): Local face recognition via dlib,
face profile/embedding storage, consent-gated labeling, visitor
dashboard with privacy controls (forget/ignore)

Group B (Daily Delight): Daily highlight reel generator with event
scoring, kiosk ambient mode with alert takeover, time-lapse generator
with scheduled presets

37 commits, 63 files changed, 360 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee 2026-04-03 19:19:39 -04:00
commit 965dc3b13d
63 changed files with 4458 additions and 21 deletions

View File

@ -24,6 +24,7 @@ dependencies = [
"py-vapid>=1.9.0", "py-vapid>=1.9.0",
"ultralytics>=8.2.0", "ultralytics>=8.2.0",
"torchvision>=0.18.0", "torchvision>=0.18.0",
"requests>=2.32.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@ -31,6 +32,9 @@ gpio = [
"gpiozero>=2.0.1", "gpiozero>=2.0.1",
"RPi.GPIO>=0.7.1", "RPi.GPIO>=0.7.1",
] ]
face = [
"face_recognition>=1.3.0",
]
dev = [ dev = [
"pytest>=8.2.0", "pytest>=8.2.0",
"pytest-cov>=5.0.0", "pytest-cov>=5.0.0",

View File

@ -82,6 +82,52 @@ class TestPetActivityConfig:
assert cfg.zoomie_threshold == 0.8 assert cfg.zoomie_threshold == 0.8
def test_security_config_defaults():
from vigilar.config import SecurityConfig
sc = SecurityConfig()
assert sc.pin_hash == ""
assert sc.recovery_passphrase_hash == ""
def test_vigilar_config_has_security():
from vigilar.config import VigilarConfig
cfg = VigilarConfig()
assert cfg.security.pin_hash == ""
assert cfg.security.recovery_passphrase_hash == ""
def test_location_config_defaults():
from vigilar.config import LocationConfig
lc = LocationConfig()
assert lc.latitude == 0.0
assert lc.longitude == 0.0
def test_vigilar_config_has_location():
from vigilar.config import VigilarConfig
cfg = VigilarConfig()
assert cfg.location.latitude == 0.0
def test_highlights_config_defaults():
from vigilar.config import HighlightsConfig
assert HighlightsConfig().enabled is True
assert HighlightsConfig().generate_time == "06:00"
def test_kiosk_config_defaults():
from vigilar.config import KioskConfig
assert KioskConfig().ambient_enabled is True
assert KioskConfig().camera_rotation_s == 10
def test_recording_trigger_highlight():
from vigilar.constants import RecordingTrigger
assert RecordingTrigger.HIGHLIGHT == "HIGHLIGHT"
def test_recording_trigger_timelapse():
from vigilar.constants import RecordingTrigger
assert RecordingTrigger.TIMELAPSE == "TIMELAPSE"
class TestCameraConfigLocation: class TestCameraConfigLocation:
def test_default_location_is_interior(self): def test_default_location_is_interior(self):
from vigilar.config import CameraConfig from vigilar.config import CameraConfig

View File

@ -0,0 +1,58 @@
"""Tests for AES-256-CTR recording encryption."""
import os
from pathlib import Path
from vigilar.storage.encryption import decrypt_stream, encrypt_file
def test_encrypt_file_creates_vge(tmp_path):
plain = tmp_path / "test.mp4"
plain.write_bytes(b"fake mp4 content here for testing")
key_hex = os.urandom(32).hex()
vge_path = encrypt_file(str(plain), key_hex)
assert vge_path.endswith(".vge")
assert Path(vge_path).exists()
assert not plain.exists()
def test_encrypt_file_prepends_iv(tmp_path):
plain = tmp_path / "test.mp4"
plain.write_bytes(b"x" * 100)
key_hex = os.urandom(32).hex()
vge_path = encrypt_file(str(plain), key_hex)
data = Path(vge_path).read_bytes()
assert len(data) == 16 + 100
def test_decrypt_stream_roundtrip(tmp_path):
original = b"Hello, this is a recording file with some content." * 100
plain = tmp_path / "test.mp4"
plain.write_bytes(original)
key_hex = os.urandom(32).hex()
vge_path = encrypt_file(str(plain), key_hex)
chunks = list(decrypt_stream(vge_path, key_hex))
decrypted = b"".join(chunks)
assert decrypted == original
def test_decrypt_stream_yields_chunks(tmp_path):
original = b"A" * 100_000
plain = tmp_path / "test.mp4"
plain.write_bytes(original)
key_hex = os.urandom(32).hex()
vge_path = encrypt_file(str(plain), key_hex)
chunks = list(decrypt_stream(vge_path, key_hex))
assert len(chunks) > 1
assert b"".join(chunks) == original
def test_encrypt_file_wrong_key_produces_garbage(tmp_path):
original = b"secret recording content" * 50
plain = tmp_path / "test.mp4"
plain.write_bytes(original)
key1 = os.urandom(32).hex()
key2 = os.urandom(32).hex()
vge_path = encrypt_file(str(plain), key1)
decrypted = b"".join(decrypt_stream(vge_path, key2))
assert decrypted != original

48
tests/unit/test_face.py Normal file
View File

@ -0,0 +1,48 @@
import numpy as np
import pytest
from vigilar.detection.face import FaceRecognizer, FaceResult
def test_face_recognizer_init():
fr = FaceRecognizer(match_threshold=0.6)
assert fr._threshold == 0.6
assert fr.is_loaded is False
def test_load_profiles_empty(test_db):
fr = FaceRecognizer()
fr.load_profiles(test_db)
assert fr.is_loaded is True
assert len(fr._known_encodings) == 0
def test_add_encoding():
fr = FaceRecognizer()
fr.is_loaded = True
enc = np.random.rand(128).astype(np.float32)
fr.add_encoding(1, enc)
assert len(fr._known_encodings) == 1
def test_find_match():
fr = FaceRecognizer(match_threshold=0.6)
fr.is_loaded = True
enc = np.random.rand(128).astype(np.float32)
fr.add_encoding(1, enc)
result = fr._find_match(enc)
assert result is not None
assert result[0] == 1
def test_no_match():
fr = FaceRecognizer(match_threshold=0.6)
fr.is_loaded = True
fr.add_encoding(1, np.ones(128, dtype=np.float32))
result = fr._find_match(-np.ones(128, dtype=np.float32))
assert result is None
def test_face_result_dataclass():
crop = np.zeros((100, 100, 3), dtype=np.uint8)
r = FaceResult(1, "Bob", 0.85, crop, (10, 20, 50, 50))
assert r.profile_id == 1

View File

@ -0,0 +1,17 @@
import numpy as np
from vigilar.detection.heatmap import accumulate_detections, render_heatmap_png
def test_accumulate_detections_returns_grid():
bboxes = [[0.5, 0.5, 0.1, 0.1], [0.5, 0.5, 0.1, 0.1], [0.2, 0.3, 0.1, 0.1]]
grid = accumulate_detections(bboxes, grid_w=64, grid_h=36)
assert grid.shape == (36, 64)
assert grid.sum() > 0
assert grid[18, 32] > grid[0, 0] # center of [0.5, 0.5]
def test_generate_heatmap_returns_png_bytes():
bboxes = [[0.3, 0.4, 0.1, 0.1]] * 20
grid = accumulate_detections(bboxes)
frame = np.zeros((360, 640, 3), dtype=np.uint8)
png_bytes = render_heatmap_png(grid, frame)
assert isinstance(png_bytes, bytes)
assert png_bytes[:4] == b"\x89PNG"

View File

@ -0,0 +1,29 @@
import pytest
from vigilar.config import VigilarConfig
from vigilar.web.app import create_app
@pytest.fixture
def kiosk_app():
cfg = VigilarConfig()
app = create_app(cfg)
app.config["TESTING"] = True
return app
def test_ambient_route_exists(kiosk_app):
with kiosk_app.test_client() as c:
rv = c.get("/kiosk/ambient")
assert rv.status_code == 200
def test_ambient_contains_clock(kiosk_app):
with kiosk_app.test_client() as c:
rv = c.get("/kiosk/ambient")
assert b"clock" in rv.data
def test_ambient_contains_hls(kiosk_app):
with kiosk_app.test_client() as c:
rv = c.get("/kiosk/ambient")
assert b"hls.min.js" in rv.data

View File

@ -0,0 +1,39 @@
import time
from vigilar.detection.package import PackageTracker, PackageState
def test_initial_state_is_idle():
tracker = PackageTracker(camera_id="front", latitude=45.0, longitude=-85.0)
assert tracker.state == PackageState.IDLE
def test_person_detected_starts_tracking():
tracker = PackageTracker(camera_id="front", latitude=45.0, longitude=-85.0)
tracker.on_person_detected(time.time())
assert tracker._person_last_seen > 0
def test_person_departed_then_package_detected():
tracker = PackageTracker(camera_id="front", latitude=45.0, longitude=-85.0)
now = time.time()
tracker.on_person_detected(now - 60)
tracker._person_last_seen = now - 35
result = tracker.check_for_package(
detections=[{"class_id": 28, "bbox": [100, 200, 50, 50], "confidence": 0.7}], now=now)
assert result is True
assert tracker.state == PackageState.PRESENT
def test_package_collected():
tracker = PackageTracker(camera_id="front", latitude=45.0, longitude=-85.0)
now = time.time()
tracker._state = PackageState.PRESENT
tracker._package_bbox = [100, 200, 50, 50]
tracker._detected_at = now - 3600
tracker.on_person_detected(now)
collected = tracker.check_collected(detections=[], now=now)
assert collected is True
assert tracker.state == PackageState.COLLECTED
def test_reminder_time_calculation():
tracker = PackageTracker(camera_id="front", latitude=45.0, longitude=-85.0)
now = time.time()
reminder = tracker._calculate_reminder_time(now)
assert reminder >= now
assert reminder <= now + 3 * 3600 + 60

View File

@ -0,0 +1,21 @@
import time
import pytest
from vigilar.storage.queries import insert_package_event, get_active_packages, update_package_status
def test_insert_package_event(test_db):
pkg_id = insert_package_event(test_db, camera_id="front", detected_at=time.time(), status="PRESENT")
assert pkg_id > 0
def test_get_active_packages(test_db):
now = time.time()
insert_package_event(test_db, camera_id="front", detected_at=now, status="PRESENT")
insert_package_event(test_db, camera_id="back", detected_at=now, status="COLLECTED")
active = get_active_packages(test_db)
assert len(active) == 1
assert active[0]["camera_id"] == "front"
def test_update_package_status(test_db):
pkg_id = insert_package_event(test_db, camera_id="front", detected_at=time.time(), status="PRESENT")
update_package_status(test_db, pkg_id, "REMINDED", reminded_at=time.time())
active = get_active_packages(test_db)
assert active[0]["status"] == "REMINDED"

View File

@ -0,0 +1,21 @@
from vigilar.constants import EventType
from vigilar.storage.schema import package_events
def test_package_delivered_event_type():
assert EventType.PACKAGE_DELIVERED == "PACKAGE_DELIVERED"
def test_package_reminder_event_type():
assert EventType.PACKAGE_REMINDER == "PACKAGE_REMINDER"
def test_package_collected_event_type():
assert EventType.PACKAGE_COLLECTED == "PACKAGE_COLLECTED"
def test_package_events_table_columns():
col_names = [c.name for c in package_events.columns]
assert "camera_id" in col_names
assert "detected_at" in col_names
assert "status" in col_names

View File

@ -0,0 +1,41 @@
import time
from vigilar.pets.rules import PetState, evaluate_condition, evaluate_rule
def test_detected_in_zone_true():
state = PetState(pet_id="milo", current_zone="EXTERIOR")
assert evaluate_condition({"type": "detected_in_zone", "zone": "EXTERIOR"}, state, time.time()) is True
def test_detected_in_zone_false():
state = PetState(pet_id="milo", current_zone="INTERIOR")
assert evaluate_condition({"type": "detected_in_zone", "zone": "EXTERIOR"}, state, time.time()) is False
def test_not_seen_in_zone_true():
now = time.time()
state = PetState(pet_id="milo", current_zone="INTERIOR", last_seen_time=now - 3600)
assert evaluate_condition({"type": "not_seen_in_zone", "zone": "EXTERIOR", "minutes": 30}, state, now) is True
def test_not_seen_anywhere():
now = time.time()
state = PetState(pet_id="milo", last_seen_time=now - 30000)
assert evaluate_condition({"type": "not_seen_anywhere", "minutes": 480}, state, now) is True
def test_detected_without_person():
assert evaluate_condition({"type": "detected_without_person"}, PetState(pet_id="snek", person_present=False), time.time()) is True
assert evaluate_condition({"type": "detected_without_person"}, PetState(pet_id="snek", person_present=True), time.time()) is False
def test_in_zone_longer_than():
now = time.time()
state = PetState(pet_id="milo", current_zone="EXTERIOR", zone_entry_time=now - 3000)
assert evaluate_condition({"type": "in_zone_longer_than", "zone": "EXTERIOR", "minutes": 45}, state, now) is True
def test_evaluate_rule_and_logic():
now = time.time()
state = PetState(pet_id="milo", current_zone="EXTERIOR", zone_entry_time=now - 3600, person_present=False)
rule = {"conditions": [{"type": "detected_in_zone", "zone": "EXTERIOR"}, {"type": "detected_without_person"}]}
assert evaluate_rule(rule, state, now) is True
def test_evaluate_rule_one_fails():
now = time.time()
state = PetState(pet_id="milo", current_zone="INTERIOR", person_present=False)
rule = {"conditions": [{"type": "detected_in_zone", "zone": "EXTERIOR"}, {"type": "detected_without_person"}]}
assert evaluate_rule(rule, state, now) is False

View File

@ -0,0 +1,55 @@
import json
import pytest
from vigilar.config import VigilarConfig
from vigilar.storage.queries import insert_pet, get_all_pets
from vigilar.web.app import create_app
@pytest.fixture
def rules_app(test_db):
cfg = VigilarConfig()
app = create_app(cfg)
app.config["TESTING"] = True
app.config["DB_ENGINE"] = test_db
insert_pet(test_db, name="Milo", species="dog")
return app
def _get_pet_id(rules_app):
db = rules_app.config["DB_ENGINE"]
return get_all_pets(db)[0]["id"]
def test_create_rule(rules_app):
pet_id = _get_pet_id(rules_app)
with rules_app.test_client() as c:
rv = c.post(f"/pets/{pet_id}/rules", json={
"name": "Outdoor timer",
"conditions": [{"type": "in_zone_longer_than", "zone": "EXTERIOR", "minutes": 45}],
"action": "push_notify", "action_message": "{pet_name} outside", "cooldown_minutes": 30})
assert rv.status_code == 200
assert rv.get_json()["ok"] is True
def test_list_rules(rules_app):
pet_id = _get_pet_id(rules_app)
with rules_app.test_client() as c:
c.post(f"/pets/{pet_id}/rules", json={"name": "Test", "conditions": [], "action": "log_event", "cooldown_minutes": 30})
rv = c.get(f"/pets/{pet_id}/rules")
assert len(rv.get_json()["rules"]) == 1
def test_delete_rule(rules_app):
pet_id = _get_pet_id(rules_app)
with rules_app.test_client() as c:
rv = c.post(f"/pets/{pet_id}/rules", json={"name": "Del", "conditions": [], "action": "log_event", "cooldown_minutes": 30})
rule_id = rv.get_json()["id"]
rv = c.delete(f"/pets/{pet_id}/rules/{rule_id}")
assert rv.status_code == 200
def test_invalid_action(rules_app):
pet_id = _get_pet_id(rules_app)
with rules_app.test_client() as c:
rv = c.post(f"/pets/{pet_id}/rules", json={"name": "Bad", "conditions": [], "action": "invalid", "cooldown_minutes": 30})
assert rv.status_code == 400
def test_rule_templates(rules_app):
with rules_app.test_client() as c:
rv = c.get("/pets/api/rule-templates")
assert rv.status_code == 200
assert len(rv.get_json()) >= 5

View File

@ -0,0 +1,36 @@
import json
import pytest
from vigilar.storage.queries import (
count_pet_rules, delete_pet_rule, get_all_enabled_rules,
get_pet_rules, insert_pet_rule, update_pet_rule,
)
def test_insert_and_get(test_db):
rid = insert_pet_rule(test_db, "pet1", "Outdoor timer",
json.dumps([{"type": "in_zone_longer_than", "zone": "EXTERIOR", "minutes": 45}]),
"push_notify", "{pet_name} outside", 30, 0)
assert rid > 0
rules = get_pet_rules(test_db, "pet1")
assert len(rules) == 1
assert rules[0]["name"] == "Outdoor timer"
def test_get_all_enabled(test_db):
insert_pet_rule(test_db, "pet1", "A", json.dumps([]), "push_notify", "", 30, 0)
insert_pet_rule(test_db, "pet2", "B", json.dumps([]), "log_event", "", 60, 0)
assert len(get_all_enabled_rules(test_db)) == 2
def test_update(test_db):
rid = insert_pet_rule(test_db, "pet1", "Test", json.dumps([]), "push_notify", "", 30, 0)
update_pet_rule(test_db, rid, name="Updated", cooldown_minutes=60)
rules = get_pet_rules(test_db, "pet1")
assert rules[0]["name"] == "Updated"
def test_delete(test_db):
rid = insert_pet_rule(test_db, "pet1", "Test", json.dumps([]), "push_notify", "", 30, 0)
delete_pet_rule(test_db, rid)
assert len(get_pet_rules(test_db, "pet1")) == 0
def test_count(test_db):
for i in range(5):
insert_pet_rule(test_db, "pet1", f"Rule {i}", json.dumps([]), "push_notify", "", 30, i)
assert count_pet_rules(test_db, "pet1") == 5

39
tests/unit/test_pin.py Normal file
View File

@ -0,0 +1,39 @@
"""Tests for PIN hashing and verification."""
from vigilar.alerts.pin import hash_pin, verify_pin
def test_hash_pin_returns_formatted_string():
result = hash_pin("1234")
parts = result.split("$")
assert len(parts) == 3
assert parts[0] == "pbkdf2_sha256"
assert len(parts[1]) == 32 # 16 bytes hex = 32 chars
assert len(parts[2]) == 64 # 32 bytes hex = 64 chars
def test_verify_pin_correct():
stored = hash_pin("5678")
assert verify_pin("5678", stored) is True
def test_verify_pin_wrong():
stored = hash_pin("5678")
assert verify_pin("0000", stored) is False
def test_verify_pin_empty_hash_returns_true():
assert verify_pin("1234", "") is True
assert verify_pin("", "") is True
def test_hash_pin_different_salts():
h1 = hash_pin("1234")
h2 = hash_pin("1234")
assert h1 != h2
def test_verify_pin_handles_unicode():
stored = hash_pin("p@ss!")
assert verify_pin("p@ss!", stored) is True
assert verify_pin("p@ss?", stored) is False

View File

@ -0,0 +1,35 @@
"""Test that event processor calls send_alert for alert actions."""
from unittest.mock import MagicMock, patch
from vigilar.config import VigilarConfig
from vigilar.events.processor import EventProcessor
def test_execute_action_calls_send_alert():
cfg = VigilarConfig()
processor = EventProcessor(cfg)
processor._engine = MagicMock()
mock_bus = MagicMock()
with patch("vigilar.events.processor.send_alert") as mock_send:
processor._execute_action(
action="alert_all", event_id=42, bus=mock_bus,
payload={"species": "bear"},
event_type="WILDLIFE_PREDATOR", severity="CRITICAL", source_id="front",
)
mock_send.assert_called_once()
def test_execute_action_push_and_record():
cfg = VigilarConfig()
processor = EventProcessor(cfg)
processor._engine = MagicMock()
mock_bus = MagicMock()
with patch("vigilar.events.processor.send_alert") as mock_send:
processor._execute_action(
action="push_and_record", event_id=10, bus=mock_bus,
payload={}, event_type="PERSON_DETECTED", severity="WARNING", source_id="cam1",
)
mock_send.assert_called_once()

View File

@ -0,0 +1,10 @@
"""Test encryption integration flag in recorder."""
import os
from vigilar.camera.recorder import AdaptiveRecorder
def test_recorder_has_encryption_support():
"""Verify recorder imports encryption module correctly."""
from vigilar.storage.encryption import encrypt_file
assert callable(encrypt_file)

View File

@ -0,0 +1,120 @@
"""Tests for recording list, download, and delete API."""
import os
import time
from pathlib import Path
from unittest.mock import patch
import pytest
from vigilar.config import VigilarConfig
from vigilar.storage.encryption import encrypt_file
from vigilar.storage.queries import insert_recording
from vigilar.web.app import create_app
@pytest.fixture
def app_with_db(test_db):
cfg = VigilarConfig()
app = create_app(cfg)
app.config["TESTING"] = True
app.config["DB_ENGINE"] = test_db
return app
@pytest.fixture
def seeded_app(app_with_db, test_db):
now = int(time.time())
insert_recording(test_db, camera_id="front", started_at=now - 3600, ended_at=now - 3500,
duration_s=100, file_path="/tmp/r1.mp4", file_size=1000, trigger="MOTION", encrypted=0, starred=0)
insert_recording(test_db, camera_id="front", started_at=now - 1800, ended_at=now - 1700,
duration_s=100, file_path="/tmp/r2.mp4", file_size=2000, trigger="PERSON", encrypted=0,
starred=1, detection_type="person")
insert_recording(test_db, camera_id="back", started_at=now - 900, ended_at=now - 800,
duration_s=100, file_path="/tmp/r3.mp4", file_size=3000, trigger="MOTION", encrypted=0, starred=0)
return app_with_db
def test_recordings_api_list_all(seeded_app):
with seeded_app.test_client() as c:
rv = c.get("/recordings/api/list")
assert rv.status_code == 200
assert len(rv.get_json()) == 3
def test_recordings_api_filter_camera(seeded_app):
with seeded_app.test_client() as c:
rv = c.get("/recordings/api/list?camera_id=front")
data = rv.get_json()
assert len(data) == 2
assert all(r["camera_id"] == "front" for r in data)
def test_recordings_api_filter_starred(seeded_app):
with seeded_app.test_client() as c:
rv = c.get("/recordings/api/list?starred=1")
data = rv.get_json()
assert len(data) == 1
assert data[0]["starred"] is True
def test_recordings_api_filter_detection_type(seeded_app):
with seeded_app.test_client() as c:
rv = c.get("/recordings/api/list?detection_type=person")
data = rv.get_json()
assert len(data) == 1
def test_download_plain_mp4(app_with_db, test_db, tmp_path):
mp4_file = tmp_path / "plain.mp4"
mp4_file.write_bytes(b"fake mp4 content")
rec_id = insert_recording(test_db, camera_id="front", started_at=1000, ended_at=1100,
duration_s=100, file_path=str(mp4_file), file_size=16, trigger="MOTION", encrypted=0, starred=0)
with app_with_db.test_client() as c:
rv = c.get(f"/recordings/{rec_id}/download")
assert rv.status_code == 200
assert rv.data == b"fake mp4 content"
def test_download_encrypted_vge(app_with_db, test_db, tmp_path):
key_hex = os.urandom(32).hex()
mp4_file = tmp_path / "encrypted.mp4"
original_content = b"secret recording data" * 100
mp4_file.write_bytes(original_content)
vge_path = encrypt_file(str(mp4_file), key_hex)
rec_id = insert_recording(test_db, camera_id="front", started_at=2000, ended_at=2100,
duration_s=100, file_path=vge_path, file_size=len(original_content) + 16,
trigger="MOTION", encrypted=1, starred=0)
with patch.dict(os.environ, {"VIGILAR_ENCRYPTION_KEY": key_hex}):
with app_with_db.test_client() as c:
rv = c.get(f"/recordings/{rec_id}/download")
assert rv.status_code == 200
assert rv.data == original_content
def test_download_not_found(app_with_db):
with app_with_db.test_client() as c:
rv = c.get("/recordings/99999/download")
assert rv.status_code == 404
def test_delete_recording(app_with_db, test_db, tmp_path):
mp4_file = tmp_path / "to_delete.mp4"
mp4_file.write_bytes(b"content")
rec_id = insert_recording(test_db, camera_id="front", started_at=3000, ended_at=3100,
duration_s=100, file_path=str(mp4_file), file_size=7, trigger="MOTION", encrypted=0, starred=0)
with app_with_db.test_client() as c:
rv = c.delete(f"/recordings/{rec_id}")
assert rv.status_code == 200
assert rv.get_json()["ok"] is True
assert not mp4_file.exists()
from sqlalchemy import select
from vigilar.storage.schema import recordings
with test_db.connect() as conn:
row = conn.execute(select(recordings).where(recordings.c.id == rec_id)).first()
assert row is None
def test_delete_recording_not_found(app_with_db):
with app_with_db.test_client() as c:
rv = c.delete("/recordings/99999")
assert rv.status_code == 404

36
tests/unit/test_reel.py Normal file
View File

@ -0,0 +1,36 @@
import datetime
import time
import pytest
from vigilar.highlights.reel import score_event, select_top_events
from vigilar.constants import EventType
from vigilar.storage.queries import insert_event
from vigilar.config import HighlightsConfig
def test_score_pet_escape():
assert score_event(EventType.PET_ESCAPE, {}) == 10
def test_score_wildlife_predator():
assert score_event(EventType.WILDLIFE_PREDATOR, {}) == 9
def test_score_person_detected():
assert score_event(EventType.PERSON_DETECTED, {}) == 4
def test_score_unknown_returns_zero():
assert score_event(EventType.MOTION_START, {}) == 0
def test_score_pet_high_motion():
assert score_event(EventType.PET_DETECTED, {"motion_confidence": 0.9}) == 6
def test_score_pet_normal():
assert score_event(EventType.PET_DETECTED, {"motion_confidence": 0.3}) == 1
def test_select_top_events(test_db):
today = datetime.date.today()
insert_event(test_db, EventType.PET_ESCAPE, "ALERT", source_id="cam1", payload={"pet_name": "Angel"})
insert_event(test_db, EventType.PERSON_DETECTED, "WARNING", source_id="cam1")
insert_event(test_db, EventType.WILDLIFE_PREDATOR, "CRITICAL", source_id="cam2", payload={"species": "bear"})
insert_event(test_db, EventType.MOTION_START, "WARNING", source_id="cam1")
config = HighlightsConfig(max_clips=3)
events_list = select_top_events(test_db, today, config)
assert len(events_list) <= 3
assert events_list[0]["type"] in (EventType.PET_ESCAPE, EventType.WILDLIFE_PREDATOR)

38
tests/unit/test_sender.py Normal file
View File

@ -0,0 +1,38 @@
"""Tests for notification content builder and Web Push sender."""
from vigilar.alerts.sender import build_notification
from vigilar.constants import EventType, Severity
def test_build_notification_person_detected():
result = build_notification(EventType.PERSON_DETECTED, Severity.WARNING, "front_entrance", {})
assert result["title"] == "Person Detected"
assert "Front Entrance" in result["body"]
def test_build_notification_pet_escape():
result = build_notification(EventType.PET_ESCAPE, Severity.ALERT, "back_deck", {"pet_name": "Angel"})
assert result["title"] == "Pet Alert"
assert "Angel" in result["body"]
def test_build_notification_wildlife_predator():
result = build_notification(EventType.WILDLIFE_PREDATOR, Severity.CRITICAL, "front_entrance", {"species": "bear"})
assert result["title"] == "Wildlife Alert"
assert "bear" in result["body"].lower()
def test_build_notification_power_loss():
result = build_notification(EventType.POWER_LOSS, Severity.CRITICAL, "ups", {})
assert result["title"] == "Power Alert"
assert "battery" in result["body"].lower()
def test_build_notification_unknown_event_type():
result = build_notification(EventType.MOTION_START, Severity.WARNING, "cam1", {})
assert result["title"] == "Vigilar Alert"
def test_build_notification_has_url():
result = build_notification(EventType.PERSON_DETECTED, Severity.WARNING, "front", {})
assert "url" in result

18
tests/unit/test_solar.py Normal file
View File

@ -0,0 +1,18 @@
import datetime
from vigilar.detection.solar import get_sunset
def test_sunset_returns_time():
result = get_sunset(45.0, -85.0, datetime.date(2026, 6, 21))
assert isinstance(result, datetime.time)
def test_sunset_equator():
result = get_sunset(0.0, 0.0, datetime.date(2026, 3, 20))
assert 17 <= result.hour <= 19
def test_sunset_different_dates_vary():
d1 = get_sunset(45.0, -85.0, datetime.date(2026, 3, 1))
d2 = get_sunset(45.0, -85.0, datetime.date(2026, 9, 1))
assert d1 != d2

View File

@ -0,0 +1,7 @@
"""Test syslog handler configuration for alerts logger."""
def test_alerts_logger_name():
from vigilar.alerts.sender import log as alerts_log
assert alerts_log.name == "vigilar.alerts"

View File

@ -0,0 +1,79 @@
"""Tests for PIN verification on arm/disarm endpoints."""
import pytest
from vigilar.alerts.pin import hash_pin
from vigilar.config import VigilarConfig, SecurityConfig
from vigilar.web.app import create_app
@pytest.fixture
def app_with_pin():
pin_hash = hash_pin("1234")
cfg = VigilarConfig(
security=SecurityConfig(
pin_hash=pin_hash,
recovery_passphrase_hash=hash_pin("recover123"),
)
)
app = create_app(cfg)
app.config["TESTING"] = True
return app
@pytest.fixture
def app_no_pin():
cfg = VigilarConfig()
app = create_app(cfg)
app.config["TESTING"] = True
return app
def test_arm_without_pin_set(app_no_pin):
with app_no_pin.test_client() as c:
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY"})
assert rv.status_code == 200
assert rv.get_json()["ok"] is True
def test_arm_correct_pin(app_with_pin):
with app_with_pin.test_client() as c:
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "1234"})
assert rv.status_code == 200
assert rv.get_json()["ok"] is True
def test_arm_wrong_pin(app_with_pin):
with app_with_pin.test_client() as c:
rv = c.post("/system/api/arm", json={"mode": "ARMED_AWAY", "pin": "0000"})
assert rv.status_code == 401
def test_disarm_correct_pin(app_with_pin):
with app_with_pin.test_client() as c:
rv = c.post("/system/api/disarm", json={"pin": "1234"})
assert rv.status_code == 200
def test_disarm_wrong_pin(app_with_pin):
with app_with_pin.test_client() as c:
rv = c.post("/system/api/disarm", json={"pin": "9999"})
assert rv.status_code == 401
def test_reset_pin_correct_passphrase(app_with_pin):
with app_with_pin.test_client() as c:
rv = c.post("/system/api/reset-pin", json={
"recovery_passphrase": "recover123",
"new_pin": "5678",
})
assert rv.status_code == 200
assert rv.get_json()["ok"] is True
def test_reset_pin_wrong_passphrase(app_with_pin):
with app_with_pin.test_client() as c:
rv = c.post("/system/api/reset-pin", json={
"recovery_passphrase": "wrong",
"new_pin": "5678",
})
assert rv.status_code == 401

View File

@ -0,0 +1,49 @@
import datetime
import time
import pytest
from vigilar.config import VigilarConfig, CameraConfig
from vigilar.storage.queries import insert_timelapse_schedule, get_timelapse_schedules, delete_timelapse_schedule
from vigilar.highlights.timelapse import generate_timelapse
from vigilar.web.app import create_app
def test_insert_schedule(test_db):
sid = insert_timelapse_schedule(test_db, "front", "Daily", 6, 20, "20:00")
assert sid > 0
def test_get_schedules(test_db):
insert_timelapse_schedule(test_db, "front", "A", 6, 12, "12:00")
insert_timelapse_schedule(test_db, "front", "B", 12, 20, "20:00")
assert len(get_timelapse_schedules(test_db, "front")) == 2
def test_delete_schedule(test_db):
sid = insert_timelapse_schedule(test_db, "front", "Test", 6, 20, "20:00")
delete_timelapse_schedule(test_db, sid)
assert len(get_timelapse_schedules(test_db, "front")) == 0
def test_generate_timelapse_no_recordings(test_db, tmp_path):
result = generate_timelapse("front", datetime.date.today(), 6, 20, 30, str(tmp_path), test_db)
assert result is None
@pytest.fixture
def timelapse_app(test_db):
cfg = VigilarConfig(cameras=[CameraConfig(id="front", display_name="Front", rtsp_url="rtsp://x")])
app = create_app(cfg)
app.config["TESTING"] = True
app.config["DB_ENGINE"] = test_db
return app
def test_post_timelapse(timelapse_app):
with timelapse_app.test_client() as c:
rv = c.post("/cameras/front/timelapse", json={"date": "2026-04-02"})
assert rv.status_code in (200, 202)
def test_get_schedules_route(timelapse_app, test_db):
insert_timelapse_schedule(test_db, "front", "Test", 6, 20, "20:00")
with timelapse_app.test_client() as c:
rv = c.get("/cameras/front/timelapse/schedules")
assert len(rv.get_json()) == 1
def test_create_schedule_route(timelapse_app):
with timelapse_app.test_client() as c:
rv = c.post("/cameras/front/timelapse/schedule", json={"name": "Daily", "time": "20:00"})
assert rv.status_code == 200

View File

@ -0,0 +1,57 @@
import time
import pytest
from vigilar.storage.queries import (
create_face_profile, get_face_profile, get_all_profiles, update_face_profile,
delete_face_profile_cascade, insert_face_embedding, get_embeddings_for_profile,
insert_visit, get_visits, get_active_visits,
)
def test_create_and_get_profile(test_db):
now = time.time()
pid = create_face_profile(test_db, first_seen_at=now, last_seen_at=now)
assert pid > 0
profile = get_face_profile(test_db, pid)
assert profile is not None
assert profile["name"] is None
def test_get_all_profiles(test_db):
now = time.time()
create_face_profile(test_db, first_seen_at=now, last_seen_at=now)
create_face_profile(test_db, name="Bob", first_seen_at=now, last_seen_at=now)
assert len(get_all_profiles(test_db)) == 2
def test_update_profile(test_db):
pid = create_face_profile(test_db, first_seen_at=0, last_seen_at=0)
update_face_profile(test_db, pid, name="Alice")
assert get_face_profile(test_db, pid)["name"] == "Alice"
def test_insert_embedding(test_db):
pid = create_face_profile(test_db, first_seen_at=0, last_seen_at=0)
eid = insert_face_embedding(test_db, pid, "AAAA", "front", time.time())
assert eid > 0
assert len(get_embeddings_for_profile(test_db, pid)) == 1
def test_insert_and_get_visit(test_db):
pid = create_face_profile(test_db, first_seen_at=0, last_seen_at=0)
insert_visit(test_db, pid, "front", time.time())
assert len(get_visits(test_db)) == 1
def test_get_active_visits(test_db):
pid = create_face_profile(test_db, first_seen_at=0, last_seen_at=0)
insert_visit(test_db, pid, "front", time.time())
assert len(get_active_visits(test_db)) == 1
def test_delete_cascade(test_db):
pid = create_face_profile(test_db, first_seen_at=0, last_seen_at=0)
insert_face_embedding(test_db, pid, "AAAA", "front", time.time())
insert_visit(test_db, pid, "front", time.time())
delete_face_profile_cascade(test_db, pid)
assert get_face_profile(test_db, pid) is None
assert len(get_embeddings_for_profile(test_db, pid)) == 0

View File

@ -0,0 +1,69 @@
import time
import pytest
from vigilar.config import VigilarConfig
from vigilar.storage.queries import create_face_profile, insert_visit
from vigilar.web.app import create_app
@pytest.fixture
def visitor_app(test_db):
cfg = VigilarConfig()
app = create_app(cfg)
app.config["TESTING"] = True
app.config["DB_ENGINE"] = test_db
return app
@pytest.fixture
def seeded_visitors(test_db):
now = time.time()
pid1 = create_face_profile(test_db, name="Bob", first_seen_at=now - 86400, last_seen_at=now)
pid2 = create_face_profile(test_db, first_seen_at=now - 3600, last_seen_at=now)
insert_visit(test_db, pid1, "front", now - 3600)
insert_visit(test_db, pid2, "front", now - 1800)
return pid1, pid2
def test_get_profiles(visitor_app, seeded_visitors):
with visitor_app.test_client() as c:
rv = c.get("/visitors/api/profiles")
assert rv.status_code == 200
assert len(rv.get_json()["profiles"]) == 2
def test_get_visits(visitor_app, seeded_visitors):
with visitor_app.test_client() as c:
assert len(c.get("/visitors/api/visits").get_json()["visits"]) == 2
def test_label_with_consent(visitor_app, seeded_visitors, test_db):
_, pid2 = seeded_visitors
with visitor_app.test_client() as c:
rv = c.post(f"/visitors/{pid2}/label", json={"name": "Alice", "consent": True})
assert rv.status_code == 200
from vigilar.storage.queries import get_face_profile
assert get_face_profile(test_db, pid2)["name"] == "Alice"
def test_label_requires_consent(visitor_app, seeded_visitors):
_, pid2 = seeded_visitors
with visitor_app.test_client() as c:
assert c.post(
f"/visitors/{pid2}/label", json={"name": "Alice", "consent": False}
).status_code == 400
def test_forget(visitor_app, seeded_visitors, test_db):
pid1, _ = seeded_visitors
with visitor_app.test_client() as c:
assert c.delete(f"/visitors/{pid1}/forget").status_code == 200
from vigilar.storage.queries import get_face_profile
assert get_face_profile(test_db, pid1) is None
def test_ignore(visitor_app, seeded_visitors, test_db):
_, pid2 = seeded_visitors
with visitor_app.test_client() as c:
assert c.post(f"/visitors/{pid2}/ignore").status_code == 200
from vigilar.storage.queries import get_face_profile
assert get_face_profile(test_db, pid2)["ignored"] == 1

View File

@ -0,0 +1,39 @@
import time
from unittest.mock import patch, MagicMock
from vigilar.detection.weather import WeatherFetcher, _weather_code_to_text
def test_get_conditions_returns_dict_on_success():
fetcher = WeatherFetcher()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"current": {"temperature_2m": 15.5, "weather_code": 2}}
with patch("vigilar.detection.weather.requests.get", return_value=mock_response):
result = fetcher.get_conditions(45.0, -85.0)
assert result is not None
assert result["temperature_c"] == 15.5
assert isinstance(result["conditions"], str)
def test_get_conditions_returns_none_on_failure():
fetcher = WeatherFetcher()
with patch("vigilar.detection.weather.requests.get", side_effect=Exception("offline")):
result = fetcher.get_conditions(45.0, -85.0)
assert result is None
def test_get_conditions_caches():
fetcher = WeatherFetcher()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"current": {"temperature_2m": 10.0, "weather_code": 0}}
with patch("vigilar.detection.weather.requests.get", return_value=mock_response) as mock_get:
fetcher.get_conditions(45.0, -85.0)
fetcher.get_conditions(45.0, -85.0)
assert mock_get.call_count == 1
def test_weather_code_mapping():
assert _weather_code_to_text(0) == "Clear sky"
assert _weather_code_to_text(61) == "Light rain"
assert _weather_code_to_text(999) == "Unknown"

View File

@ -0,0 +1,49 @@
import pytest
from vigilar.config import VigilarConfig
from vigilar.storage.queries import insert_wildlife_sighting
from vigilar.web.app import create_app
@pytest.fixture
def wildlife_app(test_db):
cfg = VigilarConfig()
app = create_app(cfg)
app.config["TESTING"] = True
app.config["DB_ENGINE"] = test_db
for i in range(3):
insert_wildlife_sighting(test_db, species="deer", threat_level="PASSIVE",
camera_id="front", confidence=0.9)
insert_wildlife_sighting(test_db, species="bear", threat_level="PREDATOR",
camera_id="back", confidence=0.95)
return app
def test_wildlife_sightings_api(wildlife_app):
with wildlife_app.test_client() as c:
rv = c.get("/wildlife/api/sightings")
assert rv.status_code == 200
assert len(rv.get_json()["sightings"]) == 4
def test_wildlife_sightings_filter_species(wildlife_app):
with wildlife_app.test_client() as c:
rv = c.get("/wildlife/api/sightings?species=bear")
assert len(rv.get_json()["sightings"]) == 1
def test_wildlife_stats_api(wildlife_app):
with wildlife_app.test_client() as c:
rv = c.get("/wildlife/api/stats")
data = rv.get_json()
assert data["total"] == 4
assert data["per_species"]["deer"] == 3
def test_wildlife_frequency_api(wildlife_app):
with wildlife_app.test_client() as c:
rv = c.get("/wildlife/api/frequency")
assert rv.status_code == 200
assert len(rv.get_json()) == 6
def test_wildlife_export_csv(wildlife_app):
with wildlife_app.test_client() as c:
rv = c.get("/wildlife/api/export")
assert rv.status_code == 200
assert "text/csv" in rv.content_type
lines = rv.data.decode().strip().split("\n")
assert len(lines) == 5 # header + 4 rows

View File

@ -0,0 +1,38 @@
import time
import pytest
from vigilar.storage.queries import (
get_wildlife_stats, get_wildlife_frequency, get_wildlife_sightings_paginated,
insert_wildlife_sighting,
)
@pytest.fixture
def seeded_wildlife(test_db):
for i in range(5):
insert_wildlife_sighting(test_db, species="deer", threat_level="PASSIVE",
camera_id="front", confidence=0.9, event_id=i + 1)
for i in range(3):
insert_wildlife_sighting(test_db, species="raccoon", threat_level="NUISANCE",
camera_id="back", confidence=0.8, event_id=i + 10)
insert_wildlife_sighting(test_db, species="bear", threat_level="PREDATOR",
camera_id="front", confidence=0.95, event_id=20)
return test_db
def test_get_wildlife_stats(seeded_wildlife):
stats = get_wildlife_stats(seeded_wildlife)
assert stats["total"] == 9
assert stats["per_species"]["deer"] == 5
def test_get_wildlife_frequency(seeded_wildlife):
freq = get_wildlife_frequency(seeded_wildlife)
assert len(freq) == 6
def test_get_wildlife_sightings_paginated(seeded_wildlife):
page1 = get_wildlife_sightings_paginated(seeded_wildlife, limit=5, offset=0)
assert len(page1) == 5
page2 = get_wildlife_sightings_paginated(seeded_wildlife, limit=5, offset=5)
assert len(page2) == 4
def test_get_wildlife_sightings_filter_species(seeded_wildlife):
result = get_wildlife_sightings_paginated(seeded_wildlife, species="bear", limit=50, offset=0)
assert len(result) == 1
assert result[0]["species"] == "bear"

View File

@ -0,0 +1,13 @@
from vigilar.storage.schema import wildlife_sightings
def test_wildlife_sightings_has_temperature():
assert "temperature_c" in [c.name for c in wildlife_sightings.columns]
def test_wildlife_sightings_has_conditions():
assert "conditions" in [c.name for c in wildlife_sightings.columns]
def test_wildlife_sightings_has_bbox():
assert "bbox" in [c.name for c in wildlife_sightings.columns]

23
vigilar/alerts/pin.py Normal file
View File

@ -0,0 +1,23 @@
"""PIN hashing and verification using PBKDF2-SHA256."""
import hashlib
import hmac
import os
def hash_pin(pin: str) -> str:
salt = os.urandom(16)
dk = hashlib.pbkdf2_hmac("sha256", pin.encode(), salt, iterations=600_000)
return f"pbkdf2_sha256${salt.hex()}${dk.hex()}"
def verify_pin(pin: str, stored_hash: str) -> bool:
if not stored_hash:
return True
parts = stored_hash.split("$")
if len(parts) != 3 or parts[0] != "pbkdf2_sha256":
return False
salt = bytes.fromhex(parts[1])
expected = parts[2]
dk = hashlib.pbkdf2_hmac("sha256", pin.encode(), salt, iterations=600_000)
return hmac.compare_digest(dk.hex(), expected)

99
vigilar/alerts/sender.py Normal file
View File

@ -0,0 +1,99 @@
"""Web Push notification sender with content mapping."""
import json
import logging
import os
import time
from sqlalchemy.engine import Engine
from vigilar.constants import AlertChannel, AlertStatus, EventType, Severity
log = logging.getLogger("vigilar.alerts")
_CONTENT_MAP: dict[str, tuple[str, str]] = {
EventType.PERSON_DETECTED: ("Person Detected", "Person on {source}"),
EventType.PET_ESCAPE: ("Pet Alert", "{pet_name} detected on {source} (exterior)"),
EventType.UNKNOWN_ANIMAL: ("Unknown Animal", "Unknown {species} on {source}"),
EventType.WILDLIFE_PREDATOR: ("Wildlife Alert", "{species} detected — {source}"),
EventType.WILDLIFE_NUISANCE: ("Wildlife", "{species} on {source}"),
EventType.UNKNOWN_VEHICLE_DETECTED: ("Unknown Vehicle", "Unknown vehicle on {source}"),
EventType.POWER_LOSS: ("Power Alert", "UPS on battery"),
EventType.LOW_BATTERY: ("Battery Critical", "UPS battery low"),
EventType.PACKAGE_DELIVERED: ("Package Delivered", "Package delivered — {source}"),
EventType.PACKAGE_REMINDER: ("Package Reminder", "Package still on porch — {source}"),
}
def _format_source(source_id: str) -> str:
return source_id.replace("_", " ").title() if source_id else "Unknown"
def build_notification(event_type: str, severity: str, source_id: str, payload: dict) -> dict:
title, body_template = _CONTENT_MAP.get(event_type, ("Vigilar Alert", "{source}"))
source = _format_source(source_id)
body = body_template.format(
source=source,
pet_name=payload.get("pet_name", "Pet"),
species=payload.get("species", "animal").title(),
)
return {"title": title, "body": body, "url": f"/events?source={source_id}", "severity": severity}
def _get_vapid_private_key(config) -> str | None:
key = os.environ.get("VIGILAR_VAPID_PRIVATE_KEY")
if key:
return key
key_file = config.alerts.web_push.vapid_private_key_file
if os.path.exists(key_file):
with open(key_file) as f:
return f.read().strip()
return None
def send_alert(
engine: Engine,
event_type: str,
severity: str,
source_id: str,
payload: dict,
config=None,
event_id: int | None = None,
) -> int:
from vigilar.storage.queries import delete_push_subscription, get_push_subscriptions, insert_alert_log
notification = build_notification(event_type, severity, source_id, payload)
subscriptions = get_push_subscriptions(engine)
if not subscriptions:
log.info("No push subscriptions — skipping notification for %s", event_type)
return 0
vapid_key = _get_vapid_private_key(config) if config else None
if not vapid_key:
log.warning("VAPID private key not available — cannot send push notifications")
return 0
claim_email = config.alerts.web_push.vapid_claim_email if config else "mailto:admin@vigilar.local"
sent_count = 0
for sub in subscriptions:
try:
from pywebpush import webpush
webpush(
subscription_info={
"endpoint": sub["endpoint"],
"keys": {"p256dh": sub["p256dh_key"], "auth": sub["auth_key"]},
},
data=json.dumps(notification),
vapid_private_key=vapid_key,
vapid_claims={"sub": claim_email},
)
insert_alert_log(engine, event_id, AlertChannel.WEB_PUSH, AlertStatus.SENT)
sent_count += 1
except Exception as e:
error_msg = str(e)
insert_alert_log(engine, event_id, AlertChannel.WEB_PUSH, AlertStatus.FAILED, error_msg)
if "410" in error_msg or "Gone" in error_msg:
delete_push_subscription(engine, sub["endpoint"])
return sent_count

View File

@ -156,6 +156,27 @@ class AdaptiveRecorder:
duration, self._frame_count, file_size, duration, self._frame_count, file_size,
) )
# Encrypt if key is available
encryption_key = os.environ.get("VIGILAR_ENCRYPTION_KEY")
if encryption_key and self._current_path and self._current_path.exists():
try:
from vigilar.storage.encryption import encrypt_file
vge_path = encrypt_file(str(self._current_path), encryption_key)
segment = RecordingSegment(
file_path=vge_path,
started_at=self._started_at,
ended_at=ended_at,
duration_s=duration,
file_size=Path(vge_path).stat().st_size,
trigger=self._current_trigger,
fps=self._current_fps,
frame_count=self._frame_count,
)
log.info("Encrypted recording: %s", Path(vge_path).name)
except Exception:
log.exception("Encryption failed for %s, keeping plain MP4",
self._current_path.name)
self._process = None self._process = None
self._current_path = None self._current_path = None
return segment return segment

View File

@ -275,6 +275,12 @@ def run_camera_worker(
detections = yolo_detector.detect(frame) detections = yolo_detector.detect(frame)
for det in detections: for det in detections:
category = YOLODetector.classify(det) category = YOLODetector.classify(det)
frame_h, frame_w = frame.shape[:2]
bx, by, bw, bh = det.bbox
norm_bbox = [
round(bx / frame_w, 4), round(by / frame_h, 4),
round(bw / frame_w, 4), round(bh / frame_h, 4),
]
if category == "domestic_animal": if category == "domestic_animal":
# Crop for pet ID and staging # Crop for pet ID and staging
x, y, w, h = det.bbox x, y, w, h = det.bbox
@ -295,6 +301,7 @@ def run_camera_worker(
"confidence": round(det.confidence, 3), "confidence": round(det.confidence, 3),
"camera_location": camera_cfg.location, "camera_location": camera_cfg.location,
"crop_path": crop_path, "crop_path": crop_path,
"bbox": norm_bbox,
} }
if pet_result and pet_result.is_identified: if pet_result and pet_result.is_identified:
payload["pet_id"] = pet_result.pet_id payload["pet_id"] = pet_result.pet_id
@ -320,6 +327,7 @@ def run_camera_worker(
confidence=round(det.confidence, 3), confidence=round(det.confidence, 3),
camera_location=camera_cfg.location, camera_location=camera_cfg.location,
crop_path=None, crop_path=None,
bbox=norm_bbox,
) )
elif category == "person": elif category == "person":
@ -327,6 +335,7 @@ def run_camera_worker(
Topics.camera_motion_start(camera_id), Topics.camera_motion_start(camera_id),
detection="person", detection="person",
confidence=round(det.confidence, 3), confidence=round(det.confidence, 3),
bbox=norm_bbox,
) )
elif category == "vehicle": elif category == "vehicle":
@ -334,6 +343,7 @@ def run_camera_worker(
Topics.camera_motion_start(camera_id), Topics.camera_motion_start(camera_id),
detection="vehicle", detection="vehicle",
confidence=round(det.confidence, 3), confidence=round(det.confidence, 3),
bbox=norm_bbox,
) )
# Heartbeat every 10 seconds # Heartbeat every 10 seconds

View File

@ -280,6 +280,56 @@ class PetsConfig(BaseModel):
min_training_images: int = 20 min_training_images: int = 20
wildlife: WildlifeConfig = Field(default_factory=WildlifeConfig) wildlife: WildlifeConfig = Field(default_factory=WildlifeConfig)
activity: PetActivityConfig = Field(default_factory=PetActivityConfig) activity: PetActivityConfig = Field(default_factory=PetActivityConfig)
max_pets: int = 8
max_rules_per_pet: int = 32
rule_eval_interval_s: int = 60
# --- Visitors Config ---
class VisitorsConfig(BaseModel):
enabled: bool = False
match_threshold: float = 0.6
cameras: list[str] = Field(default_factory=list)
unknown_alert_threshold: int = 3
departure_timeout_s: int = 300
max_embeddings_per_profile: int = 10
face_crop_dir: str = "/var/vigilar/faces"
# --- Highlights Config ---
class HighlightsConfig(BaseModel):
enabled: bool = True
generate_time: str = "06:00"
max_clips: int = 10
clip_duration_s: int = 5
cameras: list[str] = Field(default_factory=list)
event_types: list[str] = Field(default_factory=list)
class KioskConfig(BaseModel):
ambient_enabled: bool = True
camera_rotation_s: int = 10
alert_timeout_s: int = 30
predator_alert_timeout_s: int = 60
dim_start: str = "23:00"
dim_end: str = "06:00"
highlight_play_time: str = "07:00"
# --- Location Config ---
class LocationConfig(BaseModel):
latitude: float = 0.0
longitude: float = 0.0
# --- Security Config ---
class SecurityConfig(BaseModel):
pin_hash: str = ""
recovery_passphrase_hash: str = ""
# --- Rule Config --- # --- Rule Config ---
@ -328,6 +378,11 @@ class VigilarConfig(BaseModel):
vehicles: VehicleConfig = Field(default_factory=VehicleConfig) vehicles: VehicleConfig = Field(default_factory=VehicleConfig)
health: HealthConfig = Field(default_factory=HealthConfig) health: HealthConfig = Field(default_factory=HealthConfig)
pets: PetsConfig = Field(default_factory=PetsConfig) pets: PetsConfig = Field(default_factory=PetsConfig)
visitors: VisitorsConfig = Field(default_factory=VisitorsConfig)
highlights: HighlightsConfig = Field(default_factory=HighlightsConfig)
kiosk: KioskConfig = Field(default_factory=KioskConfig)
security: SecurityConfig = Field(default_factory=SecurityConfig)
location: LocationConfig = Field(default_factory=LocationConfig)
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")

View File

@ -45,6 +45,13 @@ class EventType(StrEnum):
WILDLIFE_PREDATOR = "WILDLIFE_PREDATOR" WILDLIFE_PREDATOR = "WILDLIFE_PREDATOR"
WILDLIFE_NUISANCE = "WILDLIFE_NUISANCE" WILDLIFE_NUISANCE = "WILDLIFE_NUISANCE"
WILDLIFE_PASSIVE = "WILDLIFE_PASSIVE" WILDLIFE_PASSIVE = "WILDLIFE_PASSIVE"
PACKAGE_DELIVERED = "PACKAGE_DELIVERED"
PACKAGE_REMINDER = "PACKAGE_REMINDER"
PACKAGE_COLLECTED = "PACKAGE_COLLECTED"
PET_RULE_TRIGGERED = "PET_RULE_TRIGGERED"
KNOWN_VISITOR = "KNOWN_VISITOR"
UNKNOWN_VISITOR = "UNKNOWN_VISITOR"
VISITOR_DEPARTED = "VISITOR_DEPARTED"
# --- Sensor Types --- # --- Sensor Types ---
@ -75,6 +82,8 @@ class RecordingTrigger(StrEnum):
VEHICLE = "VEHICLE" VEHICLE = "VEHICLE"
PET = "PET" PET = "PET"
WILDLIFE = "WILDLIFE" WILDLIFE = "WILDLIFE"
HIGHLIGHT = "HIGHLIGHT"
TIMELAPSE = "TIMELAPSE"
# --- Alert Channels --- # --- Alert Channels ---
@ -180,6 +189,18 @@ class Topics:
def camera_wildlife_detected(camera_id: str) -> str: def camera_wildlife_detected(camera_id: str) -> str:
return f"vigilar/camera/{camera_id}/wildlife/detected" return f"vigilar/camera/{camera_id}/wildlife/detected"
@staticmethod
def camera_package_delivered(camera_id: str) -> str:
return f"vigilar/camera/{camera_id}/package/delivered"
@staticmethod
def camera_package_reminder(camera_id: str) -> str:
return f"vigilar/camera/{camera_id}/package/reminder"
@staticmethod
def camera_package_collected(camera_id: str) -> str:
return f"vigilar/camera/{camera_id}/package/collected"
@staticmethod @staticmethod
def pet_location(pet_name: str) -> str: def pet_location(pet_name: str) -> str:
return f"vigilar/pets/{pet_name}/location" return f"vigilar/pets/{pet_name}/location"
@ -188,6 +209,7 @@ class Topics:
SYSTEM_ARM_STATE = "vigilar/system/arm_state" SYSTEM_ARM_STATE = "vigilar/system/arm_state"
SYSTEM_ALERT = "vigilar/system/alert" SYSTEM_ALERT = "vigilar/system/alert"
SYSTEM_SHUTDOWN = "vigilar/system/shutdown" SYSTEM_SHUTDOWN = "vigilar/system/shutdown"
SYSTEM_RULES_UPDATED = "vigilar/system/rules_updated"
# Wildcard subscriptions # Wildcard subscriptions
ALL = "vigilar/#" ALL = "vigilar/#"

103
vigilar/detection/face.py Normal file
View File

@ -0,0 +1,103 @@
"""Local face recognition using face_recognition library (dlib-based)."""
import base64
import logging
from dataclasses import dataclass
import numpy as np
from sqlalchemy.engine import Engine
log = logging.getLogger(__name__)
@dataclass
class FaceResult:
profile_id: int | None
name: str | None
confidence: float
face_crop: np.ndarray
bbox: tuple[int, int, int, int]
class FaceRecognizer:
def __init__(self, match_threshold: float = 0.6):
self._threshold = match_threshold
self._known_encodings: list[np.ndarray] = []
self._known_profile_ids: list[int] = []
self._known_names: list[str | None] = []
self.is_loaded = False
def load_profiles(self, engine: Engine) -> None:
from vigilar.storage.queries import get_all_profiles, get_embeddings_for_profile
self._known_encodings = []
self._known_profile_ids = []
self._known_names = []
profiles = get_all_profiles(engine)
for profile in profiles:
embeddings = get_embeddings_for_profile(engine, profile["id"])
for emb in embeddings:
try:
raw = base64.b64decode(emb["embedding"])
encoding = np.frombuffer(raw, dtype=np.float32)
if len(encoding) == 128:
self._known_encodings.append(encoding)
self._known_profile_ids.append(profile["id"])
self._known_names.append(profile.get("name"))
except Exception:
continue
self.is_loaded = True
log.info(
"Loaded %d face encodings from %d profiles",
len(self._known_encodings),
len(profiles),
)
def add_encoding(self, profile_id: int, encoding: np.ndarray, name: str | None = None) -> None:
self._known_encodings.append(encoding)
self._known_profile_ids.append(profile_id)
self._known_names.append(name)
def identify(self, frame: np.ndarray) -> list[FaceResult]:
if not self.is_loaded:
return []
try:
import face_recognition
except ImportError:
log.warning("face_recognition not installed")
return []
face_locations = face_recognition.face_locations(frame)
if not face_locations:
return []
face_encodings = face_recognition.face_encodings(frame, face_locations)
results = []
for (top, right, bottom, left), encoding in zip(face_locations, face_encodings):
face_crop = frame[top:bottom, left:right].copy()
bbox = (left, top, right - left, bottom - top)
match = self._find_match(encoding)
if match:
pid, conf, name = match
results.append(FaceResult(pid, name, conf, face_crop, bbox))
else:
results.append(FaceResult(None, None, 0.0, face_crop, bbox))
return results
def _find_match(self, encoding: np.ndarray) -> tuple[int, float, str | None] | None:
if not self._known_encodings:
return None
distances = [float(np.linalg.norm(encoding - k)) for k in self._known_encodings]
min_idx = int(np.argmin(distances))
if distances[min_idx] < self._threshold:
return (
self._known_profile_ids[min_idx],
1.0 - distances[min_idx],
self._known_names[min_idx],
)
return None
def encoding_to_b64(encoding: np.ndarray) -> str:
return base64.b64encode(encoding.astype(np.float32).tobytes()).decode()
def b64_to_encoding(b64_str: str) -> np.ndarray:
return np.frombuffer(base64.b64decode(b64_str), dtype=np.float32)

View File

@ -0,0 +1,71 @@
"""Activity heatmap generation from detection bounding boxes."""
import io
import json
import logging
import time
from pathlib import Path
import numpy as np
log = logging.getLogger(__name__)
GRID_W = 64
GRID_H = 36
def accumulate_detections(
bboxes: list[list[float]], grid_w: int = GRID_W, grid_h: int = GRID_H,
) -> np.ndarray:
grid = np.zeros((grid_h, grid_w), dtype=np.float32)
for bbox in bboxes:
if len(bbox) < 4:
continue
x, y, w, h = bbox
cx = x
cy = y
gx = max(0, min(grid_w - 1, int(cx * grid_w)))
gy = max(0, min(grid_h - 1, int(cy * grid_h)))
grid[gy, gx] += 1
return grid
def _gaussian_blur(grid: np.ndarray, sigma: float = 2.0) -> np.ndarray:
size = int(sigma * 3) * 2 + 1
x = np.arange(size) - size // 2
kernel_1d = np.exp(-x ** 2 / (2 * sigma ** 2))
kernel_1d = kernel_1d / kernel_1d.sum()
blurred = np.apply_along_axis(lambda row: np.convolve(row, kernel_1d, mode="same"), 1, grid)
blurred = np.apply_along_axis(lambda col: np.convolve(col, kernel_1d, mode="same"), 0, blurred)
return blurred
def _apply_colormap(normalized: np.ndarray) -> np.ndarray:
h, w = normalized.shape
rgb = np.zeros((h, w, 3), dtype=np.uint8)
low = normalized <= 0.5
high = ~low
t_low = normalized[low] * 2
rgb[low, 0] = (t_low * 255).astype(np.uint8)
rgb[low, 1] = (t_low * 255).astype(np.uint8)
rgb[low, 2] = ((1 - t_low) * 255).astype(np.uint8)
t_high = (normalized[high] - 0.5) * 2
rgb[high, 0] = np.full(t_high.shape, 255, dtype=np.uint8)
rgb[high, 1] = ((1 - t_high) * 255).astype(np.uint8)
rgb[high, 2] = np.zeros(t_high.shape, dtype=np.uint8)
return rgb
def render_heatmap_png(grid: np.ndarray, frame: np.ndarray, alpha: float = 0.5) -> bytes:
from PIL import Image
blurred = _gaussian_blur(grid)
max_val = blurred.max()
normalized = blurred / max_val if max_val > 0 else blurred
heatmap_rgb = _apply_colormap(normalized)
frame_h, frame_w = frame.shape[:2]
heatmap_img = Image.fromarray(heatmap_rgb).resize((frame_w, frame_h), Image.BILINEAR)
frame_img = Image.fromarray(frame[:, :, ::-1]) # BGR to RGB
blended = Image.blend(frame_img, heatmap_img, alpha=alpha)
buf = io.BytesIO()
blended.save(buf, format="PNG")
return buf.getvalue()

View File

@ -0,0 +1,99 @@
"""Package delivery detection state machine."""
import datetime
import logging
import time
from enum import StrEnum
from vigilar.detection.solar import get_sunset
log = logging.getLogger(__name__)
PACKAGE_CLASS_IDS = {24, 26, 28} # backpack, handbag, suitcase
class PackageState(StrEnum):
IDLE = "IDLE"
PRESENT = "PRESENT"
REMINDED = "REMINDED"
COLLECTED = "COLLECTED"
class PackageTracker:
def __init__(self, camera_id: str, latitude: float, longitude: float):
self.camera_id = camera_id
self._latitude = latitude
self._longitude = longitude
self._state = PackageState.IDLE
self._person_last_seen: float = 0
self._person_departure_threshold: float = 30.0
self._package_bbox: list[float] | None = None
self._detected_at: float = 0
self._reminder_at: float = 0
self._person_returned: bool = False
@property
def state(self) -> PackageState:
return self._state
def on_person_detected(self, now: float) -> None:
self._person_last_seen = now
if self._state in (PackageState.PRESENT, PackageState.REMINDED):
self._person_returned = True
def check_for_package(self, detections: list[dict], now: float) -> bool:
if self._state != PackageState.IDLE:
return False
if self._person_last_seen == 0:
return False
if now - self._person_last_seen < self._person_departure_threshold:
return False
for det in detections:
if det.get("class_id") in PACKAGE_CLASS_IDS:
self._state = PackageState.PRESENT
self._package_bbox = det.get("bbox")
self._detected_at = now
self._reminder_at = self._calculate_reminder_time(now)
self._person_returned = False
log.info("Package detected on %s", self.camera_id)
return True
return False
def check_collected(self, detections: list[dict], now: float) -> bool:
if self._state not in (PackageState.PRESENT, PackageState.REMINDED):
return False
if not self._person_returned:
return False
for det in detections:
if det.get("class_id") in PACKAGE_CLASS_IDS:
return False
self._state = PackageState.COLLECTED
return True
def check_reminder(self, now: float) -> bool:
if self._state != PackageState.PRESENT:
return False
if now >= self._reminder_at:
self._state = PackageState.REMINDED
return True
return False
def reset(self) -> None:
self._state = PackageState.IDLE
self._package_bbox = None
self._detected_at = 0
self._reminder_at = 0
self._person_returned = False
def _calculate_reminder_time(self, now: float) -> float:
three_hours = now + 3 * 3600
try:
today = datetime.date.today()
sunset_time = get_sunset(self._latitude, self._longitude, today)
sunset_dt = datetime.datetime.combine(today, sunset_time)
sunset_ts = sunset_dt.timestamp()
if sunset_ts < now:
sunset_ts += 86400
return min(sunset_ts, three_hours)
except Exception:
return three_hours

View File

@ -0,0 +1,31 @@
"""Sunset calculation using NOAA solar equations. Stdlib only."""
import datetime
import math
def get_sunset(latitude: float, longitude: float, date: datetime.date) -> datetime.time:
n = date.timetuple().tm_yday
gamma = 2 * math.pi / 365 * (n - 1)
eqtime = 229.18 * (
0.000075 + 0.001868 * math.cos(gamma) - 0.032077 * math.sin(gamma)
- 0.014615 * math.cos(2 * gamma) - 0.040849 * math.sin(2 * gamma)
)
decl = (
0.006918 - 0.399912 * math.cos(gamma) + 0.070257 * math.sin(gamma)
- 0.006758 * math.cos(2 * gamma) + 0.000907 * math.sin(2 * gamma)
- 0.002697 * math.cos(3 * gamma) + 0.00148 * math.sin(3 * gamma)
)
lat_rad = math.radians(latitude)
zenith = math.radians(90.833)
cos_ha = (
math.cos(zenith) / (math.cos(lat_rad) * math.cos(decl))
- math.tan(lat_rad) * math.tan(decl)
)
cos_ha = max(-1.0, min(1.0, cos_ha))
ha = math.degrees(math.acos(cos_ha))
sunset_minutes = 720 - 4 * (longitude - ha) - eqtime
sunset_minutes = sunset_minutes % 1440
hours = int(sunset_minutes // 60)
minutes = int(sunset_minutes % 60)
return datetime.time(hour=hours, minute=minutes)

View File

@ -0,0 +1,57 @@
"""Open-Meteo weather fetcher with in-memory caching."""
import logging
import time
import requests
log = logging.getLogger(__name__)
_WMO_CODES = {
0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
45: "Fog", 48: "Depositing rime fog",
51: "Light drizzle", 53: "Moderate drizzle", 55: "Dense drizzle",
61: "Light rain", 63: "Moderate rain", 65: "Heavy rain",
66: "Light freezing rain", 67: "Heavy freezing rain",
71: "Light snow", 73: "Moderate snow", 75: "Heavy snow", 77: "Snow grains",
80: "Light showers", 81: "Moderate showers", 82: "Violent showers",
85: "Light snow showers", 86: "Heavy snow showers",
95: "Thunderstorm", 96: "Thunderstorm with light hail", 99: "Thunderstorm with heavy hail",
}
CACHE_TTL_S = 3600
def _weather_code_to_text(code: int) -> str:
return _WMO_CODES.get(code, "Unknown")
class WeatherFetcher:
def __init__(self):
self._cache: dict[str, tuple[dict, float]] = {}
def get_conditions(self, lat: float, lon: float) -> dict | None:
cache_key = f"{lat:.2f},{lon:.2f}"
if cache_key in self._cache:
data, ts = self._cache[cache_key]
if time.time() - ts < CACHE_TTL_S:
return data
try:
resp = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={"latitude": lat, "longitude": lon, "current": "temperature_2m,weather_code"},
timeout=10,
)
if resp.status_code != 200:
return None
j = resp.json()
current = j.get("current", {})
result = {
"temperature_c": current.get("temperature_2m"),
"conditions": _weather_code_to_text(current.get("weather_code", -1)),
}
self._cache[cache_key] = (result, time.time())
return result
except Exception:
log.debug("Weather fetch failed", exc_info=True)
return None

View File

@ -13,6 +13,7 @@ from vigilar.constants import EventType, Severity, Topics
from vigilar.events.rules import RuleEngine from vigilar.events.rules import RuleEngine
from vigilar.events.state import ArmStateFSM from vigilar.events.state import ArmStateFSM
from vigilar.storage.db import get_db_path, init_db from vigilar.storage.db import get_db_path, init_db
from vigilar.alerts.sender import send_alert
from vigilar.storage.queries import insert_event from vigilar.storage.queries import insert_event
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -46,6 +47,7 @@ class EventProcessor:
# Init DB # Init DB
db_path = get_db_path(self._config.system.data_dir) db_path = get_db_path(self._config.system.data_dir)
engine = init_db(db_path) engine = init_db(db_path)
self._engine = engine
# Init components # Init components
fsm = ArmStateFSM(engine, self._config) fsm = ArmStateFSM(engine, self._config)
@ -134,7 +136,10 @@ class EventProcessor:
# Execute actions # Execute actions
for action in actions: for action in actions:
self._execute_action(action, event_id, bus, payload) self._execute_action(
action, event_id, bus, payload,
event_type=event_type, severity=severity, source_id=source_id,
)
except Exception: except Exception:
log.exception("Error processing event on %s", topic) log.exception("Error processing event on %s", topic)
@ -178,6 +183,20 @@ class EventProcessor:
else: else:
return EventType.WILDLIFE_PASSIVE, Severity.INFO, camera_id return EventType.WILDLIFE_PASSIVE, Severity.INFO, camera_id
# Package detection
if suffix == "package/delivered":
return EventType.PACKAGE_DELIVERED, Severity.INFO, camera_id
if suffix == "package/reminder":
return EventType.PACKAGE_REMINDER, Severity.WARNING, camera_id
if suffix == "package/collected":
return EventType.PACKAGE_COLLECTED, Severity.INFO, camera_id
# Visitor recognition
if suffix == "visitor/known":
return EventType.KNOWN_VISITOR, Severity.INFO, camera_id
if suffix == "visitor/unknown":
return EventType.UNKNOWN_VISITOR, Severity.INFO, camera_id
# Ignore heartbeats etc. # Ignore heartbeats etc.
return None, None, None return None, None, None
@ -217,18 +236,35 @@ class EventProcessor:
event_id: int, event_id: int,
bus: MessageBus, bus: MessageBus,
payload: dict[str, Any], payload: dict[str, Any],
event_type: str = "",
severity: str = "",
source_id: str = "",
) -> None: ) -> None:
"""Execute a rule action.""" """Execute a rule action."""
log.info("Executing action: %s (event_id=%d)", action, event_id) log.info("Executing action: %s (event_id=%d)", action, event_id)
if action == "alert_all": if action in ("alert_all", "push_and_record"):
try:
send_alert(
engine=self._engine,
event_type=event_type,
severity=severity,
source_id=source_id,
payload=payload,
config=self._config,
event_id=event_id,
)
except Exception:
log.exception("Failed to send alert for event %d", event_id)
bus.publish(Topics.SYSTEM_ALERT, { bus.publish(Topics.SYSTEM_ALERT, {
"ts": int(time.time() * 1000), "ts": int(time.time() * 1000),
"event_id": event_id, "event_id": event_id,
"type": "alert", "type": "alert",
"payload": payload, "payload": payload,
}) })
elif action == "record_all_cameras":
if action in ("push_and_record", "record_all_cameras"):
# Publish a command for each configured camera to start recording # Publish a command for each configured camera to start recording
for cam in self._config.cameras: for cam in self._config.cameras:
bus.publish(f"vigilar/camera/{cam.id}/command/record", { bus.publish(f"vigilar/camera/{cam.id}/command/record", {
@ -236,5 +272,3 @@ class EventProcessor:
"event_id": event_id, "event_id": event_id,
"action": "start_recording", "action": "start_recording",
}) })
else:
log.warning("Unknown action: %s", action)

View File

@ -73,6 +73,9 @@ class HealthMonitor:
log.info("Health monitor started") log.info("Health monitor started")
last_disk_check = 0 last_disk_check = 0
last_mqtt_check = 0 last_mqtt_check = 0
last_highlight_check = 0
last_timelapse_check = 0
highlight_generated_today = False
while not shutdown: while not shutdown:
now = time.monotonic() now = time.monotonic()
@ -91,6 +94,37 @@ class HealthMonitor:
self._update_check(mqtt) self._update_check(mqtt)
last_mqtt_check = now last_mqtt_check = now
# Highlight reel generation (daily)
if hasattr(self._cfg, 'highlights') and self._cfg.highlights.enabled:
if now - last_highlight_check >= 60:
last_highlight_check = now
import datetime
current_time = datetime.datetime.now().strftime("%H:%M")
if current_time == self._cfg.highlights.generate_time and not highlight_generated_today:
highlight_generated_today = True
try:
from vigilar.highlights.reel import generate_daily_reel
from vigilar.storage.db import get_db_path, get_engine
engine = get_engine(get_db_path(self._cfg.system.data_dir))
yesterday = datetime.date.today() - datetime.timedelta(days=1)
generate_daily_reel(engine, self._cfg.system.recordings_dir,
yesterday, self._cfg.highlights)
except Exception:
log.exception("Highlight reel generation failed")
if current_time == "00:00":
highlight_generated_today = False
# Timelapse schedule check (every 60s)
if now - last_timelapse_check >= 60:
last_timelapse_check = now
try:
from vigilar.highlights.timelapse import check_schedules
from vigilar.storage.db import get_db_path, get_engine
engine = get_engine(get_db_path(self._cfg.system.data_dir))
check_schedules(engine, self._cfg.system.recordings_dir)
except Exception:
log.exception("Timelapse schedule check failed")
self._publish_status() self._publish_status()
time.sleep(10) time.sleep(10)

View File

168
vigilar/highlights/reel.py Normal file
View File

@ -0,0 +1,168 @@
"""Daily highlight reel generator — event scoring and clip assembly."""
import datetime
import json
import logging
import subprocess
import time
from pathlib import Path
from sqlalchemy import desc, select
from sqlalchemy.engine import Engine
from vigilar.config import HighlightsConfig
from vigilar.constants import EventType, RecordingTrigger
from vigilar.storage.schema import events, recordings
log = logging.getLogger(__name__)
_SCORE_MAP = {
EventType.PET_ESCAPE: 10,
EventType.WILDLIFE_PREDATOR: 9,
EventType.UNKNOWN_ANIMAL: 7,
EventType.WILDLIFE_NUISANCE: 5,
EventType.PERSON_DETECTED: 4,
EventType.UNKNOWN_VEHICLE_DETECTED: 4,
EventType.WILDLIFE_PASSIVE: 3,
EventType.VEHICLE_DETECTED: 2,
EventType.PET_DETECTED: 1,
}
ZOOMIE_THRESHOLD = 0.8
def score_event(event_type: str, payload: dict) -> int:
base = _SCORE_MAP.get(event_type, 0)
if event_type == EventType.PET_DETECTED:
if payload.get("motion_confidence", 0) >= ZOOMIE_THRESHOLD:
return 6
return base
def select_top_events(engine: Engine, date: datetime.date, config: HighlightsConfig) -> list[dict]:
day_start_ms = int(datetime.datetime.combine(date, datetime.time.min).timestamp() * 1000)
day_end_ms = int(datetime.datetime.combine(
date + datetime.timedelta(days=1), datetime.time.min).timestamp() * 1000)
query = select(events).where(
events.c.ts >= day_start_ms, events.c.ts < day_end_ms
).order_by(desc(events.c.ts)).limit(500)
if config.cameras:
query = query.where(events.c.source_id.in_(config.cameras))
if config.event_types:
query = query.where(events.c.type.in_(config.event_types))
with engine.connect() as conn:
rows = [dict(r) for r in conn.execute(query).mappings().all()]
scored = []
for row in rows:
payload = json.loads(row["payload"]) if row["payload"] else {}
s = score_event(row["type"], payload)
if s > 0:
scored.append({**row, "_score": s, "_payload": payload})
scored.sort(key=lambda x: x["_score"], reverse=True)
return scored[:config.max_clips]
def generate_daily_reel(
engine: Engine, recordings_dir: str, date: datetime.date, config: HighlightsConfig,
) -> str | None:
top_events = select_top_events(engine, date, config)
if not top_events:
log.info("No events to highlight for %s", date)
return None
highlights_dir = Path(recordings_dir) / "highlights"
highlights_dir.mkdir(parents=True, exist_ok=True)
output_path = highlights_dir / f"{date.isoformat()}.mp4"
clips = []
for evt in top_events:
clip_path = _extract_clip(engine, evt, config.clip_duration_s, recordings_dir)
if clip_path:
clips.append(clip_path)
if not clips:
log.warning("No clips extracted for %s", date)
return None
_concatenate_clips(clips, str(output_path))
for clip in clips:
Path(clip).unlink(missing_ok=True)
if not output_path.exists():
return None
from vigilar.storage.queries import insert_recording
insert_recording(
engine, camera_id="all",
started_at=int(datetime.datetime.combine(date, datetime.time.min).timestamp()),
ended_at=int(datetime.datetime.combine(date, datetime.time.max).timestamp()),
duration_s=len(clips) * config.clip_duration_s,
file_path=str(output_path), file_size=output_path.stat().st_size,
trigger=RecordingTrigger.HIGHLIGHT, encrypted=0, starred=0,
)
log.info("Highlight reel: %s (%d clips)", output_path, len(clips))
return str(output_path)
def _extract_clip(engine, event, duration_s, recordings_dir):
event_ts = event["ts"] / 1000
source_id = event.get("source_id", "")
with engine.connect() as conn:
row = conn.execute(
select(recordings).where(
recordings.c.camera_id == source_id,
recordings.c.started_at <= int(event_ts),
recordings.c.ended_at >= int(event_ts),
).limit(1)
).mappings().first()
if not row or not Path(row["file_path"]).exists():
return None
offset = max(0, event_ts - row["started_at"] - duration_s / 2)
clip_path = Path(recordings_dir) / "highlights" / f"clip_{int(event_ts)}.mp4"
source = source_id.replace("_", " ").title()
ts_str = datetime.datetime.fromtimestamp(event_ts).strftime("%I:%M %p")
watermark = f"{source}{ts_str}"
cmd = [
"ffmpeg", "-y", "-ss", str(offset), "-t", str(duration_s),
"-i", row["file_path"],
"-vf", (f"drawtext=text='{watermark}':fontsize=24:fontcolor=white:"
f"x=20:y=h-50:shadowcolor=black:shadowx=2:shadowy=2,"
f"scale=1280:720:force_original_aspect_ratio=decrease,"
f"pad=1280:720:(ow-iw)/2:(oh-ih)/2"),
"-c:v", "libx264", "-preset", "ultrafast", "-crf", "23", "-an", str(clip_path),
]
try:
subprocess.run(cmd, capture_output=True, timeout=30, check=True)
return str(clip_path)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
return None
def _concatenate_clips(clips, output_path):
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
for clip in clips:
f.write(f"file '{clip}'\n")
concat_file = f.name
cmd = [
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-c:v", "libx264", "-preset", "ultrafast", "-crf", "23",
"-movflags", "+faststart", output_path,
]
try:
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
log.error("Reel concatenation failed")
finally:
Path(concat_file).unlink(missing_ok=True)

View File

@ -0,0 +1,96 @@
"""Time-lapse video generator with scheduling."""
import datetime
import logging
import shutil
import subprocess
import time
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.engine import Engine
from vigilar.constants import RecordingTrigger
from vigilar.storage.schema import recordings
log = logging.getLogger(__name__)
def generate_timelapse(
camera_id, date, start_hour, end_hour, fps, recordings_dir, engine,
) -> str | None:
day_start = int(datetime.datetime.combine(date, datetime.time(start_hour)).timestamp())
day_end = int(datetime.datetime.combine(date, datetime.time(end_hour)).timestamp())
with engine.connect() as conn:
rows = conn.execute(
select(recordings).where(
recordings.c.camera_id == camera_id,
recordings.c.started_at >= day_start,
recordings.c.started_at < day_end,
).order_by(recordings.c.started_at.asc())
).mappings().all()
if not rows:
log.info("No recordings for timelapse: %s on %s", camera_id, date)
return None
frames_dir = Path(recordings_dir) / "timelapse_tmp" / camera_id
frames_dir.mkdir(parents=True, exist_ok=True)
frame_idx = 0
for row in rows:
src_path = row["file_path"]
if not Path(src_path).exists():
continue
duration_s = row.get("duration_s", 60) or 60
for offset in range(0, int(duration_s), 60):
frame_path = frames_dir / f"frame_{frame_idx:06d}.jpg"
cmd = ["ffmpeg", "-y", "-ss", str(offset), "-i", src_path,
"-frames:v", "1", "-q:v", "2", str(frame_path)]
try:
subprocess.run(cmd, capture_output=True, timeout=10, check=True)
frame_idx += 1
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
continue
if frame_idx == 0:
return None
output_dir = Path(recordings_dir) / "timelapses"
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / f"{camera_id}_{date.isoformat()}_{start_hour}-{end_hour}.mp4"
cmd = ["ffmpeg", "-y", "-framerate", str(fps),
"-i", str(frames_dir / "frame_%06d.jpg"),
"-c:v", "libx264", "-preset", "ultrafast", "-crf", "23",
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(output_path)]
try:
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
return None
finally:
shutil.rmtree(frames_dir, ignore_errors=True)
if not output_path.exists():
return None
from vigilar.storage.queries import insert_recording
insert_recording(engine, camera_id=camera_id, started_at=day_start, ended_at=day_end,
duration_s=frame_idx / fps, file_path=str(output_path),
file_size=output_path.stat().st_size,
trigger=RecordingTrigger.TIMELAPSE, encrypted=0, starred=0)
return str(output_path)
def check_schedules(engine, recordings_dir):
from vigilar.storage.queries import get_timelapse_schedules
now = datetime.datetime.now()
current_time = now.strftime("%H:%M")
for sched in get_timelapse_schedules(engine):
if not sched.get("enabled"):
continue
if sched["generate_time"] != current_time:
continue
generate_timelapse(sched["camera_id"], now.date(), sched["start_hour"],
sched["end_hour"], 30, recordings_dir, engine)

View File

@ -147,6 +147,18 @@ def run_supervisor(cfg: VigilarConfig) -> None:
# Start all subsystems # Start all subsystems
log.info("Starting %d subsystems", len(subsystems)) log.info("Starting %d subsystems", len(subsystems))
# Configure syslog handler for alerts audit trail
import logging.handlers
alerts_logger = logging.getLogger("vigilar.alerts")
try:
syslog_handler = logging.handlers.SysLogHandler(address="/dev/log")
syslog_handler.setFormatter(logging.Formatter("vigilar-alerts: %(message)s"))
alerts_logger.addHandler(syslog_handler)
log.info("Syslog handler configured for vigilar.alerts")
except (OSError, FileNotFoundError):
log.warning("Syslog socket not available — alerts will only log to stdout")
for sub in subsystems: for sub in subsystems:
sub.start() sub.start()

0
vigilar/pets/__init__.py Normal file
View File

135
vigilar/pets/rules.py Normal file
View File

@ -0,0 +1,135 @@
"""Pet rule engine — composable conditions with per-pet state tracking."""
import json
import logging
import time as time_mod
from dataclasses import dataclass
from vigilar.alerts.profiles import is_in_time_window
log = logging.getLogger(__name__)
@dataclass
class PetState:
pet_id: str
last_seen_camera: str | None = None
last_seen_time: float = 0
current_zone: str | None = None
zone_entry_time: float = 0
person_present: bool = False
person_last_seen: float = 0
def evaluate_condition(condition: dict, pet_state: PetState, now: float) -> bool:
ctype = condition.get("type")
if ctype == "detected_in_zone":
return pet_state.current_zone == condition.get("zone")
elif ctype == "not_seen_in_zone":
if pet_state.current_zone == condition.get("zone"):
return False
return (now - pet_state.last_seen_time) >= condition.get("minutes", 0) * 60
elif ctype == "not_seen_anywhere":
return (now - pet_state.last_seen_time) >= condition.get("minutes", 0) * 60
elif ctype == "detected_without_person":
return not pet_state.person_present
elif ctype == "in_zone_longer_than":
if pet_state.current_zone != condition.get("zone"):
return False
return (now - pet_state.zone_entry_time) >= condition.get("minutes", 0) * 60
elif ctype == "time_of_day":
start = condition.get("start", "00:00")
end = condition.get("end", "23:59")
current_time = time_mod.strftime("%H:%M")
return is_in_time_window(f"{start}-{end}", current_time)
return False
def evaluate_rule(rule: dict, pet_state: PetState, now: float) -> bool:
conditions = rule.get("conditions", [])
if isinstance(conditions, str):
conditions = json.loads(conditions)
for condition in conditions:
if not evaluate_condition(condition, pet_state, now):
return False
return True
def format_action_message(template: str, pet_state: PetState) -> str:
duration_s = time_mod.time() - pet_state.zone_entry_time if pet_state.zone_entry_time else 0
duration_min = int(duration_s / 60)
duration_str = f"{duration_min // 60}h {duration_min % 60}m" if duration_min >= 60 else f"{duration_min} minutes"
last_seen_s = time_mod.time() - pet_state.last_seen_time if pet_state.last_seen_time else 0
last_seen_min = int(last_seen_s / 60)
last_seen_str = f"{last_seen_min}m ago" if last_seen_min < 60 else f"{last_seen_min // 60}h ago"
camera = (pet_state.last_seen_camera or "unknown").replace("_", " ").title()
return template.format(
pet_name=pet_state.pet_id, camera=camera,
zone=pet_state.current_zone or "unknown",
duration=duration_str, last_seen=last_seen_str)
class PetRuleEngine:
def __init__(self):
self._pet_states: dict[str, PetState] = {}
self._rules: list[dict] = []
self._last_fired: dict[int, float] = {}
def load_rules(self, engine) -> None:
from vigilar.storage.queries import get_all_enabled_rules
self._rules = get_all_enabled_rules(engine)
log.info("Loaded %d pet rules", len(self._rules))
def get_or_create_state(self, pet_id: str) -> PetState:
if pet_id not in self._pet_states:
self._pet_states[pet_id] = PetState(pet_id=pet_id)
return self._pet_states[pet_id]
def on_pet_detection(self, pet_id, camera_id, zone, now) -> list[dict]:
state = self.get_or_create_state(pet_id)
old_zone = state.current_zone
state.last_seen_camera = camera_id
state.last_seen_time = now
if zone != old_zone:
state.current_zone = zone
state.zone_entry_time = now
else:
state.current_zone = zone
return self._evaluate_rules_for_pet(pet_id, now)
def on_person_detection(self, camera_id, now) -> None:
for state in self._pet_states.values():
if state.last_seen_camera == camera_id:
state.person_present = True
state.person_last_seen = now
def tick(self, now) -> list[dict]:
for state in self._pet_states.values():
if state.person_present and now - state.person_last_seen > 30:
state.person_present = False
triggered = []
for pet_id in self._pet_states:
triggered.extend(self._evaluate_rules_for_pet(pet_id, now))
return triggered
def _evaluate_rules_for_pet(self, pet_id, now) -> list[dict]:
state = self._pet_states.get(pet_id)
if not state:
return []
triggered = []
for rule in self._rules:
if rule["pet_id"] != pet_id:
continue
rule_id = rule["id"]
last = self._last_fired.get(rule_id, 0)
cooldown = rule.get("cooldown_minutes", 30) * 60
if now - last < cooldown:
continue
if evaluate_rule(rule, state, now):
self._last_fired[rule_id] = now
message = format_action_message(rule.get("action_message", ""), state)
triggered.append({
"rule_id": rule_id, "rule_name": rule["name"], "pet_id": pet_id,
"action": rule["action"], "message": message,
"camera_id": state.last_seen_camera})
return triggered

View File

@ -0,0 +1,43 @@
"""AES-256-CTR encryption for recording files."""
import os
from collections.abc import Generator
from pathlib import Path
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
CHUNK_SIZE = 64 * 1024 # 64KB
def encrypt_file(plain_path: str, key_hex: str) -> str:
key = bytes.fromhex(key_hex)
iv = os.urandom(16)
cipher = Cipher(algorithms.AES(key), modes.CTR(iv))
encryptor = cipher.encryptor()
vge_path = Path(plain_path).with_suffix(".vge")
with open(plain_path, "rb") as src, open(vge_path, "wb") as dst:
dst.write(iv)
while True:
chunk = src.read(CHUNK_SIZE)
if not chunk:
break
dst.write(encryptor.update(chunk))
dst.write(encryptor.finalize())
Path(plain_path).unlink()
return str(vge_path)
def decrypt_stream(vge_path: str, key_hex: str) -> Generator[bytes, None, None]:
key = bytes.fromhex(key_hex)
with open(vge_path, "rb") as f:
iv = f.read(16)
cipher = Cipher(algorithms.AES(key), modes.CTR(iv))
decryptor = cipher.decryptor()
while True:
chunk = f.read(CHUNK_SIZE)
if not chunk:
break
yield decryptor.update(chunk)
final = decryptor.finalize()
if final:
yield final

View File

@ -426,6 +426,65 @@ def get_wildlife_sightings(
return [dict(r._mapping) for r in rows] return [dict(r._mapping) for r in rows]
# --- Wildlife Journal Queries ---
def get_wildlife_sightings_paginated(
engine: Engine,
species: str | None = None,
threat_level: str | None = None,
camera_id: str | None = None,
since_ts: float | None = None,
until_ts: float | None = None,
limit: int = 50,
offset: int = 0,
) -> list[dict[str, Any]]:
query = (
select(wildlife_sightings).order_by(desc(wildlife_sightings.c.ts))
.limit(limit).offset(offset)
)
if species:
query = query.where(wildlife_sightings.c.species == species)
if threat_level:
query = query.where(wildlife_sightings.c.threat_level == threat_level)
if camera_id:
query = query.where(wildlife_sightings.c.camera_id == camera_id)
if since_ts:
query = query.where(wildlife_sightings.c.ts >= since_ts)
if until_ts:
query = query.where(wildlife_sightings.c.ts < until_ts)
with engine.connect() as conn:
return [dict(r) for r in conn.execute(query).mappings().all()]
def get_wildlife_stats(engine: Engine) -> dict[str, Any]:
from sqlalchemy import func
with engine.connect() as conn:
total = conn.execute(
select(func.count()).select_from(wildlife_sightings)
).scalar() or 0
species_rows = conn.execute(
select(wildlife_sightings.c.species, func.count().label("cnt"))
.group_by(wildlife_sightings.c.species)
).mappings().all()
per_species = {r["species"]: r["cnt"] for r in species_rows}
return {"total": total, "species_count": len(per_species), "per_species": per_species}
def get_wildlife_frequency(engine: Engine) -> dict[str, dict[str, int]]:
import datetime
buckets: dict[str, dict[str, int]] = {
"00-04": {}, "04-08": {}, "08-12": {}, "12-16": {}, "16-20": {}, "20-24": {}
}
with engine.connect() as conn:
rows = conn.execute(select(wildlife_sightings)).mappings().all()
for r in rows:
dt = datetime.datetime.fromtimestamp(r["ts"])
bucket_key = list(buckets.keys())[dt.hour // 4]
species = r["species"]
buckets[bucket_key][species] = buckets[bucket_key].get(species, 0) + 1
return buckets
# --- Training Images --- # --- Training Images ---
def insert_training_image( def insert_training_image(
@ -457,3 +516,203 @@ def get_training_images(
.order_by(desc(pet_training_images.c.created_at)) .order_by(desc(pet_training_images.c.created_at))
).fetchall() ).fetchall()
return [dict(r._mapping) for r in rows] return [dict(r._mapping) for r in rows]
# --- Package Events ---
def insert_package_event(
engine: Engine, camera_id: str, detected_at: float, status: str,
crop_path: str | None = None, event_id: int | None = None,
) -> int:
from vigilar.storage.schema import package_events
with engine.begin() as conn:
result = conn.execute(package_events.insert().values(
camera_id=camera_id, detected_at=detected_at, status=status,
crop_path=crop_path, event_id=event_id))
return result.inserted_primary_key[0]
def get_active_packages(engine: Engine) -> list[dict[str, Any]]:
from vigilar.storage.schema import package_events
with engine.connect() as conn:
rows = conn.execute(
select(package_events).where(package_events.c.status.in_(["PRESENT", "REMINDED"]))
.order_by(desc(package_events.c.detected_at))).mappings().all()
return [dict(r) for r in rows]
def update_package_status(engine: Engine, package_id: int, status: str, **kwargs: Any) -> None:
from vigilar.storage.schema import package_events
with engine.begin() as conn:
conn.execute(package_events.update().where(package_events.c.id == package_id)
.values(status=status, **kwargs))
# --- Pet Rules ---
def insert_pet_rule(engine, pet_id, name, conditions, action, action_message, cooldown_minutes, priority) -> int:
from vigilar.storage.schema import pet_rules
with engine.begin() as conn:
result = conn.execute(pet_rules.insert().values(
pet_id=pet_id, name=name, enabled=1, conditions=conditions, action=action,
action_message=action_message, cooldown_minutes=cooldown_minutes,
priority=priority, created_at=time.time()))
return result.inserted_primary_key[0]
def get_pet_rules(engine, pet_id) -> list[dict]:
from vigilar.storage.schema import pet_rules
with engine.connect() as conn:
return [dict(r) for r in conn.execute(
select(pet_rules).where(pet_rules.c.pet_id == pet_id)
.order_by(pet_rules.c.priority, pet_rules.c.id)).mappings().all()]
def get_all_enabled_rules(engine) -> list[dict]:
from vigilar.storage.schema import pet_rules
with engine.connect() as conn:
return [dict(r) for r in conn.execute(
select(pet_rules).where(pet_rules.c.enabled == 1)
.order_by(pet_rules.c.priority, pet_rules.c.id)).mappings().all()]
def update_pet_rule(engine, rule_id, **updates) -> None:
from vigilar.storage.schema import pet_rules
with engine.begin() as conn:
conn.execute(pet_rules.update().where(pet_rules.c.id == rule_id).values(**updates))
def delete_pet_rule(engine, rule_id) -> None:
from vigilar.storage.schema import pet_rules
with engine.begin() as conn:
conn.execute(pet_rules.delete().where(pet_rules.c.id == rule_id))
def count_pet_rules(engine, pet_id) -> int:
from sqlalchemy import func
from vigilar.storage.schema import pet_rules
with engine.connect() as conn:
return conn.execute(
select(func.count()).select_from(pet_rules).where(pet_rules.c.pet_id == pet_id)
).scalar() or 0
# --- Face Profiles ---
def create_face_profile(engine, name=None, first_seen_at=0, last_seen_at=0) -> int:
from vigilar.storage.schema import face_profiles
with engine.begin() as conn:
result = conn.execute(face_profiles.insert().values(
name=name, is_household=0, visit_count=0,
first_seen_at=first_seen_at, last_seen_at=last_seen_at,
ignored=0, created_at=time.time()))
return result.inserted_primary_key[0]
def get_face_profile(engine, profile_id) -> dict | None:
from vigilar.storage.schema import face_profiles
with engine.connect() as conn:
row = conn.execute(select(face_profiles).where(
face_profiles.c.id == profile_id)).mappings().first()
return dict(row) if row else None
def get_all_profiles(engine, named_only=False, include_ignored=True) -> list[dict]:
from vigilar.storage.schema import face_profiles
query = select(face_profiles).order_by(desc(face_profiles.c.last_seen_at))
if named_only:
query = query.where(face_profiles.c.name.isnot(None))
if not include_ignored:
query = query.where(face_profiles.c.ignored == 0)
with engine.connect() as conn:
return [dict(r) for r in conn.execute(query).mappings().all()]
def update_face_profile(engine, profile_id, **updates) -> None:
from vigilar.storage.schema import face_profiles
with engine.begin() as conn:
conn.execute(face_profiles.update().where(
face_profiles.c.id == profile_id).values(**updates))
def delete_face_profile_cascade(engine, profile_id) -> None:
from vigilar.storage.schema import face_embeddings, face_profiles, visits
with engine.begin() as conn:
conn.execute(face_embeddings.delete().where(face_embeddings.c.profile_id == profile_id))
conn.execute(visits.delete().where(visits.c.profile_id == profile_id))
conn.execute(face_profiles.delete().where(face_profiles.c.id == profile_id))
# --- Face Embeddings ---
def insert_face_embedding(
engine, profile_id, embedding_b64, camera_id, captured_at, crop_path=None
) -> int:
from vigilar.storage.schema import face_embeddings
with engine.begin() as conn:
result = conn.execute(face_embeddings.insert().values(
profile_id=profile_id, embedding=embedding_b64,
crop_path=crop_path, camera_id=camera_id, captured_at=captured_at))
return result.inserted_primary_key[0]
def get_embeddings_for_profile(engine, profile_id) -> list[dict]:
from vigilar.storage.schema import face_embeddings
with engine.connect() as conn:
return [dict(r) for r in conn.execute(
select(face_embeddings).where(
face_embeddings.c.profile_id == profile_id)).mappings().all()]
# --- Visits ---
def insert_visit(engine, profile_id, camera_id, arrived_at, event_id=None) -> int:
from vigilar.storage.schema import visits
with engine.begin() as conn:
result = conn.execute(visits.insert().values(
profile_id=profile_id, camera_id=camera_id,
arrived_at=arrived_at, event_id=event_id))
return result.inserted_primary_key[0]
def get_visits(engine, profile_id=None, camera_id=None, limit=50) -> list[dict]:
from vigilar.storage.schema import visits
query = select(visits).order_by(desc(visits.c.arrived_at)).limit(limit)
if profile_id:
query = query.where(visits.c.profile_id == profile_id)
if camera_id:
query = query.where(visits.c.camera_id == camera_id)
with engine.connect() as conn:
return [dict(r) for r in conn.execute(query).mappings().all()]
def insert_timelapse_schedule(engine, camera_id, name, start_hour, end_hour, generate_time) -> int:
from vigilar.storage.schema import timelapse_schedules
with engine.begin() as conn:
result = conn.execute(timelapse_schedules.insert().values(
camera_id=camera_id, name=name, start_hour=start_hour, end_hour=end_hour,
generate_time=generate_time, enabled=1, created_at=time.time()))
return result.inserted_primary_key[0]
def get_timelapse_schedules(engine, camera_id=None) -> list[dict]:
from vigilar.storage.schema import timelapse_schedules
query = select(timelapse_schedules)
if camera_id:
query = query.where(timelapse_schedules.c.camera_id == camera_id)
with engine.connect() as conn:
return [dict(r) for r in conn.execute(query).mappings().all()]
def delete_timelapse_schedule(engine, schedule_id) -> bool:
from vigilar.storage.schema import timelapse_schedules
with engine.begin() as conn:
return conn.execute(timelapse_schedules.delete().where(
timelapse_schedules.c.id == schedule_id)).rowcount > 0
def get_active_visits(engine) -> list[dict]:
from vigilar.storage.schema import visits
with engine.connect() as conn:
return [dict(r) for r in conn.execute(
select(visits).where(visits.c.departed_at.is_(None))).mappings().all()]

View File

@ -165,10 +165,27 @@ wildlife_sightings = Table(
Column("confidence", Float), Column("confidence", Float),
Column("crop_path", String), Column("crop_path", String),
Column("event_id", Integer), Column("event_id", Integer),
Column("temperature_c", Float),
Column("conditions", String),
Column("bbox", String), # JSON [x, y, w, h] normalized
) )
Index("idx_wildlife_ts", wildlife_sightings.c.ts.desc()) Index("idx_wildlife_ts", wildlife_sightings.c.ts.desc())
Index("idx_wildlife_threat", wildlife_sightings.c.threat_level, wildlife_sightings.c.ts.desc()) Index("idx_wildlife_threat", wildlife_sightings.c.threat_level, wildlife_sightings.c.ts.desc())
package_events = Table(
"package_events",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("camera_id", String, nullable=False),
Column("detected_at", Float, nullable=False),
Column("reminded_at", Float),
Column("collected_at", Float),
Column("status", String, nullable=False),
Column("crop_path", String),
Column("event_id", Integer),
)
Index("idx_package_camera_status", package_events.c.camera_id, package_events.c.status)
pet_training_images = Table( pet_training_images = Table(
"pet_training_images", "pet_training_images",
metadata, metadata,
@ -178,3 +195,69 @@ pet_training_images = Table(
Column("source", String, nullable=False), Column("source", String, nullable=False),
Column("created_at", Float, nullable=False), Column("created_at", Float, nullable=False),
) )
pet_rules = Table(
"pet_rules", metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("pet_id", String, nullable=False),
Column("name", String, nullable=False),
Column("enabled", Integer, nullable=False, default=1),
Column("conditions", Text, nullable=False),
Column("action", String, nullable=False),
Column("action_message", String),
Column("cooldown_minutes", Integer, nullable=False, default=30),
Column("priority", Integer, nullable=False, default=0),
Column("created_at", Float, nullable=False),
)
Index("idx_pet_rules_pet", pet_rules.c.pet_id, pet_rules.c.enabled, pet_rules.c.priority)
face_profiles = Table(
"face_profiles", metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("name", String),
Column("is_household", Integer, nullable=False, default=0),
Column("presence_member", String),
Column("primary_photo_path", String),
Column("visit_count", Integer, nullable=False, default=0),
Column("first_seen_at", Float, nullable=False),
Column("last_seen_at", Float, nullable=False),
Column("ignored", Integer, nullable=False, default=0),
Column("created_at", Float, nullable=False),
)
Index("idx_face_profiles_name", face_profiles.c.name)
face_embeddings = Table(
"face_embeddings", metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("profile_id", Integer, nullable=False),
Column("embedding", Text, nullable=False),
Column("crop_path", String),
Column("camera_id", String, nullable=False),
Column("captured_at", Float, nullable=False),
)
Index("idx_face_embeddings_profile", face_embeddings.c.profile_id)
visits = Table(
"visits", metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("profile_id", Integer, nullable=False),
Column("camera_id", String, nullable=False),
Column("arrived_at", Float, nullable=False),
Column("departed_at", Float),
Column("duration_s", Float),
Column("event_id", Integer),
)
Index("idx_visits_profile_ts", visits.c.profile_id, visits.c.arrived_at.desc())
Index("idx_visits_ts", visits.c.arrived_at.desc())
timelapse_schedules = Table(
"timelapse_schedules", metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("camera_id", String, nullable=False),
Column("name", String, nullable=False),
Column("start_hour", Integer, nullable=False),
Column("end_hour", Integer, nullable=False),
Column("generate_time", String, nullable=False),
Column("enabled", Integer, nullable=False, default=1),
Column("created_at", Float, nullable=False),
)

View File

@ -30,6 +30,8 @@ def create_app(cfg: VigilarConfig | None = None) -> Flask:
from vigilar.web.blueprints.recordings import recordings_bp from vigilar.web.blueprints.recordings import recordings_bp
from vigilar.web.blueprints.sensors import sensors_bp from vigilar.web.blueprints.sensors import sensors_bp
from vigilar.web.blueprints.system import system_bp from vigilar.web.blueprints.system import system_bp
from vigilar.web.blueprints.visitors import visitors_bp
from vigilar.web.blueprints.wildlife import wildlife_bp
app.register_blueprint(cameras_bp) app.register_blueprint(cameras_bp)
app.register_blueprint(events_bp) app.register_blueprint(events_bp)
@ -38,6 +40,8 @@ def create_app(cfg: VigilarConfig | None = None) -> Flask:
app.register_blueprint(recordings_bp) app.register_blueprint(recordings_bp)
app.register_blueprint(sensors_bp) app.register_blueprint(sensors_bp)
app.register_blueprint(system_bp) app.register_blueprint(system_bp)
app.register_blueprint(visitors_bp)
app.register_blueprint(wildlife_bp)
# Root route → dashboard # Root route → dashboard
@app.route("/") @app.route("/")

View File

@ -2,7 +2,7 @@
from pathlib import Path from pathlib import Path
from flask import Blueprint, Response, abort, current_app, jsonify, render_template, send_from_directory from flask import Blueprint, Response, abort, current_app, jsonify, render_template, request, send_from_directory
cameras_bp = Blueprint("cameras", __name__, url_prefix="/cameras") cameras_bp = Blueprint("cameras", __name__, url_prefix="/cameras")
@ -71,6 +71,77 @@ def camera_hls_remote(camera_id: str, filename: str):
return response return response
@cameras_bp.route("/<camera_id>/heatmap")
def camera_heatmap(camera_id: str):
days = request.args.get("days", 7, type=int)
if days not in (7, 30):
days = 7
engine = current_app.config.get("DB_ENGINE")
if engine is None:
abort(503)
import numpy as np
frame = np.zeros((360, 640, 3), dtype=np.uint8)
from vigilar.detection.heatmap import render_heatmap_png, accumulate_detections
# For now return an empty heatmap — full DB query integration in future
grid = accumulate_detections([])
png_bytes = render_heatmap_png(grid, frame)
return Response(png_bytes, mimetype="image/png")
@cameras_bp.route("/<camera_id>/timelapse", methods=["POST"])
def start_timelapse(camera_id):
data = request.get_json() or {}
date_str = data.get("date")
if not date_str:
return jsonify({"error": "date required"}), 400
import datetime
import threading
date = datetime.date.fromisoformat(date_str)
engine = current_app.config.get("DB_ENGINE")
cfg = current_app.config.get("VIGILAR_CONFIG")
recordings_dir = cfg.system.recordings_dir if cfg else "/var/vigilar/recordings"
def run():
from vigilar.highlights.timelapse import generate_timelapse
generate_timelapse(camera_id, date, data.get("start_hour", 6),
data.get("end_hour", 20), data.get("fps", 30), recordings_dir, engine)
threading.Thread(target=run, daemon=True).start()
return jsonify({"ok": True, "status": "generating"}), 202
@cameras_bp.route("/<camera_id>/timelapse/status")
def timelapse_status(camera_id):
return jsonify({"status": "idle"})
@cameras_bp.route("/<camera_id>/timelapse/schedules")
def timelapse_schedules(camera_id):
engine = current_app.config.get("DB_ENGINE")
if engine is None:
return jsonify([])
from vigilar.storage.queries import get_timelapse_schedules
return jsonify(get_timelapse_schedules(engine, camera_id))
@cameras_bp.route("/<camera_id>/timelapse/schedule", methods=["POST"])
def create_timelapse_schedule(camera_id):
data = request.get_json() or {}
if not data.get("name"):
return jsonify({"error": "name required"}), 400
engine = current_app.config.get("DB_ENGINE")
if engine is None:
return jsonify({"error": "database not available"}), 503
from vigilar.storage.queries import insert_timelapse_schedule
sid = insert_timelapse_schedule(engine, camera_id, data["name"],
data.get("start_hour", 6), data.get("end_hour", 20), data.get("time", "20:00"))
return jsonify({"ok": True, "id": sid})
@cameras_bp.route("/<camera_id>/timelapse/schedule/<int:schedule_id>", methods=["DELETE"])
def delete_timelapse_schedule_route(camera_id, schedule_id):
engine = current_app.config.get("DB_ENGINE")
if engine is None:
return jsonify({"error": "database not available"}), 503
from vigilar.storage.queries import delete_timelapse_schedule
delete_timelapse_schedule(engine, schedule_id)
return jsonify({"ok": True})
@cameras_bp.route("/api/status") @cameras_bp.route("/api/status")
def cameras_status_api(): def cameras_status_api():
"""JSON API: all camera statuses.""" """JSON API: all camera statuses."""

View File

@ -1,4 +1,4 @@
"""Kiosk blueprint — fullscreen 2x2 grid for TV display.""" """Kiosk blueprint — fullscreen 2x2 grid and ambient mode."""
from flask import Blueprint, current_app, render_template from flask import Blueprint, current_app, render_template
@ -10,3 +10,12 @@ def kiosk_view():
cfg = current_app.config.get("VIGILAR_CONFIG") cfg = current_app.config.get("VIGILAR_CONFIG")
cameras = cfg.cameras if cfg else [] cameras = cfg.cameras if cfg else []
return render_template("kiosk.html", cameras=cameras) return render_template("kiosk.html", cameras=cameras)
@kiosk_bp.route("/ambient")
def ambient_view():
cfg = current_app.config.get("VIGILAR_CONFIG")
cameras = cfg.cameras if cfg else []
raw_kiosk = cfg.kiosk if cfg and hasattr(cfg, "kiosk") else None
kiosk_cfg = raw_kiosk.model_dump() if raw_kiosk is not None and hasattr(raw_kiosk, "model_dump") else raw_kiosk
return render_template("kiosk/ambient.html", cameras=cameras, kiosk_config=kiosk_cfg)

View File

@ -223,6 +223,107 @@ def delete_pet(pet_id: str):
return jsonify({"ok": True}) return jsonify({"ok": True})
VALID_ACTIONS = {"push_notify", "log_event", "start_recording"}
VALID_CONDITION_TYPES = {
"detected_in_zone", "not_seen_in_zone", "not_seen_anywhere",
"detected_without_person", "in_zone_longer_than", "time_of_day",
}
@pets_bp.route("/<pet_id>/rules")
def list_rules(pet_id):
engine = _engine()
if engine is None:
return jsonify({"rules": []})
from vigilar.storage.queries import get_pet_rules
return jsonify({"rules": get_pet_rules(engine, pet_id)})
@pets_bp.route("/<pet_id>/rules", methods=["POST"])
def create_rule(pet_id):
engine = _engine()
if engine is None:
return jsonify({"error": "database not available"}), 503
data = request.get_json(silent=True) or {}
name = data.get("name")
conditions = data.get("conditions", [])
action = data.get("action")
action_message = data.get("action_message", "")
cooldown = data.get("cooldown_minutes", 30)
if not name:
return jsonify({"error": "name required"}), 400
if action not in VALID_ACTIONS:
return jsonify({"error": f"Invalid action: {action}"}), 400
if cooldown < 1:
return jsonify({"error": "cooldown_minutes must be >= 1"}), 400
for cond in conditions:
if cond.get("type") not in VALID_CONDITION_TYPES:
return jsonify({"error": f"Invalid condition type: {cond.get('type')}"}), 400
from vigilar.storage.queries import count_pet_rules
cfg = current_app.config.get("VIGILAR_CONFIG")
max_rules = cfg.pets.max_rules_per_pet if cfg else 32
if count_pet_rules(engine, pet_id) >= max_rules:
return jsonify({"error": f"Max {max_rules} rules per pet"}), 400
import json as json_mod
from vigilar.storage.queries import insert_pet_rule
rule_id = insert_pet_rule(engine, pet_id, name, json_mod.dumps(conditions),
action, action_message, cooldown, data.get("priority", 0))
return jsonify({"ok": True, "id": rule_id})
@pets_bp.route("/<pet_id>/rules/<int:rule_id>", methods=["PUT"])
def update_rule_route(pet_id, rule_id):
engine = _engine()
if engine is None:
return jsonify({"error": "database not available"}), 503
import json as json_mod
data = request.get_json(silent=True) or {}
updates = {}
if "name" in data: updates["name"] = data["name"]
if "conditions" in data: updates["conditions"] = json_mod.dumps(data["conditions"])
if "action" in data:
if data["action"] not in VALID_ACTIONS:
return jsonify({"error": "Invalid action"}), 400
updates["action"] = data["action"]
if "action_message" in data: updates["action_message"] = data["action_message"]
if "cooldown_minutes" in data: updates["cooldown_minutes"] = data["cooldown_minutes"]
if "enabled" in data: updates["enabled"] = 1 if data["enabled"] else 0
if "priority" in data: updates["priority"] = data["priority"]
if not updates:
return jsonify({"error": "no fields to update"}), 400
from vigilar.storage.queries import update_pet_rule
update_pet_rule(engine, rule_id, **updates)
return jsonify({"ok": True})
@pets_bp.route("/<pet_id>/rules/<int:rule_id>", methods=["DELETE"])
def delete_rule_route(pet_id, rule_id):
engine = _engine()
if engine is None:
return jsonify({"error": "database not available"}), 503
from vigilar.storage.queries import delete_pet_rule
delete_pet_rule(engine, rule_id)
return jsonify({"ok": True})
@pets_bp.route("/api/rule-templates")
def rule_templates():
return jsonify([
{"name": "Outdoor timer", "conditions": [{"type": "in_zone_longer_than", "zone": "EXTERIOR", "minutes": 45}],
"action": "push_notify", "action_message": "{pet_name} has been outside for {duration}", "cooldown_minutes": 30},
{"name": "Needs to go out", "conditions": [{"type": "not_seen_in_zone", "zone": "EXTERIOR", "minutes": 240}],
"action": "push_notify", "action_message": "{pet_name} hasn't been outside in {duration}", "cooldown_minutes": 60},
{"name": "Missing pet", "conditions": [{"type": "not_seen_anywhere", "minutes": 480}],
"action": "push_notify", "action_message": "{pet_name} hasn't been seen in {duration}", "cooldown_minutes": 120},
{"name": "Wrong zone alert", "conditions": [{"type": "detected_in_zone", "zone": "EXTERIOR"}],
"action": "push_notify", "action_message": "{pet_name} detected in {zone}{camera}", "cooldown_minutes": 15},
{"name": "On the loose", "conditions": [{"type": "detected_without_person"}],
"action": "push_notify", "action_message": "{pet_name} spotted without supervision — {camera}", "cooldown_minutes": 5},
{"name": "Night escape", "conditions": [{"type": "detected_in_zone", "zone": "EXTERIOR"}, {"type": "time_of_day", "start": "22:00", "end": "06:00"}],
"action": "push_notify", "action_message": "{pet_name} is outside at night — {camera}", "cooldown_minutes": 30},
])
@pets_bp.route("/train", methods=["POST"]) @pets_bp.route("/train", methods=["POST"])
def train_model(): def train_model():
pets_cfg = current_app.config.get("PETS_CONFIG") pets_cfg = current_app.config.get("PETS_CONFIG")

View File

@ -3,7 +3,7 @@
import time as time_mod import time as time_mod
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import Blueprint, current_app, jsonify, render_template, request from flask import Blueprint, Response, current_app, jsonify, render_template, request
recordings_bp = Blueprint("recordings", __name__, url_prefix="/recordings") recordings_bp = Blueprint("recordings", __name__, url_prefix="/recordings")
@ -18,11 +18,47 @@ def recordings_list():
@recordings_bp.route("/api/list") @recordings_bp.route("/api/list")
def recordings_api(): def recordings_api():
"""JSON API: recording list.""" """JSON API: recording list."""
camera_id = request.args.get("camera") engine = current_app.config.get("DB_ENGINE")
limit = request.args.get("limit", 50, type=int) if engine is None:
# TODO: query from DB
return jsonify([]) return jsonify([])
from sqlalchemy import desc, select
from vigilar.storage.schema import recordings
query = select(recordings).order_by(desc(recordings.c.started_at))
camera_id = request.args.get("camera_id")
if camera_id:
query = query.where(recordings.c.camera_id == camera_id)
date_str = request.args.get("date")
if date_str:
day = datetime.strptime(date_str, "%Y-%m-%d")
day_start = int(day.timestamp())
day_end = int((day + timedelta(days=1)).timestamp())
query = query.where(recordings.c.started_at >= day_start, recordings.c.started_at < day_end)
detection_type = request.args.get("detection_type")
if detection_type:
query = query.where(recordings.c.detection_type == detection_type)
starred = request.args.get("starred")
if starred is not None:
query = query.where(recordings.c.starred == int(starred))
limit = min(request.args.get("limit", 50, type=int), 500)
query = query.limit(limit)
with engine.connect() as conn:
rows = conn.execute(query).mappings().all()
return jsonify([{
"id": r["id"], "camera_id": r["camera_id"],
"started_at": r["started_at"], "ended_at": r["ended_at"],
"duration_s": r["duration_s"], "detection_type": r["detection_type"],
"starred": bool(r["starred"]), "file_size": r["file_size"], "trigger": r["trigger"],
} for r in rows])
@recordings_bp.route("/api/timeline") @recordings_bp.route("/api/timeline")
def timeline_api(): def timeline_api():
@ -63,12 +99,69 @@ def timeline_api():
@recordings_bp.route("/<int:recording_id>/download") @recordings_bp.route("/<int:recording_id>/download")
def recording_download(recording_id: int): def recording_download(recording_id: int):
"""Stream decrypted recording for download/playback.""" """Stream decrypted recording for download/playback."""
# TODO: Phase 6 — decrypt and stream engine = current_app.config.get("DB_ENGINE")
return "Recording not available", 503 if engine is None:
return "Database not available", 503
from pathlib import Path
from sqlalchemy import select
from vigilar.storage.schema import recordings
with engine.connect() as conn:
row = conn.execute(select(recordings).where(recordings.c.id == recording_id)).mappings().first()
if not row:
return "Recording not found", 404
file_path = row["file_path"]
if not Path(file_path).exists():
return "Recording file not found", 404
is_encrypted = row["encrypted"] and file_path.endswith(".vge")
if is_encrypted:
import os
key_hex = os.environ.get("VIGILAR_ENCRYPTION_KEY")
if not key_hex:
return "Encryption key not configured", 500
from vigilar.storage.encryption import decrypt_stream
return Response(decrypt_stream(file_path, key_hex), mimetype="video/mp4",
headers={"Content-Disposition": f"inline; filename=recording_{recording_id}.mp4"})
else:
return Response(_read_file_chunks(file_path), mimetype="video/mp4",
headers={"Content-Disposition": f"inline; filename=recording_{recording_id}.mp4"})
def _read_file_chunks(path: str, chunk_size: int = 64 * 1024):
with open(path, "rb") as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
yield chunk
@recordings_bp.route("/<int:recording_id>", methods=["DELETE"]) @recordings_bp.route("/<int:recording_id>", methods=["DELETE"])
def recording_delete(recording_id: int): def recording_delete(recording_id: int):
"""Delete a recording.""" """Delete a recording."""
# TODO: delete from DB + filesystem engine = current_app.config.get("DB_ENGINE")
if engine is None:
return jsonify({"error": "Database not available"}), 503
from pathlib import Path
from sqlalchemy import select
from vigilar.storage.schema import recordings
with engine.connect() as conn:
row = conn.execute(select(recordings).where(recordings.c.id == recording_id)).mappings().first()
if not row:
return jsonify({"error": "Recording not found"}), 404
file_path = Path(row["file_path"])
if file_path.exists():
file_path.unlink()
from vigilar.storage.queries import delete_recording
delete_recording(engine, recording_id)
return jsonify({"ok": True}) return jsonify({"ok": True})

View File

@ -4,6 +4,7 @@ import os
from flask import Blueprint, current_app, jsonify, render_template, request from flask import Blueprint, current_app, jsonify, render_template, request
from vigilar.alerts.pin import hash_pin, verify_pin
from vigilar.config import VigilarConfig from vigilar.config import VigilarConfig
from vigilar.config_writer import ( from vigilar.config_writer import (
save_config, save_config,
@ -58,7 +59,10 @@ def arm_system():
data = request.get_json() or {} data = request.get_json() or {}
mode = data.get("mode", "ARMED_AWAY") mode = data.get("mode", "ARMED_AWAY")
pin = data.get("pin", "") pin = data.get("pin", "")
# TODO: verify PIN against config hash cfg = _get_cfg()
pin_hash = cfg.security.pin_hash
if pin_hash and not verify_pin(pin, pin_hash):
return jsonify({"error": "Invalid PIN"}), 401
return jsonify({"ok": True, "state": mode}) return jsonify({"ok": True, "state": mode})
@ -66,10 +70,30 @@ def arm_system():
def disarm_system(): def disarm_system():
data = request.get_json() or {} data = request.get_json() or {}
pin = data.get("pin", "") pin = data.get("pin", "")
# TODO: verify PIN cfg = _get_cfg()
pin_hash = cfg.security.pin_hash
if pin_hash and not verify_pin(pin, pin_hash):
return jsonify({"error": "Invalid PIN"}), 401
return jsonify({"ok": True, "state": "DISARMED"}) return jsonify({"ok": True, "state": "DISARMED"})
@system_bp.route("/api/reset-pin", methods=["POST"])
def reset_pin():
data = request.get_json() or {}
recovery_passphrase = data.get("recovery_passphrase", "")
new_pin = data.get("new_pin", "")
cfg = _get_cfg()
if not verify_pin(recovery_passphrase, cfg.security.recovery_passphrase_hash):
return jsonify({"error": "Invalid recovery passphrase"}), 401
new_security = cfg.security.model_copy(update={"pin_hash": hash_pin(new_pin)})
new_cfg = cfg.model_copy(update={"security": new_security})
try:
_save_and_reload(new_cfg)
except Exception:
current_app.config["VIGILAR_CONFIG"] = new_cfg
return jsonify({"ok": True})
# --- Config Read API --- # --- Config Read API ---
@system_bp.route("/api/config") @system_bp.route("/api/config")
@ -82,6 +106,7 @@ def get_config_api():
data.get("system", {}).pop("arm_pin_hash", None) data.get("system", {}).pop("arm_pin_hash", None)
data.get("alerts", {}).get("webhook", {}).pop("secret", None) data.get("alerts", {}).get("webhook", {}).pop("secret", None)
data.get("storage", {}).pop("key_file", None) data.get("storage", {}).pop("key_file", None)
data.pop("security", None)
return jsonify(data) return jsonify(data)

View File

@ -0,0 +1,129 @@
"""Visitors blueprint — face profiles, visits, labeling, privacy controls."""
import shutil
from pathlib import Path
from flask import Blueprint, current_app, jsonify, render_template, request
visitors_bp = Blueprint("visitors", __name__, url_prefix="/visitors")
def _engine():
return current_app.config.get("DB_ENGINE")
@visitors_bp.route("/")
def dashboard():
return render_template("visitors/dashboard.html")
@visitors_bp.route("/api/profiles")
def profiles_api():
engine = _engine()
if engine is None:
return jsonify({"profiles": []})
from vigilar.storage.queries import get_all_profiles
filter_type = request.args.get("filter")
profiles = get_all_profiles(
engine,
named_only=(filter_type == "known"),
include_ignored=(filter_type != "active"),
)
return jsonify({"profiles": profiles})
@visitors_bp.route("/api/visits")
def visits_api():
engine = _engine()
if engine is None:
return jsonify({"visits": []})
from vigilar.storage.queries import get_visits
visits_list = get_visits(
engine,
profile_id=request.args.get("profile_id", type=int),
camera_id=request.args.get("camera_id"),
limit=min(request.args.get("limit", 50, type=int), 500),
)
return jsonify({"visits": visits_list})
@visitors_bp.route("/<int:profile_id>")
def profile_detail(profile_id):
return render_template("visitors/profile.html", profile_id=profile_id)
@visitors_bp.route("/<int:profile_id>/label", methods=["POST"])
def label_profile(profile_id):
engine = _engine()
if engine is None:
return jsonify({"error": "database not available"}), 503
data = request.get_json(silent=True) or {}
if not data.get("consent"):
return jsonify({"error": "Consent required to label a face"}), 400
if not data.get("name"):
return jsonify({"error": "name required"}), 400
from vigilar.storage.queries import update_face_profile
update_face_profile(engine, profile_id, name=data["name"])
return jsonify({"ok": True})
@visitors_bp.route("/<int:profile_id>/link", methods=["POST"])
def link_household(profile_id):
engine = _engine()
if engine is None:
return jsonify({"error": "database not available"}), 503
data = request.get_json(silent=True) or {}
if not data.get("presence_member"):
return jsonify({"error": "presence_member required"}), 400
from vigilar.storage.queries import update_face_profile
update_face_profile(engine, profile_id, is_household=1, presence_member=data["presence_member"])
return jsonify({"ok": True})
@visitors_bp.route("/<int:profile_id>/unlink", methods=["POST"])
def unlink_household(profile_id):
engine = _engine()
if engine is None:
return jsonify({"error": "database not available"}), 503
from vigilar.storage.queries import update_face_profile
update_face_profile(engine, profile_id, is_household=0, presence_member=None)
return jsonify({"ok": True})
@visitors_bp.route("/<int:profile_id>/forget", methods=["DELETE"])
def forget_profile(profile_id):
engine = _engine()
if engine is None:
return jsonify({"error": "database not available"}), 503
from vigilar.storage.queries import get_face_profile
profile = get_face_profile(engine, profile_id)
if not profile:
return jsonify({"error": "Profile not found"}), 404
cfg = current_app.config.get("VIGILAR_CONFIG")
face_dir = cfg.visitors.face_crop_dir if cfg else "/var/vigilar/faces"
profile_dir = Path(face_dir) / str(profile_id)
if profile_dir.exists():
shutil.rmtree(profile_dir)
from vigilar.storage.queries import delete_face_profile_cascade
delete_face_profile_cascade(engine, profile_id)
return jsonify({"status": "forgotten"})
@visitors_bp.route("/<int:profile_id>/ignore", methods=["POST"])
def ignore_profile(profile_id):
engine = _engine()
if engine is None:
return jsonify({"error": "database not available"}), 503
from vigilar.storage.queries import update_face_profile
update_face_profile(engine, profile_id, ignored=1)
return jsonify({"ok": True})
@visitors_bp.route("/<int:profile_id>/unignore", methods=["POST"])
def unignore_profile(profile_id):
engine = _engine()
if engine is None:
return jsonify({"error": "database not available"}), 503
from vigilar.storage.queries import update_face_profile
update_face_profile(engine, profile_id, ignored=0)
return jsonify({"ok": True})

View File

@ -0,0 +1,72 @@
"""Wildlife journal blueprint — sighting log, stats, export."""
import csv
import io
from flask import Blueprint, Response, current_app, jsonify, render_template, request
wildlife_bp = Blueprint("wildlife", __name__, url_prefix="/wildlife")
def _engine():
return current_app.config.get("DB_ENGINE")
@wildlife_bp.route("/")
def journal():
return render_template("wildlife/journal.html")
@wildlife_bp.route("/api/sightings")
def sightings_api():
engine = _engine()
if engine is None:
return jsonify({"sightings": []})
from vigilar.storage.queries import get_wildlife_sightings_paginated
sightings = get_wildlife_sightings_paginated(
engine,
species=request.args.get("species"),
threat_level=request.args.get("threat_level"),
camera_id=request.args.get("camera_id"),
since_ts=request.args.get("since", type=float),
until_ts=request.args.get("until", type=float),
limit=min(request.args.get("limit", 50, type=int), 500),
offset=request.args.get("offset", 0, type=int),
)
return jsonify({"sightings": sightings})
@wildlife_bp.route("/api/stats")
def stats_api():
engine = _engine()
if engine is None:
return jsonify({"total": 0, "species_count": 0, "per_species": {}})
from vigilar.storage.queries import get_wildlife_stats
return jsonify(get_wildlife_stats(engine))
@wildlife_bp.route("/api/frequency")
def frequency_api():
engine = _engine()
if engine is None:
return jsonify({})
from vigilar.storage.queries import get_wildlife_frequency
return jsonify(get_wildlife_frequency(engine))
@wildlife_bp.route("/api/export")
def export_csv():
engine = _engine()
if engine is None:
return Response("No data", mimetype="text/csv")
from vigilar.storage.queries import get_wildlife_sightings_paginated
sightings = get_wildlife_sightings_paginated(engine, limit=10000, offset=0)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["id", "timestamp", "species", "threat_level", "camera_id",
"confidence", "temperature_c", "conditions"])
for s in sightings:
writer.writerow([s["id"], s["ts"], s["species"], s["threat_level"],
s["camera_id"], s.get("confidence"), s.get("temperature_c"), s.get("conditions")])
return Response(output.getvalue(), mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=wildlife_sightings.csv"})

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,465 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="21600">
<title>Vigilar — Ambient</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #111;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
landscape: orientation;
}
/* ── Layout ── */
#ambient-root {
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
}
/* ── Top bar (10%) ── */
#top-bar {
flex: 0 0 10%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2.5rem;
background: rgba(0, 0, 0, 0.5);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
#clock {
font-size: 3.5rem;
font-weight: 200;
letter-spacing: 0.05em;
line-height: 1;
}
#date-label {
font-size: 1.1rem;
color: #aaa;
text-align: center;
}
#weather-label {
font-size: 1.5rem;
color: #aaa;
min-width: 6rem;
text-align: right;
}
/* ── Center camera (70%) ── */
#camera-area {
flex: 0 0 70%;
position: relative;
overflow: hidden;
background: #000;
}
.cam-slide {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 1s ease-in-out;
}
.cam-slide.visible { opacity: 1; }
.cam-slide img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cam-label {
position: absolute;
bottom: 1rem;
left: 1.2rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
color: #fff;
text-shadow: 0 1px 4px rgba(0,0,0,0.9);
pointer-events: none;
}
.live-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #22c55e;
flex-shrink: 0;
animation: live-pulse 2s infinite;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
#no-camera {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #555;
font-size: 1.2rem;
}
/* ── Bottom bar (20%) ── */
#bottom-bar {
flex: 0 0 20%;
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0 2rem;
background: rgba(0, 0, 0, 0.5);
border-top: 1px solid rgba(255, 255, 255, 0.07);
overflow-x: auto;
}
#bottom-bar::-webkit-scrollbar { display: none; }
.pet-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
min-width: 7rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: rgba(255, 255, 255, 0.06);
}
.pet-name {
font-size: 1rem;
font-weight: 600;
color: #e5e7eb;
}
.pet-location {
font-size: 0.75rem;
color: #9ca3af;
text-align: center;
}
.pet-time {
font-size: 0.7rem;
color: #6b7280;
}
#bottom-placeholder {
color: #555;
font-size: 0.9rem;
}
/* ── Alert takeover ── */
#alert-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 100;
background: #000;
flex-direction: column;
}
#alert-overlay.active { display: flex; }
#alert-video-wrap {
flex: 1;
position: relative;
}
#alert-video {
width: 100%;
height: 100%;
object-fit: cover;
}
#alert-badge {
position: absolute;
top: 1.25rem;
left: 50%;
transform: translateX(-50%);
padding: 0.4rem 1.25rem;
border-radius: 99px;
background: rgba(220, 53, 69, 0.9);
color: #fff;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
#alert-countdown {
position: absolute;
bottom: 1rem;
right: 1.5rem;
font-size: 0.85rem;
color: rgba(255,255,255,0.5);
}
/* ── Dim overlay ── */
#dim-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.7);
pointer-events: none;
}
#dim-overlay.active { display: block; }
</style>
</head>
<body>
<div id="ambient-root">
<div id="top-bar">
<div id="clock">--:--</div>
<div id="date-label">Loading…</div>
<div id="weather-label"></div>
</div>
<div id="camera-area">
<div id="cam-slide-a" class="cam-slide visible">
<img id="cam-img-a" src="" alt="">
<div class="cam-label"><span class="live-dot"></span><span id="cam-name-a"></span></div>
</div>
<div id="cam-slide-b" class="cam-slide">
<img id="cam-img-b" src="" alt="">
<div class="cam-label"><span class="live-dot"></span><span id="cam-name-b"></span></div>
</div>
<div id="no-camera" style="display:none;">No cameras configured</div>
</div>
<div id="bottom-bar">
<span id="bottom-placeholder">Loading pet status…</span>
</div>
</div>
<!-- Alert takeover -->
<div id="alert-overlay">
<div id="alert-video-wrap">
<video id="alert-video" autoplay muted playsinline></video>
<div id="alert-badge">Alert</div>
<div id="alert-countdown"></div>
</div>
</div>
<!-- Screen dim overlay -->
<div id="dim-overlay"></div>
<script src="/static/js/hls.min.js"></script>
<script>
(function () {
'use strict';
// ── Config (from server, with JS fallbacks) ──────────────────────────
const serverCfg = {{ kiosk_config | tojson }};
const cfg = {
rotation_interval_s: (serverCfg && serverCfg.rotation_interval_s) || 10,
dim_start: (serverCfg && serverCfg.dim_start) || "23:00",
dim_end: (serverCfg && serverCfg.dim_end) || "06:00",
alert_timeout_s: (serverCfg && serverCfg.alert_timeout_s) || 30,
predator_alert_timeout_s: (serverCfg && serverCfg.predator_alert_timeout_s) || 60,
};
const cameras = {{ cameras | tojson }};
// ── Clock & Date ─────────────────────────────────────────────────────
function updateClock() {
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
document.getElementById('clock').textContent = hh + ':' + mm;
const days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
document.getElementById('date-label').textContent =
days[now.getDay()] + ', ' + months[now.getMonth()] + ' ' + now.getDate();
}
updateClock();
setInterval(updateClock, 60000);
// ── Screen dimming ───────────────────────────────────────────────────
function parseTOD(str) {
const [h, m] = str.split(':').map(Number);
return h * 60 + (m || 0);
}
function checkDim() {
const now = new Date();
const cur = now.getHours() * 60 + now.getMinutes();
const start = parseTOD(cfg.dim_start);
const end = parseTOD(cfg.dim_end);
let dimmed;
if (start > end) {
// spans midnight
dimmed = cur >= start || cur < end;
} else {
dimmed = cur >= start && cur < end;
}
document.getElementById('dim-overlay').classList.toggle('active', dimmed);
}
checkDim();
setInterval(checkDim, 60000);
// ── Camera rotation ──────────────────────────────────────────────────
let camIndex = 0;
let activeSlot = 'a'; // 'a' or 'b'
function snapshotUrl(cam) {
return '/cameras/' + cam.id + '/snapshot';
}
function loadCamera(slot, cam) {
const img = document.getElementById('cam-img-' + slot);
const name = document.getElementById('cam-name-' + slot);
img.src = snapshotUrl(cam) + '?t=' + Date.now();
name.textContent = cam.display_name;
}
function rotateCameras() {
if (!cameras || cameras.length === 0) {
document.getElementById('no-camera').style.display = 'flex';
document.getElementById('cam-slide-a').style.display = 'none';
document.getElementById('cam-slide-b').style.display = 'none';
return;
}
const nextSlot = activeSlot === 'a' ? 'b' : 'a';
camIndex = (camIndex + 1) % cameras.length;
loadCamera(nextSlot, cameras[camIndex]);
// Crossfade
const showing = document.getElementById('cam-slide-' + activeSlot);
const incoming = document.getElementById('cam-slide-' + nextSlot);
incoming.classList.add('visible');
setTimeout(() => {
showing.classList.remove('visible');
activeSlot = nextSlot;
}, 1000);
}
// Initial load
if (cameras && cameras.length > 0) {
loadCamera('a', cameras[0]);
if (cameras.length > 1) {
loadCamera('b', cameras[1]);
}
} else {
document.getElementById('no-camera').style.display = 'flex';
document.getElementById('cam-slide-a').style.display = 'none';
document.getElementById('cam-slide-b').style.display = 'none';
}
setInterval(rotateCameras, cfg.rotation_interval_s * 1000);
// ── Pet status ───────────────────────────────────────────────────────
function timeAgo(isoStr) {
if (!isoStr) return '';
const diff = Math.floor((Date.now() - new Date(isoStr).getTime()) / 1000);
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
return Math.floor(diff / 3600) + 'h ago';
}
function fetchPetStatus() {
fetch('/pets/api/status')
.then(r => r.ok ? r.json() : null)
.then(data => {
const bar = document.getElementById('bottom-bar');
const placeholder = document.getElementById('bottom-placeholder');
if (!data || !data.pets || data.pets.length === 0) {
if (placeholder) placeholder.textContent = 'No pets tracked';
return;
}
if (placeholder) placeholder.remove();
// Rebuild pet cards (preserve existing to avoid flicker on re-render)
bar.innerHTML = '';
data.pets.forEach(pet => {
const card = document.createElement('div');
card.className = 'pet-card';
card.innerHTML =
'<div class="pet-name">' + (pet.name || 'Unknown') + '</div>' +
'<div class="pet-location">' + (pet.last_camera || '—') + '</div>' +
'<div class="pet-time">' + timeAgo(pet.last_seen) + '</div>';
bar.appendChild(card);
});
})
.catch(() => {});
}
fetchPetStatus();
setInterval(fetchPetStatus, 30000);
// ── Alert takeover (SSE) ─────────────────────────────────────────────
let alertTimer = null;
let alertHls = null;
function dismissAlert() {
if (alertTimer) { clearTimeout(alertTimer); alertTimer = null; }
if (alertHls) { alertHls.destroy(); alertHls = null; }
const vid = document.getElementById('alert-video');
vid.src = '';
document.getElementById('alert-overlay').classList.remove('active');
}
function showAlert(event) {
const overlay = document.getElementById('alert-overlay');
const badge = document.getElementById('alert-badge');
const countdown = document.getElementById('alert-countdown');
const vid = document.getElementById('alert-video');
// Dismiss any previous alert
dismissAlert();
// Set badge text
const label = event.type || 'Alert';
badge.textContent = label;
// Choose timeout
const isPredator = (event.type || '').toLowerCase().includes('predator');
const timeout = (isPredator ? cfg.predator_alert_timeout_s : cfg.alert_timeout_s) * 1000;
// Load HLS feed if camera provided
if (event.camera_id) {
const hlsUrl = '/cameras/' + event.camera_id + '/stream.m3u8';
if (Hls && Hls.isSupported()) {
alertHls = new Hls();
alertHls.loadSource(hlsUrl);
alertHls.attachMedia(vid);
} else if (vid.canPlayType('application/vnd.apple.mpegurl')) {
vid.src = hlsUrl;
}
}
overlay.classList.add('active');
// Countdown display
let remaining = Math.ceil(timeout / 1000);
countdown.textContent = remaining + 's';
const countdownInterval = setInterval(() => {
remaining -= 1;
countdown.textContent = remaining + 's';
if (remaining <= 0) clearInterval(countdownInterval);
}, 1000);
alertTimer = setTimeout(() => {
clearInterval(countdownInterval);
dismissAlert();
}, timeout);
}
// SSE listener
function connectSSE() {
const es = new EventSource('/events/stream');
es.onmessage = function (e) {
try {
const event = JSON.parse(e.data);
if (event && event.type) {
showAlert(event);
}
} catch (_) {}
};
es.onerror = function () {
es.close();
// Reconnect after 5s
setTimeout(connectSSE, 5000);
};
}
connectSSE();
})();
</script>
</body>
</html>

View File

@ -0,0 +1,350 @@
{% extends "base.html" %}
{% block title %}Visitors — Vigilar{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0"><i class="bi bi-people-fill me-2 text-info"></i>Visitor Recognition</h5>
</div>
<!-- Tab nav -->
<ul class="nav nav-tabs border-secondary mb-3" id="visitors-tabs">
<li class="nav-item">
<button class="nav-link active" data-tab="household">
<i class="bi bi-house-fill me-1"></i>Household
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-tab="known">
<i class="bi bi-person-check-fill me-1"></i>Known
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-tab="unknown">
<i class="bi bi-person-fill-question me-1"></i>Unknown
</button>
</li>
<li class="nav-item">
<button class="nav-link" data-tab="visits">
<i class="bi bi-clock-history me-1"></i>Recent Visits
</button>
</li>
</ul>
<!-- Profiles panel -->
<div id="panel-household" class="tab-panel">
<div class="card bg-dark border-secondary">
<div class="card-header border-secondary py-2">
<span class="small fw-semibold">Household Members</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0 small">
<thead>
<tr>
<th>Name</th>
<th>Presence Link</th>
<th>Visits</th>
<th>Last Seen</th>
<th></th>
</tr>
</thead>
<tbody id="tbody-household">
<tr><td colspan="5" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div id="panel-known" class="tab-panel d-none">
<div class="card bg-dark border-secondary">
<div class="card-header border-secondary py-2">
<span class="small fw-semibold">Named Visitors</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0 small">
<thead>
<tr>
<th>Name</th>
<th>Visits</th>
<th>First Seen</th>
<th>Last Seen</th>
<th></th>
</tr>
</thead>
<tbody id="tbody-known">
<tr><td colspan="5" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div id="panel-unknown" class="tab-panel d-none">
<div class="card bg-dark border-secondary">
<div class="card-header border-secondary py-2">
<span class="small fw-semibold">Unidentified Faces</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0 small">
<thead>
<tr>
<th>Profile ID</th>
<th>Visits</th>
<th>First Seen</th>
<th>Last Seen</th>
<th>Ignored</th>
<th></th>
</tr>
</thead>
<tbody id="tbody-unknown">
<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div id="panel-visits" class="tab-panel d-none">
<div class="card bg-dark border-secondary">
<div class="card-header border-secondary py-2">
<span class="small fw-semibold">Recent Visits</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0 small">
<thead>
<tr>
<th>Profile</th>
<th>Camera</th>
<th>Arrived</th>
<th>Departed</th>
<th>Duration</th>
</tr>
</thead>
<tbody id="tbody-visits">
<tr><td colspan="5" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Label modal -->
<div class="modal fade" id="label-modal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark border-secondary">
<div class="modal-header border-secondary">
<h6 class="modal-title"><i class="bi bi-tag-fill me-2"></i>Label Face Profile</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning small py-2">
<i class="bi bi-shield-exclamation me-1"></i>
Labeling stores biometric data. Only label faces with the subject's consent.
</div>
<div class="mb-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm bg-dark text-light border-secondary"
id="label-name-input" placeholder="Enter name">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="label-consent-check">
<label class="form-check-label small" for="label-consent-check">
I confirm consent has been obtained
</label>
</div>
</div>
<div class="modal-footer border-secondary">
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-sm btn-primary" id="label-confirm-btn">Save Label</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
'use strict';
let labelTargetId = null;
const labelModal = new bootstrap.Modal(document.getElementById('label-modal'));
function fmtTs(ts) {
if (!ts) return '—';
return new Date(ts * 1000).toLocaleString();
}
function fmtDuration(s) {
if (!s) return '—';
if (s < 60) return s.toFixed(0) + 's';
return (s / 60).toFixed(1) + 'm';
}
function profileLink(p) {
const label = p.name || ('Profile #' + p.id);
return `<a href="/visitors/${p.id}" class="text-info text-decoration-none">${label}</a>`;
}
function actionBtns(p) {
const ignore = p.ignored
? `<button class="btn btn-xs btn-outline-secondary btn-unignore" data-id="${p.id}">Unignore</button>`
: `<button class="btn btn-xs btn-outline-warning btn-ignore" data-id="${p.id}">Ignore</button>`;
return `<div class="d-flex gap-1 justify-content-end">
<button class="btn btn-xs btn-outline-info btn-label" data-id="${p.id}">Label</button>
${ignore}
<button class="btn btn-xs btn-outline-danger btn-forget" data-id="${p.id}">Forget</button>
</div>`;
}
function loadHousehold() {
fetch('/visitors/api/profiles')
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
const rows = (data.profiles || []).filter(p => p.is_household);
const tbody = document.getElementById('tbody-household');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">No household members</td></tr>';
return;
}
tbody.innerHTML = rows.map(p => `<tr>
<td>${profileLink(p)}</td>
<td><span class="badge bg-secondary">${p.presence_member || '—'}</span></td>
<td>${p.visit_count}</td>
<td class="text-nowrap">${fmtTs(p.last_seen_at)}</td>
<td>${actionBtns(p)}</td>
</tr>`).join('');
}).catch(() => {});
}
function loadKnown() {
fetch('/visitors/api/profiles?filter=known')
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
const rows = (data.profiles || []).filter(p => !p.is_household);
const tbody = document.getElementById('tbody-known');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">No named visitors</td></tr>';
return;
}
tbody.innerHTML = rows.map(p => `<tr>
<td>${profileLink(p)}</td>
<td>${p.visit_count}</td>
<td class="text-nowrap">${fmtTs(p.first_seen_at)}</td>
<td class="text-nowrap">${fmtTs(p.last_seen_at)}</td>
<td>${actionBtns(p)}</td>
</tr>`).join('');
}).catch(() => {});
}
function loadUnknown() {
fetch('/visitors/api/profiles')
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
const rows = (data.profiles || []).filter(p => !p.name && !p.is_household);
const tbody = document.getElementById('tbody-unknown');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-3">No unidentified faces</td></tr>';
return;
}
tbody.innerHTML = rows.map(p => `<tr>
<td>${profileLink(p)}</td>
<td>${p.visit_count}</td>
<td class="text-nowrap">${fmtTs(p.first_seen_at)}</td>
<td class="text-nowrap">${fmtTs(p.last_seen_at)}</td>
<td>${p.ignored ? '<span class="badge bg-secondary">Yes</span>' : '—'}</td>
<td>${actionBtns(p)}</td>
</tr>`).join('');
}).catch(() => {});
}
function loadVisits() {
fetch('/visitors/api/visits?limit=50')
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
const rows = data.visits || [];
const tbody = document.getElementById('tbody-visits');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">No visits recorded</td></tr>';
return;
}
tbody.innerHTML = rows.map(v => `<tr>
<td><a href="/visitors/${v.profile_id}" class="text-info text-decoration-none">#${v.profile_id}</a></td>
<td>${v.camera_id}</td>
<td class="text-nowrap">${fmtTs(v.arrived_at)}</td>
<td class="text-nowrap">${fmtTs(v.departed_at)}</td>
<td>${fmtDuration(v.duration_s)}</td>
</tr>`).join('');
}).catch(() => {});
}
// Tab switching
document.querySelectorAll('[data-tab]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-tab]').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.add('d-none'));
btn.classList.add('active');
document.getElementById('panel-' + btn.dataset.tab).classList.remove('d-none');
if (btn.dataset.tab === 'household') loadHousehold();
else if (btn.dataset.tab === 'known') loadKnown();
else if (btn.dataset.tab === 'unknown') loadUnknown();
else if (btn.dataset.tab === 'visits') loadVisits();
});
});
// Action delegation
document.addEventListener('click', e => {
const btn = e.target.closest('button[class*="btn-label"], button[class*="btn-ignore"],' +
'button[class*="btn-unignore"], button[class*="btn-forget"]');
if (!btn) return;
const id = btn.dataset.id;
if (btn.classList.contains('btn-label')) {
labelTargetId = id;
document.getElementById('label-name-input').value = '';
document.getElementById('label-consent-check').checked = false;
labelModal.show();
} else if (btn.classList.contains('btn-ignore')) {
fetch(`/visitors/${id}/ignore`, { method: 'POST' }).then(() => loadAll());
} else if (btn.classList.contains('btn-unignore')) {
fetch(`/visitors/${id}/unignore`, { method: 'POST' }).then(() => loadAll());
} else if (btn.classList.contains('btn-forget')) {
if (confirm('Permanently delete this face profile and all associated data?')) {
fetch(`/visitors/${id}/forget`, { method: 'DELETE' }).then(() => loadAll());
}
}
});
document.getElementById('label-confirm-btn').addEventListener('click', () => {
if (!labelTargetId) return;
const name = document.getElementById('label-name-input').value.trim();
const consent = document.getElementById('label-consent-check').checked;
fetch(`/visitors/${labelTargetId}/label`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, consent }),
}).then(r => {
if (r.ok) { labelModal.hide(); loadAll(); }
else r.json().then(d => alert(d.error || 'Failed to label profile'));
});
});
function loadAll() {
loadHousehold();
loadKnown();
loadUnknown();
loadVisits();
}
loadHousehold();
setInterval(loadAll, 60000);
})();
</script>
{% endblock %}

View File

@ -0,0 +1,211 @@
{% extends "base.html" %}
{% block title %}Visitor Profile — Vigilar{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-3">
<a href="/visitors/" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i>
</a>
<h5 class="mb-0"><i class="bi bi-person-badge-fill me-2 text-info"></i>
<span id="profile-title">Loading…</span>
</h5>
</div>
<!-- Profile summary -->
<div class="row g-3 mb-3">
<div class="col-md-4">
<div class="card bg-dark border-secondary h-100">
<div class="card-header border-secondary py-2">
<span class="small fw-semibold"><i class="bi bi-info-circle me-1"></i>Profile Details</span>
</div>
<div class="card-body small" id="profile-details">
<div class="text-muted">Loading…</div>
</div>
<div class="card-footer border-secondary py-2 d-flex flex-wrap gap-2" id="profile-actions">
</div>
</div>
</div>
<div class="col-md-8">
<div class="card bg-dark border-secondary h-100">
<div class="card-header border-secondary py-2">
<span class="small fw-semibold"><i class="bi bi-clock-history me-1"></i>Visit History</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0 small">
<thead>
<tr>
<th>Camera</th>
<th>Arrived</th>
<th>Departed</th>
<th>Duration</th>
</tr>
</thead>
<tbody id="visits-tbody">
<tr><td colspan="4" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Label modal -->
<div class="modal fade" id="label-modal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-dark border-secondary">
<div class="modal-header border-secondary">
<h6 class="modal-title"><i class="bi bi-tag-fill me-2"></i>Label Face Profile</h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning small py-2">
<i class="bi bi-shield-exclamation me-1"></i>
Labeling stores biometric data. Only label faces with the subject's consent.
</div>
<div class="mb-3">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm bg-dark text-light border-secondary"
id="label-name-input" placeholder="Enter name">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="label-consent-check">
<label class="form-check-label small" for="label-consent-check">
I confirm consent has been obtained
</label>
</div>
</div>
<div class="modal-footer border-secondary">
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-sm btn-primary" id="label-confirm-btn">Save Label</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
'use strict';
const profileId = {{ profile_id }};
const labelModal = new bootstrap.Modal(document.getElementById('label-modal'));
function fmtTs(ts) {
if (!ts) return '—';
return new Date(ts * 1000).toLocaleString();
}
function fmtDuration(s) {
if (!s) return '—';
if (s < 60) return s.toFixed(0) + 's';
return (s / 60).toFixed(1) + 'm';
}
function loadProfile() {
fetch('/visitors/api/profiles')
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
const profile = (data.profiles || []).find(p => p.id === profileId);
if (!profile) {
document.getElementById('profile-title').textContent = 'Profile not found';
return;
}
document.getElementById('profile-title').textContent =
profile.name || ('Visitor #' + profile.id);
const details = document.getElementById('profile-details');
details.innerHTML = `
<dl class="mb-0">
<dt class="text-muted">Name</dt>
<dd>${profile.name || '<em class="text-muted">Unlabeled</em>'}</dd>
<dt class="text-muted">Household</dt>
<dd>${profile.is_household
? `<span class="badge bg-primary">Yes</span>${profile.presence_member ? ' — ' + profile.presence_member : ''}`
: '—'}</dd>
<dt class="text-muted">Total Visits</dt>
<dd>${profile.visit_count}</dd>
<dt class="text-muted">First Seen</dt>
<dd>${fmtTs(profile.first_seen_at)}</dd>
<dt class="text-muted">Last Seen</dt>
<dd>${fmtTs(profile.last_seen_at)}</dd>
<dt class="text-muted">Status</dt>
<dd>${profile.ignored ? '<span class="badge bg-secondary">Ignored</span>' : '<span class="badge bg-success">Active</span>'}</dd>
</dl>`;
const actions = document.getElementById('profile-actions');
actions.innerHTML = `
<button class="btn btn-sm btn-outline-info" id="btn-label">
<i class="bi bi-tag me-1"></i>Label
</button>
${profile.ignored
? '<button class="btn btn-sm btn-outline-secondary" id="btn-unignore">Unignore</button>'
: '<button class="btn btn-sm btn-outline-warning" id="btn-ignore">Ignore</button>'}
<button class="btn btn-sm btn-outline-danger" id="btn-forget">
<i class="bi bi-trash me-1"></i>Forget
</button>`;
document.getElementById('btn-label').addEventListener('click', () => {
document.getElementById('label-name-input').value = profile.name || '';
document.getElementById('label-consent-check').checked = false;
labelModal.show();
});
const ignBtn = document.getElementById('btn-ignore') || document.getElementById('btn-unignore');
if (ignBtn) {
ignBtn.addEventListener('click', () => {
const endpoint = profile.ignored ? 'unignore' : 'ignore';
fetch(`/visitors/${profileId}/${endpoint}`, { method: 'POST' })
.then(() => loadProfile());
});
}
document.getElementById('btn-forget').addEventListener('click', () => {
if (confirm('Permanently delete this profile and all associated data?')) {
fetch(`/visitors/${profileId}/forget`, { method: 'DELETE' })
.then(() => { window.location.href = '/visitors/'; });
}
});
}).catch(() => {
document.getElementById('profile-title').textContent = 'Error loading profile';
});
}
function loadVisits() {
fetch(`/visitors/api/visits?profile_id=${profileId}&limit=100`)
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
const rows = data.visits || [];
const tbody = document.getElementById('visits-tbody');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-3">No visits recorded</td></tr>';
return;
}
tbody.innerHTML = rows.map(v => `<tr>
<td>${v.camera_id}</td>
<td class="text-nowrap">${fmtTs(v.arrived_at)}</td>
<td class="text-nowrap">${fmtTs(v.departed_at)}</td>
<td>${fmtDuration(v.duration_s)}</td>
</tr>`).join('');
}).catch(() => {});
}
document.getElementById('label-confirm-btn').addEventListener('click', () => {
const name = document.getElementById('label-name-input').value.trim();
const consent = document.getElementById('label-consent-check').checked;
fetch(`/visitors/${profileId}/label`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, consent }),
}).then(r => {
if (r.ok) { labelModal.hide(); loadProfile(); }
else r.json().then(d => alert(d.error || 'Failed to label profile'));
});
});
loadProfile();
loadVisits();
setInterval(() => { loadProfile(); loadVisits(); }, 30000);
})();
</script>
{% endblock %}

View File

@ -0,0 +1,343 @@
{% extends "base.html" %}
{% block title %}Wildlife Journal — Vigilar{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0"><i class="bi bi-binoculars-fill me-2 text-success"></i>Wildlife Journal</h5>
<div class="d-flex gap-2">
<a href="/wildlife/api/export" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-download me-1"></i>Export CSV
</a>
</div>
</div>
<!-- Summary cards -->
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="card bg-dark border-secondary h-100">
<div class="card-body text-center py-3">
<div class="fs-3 fw-bold text-success" id="stat-total"></div>
<div class="small text-muted">Total Sightings</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card bg-dark border-secondary h-100">
<div class="card-body text-center py-3">
<div class="fs-3 fw-bold text-info" id="stat-species"></div>
<div class="small text-muted">Species Observed</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card bg-dark border-secondary h-100">
<div class="card-body text-center py-3">
<div class="fs-3 fw-bold text-warning" id="stat-top-species"></div>
<div class="small text-muted">Most Frequent</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card bg-dark border-secondary h-100">
<div class="card-body text-center py-3">
<div class="fs-3 fw-bold text-danger" id="stat-predators"></div>
<div class="small text-muted">Predator Sightings</div>
</div>
</div>
</div>
</div>
<!-- Species breakdown -->
<div class="card bg-dark border-secondary mb-3">
<div class="card-header border-secondary py-2">
<span class="small fw-semibold"><i class="bi bi-bar-chart-fill me-2"></i>Species Breakdown</span>
</div>
<div class="card-body py-2">
<div class="d-flex flex-wrap gap-2" id="species-bars">
<span class="text-muted small">Loading…</span>
</div>
</div>
</div>
<!-- Activity by time of day -->
<div class="card bg-dark border-secondary mb-3">
<div class="card-header border-secondary py-2">
<span class="small fw-semibold"><i class="bi bi-clock-history me-2"></i>Activity by Time of Day</span>
</div>
<div class="card-body py-2">
<div class="row g-2" id="frequency-grid">
<div class="col-12 text-muted small text-center py-2">Loading…</div>
</div>
</div>
</div>
<!-- Filters + sighting table -->
<div class="card bg-dark border-secondary mb-3">
<div class="card-header border-secondary py-2 d-flex justify-content-between align-items-center">
<span class="small fw-semibold"><i class="bi bi-table me-2"></i>Sighting Log</span>
<div class="d-flex gap-2">
<select class="form-select form-select-sm bg-dark text-light border-secondary" id="filter-species"
style="width:auto;">
<option value="">All species</option>
</select>
<select class="form-select form-select-sm bg-dark text-light border-secondary" id="filter-threat"
style="width:auto;">
<option value="">All threat levels</option>
<option value="PASSIVE">Passive</option>
<option value="NUISANCE">Nuisance</option>
<option value="PREDATOR">Predator</option>
</select>
<select class="form-select form-select-sm bg-dark text-light border-secondary" id="filter-camera"
style="width:auto;">
<option value="">All cameras</option>
</select>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-dark table-hover mb-0 small">
<thead>
<tr>
<th>Time</th>
<th>Species</th>
<th>Threat</th>
<th>Camera</th>
<th>Confidence</th>
<th>Conditions</th>
</tr>
</thead>
<tbody id="sightings-tbody">
<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top border-secondary">
<span class="small text-muted" id="page-info"></span>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" id="btn-prev" disabled>
<i class="bi bi-chevron-left"></i> Prev
</button>
<button class="btn btn-sm btn-outline-secondary" id="btn-next" disabled>
Next <i class="bi bi-chevron-right"></i>
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function () {
'use strict';
const THREAT_BADGE = {
PASSIVE: 'bg-success',
NUISANCE: 'bg-warning text-dark',
PREDATOR: 'bg-danger',
};
const PAGE_SIZE = 50;
let currentOffset = 0;
let totalShown = 0;
// ---- utility ------------------------------------------------------------
function fmtTs(ts) {
if (!ts) return '—';
return new Date(ts * 1000).toLocaleString();
}
function fmtPct(v) {
if (v == null) return '—';
return (v * 100).toFixed(1) + '%';
}
// ---- stats --------------------------------------------------------------
function loadStats() {
fetch('/wildlife/api/stats')
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
document.getElementById('stat-total').textContent = data.total ?? '—';
document.getElementById('stat-species').textContent = data.species_count ?? '—';
const per = data.per_species || {};
const entries = Object.entries(per).sort((a, b) => b[1] - a[1]);
document.getElementById('stat-top-species').textContent =
entries.length > 0 ? entries[0][0] : '—';
// Populate species filter
const sel = document.getElementById('filter-species');
entries.forEach(([sp]) => {
if (!sel.querySelector(`option[value="${sp}"]`)) {
const opt = document.createElement('option');
opt.value = sp;
opt.textContent = sp.charAt(0).toUpperCase() + sp.slice(1);
sel.appendChild(opt);
}
});
// Species bars
const barsEl = document.getElementById('species-bars');
if (entries.length === 0) {
barsEl.innerHTML = '<span class="text-muted small">No sightings recorded</span>';
} else {
const maxCnt = entries[0][1];
barsEl.innerHTML = entries.map(([sp, cnt]) => {
const pct = Math.round((cnt / maxCnt) * 100);
return `<div style="min-width:12rem;flex:1 1 12rem;">
<div class="d-flex justify-content-between small mb-1">
<span class="fw-semibold">${sp}</span>
<span class="text-muted">${cnt}</span>
</div>
<div class="progress" style="height:8px;background:rgba(255,255,255,.1);">
<div class="progress-bar bg-success" style="width:${pct}%"></div>
</div>
</div>`;
}).join('');
}
})
.catch(() => {});
}
// ---- frequency ----------------------------------------------------------
function loadFrequency() {
fetch('/wildlife/api/frequency')
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
const grid = document.getElementById('frequency-grid');
const buckets = Object.entries(data);
if (buckets.length === 0) {
grid.innerHTML = '<div class="col-12 text-muted small text-center py-2">No data</div>';
return;
}
// find max value across all buckets
let maxVal = 1;
buckets.forEach(([, counts]) => {
const total = Object.values(counts).reduce((a, b) => a + b, 0);
if (total > maxVal) maxVal = total;
});
grid.innerHTML = buckets.map(([label, counts]) => {
const total = Object.values(counts).reduce((a, b) => a + b, 0);
const pct = Math.round((total / maxVal) * 100);
const species = Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.map(([sp, n]) => `${sp} (${n})`)
.join(', ');
return `<div class="col-6 col-md-4 col-lg-2">
<div class="text-center small text-muted mb-1">${label}</div>
<div class="progress mb-1" style="height:20px;background:rgba(255,255,255,.1);">
<div class="progress-bar bg-info" style="width:${pct}%;" title="${species}">
${total > 0 ? total : ''}
</div>
</div>
<div class="text-center x-small text-muted" style="font-size:.7rem;">${species || 'none'}</div>
</div>`;
}).join('');
})
.catch(() => {
document.getElementById('frequency-grid').innerHTML =
'<div class="col-12 text-muted small text-center py-2">Unavailable</div>';
});
}
// ---- sightings table ----------------------------------------------------
function buildParams(offset) {
const params = new URLSearchParams();
const species = document.getElementById('filter-species').value;
const threat = document.getElementById('filter-threat').value;
const camera = document.getElementById('filter-camera').value;
if (species) params.set('species', species);
if (threat) params.set('threat_level', threat);
if (camera) params.set('camera_id', camera);
params.set('limit', PAGE_SIZE);
params.set('offset', offset);
return params;
}
function loadSightings(offset) {
const tbody = document.getElementById('sightings-tbody');
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-3">Loading…</td></tr>';
fetch('/wildlife/api/sightings?' + buildParams(offset))
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
const rows = data.sightings || [];
totalShown = rows.length;
currentOffset = offset;
if (rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-3">No sightings found</td></tr>';
} else {
tbody.innerHTML = rows.map(s => {
const badgeCls = THREAT_BADGE[s.threat_level] || 'bg-secondary';
return `<tr>
<td class="text-nowrap">${fmtTs(s.ts)}</td>
<td class="fw-semibold">${s.species || '—'}</td>
<td><span class="badge ${badgeCls}">${s.threat_level || '—'}</span></td>
<td>${s.camera_id || '—'}</td>
<td>${fmtPct(s.confidence)}</td>
<td class="text-muted">${s.conditions || '—'}</td>
</tr>`;
}).join('');
// Populate camera filter from data
const camSel = document.getElementById('filter-camera');
rows.forEach(s => {
if (s.camera_id && !camSel.querySelector(`option[value="${s.camera_id}"]`)) {
const opt = document.createElement('option');
opt.value = s.camera_id;
opt.textContent = s.camera_id;
camSel.appendChild(opt);
}
});
}
const pageInfo = document.getElementById('page-info');
pageInfo.textContent = rows.length === 0
? 'No results'
: `Showing ${offset + 1}${offset + rows.length}`;
document.getElementById('btn-prev').disabled = offset === 0;
document.getElementById('btn-next').disabled = rows.length < PAGE_SIZE;
})
.catch(() => {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-3">Failed to load sightings</td></tr>';
});
}
// ---- stats predator count -----------------------------------------------
function loadPredatorCount() {
fetch('/wildlife/api/sightings?threat_level=PREDATOR&limit=500&offset=0')
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
document.getElementById('stat-predators').textContent =
(data.sightings || []).length;
})
.catch(() => {});
}
// ---- events -------------------------------------------------------------
document.getElementById('btn-prev').addEventListener('click', () => {
loadSightings(Math.max(0, currentOffset - PAGE_SIZE));
});
document.getElementById('btn-next').addEventListener('click', () => {
loadSightings(currentOffset + PAGE_SIZE);
});
['filter-species', 'filter-threat', 'filter-camera'].forEach(id => {
document.getElementById(id).addEventListener('change', () => {
currentOffset = 0;
loadSightings(0);
});
});
// ---- initial load -------------------------------------------------------
loadStats();
loadFrequency();
loadSightings(0);
loadPredatorCount();
setInterval(() => { loadStats(); loadFrequency(); loadSightings(currentOffset); }, 60000);
})();
</script>
{% endblock %}