diff --git a/cameras/app.js b/cameras/app.js new file mode 100644 index 0000000..baef0c1 --- /dev/null +++ b/cameras/app.js @@ -0,0 +1,402 @@ +'use strict'; + +// ── Config (persisted in localStorage) ────────────────────────────────────── + +const STORAGE_KEY = 'cam_dashboard_config'; + +const DEFAULT_CONFIG = { + host: 'localhost:1984', + cameras: [], +}; + +function loadConfig() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? Object.assign({}, DEFAULT_CONFIG, JSON.parse(raw)) : Object.assign({}, DEFAULT_CONFIG); + } catch { + return Object.assign({}, DEFAULT_CONFIG); + } +} + +function saveConfig(cfg) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); +} + +let config = loadConfig(); + +// ── Active WebRTC connections { [cameraIndex]: RTCPeerConnection } ────────── + +const connections = {}; + +// ── WebRTC via go2rtc ──────────────────────────────────────────────────────── +// +// go2rtc REST WebRTC API: +// POST http://{host}/api/webrtc?src={streamName} +// Body: SDP offer (plain text) +// Response: SDP answer (plain text) +// +// go2rtc uses "ICE Complete" mode, so we must wait for all local ICE +// candidates to be gathered before POSTing the offer. + +async function waitForIceGathering(pc, timeoutMs = 3000) { + return new Promise((resolve) => { + if (pc.iceGatheringState === 'complete') { resolve(); return; } + const timer = setTimeout(resolve, timeoutMs); + function handler() { + if (pc.iceGatheringState === 'complete') { + clearTimeout(timer); + pc.removeEventListener('icegatheringstatechange', handler); + resolve(); + } + } + pc.addEventListener('icegatheringstatechange', handler); + }); +} + +async function connectCamera(index) { + const cam = config.cameras[index]; + if (!cam) return; + + // Clean up any prior connection for this slot + disconnectCamera(index, /* silent */ true); + + const tile = document.getElementById('tile-' + index); + if (!tile) return; + + setTileStatus(tile, 'connecting', 'Connecting\u2026'); + + const pc = new RTCPeerConnection({ + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], + }); + connections[index] = pc; + + // Display the stream once tracks arrive + pc.addEventListener('track', (e) => { + if (!e.streams[0]) return; + const video = tile.querySelector('video'); + const placeholder = tile.querySelector('.tile-placeholder'); + video.srcObject = e.streams[0]; + video.play().catch(() => {}); + if (placeholder) placeholder.style.display = 'none'; + setTileStatus(tile, 'live', '\u25cf LIVE'); + updateConnectBtn(tile, index, true); + updateGlobalStatus(); + }); + + // Handle unexpected disconnection + pc.addEventListener('iceconnectionstatechange', () => { + if (pc.iceConnectionState === 'failed' || pc.iceConnectionState === 'closed') { + onCameraLost(index, tile, 'Connection lost'); + } + }); + + // Receive-only transceivers (Wyze cams transmit video + audio) + pc.addTransceiver('video', { direction: 'recvonly' }); + pc.addTransceiver('audio', { direction: 'recvonly' }); + + try { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + await waitForIceGathering(pc); + + const protocol = location.protocol === 'https:' ? 'https:' : 'http:'; + const url = protocol + '//' + config.host + '/api/webrtc?src=' + encodeURIComponent(cam.stream); + + const resp = await fetch(url, { + method: 'POST', + body: pc.localDescription.sdp, + }); + + if (!resp.ok) { + throw new Error('go2rtc responded with HTTP ' + resp.status + '. Check that the stream name matches your go2rtc config.'); + } + + const answerSdp = await resp.text(); + await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp }); + + } catch (err) { + console.error('[Camera ' + index + '] connect error:', err); + pc.close(); + delete connections[index]; + onCameraLost(index, tile, err.message); + } +} + +function disconnectCamera(index, silent) { + const pc = connections[index]; + if (pc) { + pc.close(); + delete connections[index]; + } + if (!silent) { + const tile = document.getElementById('tile-' + index); + if (tile) { + const video = tile.querySelector('video'); + const placeholder = tile.querySelector('.tile-placeholder'); + if (video) video.srcObject = null; + if (placeholder) { + placeholder.style.display = 'flex'; + placeholder.querySelector('p').textContent = 'Not connected'; + } + setTileStatus(tile, 'idle', 'Disconnected'); + updateConnectBtn(tile, index, false); + } + updateGlobalStatus(); + } +} + +function onCameraLost(index, tile, message) { + delete connections[index]; + const video = tile && tile.querySelector('video'); + const placeholder = tile && tile.querySelector('.tile-placeholder'); + if (video) video.srcObject = null; + if (placeholder) { + placeholder.style.display = 'flex'; + placeholder.querySelector('p').textContent = message || 'Connection lost'; + } + if (tile) { + setTileStatus(tile, 'error', '\u26a0 Error'); + updateConnectBtn(tile, index, false); + } + updateGlobalStatus(); +} + +// ── Status helpers ─────────────────────────────────────────────────────────── + +function setTileStatus(tile, state, text) { + const el = tile.querySelector('.tile-status'); + if (!el) return; + el.className = 'tile-status ' + state; + el.textContent = text; +} + +function updateConnectBtn(tile, index, isConnected) { + const btn = tile.querySelector('[data-action="toggle"]'); + if (!btn) return; + btn.title = isConnected ? 'Disconnect' : 'Connect'; + btn.textContent = isConnected ? '\u23f9' : '\u25b6'; +} + +function updateGlobalStatus() { + const el = document.getElementById('globalStatus'); + const total = config.cameras.length; + const live = Object.keys(connections).length; + if (total === 0) { + el.className = 'connection-status off'; + el.textContent = '\u25cf No cameras'; + } else if (live === 0) { + el.className = 'connection-status off'; + el.textContent = '\u25cf Disconnected'; + } else if (live < total) { + el.className = 'connection-status partial'; + el.textContent = '\u25d1 ' + live + '/' + total + ' connected'; + } else { + el.className = 'connection-status live'; + el.textContent = '\u25cf ' + live + '/' + total + ' live'; + } +} + +// ── Grid rendering ─────────────────────────────────────────────────────────── + +let currentLayout = 1; + +function renderGrid() { + const grid = document.getElementById('cameraGrid'); + grid.innerHTML = ''; + grid.className = 'camera-grid layout-' + currentLayout; + + if (config.cameras.length === 0) { + const addTile = document.createElement('div'); + addTile.className = 'add-tile'; + addTile.innerHTML = '
\uD83D\uDCF7

Add a camera to get started

'; + addTile.addEventListener('click', openSettings); + grid.appendChild(addTile); + } else { + config.cameras.forEach((cam, i) => grid.appendChild(buildTile(cam, i))); + } + updateGlobalStatus(); +} + +function buildTile(cam, index) { + const tile = document.createElement('div'); + tile.className = 'camera-tile'; + tile.id = 'tile-' + index; + + tile.innerHTML = + '
' + + '' + escHtml(cam.name) + '' + + '
' + + 'Disconnected' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '
' + + '
\uD83D\uDCF7
' + + '

Not connected

' + + '
' + + '
'; + + tile.querySelector('[data-action="toggle"]').addEventListener('click', function () { + const idx = parseInt(this.dataset.index, 10); + if (connections[idx]) { + disconnectCamera(idx); + } else { + connectCamera(idx); + } + }); + + tile.querySelector('[data-action="mute"]').addEventListener('click', function () { + const video = tile.querySelector('video'); + video.muted = !video.muted; + this.textContent = video.muted ? '\uD83D\uDD07' : '\uD83D\uDD0A'; + this.title = video.muted ? 'Unmute' : 'Mute'; + }); + + tile.querySelector('[data-action="fullscreen"]').addEventListener('click', function () { + const wrap = tile.querySelector('.tile-video-wrap'); + if (wrap.requestFullscreen) wrap.requestFullscreen(); + else if (wrap.webkitRequestFullscreen) wrap.webkitRequestFullscreen(); + }); + + return tile; +} + +function escHtml(str) { + return String(str).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); +} + +// ── Settings modal ─────────────────────────────────────────────────────────── + +function openSettings() { + document.getElementById('go2rtcHost').value = config.host; + rebuildCameraRows(); + document.getElementById('settingsModal').classList.add('open'); +} + +function closeSettings() { + document.getElementById('settingsModal').classList.remove('open'); +} + +function rebuildCameraRows() { + const list = document.getElementById('cameraList'); + list.innerHTML = ''; + + // Column hints + const hints = document.createElement('div'); + hints.className = 'cam-row-hint'; + hints.innerHTML = 'Stream ID (from go2rtc.yaml)Display name'; + list.appendChild(hints); + + config.cameras.forEach(function (cam, i) { + list.appendChild(buildCameraRow(cam.stream, cam.name, i)); + }); +} + +function buildCameraRow(stream, name, index) { + const row = document.createElement('div'); + row.className = 'cam-row'; + row.dataset.index = index; + row.innerHTML = + '' + + '' + + ''; + + row.querySelector('[data-action="remove"]').addEventListener('click', function () { + row.remove(); + }); + + return row; +} + +function saveSettings() { + config.host = (document.getElementById('go2rtcHost').value.trim()) || 'localhost:1984'; + + const rows = document.querySelectorAll('#cameraList .cam-row'); + const cameras = []; + rows.forEach(function (row) { + const stream = row.querySelector('[data-field="stream"]').value.trim(); + const name = row.querySelector('[data-field="name"]').value.trim(); + if (stream || name) { + cameras.push({ stream: stream || name, name: name || stream }); + } + }); + config.cameras = cameras; + saveConfig(config); + closeSettings(); + + // Disconnect everything and re-render + Object.keys(connections).forEach(function (i) { disconnectCamera(parseInt(i, 10), true); }); + renderGrid(); +} + +// ── Setup guide modal ──────────────────────────────────────────────────────── + +function openSetup() { + document.getElementById('setupModal').classList.add('open'); +} + +function closeSetup() { + document.getElementById('setupModal').classList.remove('open'); +} + +// ── Init ───────────────────────────────────────────────────────────────────── + +document.addEventListener('DOMContentLoaded', function () { + + renderGrid(); + + // Show setup guide on first visit (no cameras configured) + if (config.cameras.length === 0) { + openSetup(); + } + + // Layout buttons + document.querySelectorAll('.layout-btn').forEach(function (btn) { + btn.addEventListener('click', function () { + document.querySelectorAll('.layout-btn').forEach(function (b) { b.classList.remove('active'); }); + btn.classList.add('active'); + currentLayout = parseInt(btn.dataset.layout, 10); + document.getElementById('cameraGrid').className = 'camera-grid layout-' + currentLayout; + }); + }); + + // Connect / disconnect all + document.getElementById('btnConnectAll').addEventListener('click', function () { + config.cameras.forEach(function (_, i) { connectCamera(i); }); + }); + + document.getElementById('btnDisconnectAll').addEventListener('click', function () { + config.cameras.forEach(function (_, i) { disconnectCamera(i); }); + }); + + // Settings modal + document.getElementById('btnSettings').addEventListener('click', openSettings); + document.getElementById('btnCloseSettings').addEventListener('click', closeSettings); + document.getElementById('btnAddCamera').addEventListener('click', function () { + document.getElementById('cameraList').appendChild(buildCameraRow('', '', Date.now())); + }); + document.getElementById('btnSaveSettings').addEventListener('click', saveSettings); + document.getElementById('settingsModal').addEventListener('click', function (e) { + if (e.target === e.currentTarget) closeSettings(); + }); + + // Setup modal + document.getElementById('btnSetup').addEventListener('click', openSetup); + document.getElementById('btnCloseSetup').addEventListener('click', closeSetup); + document.getElementById('setupModal').addEventListener('click', function (e) { + if (e.target === e.currentTarget) closeSetup(); + }); + + // Keyboard: Escape closes open modals + document.addEventListener('keydown', function (e) { + if (e.key !== 'Escape') return; + closeSettings(); + closeSetup(); + }); +}); diff --git a/cameras/index.html b/cameras/index.html new file mode 100644 index 0000000..fff2718 --- /dev/null +++ b/cameras/index.html @@ -0,0 +1,158 @@ + + + + + + Camera Dashboard + + + +
+
+

Camera Dashboard

