UNPKG

iobroker.frigate

Version:
684 lines 31 kB
import fs, { existsSync } from 'node:fs'; import https from 'node:https'; import { join } from 'node:path'; import { createServer } from 'node:net'; import { tmpdir, networkInterfaces } from 'node:os'; import { lookup } from 'node:dns/promises'; import { fileURLToPath } from 'node:url'; import axios from 'axios'; import { Aedes } from 'aedes'; import mqtt from 'mqtt'; import { Adapter, getAbsoluteDefaultDataDir } from '@iobroker/adapter-core'; import { createFrigateConfigFile } from './lib/utils.js'; import Json2iob from './lib/json2iob.js'; import { handleMqttMessage } from './lib/messageHandler.js'; import { prepareEventNotification, sendNotification } from './lib/notifications.js'; import { fetchEventHistory, createCameraDevices, cleanTrackedObjects, handleTrackedObjectUpdate, } from './lib/eventHistory.js'; import { handleStateChange } from './lib/stateHandler.js'; import { ZoneAggregator } from './lib/zoneAggregator.js'; class FrigateAdapter extends Adapter { server; requestClient; json2iob; tmpDir = join(tmpdir(), 'iobroker-frigate'); notificationMinScore = null; firstStart = true; deviceArray = ['']; notificationsLog = {}; trackedObjectsHistory = []; notificationExcludeArray = []; aedes; mqttClient = null; fetchEventHistoryTimeout = null; zoneAggregator; frigateBaseUrl = ''; constructor(options) { super({ ...options, name: 'frigate', }); this.on('ready', this.onReady); this.on('stateChange', this.onStateChange); this.on('unload', this.onUnload); this.on('message', this.onMessage); this.requestClient = axios.create({ withCredentials: true, headers: { 'User-Agent': 'ioBroker.frigate', accept: '*/*', }, timeout: 3 * 60 * 1000, httpsAgent: new https.Agent({ rejectUnauthorized: false }), }); this.json2iob = new Json2iob(this); this.zoneAggregator = new ZoneAggregator({ adapter: this }); this.setupAuthInterceptor(); } onReady = async () => { await this.setStateAsync('info.connection', false, true); this.config.dockerFrigate ||= { enabled: false }; this.config.dockerFrigate.port = parseInt((this.config.dockerFrigate.port || '5000'), 10) || 5000; this.config.dockerFrigate.shmSize = parseInt((this.config.dockerFrigate.shmSize || '256'), 10) || 256; if (this.config.dockerFrigate.location && !this.config.dockerFrigate.location.endsWith('/')) { this.config.dockerFrigate.location += '/'; } if (this.config.dockerFrigate.enabled) { this.config.friurl = `${this.config.dockerFrigate.bind}:${this.config.dockerFrigate.port}`; if (this.config.notificationInstances?.replace(/ /g, '')) { const instances = this.config.notificationInstances.replace(/ /g, '').split(','); const ownHost = this.common?.host; if (ownHost) { for (const instance of instances) { const obj = (await this.getForeignObjectAsync(`system.adapter.${instance}`)); if (obj && obj.common.host !== ownHost) { this.log.warn(`Notification will not work, as the "${instance}" is running on different host ("${obj.common.host}") as frigate("${ownHost}"). Change the host of "${instance}" to "${ownHost}"`); } } } } } if (!this.config.friurl) { this.log.warn('No Frigate url set'); } // Build base URL: user can prefix with https:// for TLS, otherwise http:// if (this.config.friurl?.startsWith('http')) { this.frigateBaseUrl = this.config.friurl; } else { this.frigateBaseUrl = `http://${this.config.friurl}`; } if (this.config.frigateUsername && this.config.frigatePassword) { await this.loginToFrigate(); } else if (this.config.friurl?.includes(':8971')) { this.log.warn('Port 8971 requires authentication. Please enter Frigate username and password in the adapter settings.'); } this.config.notificationMinScore = parseFloat(this.config.notificationMinScore) || 0; this.config.notificationEventClipWaitTime = parseFloat(this.config.notificationEventClipWaitTime) || 5; this.config.webnum = parseInt(this.config.webnum, 10) || 5; this.config.mqttPort = parseInt((this.config.mqttPort || '1883'), 10) || 1883; this.config.mqttMode = this.config.mqttMode || 'broker'; this.config.mqttTopicPrefix = this.config.mqttTopicPrefix || 'frigate'; try { if (this.config.notificationMinScore) { this.notificationMinScore = this.config.notificationMinScore; if (this.notificationMinScore > 1) { this.notificationMinScore = this.notificationMinScore / 100; this.log.info(`Notification min score is higher than 1. Recalculated to ${this.notificationMinScore}`); } } } catch (error) { this.log.error(error instanceof Error ? error.message : String(error)); } if (this.config.notificationEventClipWaitTime < 1) { this.log.warn('Notification clip wait time is lower than 1. Set to 1'); this.config.notificationEventClipWaitTime = 1; } if (this.config.notificationExcludeList) { this.notificationExcludeArray = this.config.notificationExcludeList.replace(/\s/g, '').split(','); } await fs.promises.mkdir(this.tmpDir, { recursive: true }).catch(() => { }); if (this.config.notificationActive) { this.log.debug('Clean old images and clips'); let count = 0; try { const files = await fs.promises.readdir(this.tmpDir); for (const file of files) { if (file.endsWith('.jpg') || file.endsWith('.mp4')) { this.log.debug(`Try to delete ${file}`); await fs.promises.unlink(join(this.tmpDir, file)); count++; this.log.debug(`Deleted ${file}`); } } count && this.log.info(`Deleted ${count} old images and clips in tmp folder`); } catch (error) { this.log.warn('Cannot delete old images and clips'); this.log.warn(error instanceof Error ? error.message : String(error)); } } await this.cleanOldObjects(); await cleanTrackedObjects(this); this.trackedObjectsHistory = []; this.subscribeStates('*_state'); this.subscribeStates('*.remote.*'); this.subscribeStates('remote.*'); this.subscribeStates('notifications.*'); if (this.config.dockerFrigate.enabled) { await this.setupDocker(); } this.aedes = await Aedes.createBroker(); this.server = createServer(this.aedes.handle); this.initContexts(); if (this.config.mqttMode === 'client') { this.initMqttClient(); } else { this.initMqtt(); } }; async setupDocker() { const dockerManager = this.getPluginInstance('docker'); if (!this.config.dockerFrigate.location) { const dataDir = getAbsoluteDefaultDataDir(); this.config.dockerFrigate.location = `${join(dataDir, this.namespace)}/`; } for (const subDir of ['config', 'recordings', 'clips']) { if (!existsSync(join(this.config.dockerFrigate.location, subDir))) { fs.mkdirSync(join(this.config.dockerFrigate.location, subDir), { recursive: true }); } } const configFile = createFrigateConfigFile(this.config); const configPath = join(this.config.dockerFrigate.location, 'config', 'config.yml'); try { let oldConfigFile = null; try { oldConfigFile = await fs.promises.readFile(configPath, 'utf-8'); } catch { // File does not exist yet } if (oldConfigFile !== configFile) { await fs.promises.writeFile(configPath, configFile); } dockerManager?.instanceIsReady(oldConfigFile !== configFile); } catch (error) { this.log.error(`Cannot write Frigate config file ${configPath}: ${error instanceof Error ? error.message : String(error)}`); } } async cleanOldObjects() { await this.delObjectAsync('reviews.before.data.detections', { recursive: true }); await this.delObjectAsync('reviews.after.data.detections', { recursive: true }); const allObjects = await this.getObjectListAsync({ startkey: `${this.namespace}.`, endkey: `${this.namespace}.\u9999`, }); const dataFoldersToDelete = new Set(); for (const obj of allObjects.rows) { if (obj.id.includes('.path_data')) { const match = obj.id.match(/(.+\.history\.\d+\.data)/); if (match) { dataFoldersToDelete.add(match[1].replace(`${this.namespace}.`, '')); } } } for (const dataFolder of dataFoldersToDelete) { try { await this.delObjectAsync(dataFolder, { recursive: true }); } catch { // Continue if deletion fails } } // Migration script const remoteState = await this.getObjectAsync('lastidurl'); if (remoteState) { this.log.info('clean old states '); await this.delObjectAsync('', { recursive: true }); const obj = await this.getForeignObjectAsync(`system.adapter.${this.namespace}`); if (obj) { await this.setForeignObjectAsync(obj._id, obj); } } } // --- MQTT --- initMqtt() { this.server .listen(this.config.mqttPort, () => { this.log.info(`MQTT server started and listening on port ${this.config.mqttPort}`); this.log.info(`Please enter host: '${this.host}' and port: '${this.config.mqttPort}' in frigate config`); this.log.info("If you don't see a new client connected, please restart frigate and adapter."); }) .once('error', err => { this.log.error(`MQTT server error: ${err}`); this.log.error(`Please check if port ${this.config.mqttPort} is already in use. Use a different port in instance and frigate settings or restart ioBroker.`); this.terminate(); }); this.aedes.on('client', async (client) => { this.log.info(`New client: ${client.id}`); await this.setStateAsync('info.connection', true, true); this.aedes.publish({ cmd: 'publish', qos: 0, topic: 'frigate/onConnect', payload: Buffer.from(''), retain: false, dup: false, }, err => { if (err) { this.log.error(`onConnect publish error: ${err}`); } else { this.log.info('Published frigate/onConnect to trigger camera_activity'); } }); await this.doFetchEventHistory(); }); this.aedes.on('clientDisconnect', async (client) => { this.log.info(`client disconnected ${client.id}`); await this.setStateAsync('info.connection', false, true); await this.setStateAsync('available', 'offline', true); }); this.aedes.on('publish', async (packet, client) => { if (packet.payload) { if (packet.topic === 'frigate/stats' || packet.topic.endsWith('snapshot')) { this.log.silly(`publish ${packet.topic} ${packet.payload.toString()}`); } else { this.log.debug(`publish ${packet.topic} ${packet.payload.toString()}`); } } else { this.log.debug(JSON.stringify(packet)); } if (client) { await handleMqttMessage(this._msgCtx, packet.topic, Buffer.from(packet.payload)); } }); this.aedes.on('subscribe', (subscriptions, client) => { this.log.info(`MQTT client ${client ? client.id : client} subscribed to topics: ${subscriptions.map(s => s.topic).join('\n')} from broker ${this.aedes.id}`); }); this.aedes.on('unsubscribe', (subscriptions, client) => this.log.info(`MQTT client ${client ? client.id : client} unsubscribed to topics: ${subscriptions.join('\n')} from broker ${this.aedes.id}`)); this.aedes.on('clientError', (client, err) => this.log.warn(`client error: ${client.id} ${err.message} ${err.stack}`)); this.aedes.on('connectionError', (client, err) => this.log.warn(`client error: ${client.id} ${err.message} ${err.stack}`)); } initMqttClient() { if (!this.config.mqttHost) { this.log.error('External MQTT broker host is not configured. Please set the MQTT host in the adapter settings.'); this.terminate(); return; } let brokerUrl = this.config.mqttHost; if (!brokerUrl.includes('://')) { brokerUrl = `mqtt://${brokerUrl}`; } const urlWithoutProtocol = brokerUrl.replace(/^.*:\/\//, ''); if (!urlWithoutProtocol.includes(':')) { brokerUrl = `${brokerUrl}:1883`; } const mqttOptions = { clientId: `iobroker_frigate_${this.instance}`, clean: true, reconnectPeriod: 5000, }; if (this.config.mqttUsername) { mqttOptions.username = this.config.mqttUsername; } if (this.config.mqttPassword) { mqttOptions.password = this.config.mqttPassword; } this.log.info(`Connecting to external MQTT broker at ${brokerUrl}`); this.mqttClient = mqtt.connect(brokerUrl, mqttOptions); this.mqttClient.on('connect', async () => { this.log.info(`Connected to external MQTT broker at ${brokerUrl}`); await this.setStateAsync('info.connection', true, true); const prefix = this.config.mqttTopicPrefix; this.mqttClient.subscribe(`${prefix}/#`, err => { if (err) { this.log.error(`Failed to subscribe to ${prefix}/#: ${err.message}`); } else { this.log.info(`Subscribed to ${prefix}/#`); } }); await this.doFetchEventHistory(); }); this.mqttClient.on('close', async () => { this.log.info('Disconnected from external MQTT broker'); await this.setStateAsync('info.connection', false, true); }); this.mqttClient.on('error', err => this.log.error(`MQTT client error: ${err.message}`)); this.mqttClient.on('reconnect', () => this.log.debug('Reconnecting to external MQTT broker...')); this.mqttClient.on('message', async (topic, payload) => { if (payload) { if (topic === `${this.config.mqttTopicPrefix}/stats` || topic.endsWith('snapshot')) { this.log.silly(`received ${topic} ${payload.toString()}`); } else { this.log.debug(`received ${topic} ${payload.toString()}`); } } await handleMqttMessage(this._msgCtx, topic, payload); }); } publishMqtt(topic, payload, callback) { if (this.config.mqttMode === 'client') { if (!this.mqttClient || !this.mqttClient.connected) { const err = new Error('External MQTT client is not connected'); this.log.warn(`Cannot publish to "${topic}": ${err.message}`); callback?.(err); return; } this.mqttClient.publish(topic, payload, { qos: 0, retain: false }, err => callback?.(err || undefined)); } else { this.aedes.publish({ cmd: 'publish', qos: 0, topic, payload: typeof payload === 'string' ? Buffer.from(payload) : payload, retain: false, dup: false, }, err => callback?.(err || undefined)); } } // --- Cached context objects for extracted modules (avoid re-allocation per message) --- _notifCtx; _msgCtx; initContexts() { this._notifCtx = { adapter: this, requestClient: this.requestClient, tmpDir: this.tmpDir, notificationMinScore: this.notificationMinScore, notificationsLog: this.notificationsLog, notificationExcludeArray: this.notificationExcludeArray, }; this._msgCtx = { adapter: this, json2iob: this.json2iob, requestClient: this.requestClient, tmpDir: this.tmpDir, get firstStart() { return false; }, onFirstStats: async () => { const configData = await createCameraDevices({ adapter: this, requestClient: this.requestClient, json2iob: this.json2iob, deviceArray: this.deviceArray, }); await this.zoneAggregator.initZones(configData); this.firstStart = false; }, onEvent: async (data) => { await prepareEventNotification(this._notifCtx, data); await this.zoneAggregator.processEvent(data); }, onTrackedObjectUpdate: async (data) => { this.trackedObjectsHistory = await handleTrackedObjectUpdate(this, this.trackedObjectsHistory, data); }, debouncedFetchEventHistory: () => this.debouncedFetchEventHistory(), sendNotification: async (msg) => sendNotification(this._notifCtx, msg), }; // Make firstStart a live reference to the adapter's property Object.defineProperty(this._msgCtx, 'firstStart', { get: () => this.firstStart, }); } // --- Event History --- debouncedFetchEventHistory() { if (this.fetchEventHistoryTimeout) { this.clearTimeout(this.fetchEventHistoryTimeout); } this.fetchEventHistoryTimeout = this.setTimeout(async () => { this.fetchEventHistoryTimeout = null; await this.doFetchEventHistory(); }, 2000); } async doFetchEventHistory() { await fetchEventHistory({ adapter: this, requestClient: this.requestClient, json2iob: this.json2iob, deviceArray: this.deviceArray, }); } // --- Frigate API Authentication --- async loginToFrigate() { try { const url = `${this.frigateBaseUrl}/api/login`; this.log.info(`Logging in to Frigate API at ${url}`); const response = await this.requestClient.post(url, { user: this.config.frigateUsername, password: this.config.frigatePassword, }); if (response.status === 200) { this.log.info('Successfully authenticated with Frigate API'); // Extract Bearer token from cookie if available const cookies = response.headers['set-cookie']; if (cookies) { for (const cookie of cookies) { const match = cookie.match(/frigate_token=([^;]+)/); if (match) { this.requestClient.defaults.headers.common.Authorization = `Bearer ${match[1]}`; this.log.debug('Set Bearer token from login response'); } } } return true; } this.log.warn(`Frigate login returned status ${response.status}`); return false; } catch (error) { const msg = error instanceof Error ? error.message : String(error); if (error.response?.status === 401) { this.log.error('Frigate login failed: Invalid username or password'); } else if (error.code === 'EPROTO' || msg.includes('SSL') || msg.includes('EPROTO')) { this.log.error(`Frigate login failed: SSL/TLS error. Port 8971 requires https:// — change the URL to https://${this.config.friurl}`); } else if (error.response?.status === 404 || error.response?.status === 400) { if (!this.frigateBaseUrl.startsWith('https')) { this.log.error(`Frigate login failed: Login not available at ${this.frigateBaseUrl}. Try adding https:// to the URL`); } else { this.log.error(`Frigate login failed: Login endpoint returned ${error.response.status} at ${this.frigateBaseUrl}`); } } else { this.log.error(`Frigate login failed: ${msg}`); } return false; } } setupAuthInterceptor() { this.requestClient.interceptors.response.use(response => response, async (error) => { const originalRequest = error.config; if (error.response?.status === 401 && !originalRequest._retry && this.config.frigateUsername && this.config.frigatePassword && !originalRequest.url?.includes('/api/login')) { originalRequest._retry = true; this.log.debug('Received 401, attempting re-login to Frigate API'); const loggedIn = await this.loginToFrigate(); if (loggedIn) { return this.requestClient(originalRequest); } } throw error; }); } // --- Adapter lifecycle --- async sleep(ms) { return new Promise(resolve => this.setTimeout(resolve, ms)); } async detectIpAddress(hostname) { const isIPv4 = (value) => /^\d{1,3}(\.\d{1,3}){3}$/.test(value); const ipv4ToInt = (ip) => ip.split('.').reduce((acc, oct) => ((acc << 8) | parseInt(oct, 10)) >>> 0, 0) >>> 0; // Resolve hostname to the IPv4 the browser used to reach the admin. let browserIp = hostname; if (!isIPv4(browserIp)) { try { const result = await lookup(hostname, { family: 4 }); browserIp = result.address; } catch (error) { this.log.debug(`DNS lookup for "${hostname}" failed: ${error instanceof Error ? error.message : String(error)}`); } } if (!isIPv4(browserIp)) { return hostname; } // Pick the local interface whose subnet contains the browser IP: // (interfaceIp & netmask) === (browserIp & netmask) const browserIpInt = ipv4ToInt(browserIp); const interfaces = networkInterfaces(); for (const name of Object.keys(interfaces)) { for (const info of interfaces[name] || []) { if (info.family !== 'IPv4' || info.internal) { continue; } const maskInt = ipv4ToInt(info.netmask); if ((ipv4ToInt(info.address) & maskInt) === (browserIpInt & maskInt)) { return info.address; } } } // No interface in the browser's subnet — fall back to the first non-internal IPv4. for (const name of Object.keys(interfaces)) { for (const info of interfaces[name] || []) { if (info.family === 'IPv4' && !info.internal) { return info.address; } } } return hostname; } onMessage = (obj) => { if (obj?.command === 'showLink') { let data = { href: '', name: '' }; // parse href if (typeof obj.message === 'string') { try { data = JSON.parse(obj.message); } catch (error) { this.log.error('Cannot parse config. Please use valid JSON'); this.log.error(error instanceof Error ? error.message : String(error)); this.sendTo(obj.from, obj.command, { error: 'Cannot parse config. Please use valid JSON' }, obj.callback); return; } } else { data = obj.message; } try { const url = new URL(data.href); this.detectIpAddress(url.hostname) .then(ip => { this.sendTo(obj.from, obj.command, { text: `rtsp://${ip}:8554/${data.name}`, style: { color: 'blue' } }, obj.callback); }) .catch(error => { this.log.error('Failed to detect IP address for showLink'); this.log.error(error instanceof Error ? error.message : String(error)); this.sendTo(obj.from, obj.command, { text: `rtsp://${url.hostname}:8554/${data.name}`, style: { color: 'blue' } }, obj.callback); }); } catch (error) { this.log.error('Invalid URL in showLink command'); this.log.error(error instanceof Error ? error.message : String(error)); this.sendTo(obj.from, obj.command, { error: 'Invalid URL in showLink command' }, obj.callback); return; } } else if (obj?.command === 'readConfig') { this.log.info('readConfig command received'); let config; if (typeof obj.message === 'string') { try { config = JSON.parse(obj.message); } catch (error) { this.log.error('Cannot parse config. Please use valid JSON'); this.log.error(error instanceof Error ? error.message : String(error)); this.sendTo(obj.from, obj.command, { error: 'Cannot parse config. Please use valid JSON' }, obj.callback); return; } } else { config = obj.message; } this.sendTo(obj.from, obj.command, { copyDialog: { title: 'Current frigate config.yaml', text: createFrigateConfigFile(config), type: 'yaml', }, }, obj.callback); } else if (obj?.command === 'recreateContainer') { void this.recreateContainer(obj); } }; async recreateContainer(obj) { if (!this.config.dockerFrigate?.enabled) { this.sendTo(obj.from, obj.command, { error: 'Docker mode is not enabled for this instance' }, obj.callback); return; } try { const dockerPlugin = this.getPluginInstance('docker'); const dockerManager = dockerPlugin?.getDockerManager?.(); if (!dockerManager) { this.sendTo(obj.from, obj.command, { error: 'Docker plugin is not available' }, obj.callback); return; } // getDefaultContainerName() only returns the prefix (e.g. "iob_frigate_0"). // The real container is named "<prefix>_<service>" (e.g. "iob_frigate_0_frigate"), // so look up the actual container(s) belonging to this instance dynamically. const prefix = dockerManager.getDefaultContainerName(); const containers = await dockerManager.containerList(true); const ownContainers = containers.filter(c => { const name = (c.names || '').replace(/^\//, ''); return name === prefix || name.startsWith(`${prefix}_`); }); if (!ownContainers.length) { this.log.warn(`No Frigate container found (prefix "${prefix}"). Restarting instance to (re-)create it...`); } for (const container of ownContainers) { const name = (container.names || '').replace(/^\//, '') || container.id; this.log.info(`Removing Frigate container "${name}" on user request...`); await dockerManager.containerRemove(container.id); } this.log.info('Restarting instance to re-create the Frigate container...'); this.sendTo(obj.from, obj.command, { result: 'Frigate container deleted. The instance is restarting and will re-create the container.', }, obj.callback); // Give the message time to reach the admin GUI before the instance restarts this.setTimeout(() => this.restart(), 1500); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.log.error(`Cannot re-create Frigate container: ${message}`); this.sendTo(obj.from, obj.command, { error: message }, obj.callback); } } onUnload = (callback) => { try { if (this.mqttClient) { this.mqttClient.end(true, () => { this.aedes?.close(() => this.server?.close(() => callback?.())); }); } else { this.aedes?.close(() => this.server?.close(() => callback?.())); } } catch (e) { this.log.error(`Error onUnload: ${e}`); callback(); } }; onStateChange = async (id, state) => { await handleStateChange({ adapter: this, requestClient: this.requestClient, publishMqtt: (topic, payload, cb) => this.publishMqtt(topic, payload, cb), }, id, state); }; } const modulePath = fileURLToPath(import.meta.url); if (process.argv[1] === modulePath) { new FrigateAdapter(); } export default function startAdapter(options) { return new FrigateAdapter(options); } //# sourceMappingURL=main.js.map