UNPKG

homebridge-hikvision-local

Version:

Homebridge plugin that connects to your HikVision DVR via a local connection and exposes your cameras in Homebridge. The plugin is heavily based on excellent [homebridge-camera-ffmpeg](https://github.com/Sunoo/homebridge-camera-ffmpeg) and the various hom

203 lines 8.71 kB
import https from 'https'; import { XMLParser } from 'fast-xml-parser'; import { MultipartXmlStreamParser } from './lib/MultiPartXMLStreamParser.js'; import { createLoggedDigestFetch } from './lib/loggedDigestFetch.js'; export class HikvisionApi { constructor(config, log) { this.connected = false; this.abortController = null; this.isStreaming = false; this._baseURL = `http${config.secure ? 's' : ''}://${config.host}`; this.config = config; this.xmlParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '', }); this.log = log; this.client = createLoggedDigestFetch(this.config.username, this.config.password, { algorithm: 'MD5', timeout: 8000, agent: new https.Agent({ rejectUnauthorized: !this.config.ignoreInsecureTls }), }, (msg) => this.log.debug(msg)); } /* "DeviceInfo": { "$": { "version": "2.0", "xmlns": "http://www.isapi.org/ver20/XMLSchema" }, "deviceName": "Network Video Recorder", "deviceID": "48443030-3637-3534-3837-f84dfcf8ef1c", "model": "DS-7608NI-I2/8P", "serialNumber": "DS-7608NI-I2/8P0820190316CCRRD00675487WCVU", "macAddress": "f8:4d:fc:f8:ef:1c", "firmwareVersion": "V4.22.005", "firmwareReleasedDate": "build 191208", "encoderVersion": "V5.0", "encoderReleasedDate": "build 191208", "deviceType": "NVR", "telecontrolID": "255" } */ async getSystemInfo() { return this._getResponse('/ISAPI/System/deviceInfo'); } async getCameras() { const channels = await this._getResponse('/ISAPI/System/Video/inputs/channels'); if (channels.VideoInputChannelList) { for (let i = 0; i < channels.VideoInputChannelList.VideoInputChannel.length; i++) { const channel = channels.VideoInputChannelList.VideoInputChannel[i]; if (channel.resDesc !== 'NO VIDEO') { channel.capabilities = await this._getResponse(`/ISAPI/ContentMgmt/StreamingProxy/channels/${channel.id}01/capabilities`); } channel.status = { online: channel.resDesc !== 'NO VIDEO' }; } return channels.VideoInputChannelList.VideoInputChannel.filter((camera) => camera.status.online); } else { const channels2 = await this._getResponse('/ISAPI/ContentMgmt/InputProxy/channels'); for (let i = 0; i < channels2.InputProxyChannelList.InputProxyChannel.length; i++) { const channel = channels2.InputProxyChannelList.InputProxyChannel[i]; if (channel.resDesc !== 'NO VIDEO') { channel.capabilities = await this._getResponse(`/ISAPI/ContentMgmt/StreamingProxy/channels/${channel.id}01/capabilities`); } channel.status = { online: channel.resDesc !== 'NO VIDEO' }; } return channels2.InputProxyChannelList.InputProxyChannel.filter((camera) => camera.status.online); } } async startMonitoringEvents(callback) { const url = `${this._baseURL}/ISAPI/Event/notification/alertStream`; const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '' }); const startStream = async () => { if (this.isStreaming) { this.log.debug('Stream already active, skipping new connection'); return; } this.isStreaming = true; this.abortController = new AbortController(); try { const res = await this.client.fetch(url, { method: 'GET', headers: { Accept: 'multipart/mixed', // Adjusted for Hikvision event stream }, signal: this.abortController.signal, }); if (res.status === 403) { this.log.warn('Received 403 — likely expired digest nonce. Reconnecting...'); this.isStreaming = false; setTimeout(startStream, 5000); return; } if (!res.ok || !res.body) { this.log.error(`Stream connection failed: ${res.status} -> ${res.statusText}`); this.isStreaming = false; setTimeout(startStream, 30000); return; } const streamParser = new MultipartXmlStreamParser(); streamParser.on('message', (part) => { try { const event = parser.parse(part.body); callback(event); } catch (e) { this.log.error(`Failed to parse XML: ${e instanceof Error ? e.message : String(e)}`); this.log.debug(`Fragment: ${part.body}`); } }); streamParser.on('error', (err) => { this.log.error(`Stream parse error: ${err}`); }); const reader = res.body.getReader(); const decoder = new TextDecoder(); const pump = async () => { try { const { value, done } = await reader.read(); if (done) { this.log.warn('Stream ended. Reconnecting...'); this.isStreaming = false; setTimeout(startStream, 30000); return; } streamParser.write(decoder.decode(value, { stream: true })); await pump(); } catch (err) { this.log.error(`Stream read error: ${err.message}`); this.isStreaming = false; setTimeout(startStream, 30000); } }; await pump(); } catch (err) { if (err.name === 'AbortError') { this.log.debug('Stream aborted'); } else { this.log.error(`Stream error: ${err.message}`); this.isStreaming = false; setTimeout(startStream, 30000); } } finally { if (this.isStreaming) { this.isStreaming = false; } } }; startStream(); } stopMonitoringEvents() { if (this.abortController) { this.abortController.abort(); this.abortController = null; } this.isStreaming = false; this.log.debug('Stream stopped'); } async _getResponse(path) { try { const url = `${this._baseURL}${path}`; const client = createLoggedDigestFetch(this.config.username, this.config.password, { algorithm: 'MD5', timeout: 8000, agent: new https.Agent({ rejectUnauthorized: !this.config.ignoreInsecureTls }), }, (msg) => this.log.debug(msg)); // this.log.debug(`➡️ Fetching URL: ${url}`); const res = await client.fetch(url, { method: 'GET', headers: { Accept: 'application/xml', }, signal: AbortSignal.timeout(8000), }); if (res.status === 401) { this.log.error(`❌ Unauthorized (401): ${url}`); return; } const xml = await res.text(); // Log raw response // this.log.debug(`⬅️ STATUS: ${res.status} ${res.statusText}`); // this.log.debug(`⬅️ HEADERS: ${JSON.stringify(Object.fromEntries(res.headers.entries()), null, 2)}`); // this.log.debug(`⬅️ BODY: ${xml}`); // Always attempt to parse XML (even for 403 or 404) let responseJson; try { responseJson = this.xmlParser.parse(xml); } catch (e) { this.log.error(`❌ Failed to parse XML from ${url}: ${e.message}`); return; } this.connected = true; return responseJson; } catch (e) { this.log.error(`❌ ERROR: _getResponse ${path} -> ${e.message}`); } } } //# sourceMappingURL=HikvisionApi.js.map