mirror of
https://github.com/koush/scrypted.git
synced 2026-05-07 06:30:26 +01:00
Merge branch 'main' of github.com:koush/scrypted
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
9
plugins/hikvision-doorbell/fs/ENTRY_SENSOR_README.md
Normal file
9
plugins/hikvision-doorbell/fs/ENTRY_SENSOR_README.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
43
plugins/hikvision-doorbell/src/debug-console.ts
Normal file
43
plugins/hikvision-doorbell/src/debug-console.ts
Normal file
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
30
plugins/hikvision-doorbell/src/entry-sensor.ts
Normal file
30
plugins/hikvision-doorbell/src/entry-sensor.ts
Normal file
@@ -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<string>
|
||||
{
|
||||
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
|
||||
];
|
||||
}
|
||||
144
plugins/hikvision-doorbell/src/http-stream-switcher.ts
Normal file
144
plugins/hikvision-doorbell/src/http-stream-switcher.ts
Normal file
@@ -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<any>;
|
||||
}
|
||||
|
||||
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<any>): 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();
|
||||
}
|
||||
}
|
||||
@@ -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<void>
|
||||
{
|
||||
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<string>
|
||||
@@ -27,52 +45,24 @@ export class HikvisionLock extends ScryptedDeviceBase implements Lock, Settings,
|
||||
return fs.readFile (fileName, 'utf-8');
|
||||
}
|
||||
|
||||
lock(): Promise<void> {
|
||||
return this.getClient().closeDoor();
|
||||
}
|
||||
unlock(): Promise<void> {
|
||||
return this.getClient().openDoor();
|
||||
}
|
||||
|
||||
async getSettings(): Promise<Setting[]> {
|
||||
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<void> {
|
||||
this.storage.setItem(key, value.toString());
|
||||
}
|
||||
|
||||
getClient(): HikvisionDoorbellAPI
|
||||
async lock(): Promise<void>
|
||||
{
|
||||
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<void>
|
||||
{
|
||||
await this.getClient().controlDoor (this.doorNumber, 'open');
|
||||
}
|
||||
|
||||
private getClient(): HikvisionDoorbellAPI {
|
||||
return this.camera.getClient();
|
||||
}
|
||||
|
||||
static deviceInterfaces: string[] = [
|
||||
ScryptedInterface.Lock,
|
||||
ScryptedInterface.Settings,
|
||||
ScryptedInterface.Readme
|
||||
];
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
121
plugins/hikvision-doorbell/src/rtp-stream-switcher.ts
Normal file
121
plugins/hikvision-doorbell/src/rtp-stream-switcher.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<void> (waitResponseTimeout, new Promise<void> (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<boolean>
|
||||
{
|
||||
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<any> (waitResponseTimeout * 3, new Promise<any> ((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<boolean> (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<boolean> (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<boolean>
|
||||
{
|
||||
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<boolean> (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<boolean> (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<number> (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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>
|
||||
@@ -24,48 +20,19 @@ export class HikvisionTamperAlert extends ScryptedDeviceBase implements OnOff, S
|
||||
return fs.readFile (fileName, 'utf-8');
|
||||
}
|
||||
|
||||
turnOff(): Promise<void>
|
||||
{
|
||||
async turnOff(): Promise<void> {
|
||||
this.on = false;
|
||||
this.storage.setItem(HikvisionTamperAlert.ONOFF_KEY, 'false');
|
||||
return;
|
||||
}
|
||||
turnOn(): Promise<void>
|
||||
{
|
||||
|
||||
async turnOn(): Promise<void> {
|
||||
this.on = true;
|
||||
this.storage.setItem(HikvisionTamperAlert.ONOFF_KEY, 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
async getSettings(): Promise<Setting[]> {
|
||||
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<void> {
|
||||
this.storage.setItem(key, value.toString());
|
||||
}
|
||||
|
||||
static deviceInterfaces: string[] = [
|
||||
ScryptedInterface.OnOff,
|
||||
ScryptedInterface.Settings,
|
||||
ScryptedInterface.Readme
|
||||
];
|
||||
}
|
||||
|
||||
8
plugins/hikvision-doorbell/src/types.d.ts
vendored
Normal file
8
plugins/hikvision-doorbell/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
// Local type declarations to support Symbol.dispose without affecting other plugins
|
||||
declare global {
|
||||
interface SymbolConstructor {
|
||||
readonly dispose: unique symbol;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -5,7 +5,8 @@
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "Node16",
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
|
||||
Reference in New Issue
Block a user