iobroker.frigate
Version:
684 lines • 31 kB
JavaScript
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