diff --git a/tests/unit/test_profiles.py b/tests/unit/test_profiles.py new file mode 100644 index 0000000..1bf5362 --- /dev/null +++ b/tests/unit/test_profiles.py @@ -0,0 +1,77 @@ +"""Tests for smart alert profiles.""" + +from vigilar.alerts.profiles import match_profile, is_in_time_window, get_action_for_event +from vigilar.config import AlertProfileConfig, AlertProfileRule +from vigilar.constants import HouseholdState + + +def _make_profile(name, states, time_window="", rules=None): + return AlertProfileConfig( + name=name, + presence_states=states, + time_window=time_window, + rules=rules or [], + ) + + +class TestTimeWindow: + def test_no_window_always_matches(self): + assert is_in_time_window("", "14:30") is True + + def test_inside_window(self): + assert is_in_time_window("23:00-06:00", "02:30") is True + + def test_outside_window(self): + assert is_in_time_window("23:00-06:00", "14:30") is False + + def test_at_start(self): + assert is_in_time_window("23:00-06:00", "23:00") is True + + def test_daytime_window(self): + assert is_in_time_window("06:00-23:00", "14:30") is True + + +class TestProfileMatching: + def test_match_by_presence(self): + profiles = [ + _make_profile("Away", ["EMPTY"]), + _make_profile("Home", ["ADULTS_HOME", "ALL_HOME"]), + ] + result = match_profile(profiles, HouseholdState.EMPTY, "14:00") + assert result is not None + assert result.name == "Away" + + def test_no_match(self): + profiles = [_make_profile("Away", ["EMPTY"])] + result = match_profile(profiles, HouseholdState.ALL_HOME, "14:00") + assert result is None + + def test_time_window_narrows(self): + profiles = [ + _make_profile("Night", ["ALL_HOME"], "23:00-06:00"), + _make_profile("Day", ["ALL_HOME"], "06:00-23:00"), + ] + result = match_profile(profiles, HouseholdState.ALL_HOME, "02:00") + assert result is not None + assert result.name == "Night" + + +class TestActionForEvent: + def test_person_gets_push(self): + rules = [AlertProfileRule(detection_type="person", action="push_and_record", recipients="all")] + profile = _make_profile("Away", ["EMPTY"], rules=rules) + action, recipients = get_action_for_event(profile, "person", "front_door") + assert action == "push_and_record" + assert recipients == "all" + + def test_motion_gets_record_only(self): + rules = [AlertProfileRule(detection_type="motion", action="record_only", recipients="none")] + profile = _make_profile("Home", ["ALL_HOME"], rules=rules) + action, recipients = get_action_for_event(profile, "motion", "front_door") + assert action == "record_only" + + def test_no_matching_rule_defaults_quiet(self): + profile = _make_profile("Home", ["ALL_HOME"], rules=[]) + action, recipients = get_action_for_event(profile, "person", "front_door") + assert action == "quiet_log" + assert recipients == "none" diff --git a/tests/unit/test_timeline.py b/tests/unit/test_timeline.py new file mode 100644 index 0000000..5fbb1df --- /dev/null +++ b/tests/unit/test_timeline.py @@ -0,0 +1,39 @@ +"""Tests for recording timeline.""" + +import time + +from vigilar.storage.queries import get_timeline_data, insert_recording + + +class TestTimelineQuery: + def test_returns_recordings_for_day(self, test_db): + now = int(time.time()) + insert_recording(test_db, camera_id="cam1", started_at=now, ended_at=now+60, + file_path="/tmp/a.mp4", trigger="MOTION", detection_type="person") + insert_recording(test_db, camera_id="cam1", started_at=now+100, ended_at=now+130, + file_path="/tmp/b.mp4", trigger="MOTION", detection_type="motion") + + results = get_timeline_data(test_db, "cam1", now - 3600, now + 3600) + assert len(results) == 2 + assert results[0]["detection_type"] == "person" + assert results[1]["detection_type"] == "motion" + + def test_filters_by_camera(self, test_db): + now = int(time.time()) + insert_recording(test_db, camera_id="cam1", started_at=now, ended_at=now+60, + file_path="/tmp/a.mp4", trigger="MOTION") + insert_recording(test_db, camera_id="cam2", started_at=now, ended_at=now+60, + file_path="/tmp/b.mp4", trigger="MOTION") + + results = get_timeline_data(test_db, "cam1", now - 3600, now + 3600) + assert len(results) == 1 + + def test_filters_by_time_range(self, test_db): + now = int(time.time()) + insert_recording(test_db, camera_id="cam1", started_at=now - 7200, ended_at=now - 7100, + file_path="/tmp/old.mp4", trigger="MOTION") + insert_recording(test_db, camera_id="cam1", started_at=now, ended_at=now+60, + file_path="/tmp/new.mp4", trigger="MOTION") + + results = get_timeline_data(test_db, "cam1", now - 3600, now + 3600) + assert len(results) == 1 diff --git a/vigilar/alerts/profiles.py b/vigilar/alerts/profiles.py new file mode 100644 index 0000000..e1e1460 --- /dev/null +++ b/vigilar/alerts/profiles.py @@ -0,0 +1,56 @@ +"""Smart alert profile matching engine.""" + +import logging +from datetime import datetime + +from vigilar.config import AlertProfileConfig, AlertProfileRule +from vigilar.constants import HouseholdState + +log = logging.getLogger(__name__) + + +def is_in_time_window(window: str, current_time: str) -> bool: + if not window: + return True + + parts = window.split("-") + if len(parts) != 2: + return True + + start, end = parts[0].strip(), parts[1].strip() + t = current_time + + if start <= end: + return start <= t < end + else: + return t >= start or t < end + + +def match_profile( + profiles: list[AlertProfileConfig], + household_state: HouseholdState, + current_time: str, +) -> AlertProfileConfig | None: + for profile in profiles: + if not profile.enabled: + continue + if household_state.value not in profile.presence_states: + continue + if not is_in_time_window(profile.time_window, current_time): + continue + return profile + return None + + +def get_action_for_event( + profile: AlertProfileConfig, + detection_type: str, + camera_id: str, +) -> tuple[str, str]: + for rule in profile.rules: + if rule.detection_type != detection_type: + continue + if rule.camera_location not in ("any", camera_id): + continue + return rule.action, rule.recipients + return "quiet_log", "none" diff --git a/vigilar/config.py b/vigilar/config.py index a846fff..75652b9 100644 --- a/vigilar/config.py +++ b/vigilar/config.py @@ -144,6 +144,9 @@ class AlertsConfig(BaseModel): web_push: WebPushConfig = Field(default_factory=WebPushConfig) email: EmailAlertConfig = Field(default_factory=EmailAlertConfig) webhook: WebhookAlertConfig = Field(default_factory=WebhookAlertConfig) + sleep_start: str = "23:00" + sleep_end: str = "06:00" + profiles: list["AlertProfileConfig"] = Field(default_factory=list) # --- Remote Access Config --- @@ -208,6 +211,22 @@ class PresenceConfig(BaseModel): }) +# --- Alert Profile Config --- + +class AlertProfileRule(BaseModel): + detection_type: str # person, unknown_vehicle, known_vehicle, motion + camera_location: str = "any" # any | exterior | common_area | specific camera id + action: str = "record_only" # push_and_record, push_adults, record_only, quiet_log + recipients: str = "all" # all, adults, none + +class AlertProfileConfig(BaseModel): + name: str + enabled: bool = True + presence_states: list[str] = Field(default_factory=list) + time_window: str = "" # "" = all day, "23:00-06:00" = sleep hours + rules: list[AlertProfileRule] = Field(default_factory=list) + + # --- Health Config --- class HealthConfig(BaseModel): @@ -289,8 +308,9 @@ class VigilarConfig(BaseModel): return self -# Resolve forward reference: CameraConfig.zones references CameraZone defined later +# Resolve forward references CameraConfig.model_rebuild() +AlertsConfig.model_rebuild() def load_config(path: str | Path | None = None) -> VigilarConfig: diff --git a/vigilar/storage/queries.py b/vigilar/storage/queries.py index 3288dda..1f87050 100644 --- a/vigilar/storage/queries.py +++ b/vigilar/storage/queries.py @@ -100,6 +100,31 @@ def get_recordings( return [dict(r) for r in conn.execute(query).mappings().all()] +def get_timeline_data( + engine: Engine, + camera_id: str, + day_start_ts: int, + day_end_ts: int, +) -> list[dict[str, Any]]: + query = ( + select( + recordings.c.id, + recordings.c.started_at, + recordings.c.ended_at, + recordings.c.detection_type, + recordings.c.starred, + ) + .where( + recordings.c.camera_id == camera_id, + recordings.c.started_at >= day_start_ts, + recordings.c.started_at < day_end_ts, + ) + .order_by(recordings.c.started_at.asc()) + ) + with engine.connect() as conn: + return [dict(r) for r in conn.execute(query).mappings().all()] + + def delete_recording(engine: Engine, recording_id: int) -> bool: with engine.begin() as conn: result = conn.execute( diff --git a/vigilar/web/blueprints/recordings.py b/vigilar/web/blueprints/recordings.py index b40037d..c93be35 100644 --- a/vigilar/web/blueprints/recordings.py +++ b/vigilar/web/blueprints/recordings.py @@ -1,6 +1,9 @@ """Recordings blueprint — browse, download, delete.""" -from flask import Blueprint, jsonify, render_template, request +import time as time_mod +from datetime import datetime, timedelta + +from flask import Blueprint, current_app, jsonify, render_template, request recordings_bp = Blueprint("recordings", __name__, url_prefix="/recordings") @@ -21,6 +24,42 @@ def recordings_api(): return jsonify([]) +@recordings_bp.route("/api/timeline") +def timeline_api(): + """JSON API: timeline data for a camera on a given day.""" + camera_id = request.args.get("camera") + date_str = request.args.get("date") # YYYY-MM-DD + + if not camera_id: + return jsonify({"error": "camera required"}), 400 + + if date_str: + day = datetime.strptime(date_str, "%Y-%m-%d") + else: + day = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + day_start = int(day.timestamp()) + day_end = int((day + timedelta(days=1)).timestamp()) + + engine = current_app.config.get("DB_ENGINE") + if engine is None: + return jsonify([]) + + from vigilar.storage.queries import get_timeline_data + data = get_timeline_data(engine, camera_id, day_start, day_end) + + return jsonify([ + { + "id": r["id"], + "start": r["started_at"], + "end": r["ended_at"], + "type": r.get("detection_type", "motion"), + "starred": bool(r.get("starred", 0)), + } + for r in data + ]) + + @recordings_bp.route("//download") def recording_download(recording_id: int): """Stream decrypted recording for download/playback.""" diff --git a/vigilar/web/static/js/timeline.js b/vigilar/web/static/js/timeline.js new file mode 100644 index 0000000..36bd0c5 --- /dev/null +++ b/vigilar/web/static/js/timeline.js @@ -0,0 +1,95 @@ +/* Vigilar — Recording timeline renderer */ + +(function() { + 'use strict'; + + const COLORS = { + person: '#dc3545', + vehicle: '#0d6efd', + motion: '#6c757d', + default: '#6c757d', + }; + + class Timeline { + constructor(canvas, options = {}) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.segments = []; + this.cameraId = options.cameraId || ''; + this.date = options.date || new Date().toISOString().split('T')[0]; + this.onClick = options.onClick || null; + this.dayStartTs = 0; + this.dayEndTs = 0; + this._resize(); + this._bindEvents(); + } + + _resize() { + const rect = this.canvas.parentElement.getBoundingClientRect(); + this.canvas.width = rect.width; + this.canvas.height = this.canvas.dataset.height ? parseInt(this.canvas.dataset.height) : 48; + } + + _bindEvents() { + this.canvas.addEventListener('click', (e) => this._handleClick(e)); + window.addEventListener('resize', () => { this._resize(); this.render(); }); + } + + async load() { + const resp = await fetch(`/recordings/api/timeline?camera=${this.cameraId}&date=${this.date}`); + if (!resp.ok) return; + this.segments = await resp.json(); + + const d = new Date(this.date + 'T00:00:00'); + this.dayStartTs = d.getTime() / 1000; + this.dayEndTs = this.dayStartTs + 86400; + this.render(); + } + + render() { + const w = this.canvas.width; + const h = this.canvas.height; + const ctx = this.ctx; + const dur = this.dayEndTs - this.dayStartTs; + + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = '#161b22'; + ctx.fillRect(0, 0, w, h); + + for (const seg of this.segments) { + const x1 = ((seg.start - this.dayStartTs) / dur) * w; + const x2 = seg.end ? ((seg.end - this.dayStartTs) / dur) * w : x1 + 2; + const segW = Math.max(x2 - x1, 2); + ctx.fillStyle = COLORS[seg.type] || COLORS.default; + ctx.fillRect(x1, 4, segW, h - 8); + } + + // Hour markers + ctx.fillStyle = '#30363d'; + ctx.font = '10px sans-serif'; + for (let hour = 0; hour <= 24; hour += 6) { + const x = (hour / 24) * w; + ctx.fillRect(x, 0, 1, h); + if (hour < 24) { + ctx.fillStyle = '#8b949e'; + ctx.fillText(`${hour}:00`, x + 3, h - 2); + ctx.fillStyle = '#30363d'; + } + } + } + + _handleClick(e) { + const rect = this.canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const pct = x / this.canvas.width; + const ts = this.dayStartTs + pct * 86400; + + const clicked = this.segments.find(s => ts >= s.start && ts <= (s.end || s.start + 10)); + if (clicked && this.onClick) { + this.onClick(clicked); + } + } + } + + window.VigilarTimeline = Timeline; +})(); diff --git a/vigilar/web/templates/recordings.html b/vigilar/web/templates/recordings.html index 6036439..eedf3f0 100644 --- a/vigilar/web/templates/recordings.html +++ b/vigilar/web/templates/recordings.html @@ -4,11 +4,52 @@ {% block content %}
Recordings
- +
+ + +
+ + + + +
+
+ +
+
+
+ Select a camera to view the recording timeline +
+
+
+ + +
+
+ Recording + +
+
+ +
+
+ + +
+   Person +   Vehicle +   Motion +
+ +
@@ -47,4 +88,71 @@
+ + + {% endblock %}