feat(Q4): kiosk ambient mode with camera rotation, alert takeover, dimming
Add GET /kiosk/ambient route and standalone fullscreen template with rotating camera snapshots (crossfade), top bar clock/date, pet status bottom bar, SSE-driven alert takeover with HLS playback, configurable screen dimming, and 6-hour auto-refresh. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d69bf6d6af
commit
b4dbb41624
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
|
||||
@ -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)
|
||||
|
||||
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>
|
||||
Loading…
Reference in New Issue
Block a user