New comprehensive Frigate binding

Better Late Than Never :sweat_smile:

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:

  1. Motion Detection → Person/car detected → Snapshot with bounding box
  2. Cat Doorbell → Cat enters specific zone → “Let me in!” notification with snapshot
  3. 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

  1. Frigate detects motion (person/car/etc.)
  2. JSON event published via MQTT → openHAB Frigate Binding
  3. Rule 2 triggers when has_snapshot becomes true
  4. Checks conditions:
  • Is door closed?
  • Is timer inactive?
  • Is AutoRecord enabled?
  1. Downloads snapshot (with retry + thumbnail fallback)
  2. Saves to NAS (last 50 per camera) + Image Item (round-robin buffer)
  3. Sends notification via openHAB Cloud (to specified email addresses)
  4. Sends Telegram photo (Base64 from Image Item)

Cat Doorbell (Zone-Based)

  1. Frigate detects cat in specific zone (cat_doorbell)
  2. Rule 2 branches to cat logic
  3. Additional conditions:
  • 60s cooldown
  • Door closed
  • Feature enabled
  1. Sends special notification (“Please let me in!”)

Physical Doorbell

  1. Button pressed (hardware switch → Virtual Item)
  2. Rule 3 triggers
  3. Priority strategy:
  • Latest Frame first (most current image)
  • Event Snapshot fallback (if latest fails)
  1. Notification with image (or without if both fail)
  2. 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.