From 3fa2799991b4617b691f0eeb74067c8fa248a7a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 18:40:19 +0000 Subject: [PATCH 01/12] Add self-hosted camera dashboard using go2rtc + WebRTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a cameras/ page that streams Wyze RTSP feeds directly in the browser via go2rtc — no Wyze cloud, no subscription required. Features: - Live WebRTC streams from go2rtc (converts RTSP → WebRTC locally) - 1×1, 2×2, 1+3 grid layouts - Per-camera connect/disconnect, mute, and fullscreen controls - Settings panel to configure go2rtc host and camera list (localStorage) - Setup guide with Frigate NVR recommendation and step-by-step go2rtc setup - Dark theme, mobile-responsive https://claude.ai/code/session_013Uou3hhxTzooTKyGfBkoLz --- cameras/app.js | 402 +++++++++++++++++++++++++++++++++++++++++++++ cameras/index.html | 158 ++++++++++++++++++ cameras/style.css | 402 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 962 insertions(+) create mode 100644 cameras/app.js create mode 100644 cameras/index.html create mode 100644 cameras/style.css 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; } +} From 2aafb037a6b57856fdf450dd855b3d3a9f4ced9b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 18:43:41 +0000 Subject: [PATCH 02/12] Add Frigate NVR setup (docker-compose + config) Configured for 2 Wyze cameras with RTSP. Addresses the specific Wyze pain points: accurate cat/dog/person/car detection via local AI (no cloud), motion masks for wind chimes and shadows, zone-based alerting so the street doesn't trigger, and configurable confidence thresholds to cut false positives. Includes: - docker-compose.yml with sensible defaults, hardware accel comments - config/config.yml with go2rtc stream setup, per-object score filters, motion threshold tuning, snapshot/recording retention, and zone examples User needs to fill in camera IPs and draw masks/zones in the Frigate UI. https://claude.ai/code/session_013Uou3hhxTzooTKyGfBkoLz --- frigate/config/config.yml | 233 +++++++++++++++++++++++++++++++++++++ frigate/docker-compose.yml | 85 ++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 frigate/config/config.yml create mode 100644 frigate/docker-compose.yml diff --git a/frigate/config/config.yml b/frigate/config/config.yml new file mode 100644 index 0000000..bb297cd --- /dev/null +++ b/frigate/config/config.yml @@ -0,0 +1,233 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Frigate config.yml — Wyze 2-camera setup +# +# Things you MUST change before starting: +# 1. Camera RTSP URLs (search for YOUR_CAMERA_IP below) +# 2. Camera resolution — check your Wyze cam's actual resolution +# +# Things you'll probably want to tune after first run: +# 3. Motion masks — draw them in the Frigate UI, paste coords here +# 4. Object thresholds — raise if you get too many false detections +# +# 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: false + # host: 192.168.1.x # IP of your MQTT broker (e.g. Home Assistant) + # port: 1883 + # user: mqtt_user + # password: mqtt_pass + + +# ── Detector ───────────────────────────────────────────────────────────────── +# CPU detection works on any machine. It's slower than a Coral TPU but totally +# fine for 2 cameras at 5 fps. +# +# 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: 3 # increase to 4-6 if your CPU has extra 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: + + front_door: + # ⚠️ Replace with your actual camera IP + - rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@YOUR_CAMERA_IP_1/live + + backyard: + # ⚠️ Replace with your actual camera IP + - rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@YOUR_CAMERA_IP_2/live + + +# ── 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. + hwaccel_args: preset-rpi-64-h264 # Raspberry Pi 4/5 + # hwaccel_args: preset-intel-qsv-h264 # Intel Quick Sync + # hwaccel_args: preset-nvidia-h264 # NVIDIA GPU + # hwaccel_args: [] # CPU only (no acceleration) + + 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 + + # ── 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 ─────────────────────────────────────────────────────────────────── +cameras: + + front_door: + <<: *camera_defaults # inherit all the defaults above + + ffmpeg: + inputs: + # go2rtc re-streams your camera at this internal address. + # "detect" role = send frames to the AI detector. + # "record" role = save continuous/event recordings. + - path: rtsp://127.0.0.1:8554/front_door + roles: + - detect + - record + + # ── Zones ──────────────────────────────────────────────────────────────── + # Zones let you limit alerts to specific areas of the frame, or alert + # only when an object enters/exits a zone. + # + # HOW TO GET COORDINATES: + # Frigate UI → front_door camera → Zones → draw your zone → copy coords + # + # Example: only alert on people in the driveway, not the street + zones: + driveway: + # Replace with real coordinates drawn in the Frigate UI + coordinates: 0,1080,1920,1080,1920,400,0,400 # lower portion of frame + objects: + - person + - car + porch: + coordinates: 600,1080,1320,1080,1320,600,600,600 + objects: + - person + + # ── Per-camera motion mask override (wind chimes, shadows) ─────────────── + # If the wind chimes are only visible on this camera, add a mask here + # instead of (or in addition to) the default above. + # motion: + # mask: + # - x1,y1,x2,y2,x3,y3,... ← paste coordinates from Frigate UI + + + 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 # full frame — refine in UI + objects: + - person + - cat + - dog + + # motion: + # mask: + # - x1,y1,x2,y2,... ← add backyard-specific masks here diff --git a/frigate/docker-compose.yml b/frigate/docker-compose.yml new file mode 100644 index 0000000..077e4fb --- /dev/null +++ b/frigate/docker-compose.yml @@ -0,0 +1,85 @@ +version: "3.9" + +# ───────────────────────────────────────────────────────────────────────────── +# Frigate NVR — docker-compose.yml +# +# Prerequisites: +# - Docker + Docker Compose installed on your Linux/Mac/Windows machine +# - Wyze cameras with RTSP enabled (Wyze app → Camera → Settings → +# Advanced Settings → RTSP → Enable) +# +# Run from this directory: +# docker compose up -d +# +# Web UI opens at: http://localhost:5000 +# (or http://your-server-ip:5000 from another device on the network) +# +# Logs: +# docker compose logs -f frigate +# +# Stop: +# docker compose down +# ───────────────────────────────────────────────────────────────────────────── + +services: + frigate: + image: ghcr.io/blakeblackshear/frigate:stable + container_name: frigate + restart: unless-stopped + + # Frigate needs elevated access for hardware video decoding. + # If you don't have a Coral TPU or GPU, you can change this to + # privileged: false and remove the devices section below. + privileged: true + + # Shared memory for frame buffers. + # Increase to 512mb if you have 4+ cameras. + shm_size: "256mb" + + devices: + # ── Uncomment what applies to you ─────────────────────────────────── + + # Google Coral USB accelerator (huge AI speed improvement, ~$60 on Amazon) + # - /dev/bus/usb:/dev/bus/usb + + # Intel GPU / QuickSync (if your CPU has integrated graphics) + # - /dev/dri/renderD128:/dev/dri/renderD128 + + # NVIDIA GPU — also add "runtime: nvidia" below and the CUDA image tag + # (see https://docs.frigate.video/configuration/hardware_acceleration) + + volumes: + # Sync container clock with host + - /etc/localtime:/etc/localtime:ro + + # Frigate config file — edit config/config.yml in this directory + - ./config:/config + + # Where recordings and snapshots are saved. + # Change the left side to a path with plenty of disk space. + # Example: - /mnt/nas/frigate:/media/frigate + - ./storage:/media/frigate + + # Temp cache for HLS segments (1 GB, RAM-backed for speed) + - type: tmpfs + target: /tmp/cache + tmpfs: + size: 1000000000 + + ports: + - "5000:5000" # Web UI + - "8554:8554" # go2rtc RTSP re-stream (for viewing in VLC etc.) + - "8555:8555/tcp" # go2rtc WebRTC TCP + - "8555:8555/udp" # go2rtc WebRTC UDP + + # Optional: expose these environment variables in config.yml as + # {FRIGATE_RTSP_USER} and {FRIGATE_RTSP_PASSWORD} so you don't + # hard-code credentials in config. + environment: + FRIGATE_RTSP_USER: "wyze" # your Wyze RTSP username + FRIGATE_RTSP_PASSWORD: "changeme" # your Wyze RTSP password + + # Uncomment if you have an NVIDIA GPU: + # runtime: nvidia + # environment: + # - NVIDIA_VISIBLE_DEVICES=all From 74b92925855a5e3f2d7288f1c98a62f28aa94788 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 18:49:09 +0000 Subject: [PATCH 03/12] Add AI push notifications via MQTT + ntfy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires up the full notification pipeline: - mosquitto: MQTT broker that Frigate publishes detection events to - notifier: Python service that reads MQTT events and sends push notifications to ntfy (iOS/Android app, free) Notification rules (all configurable in docker-compose env vars): - Only alert on specified objects (default: person, car — not cat/dog) - Minimum AI confidence threshold (default: 75%) - Only fire when object is inside a defined zone (ignores the street) - Per-camera+object cooldown to prevent notification spam (default: 2min) - Links back to the Frigate event in the UI on tap https://claude.ai/code/session_013Uou3hhxTzooTKyGfBkoLz --- frigate/config/config.yml | 10 +- frigate/docker-compose.yml | 133 +++++++++++++-------- frigate/mosquitto/mosquitto.conf | 6 + frigate/notifier/Dockerfile | 5 + frigate/notifier/notifier.py | 197 +++++++++++++++++++++++++++++++ 5 files changed, 296 insertions(+), 55 deletions(-) create mode 100644 frigate/mosquitto/mosquitto.conf create mode 100644 frigate/notifier/Dockerfile create mode 100644 frigate/notifier/notifier.py diff --git a/frigate/config/config.yml b/frigate/config/config.yml index bb297cd..9bc9d98 100644 --- a/frigate/config/config.yml +++ b/frigate/config/config.yml @@ -17,11 +17,11 @@ # Frigate publishes detection events to MQTT. Useful for Home Assistant. # If you don't use MQTT/Home Assistant, set enabled: false. mqtt: - enabled: false - # host: 192.168.1.x # IP of your MQTT broker (e.g. Home Assistant) - # port: 1883 - # user: mqtt_user - # password: mqtt_pass + 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 ───────────────────────────────────────────────────────────────── diff --git a/frigate/docker-compose.yml b/frigate/docker-compose.yml index 077e4fb..6106b29 100644 --- a/frigate/docker-compose.yml +++ b/frigate/docker-compose.yml @@ -1,85 +1,118 @@ version: "3.9" # ───────────────────────────────────────────────────────────────────────────── -# Frigate NVR — docker-compose.yml +# Frigate NVR stack # -# Prerequisites: -# - Docker + Docker Compose installed on your Linux/Mac/Windows machine -# - Wyze cameras with RTSP enabled (Wyze app → Camera → Settings → -# Advanced Settings → RTSP → Enable) +# Services: +# frigate — NVR + AI object detection +# mosquitto — MQTT broker (Frigate publishes events here) +# notifier — reads MQTT events, sends push notifications via ntfy # -# Run from this directory: -# docker compose up -d +# Prerequisites: +# - Docker + Docker Compose on your machine +# - Wyze RTSP enabled (Wyze app → Camera → Settings → Advanced → RTSP) # -# Web UI opens at: http://localhost:5000 -# (or http://your-server-ip:5000 from another device on the network) +# Quick start: +# 1. Edit the environment variables below (camera IPs, ntfy topic) +# 2. docker compose up -d +# 3. Open http://localhost:5000 for the Frigate web UI # # Logs: # docker compose logs -f frigate -# -# Stop: -# docker compose down +# docker compose logs -f notifier # ───────────────────────────────────────────────────────────────────────────── services: + + # ── Frigate NVR ───────────────────────────────────────────────────────────── frigate: image: ghcr.io/blakeblackshear/frigate:stable container_name: frigate restart: unless-stopped - - # Frigate needs elevated access for hardware video decoding. - # If you don't have a Coral TPU or GPU, you can change this to - # privileged: false and remove the devices section below. - privileged: true - - # Shared memory for frame buffers. - # Increase to 512mb if you have 4+ cameras. - shm_size: "256mb" + privileged: true # needed for hardware decoder access + shm_size: "256mb" # frame buffer RAM — raise to 512mb for 4+ cameras + depends_on: + - mosquitto devices: - # ── Uncomment what applies to you ─────────────────────────────────── - - # Google Coral USB accelerator (huge AI speed improvement, ~$60 on Amazon) + # ── Uncomment what you have ─────────────────────────────────────────── + # Google Coral USB (~$60, makes AI ~10x faster — highly recommended) # - /dev/bus/usb:/dev/bus/usb - # Intel GPU / QuickSync (if your CPU has integrated graphics) + # Intel integrated GPU (QuickSync hardware decode) # - /dev/dri/renderD128:/dev/dri/renderD128 - # NVIDIA GPU — also add "runtime: nvidia" below and the CUDA image tag - # (see https://docs.frigate.video/configuration/hardware_acceleration) - volumes: - # Sync container clock with host - /etc/localtime:/etc/localtime:ro - - # Frigate config file — edit config/config.yml in this directory - ./config:/config - - # Where recordings and snapshots are saved. - # Change the left side to a path with plenty of disk space. - # Example: - /mnt/nas/frigate:/media/frigate - - ./storage:/media/frigate - - # Temp cache for HLS segments (1 GB, RAM-backed for speed) + - ./storage:/media/frigate # ⚠️ change left side to a large disk path - type: tmpfs target: /tmp/cache tmpfs: - size: 1000000000 + size: 1000000000 # 1 GB HLS cache ports: - "5000:5000" # Web UI - - "8554:8554" # go2rtc RTSP re-stream (for viewing in VLC etc.) - - "8555:8555/tcp" # go2rtc WebRTC TCP - - "8555:8555/udp" # go2rtc WebRTC UDP + - "8554:8554" # RTSP re-stream (VLC, other apps) + - "8555:8555/tcp" # WebRTC TCP + - "8555:8555/udp" # WebRTC UDP - # Optional: expose these environment variables in config.yml as - # {FRIGATE_RTSP_USER} and {FRIGATE_RTSP_PASSWORD} so you don't - # hard-code credentials in config. environment: - FRIGATE_RTSP_USER: "wyze" # your Wyze RTSP username - FRIGATE_RTSP_PASSWORD: "changeme" # your Wyze RTSP password + # ⚠️ Set these to your Wyze RTSP credentials + FRIGATE_RTSP_USER: "wyze" + FRIGATE_RTSP_PASSWORD: "changeme" - # Uncomment if you have an NVIDIA GPU: + # Uncomment for NVIDIA GPU: # runtime: nvidia - # environment: - # - NVIDIA_VISIBLE_DEVICES=all + + # ── Mosquitto MQTT broker ──────────────────────────────────────────────────── + # Frigate publishes detection events to MQTT. The notifier reads from here. + mosquitto: + image: eclipse-mosquitto:2 + container_name: mosquitto + restart: unless-stopped + volumes: + - ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + + # ── Push notification bridge ───────────────────────────────────────────────── + # Listens to Frigate MQTT events and sends push notifications via ntfy. + notifier: + build: ./notifier + container_name: frigate-notifier + restart: unless-stopped + depends_on: + - mosquitto + - frigate + + environment: + MQTT_HOST: mosquitto + MQTT_PORT: "1883" + + # ── ntfy settings ────────────────────────────────────────────────────── + # 1. Install the ntfy app on your phone (iOS or Android — it's free) + # 2. Pick a unique topic name — anyone who knows it can subscribe, so + # make it hard to guess (treat it like a password) + # 3. Open the ntfy app → + → enter your topic name → Subscribe + NTFY_URL: "https://ntfy.sh" # or your self-hosted ntfy address + NTFY_TOPIC: "change-this-to-something-unique-abc123" # ⚠️ change this! + + # Frigate's base URL — used to build links in notifications + FRIGATE_URL: "http://frigate:5000" + + # ── What triggers an alert ───────────────────────────────────────────── + # Comma-separated list of objects that will send a notification. + # Remove "cat" and "dog" if you have pets and don't want alerts for them. + ALERT_OBJECTS: "person,car" + + # Minimum AI confidence to alert (0.0–1.0). 0.75 = 75% sure. + # Raise this if you're getting false positives. + MIN_SCORE: "0.75" + + # Only alert when the object is inside a defined zone. + # "true" = ignore objects wandering on the street outside your zones. + # "false" = alert anywhere in the frame. + REQUIRE_ZONE: "true" + + # Seconds before the same camera+object can alert again. + # Prevents a person standing at your door from sending 50 notifications. + ALERT_COOLDOWN: "120" 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..e71c24d --- /dev/null +++ b/frigate/notifier/notifier.py @@ -0,0 +1,197 @@ +""" +Frigate → ntfy notification bridge. + +Subscribes to Frigate's MQTT event stream and sends push notifications +to ntfy when the AI detects objects matching your alert rules. + +All settings come from environment variables (set in docker-compose.yml). +""" + +import json +import os +import time + +import paho.mqtt.client as mqtt +import requests + +# ── Settings from environment ───────────────────────────────────────────────── + +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") + +FRIGATE_URL = os.getenv("FRIGATE_URL", "http://frigate:5000").rstrip("/") + +ALERT_OBJECTS = set(os.getenv("ALERT_OBJECTS", "person,car").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")) + +# Per-object notification priority for ntfy +PRIORITY = { + "person": "high", + "car": "default", + "cat": "low", + "dog": "low", + "bird": "min", +} + +# Per-object emoji tags for ntfy +TAGS = { + "person": "bust_in_silhouette,rotating_light", + "car": "car", + "cat": "cat", + "dog": "dog", + "bird": "bird", +} + +# ── Cooldown tracking ───────────────────────────────────────────────────────── + +_last_alerted: dict[str, float] = {} + + +def _cooldown_key(camera: str, label: str) -> str: + return f"{camera}:{label}" + + +def _in_cooldown(camera: str, label: str) -> bool: + key = _cooldown_key(camera, label) + return time.monotonic() - _last_alerted.get(key, 0) < COOLDOWN + + +def _mark_alerted(camera: str, label: str) -> None: + _last_alerted[_cooldown_key(camera, label)] = time.monotonic() + + +# ── Alert logic ─────────────────────────────────────────────────────────────── + +def should_alert(event: dict) -> tuple[bool, str]: + """Return (True, '') if this event should fire a notification, else (False, reason).""" + 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 [] + + if label not in ALERT_OBJECTS: + return False, f"label '{label}' not in ALERT_OBJECTS" + + if score < MIN_SCORE: + return False, f"score {score:.0%} < MIN_SCORE {MIN_SCORE:.0%}" + + if REQUIRE_ZONE and not zones: + return False, "object not in any zone (REQUIRE_ZONE=true)" + + if _in_cooldown(camera, label): + return False, f"cooldown active ({COOLDOWN}s)" + + return True, "" + + +def send_notification(event: dict) -> None: + label = event.get("label", "object") + camera = event.get("camera", "unknown") + score = event.get("score") or event.get("top_score") or 0 + zones = event.get("current_zones") or [] + event_id = event.get("id", "") + + camera_name = camera.replace("_", " ").title() + label_name = label.title() + zone_str = ( + " in " + ", ".join(z.replace("_", " ").title() for z in zones) + if zones else "" + ) + + title = f"{label_name} detected \u2014 {camera_name}" + body = f"{label_name}{zone_str} ({score:.0%} confidence)" + + headers: dict[str, str] = { + "Title": title, + "Priority": PRIORITY.get(label, "default"), + "Tags": TAGS.get(label, "bell"), + "Click": f"{FRIGATE_URL}/events?camera={camera}", + } + + # Attach the snapshot image. + # Note: ntfy.sh will try to fetch this URL from the internet. + # If Frigate is on your local network only, the image won't attach + # on ntfy.sh cloud — but it will work if you self-host ntfy on the + # same network. The notification will still arrive either way. + 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] Sent: {title}") + else: + print(f"[notify] ntfy error {resp.status_code}: {resp.text[:200]}") + except requests.RequestException as exc: + print(f"[notify] Failed to reach ntfy: {exc}") + + +# ── MQTT callbacks ──────────────────────────────────────────────────────────── + +def on_connect(client: mqtt.Client, userdata, flags, rc: int) -> None: + if rc == 0: + client.subscribe("frigate/events") + print(f"[mqtt] Connected to {MQTT_HOST}:{MQTT_PORT}, subscribed to frigate/events") + else: + print(f"[mqtt] Connection failed (rc={rc})") + + +def on_message(client: mqtt.Client, userdata, msg: mqtt.MQTTMessage) -> None: + try: + payload = json.loads(msg.payload) + except json.JSONDecodeError: + return + + # Frigate publishes event updates as type "new", "update", or "end". + # We only alert on "new" so you get one notification per detection event, + # not one per frame. + if payload.get("type") != "new": + return + + event = payload.get("after") or {} + ok, reason = should_alert(event) + + label = event.get("label", "?") + camera = event.get("camera", "?") + + if ok: + _mark_alerted(camera, label) + send_notification(event) + else: + print(f"[skip] {camera}/{label}: {reason}") + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main() -> None: + print("[config] Alert objects:", sorted(ALERT_OBJECTS)) + print(f"[config] Min score: {MIN_SCORE:.0%}") + print(f"[config] Require zone: {REQUIRE_ZONE}") + print(f"[config] Cooldown: {COOLDOWN}s") + print(f"[config] ntfy topic: {NTFY_URL}/{NTFY_TOPIC}") + + 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() From 9c4f8ad7c80f7e3f7cd78c91f52dc2418c357cbe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 20:45:05 +0000 Subject: [PATCH 04/12] Add face recognition, HomeKit, self-hosted ntfy, and Tailscale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full feature stack: Face recognition (DeepStack + Double-Take) - DeepStack runs the AI face recognition model locally - Double-Take orchestrates: watches Frigate person events, sends snapshots to DeepStack, publishes results to MQTT - Known family members → quiet "arrived home" notification - Unrecognized face → urgent "Stranger at [camera]" alert - Train faces via the Double-Take UI at http://localhost:3000 HomeKit + Google Home (Scrypted) - Native iOS Home app, Apple Watch, and Siri camera access - "Hey Siri, show me the front door" works - Add via Scrypted UI → Frigate NVR plugin → HomeKit plugin Self-hosted ntfy - Push notifications now served locally (no cloud dependency) - Snapshot images embed in notifications (ntfy fetches directly from Frigate on the same Docker network) - Subscribe via Tailscale IP so it works away from home too Tailscale VPN (tailscale-setup.sh) - Secure, encrypted access to all services from iPhone/Android - No port forwarding, no public exposure - Run once on host, install app on phones, done Notifier updates - Routes persons through Double-Take instead of generic Frigate alerts - Dedicated package delivery notification ("Package delivered at...") - Family vs stranger alert logic with configurable KNOWN_PEOPLE https://claude.ai/code/session_013Uou3hhxTzooTKyGfBkoLz --- frigate/docker-compose.yml | 213 ++++++++++++++++------ frigate/double-take/config.yml | 52 ++++++ frigate/notifier/notifier.py | 315 ++++++++++++++++++++++----------- frigate/tailscale-setup.sh | 97 ++++++++++ 4 files changed, 512 insertions(+), 165 deletions(-) create mode 100644 frigate/double-take/config.yml create mode 100755 frigate/tailscale-setup.sh diff --git a/frigate/docker-compose.yml b/frigate/docker-compose.yml index 6106b29..b13bcb7 100644 --- a/frigate/docker-compose.yml +++ b/frigate/docker-compose.yml @@ -1,26 +1,35 @@ version: "3.9" -# ───────────────────────────────────────────────────────────────────────────── -# Frigate NVR stack +# ═══════════════════════════════════════════════════════════════════════════════ +# Frigate NVR — full feature stack # -# Services: -# frigate — NVR + AI object detection -# mosquitto — MQTT broker (Frigate publishes events here) -# notifier — reads MQTT events, sends push notifications via ntfy +# Services +# ──────── +# frigate NVR + AI object detection (person, car, package, etc.) +# mosquitto MQTT broker — Frigate publishes all events here +# deepstack AI engine for face recognition +# double-take Face recognition orchestrator + training UI +# ntfy Self-hosted push notification server (iOS + Android) +# notifier Bridges Frigate/Double-Take MQTT → ntfy push alerts +# scrypted Apple HomeKit + Google Home camera bridge # -# Prerequisites: -# - Docker + Docker Compose on your machine -# - Wyze RTSP enabled (Wyze app → Camera → Settings → Advanced → RTSP) +# Quick start +# ─────────── +# 1. Edit every line marked ⚠️ below +# 2. docker compose up -d +# 3. Frigate: http://localhost:5000 +# Double-Take: http://localhost:3000 ← train faces here +# ntfy: http://localhost:8080 ← subscribe in ntfy app +# Scrypted: https://localhost:10443 ← connect HomeKit here # -# Quick start: -# 1. Edit the environment variables below (camera IPs, ntfy topic) -# 2. docker compose up -d -# 3. Open http://localhost:5000 for the Frigate web UI +# Secure remote access (so you can view cameras from anywhere on your phone) +# ───────────────────────────────────────────────────────────────────────── +# Run ./tailscale-setup.sh on this machine once. +# Install the Tailscale app on your iPhone/Android. +# Sign in with the same account → your phone is now on a private VPN +# with this server. Access all services above via the Tailscale IP. # -# Logs: -# docker compose logs -f frigate -# docker compose logs -f notifier -# ───────────────────────────────────────────────────────────────────────────── +# ═══════════════════════════════════════════════════════════════════════════════ services: @@ -29,13 +38,13 @@ services: image: ghcr.io/blakeblackshear/frigate:stable container_name: frigate restart: unless-stopped - privileged: true # needed for hardware decoder access - shm_size: "256mb" # frame buffer RAM — raise to 512mb for 4+ cameras + privileged: true + shm_size: "256mb" # raise to 512mb for 4+ cameras depends_on: - mosquitto devices: - # ── Uncomment what you have ─────────────────────────────────────────── + # ── Uncomment what you have ───────────────────────────────────────────── # Google Coral USB (~$60, makes AI ~10x faster — highly recommended) # - /dev/bus/usb:/dev/bus/usb @@ -45,28 +54,23 @@ services: volumes: - /etc/localtime:/etc/localtime:ro - ./config:/config - - ./storage:/media/frigate # ⚠️ change left side to a large disk path + - ./storage:/media/frigate # ⚠️ change left side to a large disk - type: tmpfs target: /tmp/cache tmpfs: - size: 1000000000 # 1 GB HLS cache + size: 1000000000 ports: - - "5000:5000" # Web UI - - "8554:8554" # RTSP re-stream (VLC, other apps) + - "5000:5000" # Web UI (accessible on LAN + via Tailscale) + - "8554:8554" # RTSP re-stream - "8555:8555/tcp" # WebRTC TCP - "8555:8555/udp" # WebRTC UDP environment: - # ⚠️ Set these to your Wyze RTSP credentials - FRIGATE_RTSP_USER: "wyze" - FRIGATE_RTSP_PASSWORD: "changeme" - - # Uncomment for NVIDIA GPU: - # runtime: nvidia + FRIGATE_RTSP_USER: "wyze" # ⚠️ your Wyze RTSP username + FRIGATE_RTSP_PASSWORD: "changeme" # ⚠️ your Wyze RTSP password # ── Mosquitto MQTT broker ──────────────────────────────────────────────────── - # Frigate publishes detection events to MQTT. The notifier reads from here. mosquitto: image: eclipse-mosquitto:2 container_name: mosquitto @@ -74,45 +78,138 @@ services: volumes: - ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + # ── DeepStack — AI engine for face recognition ─────────────────────────────── + # Processes face images and determines if they match known people. + # Training happens in the Double-Take UI (no ML expertise needed). + deepstack: + image: deepquestai/deepstack:latest + container_name: deepstack + restart: unless-stopped + volumes: + - ./deepstack:/datastore # stores trained face data + environment: + VISION-FACE: "True" # enable face recognition module + + # ── Double-Take — face recognition orchestrator ─────────────────────────────── + # Watches Frigate for person events, sends snapshots to DeepStack, + # publishes results back to MQTT with the recognized person's name. + # + # Setup (one-time): + # 1. Open http://localhost:3000 + # 2. Go to Settings → enter your MQTT and Frigate URLs (already configured + # below in config.yml — you just need to confirm they're correct) + # 3. Go to Train → upload 5-10 clear face photos for each person + # (different angles, lighting conditions, with/without glasses) + # 4. Add names to KNOWN_PEOPLE in the notifier section below + 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" # Training UI + + # ── ntfy — self-hosted push notification server ─────────────────────────────── + # Runs on your machine — snapshots will actually embed in notifications + # since ntfy can reach Frigate directly on the Docker network. + # + # Setup: + # 1. Install the ntfy app (iOS App Store or Google Play — free) + # 2. In the app: Settings → Default server → http://YOUR_TAILSCALE_IP:8080 + # 3. Subscribe to the topic name you set in NTFY_TOPIC below + ntfy: + image: binwiederhier/ntfy:latest + container_name: ntfy + restart: unless-stopped + command: serve + volumes: + - ./ntfy/cache:/var/cache/ntfy + ports: + - "8080:80" # ntfy server (accessible via Tailscale) + environment: + NTFY_CACHE_FILE: /var/cache/ntfy/cache.db + NTFY_ATTACHMENT_CACHE_DIR: /var/cache/ntfy/attachments + # Tells ntfy where it's accessible from — use your Tailscale IP here + # so notification attachments (snapshots) resolve correctly on your phone. + # Find your Tailscale IP with: tailscale ip -4 + NTFY_BASE_URL: "http://YOUR_TAILSCALE_IP:8080" # ⚠️ set after running tailscale-setup.sh + # ── Push notification bridge ───────────────────────────────────────────────── - # Listens to Frigate MQTT events and sends push notifications via ntfy. + # Reads Frigate and Double-Take MQTT events → sends ntfy push notifications. + # Face recognized = you or wife → silent "arrived home" or no alert. + # Face unknown = urgent "Stranger at front door" alert. notifier: build: ./notifier container_name: frigate-notifier restart: unless-stopped depends_on: - mosquitto - - frigate + - ntfy environment: MQTT_HOST: mosquitto MQTT_PORT: "1883" - # ── ntfy settings ────────────────────────────────────────────────────── - # 1. Install the ntfy app on your phone (iOS or Android — it's free) - # 2. Pick a unique topic name — anyone who knows it can subscribe, so - # make it hard to guess (treat it like a password) - # 3. Open the ntfy app → + → enter your topic name → Subscribe - NTFY_URL: "https://ntfy.sh" # or your self-hosted ntfy address - NTFY_TOPIC: "change-this-to-something-unique-abc123" # ⚠️ change this! + # ntfy — points to the self-hosted container on the Docker network + NTFY_URL: "http://ntfy:80" + NTFY_TOPIC: "change-this-to-something-unique-abc123" # ⚠️ pick something hard to guess - # Frigate's base URL — used to build links in notifications + # Frigate's URL — used to build snapshot links in notifications + # After setting up Tailscale, change this to http://YOUR_TAILSCALE_IP:5000 + # so snapshot links in notifications open from anywhere on your phone. FRIGATE_URL: "http://frigate:5000" - # ── What triggers an alert ───────────────────────────────────────────── - # Comma-separated list of objects that will send a notification. - # Remove "cat" and "dog" if you have pets and don't want alerts for them. - ALERT_OBJECTS: "person,car" - - # Minimum AI confidence to alert (0.0–1.0). 0.75 = 75% sure. - # Raise this if you're getting false positives. - MIN_SCORE: "0.75" - - # Only alert when the object is inside a defined zone. - # "true" = ignore objects wandering on the street outside your zones. - # "false" = alert anywhere in the frame. - REQUIRE_ZONE: "true" - - # Seconds before the same camera+object can alert again. - # Prevents a person standing at your door from sending 50 notifications. - ALERT_COOLDOWN: "120" + # ── Non-person object alerts ──────────────────────────────────────────── + # "person" is handled by Double-Take (face recognition) below. + # Add objects here for Frigate-level alerts. + ALERT_OBJECTS: "car,package" + + MIN_SCORE: "0.75" # min AI confidence for object alerts (0.0–1.0) + REQUIRE_ZONE: "true" # only alert if object is in a defined zone + ALERT_COOLDOWN: "120" # seconds before same camera+object can re-alert + + # ── Face recognition alerts ────────────────────────────────────────────── + # Requires Double-Take to be set up and trained first. + USE_FACE_RECOGNITION: "true" + MIN_FACE_SCORE: "0.80" # min face recognition confidence (0.0–1.0) + + # ⚠️ Fill in names after training Double-Take. + # These people will get a low-priority "arrived home" notification + # instead of an urgent stranger alert. Use lowercase, comma-separated. + # Example: "david,sarah" + KNOWN_PEOPLE: "" + + # Set to "false" to disable "arrived home" notifications for family. + # Unknown strangers will always alert regardless of this setting. + FAMILY_ARRIVAL_ALERTS: "true" + + # ── Scrypted — Apple HomeKit + Google Home camera bridge ───────────────────── + # Exposes your Frigate cameras natively in: + # - Apple Home app (live view, notifications, Siri, Apple Watch) + # - Google Home + # + # Setup (one-time): + # 1. Open https://localhost:10443 (accept the self-signed cert warning) + # 2. Create an account + # 3. Plugins → search "Frigate NVR" → Install + # 4. Configure Frigate NVR plugin: URL = http://HOST_LAN_IP:5000 + # 5. Plugins → search "HomeKit" → Install + # 6. Follow the QR code to add cameras in the Apple Home app + # + # Note: network_mode: host is required for HomeKit's Bonjour/mDNS discovery. + # This means Scrypted connects to Frigate via your host's LAN IP (not the + # Docker service name). Find your host IP with: hostname -I | awk '{print $1}' + scrypted: + image: ghcr.io/koush/scrypted:latest + container_name: scrypted + restart: unless-stopped + network_mode: host # required for HomeKit mDNS discovery + volumes: + - ./scrypted:/server/volume + # Scrypted web UI: https://localhost:10443 + # (or https://YOUR_TAILSCALE_IP:10443 from your phone) diff --git a/frigate/double-take/config.yml b/frigate/double-take/config.yml new file mode 100644 index 0000000..bbed238 --- /dev/null +++ b/frigate/double-take/config.yml @@ -0,0 +1,52 @@ +# ───────────────────────────────────────────────────────────────────────────── +# 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 + +# Add your camera names here. Must exactly match the camera names in +# Frigate's config/config.yml. +cameras: + # Example entries — uncomment and rename to match your Frigate cameras: + # front_door: + # zones: [] # empty list = check full frame + # # or restrict to specific zones: + # # zones: [driveway, front_porch] + # back_yard: + # zones: [] + # garage: + # zones: [] diff --git a/frigate/notifier/notifier.py b/frigate/notifier/notifier.py index e71c24d..fdb9438 100644 --- a/frigate/notifier/notifier.py +++ b/frigate/notifier/notifier.py @@ -1,10 +1,13 @@ """ -Frigate → ntfy notification bridge. +Frigate + Double-Take → ntfy push notification bridge. -Subscribes to Frigate's MQTT event stream and sends push notifications -to ntfy when the AI detects objects matching your alert rules. +Event routing: + frigate/events → non-person objects (car, package, cat, etc.) + double-take/cameras/# → face-identified person events + - known family member → low-priority "arrived home" + - unrecognized face → urgent "Stranger" alert -All settings come from environment variables (set in docker-compose.yml). +All settings are environment variables configured in docker-compose.yml. """ import json @@ -14,37 +17,60 @@ import paho.mqtt.client as mqtt import requests -# ── Settings from environment ───────────────────────────────────────────────── +# ── Settings ────────────────────────────────────────────────────────────────── -MQTT_HOST = os.getenv("MQTT_HOST", "mosquitto") -MQTT_PORT = int(os.getenv("MQTT_PORT", "1883")) +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") +NTFY_URL = os.getenv("NTFY_URL", "https://ntfy.sh").rstrip("/") +NTFY_TOPIC = os.getenv("NTFY_TOPIC", "frigate-alerts") -FRIGATE_URL = os.getenv("FRIGATE_URL", "http://frigate:5000").rstrip("/") +# 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("/") -ALERT_OBJECTS = set(os.getenv("ALERT_OBJECTS", "person,car").split(",")) +# 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")) -# Per-object notification priority for ntfy +# 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" + +# ── ntfy priority and tag mappings ──────────────────────────────────────────── + PRIORITY = { - "person": "high", - "car": "default", - "cat": "low", - "dog": "low", - "bird": "min", + "stranger": "urgent", + "person": "high", + "package": "high", + "car": "default", + "family": "low", + "dog": "low", + "cat": "low", + "bird": "min", } -# Per-object emoji tags for ntfy TAGS = { - "person": "bust_in_silhouette,rotating_light", - "car": "car", - "cat": "cat", - "dog": "dog", - "bird": "bird", + "stranger": "warning,bust_in_silhouette", + "person": "bust_in_silhouette,rotating_light", + "package": "package", + "car": "car", + "family": "house", + "dog": "dog", + "cat": "cat", + "bird": "bird", } # ── Cooldown tracking ───────────────────────────────────────────────────────── @@ -52,50 +78,84 @@ _last_alerted: dict[str, float] = {} -def _cooldown_key(camera: str, label: str) -> str: - return f"{camera}:{label}" - - -def _in_cooldown(camera: str, label: str) -> bool: - key = _cooldown_key(camera, label) +def _in_cooldown(key: str) -> bool: return time.monotonic() - _last_alerted.get(key, 0) < COOLDOWN -def _mark_alerted(camera: str, label: str) -> None: - _last_alerted[_cooldown_key(camera, label)] = time.monotonic() +def _mark_alerted(key: str) -> None: + _last_alerted[key] = time.monotonic() -# ── Alert logic ─────────────────────────────────────────────────────────────── +# ── ntfy sender ─────────────────────────────────────────────────────────────── -def should_alert(event: dict) -> tuple[bool, str]: - """Return (True, '') if this event should fire a notification, else (False, reason).""" - 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 [] - - if label not in ALERT_OBJECTS: - return False, f"label '{label}' not in ALERT_OBJECTS" - - if score < MIN_SCORE: - return False, f"score {score:.0%} < MIN_SCORE {MIN_SCORE:.0%}" +def _send( + title: str, + body: str, + kind: str, + camera: str, + event_id: str = "", +) -> None: + """POST a notification to ntfy.""" + headers: dict[str, str] = { + "Title": title, + "Priority": 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" - if REQUIRE_ZONE and not zones: - return False, "object not in any zone (REQUIRE_ZONE=true)" + 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}") - if _in_cooldown(camera, label): - return False, f"cooldown active ({COOLDOWN}s)" - return True, "" +# ── Frigate object event handler ────────────────────────────────────────────── +def handle_frigate_event(payload: dict) -> None: + if payload.get("type") != "new": + return -def send_notification(event: dict) -> None: - label = event.get("label", "object") - camera = event.get("camera", "unknown") + 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", "") + # 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 + + 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 = ( @@ -103,47 +163,93 @@ def send_notification(event: dict) -> None: if zones else "" ) - title = f"{label_name} detected \u2014 {camera_name}" - body = f"{label_name}{zone_str} ({score:.0%} confidence)" + if label == "package": + title = f"Package delivered — {camera_name}" + body = f"Package spotted{zone_str}" + elif label == "car": + title = f"Vehicle at {camera_name}" + body = f"Vehicle detected{zone_str} ({score:.0%} confidence)" + else: + title = f"{label_name} detected — {camera_name}" + body = f"{label_name}{zone_str} ({score:.0%} confidence)" - headers: dict[str, str] = { - "Title": title, - "Priority": PRIORITY.get(label, "default"), - "Tags": TAGS.get(label, "bell"), - "Click": f"{FRIGATE_URL}/events?camera={camera}", - } + _send(title, body, label, camera, event_id) - # Attach the snapshot image. - # Note: ntfy.sh will try to fetch this URL from the internet. - # If Frigate is on your local network only, the image won't attach - # on ntfy.sh cloud — but it will work if you self-host ntfy on the - # same network. The notification will still arrive either way. - 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] Sent: {title}") - else: - print(f"[notify] ntfy error {resp.status_code}: {resp.text[:200]}") - except requests.RequestException as exc: - print(f"[notify] Failed to reach ntfy: {exc}") +# ── 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 -# ── MQTT callbacks ──────────────────────────────────────────────────────────── + 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 [] + + camera_name = camera.replace("_", " ").title() + zone_str = ( + " in " + ", ".join(z.replace("_", " ").title() for z in zones) + if zones 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 + + name_display = name.title() + title = f"{name_display} is home — {camera_name}" + body = f"{name_display} arrived{zone_str} ({confidence:.0%})" + print(f"[known] {title}") + _send(title, body, "family", camera, event_id) -def on_connect(client: mqtt.Client, userdata, flags, rc: int) -> None: - if rc == 0: - client.subscribe("frigate/events") - print(f"[mqtt] Connected to {MQTT_HOST}:{MQTT_PORT}, subscribed to frigate/events") else: + # ── Unknown / stranger ─────────────────────────────────────────────── + key = f"{camera}:stranger" + if _in_cooldown(key): + print(f"[skip] face/{camera}/stranger: cooldown") + return + _mark_alerted(key) + + title = f"Stranger at {camera_name}" + body = f"Unrecognized person detected{zone_str} — check camera" + print(f"[alert] {title}") + _send(title, body, "stranger", 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") + print(f"[mqtt] Connected to {MQTT_HOST}:{MQTT_PORT}") + print("[mqtt] Subscribed to: frigate/events") + + 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: @@ -152,33 +258,28 @@ def on_message(client: mqtt.Client, userdata, msg: mqtt.MQTTMessage) -> None: except json.JSONDecodeError: return - # Frigate publishes event updates as type "new", "update", or "end". - # We only alert on "new" so you get one notification per detection event, - # not one per frame. - if payload.get("type") != "new": - return - - event = payload.get("after") or {} - ok, reason = should_alert(event) - - label = event.get("label", "?") - camera = event.get("camera", "?") - - if ok: - _mark_alerted(camera, label) - send_notification(event) - else: - print(f"[skip] {camera}/{label}: {reason}") + topic = msg.topic + if topic == "frigate/events": + handle_frigate_event(payload) + elif topic.startswith("double-take/cameras/"): + handle_face_event(topic, payload) # ── Main ────────────────────────────────────────────────────────────────────── def main() -> None: - print("[config] Alert objects:", sorted(ALERT_OBJECTS)) - print(f"[config] Min score: {MIN_SCORE:.0%}") - print(f"[config] Require zone: {REQUIRE_ZONE}") - print(f"[config] Cooldown: {COOLDOWN}s") - print(f"[config] ntfy topic: {NTFY_URL}/{NTFY_TOPIC}") + 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(f"[config] Cooldown: {COOLDOWN}s") + print(f"[config] ntfy endpoint: {NTFY_URL}/{NTFY_TOPIC}") + print("=" * 60) client = mqtt.Client() client.on_connect = on_connect 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 "" From 00b5d7247b9f65eba9dd69e921dbbbd52b0d6b40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 21:13:14 +0000 Subject: [PATCH 05/12] =?UTF-8?q?Add=20setup.sh=20wizard=20=E2=80=94=20one?= =?UTF-8?q?=20script=20configures=20and=20starts=20everything?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces manual config editing with an interactive setup wizard: - Asks 8 questions (camera IPs, RTSP password, names, GPU, timezone) - Writes a .env file — docker-compose reads it automatically, no YAML editing - Camera IPs/credentials flow through FRIGATE_* env vars into config.yml - Applies hardware acceleration preset via sed (default: CPU-only safe mode) - Runs docker compose pull + up -d - Installs Tailscale and updates .env with the Tailscale IP - Restarts ntfy and notifier with the correct external URL - Prints all service URLs and next steps at the end Also: - Updated for 4 cameras (front_door, backyard, front_yard, side_yard) - shm_size raised to 512mb (recommended for 4 cameras) - num_threads raised to 4 - .gitignore added to exclude .env (contains credentials) - double-take/config.yml pre-populated with all 4 camera names - All ⚠️ manual-edit markers removed from docker-compose.yml and config.yml https://claude.ai/code/session_013Uou3hhxTzooTKyGfBkoLz --- frigate/.gitignore | 9 + frigate/config/config.yml | 138 +++++---- frigate/docker-compose.yml | 172 +++-------- frigate/double-take/config.yml | 21 +- frigate/setup.sh | 539 +++++++++++++++++++++++++++++++++ 5 files changed, 685 insertions(+), 194 deletions(-) create mode 100644 frigate/.gitignore create mode 100755 frigate/setup.sh 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 index 9bc9d98..d3be1f0 100644 --- a/frigate/config/config.yml +++ b/frigate/config/config.yml @@ -1,13 +1,13 @@ # ───────────────────────────────────────────────────────────────────────────── -# Frigate config.yml — Wyze 2-camera setup +# Frigate config.yml — 4-camera setup # -# Things you MUST change before starting: -# 1. Camera RTSP URLs (search for YOUR_CAMERA_IP below) -# 2. Camera resolution — check your Wyze cam's actual resolution +# DO NOT edit camera IPs or credentials here. +# Run ./setup.sh — it writes a .env file and updates this config automatically. # -# Things you'll probably want to tune after first run: -# 3. Motion masks — draw them in the Frigate UI, paste coords here -# 4. Object thresholds — raise if you get too many false detections +# 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 # ───────────────────────────────────────────────────────────────────────────── @@ -25,15 +25,15 @@ mqtt: # ── Detector ───────────────────────────────────────────────────────────────── -# CPU detection works on any machine. It's slower than a Coral TPU but totally -# fine for 2 cameras at 5 fps. +# 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: 3 # increase to 4-6 if your CPU has extra cores + num_threads: 4 # 4 threads for 4 cameras; raise to 6 if your CPU has spare cores # ── Coral USB TPU (uncomment to use) ── # coral: @@ -60,13 +60,18 @@ detectors: go2rtc: streams: + # Camera IPs and credentials come from .env — setup.sh fills these in. front_door: - # ⚠️ Replace with your actual camera IP - - rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@YOUR_CAMERA_IP_1/live + - "rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_CAM1_IP}{FRIGATE_RTSP_PATH}" backyard: - # ⚠️ Replace with your actual camera IP - - rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@YOUR_CAMERA_IP_2/live + - "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) ───────────── @@ -78,10 +83,12 @@ cameras_default: &camera_defaults # Hardware acceleration for decoding — improves CPU usage significantly. # Comment out if you have problems; plain CPU decode still works fine. - hwaccel_args: preset-rpi-64-h264 # Raspberry Pi 4/5 - # hwaccel_args: preset-intel-qsv-h264 # Intel Quick Sync - # hwaccel_args: preset-nvidia-h264 # NVIDIA GPU - # hwaccel_args: [] # CPU only (no acceleration) + # 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 @@ -167,47 +174,31 @@ cameras_default: &camera_defaults # ── 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 # inherit all the defaults above + <<: *camera_defaults ffmpeg: inputs: - # go2rtc re-streams your camera at this internal address. - # "detect" role = send frames to the AI detector. - # "record" role = save continuous/event recordings. - path: rtsp://127.0.0.1:8554/front_door - roles: - - detect - - record + roles: [detect, record] - # ── Zones ──────────────────────────────────────────────────────────────── - # Zones let you limit alerts to specific areas of the frame, or alert - # only when an object enters/exits a zone. - # - # HOW TO GET COORDINATES: - # Frigate UI → front_door camera → Zones → draw your zone → copy coords - # - # Example: only alert on people in the driveway, not the street zones: - driveway: - # Replace with real coordinates drawn in the Frigate UI - coordinates: 0,1080,1920,1080,1920,400,0,400 # lower portion of frame - objects: - - person - - car - porch: - coordinates: 600,1080,1320,1080,1320,600,600,600 - objects: - - person - - # ── Per-camera motion mask override (wind chimes, shadows) ─────────────── - # If the wind chimes are only visible on this camera, add a mask here - # instead of (or in addition to) the default above. + # 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,x3,y3,... ← paste coordinates from Frigate UI + # - x1,y1,x2,y2,... ← draw in Frigate UI, paste coordinates here backyard: @@ -216,18 +207,49 @@ cameras: ffmpeg: inputs: - path: rtsp://127.0.0.1:8554/backyard - roles: - - detect - - record + 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: - yard: - coordinates: 0,1080,1920,1080,1920,0,0,0 # full frame — refine in UI - objects: - - person - - cat - - dog + # side_zone: + # coordinates: 0,1080,1920,1080,1920,0,0,0 + # objects: [person] # motion: # mask: - # - x1,y1,x2,y2,... ← add backyard-specific masks here + # - x1,y1,x2,y2,... diff --git a/frigate/docker-compose.yml b/frigate/docker-compose.yml index b13bcb7..e2e14f3 100644 --- a/frigate/docker-compose.yml +++ b/frigate/docker-compose.yml @@ -3,32 +3,10 @@ version: "3.9" # ═══════════════════════════════════════════════════════════════════════════════ # Frigate NVR — full feature stack # -# Services -# ──────── -# frigate NVR + AI object detection (person, car, package, etc.) -# mosquitto MQTT broker — Frigate publishes all events here -# deepstack AI engine for face recognition -# double-take Face recognition orchestrator + training UI -# ntfy Self-hosted push notification server (iOS + Android) -# notifier Bridges Frigate/Double-Take MQTT → ntfy push alerts -# scrypted Apple HomeKit + Google Home camera bridge -# -# Quick start -# ─────────── -# 1. Edit every line marked ⚠️ below -# 2. docker compose up -d -# 3. Frigate: http://localhost:5000 -# Double-Take: http://localhost:3000 ← train faces here -# ntfy: http://localhost:8080 ← subscribe in ntfy app -# Scrypted: https://localhost:10443 ← connect HomeKit here -# -# Secure remote access (so you can view cameras from anywhere on your phone) -# ───────────────────────────────────────────────────────────────────────── -# Run ./tailscale-setup.sh on this machine once. -# Install the Tailscale app on your iPhone/Android. -# Sign in with the same account → your phone is now on a private VPN -# with this server. Access all services above via the Tailscale IP. +# 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: @@ -39,36 +17,40 @@ services: container_name: frigate restart: unless-stopped privileged: true - shm_size: "256mb" # raise to 512mb for 4+ cameras + shm_size: "512mb" # 512mb for 4 cameras depends_on: - mosquitto devices: - # ── Uncomment what you have ───────────────────────────────────────────── - # Google Coral USB (~$60, makes AI ~10x faster — highly recommended) - # - /dev/bus/usb:/dev/bus/usb - - # Intel integrated GPU (QuickSync hardware decode) + # 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:/media/frigate # ⚠️ change left side to a large disk + - ${STORAGE_PATH:-./storage}:/media/frigate - type: tmpfs target: /tmp/cache tmpfs: size: 1000000000 ports: - - "5000:5000" # Web UI (accessible on LAN + via Tailscale) - - "8554:8554" # RTSP re-stream - - "8555:8555/tcp" # WebRTC TCP - - "8555:8555/udp" # WebRTC UDP + - "5000:5000" + - "8554:8554" + - "8555:8555/tcp" + - "8555:8555/udp" environment: - FRIGATE_RTSP_USER: "wyze" # ⚠️ your Wyze RTSP username - FRIGATE_RTSP_PASSWORD: "changeme" # ⚠️ your Wyze RTSP password + # 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: @@ -78,29 +60,18 @@ services: volumes: - ./mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro - # ── DeepStack — AI engine for face recognition ─────────────────────────────── - # Processes face images and determines if they match known people. - # Training happens in the Double-Take UI (no ML expertise needed). + # ── DeepStack — face recognition AI ───────────────────────────────────────── deepstack: image: deepquestai/deepstack:latest container_name: deepstack restart: unless-stopped volumes: - - ./deepstack:/datastore # stores trained face data + - ./deepstack:/datastore environment: - VISION-FACE: "True" # enable face recognition module + VISION-FACE: "True" # ── Double-Take — face recognition orchestrator ─────────────────────────────── - # Watches Frigate for person events, sends snapshots to DeepStack, - # publishes results back to MQTT with the recognized person's name. - # - # Setup (one-time): - # 1. Open http://localhost:3000 - # 2. Go to Settings → enter your MQTT and Frigate URLs (already configured - # below in config.yml — you just need to confirm they're correct) - # 3. Go to Train → upload 5-10 clear face photos for each person - # (different angles, lighting conditions, with/without glasses) - # 4. Add names to KNOWN_PEOPLE in the notifier section below + # Train faces at http://localhost:3000 double-take: image: jakowenko/double-take:latest container_name: double-take @@ -112,16 +83,9 @@ services: volumes: - ./double-take:/double-take/.storage ports: - - "3000:3000" # Training UI - - # ── ntfy — self-hosted push notification server ─────────────────────────────── - # Runs on your machine — snapshots will actually embed in notifications - # since ntfy can reach Frigate directly on the Docker network. - # - # Setup: - # 1. Install the ntfy app (iOS App Store or Google Play — free) - # 2. In the app: Settings → Default server → http://YOUR_TAILSCALE_IP:8080 - # 3. Subscribe to the topic name you set in NTFY_TOPIC below + - "3000:3000" + + # ── ntfy — self-hosted push notifications ──────────────────────────────────── ntfy: image: binwiederhier/ntfy:latest container_name: ntfy @@ -130,19 +94,13 @@ services: volumes: - ./ntfy/cache:/var/cache/ntfy ports: - - "8080:80" # ntfy server (accessible via Tailscale) + - "8080:80" environment: - NTFY_CACHE_FILE: /var/cache/ntfy/cache.db - NTFY_ATTACHMENT_CACHE_DIR: /var/cache/ntfy/attachments - # Tells ntfy where it's accessible from — use your Tailscale IP here - # so notification attachments (snapshots) resolve correctly on your phone. - # Find your Tailscale IP with: tailscale ip -4 - NTFY_BASE_URL: "http://YOUR_TAILSCALE_IP:8080" # ⚠️ set after running tailscale-setup.sh - - # ── Push notification bridge ───────────────────────────────────────────────── - # Reads Frigate and Double-Take MQTT events → sends ntfy push notifications. - # Face recognized = you or wife → silent "arrived home" or no alert. - # Face unknown = urgent "Stranger at front door" alert. + NTFY_CACHE_FILE: /var/cache/ntfy/cache.db + NTFY_ATTACHMENT_CACHE_DIR: /var/cache/ntfy/attachments + NTFY_BASE_URL: "${NTFY_BASE_URL:-http://localhost:8080}" + + # ── Notification bridge ────────────────────────────────────────────────────── notifier: build: ./notifier container_name: frigate-notifier @@ -152,64 +110,28 @@ services: - ntfy environment: - MQTT_HOST: mosquitto - MQTT_PORT: "1883" - - # ntfy — points to the self-hosted container on the Docker network - NTFY_URL: "http://ntfy:80" - NTFY_TOPIC: "change-this-to-something-unique-abc123" # ⚠️ pick something hard to guess - - # Frigate's URL — used to build snapshot links in notifications - # After setting up Tailscale, change this to http://YOUR_TAILSCALE_IP:5000 - # so snapshot links in notifications open from anywhere on your phone. - FRIGATE_URL: "http://frigate:5000" + MQTT_HOST: mosquitto + MQTT_PORT: "1883" + NTFY_URL: "http://ntfy:80" + NTFY_TOPIC: "${NTFY_TOPIC:-frigate-alerts}" + FRIGATE_URL: "${FRIGATE_URL:-http://frigate:5000}" - # ── Non-person object alerts ──────────────────────────────────────────── - # "person" is handled by Double-Take (face recognition) below. - # Add objects here for Frigate-level alerts. - ALERT_OBJECTS: "car,package" + ALERT_OBJECTS: "car,package" + MIN_SCORE: "0.75" + REQUIRE_ZONE: "true" + ALERT_COOLDOWN: "120" - MIN_SCORE: "0.75" # min AI confidence for object alerts (0.0–1.0) - REQUIRE_ZONE: "true" # only alert if object is in a defined zone - ALERT_COOLDOWN: "120" # seconds before same camera+object can re-alert - - # ── Face recognition alerts ────────────────────────────────────────────── - # Requires Double-Take to be set up and trained first. USE_FACE_RECOGNITION: "true" - MIN_FACE_SCORE: "0.80" # min face recognition confidence (0.0–1.0) - - # ⚠️ Fill in names after training Double-Take. - # These people will get a low-priority "arrived home" notification - # instead of an urgent stranger alert. Use lowercase, comma-separated. - # Example: "david,sarah" - KNOWN_PEOPLE: "" - - # Set to "false" to disable "arrived home" notifications for family. - # Unknown strangers will always alert regardless of this setting. + MIN_FACE_SCORE: "0.80" + KNOWN_PEOPLE: "${KNOWN_PEOPLE:-}" FAMILY_ARRIVAL_ALERTS: "true" - # ── Scrypted — Apple HomeKit + Google Home camera bridge ───────────────────── - # Exposes your Frigate cameras natively in: - # - Apple Home app (live view, notifications, Siri, Apple Watch) - # - Google Home - # - # Setup (one-time): - # 1. Open https://localhost:10443 (accept the self-signed cert warning) - # 2. Create an account - # 3. Plugins → search "Frigate NVR" → Install - # 4. Configure Frigate NVR plugin: URL = http://HOST_LAN_IP:5000 - # 5. Plugins → search "HomeKit" → Install - # 6. Follow the QR code to add cameras in the Apple Home app - # - # Note: network_mode: host is required for HomeKit's Bonjour/mDNS discovery. - # This means Scrypted connects to Frigate via your host's LAN IP (not the - # Docker service name). Find your host IP with: hostname -I | awk '{print $1}' + # ── 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 # required for HomeKit mDNS discovery + network_mode: host volumes: - ./scrypted:/server/volume - # Scrypted web UI: https://localhost:10443 - # (or https://YOUR_TAILSCALE_IP:10443 from your phone) diff --git a/frigate/double-take/config.yml b/frigate/double-take/config.yml index bbed238..8346ce5 100644 --- a/frigate/double-take/config.yml +++ b/frigate/double-take/config.yml @@ -38,15 +38,14 @@ detect: unknown: save: true # save unrecognized faces — useful for training -# Add your camera names here. Must exactly match the camera names in -# Frigate's config/config.yml. +# Camera names must exactly match Frigate's config/config.yml. +# setup.sh keeps these in sync automatically. cameras: - # Example entries — uncomment and rename to match your Frigate cameras: - # front_door: - # zones: [] # empty list = check full frame - # # or restrict to specific zones: - # # zones: [driveway, front_porch] - # back_yard: - # zones: [] - # garage: - # zones: [] + front_door: + zones: [] + backyard: + zones: [] + front_yard: + zones: [] + side_yard: + zones: [] diff --git a/frigate/setup.sh b/frigate/setup.sh new file mode 100755 index 0000000..43892f7 --- /dev/null +++ b/frigate/setup.sh @@ -0,0 +1,539 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# Frigate NVR — one-time setup wizard +# +# 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=() +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 +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 + +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 +python3 - << PYEOF +import re + +with open('config/config.yml', 'r') as f: + content = f.read() + +cam_names = ${CAM_NAMES[@]@Q} +cam_count = $NUM_CAMS + +# Build new go2rtc streams section +streams = "go2rtc:\n streams:\n\n # Camera IPs come from .env — no editing needed.\n" +for i, name in enumerate(cam_names.split()): + name = name.strip("'") + streams += f" {name}:\n" + streams += f' - "rtsp://{{FRIGATE_RTSP_USER}}:{{FRIGATE_RTSP_PASSWORD}}@{{FRIGATE_CAM{i+1}_IP}}{{FRIGATE_RTSP_PATH}}"\n\n' + +# Replace go2rtc block (from "go2rtc:" to the next top-level section) +content = re.sub( + r'^go2rtc:.*?(?=^[a-z])', + streams + "\n", + content, + flags=re.MULTILINE | re.DOTALL +) + +# Build new cameras section +cam_section = "# ── Cameras ───────────────────────────────────────────────────────────────────\ncameras:\n\n" +for i, name in enumerate(cam_names.split()): + name = name.strip("'") + is_last = (i == cam_count - 1) + 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:\n" + cam_section += " - detect\n" + cam_section += " - record\n\n" + cam_section += " zones:\n" + if i == 0: + cam_section += " # Draw zones in the Frigate UI then paste coordinates here.\n" + cam_section += " # Example: driveway or front_porch zone\n" + cam_section += " # driveway:\n" + cam_section += " # coordinates: 0,1080,1920,1080,1920,400,0,400\n" + cam_section += " # objects: [person, car]\n" + else: + cam_section += " # yard:\n" + cam_section += " # coordinates: 0,1080,1920,1080,1920,0,0,0\n" + cam_section += " # objects: [person]\n" + if not is_last: + cam_section += "\n\n" + +# Replace cameras block +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 "" +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 "" From 9097995629a4192fba90e110eb6fb4ae5986d131 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 21:35:46 +0000 Subject: [PATCH 06/12] Add PTZ auto-tracking support for Wyze Pan Cam - setup.sh now asks per-camera whether it's pan/tilt - PTZ cameras get ONVIF + autotracking config blocks auto-generated (Wyze Pan Cam uses ONVIF port 2020, reuses RTSP credentials) - Frigate will physically rotate the camera to follow detected people - calibrate_on_startup: true automatically finds pan/tilt limits - Confirm screen shows "(pan/tilt + autotracking)" for PTZ cameras - Final summary prints Wyze conflict warning + home preset instructions - PTZ flags saved to .env for reference https://claude.ai/code/session_013Uou3hhxTzooTKyGfBkoLz --- frigate/setup.sh | 102 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 76 insertions(+), 26 deletions(-) diff --git a/frigate/setup.sh b/frigate/setup.sh index 43892f7..ccb413f 100755 --- a/frigate/setup.sh +++ b/frigate/setup.sh @@ -157,6 +157,8 @@ NUM_CAMS="${NUM_CAMS:-2}" 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") @@ -188,6 +190,16 @@ for (( i=0; i " 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 ────────────────────────────────────── @@ -273,7 +285,9 @@ echo " RTSP user: $RTSP_USER" echo " RTSP path: $RTSP_PATH" echo " Cameras:" for (( i=0; i> .env +echo "# ── PTZ auto-tracking ────────────────────────────────────────────────────────" >> .env +for (( i=0; i> .env +done + cat >> .env << EOF # ── Notifications ───────────────────────────────────────────────────────────── @@ -331,23 +352,29 @@ ok ".env written" 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 = ${CAM_NAMES[@]@Q} -cam_count = $NUM_CAMS +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 [] -# Build new go2rtc streams section +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.split()): - name = name.strip("'") +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' -# Replace go2rtc block (from "go2rtc:" to the next top-level section) content = re.sub( r'^go2rtc:.*?(?=^[a-z])', streams + "\n", @@ -355,34 +382,46 @@ content = re.sub( flags=re.MULTILINE | re.DOTALL ) -# Build new cameras section +# ── cameras section ────────────────────────────────────────────────────────── cam_section = "# ── Cameras ───────────────────────────────────────────────────────────────────\ncameras:\n\n" -for i, name in enumerate(cam_names.split()): - name = name.strip("'") - is_last = (i == cam_count - 1) + +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:\n" - cam_section += " - detect\n" - cam_section += " - record\n\n" - cam_section += " zones:\n" - if i == 0: - cam_section += " # Draw zones in the Frigate UI then paste coordinates here.\n" - cam_section += " # Example: driveway or front_porch zone\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" - else: - cam_section += " # yard:\n" - cam_section += " # coordinates: 0,1080,1920,1080,1920,0,0,0\n" - cam_section += " # objects: [person]\n" - if not is_last: - cam_section += "\n\n" + cam_section += " # objects: [person, car]\n\n\n" -# Replace cameras block content = re.sub( r'^# ── Cameras.*', cam_section, @@ -517,6 +556,17 @@ 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)" From 181227c95bfdfdab56a94a7af373271ed24e7e23 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 21:38:10 +0000 Subject: [PATCH 07/12] Add audio detection alerts (glass break, fire alarm, screaming, bark) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frigate listens to camera microphones and fires MQTT events on specific sounds. The notifier subscribes to frigate/+/audio/+ and routes them to ntfy. Supported and wired up: glass_breaking → urgent alert "Glass breaking" screaming → urgent alert "Screaming detected" fire_alarm → urgent alert "Fire alarm" smoke_detector_alarm → urgent alert "Smoke alarm" bark → default alert "Dog barking" speech, motorcycle → low-priority (can filter out in future) Note: gunshot detection is not in Frigate's built-in audio model. The default model covers the above list only. Two-way audio works via Scrypted + HomeKit (Apple Home app intercom). Frigate itself doesn't implement audio talk-back. https://claude.ai/code/session_013Uou3hhxTzooTKyGfBkoLz --- frigate/config/config.yml | 26 ++++++++++ frigate/notifier/notifier.py | 96 +++++++++++++++++++++++++++++------- 2 files changed, 105 insertions(+), 17 deletions(-) diff --git a/frigate/config/config.yml b/frigate/config/config.yml index d3be1f0..b303e09 100644 --- a/frigate/config/config.yml +++ b/frigate/config/config.yml @@ -164,6 +164,32 @@ cameras_default: &camera_defaults 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. diff --git a/frigate/notifier/notifier.py b/frigate/notifier/notifier.py index fdb9438..8e601cc 100644 --- a/frigate/notifier/notifier.py +++ b/frigate/notifier/notifier.py @@ -3,6 +3,7 @@ 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 @@ -52,25 +53,52 @@ # ── ntfy priority and tag mappings ──────────────────────────────────────────── PRIORITY = { - "stranger": "urgent", - "person": "high", - "package": "high", - "car": "default", - "family": "low", - "dog": "low", - "cat": "low", - "bird": "min", + "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", + "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 ───────────────────────────────────────────────────────── @@ -236,6 +264,36 @@ def handle_face_event(topic: str, payload: dict) -> None: _send(title, body, "stranger", camera, event_id) +# ── 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: @@ -244,8 +302,9 @@ def on_connect(client: mqtt.Client, userdata, flags, rc: int) -> None: return client.subscribe("frigate/events") + client.subscribe("frigate/+/audio/+") print(f"[mqtt] Connected to {MQTT_HOST}:{MQTT_PORT}") - print("[mqtt] Subscribed to: frigate/events") + print("[mqtt] Subscribed to: frigate/events, frigate/+/audio/+") if USE_FACE_RECOGNITION: client.subscribe("double-take/cameras/#") @@ -261,6 +320,8 @@ def on_message(client: mqtt.Client, userdata, msg: mqtt.MQTTMessage) -> None: 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) @@ -277,6 +338,7 @@ def main() -> None: 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())) print(f"[config] Cooldown: {COOLDOWN}s") print(f"[config] ntfy endpoint: {NTFY_URL}/{NTFY_TOPIC}") print("=" * 60) From 64350d8be0ce9e7eddb8c25ea58626c5ec2183eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 21:39:21 +0000 Subject: [PATCH 08/12] Extend ntfy notification cache from 12h to 7 days Default ntfy cache_duration is 12h, which means notifications disappear from history quickly. 168h (7 days) keeps a full week of alert history visible in the ntfy app and web UI. https://claude.ai/code/session_013Uou3hhxTzooTKyGfBkoLz --- frigate/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/frigate/docker-compose.yml b/frigate/docker-compose.yml index e2e14f3..85555ab 100644 --- a/frigate/docker-compose.yml +++ b/frigate/docker-compose.yml @@ -98,6 +98,7 @@ services: environment: NTFY_CACHE_FILE: /var/cache/ntfy/cache.db NTFY_ATTACHMENT_CACHE_DIR: /var/cache/ntfy/attachments + NTFY_CACHE_DURATION: "168h" # keep notification history for 7 days NTFY_BASE_URL: "${NTFY_BASE_URL:-http://localhost:8080}" # ── Notification bridge ────────────────────────────────────────────────────── From 681313daa48028d7545eec37e2b1eaaca2731e95 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 21:46:03 +0000 Subject: [PATCH 09/12] Add daily digest with ghost composite + quiet hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daily digest (new service: digest/): - Runs every morning at 8am (configurable via DIGEST_HOUR in .env) - Fetches all of yesterday's Frigate events via API - Sends an ntfy notification: "14 people · 3 cars · 1 package" - Attaches a ghost composite image: all person snapshots overlaid with transparency so every position shows at once (the "multiple people" effect) - Falls back to a thumbnail grid if fewer than 3 person detections - ntfy attachment expiry set to 72h so images are accessible for 3 days Quiet hours (notifier): - New QUIET_HOURS_START / QUIET_HOURS_END env vars (default: 10pm–7am) - During quiet hours: car, package, family arrival, bark alerts are suppressed - Strangers, glass breaking, screaming, fire/smoke alarms always fire - Set both to the same value to disable quiet hours entirely https://claude.ai/code/session_013Uou3hhxTzooTKyGfBkoLz --- frigate/digest/Dockerfile | 9 ++ frigate/digest/digest.py | 263 +++++++++++++++++++++++++++++++++++ frigate/docker-compose.yml | 31 ++++- frigate/notifier/notifier.py | 33 +++++ 4 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 frigate/digest/Dockerfile create mode 100644 frigate/digest/digest.py 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..761320b --- /dev/null +++ b/frigate/digest/digest.py @@ -0,0 +1,263 @@ +""" +Daily digest service — sends a morning summary notification. + +Every day at DIGEST_HOUR (default 8am), fetches yesterday's events from +Frigate and sends two notifications: + + 1. Text summary: "14 people · 3 cars · 1 package | 2 strangers" + 2. Ghost composite image: every person snapshot from the day overlaid on + top of each other — you can see every position a person was detected, + making it look like there are multiple people in the yard at once. + 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, ImageFont, 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 composite/grid + +# ── 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: + try: + resp = requests.get( + f"{FRIGATE_URL}/api/events/{event_id}/snapshot.jpg", + params={"bbox": 0, "crop": 0}, # full frame, no crop — needed for ghost effect + timeout=15, + ) + resp.raise_for_status() + return resp.content + except Exception: + return None + + +# ── Image generators ────────────────────────────────────────────────────────── + +def make_ghost_composite(snapshots: list[bytes]) -> bytes | None: + """ + Overlay every snapshot with transparency to create the ghost effect. + + Each frame is blended at low opacity so when you have 10 person + detections throughout the day, they all show up semi-transparently + in their positions — like a long-exposure photo of the yard. + """ + if not snapshots: + return None + + images = [] + for data in snapshots: + try: + images.append(Image.open(io.BytesIO(data)).convert("RGBA")) + except Exception: + continue + + if not images: + return None + + # Normalise size to the first image + w, h = images[0].size + images = [img.resize((w, h), Image.LANCZOS) for img in images] + + # Start from the first frame as the background + composite = images[0].copy() + + # Per-image alpha: fewer images → each one is more opaque so they're + # still visible; more images → lower alpha so they don't all wash out. + per_alpha = max(25, min(110, 180 // len(images))) + + for img in images[1:]: + r, g, b, a = img.split() + a = a.point(lambda x: int(x * per_alpha / 255)) + overlay = Image.merge("RGBA", (r, g, b, a)) + composite = Image.alpha_composite(composite, overlay) + + # Stamp a label in the corner so it's clear what this is + draw = ImageDraw.Draw(composite) + label = "All detections — yesterday" + draw.text((12, 12), label, fill=(255, 255, 255, 180)) + draw.text((11, 11), label, fill=(0, 0, 0, 120)) # shadow + + out = io.BytesIO() + composite.convert("RGB").save(out, format="JPEG", quality=85) + return out.getvalue() + + +def make_snapshot_grid(snapshots: list[bytes]) -> bytes | None: + """Grid of event thumbnails — used when there are only 1-2 detections.""" + 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: + # PUT with binary body uploads the image directly to ntfy's + # attachment cache so the phone can fetch it over Tailscale. + 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": + stranger_count += 1 # will subtract known below (best estimate) + + 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 ghost composite ──────────────────────────────────────────────── + person_events = [e for e in events if e.get("label") == "person"] + # Best detections first (most confident = cleanest snapshot) + person_events.sort(key=lambda e: e.get("top_score") or 0, reverse=True) + top = person_events[:MAX_SNAPSHOTS] + + print(f"[digest] {len(events)} events total, downloading {len(top)} person snapshots ...") + snapshots = [s for e in top if (s := fetch_snapshot(e["id"])) is not None] + print(f"[digest] Got {len(snapshots)} snapshots") + + if len(snapshots) >= 3: + image = make_ghost_composite(snapshots) + print("[digest] Ghost composite ready") + elif snapshots: + image = make_snapshot_grid(snapshots) + print("[digest] Snapshot grid ready (< 3 images, no ghost)") + 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 index 85555ab..ba76c13 100644 --- a/frigate/docker-compose.yml +++ b/frigate/docker-compose.yml @@ -96,10 +96,11 @@ services: ports: - "8080:80" environment: - NTFY_CACHE_FILE: /var/cache/ntfy/cache.db - NTFY_ATTACHMENT_CACHE_DIR: /var/cache/ntfy/attachments - NTFY_CACHE_DURATION: "168h" # keep notification history for 7 days - NTFY_BASE_URL: "${NTFY_BASE_URL:-http://localhost:8080}" + 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: @@ -127,6 +128,28 @@ services: 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 + + # ── 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" + # ── Scrypted — Apple HomeKit + Google Home ──────────────────────────────────── # Setup at https://localhost:10443 → install Frigate NVR + HomeKit plugins scrypted: diff --git a/frigate/notifier/notifier.py b/frigate/notifier/notifier.py index 8e601cc..02b75b0 100644 --- a/frigate/notifier/notifier.py +++ b/frigate/notifier/notifier.py @@ -11,6 +11,7 @@ All settings are environment variables configured in docker-compose.yml. """ +import datetime import json import os import time @@ -50,6 +51,28 @@ # 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 + # ── ntfy priority and tag mappings ──────────────────────────────────────────── PRIORITY = { @@ -176,6 +199,9 @@ def handle_frigate_event(payload: dict) -> None: 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): @@ -243,6 +269,9 @@ def handle_face_event(topic: str, payload: dict) -> None: 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}" @@ -339,6 +368,10 @@ def main() -> None: 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] Cooldown: {COOLDOWN}s") print(f"[config] ntfy endpoint: {NTFY_URL}/{NTFY_TOPIC}") print("=" * 60) From de2d436c9450cc8949b22d461d107e65a20b28ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 23:14:00 +0000 Subject: [PATCH 10/12] Add LPR, spatial awareness, fix twin composite for pan cameras MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit License plate recognition (new service: lpr/): - Listens for car events via MQTT; on event end, downloads the snapshot and posts to Plate Recognizer API (free tier: 2,500 reads/month) - Sends ntfy notification: "ABC-1234 · White Sedan · Ontario" + snapshot - Per-plate cooldown (default 1hr) prevents repeat alerts for parked cars - Appends every detected plate to /data/plates.log for later review - Gracefully disabled if PLATE_RECOGNIZER_TOKEN is not set in .env Spatial awareness (notifier): - Reads each event's bounding box height as a fraction of the frame - Classifies distance: "close" (>55%), "mid" (25-55%), "far" (<25%) - Proximity phrase appended to notification body: "Stranger — right at the camera" vs "Stranger — far edge of view" - Close detections bump priority one level (close stranger → max urgency) - Thresholds tunable via PROXIMITY_CLOSE / PROXIMITY_MID in .env Twin composite / pan cameras (digest): - Replace semi-transparent ghost with full-opacity twin effect: crops each person's bounding box region at 100% opacity and pastes onto a base frame — literally looks like multiple people in yard - Deduplicates positions that overlap (15% threshold) so twins spread out - Add TWIN_CAMERAS env var: only list static cameras in the composite (exclude PTZ/pan cameras whose background shifts between shots) https://claude.ai/code/session_013Uou3hhxTzooTKyGfBkoLz --- frigate/digest/digest.py | 181 ++++++++++++++++--------- frigate/docker-compose.yml | 37 +++++ frigate/lpr/Dockerfile | 9 ++ frigate/lpr/lpr.py | 255 +++++++++++++++++++++++++++++++++++ frigate/notifier/notifier.py | 87 ++++++++++-- 5 files changed, 497 insertions(+), 72 deletions(-) create mode 100644 frigate/lpr/Dockerfile create mode 100644 frigate/lpr/lpr.py diff --git a/frigate/digest/digest.py b/frigate/digest/digest.py index 761320b..8c8f181 100644 --- a/frigate/digest/digest.py +++ b/frigate/digest/digest.py @@ -2,12 +2,12 @@ Daily digest service — sends a morning summary notification. Every day at DIGEST_HOUR (default 8am), fetches yesterday's events from -Frigate and sends two notifications: +Frigate and sends a notification with: - 1. Text summary: "14 people · 3 cars · 1 package | 2 strangers" - 2. Ghost composite image: every person snapshot from the day overlaid on - top of each other — you can see every position a person was detected, - making it look like there are multiple people in the yard at once. + 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. @@ -19,7 +19,7 @@ from datetime import datetime, timedelta import requests -from PIL import Image, ImageDraw, ImageFont, ImageOps +from PIL import Image, ImageDraw, ImageOps # ── Settings ────────────────────────────────────────────────────────────────── @@ -27,7 +27,15 @@ 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 composite/grid +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 ─────────────────────────────────────────────────────────────── @@ -46,10 +54,11 @@ def get_events(after: float, before: float) -> list[dict]: 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}, # full frame, no crop — needed for ghost effect + params={"bbox": 0, "crop": 0}, timeout=15, ) resp.raise_for_status() @@ -60,57 +69,91 @@ def fetch_snapshot(event_id: str) -> bytes | None: # ── Image generators ────────────────────────────────────────────────────────── -def make_ghost_composite(snapshots: list[bytes]) -> bytes | None: +def make_twin_composite(events: list[dict], snapshots: list[bytes]) -> bytes | None: """ - Overlay every snapshot with transparency to create the ghost effect. - - Each frame is blended at low opacity so when you have 10 person - detections throughout the day, they all show up semi-transparently - in their positions — like a long-exposure photo of the yard. + 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. """ - if not snapshots: + pairs = [(e, s) for e, s in zip(events, snapshots) if s is not None] + if not pairs: return None - images = [] - for data in snapshots: - try: - images.append(Image.open(io.BytesIO(data)).convert("RGBA")) - except Exception: - continue - - if not images: + # Background = first snapshot + try: + base = Image.open(io.BytesIO(pairs[0][1])).convert("RGB") + except Exception: return None - # Normalise size to the first image - w, h = images[0].size - images = [img.resize((w, h), Image.LANCZOS) for img in images] + W, H = base.size + placed: list[tuple[int, int]] = [] # centers of already-pasted crops - # Start from the first frame as the background - composite = images[0].copy() + for event, snap_data in pairs[1:]: + box = event.get("box") or [] + if len(box) < 4: + continue # no bounding box — skip - # Per-image alpha: fewer images → each one is more opaque so they're - # still visible; more images → lower alpha so they don't all wash out. - per_alpha = max(25, min(110, 180 // len(images))) + 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)) - for img in images[1:]: - r, g, b, a = img.split() - a = a.point(lambda x: int(x * per_alpha / 255)) - overlay = Image.merge("RGBA", (r, g, b, a)) - composite = Image.alpha_composite(composite, overlay) + except Exception as exc: + print(f"[digest] Composite paste error: {exc}") - # Stamp a label in the corner so it's clear what this is - draw = ImageDraw.Draw(composite) - label = "All detections — yesterday" - draw.text((12, 12), label, fill=(255, 255, 255, 180)) - draw.text((11, 11), label, fill=(0, 0, 0, 120)) # shadow + # 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() - composite.convert("RGB").save(out, format="JPEG", quality=85) + base.save(out, format="JPEG", quality=90) return out.getvalue() def make_snapshot_grid(snapshots: list[bytes]) -> bytes | None: - """Grid of event thumbnails — used when there are only 1-2 detections.""" + """Thumbnail grid — fallback when there aren't enough unique positions.""" if not snapshots: return None @@ -152,8 +195,6 @@ def send_digest(title: str, body: str, image: bytes | None) -> None: } try: if image: - # PUT with binary body uploads the image directly to ntfy's - # attachment cache so the phone can fetch it over Tailscale. resp = requests.put( f"{NTFY_URL}/{NTFY_TOPIC}", data=image, @@ -201,8 +242,8 @@ def run_digest() -> None: for ev in events: label = ev.get("label", "unknown") counts[label] = counts.get(label, 0) + 1 - if label == "person": - stranger_count += 1 # will subtract known below (best estimate) + 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]): @@ -211,24 +252,38 @@ def run_digest() -> None: 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") + body_lines.append( + f" {stranger_count} unrecognized person{'s' if stranger_count > 1 else ''}" + " — check Frigate for clips" + ) - # ── Build ghost composite ──────────────────────────────────────────────── - person_events = [e for e in events if e.get("label") == "person"] - # Best detections first (most confident = cleanest snapshot) + # ── 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 = person_events[:MAX_SNAPSHOTS] - - print(f"[digest] {len(events)} events total, downloading {len(top)} person snapshots ...") - snapshots = [s for e in top if (s := fetch_snapshot(e["id"])) is not None] - print(f"[digest] Got {len(snapshots)} snapshots") - - if len(snapshots) >= 3: - image = make_ghost_composite(snapshots) - print("[digest] Ghost composite ready") - elif snapshots: - image = make_snapshot_grid(snapshots) - print("[digest] Snapshot grid ready (< 3 images, no ghost)") + 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") diff --git a/frigate/docker-compose.yml b/frigate/docker-compose.yml index ba76c13..bee1ef0 100644 --- a/frigate/docker-compose.yml +++ b/frigate/docker-compose.yml @@ -132,6 +132,11 @@ services: 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. @@ -149,6 +154,35 @@ services: 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 # ── Scrypted — Apple HomeKit + Google Home ──────────────────────────────────── # Setup at https://localhost:10443 → install Frigate NVR + HomeKit plugins @@ -159,3 +193,6 @@ services: network_mode: host volumes: - ./scrypted:/server/volume + +volumes: + lpr-data: 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/notifier/notifier.py b/frigate/notifier/notifier.py index 02b75b0..9d74275 100644 --- a/frigate/notifier/notifier.py +++ b/frigate/notifier/notifier.py @@ -73,6 +73,59 @@ def _in_quiet_hours() -> bool: 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 = { @@ -145,11 +198,12 @@ def _send( 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.get(kind, "default"), + "Priority": priority_override or PRIORITY.get(kind, "default"), "Tags": TAGS.get(kind, "bell"), "Click": f"{FRIGATE_URL}/events?camera={camera}", } @@ -185,6 +239,7 @@ def handle_frigate_event(payload: dict) -> None: 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": @@ -217,17 +272,22 @@ def handle_frigate_event(payload: dict) -> None: 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}" + body = f"Package spotted{zone_str}{prox_str}" elif label == "car": title = f"Vehicle at {camera_name}" - body = f"Vehicle detected{zone_str} ({score:.0%} confidence)" + body = f"Vehicle detected{zone_str}{prox_str} ({score:.0%})" else: title = f"{label_name} detected — {camera_name}" - body = f"{label_name}{zone_str} ({score:.0%} confidence)" + body = f"{label_name}{zone_str}{prox_str} ({score:.0%})" - _send(title, body, label, camera, event_id) + _send(title, body, label, camera, event_id, priority_override=effective_priority) # ── Double-Take face recognition event handler ──────────────────────────────── @@ -243,6 +303,7 @@ def handle_face_event(topic: str, payload: dict) -> None: 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 = ( @@ -250,6 +311,10 @@ def handle_face_event(topic: str, payload: dict) -> None: 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 @@ -275,7 +340,7 @@ def handle_face_event(topic: str, payload: dict) -> None: name_display = name.title() title = f"{name_display} is home — {camera_name}" - body = f"{name_display} arrived{zone_str} ({confidence:.0%})" + body = f"{name_display} arrived{zone_str}{prox_str} ({confidence:.0%})" print(f"[known] {title}") _send(title, body, "family", camera, event_id) @@ -287,10 +352,13 @@ def handle_face_event(topic: str, payload: dict) -> None: 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 detected{zone_str} — check camera" - print(f"[alert] {title}") - _send(title, body, "stranger", camera, event_id) + 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 ─────────────────────────────────────────────────────── @@ -372,6 +440,7 @@ def main() -> None: 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) From 28342f29db1a795fa453a6189c613a43829f67a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 23:16:40 +0000 Subject: [PATCH 11/12] Add wildlife species identification service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New service (species/): - Subscribes to Frigate MQTT events for configurable animal labels (bird, cat, dog, deer, bear, raccoon, fox, squirrel by default) - On event end, downloads the cropped animal snapshot from Frigate - Sends to iNaturalist computer vision API for species identification - The iNat model is trained on tens of millions of wildlife photos and covers birds, mammals, reptiles, insects, amphibians — all of nature - Sends ntfy notification: "Northern Cardinal at Backyard" with the snapshot attached and scientific name in the body - 30-minute per-species cooldown so you're not spammed when the same bird hangs out at the feeder all morning - Logs every sighting to /data/sightings.log with timestamp + confidence - Gracefully disabled if INAT_TOKEN is not set in .env Optional accuracy improvement: set INAT_LAT + INAT_LNG in .env so the model can use local species range data to pick between similar-looking birds/animals (e.g. Cooper's Hawk vs Sharp-shinned Hawk) https://claude.ai/code/session_013Uou3hhxTzooTKyGfBkoLz --- frigate/docker-compose.yml | 34 ++++ frigate/species/Dockerfile | 9 ++ frigate/species/species.py | 318 +++++++++++++++++++++++++++++++++++++ 3 files changed, 361 insertions(+) create mode 100644 frigate/species/Dockerfile create mode 100644 frigate/species/species.py diff --git a/frigate/docker-compose.yml b/frigate/docker-compose.yml index bee1ef0..b1474d6 100644 --- a/frigate/docker-compose.yml +++ b/frigate/docker-compose.yml @@ -184,6 +184,39 @@ services: 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: @@ -196,3 +229,4 @@ services: volumes: lpr-data: + species-data: 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() From 8e58b9f7111813dbd3bea92a2febd33cd4cb5e7f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 00:24:05 +0000 Subject: [PATCH 12/12] Add Windows instructions to setup.sh header Explains that "bash is not recognized" on Windows means bash isn't in PATH, and gives two options: Git Bash (quickest) or WSL (recommended). https://claude.ai/code/session_013Uou3hhxTzooTKyGfBkoLz --- frigate/setup.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frigate/setup.sh b/frigate/setup.sh index ccb413f..1715e03 100755 --- a/frigate/setup.sh +++ b/frigate/setup.sh @@ -2,6 +2,19 @@ # ───────────────────────────────────────────────────────────────────────────── # 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 #