+ ● No cameras +
+
+ + +
+
+ +
+
+ + + +
+
+ + +
+
+ +
+ + + + + + + + + + diff --git a/cameras/style.css b/cameras/style.css new file mode 100644 index 0000000..1476503 --- /dev/null +++ b/cameras/style.css @@ -0,0 +1,402 @@ +:root { + --bg: #0d0f14; + --surface: #161922; + --surface2: #1e2230; + --border: #272d3d; + --text: #dde3f0; + --text-muted: #7a8499; + --accent: #3b82f6; + --accent-h: #2563eb; + --danger: #ef4444; + --success: #22c55e; + --warning: #f59e0b; + --radius: 10px; +} + +*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } + +body { + background: var(--bg); + color: var(--text); + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + min-height: 100vh; + display: flex; + flex-direction: column; + font-size: 15px; +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px; + background: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 20; + gap: 12px; +} + +.header-left { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.header-left h1 { + font-size: 1.1rem; + font-weight: 600; + white-space: nowrap; +} + +.header-right { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.connection-status { + font-size: 0.78rem; + padding: 3px 10px; + border-radius: 20px; + background: var(--surface2); + white-space: nowrap; +} +.connection-status.live { color: var(--success); } +.connection-status.partial { color: var(--warning); } +.connection-status.off { color: var(--text-muted); } + +/* ── Controls bar ───────────────────────────────────────────────────────── */ + +.controls { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px; + background: var(--surface); + border-bottom: 1px solid var(--border); + gap: 12px; + flex-wrap: wrap; +} + +.layout-toggles { display: flex; gap: 6px; } + +.layout-btn { + padding: 5px 13px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + font-size: 0.82rem; + transition: all 0.15s; +} +.layout-btn:hover { border-color: var(--accent); color: var(--accent); } +.layout-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } + +.action-btns { display: flex; gap: 8px; } + +/* ── Buttons ────────────────────────────────────────────────────────────── */ + +.btn { + padding: 7px 15px; + border: none; + border-radius: 7px; + cursor: pointer; + font-size: 0.85rem; + font-weight: 500; + transition: background 0.15s, opacity 0.15s; + line-height: 1; +} +.btn-primary { background: var(--accent); color: #fff; } +.btn-primary:hover { background: var(--accent-h); } +.btn-secondary { background: var(--surface2); color: var(--text); border: 1px solid var(--border); } +.btn-secondary:hover { background: var(--border); } +.btn-icon { + width: 34px; height: 34px; padding: 0; + display: inline-flex; align-items: center; justify-content: center; + background: var(--surface2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 7px; + font-size: 0.95rem; + cursor: pointer; + transition: background 0.15s; +} +.btn-icon:hover { background: var(--border); } + +/* ── Camera Grid ────────────────────────────────────────────────────────── */ + +.camera-grid { + flex: 1; + display: grid; + gap: 12px; + padding: 16px 20px; + align-content: start; +} +.camera-grid.layout-1 { grid-template-columns: 1fr; } +.camera-grid.layout-2 { grid-template-columns: repeat(2, 1fr); } +.camera-grid.layout-3 { grid-template-columns: repeat(2, 1fr); } +.camera-grid.layout-3 .camera-tile:first-child { grid-column: 1 / -1; } + +/* ── Camera Tile ────────────────────────────────────────────────────────── */ + +.camera-tile { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.tile-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 9px 12px; + background: var(--surface2); + border-bottom: 1px solid var(--border); + gap: 8px; +} + +.tile-name { font-size: 0.85rem; font-weight: 500; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.tile-controls { display: flex; gap: 5px; align-items: center; } +.tile-controls .btn-icon { width: 27px; height: 27px; font-size: 0.78rem; } + +.tile-status { + font-size: 0.72rem; + padding: 2px 8px; + border-radius: 10px; + background: var(--bg); + white-space: nowrap; +} +.tile-status.live { color: var(--success); } +.tile-status.connecting { color: var(--warning); } +.tile-status.error { color: var(--danger); } +.tile-status.idle { color: var(--text-muted); } + +/* Video area */ + +.tile-video-wrap { + position: relative; + background: #000; + aspect-ratio: 16 / 9; + flex: 1; +} + +.tile-video-wrap video { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} + +.tile-placeholder { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--text-muted); +} +.tile-placeholder .ph-icon { font-size: 2.4rem; line-height: 1; } +.tile-placeholder p { font-size: 0.8rem; } + +/* Empty / add-camera tile */ + +.add-tile { + background: var(--surface); + border: 2px dashed var(--border); + border-radius: var(--radius); + aspect-ratio: 16 / 9; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + cursor: pointer; + color: var(--text-muted); + transition: border-color 0.15s, color 0.15s; +} +.add-tile:hover { border-color: var(--accent); color: var(--accent); } +.add-tile .ph-icon { font-size: 2rem; } +.add-tile p { font-size: 0.85rem; } + +/* ── Modal ──────────────────────────────────────────────────────────────── */ + +.modal-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.72); + align-items: center; + justify-content: center; + z-index: 100; + padding: 16px; +} +.modal-overlay.open { display: flex; } + +.modal { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + width: 100%; + max-width: 460px; + max-height: 90vh; + overflow-y: auto; + display: flex; + flex-direction: column; +} +.modal-wide { max-width: 700px; } + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 22px 14px; + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + background: var(--surface); + z-index: 1; +} +.modal-header h2 { font-size: 1.05rem; font-weight: 600; } + +.modal-body { padding: 20px 22px; flex: 1; } +.modal-footer { padding: 14px 22px; border-top: 1px solid var(--border); } + +/* ── Forms ──────────────────────────────────────────────────────────────── */ + +.form-group { margin-bottom: 20px; } +.form-group label { + display: block; + font-size: 0.82rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 7px; +} +.form-group input { + width: 100%; + padding: 9px 11px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 7px; + color: var(--text); + font-size: 0.875rem; + outline: none; + transition: border-color 0.15s; +} +.form-group input:focus { border-color: var(--accent); } +.hint { font-size: 0.76rem; color: var(--text-muted); margin-top: 5px; line-height: 1.5; } + +/* Camera rows in settings */ + +.cam-row { + display: flex; + gap: 7px; + margin-bottom: 8px; + align-items: center; +} +.cam-row input { flex: 1; } +.cam-row .cam-label { flex: 1.4; } +.cam-row-hint { display: flex; gap: 7px; margin-bottom: 12px; } +.cam-row-hint span { flex: 1; font-size: 0.72rem; color: var(--text-muted); padding-left: 2px; } +.cam-row-hint span:last-of-type { flex: 1.4; } + +/* ── Setup Guide ────────────────────────────────────────────────────────── */ + +.setup-guide h3 { + font-size: 0.95rem; + font-weight: 700; + margin: 22px 0 9px; + color: var(--accent); +} +.setup-guide h3:first-of-type { margin-top: 0; } +.setup-guide h4 { font-size: 0.875rem; font-weight: 600; margin: 16px 0 7px; } +.setup-guide p { font-size: 0.855rem; color: var(--text-muted); line-height: 1.65; margin-bottom: 9px; } +.setup-guide ul, .setup-guide ol { + padding-left: 20px; + font-size: 0.855rem; + color: var(--text-muted); + line-height: 1.8; + margin-bottom: 10px; +} +.setup-guide li { margin-bottom: 3px; } +.setup-guide a { color: var(--accent); } +.setup-guide a:hover { text-decoration: underline; } + +.setup-guide pre { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px 16px; + font-size: 0.8rem; + overflow-x: auto; + margin-bottom: 12px; + line-height: 1.6; + color: #a8c8f8; +} +.setup-guide code { + background: var(--bg); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.8rem; + color: #a8c8f8; +} + +/* Callout box */ +.callout { + border-left: 3px solid var(--accent); + background: rgba(59,130,246,0.08); + border-radius: 0 8px 8px 0; + padding: 12px 16px; + font-size: 0.855rem; + line-height: 1.6; + margin-bottom: 18px; + color: var(--text); +} +.callout a { color: var(--accent); } + +/* Comparison table */ +.compare-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; + margin-top: 8px; +} +.compare-table th, .compare-table td { + padding: 8px 12px; + border: 1px solid var(--border); + text-align: center; +} +.compare-table th { + background: var(--surface2); + font-weight: 600; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.compare-table td:first-child { text-align: left; color: var(--text-muted); } +.compare-table tr:nth-child(even) td { background: rgba(255,255,255,0.02); } + +/* ── Responsive ─────────────────────────────────────────────────────────── */ + +@media (max-width: 600px) { + .camera-grid { padding: 10px 12px; gap: 8px; } + .camera-grid.layout-2 { grid-template-columns: 1fr; } + .camera-grid.layout-3 { grid-template-columns: 1fr; } + .camera-grid.layout-3 .camera-tile:first-child { grid-column: auto; } + .controls { padding: 8px 12px; } + .header { padding: 10px 14px; } + .modal-body { padding: 16px; } +} diff --git a/frigate/.gitignore b/frigate/.gitignore new file mode 100644 index 0000000..85709ae --- /dev/null +++ b/frigate/.gitignore @@ -0,0 +1,9 @@ +# Contains your RTSP password and other credentials — never commit this +.env + +# Runtime data +storage/ +deepstack/ +double-take/ +ntfy/ +scrypted/ diff --git a/frigate/config/config.yml b/frigate/config/config.yml new file mode 100644 index 0000000..b303e09 --- /dev/null +++ b/frigate/config/config.yml @@ -0,0 +1,281 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Frigate config.yml — 4-camera setup +# +# DO NOT edit camera IPs or credentials here. +# Run ./setup.sh — it writes a .env file and updates this config automatically. +# +# Things to tune after first run (in the Frigate web UI, then paste here): +# 1. Motion masks — draw over wind chimes, trees, shadows +# 2. Zones — define your driveway, porch, yard areas +# 3. Object thresholds — raise min_score if getting false positives +# +# Full reference docs: https://docs.frigate.video/configuration +# ───────────────────────────────────────────────────────────────────────────── + + +# ── MQTT (optional) ────────────────────────────────────────────────────────── +# Frigate publishes detection events to MQTT. Useful for Home Assistant. +# If you don't use MQTT/Home Assistant, set enabled: false. +mqtt: + enabled: true + host: mosquitto # Docker service name — matches docker-compose.yml + port: 1883 + # If you also use a separate MQTT broker (e.g. Home Assistant), change + # the host above to its IP and remove the mosquitto service from docker-compose. + + +# ── Detector ───────────────────────────────────────────────────────────────── +# CPU detection works on any machine. For 4 cameras at 5fps, a modern desktop +# handles it fine. A Coral USB TPU (~$60) makes it ~10x faster if you want it. +# +# If you buy a Google Coral USB (~$60): change type to "edgetpu" and +# uncomment the device line. Detection will be ~10x faster. +detectors: + cpu1: + type: cpu + num_threads: 4 # 4 threads for 4 cameras; raise to 6 if your CPU has spare cores + + # ── Coral USB TPU (uncomment to use) ── + # coral: + # type: edgetpu + # device: usb + + # ── Intel OpenVINO (for Intel CPUs / integrated GPUs) ── + # openvino: + # type: openvino + # device: AUTO + + +# ── go2rtc (built-in stream proxy) ─────────────────────────────────────────── +# Frigate uses go2rtc internally to pull and re-stream your cameras. +# Add one entry per camera. The key name must match the camera name below. +# +# Wyze RTSP URL formats (varies by firmware): +# rtsp://user:pass@192.168.1.x/live (most common) +# rtsp://user:pass@192.168.1.x/livestream/12 (some newer firmware) +# rtsp://user:pass@192.168.1.x/live (Wyze Cam v3 + RTSP firmware) +# +# You can use env vars from docker-compose.yml: +# rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@192.168.1.x/live +go2rtc: + streams: + + # Camera IPs and credentials come from .env — setup.sh fills these in. + front_door: + - "rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_CAM1_IP}{FRIGATE_RTSP_PATH}" + + backyard: + - "rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_CAM2_IP}{FRIGATE_RTSP_PATH}" + + front_yard: + - "rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_CAM3_IP}{FRIGATE_RTSP_PATH}" + + side_yard: + - "rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_CAM4_IP}{FRIGATE_RTSP_PATH}" + + +# ── Default settings (applied to every camera unless overridden) ───────────── +cameras_default: &camera_defaults + + ffmpeg: + # Retry connection if the camera drops off the network + retry_interval: 10 + + # Hardware acceleration for decoding — improves CPU usage significantly. + # Comment out if you have problems; plain CPU decode still works fine. + # Hardware acceleration — setup.sh sets this automatically based on your GPU. + # Default is CPU-only (works on everything). Change to a preset if you have a GPU: + # preset-intel-qsv-h264 (Intel integrated graphics) + # preset-nvidia-h264 (NVIDIA GPU) + # preset-rpi-64-h264 (Raspberry Pi 4/5) + hwaccel_args: [] + + detect: + enabled: true + fps: 5 # 5 fps is plenty for detection; saves CPU/disk vs. 10-30 fps + # ⚠️ Set this to match your camera's actual stream resolution. + # Wyze Cam v3 is 1920×1080; Wyze Cam v2 is 1920×1080 or 1280×720. + # Wrong values cause missed detections. Check in Frigate UI → camera → debug. + width: 1920 + height: 1080 + + objects: + # What Frigate will actively look for and alert on. + # "cat" and "dog" are separate labels — Frigate won't call a cat a raccoon. + track: + - person + - cat + - dog + - car + + filters: + # ── Min confidence to even consider it that object (0-1) ────────────── + # Raise these if you're getting too many false positives. + # Lower them if Frigate is missing real events. + person: + min_score: 0.60 # reject detections below 60% confidence + threshold: 0.70 # must average 70% across frames before alerting + cat: + min_score: 0.55 + threshold: 0.65 + dog: + min_score: 0.55 + threshold: 0.65 + car: + min_score: 0.60 + threshold: 0.75 + + motion: + # ── Threshold ───────────────────────────────────────────────────────────── + # How much a pixel must change to count as motion (0-255). + # Raise this if shadows or wind chimes keep triggering motion. + # Default is 25; try 35-50 if you have a windy/shaded area. + threshold: 35 + contour_area: 10 # minimum motion blob size — ignore tiny flickers + + # ── Motion masks ────────────────────────────────────────────────────────── + # Use these to completely ignore areas for MOTION detection. + # This is the best fix for wind chimes and shadows in a fixed location. + # + # HOW TO GET COORDINATES: + # 1. Open Frigate UI → your camera → Motion Masks + # 2. Draw a polygon around the wind chimes / shadow area + # 3. Copy the coordinates Frigate shows and paste them below + # + # Format: comma-separated x,y pairs going around the polygon + # Example mask covering top-right corner of a 1920×1080 frame: + # mask: + # - 1500,0,1920,0,1920,400,1500,400 + mask: [] # ← paste your mask coordinates here after drawing them in the UI + + snapshots: + enabled: true + bounding_box: true # draw the detection box on the saved image + retain: + default: 30 # keep snapshots for 30 days + + record: + enabled: true + retain: + days: 3 # always keep 3 days of continuous footage + mode: motion # "all" = record everything, "motion" = motion-triggered only + events: + retain: + default: 30 # keep event clips for 30 days + mode: active_objects # only keep clips where an object was actually tracked + + # ── Audio detection ─────────────────────────────────────────────────────── + # Frigate listens to the camera's microphone and alerts on specific sounds. + # The audio events flow through the notifier → ntfy just like motion events. + # + # Supported events (built-in Frigate audio model): + # bark — dog barking + # fire_alarm — smoke/CO detector going off + # glass_breaking — window or glass breaking + # motorcycle — motorcycle / loud engine + # screaming — human screaming + # smoke_detector_alarm — smoke/CO alarm (same model as fire_alarm, higher confidence) + # speech — human speech (use cautiously — chatty cameras) + # + # Note: Gunshot detection is not in Frigate's default model. If you need it, + # a custom audio model can be loaded, but it's an advanced configuration. + audio: + enabled: true + listen: + - bark + - fire_alarm + - glass_breaking + - screaming + - smoke_detector_alarm + # min_volume: 500 # ignore audio below this volume level (0-32767) + # raise if you get false positives from AC/wind noise + + # ── Notifications via ntfy (optional, free, self-hosted or cloud) ───────── + # Install the ntfy app on your phone, then uncomment and fill this in. + # No account needed — just pick a unique topic name. + # notifications: + # enabled: true + # email: "" + # (Frigate 0.14+ supports ntfy natively — see docs.frigate.video/notifications) + + +# ── Cameras ─────────────────────────────────────────────────────────────────── +# Camera names and IPs are set by setup.sh. +# Draw zones in the Frigate UI, then paste the coordinates into each camera below. +# HOW: Frigate UI → camera → Zones tab → draw polygon → copy coordinates +cameras: + + front_door: + <<: *camera_defaults + + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/front_door + roles: [detect, record] + + zones: + # Paste driveway/porch zone coordinates from Frigate UI here. + # driveway: + # coordinates: 0,1080,1920,1080,1920,400,0,400 + # objects: [person, car] + # porch: + # coordinates: 600,1080,1320,1080,1320,600,600,600 + # objects: [person] + + # motion: + # mask: + # - x1,y1,x2,y2,... ← draw in Frigate UI, paste coordinates here + + + backyard: + <<: *camera_defaults + + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/backyard + roles: [detect, record] + + zones: + # yard: + # coordinates: 0,1080,1920,1080,1920,0,0,0 + # objects: [person, cat, dog] + + # motion: + # mask: + # - x1,y1,x2,y2,... + + + front_yard: + <<: *camera_defaults + + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/front_yard + roles: [detect, record] + + zones: + # front_yard_zone: + # coordinates: 0,1080,1920,1080,1920,0,0,0 + # objects: [person, car] + + # motion: + # mask: + # - x1,y1,x2,y2,... + + + side_yard: + <<: *camera_defaults + + ffmpeg: + inputs: + - path: rtsp://127.0.0.1:8554/side_yard + roles: [detect, record] + + zones: + # side_zone: + # coordinates: 0,1080,1920,1080,1920,0,0,0 + # objects: [person] + + # motion: + # mask: + # - x1,y1,x2,y2,... diff --git a/frigate/digest/Dockerfile b/frigate/digest/Dockerfile new file mode 100644 index 0000000..ddc9554 --- /dev/null +++ b/frigate/digest/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN pip install --no-cache-dir requests Pillow + +COPY digest.py . + +CMD ["python", "-u", "digest.py"] diff --git a/frigate/digest/digest.py b/frigate/digest/digest.py new file mode 100644 index 0000000..8c8f181 --- /dev/null +++ b/frigate/digest/digest.py @@ -0,0 +1,318 @@ +""" +Daily digest service — sends a morning summary notification. + +Every day at DIGEST_HOUR (default 8am), fetches yesterday's events from +Frigate and sends a notification with: + + 1. Text summary: "14 people · 3 cars · 1 package" + 2. Twin composite image: every unique person position from the day cut out + at full opacity and pasted onto a single background frame — so it + literally looks like there are multiple clones of you in the yard. + Falls back to a thumbnail grid if there are only 1-2 detections. + +Settings are all environment variables — no editing needed. +""" + +import io +import os +import time +from datetime import datetime, timedelta + +import requests +from PIL import Image, ImageDraw, ImageOps + +# ── Settings ────────────────────────────────────────────────────────────────── + +FRIGATE_URL = os.getenv("FRIGATE_URL", "http://frigate:5000").rstrip("/") +NTFY_URL = os.getenv("NTFY_URL", "http://ntfy:80").rstrip("/") +NTFY_TOPIC = os.getenv("NTFY_TOPIC", "frigate-alerts") +DIGEST_HOUR = int(os.getenv("DIGEST_HOUR", "8")) # 8 = 8:00 AM local time +MAX_SNAPSHOTS = int(os.getenv("MAX_SNAPSHOTS", "20")) # cap for twin composite + +# TWIN_CAMERAS: comma-separated list of camera names to use for the twin +# composite. Only list static (non-pan) cameras here — if a PTZ/pan camera +# is included the background shifts between snapshots and the composite looks +# wrong. Leave empty to use all cameras. +# Example: TWIN_CAMERAS=front_door,backyard +_twin_cams_raw = os.getenv("TWIN_CAMERAS", "") +TWIN_CAMERAS = {c.strip() for c in _twin_cams_raw.split(",") if c.strip()} + +# ── Frigate API ─────────────────────────────────────────────────────────────── + +def get_events(after: float, before: float) -> list[dict]: + try: + resp = requests.get( + f"{FRIGATE_URL}/api/events", + params={"after": after, "before": before, "limit": 1000}, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + except Exception as exc: + print(f"[digest] Failed to fetch events: {exc}") + return [] + + +def fetch_snapshot(event_id: str) -> bytes | None: + """Full-frame snapshot, no bounding box drawn — clean for compositing.""" + try: + resp = requests.get( + f"{FRIGATE_URL}/api/events/{event_id}/snapshot.jpg", + params={"bbox": 0, "crop": 0}, + timeout=15, + ) + resp.raise_for_status() + return resp.content + except Exception: + return None + + +# ── Image generators ────────────────────────────────────────────────────────── + +def make_twin_composite(events: list[dict], snapshots: list[bytes]) -> bytes | None: + """ + Full-opacity 'twins' composite. + + Every unique person position is cropped out of its own snapshot and + pasted onto a shared background at 100% opacity. The result looks like + multiple clones of the same person standing in the yard at once. + + Algorithm: + 1. Use the first snapshot as the background frame. + 2. For each subsequent event, read the bounding box from the Frigate + event JSON (normalized [x_min, y_min, x_max, y_max] in 0-1 range). + 3. Skip positions that are too close to an already-placed person so + we don't stack duplicates on top of each other. + 4. Crop the person region (+ padding) from that event's snapshot. + 5. Paste it onto the base at full opacity. + """ + pairs = [(e, s) for e, s in zip(events, snapshots) if s is not None] + if not pairs: + return None + + # Background = first snapshot + try: + base = Image.open(io.BytesIO(pairs[0][1])).convert("RGB") + except Exception: + return None + + W, H = base.size + placed: list[tuple[int, int]] = [] # centers of already-pasted crops + + for event, snap_data in pairs[1:]: + box = event.get("box") or [] + if len(box) < 4: + continue # no bounding box — skip + + try: + snap = Image.open(io.BytesIO(snap_data)).convert("RGB") + snap = snap.resize((W, H), Image.LANCZOS) + + # Frigate stores box as [x_min, y_min, x_max, y_max] normalized + x1 = int(box[0] * W) + y1 = int(box[1] * H) + x2 = int(box[2] * W) + y2 = int(box[3] * H) + + # Guard against degenerate boxes + if x2 <= x1 or y2 <= y1: + continue + + cx, cy = (x1 + x2) // 2, (y1 + y2) // 2 + + # Skip if another person is already pasted very close to here + # (threshold: 15% of frame width / height) + if any( + abs(cx - px) < W * 0.15 and abs(cy - py) < H * 0.15 + for px, py in placed + ): + continue + + # Add 15% padding around the bounding box so we include feet/head + pw = int((x2 - x1) * 0.15) + ph = int((y2 - y1) * 0.15) + x1, y1 = max(0, x1 - pw), max(0, y1 - ph) + x2, y2 = min(W, x2 + pw), min(H, y2 + ph) + + person_crop = snap.crop((x1, y1, x2, y2)) + base.paste(person_crop, (x1, y1)) + placed.append((cx, cy)) + + except Exception as exc: + print(f"[digest] Composite paste error: {exc}") + + # Label + draw = ImageDraw.Draw(base) + label = f"All {len(placed) + 1} positions — yesterday" + draw.text((14, 14), label, fill=(0, 0, 0 )) # shadow + draw.text((12, 12), label, fill=(255, 255, 255)) + + out = io.BytesIO() + base.save(out, format="JPEG", quality=90) + return out.getvalue() + + +def make_snapshot_grid(snapshots: list[bytes]) -> bytes | None: + """Thumbnail grid — fallback when there aren't enough unique positions.""" + if not snapshots: + return None + + THUMB = (320, 180) + PAD = 4 + cols = min(4, len(snapshots)) + rows = (len(snapshots) + cols - 1) // cols + + grid = Image.new( + "RGB", + (PAD + cols * (THUMB[0] + PAD), PAD + rows * (THUMB[1] + PAD)), + (15, 15, 15), + ) + + for i, data in enumerate(snapshots): + try: + thumb = ImageOps.fit( + Image.open(io.BytesIO(data)).convert("RGB"), + THUMB, method=Image.LANCZOS, + ) + col, row = i % cols, i // cols + grid.paste(thumb, (PAD + col * (THUMB[0] + PAD), PAD + row * (THUMB[1] + PAD))) + except Exception: + continue + + out = io.BytesIO() + grid.save(out, format="JPEG", quality=85) + return out.getvalue() + + +# ── ntfy sender ─────────────────────────────────────────────────────────────── + +def send_digest(title: str, body: str, image: bytes | None) -> None: + headers = { + "Title": title, + "Priority": "low", + "Tags": "camera,sunrise", + "Click": f"{FRIGATE_URL}/events", + } + try: + if image: + resp = requests.put( + f"{NTFY_URL}/{NTFY_TOPIC}", + data=image, + headers={ + **headers, + "Filename": "daily-activity.jpg", + "Message": body, + }, + timeout=30, + ) + else: + resp = requests.post( + f"{NTFY_URL}/{NTFY_TOPIC}", + data=body.encode(), + headers=headers, + timeout=15, + ) + print(f"[digest] {'Sent' if resp.ok else f'ntfy {resp.status_code}'}: {title}") + except Exception as exc: + print(f"[digest] Send failed: {exc}") + + +# ── Main digest logic ───────────────────────────────────────────────────────── + +def run_digest() -> None: + now = datetime.now() + yesterday = now - timedelta(days=1) + + day_start = datetime(yesterday.year, yesterday.month, yesterday.day, 0, 0, 0) + day_end = datetime(yesterday.year, yesterday.month, yesterday.day, 23, 59, 59) + + print(f"[digest] Running for {yesterday.strftime('%Y-%m-%d')} ...") + events = get_events(day_start.timestamp(), day_end.timestamp()) + + date_str = yesterday.strftime("%a, %b %-d") + title = f"Yesterday's cameras — {date_str}" + + if not events: + send_digest(f"All quiet — {date_str}", "No motion events detected yesterday.", None) + return + + # ── Count by label ─────────────────────────────────────────────────────── + counts: dict[str, int] = {} + stranger_count = 0 + for ev in events: + label = ev.get("label", "unknown") + counts[label] = counts.get(label, 0) + 1 + if label == "person" and not ev.get("sub_label"): + stranger_count += 1 + + summary_parts = [] + for label, n in sorted(counts.items(), key=lambda x: -x[1]): + noun = label.replace("_", " ").title() + summary_parts.append(f"{n} {noun}{'s' if n > 1 else ''}") + + body_lines = [" · ".join(summary_parts)] + if stranger_count > 0: + body_lines.append( + f" {stranger_count} unrecognized person{'s' if stranger_count > 1 else ''}" + " — check Frigate for clips" + ) + + # ── Build twin composite ───────────────────────────────────────────────── + person_events = [ + e for e in events + if e.get("label") == "person" + # Exclude pan/PTZ cameras — background shifts when camera moves, which + # breaks the composite. Set TWIN_CAMERAS in .env to restrict to static + # cameras only (e.g. TWIN_CAMERAS=front_door,backyard). + and (not TWIN_CAMERAS or e.get("camera") in TWIN_CAMERAS) + ] + + # Sort by score desc so we get the sharpest snapshots first; then + # the position-deduplication logic keeps the most spread-out set. + person_events.sort(key=lambda e: e.get("top_score") or 0, reverse=True) + top_events = person_events[:MAX_SNAPSHOTS] + + print(f"[digest] {len(events)} total events, downloading {len(top_events)} person snapshots ...") + snapshots = [fetch_snapshot(e["id"]) for e in top_events] + valid = sum(1 for s in snapshots if s) + print(f"[digest] Got {valid} snapshots") + + # Need at least 2 snapshots (one background + one person to paste) + if valid >= 2: + image = make_twin_composite(top_events, snapshots) + print("[digest] Twin composite ready") + elif valid == 1: + image = make_snapshot_grid([s for s in snapshots if s]) + print("[digest] Single snapshot (no composite possible)") + else: + image = None + print("[digest] No snapshots available") + + send_digest(title, "\n".join(body_lines), image) + + +# ── Schedule loop ───────────────────────────────────────────────────────────── + +def main() -> None: + print(f"[digest] Starting — daily digest at {DIGEST_HOUR:02d}:00") + print(f"[digest] Frigate: {FRIGATE_URL}") + print(f"[digest] ntfy: {NTFY_URL}/{NTFY_TOPIC}") + + while True: + now = datetime.now() + next_run = now.replace(hour=DIGEST_HOUR, minute=0, second=0, microsecond=0) + if next_run <= now: + next_run += timedelta(days=1) + + wait = (next_run - now).total_seconds() + print(f"[digest] Next digest in {wait / 3600:.1f} h ({next_run.strftime('%Y-%m-%d %H:%M')})") + time.sleep(wait) + + try: + run_digest() + except Exception as exc: + print(f"[digest] Unhandled error: {exc}") + + +if __name__ == "__main__": + main() diff --git a/frigate/docker-compose.yml b/frigate/docker-compose.yml new file mode 100644 index 0000000..b1474d6 --- /dev/null +++ b/frigate/docker-compose.yml @@ -0,0 +1,232 @@ +version: "3.9" + +# ═══════════════════════════════════════════════════════════════════════════════ +# Frigate NVR — full feature stack +# +# DO NOT edit this file manually. +# Run ./setup.sh — it asks you questions and configures everything. +# +# Services: frigate · mosquitto · deepstack · double-take · ntfy · notifier · scrypted +# ═══════════════════════════════════════════════════════════════════════════════ + +services: + + # ── Frigate NVR ───────────────────────────────────────────────────────────── + frigate: + image: ghcr.io/blakeblackshear/frigate:stable + container_name: frigate + restart: unless-stopped + privileged: true + shm_size: "512mb" # 512mb for 4 cameras + depends_on: + - mosquitto + + devices: + # Uncomment after running setup.sh if you have an Intel GPU: + # - /dev/dri/renderD128:/dev/dri/renderD128 + # Uncomment for Google Coral USB: + # - /dev/bus/usb:/dev/bus/usb + + volumes: + - /etc/localtime:/etc/localtime:ro + - ./config:/config + - ${STORAGE_PATH:-./storage}:/media/frigate + - type: tmpfs + target: /tmp/cache + tmpfs: + size: 1000000000 + + ports: + - "5000:5000" + - "8554:8554" + - "8555:8555/tcp" + - "8555:8555/udp" + + environment: + # Set by setup.sh → .env + FRIGATE_RTSP_USER: "${RTSP_USER:-wyze}" + FRIGATE_RTSP_PASSWORD: "${RTSP_PASSWORD:-changeme}" + FRIGATE_RTSP_PATH: "${RTSP_PATH:-/live}" + FRIGATE_CAM1_IP: "${CAM1_IP:-YOUR_CAMERA_IP_1}" + FRIGATE_CAM2_IP: "${CAM2_IP:-YOUR_CAMERA_IP_2}" + FRIGATE_CAM3_IP: "${CAM3_IP:-}" + FRIGATE_CAM4_IP: "${CAM4_IP:-}" + + # ── Mosquitto MQTT broker ──────────────────────────────────────────────────── + mosquitto: + image: eclipse-mosquitto:2 + container_name: mosquitto + restart: unless-stopped + volumes: + - ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + + # ── DeepStack — face recognition AI ───────────────────────────────────────── + deepstack: + image: deepquestai/deepstack:latest + container_name: deepstack + restart: unless-stopped + volumes: + - ./deepstack:/datastore + environment: + VISION-FACE: "True" + + # ── Double-Take — face recognition orchestrator ─────────────────────────────── + # Train faces at http://localhost:3000 + double-take: + image: jakowenko/double-take:latest + container_name: double-take + restart: unless-stopped + depends_on: + - mosquitto + - deepstack + - frigate + volumes: + - ./double-take:/double-take/.storage + ports: + - "3000:3000" + + # ── ntfy — self-hosted push notifications ──────────────────────────────────── + ntfy: + image: binwiederhier/ntfy:latest + container_name: ntfy + restart: unless-stopped + command: serve + volumes: + - ./ntfy/cache:/var/cache/ntfy + ports: + - "8080:80" + environment: + NTFY_CACHE_FILE: /var/cache/ntfy/cache.db + NTFY_ATTACHMENT_CACHE_DIR: /var/cache/ntfy/attachments + NTFY_CACHE_DURATION: "168h" # 7 days of notification history + NTFY_ATTACHMENT_EXPIRY_DURATION: "72h" # keep digest images for 3 days + NTFY_BASE_URL: "${NTFY_BASE_URL:-http://localhost:8080}" + + # ── Notification bridge ────────────────────────────────────────────────────── + notifier: + build: ./notifier + container_name: frigate-notifier + restart: unless-stopped + depends_on: + - mosquitto + - ntfy + + environment: + MQTT_HOST: mosquitto + MQTT_PORT: "1883" + NTFY_URL: "http://ntfy:80" + NTFY_TOPIC: "${NTFY_TOPIC:-frigate-alerts}" + FRIGATE_URL: "${FRIGATE_URL:-http://frigate:5000}" + + ALERT_OBJECTS: "car,package" + MIN_SCORE: "0.75" + REQUIRE_ZONE: "true" + ALERT_COOLDOWN: "120" + + USE_FACE_RECOGNITION: "true" + MIN_FACE_SCORE: "0.80" + KNOWN_PEOPLE: "${KNOWN_PEOPLE:-}" + FAMILY_ARRIVAL_ALERTS: "true" + + # Suppress non-urgent alerts at night (strangers + alarms always fire) + QUIET_HOURS_START: "${QUIET_HOURS_START:-22}" # 10pm + QUIET_HOURS_END: "${QUIET_HOURS_END:-7}" # 7am + + # Spatial awareness — proximity thresholds (fraction of frame height) + # close > 55% = right at camera, mid = 25–55%, far < 25% = street/edge + PROXIMITY_CLOSE: "${PROXIMITY_CLOSE:-0.55}" + PROXIMITY_MID: "${PROXIMITY_MID:-0.25}" + + # ── Daily digest — morning summary + ghost composite image ─────────────────── + # Sends an 8am notification with yesterday's event counts and a ghost + # composite: all person detections from the day overlaid into one image. + # Edit DIGEST_HOUR in .env to change the time. + digest: + build: ./digest + container_name: frigate-digest + restart: unless-stopped + depends_on: + - frigate + - ntfy + environment: + FRIGATE_URL: "${FRIGATE_URL:-http://frigate:5000}" + NTFY_URL: "http://ntfy:80" + NTFY_TOPIC: "${NTFY_TOPIC:-frigate-alerts}" + DIGEST_HOUR: "${DIGEST_HOUR:-8}" + MAX_SNAPSHOTS: "20" + # Only use static cameras for the twin composite — exclude PTZ/pan cameras. + # Set to comma-separated camera names, e.g.: TWIN_CAMERAS=front_door,backyard + TWIN_CAMERAS: "${TWIN_CAMERAS:-}" + + # ── License plate recognition ───────────────────────────────────────────────── + # Reads plates from car events and sends an ntfy alert with the plate number. + # Free tier (2,500 reads/month): https://platerecognizer.com/sign-up/ + # Set PLATE_RECOGNIZER_TOKEN in .env — leave empty to disable LPR. + lpr: + build: ./lpr + container_name: frigate-lpr + restart: unless-stopped + depends_on: + - mosquitto + - ntfy + - frigate + volumes: + - lpr-data:/data # persists the plates.log between restarts + environment: + PLATE_RECOGNIZER_TOKEN: "${PLATE_RECOGNIZER_TOKEN:-}" + MQTT_HOST: mosquitto + MQTT_PORT: "1883" + NTFY_URL: "http://ntfy:80" + NTFY_TOPIC: "${NTFY_TOPIC:-frigate-alerts}" + FRIGATE_URL: "${FRIGATE_URL:-http://frigate:5000}" + LPR_OBJECTS: "car" + MIN_PLATE_CONFIDENCE: "0.80" + PLATE_COOLDOWN: "3600" # 1 hour before re-alerting same plate + LPR_REGION: "${LPR_REGION:-}" # optional region hint, e.g. us-ca, ca-on + + # ── Wildlife species identification ────────────────────────────────────────── + # Identifies the exact species of birds and animals detected by Frigate. + # Uses iNaturalist's free computer vision API (covers birds, mammals, + # reptiles, insects — anything with a photo). + # + # Setup: + # 1. Create a free account at https://www.inaturalist.org + # 2. Get your token at https://www.inaturalist.org/users/api_token + # 3. Add INAT_TOKEN=... to .env + # 4. Optional: INAT_LAT / INAT_LNG for your location (improves accuracy) + species: + build: ./species + container_name: frigate-species + restart: unless-stopped + depends_on: + - mosquitto + - ntfy + - frigate + volumes: + - species-data:/data # persists sightings.log between restarts + environment: + INAT_TOKEN: "${INAT_TOKEN:-}" + INAT_LAT: "${INAT_LAT:-}" + INAT_LNG: "${INAT_LNG:-}" + MQTT_HOST: mosquitto + MQTT_PORT: "1883" + NTFY_URL: "http://ntfy:80" + NTFY_TOPIC: "${NTFY_TOPIC:-frigate-alerts}" + FRIGATE_URL: "${FRIGATE_URL:-http://frigate:5000}" + SPECIES_OBJECTS: "bird,cat,dog,deer,bear,raccoon,fox,squirrel" + MIN_SPECIES_SCORE: "0.70" + SPECIES_COOLDOWN: "1800" # 30 min — won't re-alert same species + + # ── Scrypted — Apple HomeKit + Google Home ──────────────────────────────────── + # Setup at https://localhost:10443 → install Frigate NVR + HomeKit plugins + scrypted: + image: ghcr.io/koush/scrypted:latest + container_name: scrypted + restart: unless-stopped + network_mode: host + volumes: + - ./scrypted:/server/volume + +volumes: + lpr-data: + species-data: diff --git a/frigate/double-take/config.yml b/frigate/double-take/config.yml new file mode 100644 index 0000000..8346ce5 --- /dev/null +++ b/frigate/double-take/config.yml @@ -0,0 +1,51 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Double-Take configuration +# Reference: https://github.com/jakowenko/double-take +# +# Double-Take watches Frigate for person events, sends the snapshot to +# DeepStack for face recognition, and publishes the result to MQTT. +# +# After starting: open http://localhost:3000 to train faces. +# ───────────────────────────────────────────────────────────────────────────── + +mqtt: + host: mosquitto + port: 1883 + +frigate: + url: http://frigate:5000 + # Re-ID: wait this many seconds after a person event before finalizing. + # Gives Frigate time to capture a clear frontal face shot. + stop_on_match: true + +time: + timezone: America/Chicago # ⚠️ change to your timezone + # e.g. America/New_York, America/Los_Angeles + # Full list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + +detectors: + deepstack: + url: http://deepstack:5000 + timeout: 15 # seconds to wait for face recognition response + +detect: + match: + save: true # save matched face crops to disk for review + base64: false + confidence: 80 # minimum % to consider a positive match + # lower = more matches but more false positives + # raise to 85-90 if you're getting wrong IDs + unknown: + save: true # save unrecognized faces — useful for training + +# Camera names must exactly match Frigate's config/config.yml. +# setup.sh keeps these in sync automatically. +cameras: + front_door: + zones: [] + backyard: + zones: [] + front_yard: + zones: [] + side_yard: + zones: [] diff --git a/frigate/lpr/Dockerfile b/frigate/lpr/Dockerfile new file mode 100644 index 0000000..8839c4c --- /dev/null +++ b/frigate/lpr/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN pip install --no-cache-dir paho-mqtt requests + +COPY lpr.py . + +CMD ["python", "-u", "lpr.py"] diff --git a/frigate/lpr/lpr.py b/frigate/lpr/lpr.py new file mode 100644 index 0000000..58fb6ef --- /dev/null +++ b/frigate/lpr/lpr.py @@ -0,0 +1,255 @@ +""" +License plate recognition service. + +Listens to Frigate's MQTT events. When a car (or other vehicle) event +ends, it downloads the snapshot and sends it to the Plate Recognizer API. +If a plate is found, it fires an ntfy notification and appends to a log. + +Free tier: 2,500 reads/month — https://platerecognizer.com/sign-up/ +No API key: the service starts but skips all LPR calls (just logs a warning). + +Environment variables +───────────────────── +PLATE_RECOGNIZER_TOKEN (required) — API token from platerecognizer.com +MQTT_HOST MQTT broker host (default: mosquitto) +MQTT_PORT MQTT broker port (default: 1883) +NTFY_URL ntfy server URL (default: http://ntfy:80) +NTFY_TOPIC ntfy topic (default: frigate-alerts) +FRIGATE_URL Frigate URL for clips (default: http://frigate:5000) +LPR_OBJECTS Comma-sep labels to scan (default: car) +MIN_PLATE_CONFIDENCE Minimum score 0-1 (default: 0.80) +PLATE_COOLDOWN Seconds before re-alerting same plate (default: 3600) +LPR_REGION Plate region hint, e.g. us-ca, ca-on (default: empty) +LOG_FILE Path to append plate log (default: /data/plates.log) +""" + +import json +import logging +import os +import time +from datetime import datetime + +import paho.mqtt.client as mqtt +import requests + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [lpr] %(message)s", + datefmt="%H:%M:%S", +) +log = logging.getLogger("lpr") + +# ── Settings ────────────────────────────────────────────────────────────────── + +TOKEN = os.getenv("PLATE_RECOGNIZER_TOKEN", "") +MQTT_HOST = os.getenv("MQTT_HOST", "mosquitto") +MQTT_PORT = int(os.getenv("MQTT_PORT", "1883")) +NTFY_URL = os.getenv("NTFY_URL", "http://ntfy:80").rstrip("/") +NTFY_TOPIC = os.getenv("NTFY_TOPIC", "frigate-alerts") +FRIGATE_URL = os.getenv("FRIGATE_URL","http://frigate:5000").rstrip("/") + +LPR_OBJECTS = set(os.getenv("LPR_OBJECTS", "car").split(",")) +MIN_CONF = float(os.getenv("MIN_PLATE_CONFIDENCE", "0.80")) +COOLDOWN = int(os.getenv("PLATE_COOLDOWN", "3600")) # 1 hour default +LPR_REGION = os.getenv("LPR_REGION", "") # e.g. "us-ca" +LOG_FILE = os.getenv("LOG_FILE", "/data/plates.log") + +# In-memory cooldown: plate_text → last_alerted unix timestamp +_alerted: dict[str, float] = {} + +PLATE_RECOGNIZER_URL = "https://api.platerecognizer.com/v1/plate-reader/" + +# ── Plate Recognizer API ────────────────────────────────────────────────────── + +def read_plate(image_bytes: bytes) -> dict | None: + """ + Send snapshot to Plate Recognizer. + Returns the best result dict or None if nothing found / API unavailable. + """ + if not TOKEN: + log.warning("PLATE_RECOGNIZER_TOKEN not set — skipping LPR") + return None + + try: + payload: dict = {} + if LPR_REGION: + payload["regions"] = LPR_REGION + + resp = requests.post( + PLATE_RECOGNIZER_URL, + files={"upload": ("snap.jpg", image_bytes, "image/jpeg")}, + data=payload, + headers={"Authorization": f"Token {TOKEN}"}, + timeout=20, + ) + resp.raise_for_status() + data = resp.json() + except Exception as exc: + log.error("Plate Recognizer API error: %s", exc) + return None + + results = data.get("results", []) + if not results: + return None + + # Return the highest-confidence result + return max(results, key=lambda r: r.get("plate", {}).get("confidence", 0)) + + +# ── ntfy helper ─────────────────────────────────────────────────────────────── + +def send_alert(plate: str, camera: str, details: str, image: bytes) -> None: + """Fire an ntfy notification with the plate number and snapshot attached.""" + try: + resp = requests.put( + f"{NTFY_URL}/{NTFY_TOPIC}", + data=image, + headers={ + "Title": f"License plate — {camera}", + "Message": f"{plate} · {details}", + "Priority": "default", + "Tags": "car,blue_car", + "Filename": f"plate-{plate}.jpg", + "Click": f"{FRIGATE_URL}/events", + }, + timeout=20, + ) + log.info("ntfy %s: %s — %s", "OK" if resp.ok else resp.status_code, plate, details) + except Exception as exc: + log.error("ntfy send failed: %s", exc) + + +# ── Plate log ───────────────────────────────────────────────────────────────── + +def log_plate(plate: str, camera: str, details: str) -> None: + try: + os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) + with open(LOG_FILE, "a") as f: + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + f.write(f"{ts}\t{camera}\t{plate}\t{details}\n") + except Exception as exc: + log.error("Log write failed: %s", exc) + + +# ── Frigate snapshot ────────────────────────────────────────────────────────── + +def fetch_snapshot(event_id: str) -> bytes | None: + try: + resp = requests.get( + f"{FRIGATE_URL}/api/events/{event_id}/snapshot.jpg", + params={"bbox": 0, "crop": 0}, + timeout=15, + ) + resp.raise_for_status() + return resp.content + except Exception as exc: + log.warning("Snapshot fetch failed (%s): %s", event_id, exc) + return None + + +# ── Event processing ────────────────────────────────────────────────────────── + +def handle_event(payload: dict) -> None: + """Process one Frigate event payload.""" + ev_type = payload.get("type") + after = payload.get("after") or {} + label = after.get("label", "") + camera = after.get("camera", "unknown") + + # Only act on events that just ended (camera has final snapshot) + if ev_type != "end": + return + if label not in LPR_OBJECTS: + return + + event_id = after.get("id") or payload.get("before", {}).get("id", "") + if not event_id: + return + + log.info("Car event ended — %s/%s (id=%s), running LPR ...", camera, label, event_id) + + snapshot = fetch_snapshot(event_id) + if not snapshot: + return + + result = read_plate(snapshot) + if not result: + log.info("No plate found in %s/%s", camera, label) + return + + plate_info = result.get("plate", {}) + plate_text = plate_info.get("value", "").upper().replace(" ", "") + confidence = plate_info.get("confidence", 0) + + if confidence < MIN_CONF: + log.info("Plate %s confidence %.0f%% < %.0f%% threshold — skipped", + plate_text, confidence * 100, MIN_CONF * 100) + return + + # Build a readable details string + vehicle = result.get("vehicle", {}) + v_type = vehicle.get("type", "") + colors = vehicle.get("color", []) + color_name = colors[0]["value"].title() if colors else "" + region_code = (result.get("region") or {}).get("code", "") + + parts = [p for p in [color_name, v_type.title(), region_code] if p] + details = " · ".join(parts) if parts else "unknown vehicle" + + # Cooldown per plate + now = time.time() + last = _alerted.get(plate_text, 0) + if now - last < COOLDOWN: + remaining = int(COOLDOWN - (now - last)) // 60 + log.info("Plate %s in cooldown (%d min remaining)", plate_text, remaining) + return + + _alerted[plate_text] = now + log.info("PLATE: %s — %s at %s (%.0f%% confidence)", plate_text, details, camera, confidence * 100) + + log_plate(plate_text, camera, details) + send_alert(plate_text, camera, details, snapshot) + + +# ── MQTT client ─────────────────────────────────────────────────────────────── + +def on_connect(client, _userdata, _flags, rc): + if rc == 0: + log.info("Connected to MQTT broker %s:%s", MQTT_HOST, MQTT_PORT) + client.subscribe("frigate/events") + else: + log.error("MQTT connect failed: rc=%d", rc) + + +def on_message(_client, _userdata, msg): + try: + payload = json.loads(msg.payload) + handle_event(payload) + except Exception as exc: + log.error("Message handling error: %s", exc) + + +def main() -> None: + log.info("LPR service starting") + log.info("Objects: %s | Min confidence: %.0f%% | Cooldown: %ds", + LPR_OBJECTS, MIN_CONF * 100, COOLDOWN) + if not TOKEN: + log.warning("No PLATE_RECOGNIZER_TOKEN — set it in .env to enable LPR") + if LPR_REGION: + log.info("Region hint: %s", LPR_REGION) + + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) + client.on_connect = on_connect + client.on_message = on_message + + while True: + try: + client.connect(MQTT_HOST, MQTT_PORT, keepalive=60) + client.loop_forever() + except Exception as exc: + log.error("MQTT error: %s — retrying in 10s", exc) + time.sleep(10) + + +if __name__ == "__main__": + main() diff --git a/frigate/mosquitto/mosquitto.conf b/frigate/mosquitto/mosquitto.conf new file mode 100644 index 0000000..2893006 --- /dev/null +++ b/frigate/mosquitto/mosquitto.conf @@ -0,0 +1,6 @@ +# Mosquitto MQTT broker config — internal use only (not exposed outside Docker) +listener 1883 +allow_anonymous true +persistence false +log_type error +log_type warning diff --git a/frigate/notifier/Dockerfile b/frigate/notifier/Dockerfile new file mode 100644 index 0000000..1069905 --- /dev/null +++ b/frigate/notifier/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.12-slim +WORKDIR /app +RUN pip install --no-cache-dir paho-mqtt==1.6.1 requests==2.31.0 +COPY notifier.py . +CMD ["python", "-u", "notifier.py"] diff --git a/frigate/notifier/notifier.py b/frigate/notifier/notifier.py new file mode 100644 index 0000000..9d74275 --- /dev/null +++ b/frigate/notifier/notifier.py @@ -0,0 +1,462 @@ +""" +Frigate + Double-Take → ntfy push notification bridge. + +Event routing: + frigate/events → non-person objects (car, package, cat, etc.) + frigate/+/audio/+ → audio detection (glass breaking, alarms, screaming, etc.) + double-take/cameras/# → face-identified person events + - known family member → low-priority "arrived home" + - unrecognized face → urgent "Stranger" alert + +All settings are environment variables configured in docker-compose.yml. +""" + +import datetime +import json +import os +import time + +import paho.mqtt.client as mqtt +import requests + +# ── Settings ────────────────────────────────────────────────────────────────── + +MQTT_HOST = os.getenv("MQTT_HOST", "mosquitto") +MQTT_PORT = int(os.getenv("MQTT_PORT", "1883")) + +NTFY_URL = os.getenv("NTFY_URL", "https://ntfy.sh").rstrip("/") +NTFY_TOPIC = os.getenv("NTFY_TOPIC", "frigate-alerts") + +# External URL for links in notifications. After setting up Tailscale, change +# this to http://YOUR_TAILSCALE_IP:5000 so links open from anywhere on your phone. +FRIGATE_URL = os.getenv("FRIGATE_URL", "http://frigate:5000").rstrip("/") + +# Objects that trigger Frigate-level alerts. +# "person" is intentionally excluded here — persons go through Double-Take +# for face recognition. If USE_FACE_RECOGNITION=false, person alerts still fire. +ALERT_OBJECTS = set(os.getenv("ALERT_OBJECTS", "car,package").split(",")) +MIN_SCORE = float(os.getenv("MIN_SCORE", "0.75")) +REQUIRE_ZONE = os.getenv("REQUIRE_ZONE", "true").lower() == "true" +COOLDOWN = int(os.getenv("ALERT_COOLDOWN", "120")) + +# Face recognition settings +USE_FACE_RECOGNITION = os.getenv("USE_FACE_RECOGNITION", "true").lower() == "true" +MIN_FACE_SCORE = float(os.getenv("MIN_FACE_SCORE", "0.80")) + +# Names (lowercase) that are family — get a quiet "arrived home" instead of alert. +# Fill in after training Double-Take. Example: "david,sarah" +_known_str = os.getenv("KNOWN_PEOPLE", "") +KNOWN_PEOPLE = {n.strip().lower() for n in _known_str.split(",") if n.strip()} + +# Set to "false" to suppress family arrival notifications entirely. +FAMILY_ARRIVAL_ALERTS = os.getenv("FAMILY_ARRIVAL_ALERTS", "true").lower() == "true" + +# ── Quiet hours ─────────────────────────────────────────────────────────────── +# During quiet hours only urgent alerts fire (strangers + audio alarms). +# Car detections, packages, family arrivals, and barking are suppressed. +# Default: quiet from 10pm to 7am. Set to the same value to disable entirely. +QUIET_START = int(os.getenv("QUIET_HOURS_START", "22")) # 22 = 10pm +QUIET_END = int(os.getenv("QUIET_HOURS_END", "7")) # 7 = 7am + +# These event types always wake you regardless of quiet hours +_ALWAYS_ALERT = { + "stranger", "glass_breaking", "screaming", + "fire_alarm", "smoke_detector_alarm", +} + + +def _in_quiet_hours() -> bool: + h = datetime.datetime.now().hour + if QUIET_START == QUIET_END: + return False # same value = quiet hours disabled + if QUIET_START > QUIET_END: # wraps midnight (22:00 → 07:00) + return h >= QUIET_START or h < QUIET_END + return QUIET_START <= h < QUIET_END + + +# ── Spatial awareness — proximity from bounding box ─────────────────────────── +# Frigate gives us a bounding box [x_min, y_min, x_max, y_max] normalized 0-1. +# The taller the box is relative to the frame, the closer the object is. +# These thresholds are tunable per environment (open yard vs tight driveway). +# +# CLOSE > 55% of frame height → "at the door / right in front of camera" +# MID 25–55% → "in the yard / driveway" +# FAR < 25% → "at the edge of view / street" +# +PROXIMITY_CLOSE = float(os.getenv("PROXIMITY_CLOSE", "0.55")) +PROXIMITY_MID = float(os.getenv("PROXIMITY_MID", "0.25")) + + +def _proximity(box: list) -> str | None: + """ + Return 'close', 'mid', or 'far' based on bounding box height fraction. + Returns None if box data is missing or malformed. + """ + if not box or len(box) < 4: + return None + height_frac = box[3] - box[1] # y_max - y_min + if height_frac <= 0: + return None + if height_frac >= PROXIMITY_CLOSE: + return "close" + if height_frac >= PROXIMITY_MID: + return "mid" + return "far" + + +# Human-readable proximity phrases used in notification bodies +_PROXIMITY_PHRASE = { + "close": "right at the camera", + "mid": "mid-range", + "far": "far edge of view", +} + +# Proximity affects alert priority (close stranger → urgent → max) +_PROXIMITY_PRIORITY_BUMP = { + "close": 1, # bump priority up one level for close detections + "mid": 0, + "far": 0, +} + +_PRIORITY_LEVELS = ["min", "low", "default", "high", "urgent"] + + +def _bumped_priority(base: str, prox: str | None) -> str: + bump = _PROXIMITY_PRIORITY_BUMP.get(prox or "mid", 0) + idx = _PRIORITY_LEVELS.index(base) if base in _PRIORITY_LEVELS else 2 + return _PRIORITY_LEVELS[min(idx + bump, len(_PRIORITY_LEVELS) - 1)] + +# ── ntfy priority and tag mappings ──────────────────────────────────────────── + +PRIORITY = { + "stranger": "urgent", + "person": "high", + "package": "high", + "car": "default", + "family": "low", + "dog": "low", + "cat": "low", + "bird": "min", + # Audio events + "glass_breaking": "urgent", + "screaming": "urgent", + "fire_alarm": "urgent", + "smoke_detector_alarm": "urgent", + "bark": "default", + "speech": "low", + "motorcycle": "low", +} + +TAGS = { + "stranger": "warning,bust_in_silhouette", + "person": "bust_in_silhouette,rotating_light", + "package": "package", + "car": "car", + "family": "house", + "dog": "dog", + "cat": "cat", + "bird": "bird", + # Audio events + "glass_breaking": "rotating_light,glass", + "screaming": "rotating_light,sos", + "fire_alarm": "fire,rotating_light", + "smoke_detector_alarm": "fire,rotating_light", + "bark": "dog", + "speech": "speech_balloon", + "motorcycle": "oncoming_automobile", +} + +# Human-readable alert titles and body text for each audio event +_AUDIO_ALERTS: dict[str, tuple[str, str]] = { + "glass_breaking": ("Glass breaking — {camera}", "Sound of breaking glass detected"), + "screaming": ("Screaming detected — {camera}", "Human screaming detected — check camera"), + "fire_alarm": ("Fire alarm — {camera}", "Fire or smoke alarm sound detected"), + "smoke_detector_alarm": ("Smoke alarm — {camera}", "Smoke or CO detector going off"), + "bark": ("Dog barking — {camera}", "Barking detected"), + "speech": ("Speech — {camera}", "Human speech detected"), + "motorcycle": ("Loud engine — {camera}", "Motorcycle or loud engine detected"), +} + +# ── Cooldown tracking ───────────────────────────────────────────────────────── + +_last_alerted: dict[str, float] = {} + + +def _in_cooldown(key: str) -> bool: + return time.monotonic() - _last_alerted.get(key, 0) < COOLDOWN + + +def _mark_alerted(key: str) -> None: + _last_alerted[key] = time.monotonic() + + +# ── ntfy sender ─────────────────────────────────────────────────────────────── + +def _send( + title: str, + body: str, + kind: str, + camera: str, + event_id: str = "", + priority_override: str | None = None, +) -> None: + """POST a notification to ntfy.""" + headers: dict[str, str] = { + "Title": title, + "Priority": priority_override or PRIORITY.get(kind, "default"), + "Tags": TAGS.get(kind, "bell"), + "Click": f"{FRIGATE_URL}/events?camera={camera}", + } + # Attach camera snapshot. Works reliably when ntfy is self-hosted on the + # same Docker network as Frigate (ntfy fetches the image server-side). + if event_id: + headers["Attach"] = f"{FRIGATE_URL}/api/events/{event_id}/snapshot.jpg" + + try: + resp = requests.post( + f"{NTFY_URL}/{NTFY_TOPIC}", + data=body.encode("utf-8"), + headers=headers, + timeout=10, + ) + if resp.ok: + print(f"[notify] {title}") + else: + print(f"[notify] ntfy {resp.status_code}: {resp.text[:120]}") + except requests.RequestException as exc: + print(f"[notify] Failed to reach ntfy: {exc}") + + +# ── Frigate object event handler ────────────────────────────────────────────── + +def handle_frigate_event(payload: dict) -> None: + if payload.get("type") != "new": + return + + event = payload.get("after") or {} + label = event.get("label", "") + camera = event.get("camera", "") + score = event.get("score") or event.get("top_score") or 0 + zones = event.get("current_zones") or [] + event_id = event.get("id", "") + box = event.get("box") or [] + + # Persons are routed through Double-Take when face recognition is on + if USE_FACE_RECOGNITION and label == "person": + return + + if label not in ALERT_OBJECTS: + print(f"[skip] {camera}/{label}: not in ALERT_OBJECTS") + return + if score < MIN_SCORE: + print(f"[skip] {camera}/{label}: score {score:.0%} < {MIN_SCORE:.0%}") + return + if REQUIRE_ZONE and not zones: + print(f"[skip] {camera}/{label}: not in any zone") + return + if _in_quiet_hours() and label not in _ALWAYS_ALERT: + print(f"[skip] {camera}/{label}: quiet hours ({QUIET_START:02d}:00–{QUIET_END:02d}:00)") + return + + key = f"{camera}:{label}" + if _in_cooldown(key): + print(f"[skip] {camera}/{label}: cooldown") + return + + _mark_alerted(key) + + camera_name = camera.replace("_", " ").title() + label_name = label.title() + zone_str = ( + " in " + ", ".join(z.replace("_", " ").title() for z in zones) + if zones else "" + ) + + # Spatial awareness — how close is the detected object? + prox = _proximity(box) + prox_str = f" — {_PROXIMITY_PHRASE[prox]}" if prox else "" + effective_priority = _bumped_priority(PRIORITY.get(label, "default"), prox) + + if label == "package": + title = f"Package delivered — {camera_name}" + body = f"Package spotted{zone_str}{prox_str}" + elif label == "car": + title = f"Vehicle at {camera_name}" + body = f"Vehicle detected{zone_str}{prox_str} ({score:.0%})" + else: + title = f"{label_name} detected — {camera_name}" + body = f"{label_name}{zone_str}{prox_str} ({score:.0%})" + + _send(title, body, label, camera, event_id, priority_override=effective_priority) + + +# ── Double-Take face recognition event handler ──────────────────────────────── + +def handle_face_event(topic: str, payload: dict) -> None: + # Topic: double-take/cameras/{camera}/{name} + parts = topic.split("/") + if len(parts) < 4: + return + + camera = parts[2] + name = parts[3].lower() + confidence = payload.get("confidence", 0) + event_id = (payload.get("event") or {}).get("id", "") + zones = (payload.get("event") or {}).get("current_zones") or [] + box = (payload.get("event") or {}).get("box") or [] + + camera_name = camera.replace("_", " ").title() + zone_str = ( + " in " + ", ".join(z.replace("_", " ").title() for z in zones) + if zones else "" + ) + + # Spatial awareness + prox = _proximity(box) + prox_str = f" — {_PROXIMITY_PHRASE[prox]}" if prox else "" + + if confidence < MIN_FACE_SCORE: + print(f"[skip] face/{camera}/{name}: {confidence:.0%} < {MIN_FACE_SCORE:.0%}") + return + + if REQUIRE_ZONE and not zones: + print(f"[skip] face/{camera}/{name}: not in any zone") + return + + if name in KNOWN_PEOPLE: + # ── Family member ──────────────────────────────────────────────────── + key = f"{camera}:family:{name}" + if _in_cooldown(key): + print(f"[skip] face/{camera}/{name}: cooldown") + return + _mark_alerted(key) + + if not FAMILY_ARRIVAL_ALERTS: + print(f"[known] {name} at {camera} — arrival alerts disabled") + return + if _in_quiet_hours(): + print(f"[skip] face/{camera}/{name}: quiet hours — family arrival suppressed") + return + + name_display = name.title() + title = f"{name_display} is home — {camera_name}" + body = f"{name_display} arrived{zone_str}{prox_str} ({confidence:.0%})" + print(f"[known] {title}") + _send(title, body, "family", camera, event_id) + + else: + # ── Unknown / stranger ─────────────────────────────────────────────── + key = f"{camera}:stranger" + if _in_cooldown(key): + print(f"[skip] face/{camera}/stranger: cooldown") + return + _mark_alerted(key) + + # Stranger close to camera → bump to max urgency + effective_priority = _bumped_priority("urgent", prox) + + title = f"Stranger at {camera_name}" + body = f"Unrecognized person{zone_str}{prox_str} — check camera" + print(f"[alert] {title} ({prox or 'unknown distance'})") + _send(title, body, "stranger", camera, event_id, priority_override=effective_priority) + + +# ── Audio event handler ─────────────────────────────────────────────────────── + +def handle_audio_event(topic: str, payload: dict) -> None: + # Topic: frigate/{camera}/audio/{event_type} + parts = topic.split("/") + if len(parts) < 4: + return + + camera = parts[1] + event_type = parts[3] + + if event_type not in _AUDIO_ALERTS: + print(f"[skip] audio/{camera}/{event_type}: not in alert list") + return + + key = f"{camera}:audio:{event_type}" + if _in_cooldown(key): + print(f"[skip] audio/{camera}/{event_type}: cooldown") + return + _mark_alerted(key) + + camera_name = camera.replace("_", " ").title() + title_tpl, body = _AUDIO_ALERTS[event_type] + title = title_tpl.format(camera=camera_name) + + print(f"[audio] {title}") + # Audio events don't have a snapshot, but we link to the camera's event page + _send(title, body, event_type, camera, event_id="") + + +# ── MQTT ────────────────────────────────────────────────────────────────────── + +def on_connect(client: mqtt.Client, userdata, flags, rc: int) -> None: + if rc != 0: + print(f"[mqtt] Connection failed (rc={rc})") + return + + client.subscribe("frigate/events") + client.subscribe("frigate/+/audio/+") + print(f"[mqtt] Connected to {MQTT_HOST}:{MQTT_PORT}") + print("[mqtt] Subscribed to: frigate/events, frigate/+/audio/+") + + if USE_FACE_RECOGNITION: + client.subscribe("double-take/cameras/#") + print("[mqtt] Subscribed to: double-take/cameras/#") + + +def on_message(client: mqtt.Client, userdata, msg: mqtt.MQTTMessage) -> None: + try: + payload = json.loads(msg.payload) + except json.JSONDecodeError: + return + + topic = msg.topic + if topic == "frigate/events": + handle_frigate_event(payload) + elif "/audio/" in topic: + handle_audio_event(topic, payload) + elif topic.startswith("double-take/cameras/"): + handle_face_event(topic, payload) + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main() -> None: + print("=" * 60) + print("[config] Face recognition: ", USE_FACE_RECOGNITION) + if USE_FACE_RECOGNITION: + print("[config] Known family members:", sorted(KNOWN_PEOPLE) or "(none — add after training)") + print(f"[config] Min face confidence: {MIN_FACE_SCORE:.0%}") + print(f"[config] Family arrival alerts: {FAMILY_ARRIVAL_ALERTS}") + print("[config] Object alerts: ", sorted(ALERT_OBJECTS)) + print(f"[config] Min object score: {MIN_SCORE:.0%}") + print(f"[config] Require zone: {REQUIRE_ZONE}") + print("[config] Audio alerts: ", sorted(_AUDIO_ALERTS.keys())) + if QUIET_START != QUIET_END: + print(f"[config] Quiet hours: {QUIET_START:02d}:00–{QUIET_END:02d}:00 (urgent alerts still fire)") + else: + print("[config] Quiet hours: disabled") + print(f"[config] Spatial awareness: close>{PROXIMITY_CLOSE:.0%} mid>{PROXIMITY_MID:.0%} far=rest") + print(f"[config] Cooldown: {COOLDOWN}s") + print(f"[config] ntfy endpoint: {NTFY_URL}/{NTFY_TOPIC}") + print("=" * 60) + + client = mqtt.Client() + client.on_connect = on_connect + client.on_message = on_message + + while True: + try: + client.connect(MQTT_HOST, MQTT_PORT, keepalive=60) + client.loop_forever() + except Exception as exc: + print(f"[mqtt] Error: {exc} — retrying in 10s") + time.sleep(10) + + +if __name__ == "__main__": + main() diff --git a/frigate/setup.sh b/frigate/setup.sh new file mode 100755 index 0000000..1715e03 --- /dev/null +++ b/frigate/setup.sh @@ -0,0 +1,602 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Frigate NVR — one-time setup wizard +# +# ┌─ Windows users ──────────────────────────────────────────────────────────┐ +# │ "bash is not recognized" means you need a bash shell. Two options: │ +# │ │ +# │ Option A — Git Bash (quickest if you have Git installed) │ +# │ Open the Start menu → search "Git Bash" → open it │ +# │ cd into this folder, then: bash setup.sh │ +# │ │ +# │ Option B — WSL (recommended, needed for Docker on Windows anyway) │ +# │ In an admin PowerShell: wsl --install │ +# │ Restart, then open "Ubuntu" from the Start menu │ +# │ cd into this folder, then: bash setup.sh │ +# └──────────────────────────────────────────────────────────────────────────┘ +# +# Run from the frigate/ directory: +# bash setup.sh +# +# What this does: +# 1. Checks / installs Docker +# 2. Asks you a few questions (camera IPs, Wyze password, your names) +# 3. Writes a .env file — docker-compose reads it automatically +# 4. Starts all services +# 5. Installs Tailscale for secure phone access +# 6. Prints URLs for everything +# +# Re-running this script is safe — it just updates your .env and restarts. +# ───────────────────────────────────────────────────────────────────────────── + +set -euo pipefail +cd "$(dirname "$0")" + +# ── Terminal colors ──────────────────────────────────────────────────────────── +if [[ -t 1 ]]; then + BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m' + GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; RED='\033[0;31m'; CYAN='\033[0;36m' +else + BOLD=''; DIM=''; NC='' + GREEN=''; YELLOW=''; BLUE=''; RED=''; CYAN='' +fi + +ok() { echo -e "${GREEN} ✓${NC} $*"; } +info() { echo -e "${BLUE} →${NC} $*"; } +warn() { echo -e "${YELLOW} !${NC} $*"; } +err() { echo -e "${RED} ✗${NC} $*"; exit 1; } +step() { echo -e "\n${BOLD}${CYAN}▸ $*${NC}"; } +divider() { echo -e "\n${DIM}──────────────────────────────────────────────────${NC}"; } +ask() { echo -e " ${BOLD}$*${NC}"; } + +# ── Ctrl+C handler ──────────────────────────────────────────────────────────── +trap 'echo -e "\n\n Setup cancelled."; exit 1' INT + +# ── Utilities ───────────────────────────────────────────────────────────────── + +# Generate a random hard-to-guess string (for ntfy topic) +gen_token() { + python3 -c "import secrets, string; print(secrets.token_urlsafe(10))" 2>/dev/null || + tr -dc 'a-z0-9' /dev/null || + echo "cam-$(date +%s | tail -c6)" +} + +# Detect the user's timezone +detect_timezone() { + timedatectl show --property=Timezone --value 2>/dev/null || + cat /etc/timezone 2>/dev/null || + readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||' || + echo "America/Chicago" +} + +# Check if a host:port is reachable (3s timeout) +is_reachable() { + timeout 3 bash -c "/dev/null +} + +# Determine docker compose command (v2 plugin or v1 standalone) +docker_compose() { + if docker compose version &>/dev/null 2>&1; then + docker compose "$@" + elif command -v docker-compose &>/dev/null; then + docker-compose "$@" + else + err "docker compose not found. Install Docker Desktop and try again." + fi +} + +# ── Welcome banner ───────────────────────────────────────────────────────────── +clear +echo "" +echo -e "${BOLD}${CYAN}" +echo " ╔══════════════════════════════════════════════════╗" +echo " ║ Frigate NVR — Setup Wizard ║" +echo " ╚══════════════════════════════════════════════════╝" +echo -e "${NC}" +echo " Answers to ~8 questions → everything configured and running." +echo " Takes about 5 minutes (most of that is Docker downloading images)." +echo "" + +# ── Step 1: Docker ──────────────────────────────────────────────────────────── +step "Checking Docker" + +if ! command -v docker &>/dev/null; then + warn "Docker not found. Installing..." + echo "" + if [[ "$(uname)" == "Darwin" ]]; then + err "On Mac, install Docker Desktop from https://www.docker.com/products/docker-desktop then re-run this script." + fi + curl -fsSL https://get.docker.com | sh + sudo usermod -aG docker "$USER" + warn "Docker installed. You may need to log out and back in." + warn "If 'docker compose' fails with a permissions error, prefix commands with 'sudo'." +fi + +if ! docker info &>/dev/null 2>&1; then + if sudo docker info &>/dev/null 2>&1; then + warn "Docker requires sudo on this machine. Continuing with sudo." + DOCKER_SUDO="sudo " + else + err "Docker is installed but not running. Start Docker Desktop (or: sudo systemctl start docker) then re-run this script." + fi +else + DOCKER_SUDO="" +fi +ok "Docker is ready" + +# ── Step 2: Wyze RTSP credentials ───────────────────────────────────────────── +step "Wyze RTSP credentials" +echo "" +echo " Find these in: Wyze app → Camera → Settings → Advanced Settings → RTSP" +echo " (If you don't see RTSP, you may need to install the Wyze RTSP firmware first)" +echo "" + +ask " RTSP username (press Enter for default 'wyze'):" +read -rp " > " RTSP_USER +RTSP_USER="${RTSP_USER:-wyze}" + +ask " RTSP password:" +read -rsp " > " RTSP_PASSWORD +echo "" +[[ -z "$RTSP_PASSWORD" ]] && err "Password cannot be empty." + +echo "" +ask " Stream path — which works for your Wyze model?" +echo " 1) /live (Wyze Cam v2, v3, Pan — most common)" +echo " 2) /livestream/12 (some older Wyze models)" +echo " 3) Other" +read -rp " > [1]: " PATH_CHOICE +case "${PATH_CHOICE:-1}" in + 2) RTSP_PATH="/livestream/12" ;; + 3) + ask " Enter your RTSP path (starting with /):" + read -rp " > " RTSP_PATH + ;; + *) RTSP_PATH="/live" ;; +esac + +ok "RTSP: ${RTSP_USER}@camera${RTSP_PATH}" + +# ── Step 3: Camera IPs ──────────────────────────────────────────────────────── +step "Camera IP addresses" +echo "" +echo " Find each camera's IP in: Wyze app → Camera → Settings → Device Info" +echo "" + +ask " How many cameras? (1–4):" +read -rp " > [2]: " NUM_CAMS +NUM_CAMS="${NUM_CAMS:-2}" +[[ "$NUM_CAMS" =~ ^[1-4]$ ]] || err "Please enter 1, 2, 3, or 4." + +declare -a CAM_IPS=() +declare -a CAM_NAMES=() +declare -a CAM_PTZES=() # "y" or "n" for each camera +HAS_PTZ=false +DEFAULT_NAMES=("front_door" "backyard" "garage" "side_yard") +DEFAULT_LABELS=("Front Door / Driveway" "Backyard" "Garage" "Side Yard") + +for (( i=0; i [${DEFAULT_NAMES[$i]}]: " CAM_NAME + CAM_NAME="${CAM_NAME:-${DEFAULT_NAMES[$i]}}" + CAM_NAME="${CAM_NAME// /_}" # spaces → underscores + CAM_NAMES+=("$CAM_NAME") + + # IP + while true; do + ask " IP address:" + read -rp " > " CAM_IP + if [[ "$CAM_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if is_reachable "$CAM_IP" 554; then + ok " $CAM_IP is reachable on RTSP port 554" + else + warn " Can't reach $CAM_IP:554 right now. Continuing anyway — verify RTSP is enabled." + fi + CAM_IPS+=("$CAM_IP") + break + else + warn " That doesn't look like a valid IP. Try again (format: 192.168.1.100)" + fi + done + + # PTZ + ask " Pan/tilt camera (Wyze Pan Cam — can physically rotate)? [y/N]:" + read -rp " > " IS_PTZ + IS_PTZ="${IS_PTZ:-n}" + CAM_PTZES+=("$IS_PTZ") + if [[ "$IS_PTZ" =~ ^[Yy] ]]; then + HAS_PTZ=true + ok " PTZ enabled — Frigate will auto-track people on this camera" + fi +done + +# ── Step 4: GPU / hardware acceleration ────────────────────────────────────── +step "Hardware acceleration" +echo "" +echo " Lets Frigate use your GPU for video decoding — saves CPU, runs cooler." +echo " If unsure, pick 'None'. You can always change it later." +echo "" +echo " 1) None — CPU only (always works, safe default)" +echo " 2) Intel integrated GPU (Intel HD / Iris / Arc graphics)" +echo " 3) NVIDIA GPU" +echo " 4) Raspberry Pi 4 / 5" +read -rp " > [1]: " GPU_CHOICE +case "${GPU_CHOICE:-1}" in + 2) HW_ACCEL="preset-intel-qsv-h264" ;; + 3) HW_ACCEL="preset-nvidia-h264" ;; + 4) HW_ACCEL="preset-rpi-64-h264" ;; + *) HW_ACCEL="" ;; +esac +[[ -n "$HW_ACCEL" ]] && ok "Hardware accel: $HW_ACCEL" || ok "CPU-only decode" + +# ── Step 5: Recordings storage ──────────────────────────────────────────────── +step "Recordings storage" +echo "" +echo " Where should Frigate save recordings and snapshots?" +echo " Needs a few GB free minimum; 500GB+ is comfortable for 2 cameras." +echo "" +ask " Storage path (press Enter for ./storage in this folder):" +echo " ${DIM}Example for a second drive: /mnt/bigdrive/frigate${NC}" +read -rp " > [./storage]: " STORAGE_PATH +STORAGE_PATH="${STORAGE_PATH:-./storage}" + +# Create directory if it doesn't exist +mkdir -p "$STORAGE_PATH" +ok "Storage: $STORAGE_PATH" + +# ── Step 6: Face recognition names ─────────────────────────────────────────── +step "Face recognition — family names" +echo "" +echo " After setup, you'll train the system with your photos." +echo " People listed here get a quiet 'arrived home' alert instead of a stranger warning." +echo " Use lowercase. Leave blank and add later if you prefer." +echo "" +ask " Your first name:" +read -rp " > " NAME1 + +ask " Your wife's first name:" +read -rp " > " NAME2 + +KNOWN_PEOPLE="" +[[ -n "$NAME1" ]] && KNOWN_PEOPLE="${NAME1,,}" +[[ -n "$NAME2" ]] && KNOWN_PEOPLE="${KNOWN_PEOPLE:+${KNOWN_PEOPLE},}${NAME2,,}" +[[ -n "$KNOWN_PEOPLE" ]] && ok "Family: $KNOWN_PEOPLE" || info "No names set yet — add them after training" + +# ── Step 7: Timezone ────────────────────────────────────────────────────────── +step "Timezone" +echo "" +DETECTED_TZ="$(detect_timezone)" +ask " Timezone (for accurate event timestamps):" +read -rp " > [${DETECTED_TZ}]: " TIMEZONE +TIMEZONE="${TIMEZONE:-$DETECTED_TZ}" +ok "Timezone: $TIMEZONE" + +# ── Step 8: ntfy topic ──────────────────────────────────────────────────────── +step "Push notification topic" +echo "" +echo " This is your private notification channel name." +echo " Treat it like a password — anyone who knows it can subscribe." +echo " We've generated a random one for you." +echo "" +RANDOM_TOPIC="cam-$(gen_token)" +ask " Topic name:" +read -rp " > [${RANDOM_TOPIC}]: " NTFY_TOPIC +NTFY_TOPIC="${NTFY_TOPIC:-$RANDOM_TOPIC}" +ok "Topic: $NTFY_TOPIC" + +# ── Confirm ──────────────────────────────────────────────────────────────────── +divider +echo "" +echo -e " ${BOLD}Ready to set up with these settings:${NC}" +echo "" +echo " RTSP user: $RTSP_USER" +echo " RTSP path: $RTSP_PATH" +echo " Cameras:" +for (( i=0; i/dev/null | awk '{print $1}' || ipconfig getifaddr en0 2>/dev/null || echo "YOUR_LAN_IP") + +cat > .env << EOF +# Generated by setup.sh — do not commit this file +# Edit values here and run: docker compose up -d + +# ── Camera credentials ──────────────────────────────────────────────────────── +RTSP_USER=${RTSP_USER} +RTSP_PASSWORD=${RTSP_PASSWORD} +RTSP_PATH=${RTSP_PATH} + +# ── Camera IPs ──────────────────────────────────────────────────────────────── +EOF + +for (( i=0; i> .env +done +# Pad to 4 cameras (unused ones will be empty, Frigate ignores empty env vars) +for (( i=NUM_CAMS; i<4; i++ )); do + echo "CAM$((i+1))_IP=" >> .env +done + +# PTZ flags (used by config generation below) +echo "" >> .env +echo "# ── PTZ auto-tracking ────────────────────────────────────────────────────────" >> .env +for (( i=0; i> .env +done + +cat >> .env << EOF + +# ── Notifications ───────────────────────────────────────────────────────────── +NTFY_TOPIC=${NTFY_TOPIC} +# Updated automatically when Tailscale is set up: +NTFY_BASE_URL=http://localhost:8080 +FRIGATE_URL=http://frigate:5000 + +# ── Face recognition ────────────────────────────────────────────────────────── +KNOWN_PEOPLE=${KNOWN_PEOPLE} + +# ── Storage ─────────────────────────────────────────────────────────────────── +STORAGE_PATH=${STORAGE_PATH} +EOF + +ok ".env written" + +# ── Update config/config.yml with camera names and hardware accel ───────────── +info "Updating Frigate config..." + +# Regenerate the go2rtc streams and cameras sections for the actual camera names +CAM_PTZES_STR="${CAM_PTZES[*]}" # "y n y n" etc. +python3 - << PYEOF +import re + +with open('config/config.yml', 'r') as f: + content = f.read() + +cam_names_raw = "${CAM_NAMES[*]}" +cam_ptzes_raw = "${CAM_PTZES_STR}" +cam_count = $NUM_CAMS + +cam_names_list = cam_names_raw.split() +cam_ptzes_list = cam_ptzes_raw.split() if cam_ptzes_raw.strip() else [] + +def is_ptz(i): + return i < len(cam_ptzes_list) and cam_ptzes_list[i].lower() in ('y', 'yes') + +# ── go2rtc streams ────────────────────────────────────────────────────────── +streams = "go2rtc:\n streams:\n\n # Camera IPs come from .env — no editing needed.\n" +for i, name in enumerate(cam_names_list): + streams += f" {name}:\n" + streams += f' - "rtsp://{{FRIGATE_RTSP_USER}}:{{FRIGATE_RTSP_PASSWORD}}@{{FRIGATE_CAM{i+1}_IP}}{{FRIGATE_RTSP_PATH}}"\n\n' + +content = re.sub( + r'^go2rtc:.*?(?=^[a-z])', + streams + "\n", + content, + flags=re.MULTILINE | re.DOTALL +) + +# ── cameras section ────────────────────────────────────────────────────────── +cam_section = "# ── Cameras ───────────────────────────────────────────────────────────────────\ncameras:\n\n" + +for i, name in enumerate(cam_names_list): + ptz = is_ptz(i) + + cam_section += f" {name}:\n" + cam_section += " <<: *camera_defaults\n\n" + cam_section += " ffmpeg:\n" + cam_section += " inputs:\n" + cam_section += f" - path: rtsp://127.0.0.1:8554/{name}\n" + cam_section += " roles: [detect, record]\n\n" + + if ptz: + # ONVIF PTZ control — Wyze Pan Cam uses port 2020 (not the ONVIF standard 80) + cam_section += " onvif:\n" + cam_section += f" host: {{FRIGATE_CAM{i+1}_IP}}\n" + cam_section += " port: 2020\n" + cam_section += " user: \"{FRIGATE_RTSP_USER}\"\n" + cam_section += " password: \"{FRIGATE_RTSP_PASSWORD}\"\n\n" + # Frigate autotracking — moves the camera to follow detected people + cam_section += " autotracking:\n" + cam_section += " enabled: true\n" + cam_section += " calibrate_on_startup: true # finds pan/tilt limits on first start\n" + cam_section += " zooming: disabled # Wyze Pan has no optical zoom\n" + cam_section += " track:\n" + cam_section += " - person\n" + cam_section += " timeout: 10 # seconds idle before returning home\n" + cam_section += " # return_preset: home # uncomment after setting a 'home' preset in Frigate UI\n\n" + cam_section += " zones:\n" + cam_section += " # Note: zones are less reliable with PTZ cameras because the\n" + cam_section += " # field of view shifts as the camera tracks. Alert on full-frame\n" + cam_section += " # detections, or set REQUIRE_ZONE=false in .env for PTZ cameras.\n\n\n" + else: + cam_section += " zones:\n" + cam_section += " # Draw zones in the Frigate UI, then paste coordinates here.\n" + cam_section += " # driveway:\n" + cam_section += " # coordinates: 0,1080,1920,1080,1920,400,0,400\n" + cam_section += " # objects: [person, car]\n\n\n" + +content = re.sub( + r'^# ── Cameras.*', + cam_section, + content, + flags=re.MULTILINE | re.DOTALL +) + +with open('config/config.yml', 'w') as f: + f.write(content) +PYEOF + +# Apply hardware acceleration if selected +if [[ -n "$HW_ACCEL" ]]; then + sed -i "s|hwaccel_args: \[\]|hwaccel_args: ${HW_ACCEL}|g" config/config.yml + ok "Hardware accel set to: $HW_ACCEL" +fi + +# Update double-take timezone +sed -i "s|timezone: America/Chicago.*|timezone: ${TIMEZONE} # auto-set by setup.sh|g" double-take/config.yml + +# Update double-take cameras to match +python3 - << PYEOF +cam_names = ${CAM_NAMES[@]@Q} + +cameras_block = "cameras:\n" +for name in cam_names.split(): + name = name.strip("'") + cameras_block += f" {name}:\n" + cameras_block += " zones: []\n" + +with open('double-take/config.yml', 'r') as f: + content = f.read() + +import re +content = re.sub(r'^cameras:.*', cameras_block, content, flags=re.MULTILINE | re.DOTALL) + +with open('double-take/config.yml', 'w') as f: + f.write(content) +PYEOF + +ok "Frigate and Double-Take configs updated" + +# ── Start Docker Compose ────────────────────────────────────────────────────── +step "Starting all services" +echo "" +info "Pulling Docker images (this can take a few minutes the first time)..." +echo "" + +${DOCKER_SUDO}docker_compose pull --quiet + +info "Starting containers..." +${DOCKER_SUDO}docker_compose up -d --build + +# Wait for Frigate to become healthy +echo "" +info "Waiting for Frigate to start..." +TRIES=0 +until ${DOCKER_SUDO}docker_compose exec -T frigate wget -qO- http://localhost:5000/api/version &>/dev/null; do + sleep 3 + TRIES=$((TRIES+1)) + [[ $TRIES -gt 30 ]] && { warn "Frigate is taking a while. Check logs: docker compose logs frigate"; break; } + echo -n "." +done +echo "" +ok "All services are running" + +# ── Tailscale ───────────────────────────────────────────────────────────────── +step "Tailscale — secure phone access" +echo "" +echo " Tailscale creates a private, encrypted connection between this machine" +echo " and your phones. No port forwarding, nothing exposed to the internet." +echo "" +read -rp " Set up Tailscale now? [Y/n]: " TS_CONFIRM + +if [[ ! "${TS_CONFIRM:-Y}" =~ ^[Nn] ]]; then + + if ! command -v tailscale &>/dev/null; then + info "Installing Tailscale..." + if [[ "$(uname)" == "Darwin" ]]; then + warn "On Mac, install Tailscale from the App Store or https://tailscale.com/download/mac then re-run this script." + else + curl -fsSL https://tailscale.com/install.sh | sh + fi + fi + + info "Connecting to Tailscale (a browser window will open to log in)..." + sudo tailscale up --accept-routes + + TS_IP=$(tailscale ip -4 2>/dev/null || echo "") + + if [[ -n "$TS_IP" ]]; then + ok "Tailscale IP: $TS_IP" + + # Update .env with Tailscale IP so notifications link correctly from phones + sed -i "s|NTFY_BASE_URL=.*|NTFY_BASE_URL=http://${TS_IP}:8080|" .env + sed -i "s|FRIGATE_URL=.*|FRIGATE_URL=http://${TS_IP}:5000|" .env + + # Reload the affected services + info "Reloading notification services with Tailscale IP..." + ${DOCKER_SUDO}docker_compose up -d ntfy notifier + + ok "Notification links will now work from your phone over Tailscale" + else + warn "Couldn't detect Tailscale IP. Run 'tailscale ip -4' after login and update .env manually." + TS_IP="YOUR_TAILSCALE_IP" + fi +else + TS_IP="YOUR_TAILSCALE_IP" + info "Skipped. Run 'bash tailscale-setup.sh' when ready." +fi + +# ── Final summary ────────────────────────────────────────────────────────────── +step "Setup complete!" +echo "" +echo -e " ${BOLD}Services are running. Here's everything you need:${NC}" +echo "" +echo -e " ${CYAN}On this machine (local network)${NC}" +printf " Frigate cameras: http://%-36s\n" "${LAN_IP}:5000" +printf " Face training UI: http://%-36s\n" "${LAN_IP}:3000" +printf " Scrypted/HomeKit: https://%-35s\n" "${LAN_IP}:10443" +echo "" +echo -e " ${CYAN}On your phone (install Tailscale app first)${NC}" +printf " Frigate cameras: http://%-36s\n" "${TS_IP}:5000" +printf " Face training UI: http://%-36s\n" "${TS_IP}:3000" +printf " Scrypted/HomeKit: https://%-35s\n" "${TS_IP}:10443" +echo "" +echo -e " ${CYAN}Push notifications (install ntfy app)${NC}" +echo " iOS: https://apps.apple.com/app/ntfy/id1625396347" +echo " Android: https://play.google.com/store/apps/details?id=io.heckel.ntfy" +echo "" +echo " In the ntfy app:" +echo " Settings → Default server → http://${TS_IP}:8080" +echo " Subscribe to topic: ${NTFY_TOPIC}" +echo "" +if [[ "$HAS_PTZ" == "true" ]]; then + echo -e " ${CYAN}Pan/tilt auto-tracking${NC}" + echo " Frigate will physically move your PTZ cameras to follow detected people." + echo "" + echo " IMPORTANT — disable Wyze's built-in tracking to avoid conflicts:" + echo " Wyze app → Camera → Settings → Detection Settings → Motion Tracking → Off" + echo "" + echo " To set a 'home' position (where the camera returns after tracking):" + echo " Frigate UI → PTZ → move camera to desired position → Save as preset 'home'" + echo "" +fi +echo -e " ${CYAN}Next steps${NC}" +echo " 1. Open Frigate → draw zones around your driveway/porch" +echo " (this limits alerts to areas you care about)" +echo " 2. Open http://${TS_IP}:3000 → Train → add face photos" +echo " for you and your wife" +if [[ -z "$KNOWN_PEOPLE" ]]; then + echo " 3. Edit .env → set KNOWN_PEOPLE=yourname,wifename" + echo " then run: docker compose up -d notifier" +fi +echo " 4. Set up Apple HomeKit: https://${TS_IP}:10443" +echo " → Frigate NVR plugin → HomeKit plugin → scan QR code" +echo "" +echo -e " ${CYAN}Useful commands${NC}" +echo " View logs: docker compose logs -f" +echo " Restart: docker compose restart" +echo " Stop everything: docker compose down" +echo " Re-run wizard: bash setup.sh" +echo "" +divider +echo "" diff --git a/frigate/species/Dockerfile b/frigate/species/Dockerfile new file mode 100644 index 0000000..6e43488 --- /dev/null +++ b/frigate/species/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN pip install --no-cache-dir paho-mqtt requests + +COPY species.py . + +CMD ["python", "-u", "species.py"] diff --git a/frigate/species/species.py b/frigate/species/species.py new file mode 100644 index 0000000..9f0d2b9 --- /dev/null +++ b/frigate/species/species.py @@ -0,0 +1,318 @@ +""" +Wildlife species identification service. + +When Frigate detects a bird, deer, squirrel, raccoon, or other animal, +this service downloads the snapshot and sends it to the iNaturalist +computer vision API to identify the exact species. + +Examples of notifications you'll receive: + "Northern Cardinal at Backyard" + "Eastern Gray Squirrel at Driveway" + "White-tailed Deer at Backyard" + "Red-tailed Hawk at Front Door" + +The iNaturalist model is trained on tens of millions of wildlife photos +and covers birds, mammals, reptiles, insects, amphibians — everything. + +Setup (free): + 1. Create a free account at https://www.inaturalist.org + 2. Get your API token at https://www.inaturalist.org/users/api_token + 3. Add INAT_TOKEN=your_token to .env + +Optional accuracy boost: + Set INAT_LAT and INAT_LNG to your rough location (e.g. 37.77, -122.41) + The model uses local species ranges to narrow down candidates. + +Environment variables +───────────────────── +INAT_TOKEN iNaturalist API token (required) +INAT_LAT Latitude (optional, improves accuracy) +INAT_LNG Longitude (optional, improves accuracy) +SPECIES_OBJECTS Comma-sep Frigate labels to ID + (default: bird,cat,dog,deer,bear,raccoon,fox,squirrel) +MIN_SPECIES_SCORE Minimum confidence 0–1 (default: 0.70) +SPECIES_COOLDOWN Seconds before re-alerting same species+camera + (default: 1800 = 30 min) +MQTT_HOST MQTT broker host (default: mosquitto) +MQTT_PORT MQTT broker port (default: 1883) +NTFY_URL ntfy server URL (default: http://ntfy:80) +NTFY_TOPIC ntfy topic (default: frigate-alerts) +FRIGATE_URL Frigate base URL (default: http://frigate:5000) +LOG_FILE Path for sightings log (default: /data/sightings.log) +""" + +import json +import logging +import os +import time +from datetime import datetime + +import paho.mqtt.client as mqtt +import requests + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [species] %(message)s", + datefmt="%H:%M:%S", +) +log = logging.getLogger("species") + +# ── Settings ────────────────────────────────────────────────────────────────── + +INAT_TOKEN = os.getenv("INAT_TOKEN", "") +INAT_LAT = os.getenv("INAT_LAT", "") +INAT_LNG = os.getenv("INAT_LNG", "") + +MQTT_HOST = os.getenv("MQTT_HOST", "mosquitto") +MQTT_PORT = int(os.getenv("MQTT_PORT", "1883")) +NTFY_URL = os.getenv("NTFY_URL", "http://ntfy:80").rstrip("/") +NTFY_TOPIC = os.getenv("NTFY_TOPIC", "frigate-alerts") +FRIGATE_URL = os.getenv("FRIGATE_URL", "http://frigate:5000").rstrip("/") +LOG_FILE = os.getenv("LOG_FILE", "/data/sightings.log") + +_objects_str = os.getenv("SPECIES_OBJECTS", "bird,cat,dog,deer,bear,raccoon,fox,squirrel") +SPECIES_OBJECTS = set(o.strip() for o in _objects_str.split(",") if o.strip()) +MIN_SCORE = float(os.getenv("MIN_SPECIES_SCORE", "0.70")) +COOLDOWN = int(os.getenv("SPECIES_COOLDOWN", "1800")) # 30 min + +INAT_API = "https://api.inaturalist.org/v1/computervision/score_image" + +# Cooldown tracking: "{camera}:{common_name}" → last alerted unix timestamp +_alerted: dict[str, float] = {} + +# ── iNaturalist icons by taxonomic group ────────────────────────────────────── +# Maps iNat's iconic_taxon_name to ntfy tag + emoji prefix + +_TAXON_META = { + "Aves": ("bird", "Bird"), + "Mammalia": ("chipmunk", "Mammal"), + "Reptilia": ("lizard", "Reptile"), + "Amphibia": ("frog", "Amphibian"), + "Actinopterygii":("tropical_fish","Fish"), + "Insecta": ("bug", "Insect"), + "Arachnida": ("spider", "Arachnid"), + "Plantae": ("herb", "Plant"), + "Fungi": ("mushroom", "Fungus"), +} + +def _taxon_meta(iconic_name: str) -> tuple[str, str]: + """Return (ntfy_tag, group_label) for an iNat iconic taxon name.""" + return _TAXON_META.get(iconic_name, ("paw_prints", "Animal")) + + +# ── Frigate snapshot ────────────────────────────────────────────────────────── + +def fetch_snapshot(event_id: str) -> bytes | None: + try: + resp = requests.get( + f"{FRIGATE_URL}/api/events/{event_id}/snapshot.jpg", + params={"bbox": 0, "crop": 1}, # crop=1 zooms in on the animal + timeout=15, + ) + resp.raise_for_status() + return resp.content + except Exception as exc: + log.warning("Snapshot fetch failed: %s", exc) + return None + + +# ── iNaturalist species ID ──────────────────────────────────────────────────── + +def identify_species(image_bytes: bytes) -> dict | None: + """ + Send a cropped animal snapshot to iNaturalist computer vision. + Returns the top result dict or None if identification failed / low confidence. + + Response structure used: + result["score"] – confidence 0-1 + result["taxon"]["preferred_common_name"] – e.g. "Northern Cardinal" + result["taxon"]["name"] – e.g. "Cardinalis cardinalis" + result["taxon"]["rank"] – e.g. "species", "genus" + result["taxon"]["iconic_taxon_name"] – e.g. "Aves", "Mammalia" + """ + if not INAT_TOKEN: + log.warning("INAT_TOKEN not set — skipping species ID") + return None + + headers = {"Authorization": f"Bearer {INAT_TOKEN}"} + data: dict = {} + if INAT_LAT and INAT_LNG: + data["lat"] = INAT_LAT + data["lng"] = INAT_LNG + + try: + resp = requests.post( + INAT_API, + files={"file": ("snapshot.jpg", image_bytes, "image/jpeg")}, + data=data, + headers=headers, + timeout=20, + ) + resp.raise_for_status() + results = resp.json().get("results", []) + except Exception as exc: + log.error("iNaturalist API error: %s", exc) + return None + + if not results: + return None + + top = results[0] + score = top.get("score", 0) + if score < MIN_SCORE: + taxon_name = (top.get("taxon") or {}).get("preferred_common_name", "unknown") + log.info("Top result '%s' at %.0f%% — below threshold", taxon_name, score * 100) + return None + + return top + + +# ── Sightings log ───────────────────────────────────────────────────────────── + +def log_sighting(common_name: str, scientific: str, camera: str, score: float) -> None: + try: + os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) + with open(LOG_FILE, "a") as f: + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + f.write(f"{ts}\t{camera}\t{common_name}\t{scientific}\t{score:.0%}\n") + except Exception as exc: + log.error("Log write failed: %s", exc) + + +# ── ntfy notification ───────────────────────────────────────────────────────── + +def send_alert( + common_name: str, + scientific: str, + rank: str, + iconic: str, + camera: str, + score: float, + image: bytes, +) -> None: + tag, group = _taxon_meta(iconic) + camera_name = camera.replace("_", " ").title() + + # For species-level IDs show scientific name; for genus/family just show group + if rank == "species": + title = f"{common_name} at {camera_name}" + body = f"{scientific} · {score:.0%} confidence" + else: + # Couldn't get to species level — show what we do know + title = f"{group} at {camera_name}" + body = f"{common_name or scientific} · {score:.0%} (identified to {rank} level)" + + try: + resp = requests.put( + f"{NTFY_URL}/{NTFY_TOPIC}", + data=image, + headers={ + "Title": title, + "Message": body, + "Priority": "low", + "Tags": tag, + "Filename": f"{common_name.lower().replace(' ', '-')}.jpg", + "Click": f"{FRIGATE_URL}/events?camera={camera}", + }, + timeout=20, + ) + log.info("ntfy %s: %s", "OK" if resp.ok else resp.status_code, title) + except Exception as exc: + log.error("ntfy send failed: %s", exc) + + +# ── Event processing ────────────────────────────────────────────────────────── + +def handle_event(payload: dict) -> None: + ev_type = payload.get("type") + after = payload.get("after") or {} + label = after.get("label", "") + camera = after.get("camera", "unknown") + + # Wait for event to end so Frigate has selected the best snapshot + if ev_type != "end": + return + if label not in SPECIES_OBJECTS: + return + + event_id = after.get("id") or "" + if not event_id: + return + + log.info("%s event ended at %s — identifying species ...", label, camera) + + snapshot = fetch_snapshot(event_id) + if not snapshot: + return + + result = identify_species(snapshot) + if not result: + log.info("No confident species ID for %s at %s", label, camera) + return + + taxon = result.get("taxon") or {} + common_name = taxon.get("preferred_common_name") or taxon.get("name", "Unknown") + scientific = taxon.get("name", "") + rank = taxon.get("rank", "") + iconic = taxon.get("iconic_taxon_name", "") + score = result.get("score", 0) + + log.info("Identified: %s (%s) at %s — %.0f%%", common_name, scientific, camera, score * 100) + + # Cooldown per camera + species to avoid repeat alerts when the same + # bird hangs out at the feeder for an hour + key = f"{camera}:{common_name.lower()}" + last = _alerted.get(key, 0) + if time.time() - last < COOLDOWN: + remaining = int(COOLDOWN - (time.time() - last)) // 60 + log.info("Cooldown: %s (%d min remaining)", common_name, remaining) + return + + _alerted[key] = time.time() + log_sighting(common_name, scientific, camera, score) + send_alert(common_name, scientific, rank, iconic, camera, score, snapshot) + + +# ── MQTT client ─────────────────────────────────────────────────────────────── + +def on_connect(client, _userdata, _flags, rc): + if rc == 0: + log.info("Connected to MQTT %s:%s", MQTT_HOST, MQTT_PORT) + client.subscribe("frigate/events") + else: + log.error("MQTT connect failed: rc=%d", rc) + + +def on_message(_client, _userdata, msg): + try: + handle_event(json.loads(msg.payload)) + except Exception as exc: + log.error("Message error: %s", exc) + + +def main() -> None: + log.info("Wildlife species ID service starting") + log.info("Watching for: %s", SPECIES_OBJECTS) + log.info("Min confidence: %.0f%% | Cooldown: %ds", MIN_SCORE * 100, COOLDOWN) + if INAT_LAT and INAT_LNG: + log.info("Location hint: %s, %s", INAT_LAT, INAT_LNG) + else: + log.info("No location set — add INAT_LAT/INAT_LNG to .env for better accuracy") + if not INAT_TOKEN: + log.warning("INAT_TOKEN not set — get one at https://www.inaturalist.org/users/api_token") + + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION1) + client.on_connect = on_connect + client.on_message = on_message + + while True: + try: + client.connect(MQTT_HOST, MQTT_PORT, keepalive=60) + client.loop_forever() + except Exception as exc: + log.error("MQTT error: %s — retrying in 10s", exc) + time.sleep(10) + + +if __name__ == "__main__": + main() diff --git a/frigate/tailscale-setup.sh b/frigate/tailscale-setup.sh new file mode 100755 index 0000000..552b992 --- /dev/null +++ b/frigate/tailscale-setup.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Tailscale setup — run once on the machine hosting Frigate +# +# What this does: +# 1. Installs Tailscale (free, no account needed to start) +# 2. Starts Tailscale and prints your private Tailscale IP +# 3. Shows you the URLs to use for all services +# +# After running this: +# - Install the Tailscale app on your iPhone and Android phone +# - Sign in with the same Tailscale account +# - You now have a private, encrypted VPN between your phones and this server +# - No port forwarding, no public IP, no one else can reach your cameras +# +# Supports: Ubuntu, Debian, Raspberry Pi OS, Fedora, Arch Linux, macOS +# ───────────────────────────────────────────────────────────────────────────── + +set -euo pipefail + +echo "" +echo " Frigate + Tailscale secure access setup" +echo " ─────────────────────────────────────────" +echo "" + +# ── 1. Install Tailscale ────────────────────────────────────────────────────── + +if command -v tailscale &>/dev/null; then + echo " [ok] Tailscale is already installed ($(tailscale version | head -1))" +else + echo " [..] Installing Tailscale..." + curl -fsSL https://tailscale.com/install.sh | sh + echo " [ok] Tailscale installed" +fi + +# ── 2. Start Tailscale ──────────────────────────────────────────────────────── + +echo "" +echo " [..] Starting Tailscale..." +echo "" +echo " A browser window (or URL) will open for you to log in." +echo " Tailscale is free for personal use — sign in with Google, GitHub, or email." +echo "" + +sudo tailscale up --accept-routes + +# ── 3. Get Tailscale IP ─────────────────────────────────────────────────────── + +TS_IP=$(tailscale ip -4 2>/dev/null || echo "") + +if [[ -z "$TS_IP" ]]; then + echo " [!] Could not detect Tailscale IP. Run 'tailscale ip -4' after login." + exit 0 +fi + +# ── 4. Print service URLs ───────────────────────────────────────────────────── + +echo "" +echo " ✓ Tailscale is running. Your private IP is: ${TS_IP}" +echo "" +echo " ┌─────────────────────────────────────────────────────────┐" +echo " │ Access from your phone (install Tailscale app first) │" +echo " ├─────────────────────────────────────────────────────────┤" +printf " │ Frigate live view: http://%-28s│\n" "${TS_IP}:5000" +printf " │ Double-Take (faces): http://%-28s│\n" "${TS_IP}:3000" +printf " │ ntfy notifications: http://%-28s│\n" "${TS_IP}:8080" +printf " │ Scrypted (HomeKit): https://%-27s│\n" "${TS_IP}:10443" +echo " └─────────────────────────────────────────────────────────┘" +echo "" +echo " Next steps:" +echo "" +echo " 1. Install Tailscale on iPhone:" +echo " https://apps.apple.com/app/tailscale/id1470499037" +echo "" +echo " 2. Install Tailscale on Android:" +echo " https://play.google.com/store/apps/details?id=com.tailscale.ipn" +echo "" +echo " 3. Sign in to Tailscale on both phones with the same account" +echo " you used above. Your phones are now on the private network." +echo "" +echo " 4. Update docker-compose.yml:" +echo " - Set NTFY_BASE_URL to http://${TS_IP}:8080" +echo " - Set FRIGATE_URL to http://${TS_IP}:5000" +echo " (so notification links open from your phone over Tailscale)" +echo "" +echo " 5. Apply the changes:" +echo " docker compose up -d notifier ntfy" +echo "" +echo " 6. In the ntfy app on your phone:" +echo " - Settings → Default server → http://${TS_IP}:8080" +echo " - Subscribe to your NTFY_TOPIC from docker-compose.yml" +echo "" +echo " Security note:" +echo " Your cameras are NOT exposed to the public internet." +echo " Port 5000 is only reachable on your local network and via Tailscale." +echo " Never forward port 5000 through your router." +echo ""