Merge branch 'main' of github.com:koush/scrypted

This commit is contained in:
Koushik Dutta
2025-10-12 21:28:04 -07:00
18 changed files with 2951 additions and 982 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 doorbells 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.

View 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.

View File

@@ -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.

View File

@@ -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",

View File

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

View 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

View 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
];
}

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

View File

@@ -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

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

View File

@@ -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;
}
}

View File

@@ -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
];
}

View 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 {};

View File

@@ -5,7 +5,8 @@
"resolveJsonModule": true,
"moduleResolution": "Node16",
"esModuleInterop": true,
"sourceMap": true
"sourceMap": true,
"skipLibCheck": true
},
"include": [
"src/**/*"