camera.ui
Version:
NVR like user interface for RTSP capable cameras.
809 lines (653 loc) • 24.2 kB
JavaScript
'use-strict';
import Bunyan from 'bunyan';
import escapeRegExp from 'lodash/escapeRegExp.js';
import has from 'lodash/has.js';
import get from 'lodash/get.js';
import { FileSystem, FtpSrv } from 'ftp-srv';
import http from 'http';
import ip from 'ip';
import { simpleParser } from 'mailparser';
import mqtt from 'mqtt';
import { parse } from 'url';
import path from 'path';
import { SMTPServer } from 'smtp-server';
import Stream from 'stream';
import ConfigService from '../../services/config/config.service.js';
import LoggerService from '../../services/logger/logger.service.js';
import Database from '../../api/database.js';
import Socket from '../../api/socket.js';
const { log } = LoggerService;
const timeout = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const toDotNot = (input, parentKey) =>
// eslint-disable-next-line unicorn/no-array-reduce, unicorn/prefer-object-from-entries
Object.keys(input || {}).reduce((accumulator, key) => {
const value = input[key];
const outputKey = parentKey ? `${parentKey}.${key}` : `${key}`;
// NOTE: remove `&& (!Array.isArray(value) || value.length)` to exclude empty arrays from the output
if (value && typeof value === 'object' && (!Array.isArray(value) || value.length > 0)) {
return { ...accumulator, ...toDotNot(value, outputKey) };
}
return { ...accumulator, [outputKey]: value };
}, {});
export default class MotionController {
static #controller;
static #motionTimers = new Map();
static httpServer = null;
static mqttClient = null;
static smtpServer = null;
static ftpServer = null;
constructor(controller) {
MotionController.#controller = controller;
if (ConfigService.ui.http) {
MotionController.startHttpServer();
}
if (ConfigService.ui.mqtt) {
MotionController.startMqttClient();
}
if (ConfigService.ui.smtp) {
MotionController.startSmtpServer();
}
if (ConfigService.ui.ftp) {
MotionController.startFtpServer();
}
//used for external events
this.triggerMotion = MotionController.triggerMotion = async (cameraName, state) => {
await MotionController.handleMotion('motion', cameraName, state, 'extern');
};
this.httpServer = MotionController.httpServer;
this.startHttpServer = MotionController.startHttpServer;
this.closeHttpServer = MotionController.closeHttpServer;
this.mqttClient = MotionController.mqttClient;
this.startMqttClient = MotionController.startMqttClient;
this.closeMqttClient = MotionController.closeMqttClient;
this.smtpServer = MotionController.smtpServer;
this.startSmtpServer = MotionController.startSmtpServer;
this.closeSmtpServer = MotionController.closeSmtpServer;
this.ftpServer = MotionController.ftpServer;
this.startFtpServer = MotionController.startFtpServer;
this.closeFtpServer = MotionController.closeFtpServer;
this.close = MotionController.close;
}
/**
*
* @url https://github.com/Sunoo/homebridge-camera-ffmpeg/blob/master/src/index.ts
* (c) Sunoo <https://github.com/sunoo>
*
**/
static startHttpServer() {
log.debug('Setting up HTTP server for motion detection...');
const hostname = ConfigService.ui.http.localhttp ? 'localhost' : undefined;
MotionController.httpServer = http.createServer();
MotionController.httpServer.on('listening', async () => {
let addr = MotionController.httpServer.address();
let bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port;
log.debug(`HTTP server for motion detection is listening on ${bind}`);
Socket.io.emit('httpStatus', {
status: 'online',
});
});
MotionController.httpServer.on('error', (error) => {
let error_;
if (error.syscall !== 'listen') {
log.error(error, 'HTTP Server', 'motion');
}
let bind = typeof port === 'string' ? 'Pipe ' + ConfigService.ui.http.port : 'Port ' + ConfigService.ui.http.port;
switch (error.code) {
case 'EACCES':
error_ = `Can not start the HTTP server for motion detection! ${bind} requires elevated privileges`;
break;
case 'EADDRINUSE':
error_ = `Can not start the HTTP server for motion detection! ${bind} is already in use`;
break;
default:
error_ = error;
}
log.error(error_, 'HTTP Server', 'motion');
Socket.io.emit('httpStatus', {
status: 'offline',
});
});
MotionController.httpServer.on('request', async (request, response) => {
let result = {
error: true,
message: `Malformed URL ${request.url}`,
};
log.info(`New message: URL: ${request.url}`, 'HTTP');
let cameraName;
if (request.url) {
const parseurl = parse(request.url);
if (parseurl.pathname && parseurl.query) {
cameraName = decodeURIComponent(parseurl.query);
// => /motion
// => /motion/reset
// => /doorbell
let triggerType = parseurl.pathname.includes('/reset') ? 'reset' : parseurl.pathname.split('/')[1];
let state = triggerType === 'dorbell' ? true : triggerType === 'reset' ? false : true;
triggerType = triggerType === 'reset' ? 'motion' : triggerType;
result = await MotionController.handleMotion(triggerType, cameraName, state, 'http', result);
}
}
response.writeHead(result.error ? 500 : 200);
response.write(JSON.stringify(result));
response.end();
});
MotionController.httpServer.on('close', () => {
log.debug('HTTP Server closed');
Socket.io.emit('httpStatus', {
status: 'offline',
});
});
MotionController.httpServer.listen(ConfigService.ui.http.port, hostname);
}
/**
*
* @url https://github.com/Sunoo/homebridge-camera-ffmpeg/blob/master/src/index.ts
* (c) Sunoo <https://github.com/sunoo>
*
**/
static startMqttClient() {
log.debug('Setting up MQTT client for motion detection...');
MotionController.mqttClient = mqtt.connect(
(ConfigService.ui.mqtt.tls ? 'mqtts://' : 'mqtt://') +
ConfigService.ui.mqtt.host +
':' +
ConfigService.ui.mqtt.port,
{
username: ConfigService.ui.mqtt.username || '',
password: ConfigService.ui.mqtt.password || '',
}
);
MotionController.mqttClient.on('connect', () => {
log.debug(`MQTT Client for motion detection connected to broker on port ${ConfigService.ui.mqtt.port}`);
for (const [topic] of ConfigService.ui.topics) {
log.debug(`Subscribing to MQTT topic: ${topic}`);
MotionController.mqttClient.subscribe(topic + '/#');
}
Socket.io.emit('mqttStatus', {
status: 'online',
});
});
MotionController.mqttClient.on('message', async (topic, data) => {
data = data.toString();
log.info(`New message: Topic: ${topic} - Data: ${data} - Type: ${typeof data}`, 'MQTT');
let cameraName;
const cameraMqttConfig = ConfigService.ui.topics.get(topic);
if (cameraMqttConfig) {
cameraName = cameraMqttConfig.camera;
let index = 0;
let message;
let state;
let triggerType;
if (cameraMqttConfig.reset) {
triggerType = 'reset';
message = cameraMqttConfig.motionResetMessage;
} else if (cameraMqttConfig.motion) {
triggerType = 'motion';
message = cameraMqttConfig.motionMessage;
} else {
triggerType = 'doorbell';
message = cameraMqttConfig.doorbellMessage;
}
const triggerState = () => {
let value;
switch (triggerType) {
case 'reset': {
value = false;
break;
}
case 'motion': {
value = true;
break;
}
case 'doorbell': {
value = true;
break;
}
// No default
}
return value;
};
const check = (message_) => {
let state_;
let json = false;
message_ = message_ || message;
try {
const dataObject = JSON.parse(data);
const messageObject = JSON.parse(message);
json = true;
if (dataObject === message || dataObject.toString() === message_.toString()) {
state_ = triggerState();
} else if (typeof dataObject === typeof messageObject) {
const dotNotMessage = toDotNot(messageObject);
const dotNotMessagePath = Object.keys(dotNotMessage)[0];
const dotNotMessageResult = Object.values(dotNotMessage)[0];
const pathExist = has(dataObject, dotNotMessagePath);
const sameValue = get(dataObject, dotNotMessagePath) === dotNotMessageResult;
if (pathExist && sameValue) {
state_ = triggerState();
}
}
} catch {
if (message_ === data) {
state_ = triggerState();
}
}
log.debug(
{
camera: cameraName,
topic: topic,
data: data,
json: json,
message: message_,
state: state_,
try: index,
},
'MQTT'
);
index++;
return state_;
};
state = check();
if (state === undefined && triggerType === 'motion') {
const resetted = check(cameraMqttConfig.motionResetMessage);
if (resetted !== undefined) {
state = !resetted;
}
}
if (state !== undefined) {
triggerType = triggerType === 'reset' ? 'motion' : triggerType;
await MotionController.handleMotion(triggerType, cameraName, state, 'mqtt');
} else {
log.warn(
`The incoming MQTT message (${data}) for the topic (${topic}) was not the same as set in config.json (${message}). Skip...`
);
}
} else {
log.warn(`Can not assign the MQTT topic (${topic}) to a camera!`, 'MQTT');
}
});
MotionController.mqttClient.on('end', () => {
log.debug('MQTT client disconnected');
Socket.io.emit('mqttStatus', {
status: 'offline',
});
});
}
/**
*
* @url https://github.com/Sunoo/homebridge-smtp-motion
* (c) Sunoo <https://github.com/sunoo>
*
**/
static startSmtpServer() {
log.debug('Setting up SMTP server for motion detection...');
const regex = new RegExp(escapeRegExp(ConfigService.ui.smtp.space_replace), 'g');
const bunyan = Bunyan.createLogger({
name: 'smtp',
streams: [
{
stream: new Stream.Writable({
write: (chunk, _encoding, callback) => {
const data = JSON.parse(chunk);
if (data.level >= 50) {
log.error(data.msg, 'SMTP', 'motion');
} else if (data.level >= 40) {
log.warn(data.msg, 'SMTP', 'motion');
}
callback();
},
}),
},
],
});
MotionController.smtpServer = new SMTPServer({
authOptional: true,
disabledCommands: ['STARTTLS'],
disableReverseLookup: true,
logger: bunyan,
onAuth(_auth, _session, callback) {
callback(null, { user: true });
},
async onData(stream, session, callback) {
stream.on('end', callback);
log.debug(session, 'SMTP');
const cameras = ConfigService.ui.cameras.map((camera) => {
return {
name: camera.name,
from: camera.smtp?.from || false,
email: camera.smtp?.email || camera.name,
body: camera.smtp?.body || false,
};
});
const mailFrom = session.envelope.mailFrom.address.split('@')[0].replace(regex, ' ');
const mailFromCamera = cameras.find((camera) => camera.from === mailFrom);
const mailTo = session.envelope.rcptTo[0].address.split('@')[0].replace(regex, ' ');
const mailToCamera = cameras.find((camera) => camera.email === mailTo);
if (mailToCamera || mailFromCamera) {
let name;
if (mailToCamera) {
log.info(`New message: Data (email to): ${JSON.stringify(session.envelope.rcptTo)}`, 'SMTP');
name = mailTo;
} else {
log.info(`New message: Data (email from): ${JSON.stringify(session.envelope.mailFrom)}`, 'SMTP');
name = mailFrom;
}
return await MotionController.handleMotion('motion', name, true, 'smtp');
} else {
log.info(
'Email received but can not determine camera name through email adresse(s), checking email body...',
'SMTP'
);
const cameraHasBody = cameras.some((camera) => camera.body);
if (cameraHasBody) {
const parsed = await simpleParser(stream);
const body = parsed.textAsHtml;
for (const camera of cameras) {
if (camera.body && body.includes(camera.body)) {
log.info(`New message: Data (email body): "${body}"`, 'SMTP');
return await MotionController.handleMotion('motion', camera.name, true, 'smtp');
}
}
}
}
//not found
await MotionController.handleMotion('motion', mailTo, true, 'smtp');
},
});
MotionController.smtpServer.server.on('listening', () => {
log.debug(`SMTP server for motion detection is listening on port ${ConfigService.ui.smtp.port}`);
Socket.io.emit('smtpStatus', {
status: 'online',
});
});
MotionController.smtpServer.server.on('close', () => {
log.debug('SMTP Server closed');
Socket.io.emit('smtpStatus', {
status: 'offline',
});
});
MotionController.smtpServer.listen(ConfigService.ui.smtp.port);
}
/**
*
* @url https://github.com/Sunoo/homebridge-ftp-motion
* (c) Sunoo <https://github.com/sunoo>
*
**/
static startFtpServer() {
log.debug('Setting up FTP server for motion detection...');
const ipAddr = ip.address('public', 'ipv4');
const bunyan = Bunyan.createLogger({
name: 'ftp',
streams: [
{
stream: new Stream.Writable({
write: (chunk, _encoding, callback) => {
const data = JSON.parse(chunk);
if (data.level >= 50) {
if (data.err?.message !== 'Server is not running.') {
log.error(data.msg, 'FTP', 'motion');
}
} else if (data.level >= 40) {
log.warn(data.msg, 'FTP', 'motion');
}
callback();
},
}),
},
],
});
MotionController.ftpServer = new FtpSrv({
url: `ftp://${ipAddr}:${ConfigService.ui.ftp.port}`,
pasv_url: ipAddr,
anonymous: true,
blacklist: ['MKD', 'APPE', 'RETR', 'DELE', 'RNFR', 'RNTO', 'RMD'],
log: bunyan,
});
MotionController.ftpServer.on('login', (data, resolve) => {
resolve({
fs: new (class extends FileSystem {
constructor() {
super();
this.connection = data.connection;
this.realCwd = '/';
}
get(fileName) {
return {
name: fileName,
isDirectory: () => true,
size: 1,
atime: new Date(),
mtime: new Date(),
ctime: new Date(),
uid: 0,
gid: 0,
};
}
list(filePath = '.') {
filePath = path.resolve(this.realCwd, filePath);
const directories = [...this.get('.')];
const pathSplit = filePath.split('/').filter((value) => value.length > 0);
if (pathSplit.length === 0) {
for (const camera of ConfigService.ui.cameras) {
directories.push(this.get(camera.name));
}
} else {
directories.push(this.get('..'));
}
return directories;
}
chdir(filePath = '.') {
filePath = path.resolve('/', filePath);
this.realCwd = filePath;
return filePath;
}
// eslint-disable-next-line no-unused-vars
async write(fileName, { append = false, start }) {
let filePath = path.resolve(this.realCwd, fileName);
let pathSplit = path.dirname(filePath).split('/').filter(Boolean);
log.info(
`New message: File Name: ${fileName} - File Path: ${filePath} - Path Split ${JSON.stringify(pathSplit)}`,
'FTP'
);
if (pathSplit.length === 0 && ConfigService.ui.ftp.useFile) {
const name = fileName.split(/[^A-Za-z]/g)[0];
pathSplit.push(name);
}
if (pathSplit.length > 0) {
const name = pathSplit[0];
await MotionController.handleMotion('motion', name, true, 'ftp');
} else {
this.connection.reply(550, 'Permission denied.');
}
return new Stream.Writable({
write: (chunk, encoding, callback) => {
callback();
},
});
}
// eslint-disable-next-line no-unused-vars
chmod(filePath, mode) {
return;
}
// eslint-disable-next-line no-unused-vars
mkdir(filePath) {
this.connection.reply(550, 'Permission denied.');
return this.realCwd;
}
// eslint-disable-next-line no-unused-vars
read(fileName, { start }) {
this.connection.reply(550, 'Permission denied.');
return;
}
// eslint-disable-next-line no-unused-vars
delete(filePath) {
this.connection.reply(550, 'Permission denied.');
return;
}
// eslint-disable-next-line no-unused-vars
rename(from, to) {
this.connection.reply(550, 'Permission denied.');
return;
}
})(),
cwd: '/',
});
});
MotionController.ftpServer.server.on('listening', () => {
log.debug(`FTP server for motion detection is listening on port ${ConfigService.ui.ftp.port}`);
Socket.io.emit('ftpStatus', {
status: 'online',
});
});
MotionController.ftpServer.server.on('close', () => {
if (!MotionController.ftpServer.server.alreadyClosed) {
MotionController.ftpServer.server.alreadyClosed = true;
log.debug('FTP Server closed');
Socket.io.emit('ftpStatus', {
status: 'offline',
});
}
});
MotionController.ftpServer.listen();
}
static closeHttpServer() {
if (MotionController.httpServer) {
MotionController.httpServer.close();
}
}
static closeMqttClient() {
if (MotionController.mqttClient) {
MotionController.mqttClient.end();
}
}
static closeSmtpServer() {
if (MotionController.smtpServer) {
MotionController.smtpServer.close();
}
}
static closeFtpServer() {
if (MotionController.ftpServer) {
MotionController.ftpServer.close();
}
}
static close() {
if (MotionController.httpServer) {
MotionController.httpServer.close();
}
if (MotionController.mqttClient) {
MotionController.mqttClient.end();
}
if (MotionController.smtpServer) {
MotionController.smtpServer.close();
}
if (MotionController.ftpServer) {
MotionController.ftpServer.close();
}
}
static async handleMotion(triggerType, cameraName, state, event, result = {}) {
// result = {} is used as http response
let camera = ConfigService.ui.cameras.find((camera) => camera?.name === cameraName);
if (event === 'smtp') {
camera = ConfigService.ui.cameras.find(
(camera) => camera?.smtp?.email === cameraName || camera?.name === cameraName
);
}
if (camera) {
if (camera.motionDelay > 0) {
log.info(
`Movement delay (${camera.motionDelay}s) is active, wait before triggering the event - Trigger: ${triggerType} - State: ${state} - Event: ${event}`,
camera.name
);
await timeout(camera.motionDelay * 1000);
}
const settingsDatabase = await Database.interfaceDB.chain.get('settings').cloneDeep().value();
const cameraSettings = settingsDatabase?.cameras.find((cam) => cam.name === cameraName);
const atHome = settingsDatabase?.general?.atHome || false;
const cameraExcluded = (settingsDatabase?.general?.exclude || []).includes(camera.name);
if (atHome && !cameraExcluded) {
const message = `Skip motion trigger. At Home is active and ${camera.name} is not excluded!`;
log.info(message, camera.name);
result = {
error: false,
message: message,
};
} else {
result = {
error: false,
message: `Handling through ${camera.recordOnMovement ? 'intern' : 'extern'} controller..`,
};
log.debug(result, camera.name);
MotionController.#controller.emit('motion', camera.name, triggerType, state, event); // used for extern controller, like Homebridge
if (camera.recordOnMovement) {
const mqttClient = MotionController.mqttClient;
if (mqttClient?.connected && cameraSettings?.mqttTopic) {
mqttClient.publish(
cameraSettings.mqttTopic,
JSON.stringify({
camera: camera.name,
state: state,
type: triggerType,
event: event,
})
);
} else {
log.debug('No MQTT Publish Topic defined, skip MQTT (motion)..');
}
const recordingSettings = await Database.interfaceDB.chain
.get('settings')
.get('recordings')
.cloneDeep()
.value();
const recActive = recordingSettings.active || false;
const recTimer = recordingSettings.timer || 10;
const timeout = MotionController.#motionTimers.get(camera.name);
let timeoutConfig =
(recActive && camera.motionTimeout < recTimer) || camera.useInterfaceTimer
? recTimer
: camera.motionTimeout;
if (timeout) {
if (state) {
log.info('Skip motion event, motion timeout active!', camera.name);
result.message += ' - Skip motion event, timeout active!';
} else {
clearTimeout(timeout);
MotionController.#motionTimers.delete(camera.name);
MotionController.#controller.emit('uiMotion', {
triggerType: triggerType,
cameraName: camera.name,
state: state,
});
}
} else {
if (state) {
const timer = setTimeout(() => {
log.debug('Motion handler timeout. (ui)', camera.name);
MotionController.#motionTimers.delete(camera.name);
}, (timeoutConfig || 1) * 1000);
MotionController.#motionTimers.set(camera.name, timer);
}
MotionController.#controller.emit('uiMotion', {
triggerType: triggerType,
cameraName: camera.name,
state: state,
});
}
} else {
result = {
error: false,
message: 'Handling through extern controller..',
};
}
}
} else {
const message = `Camera '${cameraName}' not found`;
log.warn(message, cameraName);
result = {
error: true,
message: message,
};
}
return result;
}
}