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:
parent
8314a61815
commit
11d776faa6
77
tests/unit/test_profiles.py
Normal file
77
tests/unit/test_profiles.py
Normal 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"
|
||||
39
tests/unit/test_timeline.py
Normal file
39
tests/unit/test_timeline.py
Normal 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
|
||||
56
vigilar/alerts/profiles.py
Normal file
56
vigilar/alerts/profiles.py
Normal 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"
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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."""
|
||||
|
||||
95
vigilar/web/static/js/timeline.js
Normal file
95
vigilar/web/static/js/timeline.js
Normal 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;
|
||||
})();
|
||||
@ -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;">
|
||||
<option value="">All Cameras</option>
|
||||
</select>
|
||||
<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"> </span> Person</span>
|
||||
<span><span class="badge" style="background:#0d6efd"> </span> Vehicle</span>
|
||||
<span><span class="badge" style="background:#6c757d"> </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 %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user