From e0764a54ccaf1c579e2913bcf304aa6ca7b6c197 Mon Sep 17 00:00:00 2001 From: Roman Sokolov <12689+vityevato@users.noreply.github.com> Date: Sun, 12 Oct 2025 19:28:53 +0300 Subject: [PATCH] hikvision-doorbell: Version 2 of the hikvision-doorbell plugin (#1907) --- plugins/hikvision-doorbell/README.md | 8 + plugins/hikvision-doorbell/fs/ALERT_README.md | 5 +- .../hikvision-doorbell/fs/DOORBELL_README.md | 48 +- .../fs/ENTRY_SENSOR_README.md | 9 + plugins/hikvision-doorbell/fs/LOCK_README.md | 3 +- plugins/hikvision-doorbell/package.json | 2 +- .../hikvision-doorbell/src/auth-request.ts | 20 +- .../hikvision-doorbell/src/debug-console.ts | 43 + .../hikvision-doorbell/src/doorbell-api.ts | 1251 ++++++++++--- .../hikvision-doorbell/src/entry-sensor.ts | 30 + .../src/http-stream-switcher.ts | 144 ++ plugins/hikvision-doorbell/src/lock.ts | 96 +- plugins/hikvision-doorbell/src/main.ts | 1619 +++++++++++------ .../src/rtp-stream-switcher.ts | 121 ++ plugins/hikvision-doorbell/src/sip-manager.ts | 470 ++++- .../hikvision-doorbell/src/tamper-alert.ts | 53 +- plugins/hikvision-doorbell/src/types.d.ts | 8 + plugins/hikvision-doorbell/tsconfig.json | 3 +- 18 files changed, 2951 insertions(+), 982 deletions(-) create mode 100644 plugins/hikvision-doorbell/fs/ENTRY_SENSOR_README.md create mode 100644 plugins/hikvision-doorbell/src/debug-console.ts create mode 100644 plugins/hikvision-doorbell/src/entry-sensor.ts create mode 100644 plugins/hikvision-doorbell/src/http-stream-switcher.ts create mode 100644 plugins/hikvision-doorbell/src/rtp-stream-switcher.ts create mode 100644 plugins/hikvision-doorbell/src/types.d.ts diff --git a/plugins/hikvision-doorbell/README.md b/plugins/hikvision-doorbell/README.md index a5ba91870..36e28711f 100644 --- a/plugins/hikvision-doorbell/README.md +++ b/plugins/hikvision-doorbell/README.md @@ -8,6 +8,14 @@ Most commonly this plugin is used with 2 plugins: Rebroadcast and HomeKit. Device must have built-in motion detection (most Hikvision doorbells have this). If the doorbell do not have motion detection, you will have to use a separate plugin or device to achieve this (e.g., `opencv`, `pam-diff`, or `dummy-switch`) and group it to the doorbell. +## ⚠️ Important: Version 2.x Breaking Changes + +Version 2 of this plugin is **not compatible** with version 1.x. Before installing or upgrading to version 2: +- **Option 1**: Completely remove the old plugin from Scrypted +- **Option 2**: Delete all devices that belong to the old plugin + +After removing the old version, you will need to reconfigure all doorbell devices from scratch. + ## Two Way Audio Two Way Audio is supported if the audio codec is set to G.711ulaw on the doorbell, which is usually the default audio codec. This audio codec will also work with HomeKit. Changing the audio codec from G.711ulaw will cause Two Way Audio to fail on the doorbells that were tested. diff --git a/plugins/hikvision-doorbell/fs/ALERT_README.md b/plugins/hikvision-doorbell/fs/ALERT_README.md index 5c9310b4f..aab655905 100644 --- a/plugins/hikvision-doorbell/fs/ALERT_README.md +++ b/plugins/hikvision-doorbell/fs/ALERT_README.md @@ -1,4 +1,5 @@ # Tamper Alert Mechanism Interface -This device serves as a companion for the Hikvision Doorbell device. It provides an interface for interacting with the doorbell tamper alert, which is integrated into models such as the DS-KV6113. -In the settings section, you can see the linked (parent) device, as well as the IP address of the Hikvision Doorbell (phisical device). These fields are not editable, they are for information purposes only. +This device serves as a companion for the Hikvision Doorbell device. It provides an interface for interacting with the doorbell's tamper alert sensor, which is integrated into models such as the DS-KV6113-PE1(C). + +When the doorbell's tamper sensor is triggered, this device will turn **on**. You can manually turn it **off** in the Scrypted web interface. This device is automatically removed when the parent doorbell device is deleted. diff --git a/plugins/hikvision-doorbell/fs/DOORBELL_README.md b/plugins/hikvision-doorbell/fs/DOORBELL_README.md index cfd32c9e0..f1a3b80f0 100644 --- a/plugins/hikvision-doorbell/fs/DOORBELL_README.md +++ b/plugins/hikvision-doorbell/fs/DOORBELL_README.md @@ -1,26 +1,45 @@ # Hikvision Doorbell -At the moment, plugin was tested with the **DS-KV6113PE1[C]** model `doorbell` with firmware version: **V2.2.65 build 231213**, in the following modes: +**⚠️ Important: Version 2.x Breaking Changes** + +Version 2 of this plugin is **not compatible** with version 1.x. Before installing or upgrading to version 2: +- **Option 1**: Completely remove the old plugin from Scrypted +- **Option 2**: Delete all devices that belong to the old plugin + +After removing the old version, you will need to reconfigure all doorbell devices from scratch. + +## Introduction + +At the moment, plugin was tested with the **DS-KV6113-PE1(C)** model `doorbell` with firmware version: **V3.7.0 build 250818**, in the following modes: - the `doorbell` is connected to the `Hik-Connect` service; -- the `doorbell` is connected to a local SIP proxy (asterisk); - the `doorbell` is connected to a fake SIP proxy, which this plugin runs. ## Settings ### Support door lock opening -Most of these doorbells have the ability to control an electromechanical lock. To implement the lock controller software interface in Scrypted, you need to create a separate device with the `Lock` type. Such a device is created automatically if you enable the **Expose Door Lock Controller** checkbox. +The doorbell can control electromechanical locks connected to it. To enable lock control in Scrypted, go to the doorbell device settings, navigate to **Advanced Settings**, and select **Locks** in the **Provided devices** option. -The lock controller is linked to this device (doorbell). Therefore, when the doorbell is deleted, the associated lock controller will also be deleted. +This will create dependent lock device(s) with the `Lock` type. The plugin automatically detects how many doors the doorbell supports (typically 1, but some models support multiple doors). If multiple doors are supported, each lock device will be named with its door number (e.g., "Door Lock 1", "Door Lock 2"). + +Lock devices are automatically removed when the parent doorbell device is deleted. + +### Support contact sensors + +Door open/close status monitoring is available through contact sensors. To enable this functionality in Scrypted, go to the doorbell device settings, navigate to **Advanced Settings**, and select **Contact Sensors** in the **Provided devices** option. + +This will create dependent contact sensor device(s) with the `BinarySensor` type. The plugin automatically detects how many doors the doorbell supports (typically 1, but some models support multiple doors). If multiple doors are supported, each contact sensor will be named with its door number (e.g., "Contact Sensor 1", "Contact Sensor 2"). + +Contact sensor devices are automatically removed when the parent doorbell device is deleted. ### Support tamper alert -Most of a doorbells have a tamper alert. To implement the tamper alert software interface in Scrypted, you need to create a separate device with the `Switch` type. Such a device is created automatically if you enable the **Expose Tamper Alert Controller** checkbox. If you leave this checkbox disabled, the tamper signal will be interpreted as a `Motion Detection` event. +For security, the doorbell includes a built-in tamper detection sensor. To enable tamper alert monitoring in Scrypted, go to the doorbell device settings, navigate to **Advanced Settings**, and select **Tamper Alert** in the **Provided devices** option. If you don't enable this option, tamper alert signals will be interpreted as `Motion Detection` events. -If the tamper on the doorbell is triggered, the controller (`Switch`) will **turn on**. You can **turn off** the switch manually in the Scrypted web interface only. +This will create a dependent tamper alert device with the `BinarySensor` type. When the doorbell's tamper sensor is triggered, the device will turn **on**. You can manually turn it **off** in the Scrypted web interface. -The tamper alert controller is linked to this device (doorbell). Therefore, when the doorbell is deleted, the associated tamper alert controller will also be deleted. +The tamper alert device is automatically removed when the parent doorbell device is deleted. ### Setting up a receiving call (the ability to ringing) @@ -44,10 +63,17 @@ This mode should be used when you have a separate SIP gateway and all your inter #### Emulate SIP Proxy -This mode should be used when you have a `doorbell` but no **Indoor Station**, and you want to connect this `doorbell` to Scrypted server only. +This mode should be used when you have a `doorbell` but no **Indoor Station**, and you want to connect the `doorbell` directly to the Scrypted server. -In this mode, the plugin creates a fake SIP proxy that listens for a connection on the specified port (or auto-select a port if not specified). The task of this server is to receive a notification about a call and, in the event of an intercom start (two way audio), simulate picking up the handset so that the `doorbell` switches to conversation mode (stops ringing). +In this mode, the plugin creates a fake SIP proxy that listens for connections on the specified port (or auto-selects a port if left blank). This server receives call notifications and, when intercom starts (two-way audio), simulates picking up the handset so the `doorbell` switches to conversation mode (stops ringing). -On the additional tab, configure the desired port, and you can also enable the **Autoinstall Fake SIP Proxy** checkbox, for not to configure `doorbell` manually. +**Important**: When you enable this mode, the plugin **automatically configures the doorbell** with the necessary SIP settings. You don't need to configure the doorbell manually. -In the `doorbell` settings you can configure the connection to the fake SIP proxy manually. You should specify the IP address of the Scrypted server and the port of the fake proxy. The contents of the other fields do not matter, since the SIP proxy authorizes the “*client*” using the known doorbell’s IP address. +On the additional settings tab, you can configure: +- **Port**: The listening port for the fake SIP proxy (leave blank for automatic selection) +- **Room Number**: Virtual room number (1-9999) that represents this fake SIP proxy +- **SIP Proxy Phone Number**: Phone number representing the fake SIP proxy (default: 10102) +- **Doorbell Phone Number**: Phone number representing the doorbell (default: 10101) +- **Button Number**: Call button number for doorbells with multiple buttons (1-99, default: 1) + +The plugin automatically applies these settings to the doorbell device via ISAPI. If the doorbell is temporarily unreachable, the plugin will retry the configuration automatically. diff --git a/plugins/hikvision-doorbell/fs/ENTRY_SENSOR_README.md b/plugins/hikvision-doorbell/fs/ENTRY_SENSOR_README.md new file mode 100644 index 000000000..f38ff6880 --- /dev/null +++ b/plugins/hikvision-doorbell/fs/ENTRY_SENSOR_README.md @@ -0,0 +1,9 @@ +# Binary Sensor Interface + +This device serves as a companion for the Hikvision Doorbell device. It provides a binary sensor interface for monitoring the door opening state, which is integrated into models such as the DS-KV6113. + +The Binary Sensor monitors the door opening state and reports: +- **Closed** (binaryState: false) - Door is closed +- **Open** (binaryState: true) - Door is open + +This sensor provides a simple binary state indication that can be used for automation and monitoring purposes. diff --git a/plugins/hikvision-doorbell/fs/LOCK_README.md b/plugins/hikvision-doorbell/fs/LOCK_README.md index e0eebd39f..32d97c36d 100644 --- a/plugins/hikvision-doorbell/fs/LOCK_README.md +++ b/plugins/hikvision-doorbell/fs/LOCK_README.md @@ -1,4 +1,3 @@ # Lock Opening Mechanism Interface -This device serves as a companion for the Hikvision Doorbell device. It provides an interface for interacting with the lock opening mechanism, which is integrated into models such as the DS-KV6113. -In the settings section, you can see the linked (parent) device, as well as the IP address of the Hikvision Doorbell (phisical device). These fields are not editable, they are for information purposes only. +This device serves as a companion for the Hikvision Doorbell device. It provides an interface for interacting with the lock opening mechanism, which is integrated into models such as the DS-KV6113. \ No newline at end of file diff --git a/plugins/hikvision-doorbell/package.json b/plugins/hikvision-doorbell/package.json index d9b19f9af..ce9cc77e4 100644 --- a/plugins/hikvision-doorbell/package.json +++ b/plugins/hikvision-doorbell/package.json @@ -1,6 +1,6 @@ { "name": "@vityevato/hikvision-doorbell", - "version": "1.0.1", + "version": "2.0.0", "description": "Hikvision Doorbell Plugin for Scrypted", "author": "Roman Sokolov", "license": "Apache", diff --git a/plugins/hikvision-doorbell/src/auth-request.ts b/plugins/hikvision-doorbell/src/auth-request.ts index ca73fc080..5bf9a351f 100644 --- a/plugins/hikvision-doorbell/src/auth-request.ts +++ b/plugins/hikvision-doorbell/src/auth-request.ts @@ -7,6 +7,8 @@ import * as Auth from 'http-auth-client'; export interface AuthRequestOptions extends Http.RequestOptions { sessionAuth?: Auth.Basic | Auth.Digest | Auth.Bearer; responseType: HttpFetchResponseType; + // Internal: number of digest retries performed for this request + digestRetry?: number; } export type AuthRequestBody = string | Buffer | Readable; @@ -15,11 +17,13 @@ export class AuthRequst { private username: string; private password: string; + private console: Console; private auth: Auth.Basic | Auth.Digest | Auth.Bearer; constructor(username:string, password: string, console: Console) { this.username = username; this.password = password; + this.console = console; } async request(url: string, options: AuthRequestOptions, body?: AuthRequestBody) { @@ -42,18 +46,29 @@ export class AuthRequst { if (resp.statusCode == 401) { - if (opt.sessionAuth) { + // Hikvision quirk: even if we already had a sessionAuth, a fresh + // WWW-Authenticate challenge may require rebuilding credentials. + // Limit the number of digest rebuilds to avoid infinite loops. + const attempt = (opt.digestRetry ?? 0); + if (attempt >= 2) { + // Give up after a couple of rebuild attempts and surface the 401 response resolve(await this.parseResponse (opt.responseType, resp)); return; } - opt.sessionAuth = this.createAuth(resp.headers['www-authenticate'], !!this.auth); + const newAuth = this.createAuth(resp.headers['www-authenticate'], !!this.auth); + // Clear cached auth to avoid stale nonce reuse this.auth = undefined; + opt.sessionAuth = newAuth; + opt.digestRetry = attempt + 1; const result = await this.request(url, opt, body); resolve(result); } else { + // Cache the negotiated session auth only if it was provided for this request. + if (opt.sessionAuth) { this.auth = opt.sessionAuth; + } resolve(await this.parseResponse(opt.responseType, resp)); } }); @@ -73,7 +88,6 @@ export class AuthRequst { req.end(); } else { - this.readableBody(req, body).pipe(req); req.flushHeaders(); } diff --git a/plugins/hikvision-doorbell/src/debug-console.ts b/plugins/hikvision-doorbell/src/debug-console.ts new file mode 100644 index 000000000..2ab6e5d78 --- /dev/null +++ b/plugins/hikvision-doorbell/src/debug-console.ts @@ -0,0 +1,43 @@ +import { Console } from 'console'; + +/** + * Interface for managing debug state + */ +export interface DebugController { + setDebugEnabled(enabled: boolean): void; + getDebugEnabled(): boolean; +} + +/** + * Mutates an existing Console object to provide conditional debug output + * @param console - The console object to mutate + * @returns Controller object for managing debug state + */ +export function makeDebugConsole(console: Console): DebugController { + let debugEnabled = process.env.DEBUG === 'true' || + process.env.NODE_ENV === 'development'; + + // Store original debug method + const originalDebug = console.debug.bind (console); + + // Replace debug method with conditional version + console.debug = (message?: any, ...optionalParams: any[]): void => { + if (debugEnabled) + { + const now = new Date(); + const timestamp = now.toISOString(); + originalDebug (`[DEBUG ${timestamp}] ${message}`, ...optionalParams); + } + }; + + // Return controller for managing debug state + return { + setDebugEnabled(enabled: boolean): void { + debugEnabled = enabled; + }, + + getDebugEnabled(): boolean { + return debugEnabled; + } + }; +} diff --git a/plugins/hikvision-doorbell/src/doorbell-api.ts b/plugins/hikvision-doorbell/src/doorbell-api.ts index fe4f5eb61..e80650bb3 100644 --- a/plugins/hikvision-doorbell/src/doorbell-api.ts +++ b/plugins/hikvision-doorbell/src/doorbell-api.ts @@ -2,30 +2,66 @@ import { HikvisionCameraAPI } from "../../hikvision/src/hikvision-camera-api" import { HttpFetchOptions } from '@scrypted/common/src/http-auth-fetch'; import { Readable, PassThrough } from 'stream'; import { MediaStreamOptions } from '@scrypted/sdk'; -import net, { Server } from 'net'; -import { AddressInfo } from 'net'; +import { Server } from 'net'; import { Destroyable } from "../../rtsp/src/rtsp"; import { EventEmitter } from 'events'; import { getDeviceInfo } from './probe'; import { AuthRequestOptions, AuthRequst, AuthRequestBody } from './auth-request' import { OutgoingHttpHeaders } from 'http'; -import { localServiceIpAddress } from './utils'; import libip from 'ip'; import xml2js from 'xml2js'; +import { HttpFetchResponse } from "@scrypted/server/src/fetch"; -const isapiEventListenerID: String = "1"; // Other value than '1' does not work in KV6113 -const messagePrefixSize = 692; export enum HikvisionDoorbellEvent { - Motion = '00000000', - CaseTamperAlert = '02000000', - TalkInvite = "11000000", - TalkHangup = "12000000", - Unlock = '01000000', - DoorOpened = '06000000', - DoorClosed = '05000000' + Motion, + CaseTamperAlert, + TalkInvite, + TalkHangup, + TalkOnCall, + Unlock, + Lock, + DoorOpened, + DoorClosed, + DoorAbnormalOpened, + AccessDenied, +} + +interface AcsEventInfo { + major: number; + minor: number; + time: string; + remoteHostAddr: string; + mask: string; + doorNo?: number; +} + +interface AcsEventResponse { + AcsEvent: { + searchID: string; + totalMatches: number; + responseStatusStrg: string; + numOfMatches: number; + InfoList: AcsEventInfo[]; + }; } +const maxEventAgeSeconds = 30; // Ignore events older than this many seconds +const callPollingIntervalSec = 1; // Call status polling interval in seconds +const alertTickTimeoutSec = 60; // Alert stream tick timeout in seconds + +const EventCodeMap = new Map([ + ['5,25', HikvisionDoorbellEvent.DoorOpened], + ['5,26', HikvisionDoorbellEvent.DoorClosed], + ['5,92', HikvisionDoorbellEvent.DoorAbnormalOpened], + ['1,3', HikvisionDoorbellEvent.Motion], + ['1,1028', HikvisionDoorbellEvent.CaseTamperAlert], + ['5,21', HikvisionDoorbellEvent.Unlock], + ['5,9', HikvisionDoorbellEvent.AccessDenied], + ['5,22', HikvisionDoorbellEvent.Lock], +]); + + export function getChannel(channel: string) { return channel || '101'; } @@ -55,47 +91,91 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI private eventServer?: Server; private listener?: Destroyable; - private address: string; + private requestQueue: Promise = Promise.resolve(); + private alertStream?: Readable; + + // Door control capabilities + private doorMinNo: number = 1; + private doorMaxNo: number = 1; + private availableCommands: string[] = ['open', 'close', 'alwaysOpen', 'alwaysClose']; + private capabilitiesLoaded: boolean = false; + private loadCapabilitiesPromise: Promise | null = null; - constructor(address: string, public port: string, username: string, password: string, public console: Console, public storage: Storage) + // Call status polling control + private useCallStatusPolling: boolean = true; + + // Alert stream health tracking + private alertTick?: number; + + constructor ( + address: string, + public port: string, + username: string, + password: string, + callStatusPolling: boolean, + public console: Console, + public storage: Storage + ) { let endpoint = libip.isV4Format(address) ? `${address}:${port}` : `[${address}]:${port}`; super (endpoint, username, password, console); - this.address = address; this.endpoint = endpoint; - this.auth = new AuthRequst(username, password, console); + this.auth = new AuthRequst (username, password, console); + + // Initialize door capabilities + this.initializeDoorCapabilities(); + this.useCallStatusPolling = callStatusPolling; } destroy(): void { this.listener?.destroy(); this.eventServer?.close(); + this.stopAlertStream(); } - override async request(urlOrOptions: string | HttpFetchOptions, body?: AuthRequestBody) { - - let url: string = urlOrOptions as string; - let opt: AuthRequestOptions; - if (typeof urlOrOptions !== 'string') { - url = urlOrOptions.url as string; - if (typeof urlOrOptions.url !== 'string') { - url = (urlOrOptions.url as URL).toString(); + override async request (urlOrOptions: string | HttpFetchOptions, body?: AuthRequestBody) + { + // Create a promise for this specific request to prevent queue blocking + const requestPromise = this.requestQueue.then(async () => { + let url: string = urlOrOptions as string; + let opt: AuthRequestOptions | undefined; + if (typeof urlOrOptions !== 'string') { + url = urlOrOptions.url as string; + if (typeof urlOrOptions.url !== 'string') { + url = (urlOrOptions.url as URL).toString(); + } + opt = { + method: urlOrOptions.method, + responseType: urlOrOptions.responseType || 'buffer', + headers: urlOrOptions.headers as OutgoingHttpHeaders, + }; } - opt = { - method: urlOrOptions.method, - responseType: urlOrOptions.responseType || 'buffer', - headers: urlOrOptions.headers as OutgoingHttpHeaders - } - } - return await this.auth.request(url, opt, body); + // Safety fallback and attach debug id + if (!opt) { + opt = { responseType: 'buffer' } as AuthRequestOptions; + } + + return await this.auth.request(url, opt, body); + }); + + // Update the queue to continue after this request (success or failure) + // This prevents failed requests from blocking the entire queue + this.requestQueue = requestPromise.catch(() => { + // Swallow errors in the queue chain to prevent blocking subsequent requests + // The actual error is still propagated to the caller via requestPromise + }); + + return requestPromise; } override async getDeviceInfo() { return getDeviceInfo (this.auth, this.endpoint); } - override async checkTwoWayAudio() { + override async checkTwoWayAudio() + { const response = await this.request({ url: `http://${this.endpoint}/ISAPI/System/TwoWayAudio/channels`, responseType: 'text', @@ -104,58 +184,123 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI return response.body.includes('audioCompressionType'); } - override async putVcaResource(channel: string, resource: 'smart' | 'facesnap' | 'close') { + override async putVcaResource (channel: string, resource: 'smart' | 'facesnap' | 'close') + { // this feature is not supported by the doorbell // and we return true to prevent the device from rebooting return true; } - emitEvent(eventName: string | symbol, ...args: any[]) { + override async jpegSnapshot (channel: string, timeout = 10000, width?: number, height?: number): Promise + { + let url = `http://${this.endpoint}/ISAPI/Streaming/channels/${getChannel (channel)}/picture?snapShotImageType=JPEG` + + // Add optional resolution parameters + if (width) { + url += `&videoResolutionWidth=${width}`; + } + if (height) { + url += `&videoResolutionHeight=${height}`; + } + + const response = await this.request ({ + url: url, + timeout, + }); + + return response.body; + } + + emitEvent (eventName: string | symbol, ...args: any[]) + { try { this.listener.emit(eventName, ...args); } catch (error) { - setTimeout(() => this.listener.emit(eventName, ...args), 250); + this.console.warn(`Event emission failed, retrying in 250ms: ${error}`); + setTimeout(() => { + if (this.listener) { + try { + this.listener.emit(eventName, ...args); + } catch (retryError) { + this.console.error(`Event emission retry failed: ${retryError}`); + } + } + }, 250); } } - override async listenEvents() { + override async listenEvents() + { // support multiple cameras listening to a single stream - if (!this.listener) { + if (!this.listener) + { + // Load device timezone before starting event polling + try { + await this.getDeviceTimezone(); + this.console.info ('Device timezone loaded successfully'); + } catch (error) { + this.console.warn (`Failed to load device timezone, using UTC fallback: ${error}`); + } + + await this.listenAlertStream(); + this.console.info ('Using alert stream for events'); - await this.runHttpHostsListener(); - await this.installHttpHosts(); + if (this.useCallStatusPolling) { + this.startCallStatusPolling(); + this.console.info ('Call status polling started'); + } else { + this.console.info ('Call status polling disabled (using SIP)'); + } - this.listener = new HikvisionDoorbell_Destroyable( () => { + this.listener = new HikvisionDoorbell_Destroyable (() => { this.listener = undefined; + + // Check if alert stream tick occurred more than timeout + if (this.alertTick) { + const timeSinceLastTick = Date.now() - this.alertTick; + const timeoutMs = alertTickTimeoutSec * 1000; + + if (timeSinceLastTick > timeoutMs) { + this.console.info (`Alert stream last tick ${(timeSinceLastTick / 1000).toFixed (1)}s ago, stopping alert stream`); + this.stopAlertStream(); + } else { + this.console.debug (`Alert stream last tick ${(timeSinceLastTick / 1000).toFixed (1)}s ago, keeping alert stream active`); + } + } + + this.stopCallStatusPolling(); }); } - + return this.listener; } - async getVideoChannels(camNumber: string): Promise> + async getVideoChannels (camNumber: string): Promise> { let channels: MediaStreamOptions[]; try { - channels = await this.getCodecs(camNumber); - this.storage.setItem('channelsJSON', JSON.stringify(channels)); + channels = await this.getCodecs (camNumber); + this.console.info (`Video channels from device: ${JSON.stringify (channels)}`); + this.storage.setItem ('channelsJSON', JSON.stringify (channels)); } catch (e) { - const raw = this.storage.getItem('channelsJSON'); + const raw = this.storage.getItem ('channelsJSON'); if (!raw) throw e; - channels = JSON.parse(raw); + channels = JSON.parse (raw); + this.console.warn (`Using cached video channels: ${raw}`); } const ret = new Map(); for (const streamingChannel of channels) { const channel = streamingChannel.id; - ret.set(channel, streamingChannel); + ret.set (channel, streamingChannel); } return ret; } - async twoWayAudioCodec(channel: string): Promise { + async twoWayAudioCodec (channel: string): Promise + { const parameters = `http://${this.endpoint}/ISAPI/System/TwoWayAudio/channels`; const { body } = await this.request({ @@ -163,7 +308,7 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI responseType: 'text', }); - const parsedXml = await xml2js.parseStringPromise(body); + const parsedXml = await xml2js.parseStringPromise (body); for (const twoWayChannel of parsedXml.TwoWayAudioChannelList.TwoWayAudioChannel) { const [id] = twoWayChannel.id; if (id === channel) @@ -171,22 +316,44 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI } } - async openTwoWayAudio(channel: string, passthrough: PassThrough) { + async openTwoWayAudio (channel: string, passthrough: PassThrough) + { const open = `http://${this.endpoint}/ISAPI/System/TwoWayAudio/channels/${channel}/open`; - const { body } = await this.request({ + let response = await this.request({ url: open, responseType: 'text', method: 'PUT', }); - this.console.log('two way audio opened', body); + + // Check for error responses before proceeding + const parsedXml = await this.checkResponseStatus (response, 'Open two-way audio'); + + this.console.debug ('two way audio opened', response.body); - const url = `http://${this.endpoint}/ISAPI/System/TwoWayAudio/channels/${channel}/audioData`; - this.console.log('posting audio data to', url); + // Extract sessionId from XML response + let sessionId: string | undefined; + if (parsedXml) + { + sessionId = parsedXml.TwoWayAudioSession?.sessionId?.[0]; + if (sessionId) + { + this.console.debug (`Extracted sessionId: ${sessionId}`); + } + else { + this.console.debug ('No sessionId found in response'); + } + } - return this.request({ + const url = `http://${this.endpoint}/ISAPI/System/TwoWayAudio/channels/${channel}/audioData${sessionId ? `?sessionId=${sessionId}` : ''}`; + this.console.debug ('Posting audio data to', url); + + // IMPORTANT: Use responseType 'readable' so AuthRequst does not wait for full body + // and resolves on headers. Otherwise, a long-lived PUT keeps the queue blocked + // and delays hangUp call behind this request. + response = await this.request({ url, - responseType: 'text', + responseType: 'readable', headers: { 'Content-Type': 'application/octet-stream', 'Connection': 'keep-alive', @@ -194,259 +361,807 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI }, method: 'PUT' }, passthrough); + + const result = new Promise> ((resolve, reject) => { + const result: HttpFetchResponse = response; + result.body.on ('end', () => resolve(result)); + result.body.on ('error', () => reject(result)); + }); + return { + sessionId, + result + } } - async closeTwoWayAudio(channel: string) { + async closeTwoWayAudio (channel: string, sessionId?: string) + { - await this.request({ - url: `http://${this.endpoint}/ISAPI/System/TwoWayAudio/channels/${channel}/close`, + const response = await this.request({ + url: `http://${this.endpoint}/ISAPI/System/TwoWayAudio/channels/${channel}/close${sessionId ? `?sessionId=${sessionId}` : ''}`, method: 'PUT', responseType: 'text', }); + + // Check for error responses before proceeding + await this.checkResponseStatus (response, 'Close two-way audio'); + + this.console.debug('Two way audio closed for channel', channel); } - rtspUrlFor(endpoint: string, channelId: string, params: string): string { + rtspUrlFor (endpoint: string, channelId: string, params: string): string { return `rtsp://${endpoint}/ISAPI/Streaming/channels/${channelId}/${params}`; } - async openDoor() { - this.console.info ('Open door lock runing') - // const data = 'alwaysOpen'; - const data = 'open'; - await this.request({ - url: `http://${this.endpoint}/ISAPI/AccessControl/RemoteControl/door/1`, - method: 'PUT', - responseType: 'readable', - }, data); - } - - async closeDoor() { - this.console.info ('Close door lock runing') - const data = 'resume'; - await this.request({ - url: `http://${this.endpoint}/ISAPI/AccessControl/RemoteControl/door/1`, - method: 'PUT', - responseType: 'readable', - }, data); - } - - async stopRinging() { - let resp = await this.request({ - url: `http://${this.endpoint}/ISAPI/VideoIntercom/callSignal?format=json`, - method: 'PUT', - responseType: 'text', - }, '{"CallSignal":{"cmdType":"answer"}}'); - this.console.log(`(stopRinging) Answer return: ${resp.statusCode} - ${resp.body}`); - resp = await this.request({ - url: `http://${this.endpoint}/ISAPI/VideoIntercom/callSignal?format=json`, - method: 'PUT', - responseType: 'text', - }, '{"CallSignal":{"cmdType":"hangUp"}}'); - this.console.log(`(stopRinging) HangUp return: ${resp.statusCode} - ${resp.body}`); - } - - async setFakeSip (enabled: boolean, ip: string = '127.0.0.1', port: number = 5060) + /** + * Initialize door capabilities asynchronously + */ + private async initializeDoorCapabilities() { - - const data = '' + - '1' + - '5060' + - '1' + - '' + - `${enabled ? "true" : "false"}` + - `${ip}` + - `${port}` + - 'Doorbell' + - 'fakeuser' + - '10101' + - 'fakepassword' + - '60' + - '' + - ''; - - await this.request({ - url: `http://${this.endpoint}/ISAPI/System/Network/SIP/1`, - method: 'PUT', - responseType: 'readable', - }, data); + try { + await this.loadDoorCapabilities(); + } catch (error) { + this.console.warn(`Failed to load door capabilities on initialization: ${error}`); + // Use default values if capabilities loading fails + } } - async getDoorOpenDuration(): Promise { - - let xml: string; + /** + * Load and parse door control capabilities with single-flight pattern + * Ensures only one request is made even if called multiple times simultaneously + */ + private async loadDoorCapabilities() + { + // If already loaded, return immediately + if (this.capabilitiesLoaded) { + return; + } + + // If already loading, wait for the existing promise + if (this.loadCapabilitiesPromise) { + return this.loadCapabilitiesPromise; + } + + // Start loading and store the promise + this.loadCapabilitiesPromise = this.performCapabilitiesLoad(); + + try { + await this.loadCapabilitiesPromise; + } finally { + // Clear the promise when done (success or failure) + this.loadCapabilitiesPromise = null; + } + } + + /** + * Actual implementation of capabilities loading + */ + private async performCapabilitiesLoad(): Promise + { try { const response = await this.request({ - url: `http://${this.endpoint}/ISAPI/AccessControl/Door/param/1`, + url: `http://${this.endpoint}/ISAPI/AccessControl/RemoteControl/door/capabilities`, responseType: 'text', }); - xml = response.body; - this.storage.setItem('doorOpenDuration', xml); - } - catch (e) { - xml = this.storage.getItem('doorOpenDuration'); - if (!xml) - throw e; - } - const parsedXml = await xml2js.parseStringPromise(xml); - const ret = Number (parsedXml.DoorParam?.openDuration?.[0]); - return ret; - } - - async installHttpHosts() { - - await this.deleteHttpHosts(); - - let addr = this.eventServer.address() as AddressInfo; - let address = addr.family == 'IPv4' ? - `${addr.address}` : - `${addr.address}`; - - // Despite the fact that we ask device to send us VMD (video motion detection) events, using the HTTP protocol, - // the device sends us ALL events using a protocol unknown to me, in an unknown form. This is annoying... - // Thus, we have to receive events on a regular TCP server and parse them empirically - // By the way, authorization doesn't work either :) - const data = `` + - `${isapiEventListenerID}` + - `/` + - `HTTP` + - `XML` + - `ipaddress` + - `${address}` + - `${addr.port}` + - `fakeuser` + - `fakepassword` + - `MD5digest` + - `VMD` + - `all` + - ``; - - try { - const result = await this.request({ - method: "POST", - url: `http://${this.endpoint}/ISAPI/Event/notification/httpHosts`, - responseType: 'text', - headers: { - 'Accept': '*/*' - } - }, data); - - this.console.log(`Install result: ${result.statusCode}`); + + this.console.debug('Door control capabilities XML:', response.body); + + // Parse XML to get structured data + const parsedXml = await xml2js.parseStringPromise (response.body); + + // Extract door number range + const doorNo = parsedXml.RemoteControlDoor?.doorNo?.[0]; + if (doorNo && doorNo.$) { + this.doorMinNo = parseInt (doorNo.$.min) || 1; + this.doorMaxNo = parseInt (doorNo.$.max) || 1; + } + + // Extract available commands + const cmd = parsedXml.RemoteControlDoor?.cmd?.[0]; + if (cmd && cmd.$.opt) { + this.availableCommands = cmd.$.opt.split(',').map ((c: string) => c.trim()); + } + + this.capabilitiesLoaded = true; + this.console.info (`Door capabilities loaded: doors ${this.doorMinNo}-${this.doorMaxNo}, commands: ${this.availableCommands.join (', ')}`); } catch (error) { - this.console.error(`Install error: ${error}`); - // we rethrows error for restarting of the installation process + this.console.error(`Failed to load door control capabilities: ${error}`); throw error; } } - async deleteHttpHosts() { - try { - await this.request({ - method: "DELETE", - url: `http://${this.endpoint}/ISAPI/Event/notification/httpHosts/${isapiEventListenerID}`, - responseType: 'text' - }); - } catch (error) { - this.console.log(`Delete error: ${error}`); + /** + * Get the capability of remotely controlling the door + * Returns XML_Cap_RemoteControlDoor structure with available door control options + */ + async getDoorControlCapabilities() + { + if (!this.capabilitiesLoaded) { + await this.loadDoorCapabilities(); + } + + return { + doorMinNo: this.doorMinNo, + doorMaxNo: this.doorMaxNo, + availableCommands: this.availableCommands + }; + } + + /** + * Validate door number and command against capabilities + */ + private validateDoorControl (doorNo: string, command: string): void + { + const doorNum = parseInt (doorNo); + if (doorNum < this.doorMinNo || doorNum > this.doorMaxNo) { + throw new Error(`Door number ${doorNo} is out of range. Valid range: ${this.doorMinNo}-${this.doorMaxNo}`); + } + + if (!this.availableCommands.includes (command)) { + throw new Error (`Command '${command}' is not supported. Available commands: ${this.availableCommands.join (', ')}`); } } - async runHttpHostsListener() { + /** + * Control door remotely with supported door commands + * @param doorNo - Door number (default: '1') + * @param command - Door command (validated against device capabilities) + */ + async controlDoor ( + doorNo: string = '1', + command: string = 'resume' + ) + { + // Ensure capabilities are loaded + if (!this.capabilitiesLoaded) { + await this.loadDoorCapabilities(); + } + + // Validate parameters against capabilities + this.validateDoorControl (doorNo, command); + this.console.info(`Controlling door ${doorNo} with command: ${command}`); + + let data = ``; + // data += `${doorNo}`; + data += `${command}`; + data += ``; + + try { + const response = await this.request({ + url: `http://${this.endpoint}/ISAPI/AccessControl/RemoteControl/door/${doorNo}`, + method: 'PUT', + responseType: 'text', + }, data); + + // Check for error responses before proceeding + await this.checkResponseStatus (response, `Control door ${doorNo} with command '${command}'`); + + this.console.debug(`Door control response: ${response.statusCode} - ${response.body}`); + return response; + } catch (error) { + this.console.error(`Failed to control door: ${error}`); + throw error; + } + } - if (this.eventServer) { + async answerCall() + { + const resp = await this.request({ + url: `http://${this.endpoint}/ISAPI/VideoIntercom/callSignal?format=json`, + method: 'PUT', + responseType: 'text', + }, '{"CallSignal":{"cmdType":"answer"}}'); + this.console.debug (`(answer) Answer return: ${resp.statusCode} - ${resp.body}`); + } + + async hangUpCall() + { + this.console.debug ('(hangUpCall) Starting hangUp request'); + const resp = await this.request({ + url: `http://${this.endpoint}/ISAPI/VideoIntercom/callSignal?format=json`, + method: 'PUT', + responseType: 'text', + }, '{"CallSignal":{"cmdType":"hangUp"}}'); + this.console.debug (`(hangUpCall) HangUp return: ${resp.statusCode} - ${resp.body}`); + return resp; + } + + async cancelCall() + { + this.console.debug ('(cancelCall) Starting cancel request'); + const resp = await this.request({ + url: `http://${this.endpoint}/ISAPI/VideoIntercom/callSignal?format=json`, + method: 'PUT', + responseType: 'text', + }, '{"CallSignal":{"cmdType":"cancel"}}'); + this.console.debug (`(cancelCall) Cancel return: ${resp.statusCode} - ${resp.body}`); + return resp; + } + + async rejectCall() + { + const resp = await this.request({ + url: `http://${this.endpoint}/ISAPI/VideoIntercom/callSignal?format=json`, + method: 'PUT', + responseType: 'text', + }, '{"CallSignal":{"cmdType":"reject"}}'); + this.console.debug (`(reject) Reject return: ${resp.statusCode} - ${resp.body}`); + return resp; + } + + async setFakeSip ( + ip: string = '127.0.0.1', + port: number = 5060, + roomNumber: string, + proxyPhone: string, + doorbellPhone: string, + buttonNumber: string = '1' + ) + { + const data = '' + + '1' + + `${port}` + + '1' + + '' + + 'true' + + `${ip}` + + `${port}` + + `${doorbellPhone}` + + `${doorbellPhone}` + + `${doorbellPhone}` + + `fakepassword` + + '60' + + '' + + ''; + + this.console.debug (`Attempting SIP server configuration with data: ${data}`); + + const sipResponse = await this.request ({ + url: `http://${this.endpoint}/ISAPI/System/Network/SIP/1`, + method: 'PUT', + responseType: 'text', + headers: { + 'Content-Type': 'application/xml', + 'Accept': 'application/xml' + } + }, data); + + this.console.debug (`SIP server configuration response: ${sipResponse.statusCode} - ${sipResponse.body}`); + + // Set phone number record for room + const phoneNumberData = { + "PhoneNumberRecord": { + "roomNo": roomNumber, + "PhoneNumbers": [ + { + "phoneNumber": proxyPhone + } + ] + } + }; + + try { + const response = await this.request ({ + url: `http://${this.endpoint}/ISAPI/VideoIntercom/PhoneNumberRecords?format=json`, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + responseType: 'text', + }, JSON.stringify (phoneNumberData)); + + this.console.debug (`Phone number record set: ${response.body}`); + } + catch (e) { + this.console.error ('Failed to set phone number record:', e); + } + + // Set call button configuration + const keyCfgData = `${buttonNumber}${roomNumber}10`; + + try { + const response = await this.request ({ + url: `http://${this.endpoint}/ISAPI/VideoIntercom/keyCfg/${buttonNumber}`, + method: 'PUT', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + responseType: 'text', + }, keyCfgData); + + this.console.debug (`Call button ${buttonNumber} configured for room ${roomNumber}: ${response.body}`); + } + catch (e) { + this.console.error (`Failed to configure call button ${buttonNumber}:`, e); + } + + + } + + async getDoorOpenDuration (doorNo: string = '1'): Promise + { + // Ensure capabilities are loaded to validate door number + if (!this.capabilitiesLoaded) { + await this.loadDoorCapabilities(); + } + + // Validate door number against capabilities + const doorNum = parseInt (doorNo); + if (doorNum < this.doorMinNo || doorNum > this.doorMaxNo) { + throw new Error (`Door number ${doorNo} is out of range. Valid range: ${this.doorMinNo}-${this.doorMaxNo}`); + } + + let xml: string; + const storageKey = `doorOpenDuration_${doorNo}`; + + try { + const response = await this.request ({ + url: `http://${this.endpoint}/ISAPI/AccessControl/Door/param/${doorNo}`, + responseType: 'text', + }); + xml = response.body; + this.storage.setItem (storageKey, xml); + } + catch (e) { + xml = this.storage.getItem (storageKey); + if (!xml) + throw e; + } + + const parsedXml = await xml2js.parseStringPromise (xml); + const ret = Number (parsedXml.DoorParam?.openDuration?.[0]); + + this.console.debug (`Door ${doorNo} open duration: ${ret} seconds`); + return ret; + } + + + private callStatusInterval?: NodeJS.Timeout; + private lastCallState: string = 'idle'; + private isCallPollingActive: boolean = false; + + // ACS event polling properties + private acsEventPollingInterval?: NodeJS.Timeout; + private lastAcsEventTime: Date = new Date(); + + // Timezone properties + private deviceTimezone?: string; // GMT offset in format like '+03:00' + + async getCallStatus(): Promise + { + try { + const response = await this.request ({ + url: `http://${this.endpoint}/ISAPI/VideoIntercom/callStatus?format=json`, + responseType: 'text', + }); + + const callData = JSON.parse (response.body); + + // this.console.debug (`Call status: ${JSON.stringify (callData)}`); + + const callState = callData.CallStatus?.status || 'idle'; + return callState; + } catch (e) { + this.console.error (`Failed to get call status: ${e}`); + return 'idle'; + } + } + + private startCallStatusPolling() + { + if (this.callStatusInterval || this.isCallPollingActive) { + this.console.debug ('Call status polling is already active'); return; } - - let server: Server = net.createServer((socket) => { - - if (socket.remoteAddress != this.address) { - this.console.warn(`Unknown client connected from: ${socket.remoteAddress}:${socket.remotePort}. Close it.`); - socket.destroy(); + + this.isCallPollingActive = true; + this.console.info ('Starting call status polling'); + + this.callStatusInterval = setInterval(async () => { + try { + const callState = await this.getCallStatus(); + + if (callState !== this.lastCallState) { + this.console.info (`Call state changed: ${this.lastCallState} -> ${callState}`); + + if (callState === 'ring') { + this.console.debug ('Doorbell ringing detected via polling'); + this.emitEvent ('event', HikvisionDoorbellEvent.TalkInvite); + } else if (callState === 'idle') { + this.console.debug ('Doorbell hangup detected via polling'); + this.emitEvent ('event', HikvisionDoorbellEvent.TalkHangup); + } else if (callState === 'onCall') { + this.console.debug ('Doorbell on call detected via polling'); + this.emitEvent ('event', HikvisionDoorbellEvent.TalkOnCall); + } + + this.lastCallState = callState; + } + } catch (e) { + this.console.warn (`Call status polling error: ${e}`); } + }, callPollingIntervalSec * 1000); + } + - let buffer = Buffer.alloc(0); - socket.on("data", (data) => { - buffer = Buffer.concat([buffer, data]); - if (buffer.length >= messagePrefixSize) { - socket.destroy(); - } - // const strData = data.toString(); - // this.console.warn(`Received ${data.length}: ${strData}`); - // const hexData = data.toString('hex'); - // this.console.warn(`Received in HEX: ${hexData}`); - }); - - socket.once("close", (hadError: boolean) => { - this.console.debug(`Client disconnected ${ hadError ? "with error" : "" }`); - - if (buffer.byteLength >= messagePrefixSize) { - let data = buffer.subarray(0, messagePrefixSize); - this.processEvent(data); - } - buffer = undefined; - }); - - socket.on("error", (error) => { - this.console.error(`Socket Error: ${error.message}`); - - }); - }); - - let host = await localServiceIpAddress (this.address); - - let result = new Promise((resolve, reject) => { - server.on('listening', () => { - const addr = server.address() as AddressInfo; - this.console.info(`EventReceiver listening on: ${addr.address}:${addr.port}`); - resolve(); - }); - - server.on ('error', (e: NodeJS.ErrnoException) => { - if (e.code === 'EADDRINUSE') { - this.console.error('Address in use, retrying...'); - setTimeout(() => { - server.close(); - server.listen(); - }, 1000); - } - else { - server.close(); - this.eventServer = undefined; - reject(e); - } - }); - - server.on ('close', async () => { - await this.deleteHttpHosts(); - this.emitEvent ('close'); - }); - }); - - this.eventServer = server.listen(0,host); - - return result; + private stopCallStatusPolling() + { + if (this.callStatusInterval) { + clearInterval (this.callStatusInterval); + this.callStatusInterval = undefined; + this.console.info ('Call status polling stopped'); + } + this.isCallPollingActive = false; } - processEvent( data: Buffer) { - this.console.debug ("Processing event from camera..."); + /** + * Get device timezone configuration + * Parses CST format (e.g., CST-3:00:00) and converts to GMT offset (e.g., +03:00) + * Note: CST prefix is abstract and sign must be inverted + */ + private async getDeviceTimezone(): Promise + { + try + { + const response = await this.request ({ + url: `http://${this.endpoint}/ISAPI/System/time/timeZone`, + responseType: 'text', + }); + + this.console.debug (`Timezone XML response: ${response.body}`); + + // Parse XML to get timezone + const parsedXml = await xml2js.parseStringPromise (response.body); + const timezoneStr = parsedXml.Time?.timeZone?.[0]; + + if (!timezoneStr) { + throw new Error ('No timezone found in response'); + } + + // Parse CST format: CST-3:00:00 -> +03:00 (invert sign) + const match = timezoneStr.match(/CST([+-])(\d{1,2}):(\d{2}):(\d{2})/); + if (!match) { + throw new Error (`Invalid timezone format: ${timezoneStr}`); + } + + const [, sign, hours, minutes] = match; + // Invert the sign as per requirement + const invertedSign = sign === '-' ? '+' : '-'; + const gmtOffset = `${invertedSign}${hours.padStart (2, '0')}:${minutes}`; + + this.deviceTimezone = gmtOffset; + + this.console.info (`Device timezone loaded: ${timezoneStr} -> GMT${gmtOffset}`); + return gmtOffset; + + } catch (error) { + this.console.error (`Failed to get device timezone: ${error}`); + // Fallback to system timezone if timezone detection fails + const systemOffset = new Date().getTimezoneOffset(); + const offsetHours = Math.abs(Math.floor(systemOffset / 60)); + const offsetMinutes = Math.abs(systemOffset % 60); + const sign = systemOffset <= 0 ? '+' : '-'; // getTimezoneOffset returns negative for positive offsets + this.deviceTimezone = `${sign}${offsetHours.toString().padStart(2, '0')}:${offsetMinutes.toString().padStart(2, '0')}`; + this.console.info (`Using system timezone as fallback: GMT${this.deviceTimezone}`); + return this.deviceTimezone; + } + } + + /** + * Convert local device time to UTC using device timezone + * @param localTimeStr - Local time string from device + * @returns Date object in UTC + */ + private convertDeviceTimeToUTC (localTimeStr: string): Date + { + if (!this.deviceTimezone) { + // If timezone not loaded, use system timezone + return new Date (localTimeStr); + } + + try { + // Add timezone to device time string and let JavaScript handle the conversion + const dateWithTimezone = `${localTimeStr}${this.deviceTimezone}`; + const date = new Date (dateWithTimezone); + + this.console.debug (`Converted device time: ${localTimeStr} + ${this.deviceTimezone} -> ${date.toISOString()}`); + return date; + + } catch (error) { + this.console.warn (`Failed to convert device time: ${error}`); + return new Date (localTimeStr); + } + } - const cameraNumber = '1'; - const inactive = false; + /** + * Get Access Control System events using polling method + * Returns events in reverse chronological order (newest first) + * @param maxResults - Maximum number of results to return (default: 20) + * @param searchResultPosition - Starting position for search results (default: 0) + * @param major - Major event type filter (0 = all, default: 0) + * @param minor - Minor event type filter (0 = all, default: 0) + */ + private async getAcsEvents ( + maxResults: number = 20, + searchResultPosition: number = 0, + major: number = 0, + minor: number = 0 + ): Promise + { + const requestBody = { + AcsEventCond: { + searchID: '0', + searchResultPosition, + maxResults, + major, + minor, + timeReverseOrder: true + } + }; - const model = data.toString('utf8', 0xC, 0x2C); - const serial = data.toString('utf8', 0x2C, 0x5C); - const marker = data.toString('hex', 0xB0, 0xB4); + try { + const response = await this.request ({ + url: `http://${this.endpoint}/ISAPI/AccessControl/AcsEvent?format=json`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + responseType: 'text', + }, JSON.stringify (requestBody)); - // this.console.debug (`Event string:\n${data.toString('hex')}`); + // Ensure successful response before attempting to parse JSON + if (response.statusCode !== 200) { + throw new Error(`HTTP ${response.statusCode}`); + } - for (const [name, event] of Object.entries(HikvisionDoorbellEvent)) { - if (marker == event) { - this.emitEvent('event', event, cameraNumber, inactive); - this.console.debug (`Camera event emited: "${name}"`); + const eventData: AcsEventResponse = JSON.parse (response.body); + // this.console.debug (`AcsEvent polling response: ${JSON.stringify (eventData, null, 2)}`); + + return eventData; + } catch (error) { + this.console.error (`Failed to get ACS events: ${error}`); + throw error; + } + } + + /** + * Process ACS event from polling response and emit corresponding doorbell events + * @param eventInfo - Event information from ACS polling response + */ + private processAcsEvent (eventInfo: AcsEventInfo): void + { + const eventKey = `${eventInfo.major},${eventInfo.minor}`; + const doorbellEvent = EventCodeMap.get (eventKey); + + // Check if event is too old (ignore events older than maxEventAgeSeconds) + if (eventInfo.time) { + // Convert device local time to UTC using timezone + const eventTime = this.convertDeviceTimeToUTC (eventInfo.time); + const now = new Date(); + const ageInSeconds = (now.getTime() - eventTime.getTime()) / 1000; + + if (ageInSeconds > maxEventAgeSeconds) { + this.console.debug (`Ignoring old ACS event: ${ageInSeconds.toFixed (1)}s old`); return; } } + + if (doorbellEvent !== undefined) { + // Extract door number from event + const doorNo = eventInfo.doorNo ? eventInfo.doorNo.toString() : '1'; + + this.console.info (`ACS polling event detected: ${HikvisionDoorbellEvent[doorbellEvent]} (${eventKey}) door=${doorNo}`); + this.emitEvent ('event', doorbellEvent, doorNo); + } else { + this.console.info (`Unknown ACS event: major=${eventInfo.major}, minor=${eventInfo.minor}`); + } + } - this.console.info (`Unknown camera event: "${marker}"`); + /** + * Poll for new ACS events and process them + * This method can be called periodically to check for new events + * @param lastEventTime - Optional timestamp to filter events newer than this time + */ + private async pollAndProcessAcsEvents (lastEventTime?: Date): Promise + { + try { + const eventResponse = await this.getAcsEvents(); + let latestEventTime: Date | undefined; + + if (eventResponse.AcsEvent && eventResponse.AcsEvent.InfoList) { + for (const eventInfo of eventResponse.AcsEvent.InfoList.reverse()) { + const eventTime = new Date (eventInfo.time); + + // Filter events by time if lastEventTime is provided + if (lastEventTime && eventTime <= lastEventTime) { + continue; // Skip events that are not newer + } + + this.processAcsEvent (eventInfo); + + // Track the latest event time + if (!latestEventTime || eventTime > latestEventTime) { + latestEventTime = eventTime; + } + } + } + + // Update the stored last event time if we found newer events + if (latestEventTime) { + this.lastAcsEventTime = latestEventTime; + this.console.debug (`Updated last ACS event time to: ${latestEventTime.toISOString()}`); + } + + } catch (error) { + this.console.error (`Failed to poll and process ACS events: ${error}`); + throw error; + } + } + + /** + * Check HTTP status code and XML response for errors + */ + private async checkResponseStatus (response: any, operation: string): Promise + { + // First check HTTP status code + let message: string | undefined; + if (response.statusCode && (response.statusCode < 200 || response.statusCode >= 300)) + { + message = `${operation} failed with HTTP status ${response.statusCode}`; + this.console.error (message); + } + + // Then check XML response body for error details + const body = response.body; + if (!body?.trim().startsWith (' { + buffer += chunk.toString ('utf8'); + + // Parse multipart boundary content + const parts = buffer.split ('--MIME_boundary'); + buffer = parts.pop() || ''; // Keep incomplete part + + for (const part of parts) { + if (!part.trim()) continue; + + // Extract JSON from multipart section + const jsonMatch = part.match(/Content-Type: application\/json[^{]*(\{.*\})/s); + if (jsonMatch) { + try { + const eventData = JSON.parse (jsonMatch[1]); + this.processAlertStreamEvent (eventData); + } catch (pe) { + this.console.warn(`Failed to parse alertStream JSON: ${pe}`); + } + } + } + }); + + readable.on ('error', (err) => { + if (this.alertStream === readable) { + this.alertStream = undefined; + } + this.console.error (`alertStream error: ${err}`); + this.emitEvent ('error', err); + }); + + readable.on ('close', () => { + if (this.alertStream === readable) { + this.alertStream = undefined; + } + this.console.debug ('alertStream closed'); + this.emitEvent ('close'); + }); + + } catch (err) { + this.console.error (`listenAlertStream failed: ${err}`); + throw err; + } + } + + /** + * Stop alert stream listener + */ + private stopAlertStream(): void + { + if (!this.alertStream) { + return; + } + + this.console.debug ('Stopping alert stream listener'); + const stream = this.alertStream; + this.alertStream = undefined; + stream.removeAllListeners(); + if (!stream.destroyed) { + stream.destroy(); + } + } + + processAlertStreamEvent (eventData: any) + { + const eventType = eventData.eventType || ''; + + this.console.debug (`AlertStream event: ${eventType}`); + + // Check if event is too old (ignore events older than 30 seconds) + if (eventData.dateTime) { + const eventTime = new Date (eventData.dateTime); + const now = new Date(); + const ageInSeconds = (now.getTime() - eventTime.getTime()) / 1000; + + if (ageInSeconds > maxEventAgeSeconds) { + this.console.debug (`Ignoring old event: ${ageInSeconds.toFixed (1)}s old`); + return; + } + } + + // Map JSON events to existing HikvisionDoorbellEvent enum + if (eventType === 'videoloss') { + // Track alert stream tick time + this.alertTick = Date.now(); + return; + } + + this.console.debug (`AlertStream JSON: ${JSON.stringify (eventData, null, 2)}`); + + this.pollAndProcessAcsEvents (this.lastAcsEventTime); } } diff --git a/plugins/hikvision-doorbell/src/entry-sensor.ts b/plugins/hikvision-doorbell/src/entry-sensor.ts new file mode 100644 index 000000000..cc72d3828 --- /dev/null +++ b/plugins/hikvision-doorbell/src/entry-sensor.ts @@ -0,0 +1,30 @@ +import { BinarySensor, Readme, ScryptedDeviceBase, ScryptedInterface } from "@scrypted/sdk"; +import { HikvisionDoorbellAPI } from "./doorbell-api"; +import type { HikvisionCameraDoorbell } from "./main"; +import * as fs from 'fs/promises'; +import { join } from 'path'; + +export class HikvisionEntrySensor extends ScryptedDeviceBase implements BinarySensor, Readme { + + constructor(public camera: HikvisionCameraDoorbell, nativeId: string, public doorNumber: string = '1') + { + super (nativeId); + this.binaryState = this.binaryState || false; + } + + async getReadmeMarkdown(): Promise + { + const fileName = join (process.cwd(), 'ENTRY_SENSOR_README.md'); + return fs.readFile (fileName, 'utf-8'); + } + + + private getClient(): HikvisionDoorbellAPI { + return this.camera.getClient(); + } + + static deviceInterfaces: string[] = [ + ScryptedInterface.BinarySensor, + ScryptedInterface.Readme + ]; +} diff --git a/plugins/hikvision-doorbell/src/http-stream-switcher.ts b/plugins/hikvision-doorbell/src/http-stream-switcher.ts new file mode 100644 index 000000000..5a85a66e8 --- /dev/null +++ b/plugins/hikvision-doorbell/src/http-stream-switcher.ts @@ -0,0 +1,144 @@ +import { PassThrough } from 'stream'; +import { EventEmitter } from 'events'; + +/** + * HTTP Stream Switcher + * Receives data from single source and writes to single active PassThrough stream + * Supports seamless stream switching without stopping the data source + */ +export interface HttpSession { + sessionId: string; + stream: PassThrough; + putPromise: Promise; +} + +export class HttpStreamSwitcher extends EventEmitter +{ + private currentStream?: PassThrough; + private currentSession?: HttpSession; + private byteCount: number = 0; + private streamSwitchCount: number = 0; + + constructor (private console: Console) { + super(); + } + + /** + * Write data to current active stream + */ + write (data: Buffer): void + { + if (!this.currentStream) { + // No active stream, drop data + return; + } + + try { + const canWrite = this.currentStream.write (data); + this.byteCount += data.length; + + if (!canWrite) { + // Stream buffer is full, apply backpressure + this.console.warn ('Stream buffer full, applying backpressure'); + } + } catch (error) { + this.console.error ('Error writing to stream:', error); + this.clearSession(); + } + } + + /** + * Switch to new HTTP session + * Old session will be ended gracefully + */ + switchSession (session: HttpSession): void + { + const oldSession = this.currentSession; + + if (oldSession) { + this.console.debug (`Switching HTTP session ${oldSession.sessionId} -> ${session.sessionId} (${this.byteCount} bytes sent)`); + + // End old stream gracefully + try { + oldSession.stream.end(); + } catch (e) { + // Ignore errors on old stream + } + + this.streamSwitchCount++; + } else { + this.console.debug (`Setting initial HTTP session ${session.sessionId}`); + } + + this.currentSession = session; + this.currentStream = session.stream; + this.byteCount = 0; + + // Setup error handler for new stream + session.stream.on ('error', (error) => { + this.console.error (`Stream error for session ${session.sessionId}:`, error); + if (this.currentSession === session) { + this.clearSession(); + } + }); + + session.stream.on ('close', () => { + this.console.debug (`Stream closed for session ${session.sessionId}`); + if (this.currentSession === session) { + this.clearSession(); + } + }); + } + + /** + * Clear current session without replacement + */ + private clearSession(): void + { + this.currentStream = undefined; + this.currentSession = undefined; + } + + /** + * Get current session ID + */ + getCurrentSessionId(): string | undefined + { + return this.currentSession?.sessionId; + } + + /** + * Check if given putPromise is current + */ + isCurrentPutPromise (putPromise: Promise): boolean + { + return this.currentSession?.putPromise === putPromise; + } + + /** + * Get current session + */ + getCurrentSession(): HttpSession | undefined + { + return this.currentSession; + } + + /** + * Destroy switcher and cleanup + */ + destroy(): void + { + this.console.debug (`Destroying HTTP switcher (sent ${this.byteCount} bytes, ${this.streamSwitchCount} switches)`); + + if (this.currentStream) { + try { + this.currentStream.end(); + } catch (e) { + // Ignore + } + this.currentStream = undefined; + } + + this.removeAllListeners(); + } +} diff --git a/plugins/hikvision-doorbell/src/lock.ts b/plugins/hikvision-doorbell/src/lock.ts index 257feaf85..1d7632e47 100644 --- a/plugins/hikvision-doorbell/src/lock.ts +++ b/plugins/hikvision-doorbell/src/lock.ts @@ -1,24 +1,42 @@ -import sdk, { ScryptedDeviceBase, SettingValue, ScryptedInterface, Setting, Settings, Lock, LockState, Readme } from "@scrypted/sdk"; +import { Lock, LockState, Readme, ScryptedDeviceBase, ScryptedInterface } from "@scrypted/sdk"; import { HikvisionDoorbellAPI } from "./doorbell-api"; -import { HikvisionDoorbellProvider } from "./main"; +import type { HikvisionCameraDoorbell } from "./main"; import * as fs from 'fs/promises'; import { join } from 'path'; -const { deviceManager } = sdk; +export class HikvisionLock extends ScryptedDeviceBase implements Lock, Readme { -export class HikvisionLock extends ScryptedDeviceBase implements Lock, Settings, Readme { - - // timeout: NodeJS.Timeout; - - private provider: HikvisionDoorbellProvider; - - constructor(nativeId: string, provider: HikvisionDoorbellProvider) { + constructor (public camera: HikvisionCameraDoorbell, nativeId: string, public doorNumber: string = '1') { super (nativeId); - this.lockState = this.lockState || LockState.Unlocked; - this.provider = provider; - // provider.updateLock (nativeId, this.name); + // Initialize lock state by attempting to close the lock + this.initializeLockState(); + } + + /** + * Initialize lock state by attempting to close the lock. + * If close command succeeds, assume the lock is now locked. + * If it fails, assume the lock state remains as default. + */ + private async initializeLockState(): Promise + { + try { + const capabilities = await this.getClient().getDoorControlCapabilities(); + const command = capabilities.availableCommands.includes ('close') ? 'close' : 'resume'; + + // Attempt to close/lock the door + await this.getClient().controlDoor (this.doorNumber, command); + + // If successful, set state to Locked + this.lockState = LockState.Locked; + this.camera.console.info (`Lock ${this.doorNumber} initialized as Locked (close command succeeded)`); + + } catch (error) { + // If command fails, keep default state + this.camera.console.warn (`Lock ${this.doorNumber} initialization failed: ${error}. Using default state.`); + this.lockState = LockState.Unlocked; + } } async getReadmeMarkdown(): Promise @@ -27,52 +45,24 @@ export class HikvisionLock extends ScryptedDeviceBase implements Lock, Settings, return fs.readFile (fileName, 'utf-8'); } - lock(): Promise { - return this.getClient().closeDoor(); - } - unlock(): Promise { - return this.getClient().openDoor(); - } - - async getSettings(): Promise { - const cameraNativeId = this.storage.getItem (HikvisionDoorbellProvider.CAMERA_NATIVE_ID_KEY); - const state = deviceManager.getDeviceState (cameraNativeId); - return [ - { - key: 'parentDevice', - title: 'Linked Doorbell Device Name', - description: 'The name of the associated doorbell plugin device (for information)', - value: state.id, - readonly: true, - type: 'device', - }, - { - key: 'ip', - title: 'IP Address', - description: 'IP address of the doorbell device (for information)', - value: this.storage.getItem ('ip'), - readonly: true, - type: 'string', - } - ] - } - async putSetting(key: string, value: SettingValue): Promise { - this.storage.setItem(key, value.toString()); - } - - getClient(): HikvisionDoorbellAPI + async lock(): Promise { - const ip = this.storage.getItem ('ip'); - const port = this.storage.getItem ('port'); - const user = this.storage.getItem ('user'); - const pass = this.storage.getItem ('pass'); + const capabilities = await this.getClient().getDoorControlCapabilities(); + const command = capabilities.availableCommands.includes ('close') ? 'close' : 'resume'; + await this.getClient().controlDoor (this.doorNumber, command); + } - return this.provider.createSharedClient(ip, port, user, pass, this.console, this.storage); + async unlock(): Promise + { + await this.getClient().controlDoor (this.doorNumber, 'open'); + } + + private getClient(): HikvisionDoorbellAPI { + return this.camera.getClient(); } static deviceInterfaces: string[] = [ ScryptedInterface.Lock, - ScryptedInterface.Settings, ScryptedInterface.Readme ]; } diff --git a/plugins/hikvision-doorbell/src/main.ts b/plugins/hikvision-doorbell/src/main.ts index 66bdaafbb..37f3fc013 100644 --- a/plugins/hikvision-doorbell/src/main.ts +++ b/plugins/hikvision-doorbell/src/main.ts @@ -1,24 +1,27 @@ import { HikvisionCamera } from "../../hikvision/src/main" -import sdk, { Camera, DeviceCreatorSettings, DeviceInformation, FFmpegInput, Intercom, MediaObject, MediaStreamOptions, Reboot, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, LockState, Readme } from "@scrypted/sdk"; +import sdk, { Camera, Device, DeviceCreatorSettings, DeviceInformation, FFmpegInput, Intercom, MediaObject, MediaStreamOptions, Reboot, RequestPictureOptions, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, LockState, Readme } from "@scrypted/sdk"; import { PassThrough } from "stream"; import { RtpPacket } from '../../../external/werift/packages/rtp/src/rtp/rtp'; import { createRtspMediaStreamOptions, RtspProvider, UrlMediaStreamOptions } from "../../rtsp/src/rtsp"; import { startRtpForwarderProcess } from '../../webrtc/src/rtp-forwarders'; import { HikvisionDoorbellAPI, HikvisionDoorbellEvent } from "./doorbell-api"; -import { SipManager, SipRegistration } from "./sip-manager"; -import { parseBooleans, parseNumbers } from "xml2js/lib/processors"; -import { once, EventEmitter } from 'node:events'; +import { SipManager, SipRegistration, SipAudioTarget } from "./sip-manager"; +import { parseNumbers } from "xml2js/lib/processors"; +import { EventEmitter } from 'node:events'; import { timeoutPromise } from "@scrypted/common/src/promise-utils"; import { HikvisionLock } from "./lock" +import { HikvisionEntrySensor } from "./entry-sensor" import { HikvisionTamperAlert } from "./tamper-alert" import * as fs from 'fs/promises'; import { join } from 'path'; +import { makeDebugConsole, DebugController } from "./debug-console"; +import { RtpStreamSwitcher } from "./rtp-stream-switcher"; +import { HttpStreamSwitcher, HttpSession } from "./http-stream-switcher"; -const { mediaManager, deviceManager } = sdk; +const { mediaManager } = sdk; + +const PROVIDED_DEVICES_KEY: string = 'providedDevices'; -const EXPOSE_LOCK_KEY: string = 'exposeLock'; -const USE_CONTACT_SENSOR_KEY: string = 'useContactSensor'; -const EXPOSE_ALERT_KEY: string = 'exposeAlert'; const SIP_MODE_KEY: string = 'sipMode'; const SIP_CLIENT_CALLID_KEY: string = 'sipClientCallId'; @@ -27,10 +30,21 @@ const SIP_CLIENT_PASSWORD_KEY: string = 'sipClientPassword'; const SIP_CLIENT_PROXY_IP_KEY: string = 'sipClientProxyIp'; const SIP_CLIENT_PROXY_PORT_KEY: string = 'sipClientProxyPort'; const SIP_SERVER_PORT_KEY: string = 'sipServerPort'; -const SIP_SERVER_INSTALL_ON_KEY: string = 'sipServerInstallOnDevice'; +const SIP_SERVER_ROOM_NUMBER_KEY: string = 'sipServerRoomNumber'; +const SIP_SERVER_PROXY_PHONE_KEY: string = 'sipServerProxyPhone'; +const SIP_SERVER_DOORBELL_PHONE_KEY: string = 'sipServerDoorbellPhone'; +const SIP_SERVER_BUTTON_NUMBER_KEY: string = 'sipServerButtonNumber'; -const OPEN_LOCK_AUDIO_NOTIFY_DURASTION: number = 3000 // mSeconds -const UNREACHED_REPEAT_TIMEOUT: number = 10000 // mSeconds +const DEFAULT_ROOM_NUMBER: string = '5871'; +const DEFAULT_PROXY_PHONE: string = '10102'; +const DEFAULT_DOORBELL_PHONE: string = '10101'; +const DEFAULT_BUTTON_NUMBER: string = '1'; + +const LOCK_AUDIO_NOTIFY_SEC: number = 3 // Duration to play audio notification after door unlock +const UNREACHED_RETRY_SEC: number = 10 // Retry timeout when device is unreachable +const CANCEL_CALL_DELAY_SEC: number = 3 // Delay before killing active intercom after cancelCall +const GRACE_PERIOD_SEC: number = 2 // Grace period for seamless SIP reconnection +const HTTP_SWITCH_DELAY_SEC: number = 1 // Delay between closing old and opening new HTTP session function channelToCameraNumber(channel: string) { if (!channel) @@ -44,25 +58,56 @@ enum SipMode { Server = "Emulate SIP Proxy" } -class HikvisionCameraDoorbell extends HikvisionCamera implements Camera, Intercom, Reboot, Readme -{ +export class HikvisionCameraDoorbell extends HikvisionCamera implements Camera, Intercom, Reboot, Readme { + locks: Map = new Map(); + entrySensors: Map = new Map(); + tamperAlert?: HikvisionTamperAlert; sipManager?: SipManager; private controlEvents: EventEmitter = new EventEmitter(); private doorOpenDurationTimeout: NodeJS.Timeout; + private debugController: DebugController; + + // intercom state protection + private intercomBusy: boolean = false; + private stopIntercomQueue: Promise = Promise.resolve(); + + // grace period for seamless reconnection + private gracePeriodTimer?: NodeJS.Timeout; + private waitingForReconnect: boolean = false; + + // RTP stream switcher for seamless target switching (SIP mode) + private rtpStreamSwitcher?: RtpStreamSwitcher; + + // HTTP stream switcher for seamless reconnection (ISAPI mode) + private httpStreamSwitcher?: HttpStreamSwitcher; + + // Dedicated API client for event handling + private eventApi?: HikvisionDoorbellAPI; constructor(nativeId: string, provider: RtspProvider) { super(nativeId, provider); - this.updateDevice(); + this.debugController = makeDebugConsole (this.console); + // Set debug mode from storage + const debugEnabled = this.storage.getItem ('debug'); + this.debugController.setDebugEnabled (debugEnabled === 'true'); + this.updateSip(); - this.updateDeviceInfo(); } destroy(): void { + this.clearGracePeriod(); + this.rtpStreamSwitcher?.destroy(); + this.rtpStreamSwitcher = undefined; + this.httpStreamSwitcher?.destroy(); + this.httpStreamSwitcher = undefined; this.sipManager?.stop(); - this.getEventApi()?.destroy(); + this.eventApi?.destroy(); + this.eventApi = undefined; + (this.client as HikvisionDoorbellAPI)?.destroy(); + this.client = undefined; } async getReadmeMarkdown(): Promise @@ -89,17 +134,15 @@ class HikvisionCameraDoorbell extends HikvisionCamera implements Camera, Interco break; default: + const callId = this.storage.getItem (SIP_SERVER_PROXY_PHONE_KEY) || DEFAULT_PROXY_PHONE; let port = parseInt (this.storage.getItem (SIP_SERVER_PORT_KEY)); - if (port) { - await this.sipManager.startGateway (port); - } - else { - await this.sipManager.startGateway(); - } + await this.sipManager.startGateway (callId, port); this.installSipSettingsOnDevice(); break; } } + + this.configureSipHandlers(); })(); } @@ -110,26 +153,20 @@ class HikvisionCameraDoorbell extends HikvisionCamera implements Camera, Interco override async listenEvents() { let motionTimeout: NodeJS.Timeout; - const api = this.getEventApi(); - const events = await api.listenEvents(); - - let ignoreCameraNumber: boolean; - let pulseTimeout: NodeJS.Timeout; + if (!this.eventApi) { + this.eventApi = this.createEventApi(); + } + const events = await this.eventApi.listenEvents(); let motionPingsNeeded = parseInt(this.storage.getItem('motionPings')) || 1; const motionTimeoutDuration = (parseInt(this.storage.getItem('motionTimeout')) || 10) * 1000; let motionPings = 0; - events.on('event', async (event: HikvisionDoorbellEvent, cameraNumber: string, inactive: boolean) => { + events.on('event', async (event: HikvisionDoorbellEvent, doorNo: string) => { if (event === HikvisionDoorbellEvent.CaseTamperAlert) { - const enabled = parseBooleans (this.storage.getItem (EXPOSE_ALERT_KEY)); - if (enabled) - { - const provider = this.provider as HikvisionDoorbellProvider; - const alert = await provider.getAlertDevice (this.nativeId); - if (alert) - alert.turnOn(); + if (this.tamperAlert) { + this.tamperAlert.turnOn(); } else { event = HikvisionDoorbellEvent.Motion; @@ -137,41 +174,7 @@ class HikvisionCameraDoorbell extends HikvisionCamera implements Camera, Interco } if (event === HikvisionDoorbellEvent.Motion) { - // check if the camera+channel field is in use, and filter events. - if (this.getRtspChannel()) { - // it is possible to set it up to use a camera number - // on an nvr IP (which gives RTSP urls through the NVR), but then use a http port - // that gives a filtered event stream from only that camera. - // this this case, the camera numbers will not - // match as they will be always be "1". - // to detect that a camera specific endpoint is being used - // can look at the channel ids, and see if that camera number is found. - // this is different from the use case where the NVR or camera - // is using a port other than 80 (the default). - // could add a setting to have the user explicitly denote nvr usage - // but that is error prone. - const userCameraNumber = this.getCameraNumber(); - if (ignoreCameraNumber === undefined && this.detectedChannels) { - const channelIds = (await this.detectedChannels).keys(); - ignoreCameraNumber = true; - for (const id of channelIds) { - if (channelToCameraNumber(id) === userCameraNumber) { - ignoreCameraNumber = false; - break; - } - } - } - - if (!ignoreCameraNumber && cameraNumber !== userCameraNumber) { - // this.console.error(`### Skipping motion event ${cameraNumber} != ${this.getCameraNumber()}`); - return; - } - } - motionPings++; - // this.console.log(this.name, 'motion pings', motionPings); - - // this.console.error('### Detected motion, camera: ', cameraNumber); this.motionDetected = motionPings >= motionPingsNeeded; clearTimeout(motionTimeout); // motion seems to be on a 1 second pulse @@ -180,60 +183,351 @@ class HikvisionCameraDoorbell extends HikvisionCamera implements Camera, Interco motionPings = 0; }, motionTimeoutDuration); } - else if (event === HikvisionDoorbellEvent.TalkInvite) + else if (event === HikvisionDoorbellEvent.TalkInvite + || event === HikvisionDoorbellEvent.TalkOnCall + || event === HikvisionDoorbellEvent.TalkHangup) { - // clearTimeout(pulseTimeout); - // pulseTimeout = setTimeout(() => this.binaryState = false, 3000); - this.binaryState = true; - setImmediate( () =>{ - this.controlEvents.emit (event); - }); - } - else if (event === HikvisionDoorbellEvent.TalkHangup) - { - this.binaryState = false; - setImmediate( () =>{ - this.controlEvents.emit (event); - }); - } - else if (event === HikvisionDoorbellEvent.Unlock) - { - const provider = this.provider as HikvisionDoorbellProvider; - const lock = await provider.getLockDevice (this.nativeId); - if (lock) + const invite = (event === HikvisionDoorbellEvent.TalkInvite); + this.console.info (`Doorbell ${event.toString()} detected`); + if (this.intercomBusy && invite) { - lock.lockState = LockState.Unlocked; + this.stopCall().then(() => { + // Check if we're in ISAPI mode (no SIP) and can do seamless reconnection + if (!this.sipManager && this.httpStreamSwitcher) + { + this.console.info ('(ISAPI) Received TalkInvite during active intercom, attempting seamless reconnection'); - clearTimeout (this.doorOpenDurationTimeout); - const timeout = (await this.getClient().getDoorOpenDuration()) * 1000; - this.doorOpenDurationTimeout = setTimeout ( async () => { - - const provider = this.provider as HikvisionDoorbellProvider; - const lock = await provider.getLockDevice (this.nativeId); - if (lock) { - lock.lockState = LockState.Locked; - this.console.info (`Door lock was closed automatically after duration: ${timeout}`); + // Attempt to reconnect HTTP session without stopping audio forwarder + this.switchHttpSession().then (session => { + if (session) { + this.console.info('Seamless HTTP reconnection successful'); + } else { + this.console.warn('Failed to reconnect HTTP session, stopping intercom'); + this.stopIntercom(); + } + }).catch(e => { + this.console.error('Error during HTTP reconnection:', e); + this.stopIntercom(); + }); } - } - , timeout); + }); + return; } - - setTimeout(() => this.stopRinging(), OPEN_LOCK_AUDIO_NOTIFY_DURASTION); + + this.binaryState = invite; + setImmediate( () => { + this.controlEvents.emit (event.toString()); + }); } - else if (event === HikvisionDoorbellEvent.DoorOpened && parseBooleans (this.storage.getItem (USE_CONTACT_SENSOR_KEY))) + else if (event === HikvisionDoorbellEvent.Unlock + || event === HikvisionDoorbellEvent.Lock) { - const provider = this.provider as HikvisionDoorbellProvider; - const lock = await provider.getLockDevice (this.nativeId); - if (lock) - lock.unlock(); + // Update specific lock based on doorNo + const lockNativeId = `${this.nativeId}-lock-${doorNo}`; + const lock = this.locks.get (lockNativeId); + + if (lock) { + const isUnlock = event === HikvisionDoorbellEvent.Unlock; + lock.lockState = isUnlock ? LockState.Unlocked : LockState.Locked; + this.console.info (`Door ${doorNo} ${isUnlock ? 'unlocked' : 'locked'}`); + + clearTimeout (this.doorOpenDurationTimeout); + + if (isUnlock && this.binaryState) { + setTimeout (() => this.stopCall(), LOCK_AUDIO_NOTIFY_SEC * 1000); + } + } else { + this.console.warn (`Lock for door ${doorNo} not found`); + } + } + else if ( + (event === HikvisionDoorbellEvent.DoorOpened + || event === HikvisionDoorbellEvent.DoorClosed + || event === HikvisionDoorbellEvent.DoorAbnormalOpened) + ) + { + // Update specific entry sensor based on door state and doorNo + const sensorNativeId = `${this.nativeId}-entry-${doorNo}`; + const entrySensor = this.entrySensors.get (sensorNativeId); + + if (entrySensor) { + const isOpen = event !== HikvisionDoorbellEvent.DoorClosed; + if (isOpen && this.binaryState) { this.stopCall(); } + entrySensor.binaryState = isOpen; + this.console.info (`Door ${doorNo} entry sensor: ${isOpen ? 'opened' : 'closed'}`); + } else { + this.console.warn (`Entry sensor for door ${doorNo} not found`); + } } }) return events; } + private async stopCall(): Promise + { + try + { + if (this.sipManager) { + await this.sipManager.answer(); + await this.sipManager.hangup(); + } + else { + await this.getClient().cancelCall(); + } + } + catch (e) + { + this.console.error ('Failed to cancel call:', e); + } + } + + private createSipAudioTrack (codec: string, useSwitcher: boolean = false) + { + let flag = true; + + if (useSwitcher) { + // Use switcher for seamless target switching support + return { + onRtp: (rtp: Buffer) => { + if (flag) { + this.console.debug ('First RTP packet, sending to switcher'); + flag = false; + } + // Send to switcher which will forward to current active target + this.rtpStreamSwitcher?.sendRtp (rtp); + }, + codecCopy: codec, + encoderArguments: [ + '-ar', '8000', + '-ac', '1', + '-acodec', codec, + ] + }; + } else { + // Direct RTP mode (fallback if switcher not used) + const target = this.sipManager?.remoteAudioTarget; + if (!target) { + throw new Error ('No remote audio target available'); + } + + return { + onRtp: (rtp: Buffer) => { + if (flag) { + this.console.debug (`First RTP packet sent to ${target.ip}:${target.port}`); + flag = false; + } + }, + codecCopy: codec, + encoderArguments: [ + '-ar', '8000', + '-ac', '1', + '-acodec', codec, + '-f', 'rtp', + `rtp://${target.ip}:${target.port}`, + ] + }; + } + } + + private clearGracePeriod() + { + if (this.gracePeriodTimer) { + clearTimeout (this.gracePeriodTimer); + this.gracePeriodTimer = undefined; + } + this.waitingForReconnect = false; + } + + private async attemptSipReconnection(): Promise + { + this.console.info ('Grace period expired, attempting reconnection via INVITE'); + this.clearGracePeriod(); + + // Check if intercom is still active before attempting reconnection + if (!this.activeIntercom || this.activeIntercom.killed) + { + this.console.info ('Intercom was stopped during grace period, skipping reconnection'); + return; + } + + const mng = this.sipManager; + if (!mng) + { + this.console.error ('SIP manager not available, stopping intercom'); + await this.stopIntercom(); + return; + } + + // Try to send INVITE to doorbell to re-establish connection + try + { + const inviteSuccess = await mng.invite(); + if (inviteSuccess) + { + this.console.info ('INVITE successful, received SDP response'); + + // Switch to new audio target from SDP response + const switched = await this.switchAudioTarget(); + if (!switched) { + this.console.error ('Failed to switch audio target, stopping intercom'); + await this.stopIntercom(); + return; + } + + this.console.info ('Reconnection successful via INVITE'); + } + else + { + this.console.warn ('INVITE failed, stopping intercom'); + await this.stopIntercom(); + } + } + catch (error) + { + this.console.error ('Error during reconnection attempt:', error); + await this.stopIntercom(); + } + } + + private async switchAudioTarget (): Promise + { + if (!this.rtpStreamSwitcher) { + this.console.warn ('Cannot switch audio target: switcher not initialized'); + return false; + } + + const newTarget = this.sipManager?.remoteAudioTarget; + if (!newTarget) { + this.console.error ('Cannot switch audio target: missing remote audio target'); + return false; + } + + try { + this.console.info (`Switching audio target to ${newTarget.ip}:${newTarget.port}`); + + // Switch to new target + // This allows seamless switching without killing the forwarder + this.rtpStreamSwitcher.switchTarget (newTarget.ip, newTarget.port); + + this.console.info ('Audio target switched successfully'); + return true; + } catch (error) { + this.console.error ('Failed to switch audio target:', error); + return false; + } + } + + private setupPutPromiseHandlers (put: Promise): void + { + put.finally (() => { + // Only kill forwarder if this is still the current PUT request + if (this.activeIntercom && !this.activeIntercom.killed && this.httpStreamSwitcher?.isCurrentPutPromise (put)) { + this.console.debug ('Current PUT finished, cleaning up'); + this.activeIntercom.kill(); + } else if (!this.httpStreamSwitcher?.isCurrentPutPromise (put)) { + this.console.debug ('Old PUT finished, ignoring (new session active)'); + } + }); + + // The PUT request will be open until the passthrough is closed + put.then (response => { + // Only kill forwarder if this is still the current PUT request + if (response.statusCode !== 200 && this.activeIntercom && !this.activeIntercom.killed && this.httpStreamSwitcher?.isCurrentPutPromise (put)) { + this.console.debug ('Current PUT finished with non-200 status code, cleaning up'); + this.activeIntercom.kill(); + } + }) + .catch (() => { + // Only kill forwarder if this is still the current PUT request + if (this.activeIntercom && !this.activeIntercom.killed && this.httpStreamSwitcher?.isCurrentPutPromise (put)) { + this.console.debug ('Current PUT finished with error, cleaning up'); + this.activeIntercom.kill(); + } + }); + } + + private async switchHttpSession (initialSetup: boolean = false): Promise + { + // Initialize switcher if not exists (only for initial setup) + if (!this.httpStreamSwitcher) { + if (initialSetup) { + this.httpStreamSwitcher = new HttpStreamSwitcher (this.console); + } else { + this.console.warn ('Cannot switch HTTP session: switcher not initialized'); + return null; + } + } + + try { + if (initialSetup) { + this.console.info ('Initializing HTTP session'); + } else { + this.console.info ('Switching HTTP session for seamless reconnection'); + } + + const channel = this.getRtspChannel() || '1'; + + // Store old session info for cleanup + const oldSessionId = this.httpStreamSwitcher.getCurrentSessionId(); + + // Close old session BEFORE opening new one (required by device) + if (oldSessionId) { + try { + await this.getClient().closeTwoWayAudio (channel, oldSessionId); + this.console.debug (`Old HTTP session ${oldSessionId} closed before opening new`); + } catch (e) { + this.console.warn (`Failed to close old session ${oldSessionId}:`, e); + } + + // Wait before opening new session + await new Promise (resolve => setTimeout (resolve, HTTP_SWITCH_DELAY_SEC * 1000)); + this.console.debug (`Waited ${HTTP_SWITCH_DELAY_SEC}s before opening new session`); + } + + // Open new HTTP session + const newPassthrough = new PassThrough(); + const result = await this.getClient().openTwoWayAudio (channel, newPassthrough); + const newSessionId = result.sessionId; + const newPut = result.result; + + // Create session object and switch + const newSession: HttpSession = { + sessionId: newSessionId, + stream: newPassthrough, + putPromise: newPut + }; + + this.httpStreamSwitcher.switchSession (newSession); + + if (oldSessionId) { + this.console.info (`HTTP session switched: ${oldSessionId} -> ${newSessionId}`); + } else { + this.console.debug (`HTTP session ${newSessionId} connected to switcher`); + } + + // Setup PUT promise handlers if forwarder is active + if (this.activeIntercom) { + this.setupPutPromiseHandlers (newPut); + } + + return newSession; + } catch (error) { + this.console.error ('Failed to switch HTTP session:', error); + return null; + } + } + override createClient() { - return new HikvisionDoorbellAPI(this.getIPAddress(), this.getHttpPort(), this.getUsername(), this.getPassword(), this.console, this.storage); + return new HikvisionDoorbellAPI( + this.getIPAddress(), + this.getHttpPort(), + this.getUsername(), + this.getPassword(), + this.isCallPolling(), + this.console, + this.storage + ); } override getClient(): HikvisionDoorbellAPI { @@ -293,73 +587,231 @@ class HikvisionCameraDoorbell extends HikvisionCamera implements Camera, Interco { const twoWayAudio = this.storage.getItem ('twoWayAudio') === 'true'; + const providedDevices = JSON.parse(this.storage.getItem(PROVIDED_DEVICES_KEY) || '[]') as string[]; + const interfaces = this.provider.getInterfaces(); if (twoWayAudio) { interfaces.push (ScryptedInterface.Intercom); } interfaces.push (ScryptedInterface.BinarySensor); interfaces.push (ScryptedInterface.Readme); + + if (!!providedDevices?.length) { + interfaces.push(ScryptedInterface.DeviceProvider); + } + this.provider.updateDevice (this.nativeId, this.name, interfaces, ScryptedDeviceType.Doorbell); } - async updateLock () + override async reportDevices() { - const enabled = parseBooleans (this.storage.getItem (EXPOSE_LOCK_KEY)); - const provider = this.provider as HikvisionDoorbellProvider; - if (enabled) { - return provider.enableLock (this.nativeId); + const providedDevices = JSON.parse (this.storage.getItem (PROVIDED_DEVICES_KEY) || '[]') as string[]; + const devices: Device[] = []; + + if (providedDevices?.includes ('Locks')) { + try { + const lockDevices = await this.createLockDevices(); + devices.push (...lockDevices); + } catch (error) { + this.console.warn (`Failed to create lock devices: ${error}`); + } } - else { - return provider.disableLock (this.nativeId); + + if (providedDevices?.includes ('Contact Sensors')) { + try { + const sensorDevices = await this.createEntrySensorDevices(); + devices.push(...sensorDevices); + } catch (error) { + this.console.warn (`Failed to create entry sensor devices: ${error}`); + } } + + if (providedDevices?.includes ('Tamper Alert')) { + const alertNativeId = `${this.nativeId}-alert`; + const alertDevice: Device = { + providerNativeId: this.nativeId, + name: `${this.name} (Doorbell Tamper Alert)`, + nativeId: alertNativeId, + info: { + ...this.info, + }, + interfaces: [ + ScryptedInterface.OnOff, + ScryptedInterface.Readme + ], + type: ScryptedDeviceType.Switch, + }; + devices.push (alertDevice); + } + sdk.deviceManager.onDevicesChanged ({ + providerNativeId: this.nativeId, + devices, + }); } - async updateAlert () + private async createLockDevices(): Promise { - const enabled = parseBooleans (this.storage.getItem (EXPOSE_ALERT_KEY)); - const provider = this.provider as HikvisionDoorbellProvider; - if (enabled) { - return provider.enableAlert (this.nativeId); + const devices: Device[] = []; + + try { + const client = this.getClient(); + const doorRange = await client.getDoorControlCapabilities(); + + for (let doorNo = doorRange.doorMinNo; doorNo <= doorRange.doorMaxNo; doorNo++) { + const lockNativeId = `${this.nativeId}-lock-${doorNo}`; + const lockDevice: Device = { + providerNativeId: this.nativeId, + name: doorRange.doorMaxNo > 1 ? `${this.name} (Door Lock ${doorNo})` : `${this.name} (Door Lock)`, + nativeId: lockNativeId, + info: { + ...this.info, + }, + interfaces: [ + ScryptedInterface.Lock, + ScryptedInterface.Readme + ], + type: ScryptedDeviceType.Lock, + }; + devices.push (lockDevice); + } + } catch (error) { + this.console.error (`Failed to get door capabilities: ${error}`); + // Fallback to single lock device + const lockNativeId = `${this.nativeId}-lock-1`; + const lockDevice: Device = { + providerNativeId: this.nativeId, + name: `${this.name} (Door Lock)`, + nativeId: lockNativeId, + info: { + ...this.info, + }, + interfaces: [ + ScryptedInterface.Lock, + ScryptedInterface.Readme + ], + type: ScryptedDeviceType.Lock, + }; + devices.push (lockDevice); } - else { - return provider.disableAlert (this.nativeId); + + return devices; + } + + private async createEntrySensorDevices(): Promise + { + const devices: Device[] = []; + + try + { + const client = this.getClient(); + const doorRange = await client.getDoorControlCapabilities(); + + for (let doorNo = doorRange.doorMinNo; doorNo <= doorRange.doorMaxNo; doorNo++) { + const sensorNativeId = `${this.nativeId}-entry-${doorNo}`; + const sensorDevice: Device = { + providerNativeId: this.nativeId, + name: doorRange.doorMaxNo > 1 ? `${this.name} (Contact Sensor ${doorNo})` : `${this.name} (Contact Sensor)`, + nativeId: sensorNativeId, + info: { + ...this.info, + }, + interfaces: [ + ScryptedInterface.BinarySensor, + ScryptedInterface.Readme + ], + type: ScryptedDeviceType.Sensor, + }; + devices.push (sensorDevice); + } + } catch (error) { + this.console.error (`Failed to get door capabilities: ${error}`); + // Fallback to single entry sensor device + const sensorNativeId = `${this.nativeId}-entry-1`; + const sensorDevice: Device = { + providerNativeId: this.nativeId, + name: `${this.name} (Contact Sensor)`, + nativeId: sensorNativeId, + info: { + ...this.info, + }, + interfaces: [ + ScryptedInterface.BinarySensor, + ScryptedInterface.Readme + ], + type: ScryptedDeviceType.Sensor, + }; + devices.push (sensorDevice); } + + return devices; + } + + + async getDevice (nativeId: string): Promise + { + if (nativeId.includes ('-lock-')) { + let lock = this.locks.get (nativeId); + if (!lock) { + // Extract door number from nativeId (format: deviceId-lock-doorNo) + const doorNo = nativeId.split ('-lock-')[1]; + lock = new HikvisionLock (this, nativeId, doorNo); + this.locks.set (nativeId, lock); + } + return lock; + } + if (nativeId.includes ('-entry-')) { + let entrySensor = this.entrySensors.get (nativeId); + if (!entrySensor) { + // Extract door number from nativeId (format: deviceId-entry-doorNo) + const doorNo = nativeId.split ('-entry-')[1]; + entrySensor = new HikvisionEntrySensor (this, nativeId, doorNo); + this.entrySensors.set (nativeId, entrySensor); + } + return entrySensor; + } + if (nativeId.endsWith ('-alert')) { + this.tamperAlert ||= new HikvisionTamperAlert (this, nativeId); + return this.tamperAlert; + } + return super.getDevice (nativeId); + } + + async releaseDevice (id: string, nativeId: string) + { + if (nativeId.includes ('-lock-')) + this.locks.delete (nativeId); + else if (nativeId.includes ('-entry-')) + this.entrySensors.delete (nativeId); + else if (nativeId.endsWith ('-alert')) + delete this.tamperAlert; + else + return super.releaseDevice (id, nativeId); } override async putSetting(key: string, value: string) { - this.client = undefined; this.detectedChannels = undefined; + this.eventApi?.destroy(); + this.eventApi = undefined; + (this.client as HikvisionDoorbellAPI)?.destroy(); + this.client = undefined; + + // Clear cached video channels to force refresh from device + this.storage.removeItem ('channelsJSON'); // remove 0 port for autoselect port number if (key === SIP_SERVER_PORT_KEY && value === '0') { value = ''; } + if (key === 'debug') { + // Handle both string and boolean values + const debugEnabled = typeof value === 'boolean' ? value : value === 'true'; + this.debugController?.setDebugEnabled(debugEnabled); + } + super.putSetting(key, value); - if (key === EXPOSE_LOCK_KEY) { - this.updateLock(); - } - - if (key === EXPOSE_ALERT_KEY) { - this.updateAlert(); - } - - this.updateDevice(); this.updateSip(); - this.updateDeviceInfo(); - } - - onLockRemoved() - { - super.putSetting(EXPOSE_LOCK_KEY, 'false'); - this.updateDevice(); - } - - onAlertRemoved() - { - super.putSetting(EXPOSE_ALERT_KEY, 'false'); - this.updateDevice(); } override async getSettings(): Promise @@ -383,28 +835,44 @@ class HikvisionCameraDoorbell extends HikvisionCamera implements Camera, Interco return ret; } - override async getOtherSettings(): Promise { + override async getOtherSettings(): Promise + { const ret = await super.getOtherSettings(); + // Remove existing providedDevices entry if it exists + const existingIndex = ret.findIndex(setting => setting.key === PROVIDED_DEVICES_KEY); + if (existingIndex !== -1) { + ret.splice(existingIndex, 1); + } + const providedDevices = JSON.parse(this.storage.getItem(PROVIDED_DEVICES_KEY) || '[]') as string[]; + ret.unshift( + { + key: PROVIDED_DEVICES_KEY, + subgroup: 'Advanced', + title: 'Provided devices', + description: 'Additional devices provided by this doorbell', + value: providedDevices, + choices: [ + 'Locks', + 'Contact Sensors', + 'Tamper Alert', + ], + multiple: true, + } + ); + ret.unshift( { - key: EXPOSE_LOCK_KEY, - title: 'Expose Door Lock Controller', - description: 'The doorbell may have the capability to control door opening. Enabling this feature will result in the creation of a separate (linked) device of the "Lock" type, which implements the door lock control.', - value: parseBooleans (this.storage.getItem (EXPOSE_LOCK_KEY)) || false, - type: 'boolean', - }, - { - key: EXPOSE_ALERT_KEY, - title: 'Expose Tamper Alert Controller', - description: 'The doorbell may have a tamper alert. Enabling this function will lead to the creation of a separate (linked) device of the “Switch” type that implements tamper signaling.', - value: parseBooleans (this.storage.getItem (EXPOSE_ALERT_KEY)) || false, - type: 'boolean', + title: 'SIP Mode', + value: `

Setting up a way to interact with the doorbell in order to receive calls. + Read more about how in this device description.

+

Warning: Be careful! Switch to "Emulated SIP Proxy" mode leads to automatic configuration of settings on the doorbell device.

+ `, + type: 'html', + readonly: true, }, { key: SIP_MODE_KEY, - title: 'SIP Mode', - description: 'Setting up a way to interact with the doorbell in order to receive calls. Read more about how in this device description.', choices: Object.values (SipMode), combobox: true, value: this.storage.getItem (SIP_MODE_KEY) || SipMode.Off, @@ -430,130 +898,306 @@ class HikvisionCameraDoorbell extends HikvisionCamera implements Camera, Interco value: parseInt(this.storage.getItem('motionPings')) || 1, type: 'number', }, - { - subgroup: 'Advanced', - key: USE_CONTACT_SENSOR_KEY, - title: 'Use Contact Sensor', - description: "If you installed a contact sensor on the door when installing the Hikvision doorbell, you can use its status data to control the status of the doorlock controller, which you enabled in General Tab (\"Expose Door Lock Controller\" checkbox). To do this, enable this checkbox.", - value: parseBooleans (this.storage.getItem (USE_CONTACT_SENSOR_KEY)) || false, - type: 'boolean', - }, ); return ret; } - override async startIntercom(media: MediaObject): Promise { - - await this.stopRinging(); - - const channel = this.getRtspChannel() || '1'; - let codec: string; - let format: string; - - try { - codec = await this.getClient().twoWayAudioCodec(channel); - } - catch (e) { - this.console.error('Failure while determining two way audio codec', e); - } - - if (codec === 'G.711ulaw') { - codec = 'pcm_mulaw'; - format = 'mulaw' - } - else if (codec === 'G.711alaw') { - codec = 'pcm_alaw'; - format = 'alaw' - } - else { - if (codec) { - this.console.warn('Unknown codec', codec); - this.console.warn('Set your audio codec to G.711ulaw.'); - } - this.console.warn('Using fallback codec pcm_mulaw. This may not be correct.'); - // seems to ship with this as defaults. - codec = 'pcm_mulaw'; - format = 'mulaw' - } - - const buffer = await mediaManager.convertMediaObjectToBuffer(media, ScryptedMimeTypes.FFmpegInput); - const ffmpegInput = JSON.parse(buffer.toString()) as FFmpegInput; - - const passthrough = new PassThrough(); - const put = this.getClient().openTwoWayAudio(channel, passthrough); - - let available = Buffer.alloc(0); - this.activeIntercom?.kill(); - const forwarder = this.activeIntercom = await startRtpForwarderProcess(this.console, ffmpegInput, { - audio: { - onRtp: rtp => { - const parsed = RtpPacket.deSerialize(rtp); - available = Buffer.concat([available, parsed.payload]); - if (available.length > 1024) { - const data = available.subarray(0, 1024); - passthrough.push(data); - available = available.subarray(1024); - } - }, - codecCopy: codec, - encoderArguments: [ - '-ar', '8000', - '-ac', '1', - '-acodec', codec, - ] - } - }); - - forwarder.killPromise.finally(() => { - this.console.log('audio finished'); - passthrough.end(); - this.stopIntercom(); - }); - - put.finally(() => forwarder.kill()); - } - - override async stopIntercom(): Promise { - this.activeIntercom?.kill(); - this.activeIntercom = undefined; - - await this.getClient().closeTwoWayAudio(this.getRtspChannel() || '1'); - } - - private getEventApi() + override async takeSmartCameraPicture (options?: RequestPictureOptions): Promise { - return (this.provider as HikvisionDoorbellProvider).createSharedClient( + const api: HikvisionDoorbellAPI = this.getClient(); + + // Get target resolution from options or use stream metadata + let targetWidth = options?.picture?.width; + let targetHeight = options?.picture?.height; + + // If no specific resolution requested, use main stream resolution to ensure correct aspect ratio + if (!targetWidth || !targetHeight) { + try { + const streams = await this.getConstructedVideoStreamOptions(); + if (streams?.[0]?.video) { + targetWidth = streams[0].video.width; + targetHeight = streams[0].video.height; + this.console.debug (`Using stream resolution for snapshot: ${targetWidth}x${targetHeight}`); + } + } catch (error) { + this.console.warn (`Failed to get stream resolution for snapshot: ${error}`); + } + } + + return mediaManager.createMediaObject (await api.jpegSnapshot (this.getRtspChannel(), options?.timeout, targetWidth, targetHeight), 'image/jpeg'); + } + + override async startIntercom(media: MediaObject): Promise + { + // Simple debounce protection + if (this.intercomBusy) { + this.console.debug ('Intercom busy, ignoring start request'); + return; + } + + this.intercomBusy = true; + this.console.debug ('Starting intercom'); + + let channel: string = '1'; + let sipAudioTarget: SipAudioTarget | undefined; + + try + { + await this.stopRing(); + + channel = this.getRtspChannel() || '1'; + + let codec: string; + let format: string; + + try { + codec = await this.getClient().twoWayAudioCodec(channel); + } + catch (e) { + this.console.error('Failure while determining two way audio codec', e); + } + + if (codec === 'G.711ulaw') { + codec = 'pcm_mulaw'; + format = 'mulaw' + } + else if (codec === 'G.711alaw') { + codec = 'pcm_alaw'; + format = 'alaw' + } + else { + if (codec) { + this.console.warn('Unknown codec', codec); + this.console.warn('Set your audio codec to G.711ulaw.'); + } + this.console.warn('Using fallback codec pcm_mulaw. This may not be correct.'); + // seems to ship with this as defaults. + codec = 'pcm_mulaw'; + format = 'mulaw' + } + + // Set codec for SIP manager + if (this.sipManager) { + this.sipManager.audioCodec = codec; + // Invite if needed + await this.sipManager.invite(); + } + + const buffer = await mediaManager.convertMediaObjectToBuffer(media, ScryptedMimeTypes.FFmpegInput); + const ffmpegInput = JSON.parse(buffer.toString()) as FFmpegInput; + + // Check if we have SIP audio target + sipAudioTarget = this.sipManager?.remoteAudioTarget; + + if (!sipAudioTarget) { + // Use HTTP method with switcher + const session = await this.switchHttpSession (true); + if (!session) { + throw new Error ('Failed to initialize HTTP session'); + } + } else { + this.console.info (`Using SIP RTP target: ${sipAudioTarget.ip}:${sipAudioTarget.port}`); + } + + // Kill previous forwarder if exists + if (this.activeIntercom && !this.activeIntercom.killed) { + this.activeIntercom.kill(); + } + + // Configure audio track based on mode (SIP or HTTP) + let audioTrack; + if (sipAudioTarget) { + // Initialize switcher if not exists + if (!this.rtpStreamSwitcher) { + this.rtpStreamSwitcher = new RtpStreamSwitcher (this.console); + } + + // Set initial target + this.rtpStreamSwitcher.switchTarget (sipAudioTarget.ip, sipAudioTarget.port); + + // Create audio track with switcher enabled + audioTrack = this.createSipAudioTrack (codec, true); + } else { + // HTTP mode needs buffer accumulation, write to switcher + let available = Buffer.alloc (0); + audioTrack = { + onRtp: (rtp: Buffer) => { + const parsed = RtpPacket.deSerialize (rtp); + available = Buffer.concat ([available, parsed.payload]); + if (available.length > 1024) { + const data = available.subarray (0, 1024); + // Write to switcher instead of directly to passthrough + this.httpStreamSwitcher?.write (data); + available = available.subarray (1024); + } + }, + codecCopy: codec, + encoderArguments: [ + '-ar', '8000', + '-ac', '1', + '-acodec', codec, + ] + }; + } + + const forwarder = this.activeIntercom = await startRtpForwarderProcess ( + this.console, + ffmpegInput, + { audio: audioTrack } + ); + + // Setup PUT promise handlers for initial session after forwarder is created + // so when calling we need this.activeIntercom to be populated + // Only for HTTP mode (switcher has the putPromise) + if (!sipAudioTarget && this.httpStreamSwitcher) { + const currentSession = this.httpStreamSwitcher.getCurrentSession(); + if (currentSession) { + this.setupPutPromiseHandlers (currentSession.putPromise); + } + } + + // Single cleanup + forwarder.killPromise.finally (() => { + // Only cleanup if this is still the active forwarder + if (this.activeIntercom === forwarder) + { + this.console.debug ('Audio finished, cleaning up'); + try { + // HTTP mode cleanup - close current active session (not captured sessionId) + if (!sipAudioTarget && this.httpStreamSwitcher) { + const currentSessionId = this.httpStreamSwitcher.getCurrentSessionId(); + if (currentSessionId) { + this.getClient().closeTwoWayAudio (channel, currentSessionId); + this.console.debug (`Closed HTTP session ${currentSessionId} on forwarder finish`); + } + } + } catch (e) { + // Ignore if already ended + } + + // Reset state without calling stopIntercom recursively + this.activeIntercom = undefined; + } else { + this.console.debug ('Old forwarder finished, ignoring cleanup (new forwarder is active)'); + } + }); + } catch (error) { + // Reset state on error + if (!sipAudioTarget && this.httpStreamSwitcher) { + const currentSessionId = this.httpStreamSwitcher.getCurrentSessionId(); + if (currentSessionId) { + try { + await this.getClient().closeTwoWayAudio (channel, currentSessionId); + } catch (e) { + this.console.warn (`Failed to close HTTP session ${currentSessionId} on error:`, e); + } + } + } + this.intercomBusy = false; + throw error; + } + } + + override async stopIntercom(): Promise + { + // Queue stopIntercom calls to ensure sequential execution + const stopPromise = this.stopIntercomQueue.then (async () => { + if (!this.intercomBusy) { + this.console.debug ('Intercom not active, ignoring stop request'); + return; + } + + this.console.debug ('Stopping intercom'); + + // Clear grace period if active + this.clearGracePeriod(); + + try + { + // Kill the forwarder if exists + if (this.activeIntercom && !this.activeIntercom.killed) { + this.activeIntercom.kill(); + } + this.activeIntercom = undefined; + + // Cleanup stream switchers + if (this.rtpStreamSwitcher) { + this.rtpStreamSwitcher.destroy(); + this.rtpStreamSwitcher = undefined; + } + + if (this.sipManager) { + await this.sipManager.hangup(); + } + else { + // ISAPI mode: close HTTP session if active + const currentSessionId = this.httpStreamSwitcher?.getCurrentSessionId(); + if (currentSessionId) { + const channel = this.getRtspChannel() || '1'; + try { + await this.getClient().closeTwoWayAudio (channel, currentSessionId); + this.console.debug (`Closed HTTP session ${currentSessionId}`); + } catch (e) { + this.console.warn (`Failed to close HTTP session ${currentSessionId}:`, e); + } + } + + await this.getClient().hangUpCall(); + } + + // Destroy HTTP stream switcher after closing session + if (this.httpStreamSwitcher) { + this.httpStreamSwitcher.destroy(); + this.httpStreamSwitcher = undefined; + } + } finally { + // Always reset state + this.intercomBusy = false; + } + }); + + // Update queue to continue after this request (success or failure) + this.stopIntercomQueue = stopPromise.catch (() => { + // Swallow errors in the queue chain to prevent blocking subsequent calls + }); + + return stopPromise; + } + + private createEventApi(): HikvisionDoorbellAPI + { + return new HikvisionDoorbellAPI ( this.getIPAddress(), this.getHttpPort(), this.getUsername(), this.getPassword(), + this.isCallPolling(), this.console, - this.storage); + this.storage + ); } - private async stopRinging () + private async stopRing() { if (!this.binaryState) return; if (this.sipManager) { - try - { - const hup = timeoutPromise (5000, once (this.controlEvents, HikvisionDoorbellEvent.TalkHangup)); - await Promise.all ([hup, this.sipManager.answer()]) + try { + await this.sipManager.answer(); } catch (error) { this.console.error (`Stop SIP ringing error: ${error}`); } } else { - await this.getClient().stopRinging(); + await this.getClient().hangUpCall(); } } - /// Installs fake SIP settings on physical device, - /// if appropriate option is enabled (autoinstall) + /// Installs fake SIP settings on physical device automatically + /// when SIP Proxy mode is enabled private installSipSettingsOnDeviceTimeout: NodeJS.Timeout; private async installSipSettingsOnDevice() { @@ -561,18 +1205,20 @@ class HikvisionCameraDoorbell extends HikvisionCamera implements Camera, Interco if (this.getSipMode() === SipMode.Server && this.sipManager) { - const autoinstall = parseBooleans (this.storage.getItem (SIP_SERVER_INSTALL_ON_KEY)) const ip = this.sipManager.localIp; const port = this.sipManager.localPort; - if (autoinstall) { - try { - await this.getClient().setFakeSip (true, ip, port) - this.console.info (`Installed fake SIP settings on doorbell. Address: ${ip}, port: ${port}`); - } catch (e) { - this.console.error (`Error installing fake SIP settings: ${e}`); - // repeat if unreached - this.installSipSettingsOnDeviceTimeout = setTimeout (() => this.installSipSettingsOnDevice(), UNREACHED_REPEAT_TIMEOUT); - } + const roomNumber = this.storage.getItem (SIP_SERVER_ROOM_NUMBER_KEY) || DEFAULT_ROOM_NUMBER; + const proxyPhone = this.storage.getItem (SIP_SERVER_PROXY_PHONE_KEY) || DEFAULT_PROXY_PHONE; + const doorbellPhone = this.storage.getItem (SIP_SERVER_DOORBELL_PHONE_KEY) || DEFAULT_DOORBELL_PHONE; + const buttonNumber = this.storage.getItem (SIP_SERVER_BUTTON_NUMBER_KEY) || DEFAULT_BUTTON_NUMBER; + + try { + await this.getClient().setFakeSip (ip, port, roomNumber, proxyPhone, doorbellPhone, buttonNumber) + this.console.info (`Installed fake SIP settings on doorbell. Address: ${ip}, port: ${port}, room: ${roomNumber}, proxy phone: ${proxyPhone}, doorbell phone: ${doorbellPhone}, button: ${buttonNumber}`); + } catch (e) { + this.console.error (`Error installing fake SIP settings: ${e}`); + // repeat if unreached + this.installSipSettingsOnDeviceTimeout = setTimeout (() => this.installSipSettingsOnDevice(), UNREACHED_RETRY_SEC * 1000); } } } @@ -624,10 +1270,31 @@ class HikvisionCameraDoorbell extends HikvisionCamera implements Camera, Interco placeholder: 'CallId', type: 'string', }, + { + subgroup: 'Connect to SIP Proxy', + key: SIP_SERVER_DOORBELL_PHONE_KEY, + title: 'Doorbell Caller ID', + description: 'Caller ID (Phone number) that will represent the doorbell', + value: this.storage.getItem (SIP_SERVER_DOORBELL_PHONE_KEY), + type: 'integer', + placeholder: DEFAULT_DOORBELL_PHONE + }, ]; case SipMode.Server: return [ + { + subgroup: 'Emulate SIP Proxy', + title: 'Information', + description: '', + value: `

SIP proxy is emulated on this plugin. + It allows intercepting and handling SIP calls from the doorbell device. + It is used for SIP call control and monitoring. + It is not related to SIP telephony.

+

Enabling this mode will automatically configure the necessary settings on the doorbell device!

`, + type: 'html', + readonly: true, + }, { subgroup: 'Emulate SIP Proxy', key: 'sipServerIp', @@ -648,11 +1315,39 @@ class HikvisionCameraDoorbell extends HikvisionCamera implements Camera, Interco }, { subgroup: 'Emulate SIP Proxy', - key: SIP_SERVER_INSTALL_ON_KEY, - title: 'Autoinstall Fake SIP Proxy', - description: 'Install fake SIP proxy settings on a physical device (Hikvision Doorbell) automatically', - value: parseBooleans (this.storage.getItem (SIP_SERVER_INSTALL_ON_KEY)) || false, - type: 'boolean' + key: SIP_SERVER_ROOM_NUMBER_KEY, + title: 'Room Number', + description: 'Room number to be configured on the doorbell device. Must be between 1 and 9999. This room number will represent this fake SIP proxy', + value: this.storage.getItem (SIP_SERVER_ROOM_NUMBER_KEY), + type: 'integer', + placeholder: DEFAULT_ROOM_NUMBER + }, + { + subgroup: 'Emulate SIP Proxy', + key: SIP_SERVER_PROXY_PHONE_KEY, + title: 'SIP Proxy Phone Number', + description: 'Phone number that will represent this fake SIP proxy', + value: this.storage.getItem (SIP_SERVER_PROXY_PHONE_KEY), + type: 'integer', + placeholder: DEFAULT_PROXY_PHONE + }, + { + subgroup: 'Emulate SIP Proxy', + key: SIP_SERVER_DOORBELL_PHONE_KEY, + title: 'Doorbell Phone Number', + description: 'Phone number that will represent the doorbell', + value: this.storage.getItem (SIP_SERVER_DOORBELL_PHONE_KEY), + type: 'integer', + placeholder: DEFAULT_DOORBELL_PHONE + }, + { + subgroup: 'Emulate SIP Proxy', + key: SIP_SERVER_BUTTON_NUMBER_KEY, + title: 'Button Number', + description: 'Number of the call button. Used when doorbell has multiple call buttons. Must be between 1 and 99.', + value: this.storage.getItem (SIP_SERVER_BUTTON_NUMBER_KEY), + type: 'integer', + placeholder: DEFAULT_BUTTON_NUMBER }, ]; @@ -673,21 +1368,116 @@ class HikvisionCameraDoorbell extends HikvisionCamera implements Camera, Interco password: this.storage.getItem (SIP_CLIENT_PASSWORD_KEY) || '', ip: this.storage.getItem (SIP_CLIENT_PROXY_IP_KEY) || '', port: parseNumbers (this.storage.getItem (SIP_CLIENT_PROXY_PORT_KEY) || '5060'), - callId: this.storage.getItem (SIP_CLIENT_CALLID_KEY) || '' + callId: this.storage.getItem (SIP_CLIENT_CALLID_KEY) || '', + doorbellId: this.storage.getItem (SIP_SERVER_DOORBELL_PHONE_KEY) || DEFAULT_DOORBELL_PHONE } } + + private configureSipHandlers() + { + const sipMode = this.getSipMode(); + const mng = this.sipManager; + if (sipMode !== SipMode.Off && mng) + { + // Use SIP for invite detection + this.console.debug ('Using SIP for invite detection'); + + mng.setOnInviteHandler (async () => { + this.console.debug (`SIP INVITE received`); + + // Check if we're waiting for reconnection during grace period + if (this.waitingForReconnect && this.activeIntercom) + { + this.console.info ('(SIP) Received INVITE during grace period, attempting seamless reconnection'); + + // Clear grace period timer + this.clearGracePeriod(); + + // Accept the new invite + try { + await mng.answer(); + + // Get new audio target from SIP manager + if (mng.remoteAudioTarget) + { + + // Switch to new audio target + const switched = await this.switchAudioTarget(); + if (!switched) { + this.console.error ('Failed to switch audio target, stopping intercom'); + await this.stopIntercom(); + return; + } + } + else + { + this.console.warn ('No audio target in new INVITE, stopping intercom'); + await this.stopIntercom(); + return; + } + + this.console.info ('Seamless reconnection successful'); + } catch (error) { + this.console.error ('Failed to accept INVITE during reconnection:', error); + // Fallback: stop intercom + await this.stopIntercom(); + } + return; + } + + if (this.activeIntercom) + { + this.console.debug ('(SIP) Doorbell is busy, ignore invite'); + return; + } + this.binaryState = true; + }); + + mng.setOnStopRingingHandler (() => { + this.console.debug ('SIP stop ringing'); + this.binaryState = false; + }); + + mng.setOnHangupHandler (async () => { + this.console.debug ('SIP BYE received'); + + // Check if intercom is active + if (this.activeIntercom && !this.activeIntercom.killed) + { + this.console.info ('Intercom is active, starting grace period for reconnection'); + + // Clear any existing timer + this.clearGracePeriod(); + + // Set flag that we're waiting for reconnection + this.waitingForReconnect = true; + + // Start grace period timer + this.gracePeriodTimer = setTimeout (() => { + this.attemptSipReconnection(); + }, GRACE_PERIOD_SEC * 1000); + + this.console.debug (`Waiting ${GRACE_PERIOD_SEC}s for potential reconnection`); + } else { + // No active intercom, just stop normally + await this.stopIntercom(); + } + }); + + return; + } + // Use polling for invite detection + this.console.debug ('Using call status polling for invite detection'); + } + + private isCallPolling(): boolean { + return this.getSipMode() === SipMode.Off; + } } export class HikvisionDoorbellProvider extends RtspProvider { static CAMERA_NATIVE_ID_KEY: string = 'cameraNativeId'; - - clients: Map; - lockDevices: Map; - alertDevices: Map; - - private static LOCK_DEVICE_PREFIX = 'hik-lock:'; - private static ALERT_DEVICE_PREFIX = 'hik-alert:'; constructor() { super(); @@ -705,93 +1495,41 @@ export class HikvisionDoorbellProvider extends RtspProvider ]; } - createSharedClient (ip: string, port: string, username: string, password: string, console: Console, storage: Storage) - { - if (!this.clients) - this.clients = new Map(); - - const key = `${ip}#${port}#${username}#${password}`; - const check = this.clients.get(key); - if (check) - return check; - - const client = new HikvisionDoorbellAPI (ip, port, username, password, console, storage); - this.clients.set (key, client); - return client; - } - override createCamera(nativeId: string) { return new HikvisionCameraDoorbell(nativeId, this); } - override async getDevice (nativeId: string): Promise - { - if (this.isLockId (nativeId)) - { - if (typeof (this.lockDevices) === 'undefined') { - this.lockDevices = new Map(); - } - - let ret = this.lockDevices.get (nativeId); - if (!ret) + async getCreateDeviceSettings(): Promise { + return [ { - ret = new HikvisionLock (nativeId, this); - if (ret) - this.lockDevices.set(nativeId, ret); - } - return ret; - } - else if (this.isAlertId (nativeId)) - { - if (typeof (this.alertDevices) === 'undefined') { - this.alertDevices = new Map(); - } - - let ret = this.alertDevices.get (nativeId); - if (!ret) + key: 'username', + title: 'Username', + }, { - ret = new HikvisionTamperAlert (nativeId); - if (ret) - this.alertDevices.set(nativeId, ret); + key: 'password', + title: 'Password', + type: 'password', + }, + { + key: 'ip', + title: 'IP Address', + placeholder: '192.168.2.222', + }, + { + subgroup: 'Advanced', + key: 'httpPort', + title: 'HTTP Port', + description: 'Optional: Override the HTTP Port from the default value of 80.', + placeholder: '80', + }, + { + subgroup: 'Advanced', + key: 'skipValidate', + title: 'Skip Validation', + description: 'Add the device without verifying the credentials and network settings.', + type: 'boolean', } - return ret; - } - - return super.getDevice (nativeId); - } - - async getLockDevice (cameraNativeId: string): Promise - { - const nativeId = this.lockIdFrom (cameraNativeId); - return this.getDevice (nativeId); - } - - async getAlertDevice (cameraNativeId: string): Promise - { - const nativeId = this.alertIdFrom (cameraNativeId); - return this.getDevice (nativeId); - } - - override async releaseDevice(id: string, nativeId: string): Promise { - - this.console.error(`Release device: ${id}, ${nativeId}`); - const camera = this.getCameraDeviceFor (nativeId, false); - if (this.isLockId (nativeId)) - { - camera.onLockRemoved(); - this.lockDevices.delete (nativeId); - return; - } - if (this.isAlertId (nativeId)) - { - camera.onAlertRemoved(); - this.alertDevices.delete (nativeId); - return; - } - await this.disableLock (nativeId); - await this.disableAlert (nativeId); - this.devices.delete(nativeId); - camera?.destroy(); + ] } override async createDevice(settings: DeviceCreatorSettings, nativeId?: string): Promise { @@ -802,7 +1540,15 @@ export class HikvisionDoorbellProvider extends RtspProvider const skipValidate = settings.skipValidate?.toString() === 'true'; let twoWayAudio: string; if (!skipValidate) { - const api = new HikvisionDoorbellAPI(`${settings.ip}`, `${settings.httpPort || '80'}`, username, password, this.console, this.storage); + const api = new HikvisionDoorbellAPI( + `${settings.ip}`, + `${settings.httpPort || '80'}`, + username, + password, + false, + this.console, + this.storage + ); try { const deviceInfo = await api.getDeviceInfo(); @@ -843,155 +1589,6 @@ export class HikvisionDoorbellProvider extends RtspProvider device.updateDeviceInfo(); return nativeId; } - - async enableLock (cameraNativeId: string) - { - const camera = await this.getCameraDeviceFor (cameraNativeId) - const nativeId = this.lockIdFrom (cameraNativeId); - const name = `${camera.name} (Door Lock)` - await this.updateLock (nativeId, name); - await this.cameraMetaToAux (nativeId, camera); - } - - async disableLock (cameraNativeId: string) - { - const nativeId = this.lockIdFrom (cameraNativeId); - return this.removingAuxNotify (nativeId) - } - - async updateLock (nativeId: string, name?: string) - { - await deviceManager.onDeviceDiscovered({ - nativeId, - name, - interfaces: HikvisionLock.deviceInterfaces, - type: ScryptedDeviceType.Lock - }); - - } - - async enableAlert (cameraNativeId: string) - { - const camera = await this.getCameraDeviceFor (cameraNativeId) - const nativeId = this.alertIdFrom (cameraNativeId); - const name = `${camera.name} (Doorbell Tamper Alert)` - await this.updateAlert (nativeId, name); - await this.cameraMetaToAux (nativeId, camera); - } - - async disableAlert (cameraNativeId: string) - { - const nativeId = this.alertIdFrom (cameraNativeId); - return this.removingAuxNotify (nativeId) - } - - async updateAlert (nativeId: string, name?: string) - { - await deviceManager.onDeviceDiscovered({ - nativeId, - name, - interfaces: HikvisionTamperAlert.deviceInterfaces, - type: ScryptedDeviceType.Switch - }); - - } - - private async cameraMetaToAux (nativeId: string, camera: HikvisionCameraDoorbell) - { - const user = camera.storage.getItem ('username'); - const pass = camera.storage.getItem ('password'); - const aux = await this.getDevice (nativeId) as Settings; - aux.putSetting ('user', user); - aux.putSetting ('pass', pass); - aux.putSetting ('ip', camera.getIPAddress()); - aux.putSetting ('port', camera.getHttpPort()); - aux.putSetting (HikvisionDoorbellProvider.CAMERA_NATIVE_ID_KEY, camera.nativeId); - } - - private async removingAuxNotify (nativeId: string) - { - const state = deviceManager.getDeviceState (nativeId); - if (state?.nativeId === nativeId) { - return deviceManager.onDeviceRemoved (nativeId) - } - } - - override async getCreateDeviceSettings(): Promise { - return [ - { - key: 'username', - title: 'Username', - }, - { - key: 'password', - title: 'Password', - type: 'password', - }, - { - key: 'ip', - title: 'IP Address', - placeholder: '192.168.2.222', - }, - { - key: 'httpPort', - title: 'HTTP Port', - description: 'Optional: Override the HTTP Port from the default value of 80', - placeholder: '80', - }, - { - key: 'skipValidate', - title: 'Skip Validation', - description: 'Add the device without verifying the credentials and network settings.', - type: 'boolean', - } - ] - } - - private lockIdFrom (cameraNativeId: string): string { - return `${HikvisionDoorbellProvider.LOCK_DEVICE_PREFIX}${cameraNativeId}` - } - - private alertIdFrom (cameraNativeId: string): string { - return `${HikvisionDoorbellProvider.ALERT_DEVICE_PREFIX}${cameraNativeId}` - } - - private isLockId (nativeId: string):boolean { - return nativeId.startsWith (HikvisionDoorbellProvider.LOCK_DEVICE_PREFIX); - } - - private isAlertId (nativeId: string):boolean { - return nativeId.startsWith (HikvisionDoorbellProvider.ALERT_DEVICE_PREFIX); - } - - private cameraIdFrom (nativeId: string): string - { - if (this.isLockId (nativeId)) { - return nativeId.substring (HikvisionDoorbellProvider.LOCK_DEVICE_PREFIX.length); - } - if (this.isAlertId (nativeId)) { - return nativeId.substring (HikvisionDoorbellProvider.ALERT_DEVICE_PREFIX.length); - } - return nativeId; - } - - private getCameraDeviceFor (nativeId, check: boolean = true): HikvisionCameraDoorbell - { - try - { - const cameraId = this.cameraIdFrom (nativeId); - if (check) - { - const state = deviceManager.getDeviceState (cameraId); - if (state?.nativeId !== cameraId) - return null; - } - return this.devices?.get (cameraId); - } catch (error) - { - this.console.warn (`Error obtaining camera device: ${error}`); - return null; - } - } } export default new HikvisionDoorbellProvider(); diff --git a/plugins/hikvision-doorbell/src/rtp-stream-switcher.ts b/plugins/hikvision-doorbell/src/rtp-stream-switcher.ts new file mode 100644 index 000000000..a973c5669 --- /dev/null +++ b/plugins/hikvision-doorbell/src/rtp-stream-switcher.ts @@ -0,0 +1,121 @@ +import { EventEmitter } from 'events'; +import dgram from 'dgram'; +import { udpSocketType } from './utils'; + +/** + * RTP Stream Switcher + * Receives RTP packets from single source and sends to single active target + * Supports seamless target switching without stopping the data source + * Supports both IPv4 and IPv6 + */ +export interface RtpTarget { + ip: string; + port: number; + socket: dgram.Socket; +} + +export class RtpStreamSwitcher extends EventEmitter +{ + private currentTarget?: RtpTarget; + private packetCount: number = 0; + private targetSwitchCount: number = 0; + + constructor (private console: Console) { + super(); + } + + /** + * Switch to new RTP target + * Old target will be closed gracefully + */ + switchTarget (ip: string, port: number): void + { + const oldTarget = this.currentTarget; + + if (oldTarget) { + this.console.debug (`Switching RTP target ${oldTarget.ip}:${oldTarget.port} -> ${ip}:${port} (${this.packetCount} packets sent)`); + + // Close old socket gracefully + try { + oldTarget.socket.close(); + } catch (e) { + // Ignore errors on old socket + } + + this.targetSwitchCount++; + } else { + this.console.debug (`Setting initial RTP target ${ip}:${port}`); + } + + const socketType = udpSocketType (ip); + const socket = dgram.createSocket (socketType); + + // Setup error handler for new socket + socket.on ('error', (err) => { + this.console.error (`Socket error for target ${ip}:${port}:`, err); + if (this.currentTarget?.socket === socket) { + this.clearTarget(); + } + }); + + this.currentTarget = { ip, port, socket }; + this.packetCount = 0; + + this.console.info (`RTP target set: ${ip}:${port} (${socketType})`); + } + + /** + * Clear current target without replacement + */ + private clearTarget(): void + { + this.currentTarget = undefined; + } + + /** + * Send RTP packet to current active target + */ + sendRtp (rtp: Buffer): void + { + if (!this.currentTarget) { + // No active target, drop packet + return; + } + + this.packetCount++; + + try { + this.currentTarget.socket.send (rtp, this.currentTarget.port, this.currentTarget.ip, (err) => { + if (err) { + this.console.error (`Failed to send RTP packet:`, err); + } + }); + } catch (error) { + this.console.error (`Error sending RTP packet:`, error); + this.clearTarget(); + } + + if (this.packetCount % 100 === 0) { + this.console.debug (`Sent ${this.packetCount} RTP packets to current target`); + } + } + + /** + * Destroy switcher and cleanup + */ + destroy(): void + { + this.console.debug (`Destroying RTP switcher (sent ${this.packetCount} packets, ${this.targetSwitchCount} switches)`); + + if (this.currentTarget) { + try { + this.currentTarget.socket.close(); + } catch (e) { + // Ignore + } + this.currentTarget = undefined; + } + + this.removeAllListeners(); + } +} diff --git a/plugins/hikvision-doorbell/src/sip-manager.ts b/plugins/hikvision-doorbell/src/sip-manager.ts index 3933cd2e1..c9d827786 100644 --- a/plugins/hikvision-doorbell/src/sip-manager.ts +++ b/plugins/hikvision-doorbell/src/sip-manager.ts @@ -4,17 +4,27 @@ import { localServiceIpAddress, rString, udpSocketType, unq } from './utils'; import { isV4Format } from 'ip'; import dgram from 'node:dgram'; import { timeoutPromise } from "@scrypted/common/src/promise-utils"; +import { parseSdp } from '@scrypted/common/src/sdp-utils'; - +export interface SipAudioTarget { + ip: string; + port: number; +} enum DialogStatus { Idle, + // Incoming call states Ringing, Answer, - AnswerAc, - Hangup, - HangupAc, + Bye, + ByeOk, + // Outgoing call states + Inviting, + InviteAc, + // Connected states (in/out) + Connected, + // Registration Regitering } @@ -30,42 +40,90 @@ const clientRegistrationExpires = 3600; // in seconds export interface SipRegistration { - user: string; - password: string; - ip: string; - port: number; - callId: string; - realm?: string; + user: string; // username for registration + password: string; // password for registration + ip: string; // ip address for registration or doorbell ip + port: number; // port for registration or doorbell port + callId: string; // call id for registration (local phone number) + realm?: string; // realm for registration + doorbellId: string; // doorbell id for registration (remote phone number) } export class SipManager { localIp: string; localPort: number; + remoteAudioTarget?: SipAudioTarget; + audioCodec?: string; + + private onInviteHandler?: () => void; + private onStopRingingHandler?: () => void; + private onHangupHandler?: () => void; + + private callId: string = '10012'; constructor(private ip: string, private console: Console, private storage: Storage) { } + setOnInviteHandler (handler: () => void) + { + this.onInviteHandler = handler; + } + + setOnStopRingingHandler (handler: () => void) + { + this.onStopRingingHandler = handler; + } + + setOnHangupHandler (handler: () => void) + { + this.onHangupHandler = handler; + } + + private parseSdpAudioTarget (sdpContent?: string): SipAudioTarget | undefined + { + if (!sdpContent) return undefined; + + try { + const parsed = parseSdp (sdpContent); + + // Find audio section + const audioSection = parsed.msections.find (s => s.type === 'audio'); + if (!audioSection) { + this.console.warn ('No audio section found in SDP'); + return undefined; + } + + // Extract IP from header (c=IN IP4 ...) + const cLine = parsed.header.lines.find (l => l.startsWith ('c=')); + const ipMatch = cLine?.match (/c=IN IP[46] ([\d.:a-fA-F]+)/); + const ip = ipMatch?.[1]; + + const port = audioSection.port; + + if (ip && port) { + this.console.debug (`Parsed SDP audio target: ${ip}:${port}`); + return { ip, port }; + } + } catch (e) { + this.console.error (`Failed to parse SDP: ${e}`); + } + + return undefined; + } + async startClient (creds: SipRegistration) { this.clientMode = true; this.stop(); await this.startServer(); - - this.clientCreds = creds; - // { - // user: '4442', - // password: '4443', - // ip: '10.210.210.150', - // port: 5060, - // callId: '4442' - // } + this.remoteCreds = creds; return this.register(); } - async startGateway (port?: number) + async startGateway (callId?: string, port?: number) { if (this.clientMode && sip.stop) { await this.unregister(); @@ -77,6 +135,9 @@ export class SipManager { if (port) { this.localPort = port; } + if (callId) { + this.callId = callId; + } return this.startServer (!port); } @@ -91,7 +152,6 @@ export class SipManager { { const ring = this.state.msg; - let bye = true; let rs = this.makeRs (ring, 200, 'Ok'); rs.content = this.fakeSdpContent(); @@ -99,11 +159,11 @@ export class SipManager { try { await timeoutPromise (waitResponseTimeout, new Promise (resolve => { - this.state = { + this.setState ({ status: DialogStatus.Answer, msg: ring, waitAck: resolve - } + }); sip.send (rs); })); } catch (error) { @@ -111,56 +171,262 @@ export class SipManager { } // await Promise.race ([waitAck, awaitTimeout (waitResponseTimeout)]); - this.state = { - status: DialogStatus.AnswerAc, + this.setState ({ + status: DialogStatus.Connected, msg: ring - } - const byeMsg = this.bye (ring); + }); + } + } - try + async invite(): Promise + { + if (this.state.status !== DialogStatus.Idle) { + this.console.warn ('Cannot send INVITE: dialog not idle'); + return false; + } + + if (!this.remoteCreds) { + this.console.error ('Cannot send INVITE: no remote credentials'); + return false; + } + + const creds = this.remoteCreds; + const fromUri = sip.parseUri (`sip:${creds.callId}@${this.localIp}:${this.localPort}`); + const toUri = sip.parseUri (`sip:${creds.doorbellId}@${creds.ip}:${creds.port}`); + + const inviteMsg = { + method: 'INVITE', + uri: toUri, + headers: { + to: { uri: toUri }, + from: { uri: fromUri, params: { tag: rString() } }, + 'call-id': `${rString()}@${this.localIp}:${this.localPort}`, + cseq: { seq: 1, method: 'INVITE' }, + contact: [{ uri: fromUri }], + 'content-type': 'application/sdp', + }, + content: this.fakeSdpContent() + }; + + this.setState ({ + status: DialogStatus.Inviting, + msg: inviteMsg + }); + + try { + // Send INVITE and collect all responses until final (200 or 4xx/5xx/6xx) + const response = await timeoutPromise (waitResponseTimeout * 3, new Promise ((resolve, reject) => { + sip.send (inviteMsg, (rs) => { + if (rs.status >= 100 && rs.status < 200) { + // Provisional response (100 Trying, 180 Ringing) + this.console.debug (`INVITE: Provisional response ${rs.status}`); + // Don't resolve, callback will be called again for final response + } else if (rs.status >= 200) { + // Final response (200 OK or error) + resolve (rs); + } + }); + })); + + if (response.status === 200) { - const doit = new Promise (resolve => { - - sip.send (byeMsg, (rs) => { - this.console.log (`BYE response:\n${sip.stringify (rs)}`); - if (rs.status == 200) { - this.state.status = DialogStatus.HangupAc; - resolve(true); - } - }); - this.state.status = DialogStatus.Hangup; - + this.console.info ('INVITE: Call accepted (200 OK)'); + + // Parse remote SDP + this.remoteAudioTarget = this.parseSdpAudioTarget (response.content); + + this.setState ({ + status: DialogStatus.InviteAc, + msg: response }); - var result = await timeoutPromise (waitResponseTimeout, doit); - } catch (error) { - this.console.error (`Wait OK error: ${error}`); - } + // Send ACK + const ackMsg = { + method: 'ACK', + uri: toUri, + headers: { + to: response.headers.to, + from: inviteMsg.headers.from, + 'call-id': inviteMsg.headers['call-id'], + cseq: { seq: 1, method: 'ACK' }, + contact: inviteMsg.headers.contact, + } + }; - // const result = await Promise.race ([waitOk, awaitTimeout(waitResponseTimeout).then (()=> false)]) - if (!result) { - this.console.error (`When BYE, timeut occurred`); - } + sip.send (ackMsg); + this.setState ({ + status: DialogStatus.Connected, + msg: response + }); + + return true; + } + else if (response.status >= 400) + { + this.console.error (`INVITE failed: ${response.status} ${response.reason}`); + this.clearState(); + return false; + } + } + catch (error) { + this.console.error (`INVITE error: ${error}`); this.clearState(); + return false; } + + this.clearState(); + return false; + } + + async hangup(): Promise + { + if (this.state.status !== DialogStatus.Connected) { + this.console.warn ('Cannot send BYE: dialog not connected'); + return false; + } + + const byeMsg = this.bye (this.state.msg); + + try + { + const doit = new Promise (resolve => { + + sip.send (byeMsg, (rs) => { + this.console.info (`BYE response:\n${sip.stringify (rs)}`); + if (rs.status == 200) { + this.setState ({ status: DialogStatus.ByeOk, msg: byeMsg }); + resolve (true); + } + }); + this.setState ({ status: DialogStatus.Connected, msg: byeMsg }); + + }); + + var result = await timeoutPromise (waitResponseTimeout, doit); + } catch (error) { + this.console.error (`Wait OK error: ${error}`); + return false; + } + + // const result = await Promise.race ([waitOk, awaitTimeout(waitResponseTimeout).then (()=> false)]) + if (!result) { + this.console.error (`When BYE, timeout occurred`); + return false; + } + + this.clearState(); + return true; } private state: SipState = { status: DialogStatus.Idle}; private clientMode: boolean = false; private authCtx: any = { nc: 1 }; private registrationExpires: number = clientRegistrationExpires; - private clientCreds: SipRegistration; + private remoteCreds: SipRegistration; + + private setState (newState: SipState) + { + const oldStatus = this.state.status; + const newStatus = newState.status; + + this.state = newState; + + // Hook for future actions on state transitions + this.onStateChange (oldStatus, newStatus); + } + + private onStateChange(oldStatus: DialogStatus, newStatus: DialogStatus) + { + if (oldStatus === newStatus) + return; + + this.console.debug (`State transition: ${DialogStatus[oldStatus]} -> ${DialogStatus[newStatus]}`); + + switch (oldStatus) + { + case DialogStatus.Ringing: + if (this.onStopRingingHandler) { + // Call handler asynchronously to avoid blocking SIP message flow + setImmediate (() => { + try { + this.onStopRingingHandler(); + } catch (e) { + this.console.error(`Error in onStopRinging handler: ${e}`); + } + }); + } + return; + } + + switch (newStatus) + { + + case DialogStatus.Ringing: + if (this.onInviteHandler) { + // Call handler asynchronously to avoid blocking SIP message flow + setImmediate (() => { + try { + this.onInviteHandler(); + } catch (e) { + this.console.error(`Error in onInvite handler: ${e}`); + } + }); + } + return; + + case DialogStatus.Bye: + if (this.onHangupHandler) { + // Call handler asynchronously to avoid blocking SIP message flow + setImmediate (() => { + try { + this.onHangupHandler(); + } catch (e) { + this.console.error(`Error in onHangup handler: ${e}`); + } + }); + } + return; + } + } - private incomeRegister(rq: any): boolean { - - let rs = sip.makeResponse(rq, 200, 'OK'); + private incomeRegister (rq: any): boolean + { + // Parse registration request to extract credentials + const fromUri = sip.parseUri (rq.headers.from.uri); + const contactUri = rq.headers.contact && rq.headers.contact[0] && sip.parseUri (rq.headers.contact[0].uri); + const toUri = sip.parseUri (rq.headers.to.uri); + + const user = fromUri.user || toUri.user; // username for registration + const doorbellId = toUri.user || fromUri.user; // remote phone number (doorbell extension) + const ip = contactUri?.host || fromUri.host; + const port = contactUri?.port || fromUri.port || 5060; + + if (!user || !ip || !doorbellId) { + this.console.warn ('REGISTER: Missing user, doorbellId or IP in request'); + return false; + } + + // Store registration (only one client supported in gateway mode) + this.remoteCreds = { + user, + password: '', // Password will be handled via digest auth if needed + ip, + port, + callId: this.callId, + doorbellId, + realm: undefined + }; + + this.console.debug (`REGISTER: Stored registration for user ${user} from ${ip}:${port}`); + + let rs = sip.makeResponse (rq, 200, 'OK'); rs.headers.contact = rq.headers.contact; - sip.send(rs); - + rs.headers.expires = rq.headers.expires || clientRegistrationExpires; + sip.send (rs); + return true; - } private async startServer (findFreePort: boolean = true) @@ -176,10 +442,10 @@ export class SipManager { await sip.start({ logger: { send: (message, addrInfo) => { - this.console.log(`send to ${addrInfo.address}:\n${sip.stringify(message)}`); + this.console.debug (`send to ${addrInfo.address}:\n${sip.stringify(message)}`); }, recv: (message, addrInfo) => { - this.console.log(`recv to ${addrInfo.address}:\n${sip.stringify(message)}`); + this.console.debug (`recv to ${addrInfo.address}:\n${sip.stringify(message)}`); } }, address: this.localIp, @@ -246,13 +512,21 @@ export class SipManager { { if (this.state.status === DialogStatus.Idle) { + // Parse SDP to extract audio target + this.remoteAudioTarget = this.parseSdpAudioTarget (rq.content); + rq.headers.to = {uri: rq.headers.to.uri, params: { tag: 'govno' }}; - this.state = { - status: DialogStatus.Ringing, - msg: rq - } + + // Send 180 Ringing FIRST, before changing state let rs = this.makeRs(rq, 180, 'Ringing'); sip.send(rs); + + // Then update state (this will trigger onInviteHandler asynchronously) + this.setState ({ + status: DialogStatus.Ringing, + msg: rq + }); + return true; } return false; @@ -281,11 +555,12 @@ export class SipManager { private incomeBye (rq: any): boolean { - if (this.state.status == DialogStatus.AnswerAc || - this.state.status == DialogStatus.Hangup) + if (this.state.status == DialogStatus.Connected || + this.state.status == DialogStatus.Bye) { - this.clearState(); + this.setState ({ status: DialogStatus.Bye, msg: rq }); sip.send (this.makeRs (rq, 200, 'OK')); + this.clearState(); return true; } return false; @@ -299,37 +574,27 @@ export class SipManager { return rs; } - private fakeSdpContent() - { - const ipv = isV4Format (this.localIp) ? 'IP4' : 'IP6'; - const ip = `${ipv} ${this.localIp}`; - return 'v=0\r\n' + - `o=yate 1707679323 1707679323 IN ${ip}\r\n` + - 's=SIP Call\r\n' + - `c=IN ${ip}\r\n` + - 't=0 0\r\n' + - 'm=audio 9654 RTP/AVP 0 101\r\n' + - 'a=rtpmap:0 PCMU/8000\r\n' + - 'a=rtpmap:101 telephone-event/8000\r\n'; - } - private bye (rq: any): any { - const toUser = sip.parseUri(rq.headers.to.uri).user; let uri = rq.headers.contact[0] && rq.headers.contact[0].uri; if (uri === undefined) { uri = rq.headers.from.uri; } + // In SIP dialog, BYE From/To depend on who initiated the call + // If we received INVITE (server mode): swap headers + // If we sent INVITE (client mode): keep headers as is + const isServerMode = rq.method === 'INVITE'; + let msg = { method: 'BYE', uri: uri, headers: { - to: rq.headers.from, - from: rq.headers.to, + to: isServerMode ? rq.headers.from : rq.headers.to, + from: isServerMode ? rq.headers.to : rq.headers.from, 'call-id': rq.headers['call-id'], cseq: {method: 'BYE', seq: rq.headers.cseq.seq + 1}, - contact: `sip:${toUser}@${this.localIp}:${this.localPort}` + contact: `sip:${this.callId}@${this.localIp}:${this.localPort}` } } @@ -342,6 +607,36 @@ export class SipManager { return msg; } + private fakeSdpContent() + { + const ipv = isV4Format (this.localIp) ? 'IP4' : 'IP6'; + const ip = `${ipv} ${this.localIp}`; + + // Determine codec payload type and name + let payloadType = '0'; + let codecName = 'PCMU/8000'; + + if (this.audioCodec === 'pcm_alaw' || this.audioCodec === 'alaw') { + payloadType = '8'; + codecName = 'PCMA/8000'; + } else if (this.audioCodec === 'pcm_mulaw' || this.audioCodec === 'mulaw') { + payloadType = '0'; + codecName = 'PCMU/8000'; + } + + return 'v=0\r\n' + + `o=yate 1707679323 1707679323 IN ${ip}\r\n` + + 's=SIP Call\r\n' + + `c=IN ${ip}\r\n` + + 't=0 0\r\n' + + `m=audio 9654 RTP/AVP ${payloadType} 101\r\n` + + `a=rtpmap:${payloadType} ${codecName}\r\n` + + 'a=rtpmap:101 telephone-event/8000\r\n' + + 'a=sendonly\r\n' + + 'm=video 0 RTP/AVP 96\r\n' + + 'a=inactive\r\n'; + } + private async getFreeUdpPort (ip: string, type: dgram.SocketType) { return new Promise (resolve => { @@ -369,7 +664,7 @@ export class SipManager { { if (this.state.status !== DialogStatus.Idle) return false; - const creds = this.clientCreds; + const creds = this.remoteCreds; const hereUri = sip.parseUri (`sip:${creds.callId}@${this.localIp}:${this.localPort}`); const initMsg = { @@ -386,10 +681,10 @@ export class SipManager { } } - this.state = { + this.setState ({ status: DialogStatus.Regitering, msg: {...initMsg} - } + }); if (this.authCtx.realm) { digest.signRequest (this.authCtx, initMsg); @@ -431,8 +726,9 @@ export class SipManager { } - private clearState() { - this.state = { status: DialogStatus.Idle }; + private clearState() + { + this.setState ({ status: DialogStatus.Idle }); } /// Simple check that request came from doorbell @@ -444,7 +740,7 @@ export class SipManager { const puri = sip.parseUri (uri); const ip = puri && puri.host; if (ip) { - return this.clientCreds.ip === ip || this.ip === ip; + return this.remoteCreds.ip === ip || this.ip === ip; } } diff --git a/plugins/hikvision-doorbell/src/tamper-alert.ts b/plugins/hikvision-doorbell/src/tamper-alert.ts index 0b4671c99..5801b4415 100644 --- a/plugins/hikvision-doorbell/src/tamper-alert.ts +++ b/plugins/hikvision-doorbell/src/tamper-alert.ts @@ -1,21 +1,17 @@ -import sdk, { ScryptedDeviceBase, SettingValue, ScryptedInterface, Setting, Settings, Readme, OnOff } from "@scrypted/sdk"; -import { HikvisionDoorbellProvider } from "./main"; +import { OnOff, Readme, ScryptedDeviceBase, ScryptedInterface } from "@scrypted/sdk"; +import type { HikvisionCameraDoorbell } from "./main"; import * as fs from 'fs/promises'; import { join } from 'path'; import { parseBooleans } from "xml2js/lib/processors"; -const { deviceManager } = sdk; - -export class HikvisionTamperAlert extends ScryptedDeviceBase implements OnOff, Settings, Readme { - - // timeout: NodeJS.Timeout; +export class HikvisionTamperAlert extends ScryptedDeviceBase implements OnOff, Readme { + on: boolean = false; private static ONOFF_KEY: string = "onoff"; - constructor(nativeId: string) { - super (nativeId); - - this.on = parseBooleans (this.storage.getItem (HikvisionTamperAlert.ONOFF_KEY)); + constructor(public camera: HikvisionCameraDoorbell, nativeId: string) { + super(nativeId); + this.on = parseBooleans(this.storage.getItem(HikvisionTamperAlert.ONOFF_KEY)) || false; } async getReadmeMarkdown(): Promise @@ -24,48 +20,19 @@ export class HikvisionTamperAlert extends ScryptedDeviceBase implements OnOff, S return fs.readFile (fileName, 'utf-8'); } - turnOff(): Promise - { + async turnOff(): Promise { this.on = false; this.storage.setItem(HikvisionTamperAlert.ONOFF_KEY, 'false'); - return; } - turnOn(): Promise - { + + async turnOn(): Promise { this.on = true; this.storage.setItem(HikvisionTamperAlert.ONOFF_KEY, 'true'); - return; } - async getSettings(): Promise { - const cameraNativeId = this.storage.getItem (HikvisionDoorbellProvider.CAMERA_NATIVE_ID_KEY); - const state = deviceManager.getDeviceState (cameraNativeId); - return [ - { - key: 'parentDevice', - title: 'Linked Doorbell Device Name', - description: 'The name of the associated doorbell plugin device (for information)', - value: state.id, - readonly: true, - type: 'device', - }, - { - key: 'ip', - title: 'IP Address', - description: 'IP address of the doorbell device (for information)', - value: this.storage.getItem ('ip'), - readonly: true, - type: 'string', - } - ] - } - async putSetting(key: string, value: SettingValue): Promise { - this.storage.setItem(key, value.toString()); - } static deviceInterfaces: string[] = [ ScryptedInterface.OnOff, - ScryptedInterface.Settings, ScryptedInterface.Readme ]; } diff --git a/plugins/hikvision-doorbell/src/types.d.ts b/plugins/hikvision-doorbell/src/types.d.ts new file mode 100644 index 000000000..c2375f51b --- /dev/null +++ b/plugins/hikvision-doorbell/src/types.d.ts @@ -0,0 +1,8 @@ +// Local type declarations to support Symbol.dispose without affecting other plugins +declare global { + interface SymbolConstructor { + readonly dispose: unique symbol; + } +} + +export {}; diff --git a/plugins/hikvision-doorbell/tsconfig.json b/plugins/hikvision-doorbell/tsconfig.json index ba9b4d395..b553271a5 100644 --- a/plugins/hikvision-doorbell/tsconfig.json +++ b/plugins/hikvision-doorbell/tsconfig.json @@ -5,7 +5,8 @@ "resolveJsonModule": true, "moduleResolution": "Node16", "esModuleInterop": true, - "sourceMap": true + "sourceMap": true, + "skipLibCheck": true }, "include": [ "src/**/*"