diff --git a/build-backend/cameras/Eufy.js b/build-backend/cameras/Eufy.js new file mode 100644 index 0000000..af1b9ec --- /dev/null +++ b/build-backend/cameras/Eufy.js @@ -0,0 +1,43 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const node_url_1 = require("node:url"); +const RTSP_1 = __importDefault(require("./RTSP")); +class EufyCamera extends RTSP_1.default { + async init(settings) { + if (settings.useOid && !settings.oid) { + throw new Error(`Invalid object ID: "${settings.oid}"`); + } + // check parameters + if (!settings.useOid && (!settings.ip || typeof settings.ip !== 'string')) { + throw new Error(`Invalid IP: "${settings.ip}"`); + } + const _settings = JSON.parse(JSON.stringify(settings)); + if (settings.useOid) { + const url = await this.adapter.getForeignStateAsync(settings.oid); + const parts = settings.oid.split('.'); + parts.pop(); + parts.push('rtsp_stream'); + const rtspEnabled = await this.adapter.getForeignStateAsync(parts.join('.')); + if (rtspEnabled && !rtspEnabled.val) { + await this.adapter.setForeignStateAsync(parts.join('.'), true); + } + if (url?.val) { + const u = new node_url_1.URL(url.val); + _settings.ip = u.hostname; + _settings.port = u.port; + _settings.urlPath = u.pathname; + _settings.username = u.username; + this.decodedPassword = u.password; + } + } + else { + _settings.port = '80'; + _settings.urlPath = '/live0'; + } + await super.init(_settings); + } +} +exports.default = EufyCamera; diff --git a/build-backend/cameras/Generic.js b/build-backend/cameras/Generic.js new file mode 100644 index 0000000..1940d38 --- /dev/null +++ b/build-backend/cameras/Generic.js @@ -0,0 +1,124 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const sharp_1 = __importDefault(require("sharp")); +const moment_1 = __importDefault(require("moment/moment")); +class GenericCamera { + runningRequest = null; + settings; + adapter; + cacheTimeout = 0; + cache = null; + constructor(adapter) { + this.adapter = adapter; + } + async unload() { + // do nothing + } + async getCameraImage(querySettings) { + if (!this.settings) { + throw new Error('init not called'); + } + this.adapter.log.debug(`Request ${this.settings.type} ${this.settings.ip || this.settings.url || ''} ${this.settings.name}`); + const params = { + w: parseInt(querySettings?.w || this.settings.width, 10) || 0, + h: parseInt(querySettings?.h || this.settings.height, 10) || 0, + angle: parseInt(querySettings?.angle || this.settings.angle, 10) || 0, + }; + if (!querySettings?.noCache && + this.cache && + this.cache.ts > Date.now() && + JSON.stringify(this.cache.params) === JSON.stringify(params)) { + this.adapter.log.debug(`Take from cache ${this.settings.name} ${this.settings.type} ${this.settings.ip || this.settings.url}`); + return this.cache.data; + } + let data = await this.process(); + if (data) { + data = await this.resizeImage(data, params.w, params.h); + data = await this.rotateImage(data, params.angle); + data = await this.addTextToImage(data, this.settings.addTime ? this.adapter.config.dateFormat || 'LTS' : null, this.settings.title); + if (this.cacheTimeout) { + this.cache = { + data, + ts: Date.now() + this.cacheTimeout, + params: JSON.stringify(params), + }; + } + await this.adapter.writeFileAsync(this.adapter.namespace, `/${this.settings.name}.jpg`, Buffer.from(data.body)); + return data; + } + throw new Error('No data from camera'); + } + resizeImage(data, width, height) { + if (!sharp_1.default) { + this.adapter.log.warn('Module sharp is not installed. Please install it to resize images'); + return Promise.resolve({ body: data.body, contentType: 'image/jpeg' }); + } + if (!width && !height) { + return (0, sharp_1.default)(data.body) + .jpeg() + .toBuffer() + .then(body => ({ body, contentType: 'image/jpeg' })); + } + return (0, sharp_1.default)(data.body) + .resize(width || null, height || null) + .jpeg() + .toBuffer() + .then(body => ({ body, contentType: 'image/jpeg' })); + } + rotateImage(data, angle) { + if (!angle) { + return (0, sharp_1.default)(data.body) + .jpeg() + .toBuffer() + .then(body => ({ body, contentType: 'image/jpeg' })); + } + return (0, sharp_1.default)(data.body) + .rotate(angle) + .jpeg() + .toBuffer() + .then(body => ({ body, contentType: 'image/jpeg' })); + } + async addTextToImage(data, dateFormat, title) { + if (!dateFormat && !title) { + return data; + } + const date = dateFormat ? (0, moment_1.default)().locale(this.adapter.language).format(dateFormat) : ''; + const metadata = await (0, sharp_1.default)(data.body).metadata(); + const layers = []; + if (title) { + layers.push({ + input: { + text: { + text: title, + dpi: metadata.height * 0.2, + }, + }, + top: Math.round(metadata.height * 0.95), + left: Math.round(metadata.width * 0.01), + blend: 'add', + }); + } + if (date) { + layers.push({ + input: { + text: { + text: date, + dpi: metadata.height * 0.2, + }, + }, + top: Math.round(metadata.height * 0.02), + left: Math.round(metadata.width * 0.01), + blend: 'add', + }); + } + return (0, sharp_1.default)(data.body) + .composite(layers) + .jpeg() + .toBuffer() + .then(body => ({ body, contentType: 'image/jpeg' })); + } +} +exports.default = GenericCamera; diff --git a/build-backend/cameras/Hikam.js b/build-backend/cameras/Hikam.js new file mode 100644 index 0000000..ca5e013 --- /dev/null +++ b/build-backend/cameras/Hikam.js @@ -0,0 +1,21 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const RTSP_1 = __importDefault(require("./RTSP")); +// documentation https://www.wiwacam.com/de/mw1-minikamera-kurzanleitung-und-faq/ +// https://support.hikam.de/support/solutions/articles/16000070656-zugriff-auf-kameras-der-2-generation-via-onvif-f%C3%BCr-s6-q8-a7-2-generation- +class HikamCamera extends RTSP_1.default { + async init(settings) { + // check parameters + if (!settings.ip || typeof settings.ip !== 'string') { + throw new Error(`Invalid IP: "${settings.ip}"`); + } + const _settings = JSON.parse(JSON.stringify(settings)); + _settings.port = '554'; + _settings.urlPath = settings.quality === 'high' ? '/stream=0' : '/stream=1'; + await super.init(_settings); + } +} +exports.default = HikamCamera; diff --git a/build-backend/cameras/RTSP.js b/build-backend/cameras/RTSP.js new file mode 100644 index 0000000..b98edb1 --- /dev/null +++ b/build-backend/cameras/RTSP.js @@ -0,0 +1,302 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const child_process_1 = require("child_process"); +const node_fs_1 = require("node:fs"); +const node_path_1 = require("node:path"); +const sharp_1 = __importDefault(require("sharp")); +const fluent_ffmpeg_1 = __importDefault(require("fluent-ffmpeg")); +const Generic_1 = __importDefault(require("./Generic")); +class RTSP extends Generic_1.default { + decodedPassword; + streamSubscribes = null; + settings = { ip: '', id: 0, name: '', type: 'rtsp' }; + lastFrame = 0; + timeout; + url; + streaming = null; + ratio = 0; + constructor(adapter, streamingContext) { + super(adapter); + this.streamSubscribes = streamingContext; + } + init(settings) { + this.settings = settings; + if (!this.decodedPassword) { + this.decodedPassword = this.settings.password ? this.adapter.decrypt(this.settings.password) : ''; + } + this.timeout = parseInt((this.settings.timeout || this.adapter.config.defaultTimeout), 10) || 10000; + this.cacheTimeout = + parseInt((this.settings.cacheTimeout || this.adapter.config.defaultCacheTimeout), 10) || 10000; + // check parameters + if (!this.settings.ip || typeof this.settings.ip !== 'string') { + throw new Error(`Invalid IP: "${this.settings.ip}"`); + } + this.url = this.settings.url || this.getRtspURL(); + return Promise.resolve(); + } + async process() { + if (this.runningRequest instanceof Promise) { + return this.runningRequest; + } + this.adapter.log.debug(`[${this.settings.name}/${this.settings.type}/${this.settings.ip}] Requesting snapshot...`); + if (this.streaming?.lastBase64Frame) { + return Promise.resolve({ + body: Buffer.from(this.streaming.lastBase64Frame, 'base64'), + contentType: 'image/jpeg', + }); + } + const outputFileName = (0, node_path_1.normalize)(`${this.adapter.config.tempPath}/${this.settings.ip.replace(/[.:]/g, '_')}.jpg`); + this.runningRequest = this.getRtspSnapshot(outputFileName).then(async (body) => { + this.runningRequest = null; + this.adapter.log.debug(`[${this.settings.name}/${this.settings.type}/${this.settings.ip}] Snapshot done!`); + if (!this.ratio) { + // try to get width and height + const metadata = await (0, sharp_1.default)(body).metadata(); + this.ratio = metadata.width / metadata.height; + } + return { + body, + contentType: 'image/jpeg', + }; + }); + return this.runningRequest; + } + static maskPassword(str, password) { + if (password) { + password = encodeURIComponent(password) + .replace(/!/g, '%21') + .replace(/'/g, '%27') + .replace(/\(/g, '%28') + .replace(/\)/g, '%29') + .replace(/\*/g, '%2A'); + } + return str.replace(password || 'ABCGHFG', '******'); + } + static executeFFmpeg(params, path, timeout, debug, decodedPassword) { + const timeoutMs = timeout || 10000; + return new Promise((resolve, reject) => { + let paramArray; + if (params && !Array.isArray(params)) { + paramArray = params.split(' '); + } + else if (params) { + paramArray = params; + } + else { + paramArray = []; + } + debug && debug(`Executing ${path} ${RTSP.maskPassword(paramArray.join(' '), decodedPassword)}`); + const proc = (0, child_process_1.spawn)(path, paramArray); + proc.on('error', (err) => reject(new Error(err))); + const stdout = []; + const stderr = []; + proc.stdout.setEncoding('utf8'); + proc.stdout.on('data', (data) => stdout.push(data.toString('utf8'))); + proc.stderr.setEncoding('utf8'); + proc.stderr.on('data', (data) => stderr.push(data.toString('utf8'))); + let timeout = setTimeout(() => { + timeout = null; + proc.kill(); + reject(new Error('timeout')); + }, timeoutMs); + proc.on('close', (code) => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + code ? reject(new Error(stderr.join(''))) : resolve(stdout.join('')); + } + }); + }); + } + buildCommand(options, outputFileName) { + const parameters = ['-y']; + let password = this.decodedPassword; + if (options.username) { + // convert special characters + password = encodeURIComponent(password) + .replace(/!/g, '%21') + .replace(/'/g, '%27') + .replace(/\(/g, '%28') + .replace(/\)/g, '%29') + .replace(/\*/g, '%2A'); + } + options.prefix && parameters.push(options.prefix); + parameters.push(`-rtsp_transport`); + parameters.push(options.protocol || 'udp'); + parameters.push('-i'); + parameters.push(`rtsp://${options.username ? `${encodeURIComponent(options.username)}:${password}@` : ''}${options.ip}:${options.port || 554}${options.urlPath ? (options.urlPath.startsWith('/') ? options.urlPath : `/${options.urlPath}`) : ''}`); + parameters.push('-loglevel'); + parameters.push('error'); + if (options.originalWidth && options.originalHeight) { + parameters.push(`scale=${options.originalWidth}:${options.originalHeight}`); + } + parameters.push('-vframes'); + parameters.push('1'); + options.suffix && parameters.push(options.suffix); + parameters.push(outputFileName); + return parameters; + } + async getRtspSnapshot(outputFileName) { + const parameters = this.buildCommand(this.settings, outputFileName); + await RTSP.executeFFmpeg(parameters, this.adapter.config.ffmpegPath, this.timeout, (text) => this.adapter.log.debug(text), this.decodedPassword); + return (0, node_fs_1.readFileSync)(outputFileName); + } + getRtspURL() { + return `rtsp://${this.settings.username ? `${encodeURIComponent(this.settings.username)}:${this.decodedPassword}@` : ''}${this.settings.ip}:${this.settings.port || 554}${this.settings.urlPath ? (this.settings.urlPath.startsWith('/') ? this.settings.urlPath : `/${this.settings.urlPath}`) : ''}`; + } + // ffmpeg -rtsp_transport udp -i rtsp://localhost:8090/stream -c:a aac -b:a 160000 -ac 2 -s 854x480 -c:v libx264 -b:v 800000 -hls_time 10 -hls_list_size 2 -hls_flags delete_segments -start_number 1 playlist.m3u8 + async webStreaming(fromState) { + if (!fromState) { + await this.adapter.setState(`${this.settings.name}.running`, true, true); + } + if (!this.url) { + this.adapter.log.error(`[${this.settings.name}/${this.settings.type}/${this.settings.ip}] No URL for camera`); + throw new Error(`No URL for camera ${this.settings.name}`); + } + const desiredWidth = this.settings.width || 0; + if (this.streaming && this.streaming.width !== desiredWidth) { + // if width changed drastically + if (this.streaming.width && desiredWidth && Math.abs(this.streaming.width - desiredWidth) < 100) { + this.streaming.width = desiredWidth; + } + else { + // stop streaming + this.adapter.log.debug(`[${this.settings.name}/${this.settings.type}/${this.settings.ip}] Stopping streaming while requested width is ${desiredWidth}. was ${this.streaming.width}`); + await this.stopWebStreaming(); + // wait 3 seconds + await new Promise(resolve => setTimeout(resolve, 10000)); + } + } + if (!this.streaming) { + this.streaming = { + width: desiredWidth, + proc: null, + }; + this.adapter.log.debug(`[${this.settings.name}/${this.settings.type}/${this.settings.ip}] Starting streaming (${this.url.replace(/:[^@]+@/, ':****@')}), width: ${desiredWidth}`); + const command = (0, fluent_ffmpeg_1.default)(this.url) + .setFfmpegPath(this.adapter.config.ffmpegPath) + // .addInputOption('-preset', 'ultrafast') + .addInputOption('-rtsp_transport', 'tcp') + .addInputOption('-re') + .outputFormat('mjpeg') + .fps(2) + .addOptions('-q:v 0'); + this.streaming.proc = command; + if (desiredWidth) { + // first try to find the best scale + if (!this.ratio) { + const outputFileName = (0, node_path_1.normalize)(`${this.adapter.config.tempPath}/${this.settings.ip.replace(/[.:]/g, '_')}.jpg`); + const body = await this.getRtspSnapshot(outputFileName); + // try to get width and height + const metadata = await (0, sharp_1.default)(body).metadata(); + this.ratio = metadata.width / metadata.height; + } + command.addOptions(`-vf scale=${this.settings.width}:${Math.round(this.settings.width / this.ratio)}`); + } + command.on('end', async () => { + this.adapter.log.debug(`[${this.settings.name}/${this.settings.type}/${this.settings.ip}] Streaming stopped`); + await this.adapter.setState(`${this.settings.name}.stream`, '', true); + await this.adapter.setState(`${this.settings.name}.running`, false, true); + }); + command.on('error', async (err /* , stdout, stderr */) => { + if (this.streaming) { + await this.adapter.setState(`${this.settings.name}.stream`, '', true); + await this.adapter.setState(`${this.settings.name}.running`, false, true); + this.adapter.log.debug(`[${this.settings.name}/${this.settings.type}/${this.settings.ip}] Cannot process video: ${err.message}`); + } + else { + this.adapter.log.debug(`[${this.settings.name}/${this.settings.type}/${this.settings.ip}] Streaming stopped`); + } + }); + const ffStream = command.pipe(); + let chunks = Buffer.from([]); + this.lastFrame = 0; + this.streaming.monitor = this.adapter.setInterval(async () => { + if (Date.now() - this.lastFrame > 10000) { + if (this.streaming.monitor) { + this.adapter.clearInterval(this.streaming.monitor); + } + this.streaming.monitor = null; + this.adapter.log.debug(`[${this.settings.name}/${this.settings.type}/${this.settings.ip}] No data for 10 seconds. Stopping`); + await this.stopWebStreaming(); + } + }, 10000); + ffStream.on('data', chunk => { + if (chunk.length > 2 && chunk[0] === 0xff && chunk[1] === 0xd8) { + const frame = chunks.toString('base64'); + let found = false; + if (!this.lastFrame || Date.now() - this.lastFrame > 300) { + this.lastFrame = Date.now(); + console.log(`frame ${frame.length}`); + this.streaming.lastBase64Frame = frame; + const clientsToDelete = []; + const promises = []; + this.streamSubscribes?.forEach(sub => { + if (sub.camera === this.settings.name) { + found = true; + if (this.adapter.sendToUI) { + try { + promises.push(this.adapter.sendToUI({ clientId: sub.clientId, data: frame }).catch(e => { + if (e?.toString().includes('not registered')) { + // forget this client + clientsToDelete.push(sub.clientId); + } + this.adapter.log.warn(`Cannot send to UI: ${e}`); + })); + } + catch (e) { + if (e?.toString().includes('not registered')) { + // forget this client + clientsToDelete.push(sub.clientId); + } + this.adapter.log.warn(`Cannot send to UI: ${e}`); + } + } + } + }); + void Promise.all(promises).then(() => { + if (clientsToDelete.length) { + for (let i = this.streamSubscribes.length - 1; i >= 0; i--) { + if (clientsToDelete.includes(this.streamSubscribes[i].clientId)) { + this.streamSubscribes.splice(i, 1); + } + } + } + if (!found) { + return this.adapter.setState(`${this.settings.name}.stream`, frame, true); + } + }); + } + else { + console.log(`[${this.settings.name}/${this.settings.type}/${this.settings.ip}] skip frame ${frame.length}`); + } + chunks = chunk; + } + else { + chunks = Buffer.concat([chunks, chunk]); + } + }); + } + } + async stopWebStreaming() { + if (this.streaming) { + if (this.streaming.monitor) { + clearInterval(this.streaming.monitor); + this.streaming.monitor = null; + } + try { + this.streaming.proc.kill('KILL'); + } + catch (e) { + console.error(`[${this.settings.name}/${this.settings.type}/${this.settings.ip}] Cannot stop process: ${e}`); + } + await this.adapter.setState(`${this.settings.name}.stream`, '', true); + await this.adapter.setState(`${this.settings.name}.running`, false, true); + this.streaming = null; + } + } +} +exports.default = RTSP; diff --git a/build-backend/cameras/ReolinkE1.js b/build-backend/cameras/ReolinkE1.js new file mode 100644 index 0000000..6c14095 --- /dev/null +++ b/build-backend/cameras/ReolinkE1.js @@ -0,0 +1,21 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const RTSP_1 = __importDefault(require("./RTSP")); +// documentation https://reolink.com/wp-content/uploads/2017/01/Reolink-CGI-command-v1.61.pdf +class ReolinkE1Camera extends RTSP_1.default { + async init(settings) { + // check parameters + if (!settings.ip || typeof settings.ip !== 'string') { + throw new Error(`Invalid IP: "${settings.ip}"`); + } + const _settings = JSON.parse(JSON.stringify(settings)); + _settings.port = '554'; + _settings.urlPath = settings.quality === 'high' ? '/h264Preview_01_main' : '/h264Preview_01_sub'; + await super.init(_settings); + return Promise.resolve(); + } +} +exports.default = ReolinkE1Camera; diff --git a/build-backend/cameras/Url.js b/build-backend/cameras/Url.js new file mode 100644 index 0000000..7834f22 --- /dev/null +++ b/build-backend/cameras/Url.js @@ -0,0 +1,22 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const UrlBasicAuth_1 = __importDefault(require("./UrlBasicAuth")); +class UrlCamera extends UrlBasicAuth_1.default { + init(settings) { + // check parameters + if (!settings.url || + typeof settings.url !== 'string' || + (!settings.url.startsWith('http://') && !settings.url.startsWith('https://'))) { + throw new Error(`Invalid URL: "${settings.url}"`); + } + this.timeout = parseInt((settings.timeout || this.adapter.config.defaultTimeout), 10) || 2000; + this.cacheTimeout = + parseInt((settings.cacheTimeout || this.adapter.config.defaultCacheTimeout), 10) || 10000; + this.settings = settings; + return Promise.resolve(); + } +} +exports.default = UrlCamera; diff --git a/build-backend/cameras/UrlBasicAuth.js b/build-backend/cameras/UrlBasicAuth.js new file mode 100644 index 0000000..602fe78 --- /dev/null +++ b/build-backend/cameras/UrlBasicAuth.js @@ -0,0 +1,61 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const axios_1 = __importDefault(require("axios")); +const Generic_1 = __importDefault(require("./Generic")); +class UrlBasicAuthCamera extends Generic_1.default { + timeout = 0; + basicAuth = ''; + init(settings) { + // check parameters + if (!settings.url || + typeof settings.url !== 'string' || + (!settings.url.startsWith('http://') && !settings.url.startsWith('https://'))) { + throw new Error(`Invalid URL: "${settings.url}"`); + } + if (!settings.username || typeof settings.username !== 'string') { + throw new Error(`Invalid Username: "${settings.username}"`); + } + settings.password = settings.password || ''; + this.timeout = parseInt((settings.timeout || this.adapter.config.defaultTimeout), 10) || 2000; + this.cacheTimeout = + parseInt((settings.cacheTimeout || this.adapter.config.defaultCacheTimeout), 10) || 10000; + // Calculate basic authentication. The password was encrypted and must be decrypted + this.basicAuth = `Basic ${Buffer.from(`${settings.username}:${this.adapter.decrypt(settings.password)}`).toString('base64')}`; + this.settings = settings; + return Promise.resolve(); + } + async process() { + if (this.runningRequest instanceof Promise) { + return this.runningRequest; + } + this.runningRequest = axios_1.default + .get(this.settings.url, { + responseType: 'arraybuffer', + validateStatus: status => status < 400, + timeout: this.timeout, + headers: this.basicAuth ? { Authorization: this.basicAuth } : undefined, + }) + .then(response => { + this.runningRequest = null; + return { + body: response.data, + contentType: response.headers['Content-type'] || response.headers['content-type'] || 'image/jpeg', + }; + }) + .catch(error => { + if (error.response) { + this.adapter.log.error(`Cannot read ${this.settings.url}: ${error.response.data || error}`); + throw new Error(error.response.data || error.response.status); + } + else { + this.adapter.log.error(`Cannot read ${this.settings.url}: ${error}`); + throw new Error(error.code); + } + }); + return this.runningRequest; + } +} +exports.default = UrlBasicAuthCamera; diff --git a/build-backend/lib/web.js b/build-backend/lib/web.js new file mode 100644 index 0000000..f8c2604 --- /dev/null +++ b/build-backend/lib/web.js @@ -0,0 +1,125 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const http = __importStar(require("node:http")); +function getUrl(path, query, port) { + return new Promise((resolve, reject) => { + const queryStr = Object.keys(query) + .map(attr => `${attr}=${encodeURIComponent(query[attr])}`) + .join('&'); + http.get(`http://127.0.0.1:${port}/${path}${queryStr ? `?${queryStr}` : ''}`, (res) => { + const { statusCode } = res; + const contentType = res.headers['content-type']; + if (statusCode !== 200) { + // Consume response data to free-up memory + res.resume(); + return reject(new Error(`Request Failed. Status Code: ${statusCode}`)); + } + const data = []; + res.on('data', (chunk) => data.push(chunk)); + res.on('end', () => resolve({ body: Buffer.concat(data), contentType })); + res.on('error', e => reject(new Error(e.message))); + }).on('error', e => reject(new Error((e.message || e).toString()))); + }); +} +/** + * Proxy class + * + * Read files from localhost server + * + * @param server http or https node.js object + * @param webSettings settings of the web server, like
{secure: settings.secure, port: settings.port}
+ * @param adapter web adapter object
+ * @param instanceSettings instance object with common and native
+ * @param app express application
+ */
+class ProxyCameras {
+ app;
+ config;
+ namespace;
+ route;
+ adapter;
+ constructor(_server, _webSettings, adapter, instanceSettings, app) {
+ this.app = app;
+ this.config = (instanceSettings ? instanceSettings.native : {});
+ this.namespace = instanceSettings ? instanceSettings._id.substring('system.adapter.'.length) : 'cameras';
+ // @ts-expect-error route could be defined
+ this.route = this.config.route || `${this.namespace}/`;
+ this.config.port = parseInt(this.config.port, 10) || 80;
+ // remove leading slash
+ if (this.route[0] === '/') {
+ this.route = this.route.substr(1);
+ }
+ this.adapter = adapter;
+ this.config.cameras.forEach(cam => this.oneCamera(cam));
+ }
+ oneCamera(rule) {
+ this.adapter.log.info(`Install extension on /${this.route}${rule.name}`);
+ this.app.use(`/${this.route}${rule.name}`, (req, res) => {
+ const parts = req.url.split('?');
+ const query = {};
+ (parts[1] || '').split('&').forEach(p => {
+ if (p && p.includes('=')) {
+ const pp = p.split('=');
+ query[pp[0]] = decodeURIComponent(pp[1] || '');
+ }
+ });
+ query.key = this.config.key;
+ if (req.path.match(/^\/streaming/)) {
+ getUrl(rule.name + req.path, query, this.config.port)
+ .then(file => {
+ const headers = {
+ 'Access-Control-Allow-Origin': '*' /* @dev First, read about security */,
+ 'Access-Control-Allow-Methods': 'OPTIONS, POST, GET',
+ 'Access-Control-Max-Age': 2592000, // 30 days
+ /** add other headers as per requirement */
+ };
+ res.set(headers);
+ if (req.method === 'OPTIONS') {
+ res.status(204);
+ res.end();
+ return;
+ }
+ res.setHeader('Content-type', file.contentType);
+ res.status(200).send(file.body || '');
+ })
+ .catch(error => res.status(500).send(typeof error !== 'string' ? JSON.stringify(error) : error));
+ return;
+ }
+ this.adapter.log.debug(`Request "${rule.name}" with "${JSON.stringify(query)} on port ${this.config.port}...`);
+ getUrl(rule.name, query, this.config.port)
+ .then(file => {
+ res.setHeader('Content-type', file.contentType);
+ res.status(200).send(file.body || '');
+ })
+ .catch(error => {
+ const text = error.response ? error.response.data || error : error;
+ this.adapter.log.error(`Cannot request "${rule.name}" with "${JSON.stringify(query)} on port ${this.config.port}: ${text}`);
+ res.status(500).send(typeof error !== 'string' ? JSON.stringify(error) : error);
+ });
+ });
+ }
+}
+module.exports = ProxyCameras;
diff --git a/build-backend/main.js b/build-backend/main.js
new file mode 100644
index 0000000..2cb5a0e
--- /dev/null
+++ b/build-backend/main.js
@@ -0,0 +1,567 @@
+"use strict";
+var __importDefault = (this && this.__importDefault) || function (mod) {
+ return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+const adapter_core_1 = require("@iobroker/adapter-core");
+const node_http_1 = require("node:http");
+const node_fs_1 = require("node:fs");
+const node_path_1 = require("node:path");
+const decompress_1 = __importDefault(require("decompress"));
+const Eufy_1 = __importDefault(require("./cameras/Eufy"));
+const Hikam_1 = __importDefault(require("./cameras/Hikam"));
+const ReolinkE1_1 = __importDefault(require("./cameras/ReolinkE1"));
+const RTSP_1 = __importDefault(require("./cameras/RTSP"));
+const Url_1 = __importDefault(require("./cameras/Url"));
+const UrlBasicAuth_1 = __importDefault(require("./cameras/UrlBasicAuth"));
+class CamerasAdapter extends adapter_core_1.Adapter {
+ streamSubscribes = [];
+ bruteForceInterval = null;
+ allowIPs;
+ bruteForce = {};
+ httpServer = null;
+ cameraInstances = {};
+ constructor(options = {}) {
+ options = {
+ ...options,
+ name: 'cameras', // adapter name
+ useFormatDate: true,
+ subscribable: true,
+ uiClientSubscribe: (data) => {
+ const { clientId, message } = data;
+ return this.onClientSubscribe(clientId, message);
+ },
+ uiClientUnsubscribe: (data) => {
+ const { clientId, message, reason } = data;
+ if (reason === 'client') {
+ this.log.debug(`GUI Client "${clientId} disconnected`);
+ }
+ else {
+ this.log.debug(`Client "${clientId}: ${reason}`);
+ }
+ return this.onClientUnsubscribe(clientId, message);
+ },
+ message: (obj) => this.processMessage(obj),
+ stateChange: async (id, state) => this.onStateChange(id, state),
+ ready: () => this.onReady(),
+ unload: (cb) => this.unload(cb),
+ };
+ super(options);
+ }
+ async onStateChange(id, state) {
+ if (state && !state.ack && id.endsWith('.running') && id.startsWith(this.namespace)) {
+ const parts = id.split('.');
+ const camera = parts[parts.length - 2];
+ if (state.val) {
+ try {
+ await this.startRtspStreaming(camera, true);
+ }
+ catch (e) {
+ this.log.error(`Cannot start camera ${camera}: ${e}`);
+ }
+ }
+ else {
+ this.log.debug(`Stop camera ${camera}`);
+ if (this.cameraInstances[camera] instanceof RTSP_1.default) {
+ await this.cameraInstances[camera].stopWebStreaming();
+ }
+ }
+ }
+ }
+ getCameraInstance(type) {
+ if (type === 'rtsp') {
+ return new RTSP_1.default(this, this.streamSubscribes);
+ }
+ if (type === 'eufy') {
+ return new Eufy_1.default(this, this.streamSubscribes);
+ }
+ if (type === 'url') {
+ return new Url_1.default(this);
+ }
+ if (type === 'urlBasicAuth') {
+ return new UrlBasicAuth_1.default(this);
+ }
+ if (type === 'hikam') {
+ return new Hikam_1.default(this, this.streamSubscribes);
+ }
+ if (type === 'reolinkE1') {
+ return new ReolinkE1_1.default(this, this.streamSubscribes);
+ }
+ throw new Error(`Unsupported camera type: ${type}`);
+ }
+ async onReady() {
+ this.streamSubscribes = [];
+ if (!this.config.ffmpegPath && process.platform === 'win32' && !(0, node_fs_1.existsSync)(`${__dirname}/win-ffmpeg.exe`)) {
+ this.log.info('Decompress ffmpeg.exe...');
+ await (0, decompress_1.default)(`${__dirname}/win-ffmpeg.zip`, __dirname);
+ }
+ this.language = this.config.language || this.language || 'en';
+ this.config.tempPath = this.config.tempPath || `${__dirname}/snapshots`;
+ this.config.defaultCacheTimeout = parseInt(this.config.defaultCacheTimeout, 10) || 0;
+ if (!(0, node_fs_1.existsSync)(this.config.ffmpegPath) && !(0, node_fs_1.existsSync)(`${this.config.ffmpegPath}.exe`)) {
+ if (process.platform === 'win32') {
+ this.config.ffmpegPath = `${__dirname}/win-ffmpeg.exe`;
+ }
+ else {
+ this.log.error(`Cannot find ffmpeg in "${this.config.ffmpegPath}"`);
+ }
+ }
+ try {
+ if (!(0, node_fs_1.existsSync)(this.config.tempPath)) {
+ (0, node_fs_1.mkdirSync)(this.config.tempPath);
+ this.log.debug(`Create snapshots directory: ${(0, node_path_1.normalize)(this.config.tempPath)}`);
+ }
+ }
+ catch (e) {
+ this.log.error(`Cannot create snapshots directory: ${e}`);
+ }
+ let migrate = false;
+ this.config.cameras = this.config.cameras.filter(cam => cam.enabled !== false);
+ // init all required camera providers
+ for (const item of this.config.cameras) {
+ if (item?.type) {
+ if (!item.rtsp &&
+ (item?.type === 'eufy' ||
+ item?.type === 'rtsp' ||
+ item?.type === 'reolinkE1' ||
+ item?.type === 'hikam')) {
+ migrate = true;
+ }
+ this.cameraInstances[item.name] = this.getCameraInstance(item.type);
+ await this.cameraInstances[item.name].init(item);
+ }
+ }
+ if (migrate) {
+ this.log.info('Migrate config to new format');
+ const obj = await this.getForeignObjectAsync(`system.adapter.${this.namespace}`);
+ this.config.cameras.forEach(item => {
+ if (item?.type === 'eufy' ||
+ item?.type === 'rtsp' ||
+ item?.type === 'reolinkE1' ||
+ item?.type === 'hikam') {
+ item.rtsp = true;
+ }
+ });
+ await this.setForeignObjectAsync(`system.adapter.${this.namespace}`, obj);
+ // adapter will be restarted
+ return;
+ }
+ if (typeof this.config.allowIPs === 'string') {
+ this.allowIPs = this.config.allowIPs
+ .split(/,;/)
+ .map(i => i.trim())
+ .filter(i => i);
+ if (this.allowIPs.find(i => i === '*')) {
+ this.allowIPs = true;
+ }
+ }
+ else {
+ this.allowIPs = this.config.allowIPs;
+ }
+ // garbage collector
+ this.bruteForceInterval = this.setInterval(() => {
+ const now = Date.now();
+ Object.keys(this.bruteForce).forEach(ip => {
+ if (now - this.bruteForce[ip] > 5000) {
+ delete this.bruteForce[ip];
+ }
+ });
+ }, 30000);
+ this.subscribeStates('*');
+ await this.syncData();
+ await this.syncConfig();
+ await this.fillFiles();
+ this.startWebServer();
+ }
+ unload(cb) {
+ if (this.bruteForceInterval) {
+ this.clearInterval(this.bruteForceInterval);
+ this.bruteForceInterval = null;
+ }
+ const promises = [];
+ Object.keys(this.cameraInstances).forEach(name => this.cameraInstances[name] && promises.push(this.cameraInstances[name].unload()));
+ void Promise.all(promises).then(() => {
+ if (this.httpServer) {
+ this.httpServer.close(cb);
+ this.httpServer = null;
+ }
+ else {
+ cb && cb();
+ }
+ });
+ }
+ async testCamera(item) {
+ if (item?.type) {
+ let result = null;
+ // load camera module
+ const cameraInstance = this.getCameraInstance(item.type);
+ try {
+ await this.cameraInstances[item.name].init(item);
+ }
+ catch (e) {
+ this.log.error(`Cannot load "${item.type}": ${e}`);
+ throw new Error(`Cannot load "${item.type}"`);
+ }
+ // init camera
+ // get image
+ let data = await cameraInstance.process();
+ if (data?.body) {
+ data = await cameraInstance.resizeImage(data, item.width, item.height);
+ data = await cameraInstance.rotateImage(data, item.angle);
+ data = await cameraInstance.addTextToImage(data, item.addTime ? this.config.dateFormat || 'LTS' : null, item.title);
+ result = {
+ body: `data:${data.contentType};base64,${data.body.toString('base64')}`,
+ contentType: data.contentType,
+ };
+ }
+ else {
+ throw new Error('No answer');
+ }
+ // unload camera
+ await cameraInstance.unload();
+ return result;
+ }
+ throw new Error('Unknown type or invalid parameters');
+ }
+ async startRtspStreaming(camera, fromState) {
+ if (this.cameraInstances[camera]) {
+ await this.cameraInstances[camera].webStreaming(fromState);
+ }
+ else {
+ // the camera does not support RTSP streaming
+ this.log.warn(`Camera "${camera}" does not support RTSP streaming`);
+ throw new Error("Camera doesn't support RTSP streaming");
+ }
+ }
+ async onClientSubscribe(clientId, obj) {
+ this.log.debug(`Subscribe from ${clientId}: ${JSON.stringify(obj.message)}`);
+ if (!this.streamSubscribes) {
+ return { error: 'Adapter is still initializing', accepted: false };
+ }
+ const message = obj.message;
+ if (!Array.isArray(message.type)) {
+ message.type = [message.type];
+ }
+ for (const type of message.type) {
+ if (type?.startsWith('startCamera/')) {
+ const camera = type.substring('startCamera/'.length);
+ // start camera with message.data
+ if (!this.streamSubscribes.find(s => s.camera === camera)) {
+ this.log.debug(`Start camera "${camera}"`);
+ }
+ try {
+ await this.startRtspStreaming(camera /*, message.data */);
+ }
+ catch (e) {
+ this.log.error(`Cannot start camera on subscribe "${camera}": ${e}`);
+ return {
+ error: `Cannot start camera on subscribe "${camera}": ${e}`,
+ accepted: false,
+ };
+ }
+ // inform GUI that camera is started
+ const sub = this.streamSubscribes.find(s => s.clientId === clientId && s.camera === camera);
+ if (!sub) {
+ this.streamSubscribes.push({ clientId, camera, ts: Date.now() });
+ }
+ else {
+ sub.ts = Date.now();
+ }
+ }
+ }
+ return { accepted: true, heartbeat: 60000 };
+ }
+ onClientUnsubscribe(clientId, obj) {
+ this.log.debug(`Unsubscribe from ${clientId}: ${JSON.stringify(obj?.message)}`);
+ if (!this.streamSubscribes) {
+ return;
+ }
+ if (!obj?.message?.type) {
+ return;
+ }
+ const message = obj.message;
+ if (!Array.isArray(message.type)) {
+ message.type = [message.type];
+ }
+ for (const type of message.type) {
+ if (type?.startsWith('startCamera/')) {
+ const camera = type.substring('startCamera/'.length);
+ let deleted;
+ do {
+ deleted = false;
+ const pos = this.streamSubscribes.findIndex(s => s.clientId === clientId);
+ if (pos !== -1) {
+ deleted = true;
+ this.streamSubscribes.splice(pos, 1);
+ // check if anyone else subscribed on this camera
+ if (!this.streamSubscribes.find(s => s.camera === camera || Date.now() - s.ts > 60000)) {
+ // stop camera
+ this.log.debug(`Stop camera "${camera}"`);
+ if (this.cameraInstances[camera]) {
+ this.cameraInstances[camera]
+ .stopWebStreaming()
+ .catch(e => this.log.error(`Cannot stop streaming: ${e}`));
+ }
+ }
+ }
+ } while (deleted);
+ }
+ }
+ }
+ async processMessage(obj) {
+ if (!obj || !obj.command) {
+ return;
+ }
+ switch (obj.command) {
+ case 'test': {
+ try {
+ const data = await this.testCamera(obj.message);
+ obj.callback && this.sendTo(obj.from, obj.command, data, obj.callback);
+ }
+ catch (e) {
+ obj.callback && this.sendTo(obj.from, obj.command, { error: e.toString() }, obj.callback);
+ }
+ break;
+ }
+ case 'image': {
+ if (obj.message) {
+ if (this.cameraInstances[obj.message.name] && obj.callback) {
+ try {
+ const data = await this.cameraInstances[obj.message.name].getCameraImage(obj.message);
+ this.sendTo(obj.from, obj.command, { data: Buffer.from(data.body).toString('base64'), contentType: data.contentType }, obj.callback);
+ }
+ catch (e) {
+ this.sendTo(obj.from, obj.command, { error: e }, obj.callback);
+ }
+ }
+ else {
+ obj.callback && this.sendTo(obj.from, obj.command, { error: 'Name not found' }, obj.callback);
+ }
+ }
+ else {
+ obj.callback && this.sendTo(obj.from, obj.command, { error: 'Invalid request' }, obj.callback);
+ }
+ break;
+ }
+ case 'list': {
+ obj.callback &&
+ this.sendTo(obj.from, obj.command, {
+ list: this.config.cameras.map(cam => ({
+ name: cam.name,
+ desc: cam.desc,
+ id: `${this.namespace}.cameras.${cam.name}`,
+ })),
+ }, obj.callback);
+ break;
+ }
+ case 'ffmpeg': {
+ if (obj.callback && obj.message) {
+ RTSP_1.default.executeFFmpeg('-version', obj.message.path)
+ .then(data => {
+ if (data) {
+ const result = data.split('\n')[0];
+ const version = result.match(/version\s+([-\w.]+)/i);
+ if (version) {
+ this.sendTo(obj.from, obj.command, { version: version[1] }, obj.callback);
+ }
+ else {
+ this.sendTo(obj.from, obj.command, { version: result }, obj.callback);
+ }
+ }
+ else {
+ this.sendTo(obj.from, obj.command, { error: 'No answer' }, obj.callback);
+ }
+ })
+ .catch(error => this.sendTo(obj.from, obj.command, { error }, obj.callback));
+ }
+ break;
+ }
+ }
+ }
+ startWebServer() {
+ this.log.debug(`Starting web server on http://127.0.0.1:${this.config.port}/`);
+ this.httpServer = (0, node_http_1.createServer)(async (req, res) => {
+ const clientIp = req.socket.remoteAddress;
+ if (!clientIp) {
+ res.statusCode = 401;
+ res.write('Invalid key');
+ res.end();
+ this.log.debug(`Invalid key from unknown IP`);
+ return;
+ }
+ const parts = (req.url || '').split('?');
+ const camName = parts[0].replace(/^\//, '');
+ const query = {};
+ (parts[1] || '').split('&').forEach(p => {
+ const pp = p.split('=');
+ query[pp[0]] = decodeURIComponent(pp[1] || '');
+ });
+ const now = Date.now();
+ if (this.bruteForce[clientIp] && now - this.bruteForce[clientIp] < 5000 && query.key !== this.config.key) {
+ this.bruteForce[clientIp] = now;
+ res.statusCode = 429;
+ res.write('Blocked for 5 seconds');
+ res.end();
+ return;
+ }
+ if (query.key !== this.config.key) {
+ this.bruteForce[clientIp] = Date.now();
+ res.statusCode = 401;
+ res.write('Invalid key');
+ res.end();
+ this.log.debug(`Invalid key from ${clientIp}. Expected "${this.config.key}", Received "${query.key}"`);
+ return;
+ }
+ if (clientIp !== '127.0.0.1' &&
+ clientIp !== '::1/128' &&
+ this.config.allowIPs !== true &&
+ !this.config.allowIPs.includes(clientIp)) {
+ res.statusCode = 401;
+ res.write('Invalid key');
+ res.end();
+ this.log.debug(`Invalid key from ${clientIp}. Expected ${this.config.key}`);
+ return;
+ }
+ const noCache = query.noCache === 'true' || query.noCache === '1';
+ if (camName && this.cameraInstances[camName]) {
+ try {
+ const data = await this.cameraInstances[camName].getCameraImage({ ...query, noCache });
+ res.setHeader('Content-type', data.contentType);
+ res.write(data.body || '');
+ res.end();
+ }
+ catch (e) {
+ res.statusCode = 500;
+ res.write(`Unknown error: ${e}`);
+ res.end();
+ }
+ }
+ else {
+ res.statusCode = 501;
+ res.write(`Unknown camera name: ${camName}`);
+ res.end();
+ }
+ });
+ this.httpServer.on('clientError', (_err, socket) => socket.end('HTTP/1.1 400 Bad Request\r\n\r\n'));
+ this.httpServer.listen({ port: this.config.port || '127', host: '127.0.0.1' }, () => this.log.info(`Server started on 127.0.0.1:${this.config.port}`));
+ }
+ async syncConfig() {
+ try {
+ const files = await this.readDirAsync(this.namespace, '/');
+ for (let f = 0; f < files.length; f++) {
+ const file = files[f];
+ if (!this.config.cameras.find(item => `${item.name}.jpg` === file.file)) {
+ try {
+ await this.delFileAsync(this.namespace, file.file);
+ }
+ catch (e) {
+ this.log.error(`Cannot delete file ${file.file}: ${e}`);
+ }
+ }
+ }
+ }
+ catch {
+ // ignore
+ }
+ }
+ // do not convert this function to async, as all cameras must get the first images simultaneously
+ fillFiles() {
+ // write all states with actual images one time at the start
+ const promises = [];
+ Object.keys(this.cameraInstances).forEach(name => promises.push(this.cameraInstances[name]
+ .getCameraImage()
+ .catch(e => this.log.error(`Cannot get image: ${e}`))
+ .then(() => { })));
+ return Promise.all(promises).then(() => { });
+ }
+ async syncData() {
+ const states = await this.getStatesOfAsync('');
+ let running;
+ let stream;
+ // create new states
+ for (let c = 0; c < this.config.cameras.length; c++) {
+ try {
+ running = await this.getObjectAsync(`${this.config.cameras[c].name}.running`);
+ }
+ catch {
+ // ignore
+ }
+ if (!running) {
+ try {
+ await this.setObjectAsync(`${this.config.cameras[c].name}.running`, {
+ type: 'state',
+ common: {
+ name: `${this.config.cameras[c].name}.running`,
+ type: 'boolean',
+ role: 'indicator',
+ read: true,
+ write: true,
+ },
+ native: {},
+ });
+ }
+ catch {
+ // ignore
+ }
+ }
+ const stateRunning = await this.getStateAsync(`${this.config.cameras[c].name}.running`);
+ if (stateRunning && stateRunning.val && !stateRunning.ack) {
+ this.log.debug(`Start camera ${this.config.cameras[c].name}`);
+ try {
+ await this.startRtspStreaming(this.config.cameras[c].name, true);
+ }
+ catch (e) {
+ this.log.error(`Cannot start camera ${this.config.cameras[c].name}: ${e}`);
+ }
+ }
+ try {
+ stream = await this.getObjectAsync(`${this.config.cameras[c].name}.stream`);
+ }
+ catch {
+ // ignore
+ }
+ if (!stream) {
+ try {
+ await this.setObjectAsync(`${this.config.cameras[c].name}.stream`, {
+ type: 'state',
+ common: {
+ name: `${this.config.cameras[c].name}.stream`,
+ type: 'string',
+ role: 'indicator',
+ read: true,
+ write: false,
+ },
+ native: {},
+ });
+ }
+ catch {
+ // ignore
+ }
+ }
+ }
+ // delete old states
+ for (let s = 0; s < states.length; s++) {
+ if (states[s]._id.match(/\.running$/) || states[s]._id.match(/\.stream$/)) {
+ const parts = states[s]._id.split('.');
+ parts.pop();
+ const name = parts.pop();
+ if (!this.config.cameras.find(cam => cam.name === name)) {
+ try {
+ await this.delObjectAsync(states[s]._id);
+ }
+ catch {
+ // ignore
+ }
+ }
+ }
+ }
+ }
+}
+if (require.main !== module) {
+ // Export the constructor in compact mode
+ module.exports = (options) => new CamerasAdapter(options);
+}
+else {
+ // otherwise start the instance directly
+ (() => new CamerasAdapter())();
+}