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())(); +}