Better Late Than Never ![]()
Well, I finally got around to implementing this… 3 months later. Why the delay? I was waiting for my Raspberry Pi 5 with Hailo-8 AI accelerator to arrive and get properly set up!
But now it’s running beautifully, and I wanted to share my complete implementation.
My Setup
Hardware:
- Raspberry Pi 5 (16GB) with Hailo-8 AI accelerator (running Frigate NVR)
- 8x IP Cameras (Hikvision, H.265 streams)
- QNAP NAS for snapshot storage (rolling 50 snapshots per camera)
- Raspberry Pi 5 (8GB) running openHAB 5.0.3
Software Stack:
- Frigate NVR (running on the Pi 5 with Hailo-8)
- MQTT (Mosquitto) for Frigate ↔ openHAB communication
- openHAB 5.0.3 (on separate Pi 5 8GB)
- Frigate Binding for openHAB (provides event JSON, clip URLs, etc.)
- Telegram for direct photo/video delivery
- openHAB Cloud for push notifications with images
The Example: Front Door Camera Rule
This is my most complex camera rule because it handles three different scenarios:
- Motion Detection → Person/car detected → Snapshot with bounding box
- Cat Doorbell → Cat enters specific zone → “Let me in!” notification with snapshot
- Physical Doorbell → Button pressed → Latest frame (even if no motion event active)
I’ll share the complete rule file because it demonstrates:
- Event-based triggering (JSON from Frigate Binding)
- Snapshot download with retry logic + thumbnail fallback
- Latest frame download (for doorbell without motion)
- NAS storage + Image Item buffer (works with openHAB Cloud)
- Direct openHAB Cloud notifications with media attachments
- Telegram photo delivery
- Zone-based detection (cat doorbell)
- Error isolation and timeout protection
Key Concepts
1. Why Hybrid Storage (NAS + Image Items)?
The Problem:
- Direct URLs to Frigate snapshots don’t work with openHAB Cloud
- Storing Base64 in Items works but is memory-intensive
The Solution:
- Download snapshot from Frigate
- Save to NAS (permanent storage, last 50 per camera)
- ALSO save to Image Item (5-buffer round-robin for notifications)
- Image Item works with openHAB Cloud (Base64 sync)
2. Round-Robin Buffer (5 Image Items)
Instead of one Image Item per camera, I use 5 per camera:
Frigate_Snapshot_FrontDoor_1
Frigate_Snapshot_FrontDoor_2
Frigate_Snapshot_FrontDoor_3
Frigate_Snapshot_FrontDoor_4
Frigate_Snapshot_FrontDoor_5
Why?
- Multiple events within seconds don’t overwrite each other
- Each notification gets its own image (no race conditions)
- Buffer rotates: 1 → 2 → 3 → 4 → 5 → 1
3. Latest Frame vs Event Snapshot
Event Snapshot:
- URL:
http://frigate:5000/api/events/{eventId}/snapshot.jpg - Pros: Has bounding box, tied to detection
- Cons: May not exist immediately, disappears after event ends
Latest Frame:
- URL:
http://frigate:5000/api/{camera}/latest.jpg - Pros: ALWAYS available, current frame
- Cons: No bounding box, no object detection
When to use what:
- Motion detection → Event Snapshot (with retry + thumbnail fallback)
- Doorbell button → Latest Frame first (more current), Event Snapshot as fallback
4. Telegram Photos vs Telegram Videos
My setup sends:
- Photos for snapshots (via
sendTelegramPhoto()using Base64 from Image Item) - Videos for clips (via
sendTelegramVideo()using Frigate clip URL)
This gives immediate visual feedback in Telegram alongside the openHAB Cloud push notification.
The Complete Rule File
File: automation/js/frigate_frontdoor.js
Items Required
// Frigate Binding Items (provided by binding)
String frigate_cam_frontdoor_Event_JSON "Front Door Event JSON" { channel="mqtt:frigateCamera:..." }
String frigate_cam_frontdoor_Current_Event_ID "Front Door Event ID" { channel="mqtt:frigateCamera:..." }
DateTime frigate_cam_frontdoor_Current_Start_time "Front Door Start Time" { channel="mqtt:frigateCamera:..." }
String frigate_cam_frontdoor_URL_for_current_event_clip "Front Door Clip URL" { channel="mqtt:frigateCamera:..." }
// Image Item Buffer (5 items for round-robin)
String Frigate_Snapshot_FrontDoor_1 "Front Door Snapshot 1"
String Frigate_Snapshot_FrontDoor_2 "Front Door Snapshot 2"
String Frigate_Snapshot_FrontDoor_3 "Front Door Snapshot 3"
String Frigate_Snapshot_FrontDoor_4 "Front Door Snapshot 4"
String Frigate_Snapshot_FrontDoor_5 "Front Door Snapshot 5"
// Control Items
Switch Advanced_Logging "Advanced Logging"
Switch Camera_FrontDoor_AutoRecord "Front Door Auto Record"
Switch Doorbell_Virtual "Virtual Doorbell Button" { expire="2s,command=OFF" }
Switch CatDoorbell_FrontDoor_Enabled "Cat Doorbell Enabled"
Contact DoorContact_FrontDoor_State "Front Door Contact"
JavaScript Rule
const { rules, items, triggers, actions } = require('openhab');
// ============================================================
// TRACKING VARIABLES
// ============================================================
let entrance_door_timer = null;
let processed_snapshots = new Set();
let last_snapshot_timestamp = 0;
let last_catbell_time = 0;
let cleanup_timer_snapshots = null;
let current_buffer = 1; // Round-robin: 1 → 2 → 3 → 4 → 5
const SNAPSHOT_COOLDOWN_MS = 5000; // 5 seconds between snapshots
const SNAPSHOT_CACHE_CLEANUP_MS = 120000; // 2 minutes
const CATBELL_COOLDOWN_MS = 60000; // 60 seconds between cat doorbell notifications
const MAX_CACHE_SIZE = 50;
const RECIPIENT_TIMEOUT_MS = 15000; // 15 seconds per Telegram recipient
// ============================================================
// HELPER FUNCTIONS
// ============================================================
// Logger with Advanced Logging support
const log = (message, level = 'info') => {
try {
const advancedLogging = items.getItem('Advanced_Logging')?.state === 'ON';
if (advancedLogging || level === 'error' || level === 'warn') {
const prefix = 'FRIGATE_FRONTDOOR';
switch(level) {
case 'debug':
if (advancedLogging) console.debug(`[${prefix}] ${message}`);
break;
case 'warn':
console.warn(`[${prefix}] ${message}`);
break;
case 'error':
console.error(`[${prefix}] ${message}`);
break;
default:
if (advancedLogging) console.info(`[${prefix}] ${message}`);
}
}
} catch (e) {
console.error(`[FRIGATE_FRONTDOOR] Logger error: ${e}`);
}
};
// Send notification via openHAB Cloud
// IMPORTANT: Replace 'user1@example.com', 'user2@example.com' with your actual email addresses
// registered with openHAB Cloud (myopenhab.org)
const sendNotification = (message, options = {}) => {
try {
const {
title = "Home Security",
tag = "Camera Motion",
icon = null,
mediaUrl = null,
id = `camera-${Date.now()}`,
recipients = [] // Array of email addresses
} = options;
if (recipients.length === 0) {
log(`No recipients specified for notification`, 'warn');
return;
}
// Handle media attachments (send individually per recipient)
if (mediaUrl) {
recipients.forEach((email, index) => {
try {
const uniqueId = `${id}-${index}`;
const builder = actions.notificationBuilder(message || " ")
.withTitle(title)
.withTag(tag)
.withReferenceId(uniqueId)
.withMediaAttachmentUrl(mediaUrl)
.addUserId(email);
if (icon) {
builder.withIcon(icon);
}
builder.send();
log(`📧 Notification with media sent to: ${email}`, 'debug');
} catch (error) {
log(`Error sending notification to ${email}: ${error}`, 'error');
}
});
} else {
// Standard notification (batch send)
const builder = actions.notificationBuilder(message)
.withTitle(title)
.withTag(tag)
.withReferenceId(id);
if (icon) {
builder.withIcon(icon);
}
recipients.forEach(email => builder.addUserId(email));
builder.send();
log(`📧 Notification sent to ${recipients.length} recipients`, 'debug');
}
} catch (error) {
log(`Error sending notification: ${error}`, 'error');
}
};
// Format timestamp for notifications
const formatTimestamp = (itemOrString) => {
try {
if (!itemOrString || itemOrString === 'NULL' || itemOrString === 'UNDEF') {
return null;
}
let timestampString;
if (typeof itemOrString === 'object' && itemOrString.toString) {
timestampString = itemOrString.toString();
} else {
timestampString = itemOrString;
}
if (!timestampString || timestampString === 'NULL' || timestampString === 'UNDEF') {
return null;
}
let cleanString = timestampString.replace(/\+.*$/, '').replace('T', ' ');
const parts = cleanString.split(' ');
if (parts.length !== 2) return null;
const dateParts = parts[0].split('-');
const timeParts = parts[1].split(':');
if (dateParts.length !== 3 || timeParts.length < 2) return null;
const year = parseInt(dateParts[0]);
const month = parseInt(dateParts[1]) - 1;
const day = parseInt(dateParts[2]);
const hour = timeParts[0];
const minute = timeParts[1];
const date = new Date(year, month, day);
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const weekday = weekdays[date.getDay()];
return `${weekday} ${day.toString().padStart(2, '0')}.${(month + 1).toString().padStart(2, '0')}.${year} at ${hour}:${minute}`;
} catch (e) {
log(`Error formatting timestamp: ${e}`, 'error');
return null;
}
};
// ============================================================
// FEATURE 1: Event Snapshot Download → NAS + Image Item
// ============================================================
const downloadSnapshotToBuffer = async (eventId) => {
const snapshotUrl = `http://192.168.1.100:5000/api/events/${eventId}/snapshot.jpg?bbox=1`;
const thumbnailUrl = `http://192.168.1.100:5000/api/events/${eventId}/thumbnail.jpg`;
const nasPath = '/mnt/nas/snapshots/frontdoor';
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 1000;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
let finalUrl = snapshotUrl;
let usedThumbnail = false;
// ============================================
// HTTP CHECK: Is snapshot ready?
// ============================================
try {
const http = Java.type('java.net.http.HttpClient');
const request = Java.type('java.net.http.HttpRequest');
const uri = Java.type('java.net.URI');
const client = http.newHttpClient();
const testReq = request.newBuilder()
.uri(uri.create(snapshotUrl))
.timeout(Java.type('java.time.Duration').ofSeconds(5))
.build();
const testResponse = client.send(testReq, Java.type('java.net.http.HttpResponse$BodyHandlers').ofString());
const body = testResponse.body();
if (body.includes('"success":false') || body.includes('Live frame not available')) {
log(`⚠️ Snapshot attempt ${attempt}/${MAX_RETRIES} failed - not ready yet`, 'debug');
if (attempt < MAX_RETRIES) {
log(`⏳ Waiting ${RETRY_DELAY_MS / 1000}s before retry...`, 'debug');
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
continue;
} else {
log(`⚠️ Snapshot not available after ${MAX_RETRIES} attempts - using thumbnail`, 'warn');
finalUrl = thumbnailUrl;
usedThumbnail = true;
}
}
} catch (e) {
log(`⚠️ Snapshot check failed (attempt ${attempt}/${MAX_RETRIES}): ${e}`, 'warn');
if (attempt < MAX_RETRIES) {
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
continue;
} else {
log(`⚠️ Using thumbnail after ${MAX_RETRIES} attempts`, 'warn');
finalUrl = thumbnailUrl;
usedThumbnail = true;
}
}
// ============================================
// DOWNLOAD: Get image from Frigate
// ============================================
const http = Java.type('java.net.http.HttpClient');
const request = Java.type('java.net.http.HttpRequest');
const uri = Java.type('java.net.URI');
const bodyHandler = Java.type('java.net.http.HttpResponse$BodyHandlers');
const client = http.newHttpClient();
const downloadReq = request.newBuilder()
.uri(uri.create(finalUrl))
.timeout(Java.type('java.time.Duration').ofSeconds(10))
.build();
const response = client.send(downloadReq, bodyHandler.ofByteArray());
const imageBytes = response.body();
if (!imageBytes || imageBytes.length === 0) {
throw new Error('Empty image received');
}
// ============================================
// NAS STORAGE: For history
// ============================================
try {
const timestamp = Date.now();
const filename = `${timestamp}-${eventId}.jpg`;
const filepath = `${nasPath}/${filename}`;
const Files = Java.type('java.nio.file.Files');
const Paths = Java.type('java.nio.file.Paths');
const path = Paths.get(filepath);
Files.write(path, imageBytes);
log(`💾 NAS: ${filename} (${Math.round(imageBytes.length / 1024)}KB)`, 'debug');
// Cleanup: Keep only last 50 files
const File = Java.type('java.io.File');
const nasDir = new File(nasPath);
const files = nasDir.listFiles();
if (files && files.length > 50) {
const fileList = Array.from(files).sort((a, b) => a.lastModified() - b.lastModified());
const toDelete = fileList.slice(0, fileList.length - 50);
toDelete.forEach(file => file.delete());
log(`🧹 NAS cleanup: ${toDelete.length} old files deleted (keeping 50 newest)`, 'debug');
}
} catch (nasError) {
log(`⚠️ NAS storage failed: ${nasError}`, 'warn');
// Not critical - continue with Image Item
}
// ============================================
// IMAGE ITEM: For notification (Round-Robin Buffer)
// ============================================
const base64 = Java.type('java.util.Base64').getEncoder().encodeToString(imageBytes);
const base64String = `data:image/jpeg;base64,${base64}`;
const imageItemName = `Frigate_Snapshot_FrontDoor_${current_buffer}`;
items.getItem(imageItemName).sendCommand(base64String);
// Rotate buffer: 1 → 2 → 3 → 4 → 5 → 1
const usedBuffer = current_buffer;
current_buffer = (current_buffer % 5) + 1;
if (attempt > 1) {
log(`✅ Snapshot ${usedThumbnail ? '(Thumbnail)' : ''} after ${attempt} attempts → Buffer ${usedBuffer} (${Math.round(imageBytes.length / 1024)}KB)`, 'info');
} else {
log(`✅ Snapshot ${usedThumbnail ? '(Thumbnail)' : ''} → Buffer ${usedBuffer} (${Math.round(imageBytes.length / 1024)}KB)`, 'debug');
}
return imageItemName; // Return Item name for notification
} catch (error) {
log(`❌ Download attempt ${attempt}/${MAX_RETRIES} failed: ${error}`, 'warn');
if (attempt < MAX_RETRIES) {
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
} else {
log(`❌ Snapshot download failed after ${MAX_RETRIES} attempts`, 'error');
return null;
}
}
}
return null;
};
// ============================================================
// FEATURE 2: Latest Frame Download (without Event ID)
// ============================================================
// Use case: Physical doorbell button pressed, but no motion event active
// Gets the most recent frame from the camera
const downloadLatestFrame = async (cameraName = 'frontdoor') => {
const latestFrameUrl = `http://192.168.1.100:5000/api/${cameraName}/latest.jpg`;
const nasPath = '/mnt/nas/snapshots/frontdoor';
try {
log(`📸 Fetching latest frame for ${cameraName}...`, 'debug');
const http = Java.type('java.net.http.HttpClient');
const request = Java.type('java.net.http.HttpRequest');
const uri = Java.type('java.net.URI');
const bodyHandler = Java.type('java.net.http.HttpResponse$BodyHandlers');
const client = http.newHttpClient();
const downloadReq = request.newBuilder()
.uri(uri.create(latestFrameUrl))
.timeout(Java.type('java.time.Duration').ofSeconds(10))
.build();
const response = client.send(downloadReq, bodyHandler.ofByteArray());
const imageBytes = response.body();
if (!imageBytes || imageBytes.length === 0) {
throw new Error('Empty image received');
}
// NAS storage (same as event snapshots)
try {
const timestamp = Date.now();
const filename = `${timestamp}-latest-frame.jpg`;
const filepath = `${nasPath}/${filename}`;
const Files = Java.type('java.nio.file.Files');
const Paths = Java.type('java.nio.file.Paths');
const path = Paths.get(filepath);
Files.write(path, imageBytes);
log(`💾 NAS: ${filename} (${Math.round(imageBytes.length / 1024)}KB)`, 'debug');
// Cleanup
const File = Java.type('java.io.File');
const nasDir = new File(nasPath);
const files = nasDir.listFiles();
if (files && files.length > 50) {
const fileList = Array.from(files).sort((a, b) => a.lastModified() - b.lastModified());
const toDelete = fileList.slice(0, fileList.length - 50);
toDelete.forEach(file => file.delete());
log(`🧹 NAS cleanup: ${toDelete.length} old files deleted`, 'debug');
}
} catch (nasError) {
log(`⚠️ NAS storage failed: ${nasError}`, 'warn');
}
// Image Item (round-robin)
const base64 = Java.type('java.util.Base64').getEncoder().encodeToString(imageBytes);
const base64String = `data:image/jpeg;base64,${base64}`;
const imageItemName = `Frigate_Snapshot_FrontDoor_${current_buffer}`;
items.getItem(imageItemName).sendCommand(base64String);
const usedBuffer = current_buffer;
current_buffer = (current_buffer % 5) + 1;
log(`✅ Latest Frame → Buffer ${usedBuffer} (${Math.round(imageBytes.length / 1024)}KB)`, 'info');
return imageItemName;
} catch (error) {
log(`❌ Latest frame download failed: ${error}`, 'error');
return null;
}
};
// ============================================================
// FEATURE 3: Telegram Photo Delivery
// ============================================================
// Sends Base64 image from Image Item directly to Telegram
// Works alongside openHAB Cloud notifications
const sendTelegramPhoto = async (imageItemName, caption) => {
try {
if (!imageItemName) {
log(`⚠️ No Image Item for Telegram photo`, 'warn');
return false;
}
let sentCount = 0;
let failedCount = 0;
// Get Base64 data from Image Item
const imageData = items.getItem(imageItemName).rawState.toFullString();
// Send to all Telegram recipients with error isolation
const telegramRecipients = [
{ name: 'User1', action: actions.get("telegram", "telegram:telegramBot:user1") },
{ name: 'User2', action: actions.get("telegram", "telegram:telegramBot:user2") }
];
for (const recipient of telegramRecipients) {
if (recipient.action) {
try {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), RECIPIENT_TIMEOUT_MS)
);
const sendPromise = new Promise((resolve) => {
recipient.action.sendTelegramPhoto(imageData, caption);
resolve(true);
});
await Promise.race([sendPromise, timeoutPromise]);
sentCount++;
log(`✅ Telegram photo sent to ${recipient.name}`, 'debug');
} catch (e) {
failedCount++;
log(`⚠️ Telegram photo to ${recipient.name} failed: ${e.message}`, 'warn');
}
} else {
log(`⚠️ Telegram action '${recipient.name}' not available`, 'warn');
}
}
if (sentCount > 0) {
log(`✅ Telegram photo sent to ${sentCount} recipients: ${caption}`, 'info');
if (failedCount > 0) {
log(`⚠️ ${failedCount} recipients failed`, 'warn');
}
return true;
} else {
log(`❌ Telegram photo could not be sent to ANY recipient`, 'error');
return false;
}
} catch (error) {
log(`Error sending Telegram photo: ${error}`, 'error');
return false;
}
};
// Condition check for front door notifications
const checkFrontDoorConditions = () => {
const doorOpen = items.getItem('DoorContact_FrontDoor_State')?.state === 'OPEN';
const timerActive = entrance_door_timer !== null;
const camAuto = items.getItem('Camera_FrontDoor_AutoRecord')?.state === 'ON';
if (timerActive) {
return { allowed: false, reason: '30s cooldown after door state change' };
}
if (doorOpen) {
return { allowed: false, reason: 'Door is open' };
}
if (!camAuto) {
return { allowed: false, reason: 'Auto record is OFF' };
}
return { allowed: true };
};
// ============================================================
// RULE 1: Door State Timer
// ============================================================
// Blocks notifications for 30s after door state changes
rules.JSRule({
name: "Frigate_FrontDoor-01-Timer",
description: "30-second timer after door state change (blocks notifications)",
triggers: [
triggers.ItemStateChangeTrigger('DoorContact_FrontDoor_State')
],
execute: () => {
try {
if (entrance_door_timer) {
clearTimeout(entrance_door_timer);
}
entrance_door_timer = setTimeout(() => {
entrance_door_timer = null;
log("Front door timer expired (30s)", 'debug');
}, 30000);
log("🚪 Door state changed - 30s cooldown started (blocks notifications)", 'info');
} catch (error) {
log(`Error in door timer: ${error}`, 'error');
}
}
});
// ============================================================
// RULE 2: Motion Detection + Cat Doorbell
// ============================================================
// Handles both normal motion detection AND zone-based cat doorbell
rules.JSRule({
name: "Frigate_FrontDoor-02-Snapshot",
description: "Snapshot for motion + zone-based cat doorbell + Telegram photos",
triggers: [
triggers.ItemStateChangeTrigger('frigate_cam_frontdoor_Event_JSON')
],
execute: async () => {
try {
// Parse Event JSON
const eventJsonString = items.getItem('frigate_cam_frontdoor_Event_JSON')?.state?.toString();
if (!eventJsonString || eventJsonString === 'NULL' || eventJsonString === 'UNDEF') {
log("No event JSON available", 'debug');
return;
}
let event;
try {
event = JSON.parse(eventJsonString);
} catch (e) {
log(`JSON parse error: ${e}`, 'error');
return;
}
const eventId = event.after?.id;
const label = event.after?.label;
const eventType = event.type;
const zones = event.after?.current_zones || [];
if (!eventId || !label) {
log("Incomplete event (no ID or label)", 'debug');
return;
}
log(`🔍 JSON-EVENT ${eventId} | Type: ${eventType} | Label: ${label} | Zones: [${zones.join(', ')}]`, 'info');
// ============================================
// BRANCHING: Cat or normal motion?
// ============================================
const isCat = label?.toLowerCase().includes('cat');
if (isCat) {
// ========================================
// CAT DOORBELL LOGIC (Zone-based!)
// ========================================
const beforeZones = event.before?.current_zones || [];
const afterZones = event.after?.current_zones || [];
// Trigger only when cat ENTERS the zone
const enteredCatZone =
!beforeZones.includes("cat_doorbell") &&
afterZones.includes("cat_doorbell");
if (!enteredCatZone) {
log(`🐈 Cat detected, but not in zone 'cat_doorbell' (Event ${eventId})`, 'debug');
return;
}
log(`🐈 Cat entered zone 'cat_doorbell' (Event ${eventId})`, 'info');
// 60s cooldown for cat doorbell
const now = Date.now();
if (now - last_catbell_time < CATBELL_COOLDOWN_MS) {
const secondsSince = Math.round((now - last_catbell_time) / 1000);
log(`🐈 Cat doorbell ignored (cooldown: ${secondsSince}s since last notification)`, 'debug');
return;
}
// Check all cat doorbell conditions
const timer_active = entrance_door_timer !== null;
const door_closed = items.getItem('DoorContact_FrontDoor_State')?.state === 'CLOSED';
const catbell_enabled = items.getItem('CatDoorbell_FrontDoor_Enabled')?.state === 'ON';
if (timer_active) {
log(`🐈 Cat doorbell ignored: 30s cooldown active`, 'debug');
return;
}
if (!door_closed) {
log(`🐈 Cat doorbell ignored: Door open`, 'debug');
return;
}
if (!catbell_enabled) {
log(`🐈 Cat doorbell ignored: Feature disabled`, 'debug');
return;
}
// ALL CONDITIONS MET - Remember timestamp & send notification
last_catbell_time = now;
log(`🐈⬛ CAT DOORBELL! Cat in zone (${eventId})`, 'info');
// Wait a bit
await new Promise(resolve => setTimeout(resolve, 700));
// Download Snapshot → NAS + Image Item
const imageItemName = await downloadSnapshotToBuffer(eventId);
if (!imageItemName) {
log(`⚠️ Cat doorbell: Snapshot download failed (Event ${eventId})`, 'warn');
// Send cat doorbell notification ALWAYS, even without image!
}
// Short pause for Item update & Cloud sync
if (imageItemName) {
await new Promise(resolve => setTimeout(resolve, 300));
}
const nowDate = new Date();
const timestamp = nowDate.toISOString().replace(/[-:T]/g, '').split('.')[0];
const notifTimestamp = `${nowDate.getHours().toString().padStart(2, '0')}:${nowDate.getMinutes().toString().padStart(2, '0')}:${nowDate.getSeconds().toString().padStart(2, '0')}`;
sendNotification(
`Please let me in! ${notifTimestamp}`,
{
title: "🐈⬛ Cat Doorbell",
tag: "Cat Doorbell",
icon: "motion",
mediaUrl: imageItemName,
id: `catbell-${timestamp}`,
recipients: ['user1@example.com', 'user2@example.com'] // Your email addresses
}
);
log(`🐈⬛ Cat doorbell notification sent (${eventId})`, 'info');
// Telegram photo
if (imageItemName) {
await sendTelegramPhoto(imageItemName, `🐈⬛ Cat at front door`);
}
return; // DONE - no regular motion snapshot
}
// ========================================
// NORMAL MOTION (non-cat objects)
// ========================================
const beforeHadSnapshot = event.before?.has_snapshot === true;
const afterHasSnapshot = event.after?.has_snapshot === true;
// Trigger when snapshot becomes available
const snapshotAvailable =
(eventType === "new" && afterHasSnapshot) ||
(!beforeHadSnapshot && afterHasSnapshot);
if (!snapshotAvailable) {
log(`Snapshot not available (Event ${eventId}, Type: ${eventType})`, 'debug');
return;
}
log(`✅ Snapshot available (Event ${eventId}, Type: ${eventType})`, 'debug');
// Deduplication
if (processed_snapshots.has(eventId)) {
log(`Snapshot for event ${eventId} already processed`, 'debug');
return;
}
// Cooldown
const now = Date.now();
if (now - last_snapshot_timestamp < SNAPSHOT_COOLDOWN_MS) {
const elapsed = Math.round((now - last_snapshot_timestamp) / 1000);
log(`Snapshot ignored (cooldown active: ${elapsed}s)`, 'debug');
return;
}
// Conditions
const conditions = checkFrontDoorConditions();
if (!conditions.allowed) {
log(`Event ${eventId} ignored (${conditions.reason})`, 'debug');
return;
}
// Memory leak prevention
if (processed_snapshots.size >= MAX_CACHE_SIZE) {
const cacheArray = Array.from(processed_snapshots);
const toDelete = cacheArray.slice(0, 10);
toDelete.forEach(item => processed_snapshots.delete(item));
log(`⚠️ Snapshot cache full (${MAX_CACHE_SIZE}) - deleting 10 oldest entries`, 'warn');
}
processed_snapshots.add(eventId);
last_snapshot_timestamp = now;
if (cleanup_timer_snapshots) {
clearTimeout(cleanup_timer_snapshots);
cleanup_timer_snapshots = null;
}
cleanup_timer_snapshots = setTimeout(() => {
const oldSize = processed_snapshots.size;
processed_snapshots.clear();
cleanup_timer_snapshots = null;
log(`🧹 Snapshot cache cleared (${oldSize} entries removed)`, 'debug');
}, SNAPSHOT_CACHE_CLEANUP_MS);
log(`📸 "${label}" detected (Event ${eventId}) - cached`, 'info');
// Initial delay
await new Promise(resolve => setTimeout(resolve, 700));
// Download Snapshot → NAS + Image Item
const imageItemName = await downloadSnapshotToBuffer(eventId);
if (!imageItemName) {
log(`⚠️ Snapshot download failed - no notification (Event ${eventId})`, 'warn');
return;
}
// Short pause for Item update & Cloud sync
await new Promise(resolve => setTimeout(resolve, 300));
sendNotification(
`${label} at front door`,
{
title: "Front Door Camera",
tag: "Camera Motion Front Door",
icon: "motion",
mediaUrl: imageItemName,
id: `frontdoor-snapshot-${eventId}`,
recipients: ['user1@example.com', 'user2@example.com'] // Your email addresses
}
);
log(`📧 Snapshot notification sent (${eventId})`, 'info');
// Telegram photo
await sendTelegramPhoto(imageItemName, `📸 ${label} at front door`);
} catch (error) {
log(`Error in snapshot/cat doorbell: ${error}\n${error.stack}`, 'error');
}
}
});
// ============================================================
// RULE 3: Physical Doorbell Button
// ============================================================
// Triggered by physical doorbell button press
// Priority: Latest Frame → Event Snapshot (fallback)
rules.JSRule({
name: "Frigate_FrontDoor-03-Doorbell",
description: "Camera snapshot when doorbell button pressed (Latest Frame with Event Snapshot fallback + Telegram photo)",
triggers: [
triggers.ItemStateChangeTrigger('Doorbell_Virtual', undefined, 'ON')
],
execute: async () => {
try {
log("🔔 Doorbell pressed", 'info');
await new Promise(resolve => setTimeout(resolve, 700));
// PRIORITY: Latest Frame FIRST, Event Snapshot as fallback
let imageItemName = null;
// Attempt 1: Latest Frame (MOST CURRENT image!)
log(`🔔 Loading latest frame (most current image)`, 'debug');
imageItemName = await downloadLatestFrame('frontdoor');
// Fallback: Event Snapshot (if Latest Frame fails)
if (!imageItemName) {
const eventId = items.getItem('frigate_cam_frontdoor_Current_Event_ID')?.state?.toString();
if (eventId && eventId !== 'NULL' && eventId !== 'UNDEF') {
log(`⚠️ Latest frame failed - using event snapshot: ${eventId}`, 'warn');
imageItemName = await downloadSnapshotToBuffer(eventId);
} else {
log(`⚠️ Latest frame failed + no event ID available`, 'warn');
}
}
// Short pause for Cloud sync (only if image available)
if (imageItemName) {
await new Promise(resolve => setTimeout(resolve, 300));
}
// Send notification (TO ALL!)
const nowDate = new Date();
const timestamp = `${nowDate.getHours().toString().padStart(2, '0')}:${nowDate.getMinutes().toString().padStart(2, '0')}:${nowDate.getSeconds().toString().padStart(2, '0')}`;
const uniqueId = nowDate.toISOString().replace(/[-:T.]/g, '').slice(0, 14);
sendNotification(
`Someone is at the front door. ${timestamp}`,
{
title: "🔔 Doorbell",
tag: "Doorbell",
icon: "doorbell",
mediaUrl: imageItemName,
id: `doorbell-ring-${uniqueId}`,
recipients: ['user1@example.com', 'user2@example.com'] // Your email addresses
}
);
log(`🔔 Doorbell notification sent: ${timestamp}`, 'info');
// Telegram photo (only if image available)
if (imageItemName) {
await sendTelegramPhoto(imageItemName, `🔔 Doorbell`);
}
} catch (error) {
log(`Error processing doorbell: ${error}\n${error.stack}`, 'error');
}
}
});
// ============================================================
// STARTUP
// ============================================================
rules.JSRule({
name: "Frigate_FrontDoor-04-Startup",
description: "Module loaded",
triggers: [
triggers.ItemStateChangeTrigger('Openhab_Online', undefined, 'ON'),
triggers.ItemStateChangeTrigger('Rule_Reload', undefined, 'ON')
],
execute: () => {
console.info("✅ [FRIGATE_FRONTDOOR] Front Door Camera loaded");
log("Front Door Camera Module loaded", 'info');
// Cleanup timers
if (entrance_door_timer) {
clearTimeout(entrance_door_timer);
entrance_door_timer = null;
}
if (cleanup_timer_snapshots) {
clearTimeout(cleanup_timer_snapshots);
cleanup_timer_snapshots = null;
}
// Reset tracking variables
processed_snapshots.clear();
last_snapshot_timestamp = 0;
last_catbell_time = 0;
current_buffer = 1;
log("✅ All timers and caches reset", 'debug');
}
});
log('frigate_frontdoor.js loaded', 'info');
How It Works
IMPORTANT: Before using this code, replace all instances of
['user1@example.com', 'user2@example.com']
with your actual email addresses registered with openHAB Cloud (myopenhab.org).
Normal Motion Detection
- Frigate detects motion (person/car/etc.)
- JSON event published via MQTT → openHAB Frigate Binding
- Rule 2 triggers when
has_snapshotbecomes true - Checks conditions:
- Is door closed?
- Is timer inactive?
- Is AutoRecord enabled?
- Downloads snapshot (with retry + thumbnail fallback)
- Saves to NAS (last 50 per camera) + Image Item (round-robin buffer)
- Sends notification via openHAB Cloud (to specified email addresses)
- Sends Telegram photo (Base64 from Image Item)
Cat Doorbell (Zone-Based)
- Frigate detects cat in specific zone (
cat_doorbell) - Rule 2 branches to cat logic
- Additional conditions:
- 60s cooldown
- Door closed
- Feature enabled
- Sends special notification (“Please let me in!”)
Physical Doorbell
- Button pressed (hardware switch → Virtual Item)
- Rule 3 triggers
- Priority strategy:
- Latest Frame first (most current image)
- Event Snapshot fallback (if latest fails)
- Notification with image (or without if both fail)
- Telegram photo
Why This Approach?
Hybrid Storage (NAS + Image Items):
- NAS provides permanent history
- Image Items work with openHAB Cloud (no VPN needed)
- Round-robin buffer prevents race conditions
Latest Frame for Doorbell:
- Doorbell often pressed when motion already stopped
- Latest frame is more current than old event snapshot
- Graceful fallback if latest frame unavailable
Retry Logic:
- Frigate snapshots take time to write
- 3 retries with 1s delay catches “still writing” errors
- Thumbnail fallback ensures we always get something
Error Isolation:
- Telegram send failures don’t block other recipients
- Each recipient has timeout protection (15s)
- NAS failures don’t prevent Image Item storage
Zone-Based Cat Doorbell:
- Only triggers when cat enters specific zone
- Prevents false positives from cat just walking by
- 60s cooldown prevents notification spam
Questions?
Happy to share more details about any part of this setup.