Add smart alert profiles and recording timeline (Tasks 4-5)

Task 4 — Alert Profiles: presence-aware + time-of-day alert routing.
Profiles match household state (EMPTY/KIDS_HOME/ADULTS_HOME/ALL_HOME)
and time windows (sleep hours). Per-detection-type rules control
push/record/quiet behavior with role-based recipients (all vs adults).

Task 5 — Recording Timeline: canvas-based 24h timeline per camera
with color-coded segments (person=red, vehicle=blue, motion=gray).
Click-to-play, date picker, detection type filters, hour markers.
Timeline API endpoint returns segments for a camera+date.

All 5 daily-use feature tasks complete. 140 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee 2026-04-03 00:09:08 -04:00
parent 8314a61815
commit 11d776faa6
8 changed files with 464 additions and 5 deletions

View File

@ -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"

View File

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

View File

@ -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"

View File

@ -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:

View File

@ -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(

View File

@ -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("/<int:recording_id>/download")
def recording_download(recording_id: int):
"""Stream decrypted recording for download/playback."""

View File

@ -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;
})();

View File

@ -4,11 +4,52 @@
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0"><i class="bi bi-camera-video me-2"></i>Recordings</h5>
<select class="form-select form-select-sm" id="filter-camera" style="width: auto;">
<div class="d-flex gap-2 align-items-center">
<input type="date" class="form-control form-control-sm bg-dark text-light border-secondary"
id="timeline-date" style="width: auto;">
<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 class="btn-group btn-group-sm" id="filter-type">
<button class="btn btn-outline-light active" data-type="all">All</button>
<button class="btn btn-outline-danger" data-type="person">Person</button>
<button class="btn btn-outline-primary" data-type="vehicle">Vehicle</button>
<button class="btn btn-outline-secondary" data-type="motion">Motion</button>
</div>
</div>
</div>
<!-- Timeline canvases (one per camera) -->
<div id="timeline-container">
<div class="card bg-dark border-secondary mb-3" id="timeline-placeholder">
<div class="card-body text-center text-muted py-3">
Select a camera to view the recording timeline
</div>
</div>
</div>
<!-- Video player area -->
<div class="card bg-dark border-secondary mb-3 d-none" id="player-card">
<div class="card-header border-secondary d-flex justify-content-between align-items-center">
<span id="player-title">Recording</span>
<button class="btn btn-sm btn-outline-light" id="player-close">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="card-body p-0">
<video id="player-video" class="w-100" controls></video>
</div>
</div>
<!-- Legend -->
<div class="d-flex gap-3 mb-3 small text-muted">
<span><span class="badge" style="background:#dc3545">&nbsp;</span> Person</span>
<span><span class="badge" style="background:#0d6efd">&nbsp;</span> Vehicle</span>
<span><span class="badge" style="background:#6c757d">&nbsp;</span> Motion</span>
</div>
<!-- Fallback table for recordings list -->
<div class="card bg-dark border-secondary">
<div class="card-body p-0">
<div class="table-responsive">
@ -47,4 +88,71 @@
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/timeline.js') }}"></script>
<script>
(function() {
'use strict';
const dateInput = document.getElementById('timeline-date');
const cameraSelect = document.getElementById('filter-camera');
const container = document.getElementById('timeline-container');
const placeholder = document.getElementById('timeline-placeholder');
const playerCard = document.getElementById('player-card');
const playerVideo = document.getElementById('player-video');
const playerTitle = document.getElementById('player-title');
const playerClose = document.getElementById('player-close');
// Default to today
dateInput.value = new Date().toISOString().split('T')[0];
playerClose.addEventListener('click', () => {
playerCard.classList.add('d-none');
playerVideo.pause();
playerVideo.src = '';
});
function handleSegmentClick(seg) {
playerCard.classList.remove('d-none');
playerTitle.textContent = `Recording #${seg.id} (${seg.type || 'motion'})`;
playerVideo.src = `/recordings/${seg.id}/download`;
playerVideo.play().catch(() => {});
}
function loadTimeline() {
const cameraId = cameraSelect.value;
const date = dateInput.value;
if (!cameraId) {
placeholder.classList.remove('d-none');
// Remove any existing timeline canvases
container.querySelectorAll('.timeline-row').forEach(el => el.remove());
return;
}
placeholder.classList.add('d-none');
container.querySelectorAll('.timeline-row').forEach(el => el.remove());
const row = document.createElement('div');
row.className = 'card bg-dark border-secondary mb-2 timeline-row';
row.innerHTML = `
<div class="card-header border-secondary py-1 small">${cameraId}</div>
<div class="card-body p-2">
<canvas data-height="48" style="width:100%;cursor:pointer"></canvas>
</div>`;
container.appendChild(row);
const canvas = row.querySelector('canvas');
const tl = new VigilarTimeline(canvas, {
cameraId: cameraId,
date: date,
onClick: handleSegmentClick,
});
tl.load();
}
cameraSelect.addEventListener('change', loadTimeline);
dateInput.addEventListener('change', loadTimeline);
})();
</script>
{% endblock %}