UNPKG

camera.ui

Version:

NVR like user interface for RTSP capable cameras.

662 lines (536 loc) 20.2 kB
'use-strict'; import crypto from 'crypto'; import fs from 'fs-extra'; import lodash from 'lodash'; import moment from 'moment'; import path from 'path'; import piexif from 'piexifjs'; import webpush from 'web-push'; import { Low, JSONFile, MemorySync } from '@seydx/lowdb'; import Socket from './socket.js'; import Cleartimer from '../common/cleartimer.js'; import ConfigService from '../services/config/config.service.js'; import LoggerService from '../services/logger/logger.service.js'; const { log } = LoggerService; const defaultDatabase = { version: process.env.CUI_VERSION, firstStart: true, cameras: [], notifications: [], users: [], settings: { aws: { active: false, accessKeyId: '', secretAccessKey: '', region: '', contingent_total: 5000, contingent_left: 5000, last_rekognition: '', }, cameras: [], general: { atHome: false, exclude: [], rooms: ['Standard'], automation: { active: false, atHome: false, exclude: [], startTime: '08:00', endTime: '17:00', }, }, notifications: { active: false, removeAfter: 3, alexa: { active: false, domain: '', serialNr: '', message: '', startTime: '00:00', endTime: '23:59', auth: { cookie: '', macDms: { device_private_key: '', adp_token: '', }, }, proxy: { port: 9494, }, }, telegram: { active: false, token: '', chatID: '', message: '', }, webhook: { active: false, }, }, recordings: { active: false, type: 'Snapshot', timer: 10, path: ConfigService.recordingsPath, removeAfter: 7, }, webpush: { ...webpush.generateVAPIDKeys(), subscription: false, }, widgets: { options: { locked: false, }, items: [], }, }, }; const defaultTokensDatabase = { tokens: [], }; const defaultNotificationsDatabase = { notifications: [], }; const defaultRecordingsDatabase = { path: path.join(process.env.CUI_STORAGE_PATH, 'recordings'), recordings: [], }; const defaultCameraSettingsEntry = { name: '', room: 'Standard', resolution: '1280x720', pingTimeout: 1, streamTimeout: 60, audio: false, telegramType: 'Snapshot', alexa: false, webhookUrl: '', mqttTopic: 'camera.ui/motion', privacyMode: false, camview: { favourite: true, live: true, snapshotTimer: 60, }, dashboard: { live: true, snapshotTimer: 60, }, rekognition: { active: false, confidence: 90, labels: [], }, videoanalysis: { forceCloseTimer: 3, dwellTimer: 60, sensitivity: 75, difference: 5, regions: [ { finished: true, coords: [ [0, 100], [0, 0], [100, 0], [100, 100], ], }, ], }, }; export default class Database { static interfaceDB; static notificationsDB; static recordingsDB; static tokensDB; static databasePath = ConfigService.databasePath; static databaseUserPath = ConfigService.databaseUserPath; static recordingsPath = ConfigService.recordingsPath; static reportsPath = ConfigService.reportsPath; static databaseFilePath = ConfigService.databaseFilePath; static controller; static atHomeAutomation = null; constructor(controller) { Database.controller = controller; } async prepareDatabase() { await fs.ensureDir(Database.recordingsPath); await fs.ensureDir(Database.databaseUserPath); await fs.ensureDir(Database.reportsPath); Database.interfaceDB = new Low(new JSONFile(Database.databaseFilePath)); Database.tokensDB = new Low(new MemorySync()); Database.recordingsDB = new Low(new MemorySync()); Database.notificationsDB = new Low(new MemorySync()); //used for system events (errors) await Database.interfaceDB.read(); Database.recordingsDB.read(); Database.tokensDB.read(); Database.notificationsDB.read(); Database.interfaceDB.data = Database.interfaceDB.data || defaultDatabase; Database.tokensDB.data = Database.tokensDB.data || defaultTokensDatabase; Database.recordingsDB.data = Database.recordingsDB.data || defaultRecordingsDatabase; Database.notificationsDB.data = Database.notificationsDB.data || defaultNotificationsDatabase; Database.interfaceDB.chain = lodash.chain(Database.interfaceDB.data); Database.tokensDB.chain = lodash.chain(Database.tokensDB.data); Database.recordingsDB.chain = lodash.chain(Database.recordingsDB.data); Database.notificationsDB.chain = lodash.chain(Database.notificationsDB.data); await Database.#ensureDatabaseValues(); await Database.#initializeUser(); await Database.writeConfigCamerasToDB(); await Database.refreshRecordingsDatabase(); await Database.interfaceDB.chain.set('version', process.env.CUI_VERSION).value(); await Database.startAtHomeAutomation(); await Cleartimer.start(Database.interfaceDB, Database.recordingsDB); await Database.interfaceDB.write(); LoggerService.notificationsDB = Database.notificationsDB; Socket.watchSystem(); return { interface: Database.interfaceDB, tokens: Database.tokensDB, recordings: Database.recordingsDB, notifications: Database.notificationsDB, }; } static async startAtHomeAutomation() { const generalSettings = await Database.interfaceDB.chain.get('settings').get('general').cloneDeep().value(); if ( generalSettings.automation.active && generalSettings.automation.startTime && generalSettings.automation.endTime ) { log.debug('Starting atHome automation'); const format = 'HH:mm'; const oldAtHomeValue = generalSettings.atHome; const oldExcludeValue = generalSettings.exclude; const newAtHomeValue = generalSettings.automation.atHome; const newExcludeValue = generalSettings.automation.exclude; const adaptSettings = async (inTime) => { await Database.interfaceDB.chain .get('settings') .get('general') .set('atHome', inTime ? newAtHomeValue : oldAtHomeValue) .value(); await Database.interfaceDB.chain .get('settings') .get('general') .set('exclude', inTime ? newExcludeValue : oldExcludeValue) .value(); }; const isBetween = () => { const currentTime = moment(); const startTime = moment(generalSettings.automation.startTime, format); const endTime = moment(generalSettings.automation.endTime, format); if ((startTime.hour() >= 12 && endTime.hour() <= 12) || endTime.isBefore(startTime)) { endTime.add(1, 'days'); if (currentTime.hour() <= 12) { currentTime.add(1, 'days'); } } return currentTime.isBetween(startTime, endTime); }; Database.atHomeAutomation = setInterval(() => { adaptSettings(isBetween()); }, 60 * 1000); adaptSettings(isBetween()); } } static async restartAtHomeAutomation() { Database.stopAtHomeAutomation(); await Database.startAtHomeAutomation(); } static stopAtHomeAutomation() { log.debug('Stopping atHome automation'); if (Database.atHomeAutomation) { clearInterval(Database.atHomeAutomation); Database.atHomeAutomation = null; } } static async refreshRecordingsDatabase() { Database.recordingsPath = (await Database.interfaceDB.chain.get('settings').get('recordings').get('path').cloneDeep().value()) || Database.recordingsPath; const cameras = await Database.interfaceDB.chain.get('settings').get('cameras').cloneDeep().value(); await fs.ensureDir(Database.recordingsPath); const recordingsDirectory = (await fs.readdir(Database.recordingsPath)) || []; const recordings = recordingsDirectory .filter((rec) => rec && rec.includes('_CUI') && rec.endsWith('.jpeg')) .map((rec) => { let filePath = `${Database.recordingsPath}/${rec}`; let isPlaceholder = rec.endsWith('@2.jpeg'); let fileName = isPlaceholder ? rec.split('@2')[0] : rec.split('.')[0]; let extension = isPlaceholder ? 'mp4' : 'jpeg'; let id = rec.match(/(?<=-)([\dA-z]{10})(?=-)/)[0]; let timestamp = rec.match(/(?<=-)(\d{10})(?=_)/)[0]; let cameraName = rec.match(/(.*?)(?=-[\dA-z]{10})/)[0]?.replace(/_/g, ' '); let cameraSetting = cameras.find((camera) => camera?.name === cameraName); const jpeg = fs.readFileSync(filePath); let exifPayload; let readableExifPayload = {}; try { exifPayload = piexif.load(jpeg.toString('binary')); } catch (error) { log.debug(`Can not read exif data of ${rec}: ${error.message}`, false); } readableExifPayload = exifPayload ? Database.#getReadableExifPayload(exifPayload) : { label: 'no label' }; return { id: id, camera: cameraName, fileName: `${fileName}.${extension}`, name: fileName, extension: extension, recordStoring: true, recordType: isPlaceholder ? 'Video' : 'Snapshot', trigger: rec.includes('_m') ? 'motion' : rec.includes('_d') ? 'doorbell' : 'custom', room: cameraSetting?.room || 'Standard', time: moment.unix(timestamp).format('YYYY-MM-DD HH:mm:ss'), timestamp: timestamp, label: readableExifPayload.label, }; }); Database.recordingsDB.chain.assign({ path: Database.recordingsPath, recordings: recordings }).value(); } static async resetDatabase() { Database.interfaceDB.data = defaultDatabase; Database.interfaceDB.chain = lodash.chain(Database.interfaceDB.data); await Database.interfaceDB.write(); } static async #ensureDatabaseValues() { let database = await Database.interfaceDB.chain.cloneDeep().value(); if (typeof database?.version !== 'string') { database.version = defaultDatabase.version; } if (typeof database?.firstStart !== 'boolean') { database.firstStart = defaultDatabase.firstStart; } if (!Array.isArray(database?.cameras)) { database.cameras = defaultDatabase.cameras; } if (!Array.isArray(database?.notifications)) { database.notifications = defaultDatabase.notifications; } if (!Array.isArray(database?.users)) { database.users = defaultDatabase.users; } if (typeof database?.settings !== 'object') { database.settings = defaultDatabase.settings; } if (typeof database?.settings.aws !== 'object') { database.settings.aws = defaultDatabase.settings.aws; } if (!Array.isArray(database?.settings.cameras)) { database.settings.cameras = defaultDatabase.settings.cameras; } if (typeof database?.settings.camview !== 'object') { database.settings.camview = defaultDatabase.settings.camview; } if (typeof database?.settings.dashboard !== 'object') { database.settings.dashboard = defaultDatabase.settings.dashboard; } if (typeof database?.settings.general !== 'object') { database.settings.general = defaultDatabase.settings.general; } if (typeof database?.settings.general.automation !== 'object') { database.settings.general.automation = defaultDatabase.settings.general.automation; } if (typeof database?.settings.notifications !== 'object') { database.settings.notifications = defaultDatabase.settings.notifications; } if (typeof database?.settings.notifications.alexa !== 'object') { database.settings.notifications.alexa = defaultDatabase.settings.notifications.alexa; } if (typeof database?.settings.notifications.telegram !== 'object') { database.settings.notifications.telegram = defaultDatabase.settings.notifications.telegram; } if (typeof database?.settings.notifications.webhook !== 'object') { database.settings.notifications.webhook = defaultDatabase.settings.notifications.webhook; } if (typeof database?.settings.recordings !== 'object') { database.settings.recordings = defaultDatabase.settings.recordings; } if (typeof database?.settings.webpush !== 'object') { database.settings.webpush = defaultDatabase.settings.webpush; } if (typeof database?.settings.widgets !== 'object') { database.settings.widgets = defaultDatabase.settings.widgets; } if (typeof database?.settings.widgets.options !== 'object') { database.settings.widgets.options = defaultDatabase.settings.widgets.options; } if (!Array.isArray(database?.settings.widgets.items)) { database.settings.widgets.items = defaultDatabase.settings.widgets.items; } await Database.interfaceDB.chain.assign(database).value(); } static #ensureCameraDatabaseValues(settings) { if (!settings.name) { settings.name = defaultCameraSettingsEntry.name; } if (!settings.room) { settings.room = defaultCameraSettingsEntry.room; } if (!settings.resolution) { settings.resolution = defaultCameraSettingsEntry.resolution; } if (!settings.pingTimeout >= 1) { settings.pingTimeout = defaultCameraSettingsEntry.pingTimeout; } if (!settings.streamTimeout >= 1) { settings.streamTimeout = defaultCameraSettingsEntry.streamTimeout; } if (typeof settings.audio !== 'boolean') { settings.audio = defaultCameraSettingsEntry.audio; } if (!settings.telegramType) { settings.telegramType = defaultCameraSettingsEntry.telegramType; } if (typeof settings.alexa !== 'boolean') { settings.alexa = defaultCameraSettingsEntry.alexa; } if (!settings.webhookUrl) { settings.webhookUrl = defaultCameraSettingsEntry.webhookUrl; } if (!settings.mqttTopic) { settings.mqttTopic = defaultCameraSettingsEntry.mqttTopic; } if (typeof settings.privacyMode !== 'boolean') { settings.privacyMode = defaultCameraSettingsEntry.privacyMode; } if (typeof settings.camview !== 'object') { settings.camview = {}; } if (typeof settings.camview.favourite !== 'boolean') { settings.camview.favourite = defaultCameraSettingsEntry.camview.favourite; } if (typeof settings.camview.live !== 'boolean') { settings.camview.live = defaultCameraSettingsEntry.camview.live; } if (!(settings.camview.snapshotTimer >= 1)) { settings.camview.snapshotTimer = defaultCameraSettingsEntry.camview.snapshotTimer; } if (typeof settings.dashboard !== 'object') { settings.dashboard = {}; } if (typeof settings.dashboard.live !== 'boolean') { settings.dashboard.live = defaultCameraSettingsEntry.dashboard.live; } if (!(settings.dashboard.snapshotTimer >= 1)) { settings.dashboard.snapshotTimer = defaultCameraSettingsEntry.dashboard.snapshotTimer; } if (typeof settings.rekognition !== 'object') { settings.rekognition = {}; } if (typeof settings.rekognition.active !== 'boolean') { settings.rekognition.active = defaultCameraSettingsEntry.rekognition.active; } if (!(settings.rekognition.confidence >= 0 && settings.rekognition.confidence <= 100)) { settings.rekognition.confidence = defaultCameraSettingsEntry.rekognition.confidence; } if (!Array.isArray(settings.rekognition.labels)) { settings.rekognition.labels = defaultCameraSettingsEntry.rekognition.labels; } if (typeof settings.videoanalysis !== 'object') { settings.videoanalysis = {}; } if (!(settings.videoanalysis.forceCloseTimer >= 0 && settings.videoanalysis.forceCloseTimer <= 10)) { settings.videoanalysis.forceCloseTimer = defaultCameraSettingsEntry.videoanalysis.forceCloseTimer; } if (!(settings.videoanalysis.dwellTimer >= 15)) { settings.videoanalysis.dwellTimer = defaultCameraSettingsEntry.videoanalysis.dwellTimer; } if (!(settings.videoanalysis.difference >= 0 && settings.videoanalysis.difference <= 255)) { settings.videoanalysis.difference = defaultCameraSettingsEntry.videoanalysis.difference; } if (!(settings.videoanalysis.sensitivity >= 0 && settings.videoanalysis.sensitivity <= 100)) { settings.videoanalysis.sensitivity = defaultCameraSettingsEntry.videoanalysis.sensitivity; } if (!Array.isArray(settings.videoanalysis.regions) || !settings.videoanalysis.regions?.length) { settings.videoanalysis.regions = defaultCameraSettingsEntry.videoanalysis.regions; } return settings; } static async #initializeUser() { const userEntries = await Database.interfaceDB.chain.get('users').cloneDeep().value(); if (userEntries.length === 0) { let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512', salt).update('master').digest('base64'); const admin = { id: 1, username: 'master', password: salt + '$' + hash, sessionTimer: 14400, //4h photo: 'no_img.png', permissionLevel: ['admin'], }; await Database.interfaceDB.chain.get('users').push(admin).value(); } } static async addCameraToDB(camera) { const cameraSettingsEntry = { ...defaultCameraSettingsEntry }; cameraSettingsEntry.name = camera.name; await Database.interfaceDB.chain.get('cameras').push(camera).value(); await Database.interfaceDB.chain.get('settings').get('cameras').push(cameraSettingsEntry).value(); return camera; } static async writeConfigCamerasToDB() { await Database.interfaceDB.chain .get('cameras') .remove((x) => ConfigService.ui.cameras.filter((y) => y && y.name === x.name).length === 0) .value(); await Database.interfaceDB.chain .get('settings') .get('cameras') .remove((x) => ConfigService.ui.cameras.filter((y) => y && y.name === x.name).length === 0) .value(); await Database.interfaceDB.chain .get('settings') .get('widgets') .get('items') .remove( (x) => x.type === 'CamerasWidget' && ConfigService.ui.cameras.filter((y) => y && y.name === x.id).length === 0 ) .value(); for (const cam of ConfigService.ui.cameras) { const cameraExists = await Database.interfaceDB.chain.get('cameras').find({ name: cam.name }).cloneDeep().value(); const cameraSettingsExists = await Database.interfaceDB.chain .get('settings') .get('cameras') .find({ name: cam.name }) .cloneDeep() .value(); await (cameraExists ? Database.interfaceDB.chain.get('cameras').find({ name: cam.name }).assign(cam).value() : Database.interfaceDB.chain.get('cameras').push(cam).value()); if (!cameraSettingsExists) { const cameraSettingsEntry = { ...defaultCameraSettingsEntry }; cameraSettingsEntry.name = cam.name; await Database.interfaceDB.chain.get('settings').get('cameras').push(cameraSettingsEntry).value(); } else { let camSetting = this.#ensureCameraDatabaseValues(cameraSettingsExists); await Database.interfaceDB.chain .get('settings') .get('cameras') .find({ name: cam.name }) .assign(camSetting) .value(); } } } static #getReadableExifPayload(exifPayload) { exifPayload = exifPayload || {}; const title = 40091; const comment = 40092; const author = 40093; const payload = exifPayload['0th'] || {}; return { camera: payload[title] ? Buffer.from(payload[title]).toString().replace(/\0/g, '') : 'Camera', label: payload[comment] ? Buffer.from(payload[comment]).toString().replace(/\0/g, '') : 'no label', author: payload[author] ? Buffer.from(payload[author]).toString().replace(/\0/g, '') : 'camera.ui', }; } }