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:
commit
965dc3b13d
@ -24,6 +24,7 @@ dependencies = [
|
||||
"py-vapid>=1.9.0",
|
||||
"ultralytics>=8.2.0",
|
||||
"torchvision>=0.18.0",
|
||||
"requests>=2.32.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@ -31,6 +32,9 @@ gpio = [
|
||||
"gpiozero>=2.0.1",
|
||||
"RPi.GPIO>=0.7.1",
|
||||
]
|
||||
face = [
|
||||
"face_recognition>=1.3.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8.2.0",
|
||||
"pytest-cov>=5.0.0",
|
||||
|
||||
@ -82,6 +82,52 @@ class TestPetActivityConfig:
|
||||
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:
|
||||
def test_default_location_is_interior(self):
|
||||
from vigilar.config import CameraConfig
|
||||
|
||||
58
tests/unit/test_encryption.py
Normal file
58
tests/unit/test_encryption.py
Normal 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
48
tests/unit/test_face.py
Normal 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
|
||||
17
tests/unit/test_heatmap.py
Normal file
17
tests/unit/test_heatmap.py
Normal 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"
|
||||
29
tests/unit/test_kiosk_ambient.py
Normal file
29
tests/unit/test_kiosk_ambient.py
Normal 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
|
||||
39
tests/unit/test_package.py
Normal file
39
tests/unit/test_package.py
Normal 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
|
||||
21
tests/unit/test_package_queries.py
Normal file
21
tests/unit/test_package_queries.py
Normal 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"
|
||||
21
tests/unit/test_package_schema.py
Normal file
21
tests/unit/test_package_schema.py
Normal 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
|
||||
41
tests/unit/test_pet_rules.py
Normal file
41
tests/unit/test_pet_rules.py
Normal 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
|
||||
55
tests/unit/test_pet_rules_api.py
Normal file
55
tests/unit/test_pet_rules_api.py
Normal 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
|
||||
36
tests/unit/test_pet_rules_queries.py
Normal file
36
tests/unit/test_pet_rules_queries.py
Normal 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
39
tests/unit/test_pin.py
Normal 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
|
||||
35
tests/unit/test_processor_alerts.py
Normal file
35
tests/unit/test_processor_alerts.py
Normal 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()
|
||||
10
tests/unit/test_recorder_encryption.py
Normal file
10
tests/unit/test_recorder_encryption.py
Normal 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)
|
||||
120
tests/unit/test_recordings_api.py
Normal file
120
tests/unit/test_recordings_api.py
Normal 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
36
tests/unit/test_reel.py
Normal 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
38
tests/unit/test_sender.py
Normal 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
18
tests/unit/test_solar.py
Normal 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
|
||||
7
tests/unit/test_syslog_config.py
Normal file
7
tests/unit/test_syslog_config.py
Normal 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"
|
||||
79
tests/unit/test_system_pin.py
Normal file
79
tests/unit/test_system_pin.py
Normal 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
|
||||
49
tests/unit/test_timelapse.py
Normal file
49
tests/unit/test_timelapse.py
Normal 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
|
||||
57
tests/unit/test_visitor_queries.py
Normal file
57
tests/unit/test_visitor_queries.py
Normal 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
|
||||
69
tests/unit/test_visitors_api.py
Normal file
69
tests/unit/test_visitors_api.py
Normal 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
|
||||
39
tests/unit/test_weather.py
Normal file
39
tests/unit/test_weather.py
Normal 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"
|
||||
49
tests/unit/test_wildlife_api.py
Normal file
49
tests/unit/test_wildlife_api.py
Normal 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
|
||||
38
tests/unit/test_wildlife_queries.py
Normal file
38
tests/unit/test_wildlife_queries.py
Normal 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"
|
||||
13
tests/unit/test_wildlife_schema.py
Normal file
13
tests/unit/test_wildlife_schema.py
Normal 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
23
vigilar/alerts/pin.py
Normal 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
99
vigilar/alerts/sender.py
Normal 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
|
||||
@ -156,6 +156,27 @@ class AdaptiveRecorder:
|
||||
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._current_path = None
|
||||
return segment
|
||||
|
||||
@ -275,6 +275,12 @@ def run_camera_worker(
|
||||
detections = yolo_detector.detect(frame)
|
||||
for det in detections:
|
||||
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":
|
||||
# Crop for pet ID and staging
|
||||
x, y, w, h = det.bbox
|
||||
@ -295,6 +301,7 @@ def run_camera_worker(
|
||||
"confidence": round(det.confidence, 3),
|
||||
"camera_location": camera_cfg.location,
|
||||
"crop_path": crop_path,
|
||||
"bbox": norm_bbox,
|
||||
}
|
||||
if pet_result and pet_result.is_identified:
|
||||
payload["pet_id"] = pet_result.pet_id
|
||||
@ -320,6 +327,7 @@ def run_camera_worker(
|
||||
confidence=round(det.confidence, 3),
|
||||
camera_location=camera_cfg.location,
|
||||
crop_path=None,
|
||||
bbox=norm_bbox,
|
||||
)
|
||||
|
||||
elif category == "person":
|
||||
@ -327,6 +335,7 @@ def run_camera_worker(
|
||||
Topics.camera_motion_start(camera_id),
|
||||
detection="person",
|
||||
confidence=round(det.confidence, 3),
|
||||
bbox=norm_bbox,
|
||||
)
|
||||
|
||||
elif category == "vehicle":
|
||||
@ -334,6 +343,7 @@ def run_camera_worker(
|
||||
Topics.camera_motion_start(camera_id),
|
||||
detection="vehicle",
|
||||
confidence=round(det.confidence, 3),
|
||||
bbox=norm_bbox,
|
||||
)
|
||||
|
||||
# Heartbeat every 10 seconds
|
||||
|
||||
@ -280,6 +280,56 @@ class PetsConfig(BaseModel):
|
||||
min_training_images: int = 20
|
||||
wildlife: WildlifeConfig = Field(default_factory=WildlifeConfig)
|
||||
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 ---
|
||||
@ -328,6 +378,11 @@ class VigilarConfig(BaseModel):
|
||||
vehicles: VehicleConfig = Field(default_factory=VehicleConfig)
|
||||
health: HealthConfig = Field(default_factory=HealthConfig)
|
||||
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)
|
||||
sensors: list[SensorConfig] = Field(default_factory=list)
|
||||
sensor_gpio: SensorGPIOConfig = Field(default_factory=SensorGPIOConfig, alias="sensors.gpio")
|
||||
|
||||
@ -45,6 +45,13 @@ class EventType(StrEnum):
|
||||
WILDLIFE_PREDATOR = "WILDLIFE_PREDATOR"
|
||||
WILDLIFE_NUISANCE = "WILDLIFE_NUISANCE"
|
||||
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 ---
|
||||
@ -75,6 +82,8 @@ class RecordingTrigger(StrEnum):
|
||||
VEHICLE = "VEHICLE"
|
||||
PET = "PET"
|
||||
WILDLIFE = "WILDLIFE"
|
||||
HIGHLIGHT = "HIGHLIGHT"
|
||||
TIMELAPSE = "TIMELAPSE"
|
||||
|
||||
|
||||
# --- Alert Channels ---
|
||||
@ -180,6 +189,18 @@ class Topics:
|
||||
def camera_wildlife_detected(camera_id: str) -> str:
|
||||
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
|
||||
def pet_location(pet_name: str) -> str:
|
||||
return f"vigilar/pets/{pet_name}/location"
|
||||
@ -188,6 +209,7 @@ class Topics:
|
||||
SYSTEM_ARM_STATE = "vigilar/system/arm_state"
|
||||
SYSTEM_ALERT = "vigilar/system/alert"
|
||||
SYSTEM_SHUTDOWN = "vigilar/system/shutdown"
|
||||
SYSTEM_RULES_UPDATED = "vigilar/system/rules_updated"
|
||||
|
||||
# Wildcard subscriptions
|
||||
ALL = "vigilar/#"
|
||||
|
||||
103
vigilar/detection/face.py
Normal file
103
vigilar/detection/face.py
Normal 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)
|
||||
71
vigilar/detection/heatmap.py
Normal file
71
vigilar/detection/heatmap.py
Normal 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()
|
||||
99
vigilar/detection/package.py
Normal file
99
vigilar/detection/package.py
Normal 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
|
||||
31
vigilar/detection/solar.py
Normal file
31
vigilar/detection/solar.py
Normal 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)
|
||||
57
vigilar/detection/weather.py
Normal file
57
vigilar/detection/weather.py
Normal 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
|
||||
@ -13,6 +13,7 @@ from vigilar.constants import EventType, Severity, Topics
|
||||
from vigilar.events.rules import RuleEngine
|
||||
from vigilar.events.state import ArmStateFSM
|
||||
from vigilar.storage.db import get_db_path, init_db
|
||||
from vigilar.alerts.sender import send_alert
|
||||
from vigilar.storage.queries import insert_event
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@ -46,6 +47,7 @@ class EventProcessor:
|
||||
# Init DB
|
||||
db_path = get_db_path(self._config.system.data_dir)
|
||||
engine = init_db(db_path)
|
||||
self._engine = engine
|
||||
|
||||
# Init components
|
||||
fsm = ArmStateFSM(engine, self._config)
|
||||
@ -134,7 +136,10 @@ class EventProcessor:
|
||||
|
||||
# Execute 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:
|
||||
log.exception("Error processing event on %s", topic)
|
||||
@ -178,6 +183,20 @@ class EventProcessor:
|
||||
else:
|
||||
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.
|
||||
return None, None, None
|
||||
|
||||
@ -217,18 +236,35 @@ class EventProcessor:
|
||||
event_id: int,
|
||||
bus: MessageBus,
|
||||
payload: dict[str, Any],
|
||||
event_type: str = "",
|
||||
severity: str = "",
|
||||
source_id: str = "",
|
||||
) -> None:
|
||||
"""Execute a rule action."""
|
||||
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, {
|
||||
"ts": int(time.time() * 1000),
|
||||
"event_id": event_id,
|
||||
"type": "alert",
|
||||
"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
|
||||
for cam in self._config.cameras:
|
||||
bus.publish(f"vigilar/camera/{cam.id}/command/record", {
|
||||
@ -236,5 +272,3 @@ class EventProcessor:
|
||||
"event_id": event_id,
|
||||
"action": "start_recording",
|
||||
})
|
||||
else:
|
||||
log.warning("Unknown action: %s", action)
|
||||
|
||||
@ -73,6 +73,9 @@ class HealthMonitor:
|
||||
log.info("Health monitor started")
|
||||
last_disk_check = 0
|
||||
last_mqtt_check = 0
|
||||
last_highlight_check = 0
|
||||
last_timelapse_check = 0
|
||||
highlight_generated_today = False
|
||||
|
||||
while not shutdown:
|
||||
now = time.monotonic()
|
||||
@ -91,6 +94,37 @@ class HealthMonitor:
|
||||
self._update_check(mqtt)
|
||||
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()
|
||||
time.sleep(10)
|
||||
|
||||
|
||||
0
vigilar/highlights/__init__.py
Normal file
0
vigilar/highlights/__init__.py
Normal file
168
vigilar/highlights/reel.py
Normal file
168
vigilar/highlights/reel.py
Normal 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)
|
||||
96
vigilar/highlights/timelapse.py
Normal file
96
vigilar/highlights/timelapse.py
Normal 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)
|
||||
@ -147,6 +147,18 @@ def run_supervisor(cfg: VigilarConfig) -> None:
|
||||
|
||||
# Start all 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:
|
||||
sub.start()
|
||||
|
||||
|
||||
0
vigilar/pets/__init__.py
Normal file
0
vigilar/pets/__init__.py
Normal file
135
vigilar/pets/rules.py
Normal file
135
vigilar/pets/rules.py
Normal 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
|
||||
43
vigilar/storage/encryption.py
Normal file
43
vigilar/storage/encryption.py
Normal 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
|
||||
@ -426,6 +426,65 @@ def get_wildlife_sightings(
|
||||
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 ---
|
||||
|
||||
def insert_training_image(
|
||||
@ -457,3 +516,203 @@ def get_training_images(
|
||||
.order_by(desc(pet_training_images.c.created_at))
|
||||
).fetchall()
|
||||
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()]
|
||||
|
||||
@ -165,10 +165,27 @@ wildlife_sightings = Table(
|
||||
Column("confidence", Float),
|
||||
Column("crop_path", String),
|
||||
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_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",
|
||||
metadata,
|
||||
@ -178,3 +195,69 @@ pet_training_images = Table(
|
||||
Column("source", String, 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),
|
||||
)
|
||||
|
||||
@ -30,6 +30,8 @@ def create_app(cfg: VigilarConfig | None = None) -> Flask:
|
||||
from vigilar.web.blueprints.recordings import recordings_bp
|
||||
from vigilar.web.blueprints.sensors import sensors_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(events_bp)
|
||||
@ -38,6 +40,8 @@ def create_app(cfg: VigilarConfig | None = None) -> Flask:
|
||||
app.register_blueprint(recordings_bp)
|
||||
app.register_blueprint(sensors_bp)
|
||||
app.register_blueprint(system_bp)
|
||||
app.register_blueprint(visitors_bp)
|
||||
app.register_blueprint(wildlife_bp)
|
||||
|
||||
# Root route → dashboard
|
||||
@app.route("/")
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
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")
|
||||
|
||||
@ -71,6 +71,77 @@ def camera_hls_remote(camera_id: str, filename: str):
|
||||
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")
|
||||
def cameras_status_api():
|
||||
"""JSON API: all camera statuses."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -10,3 +10,12 @@ def kiosk_view():
|
||||
cfg = current_app.config.get("VIGILAR_CONFIG")
|
||||
cameras = cfg.cameras if cfg else []
|
||||
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)
|
||||
|
||||
@ -223,6 +223,107 @@ def delete_pet(pet_id: str):
|
||||
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"])
|
||||
def train_model():
|
||||
pets_cfg = current_app.config.get("PETS_CONFIG")
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import time as time_mod
|
||||
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")
|
||||
|
||||
@ -18,10 +18,46 @@ def recordings_list():
|
||||
@recordings_bp.route("/api/list")
|
||||
def recordings_api():
|
||||
"""JSON API: recording list."""
|
||||
camera_id = request.args.get("camera")
|
||||
limit = request.args.get("limit", 50, type=int)
|
||||
# TODO: query from DB
|
||||
return jsonify([])
|
||||
engine = current_app.config.get("DB_ENGINE")
|
||||
if engine is None:
|
||||
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")
|
||||
@ -63,12 +99,69 @@ def timeline_api():
|
||||
@recordings_bp.route("/<int:recording_id>/download")
|
||||
def recording_download(recording_id: int):
|
||||
"""Stream decrypted recording for download/playback."""
|
||||
# TODO: Phase 6 — decrypt and stream
|
||||
return "Recording not available", 503
|
||||
engine = current_app.config.get("DB_ENGINE")
|
||||
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"])
|
||||
def recording_delete(recording_id: int):
|
||||
"""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})
|
||||
|
||||
@ -4,6 +4,7 @@ import os
|
||||
|
||||
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_writer import (
|
||||
save_config,
|
||||
@ -58,7 +59,10 @@ def arm_system():
|
||||
data = request.get_json() or {}
|
||||
mode = data.get("mode", "ARMED_AWAY")
|
||||
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})
|
||||
|
||||
|
||||
@ -66,10 +70,30 @@ def arm_system():
|
||||
def disarm_system():
|
||||
data = request.get_json() or {}
|
||||
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"})
|
||||
|
||||
|
||||
@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 ---
|
||||
|
||||
@system_bp.route("/api/config")
|
||||
@ -82,6 +106,7 @@ def get_config_api():
|
||||
data.get("system", {}).pop("arm_pin_hash", None)
|
||||
data.get("alerts", {}).get("webhook", {}).pop("secret", None)
|
||||
data.get("storage", {}).pop("key_file", None)
|
||||
data.pop("security", None)
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
|
||||
129
vigilar/web/blueprints/visitors.py
Normal file
129
vigilar/web/blueprints/visitors.py
Normal 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})
|
||||
72
vigilar/web/blueprints/wildlife.py
Normal file
72
vigilar/web/blueprints/wildlife.py
Normal 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"})
|
||||
6
vigilar/web/static/js/hls.min.js
vendored
6
vigilar/web/static/js/hls.min.js
vendored
File diff suppressed because one or more lines are too long
465
vigilar/web/templates/kiosk/ambient.html
Normal file
465
vigilar/web/templates/kiosk/ambient.html
Normal 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>
|
||||
350
vigilar/web/templates/visitors/dashboard.html
Normal file
350
vigilar/web/templates/visitors/dashboard.html
Normal 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 %}
|
||||
211
vigilar/web/templates/visitors/profile.html
Normal file
211
vigilar/web/templates/visitors/profile.html
Normal 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 %}
|
||||
343
vigilar/web/templates/wildlife/journal.html
Normal file
343
vigilar/web/templates/wildlife/journal.html
Normal 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 %}
|
||||
Loading…
Reference in New Issue
Block a user