hikvision-doorbell: fixes (#1970)

* Let's try to fix the plugin freezing

* hikvision-doorbell version up after merging from main
This commit is contained in:
Roman Sokolov
2026-01-24 19:18:59 +03:00
committed by GitHub
parent 079878b663
commit 3d1d3727dc
6 changed files with 242 additions and 136 deletions

View File

@@ -1,12 +1,12 @@
{ {
"name": "@vityevato/hikvision-doorbell", "name": "@vityevato/hikvision-doorbell",
"version": "1.0.1", "version": "2.0.0d",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@vityevato/hikvision-doorbell", "name": "@vityevato/hikvision-doorbell",
"version": "1.0.1", "version": "2.0.0d",
"license": "Apache", "license": "Apache",
"dependencies": { "dependencies": {
"@scrypted/common": "file:../../common", "@scrypted/common": "file:../../common",
@@ -30,39 +30,41 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@scrypted/sdk": "file:../sdk", "@scrypted/sdk": "file:../sdk",
"@scrypted/types": "^0.5.27",
"http-auth-utils": "^5.0.1", "http-auth-utils": "^5.0.1",
"typescript": "^5.5.3" "typescript": "^5.5.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.11.0", "@types/node": "^20.19.11",
"monaco-editor": "^0.50.0", "monaco-editor": "^0.50.0",
"ts-node": "^10.9.2" "ts-node": "^10.9.2"
} }
}, },
"../../sdk": { "../../sdk": {
"name": "@scrypted/sdk", "name": "@scrypted/sdk",
"version": "0.3.118", "version": "0.5.48",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@babel/preset-typescript": "^7.26.0", "@babel/preset-typescript": "^7.27.1",
"@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-commonjs": "^28.0.5",
"@rollup/plugin-json": "^6.1.0", "@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.1", "@rollup/plugin-typescript": "^12.1.2",
"@rollup/plugin-virtual": "^3.0.2", "@rollup/plugin-virtual": "^3.0.2",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"axios": "^1.7.8", "axios": "^1.10.0",
"babel-loader": "^9.2.1", "babel-loader": "^10.0.0",
"babel-plugin-const-enum": "^1.2.0", "babel-plugin-const-enum": "^1.2.0",
"ncp": "^2.0.0", "ncp": "^2.0.0",
"openai": "^6.1.0",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"rollup": "^4.27.4", "rollup": "^4.43.0",
"tmp": "^0.2.3", "tmp": "^0.2.3",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.2",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.6.3", "typescript": "^5.8.3",
"webpack": "^5.96.1", "webpack": "^5.99.9",
"webpack-bundle-analyzer": "^4.10.2" "webpack-bundle-analyzer": "^4.10.2"
}, },
"bin": { "bin": {
@@ -75,60 +77,62 @@
"scrypted-webpack": "bin/scrypted-webpack.js" "scrypted-webpack": "bin/scrypted-webpack.js"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.1", "@types/node": "^24.0.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typedoc": "^0.26.11" "typedoc": "^0.28.5"
} }
}, },
"../../server": { "../../server": {
"name": "@scrypted/server", "name": "@scrypted/server",
"version": "0.138.1", "version": "0.142.9",
"hasInstallScript": true, "hasInstallScript": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@scrypted/ffmpeg-static": "^6.1.0-build3", "@scrypted/ffmpeg-static": "^6.1.0-build3",
"@scrypted/node-pty": "^1.0.22", "@scrypted/node-pty": "^1.0.25",
"@scrypted/types": "^0.3.108", "@scrypted/types": "^0.5.43",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"body-parser": "^1.20.3", "body-parser": "^2.2.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"dotenv": "^16.4.5", "dotenv": "^16.5.0",
"engine.io": "^6.6.2", "engine.io": "^6.6.4",
"express": "^4.21.1", "express": "^5.1.0",
"follow-redirects": "^1.15.9", "follow-redirects": "^1.15.9",
"http-auth": "^4.2.0", "http-auth": "^4.2.1",
"level": "^8.0.1", "level": "^10.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mime-types": "^3.0.1",
"node-dijkstra": "^2.5.0", "node-dijkstra": "^2.5.0",
"node-forge": "^1.3.1", "node-forge": "^1.3.1",
"node-gyp": "^10.2.0", "node-gyp": "^11.2.0",
"py": "npm:@bjia56/portable-python@^0.1.112", "py": "npm:@bjia56/portable-python@^0.1.141",
"semver": "^7.6.3", "semver": "^7.7.2",
"sharp": "^0.33.5", "sharp": "^0.34.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"tar": "^7.4.3", "tar": "^7.4.3",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.5.4", "typescript": "^5.8.3",
"whatwg-mimetype": "^4.0.0", "whatwg-mimetype": "^4.0.0",
"ws": "^8.18.0" "ws": "^8.18.2"
}, },
"bin": { "bin": {
"scrypted-serve": "bin/scrypted-serve" "scrypted-serve": "bin/scrypted-serve"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
"@types/cookie-parser": "^1.4.8", "@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.0", "@types/express": "^5.0.3",
"@types/follow-redirects": "^1.14.4", "@types/follow-redirects": "^1.14.4",
"@types/http-auth": "^4.1.4", "@types/http-auth": "^4.1.4",
"@types/lodash": "^4.17.13", "@types/lodash": "^4.17.17",
"@types/node": "^22.10.1", "@types/mime-types": "^3.0.1",
"@types/node": "^24.0.3",
"@types/node-dijkstra": "^2.5.6", "@types/node-dijkstra": "^2.5.6",
"@types/node-forge": "^1.3.11", "@types/node-forge": "^1.3.11",
"@types/semver": "^7.5.8", "@types/semver": "^7.7.0",
"@types/source-map-support": "^0.5.10", "@types/source-map-support": "^0.5.10",
"@types/whatwg-mimetype": "^3.0.2", "@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.5.13", "@types/ws": "^8.18.1",
"rimraf": "^6.0.1" "rimraf": "^6.0.1"
} }
}, },
@@ -249,7 +253,8 @@
"version": "file:../../common", "version": "file:../../common",
"requires": { "requires": {
"@scrypted/sdk": "file:../sdk", "@scrypted/sdk": "file:../sdk",
"@types/node": "^20.11.0", "@scrypted/types": "^0.5.27",
"@types/node": "^20.19.11",
"http-auth-utils": "^5.0.1", "http-auth-utils": "^5.0.1",
"monaco-editor": "^0.50.0", "monaco-editor": "^0.50.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@@ -259,28 +264,29 @@
"@scrypted/sdk": { "@scrypted/sdk": {
"version": "file:../../sdk", "version": "file:../../sdk",
"requires": { "requires": {
"@babel/preset-typescript": "^7.26.0", "@babel/preset-typescript": "^7.27.1",
"@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-commonjs": "^28.0.5",
"@rollup/plugin-json": "^6.1.0", "@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.1", "@rollup/plugin-typescript": "^12.1.2",
"@rollup/plugin-virtual": "^3.0.2", "@rollup/plugin-virtual": "^3.0.2",
"@types/node": "^22.10.1", "@types/node": "^24.0.1",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"axios": "^1.7.8", "axios": "^1.10.0",
"babel-loader": "^9.2.1", "babel-loader": "^10.0.0",
"babel-plugin-const-enum": "^1.2.0", "babel-plugin-const-enum": "^1.2.0",
"ncp": "^2.0.0", "ncp": "^2.0.0",
"openai": "^6.1.0",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"rollup": "^4.27.4", "rollup": "^4.43.0",
"tmp": "^0.2.3", "tmp": "^0.2.3",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typedoc": "^0.26.11", "typedoc": "^0.28.5",
"typescript": "^5.6.3", "typescript": "^5.8.3",
"webpack": "^5.96.1", "webpack": "^5.99.9",
"webpack-bundle-analyzer": "^4.10.2" "webpack-bundle-analyzer": "^4.10.2"
} }
}, },
@@ -288,44 +294,46 @@
"version": "file:../../server", "version": "file:../../server",
"requires": { "requires": {
"@scrypted/ffmpeg-static": "^6.1.0-build3", "@scrypted/ffmpeg-static": "^6.1.0-build3",
"@scrypted/node-pty": "^1.0.22", "@scrypted/node-pty": "^1.0.25",
"@scrypted/types": "^0.3.108", "@scrypted/types": "^0.5.43",
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
"@types/cookie-parser": "^1.4.8", "@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.0", "@types/express": "^5.0.3",
"@types/follow-redirects": "^1.14.4", "@types/follow-redirects": "^1.14.4",
"@types/http-auth": "^4.1.4", "@types/http-auth": "^4.1.4",
"@types/lodash": "^4.17.13", "@types/lodash": "^4.17.17",
"@types/node": "^22.10.1", "@types/mime-types": "^3.0.1",
"@types/node": "^24.0.3",
"@types/node-dijkstra": "^2.5.6", "@types/node-dijkstra": "^2.5.6",
"@types/node-forge": "^1.3.11", "@types/node-forge": "^1.3.11",
"@types/semver": "^7.5.8", "@types/semver": "^7.7.0",
"@types/source-map-support": "^0.5.10", "@types/source-map-support": "^0.5.10",
"@types/whatwg-mimetype": "^3.0.2", "@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.5.13", "@types/ws": "^8.18.1",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"body-parser": "^1.20.3", "body-parser": "^2.2.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"dotenv": "^16.4.5", "dotenv": "^16.5.0",
"engine.io": "^6.6.2", "engine.io": "^6.6.4",
"express": "^4.21.1", "express": "^5.1.0",
"follow-redirects": "^1.15.9", "follow-redirects": "^1.15.9",
"http-auth": "^4.2.0", "http-auth": "^4.2.1",
"level": "^8.0.1", "level": "^10.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"mime-types": "^3.0.1",
"node-dijkstra": "^2.5.0", "node-dijkstra": "^2.5.0",
"node-forge": "^1.3.1", "node-forge": "^1.3.1",
"node-gyp": "^10.2.0", "node-gyp": "^11.2.0",
"py": "npm:@bjia56/portable-python@^0.1.112", "py": "npm:@bjia56/portable-python@^0.1.141",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"semver": "^7.6.3", "semver": "^7.7.2",
"sharp": "^0.33.5", "sharp": "^0.34.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"tar": "^7.4.3", "tar": "^7.4.3",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.5.4", "typescript": "^5.8.3",
"whatwg-mimetype": "^4.0.0", "whatwg-mimetype": "^4.0.0",
"ws": "^8.18.0" "ws": "^8.18.2"
} }
}, },
"@types/ip": { "@types/ip": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@vityevato/hikvision-doorbell", "name": "@vityevato/hikvision-doorbell",
"version": "2.0.2", "version": "2.0.8",
"description": "Hikvision Doorbell Plugin for Scrypted", "description": "Hikvision Doorbell Plugin for Scrypted",
"author": "Roman Sokolov", "author": "Roman Sokolov",
"license": "Apache", "license": "Apache",

View File

@@ -42,34 +42,44 @@ export class AuthRequst {
const req = Http.request(url, opt) const req = Http.request(url, opt)
// Apply timeout if specified (Node.js http.request doesn't use timeout from options)
if (opt.timeout) {
req.setTimeout (opt.timeout, () => {
req.destroy (new Error (`Request timeout after ${opt.timeout}ms`));
});
}
req.once('response', async (resp) => { req.once('response', async (resp) => {
try {
if (resp.statusCode == 401) {
if (resp.statusCode == 401) { // 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;
}
// Hikvision quirk: even if we already had a sessionAuth, a fresh const newAuth = this.createAuth(resp.headers['www-authenticate'], !!this.auth);
// WWW-Authenticate challenge may require rebuilding credentials. // Clear cached auth to avoid stale nonce reuse
// Limit the number of digest rebuilds to avoid infinite loops. this.auth = undefined;
const attempt = (opt.digestRetry ?? 0); opt.sessionAuth = newAuth;
if (attempt >= 2) { opt.digestRetry = attempt + 1;
// Give up after a couple of rebuild attempts and surface the 401 response const result = await this.request(url, opt, body);
resolve(await this.parseResponse (opt.responseType, resp)); resolve(result);
return;
} }
else {
const newAuth = this.createAuth(resp.headers['www-authenticate'], !!this.auth); // Cache the negotiated session auth only if it was provided for this request.
// Clear cached auth to avoid stale nonce reuse if (opt.sessionAuth) {
this.auth = undefined; this.auth = opt.sessionAuth;
opt.sessionAuth = newAuth; }
opt.digestRetry = attempt + 1; resolve(await this.parseResponse(opt.responseType, resp));
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)); } catch (error) {
reject(error);
} }
}); });
@@ -169,6 +179,10 @@ export class AuthRequst {
readable.once('end', () => { readable.once('end', () => {
resolve(result); resolve(result);
}); });
readable.once('error', (error) => {
reject(error);
});
}); });
} }
@@ -184,6 +198,10 @@ export class AuthRequst {
readable.once('end', () => { readable.once('end', () => {
resolve(result); resolve(result);
}); });
readable.once('error', (error) => {
reject(error);
});
}); });
} }

View File

@@ -49,6 +49,7 @@ interface AcsEventResponse {
const maxEventAgeSeconds = 30; // Ignore events older than this many seconds const maxEventAgeSeconds = 30; // Ignore events older than this many seconds
const callPollingIntervalSec = 1; // Call status polling interval in seconds const callPollingIntervalSec = 1; // Call status polling interval in seconds
const alertTickTimeoutSec = 60; // Alert stream tick timeout in seconds const alertTickTimeoutSec = 60; // Alert stream tick timeout in seconds
const acsPollingTimeoutSec = 5; // ACS polling request timeout in seconds
const EventCodeMap = new Map<string, HikvisionDoorbellEvent>([ const EventCodeMap = new Map<string, HikvisionDoorbellEvent>([
['5,25', HikvisionDoorbellEvent.DoorOpened], ['5,25', HikvisionDoorbellEvent.DoorOpened],
@@ -113,16 +114,19 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI
password: string, password: string,
callStatusPolling: boolean, callStatusPolling: boolean,
public console: Console, public console: Console,
public storage: Storage public storage: Storage,
skipCapabilitiesInit: boolean = false
) )
{ {
let endpoint = libip.isV4Format(address) ? `${address}:${port}` : `[${address}]:${port}`; let endpoint = libip.isV4Format (address) ? `${address}:${port}` : `[${address}]:${port}`;
super (endpoint, username, password, console); super (endpoint, username, password, console);
this.endpoint = endpoint; this.endpoint = endpoint;
this.auth = new AuthRequst (username, password, console); this.auth = new AuthRequst (username, password, console);
// Initialize door capabilities // Initialize door capabilities (skip for event-only API instances)
this.initializeDoorCapabilities(); if (!skipCapabilitiesInit) {
this.initializeDoorCapabilities();
}
this.useCallStatusPolling = callStatusPolling; this.useCallStatusPolling = callStatusPolling;
} }
@@ -136,26 +140,36 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI
{ {
// Create a promise for this specific request to prevent queue blocking // Create a promise for this specific request to prevent queue blocking
const requestPromise = this.requestQueue.then(async () => { const requestPromise = this.requestQueue.then(async () => {
let url: string = urlOrOptions as string; let url: string | undefined;
let opt: AuthRequestOptions | undefined; let opt: AuthRequestOptions | undefined;
if (typeof urlOrOptions !== 'string') {
url = urlOrOptions.url as string; if (typeof urlOrOptions === 'string') {
if (typeof urlOrOptions.url !== 'string') { url = urlOrOptions;
url = (urlOrOptions.url as URL).toString(); } else {
if (urlOrOptions.url) {
url = typeof urlOrOptions.url === 'string'
? urlOrOptions.url
: urlOrOptions.url.toString();
} }
opt = { opt = {
method: urlOrOptions.method, method: urlOrOptions.method,
responseType: urlOrOptions.responseType || 'buffer', responseType: urlOrOptions.responseType || 'buffer',
headers: urlOrOptions.headers as OutgoingHttpHeaders, headers: urlOrOptions.headers as OutgoingHttpHeaders,
timeout: urlOrOptions.timeout,
}; };
} }
// Validate URL before making request
if (!url || url.includes ('undefined')) {
throw new Error (`Invalid request URL: ${url}`);
}
// Safety fallback and attach debug id // Safety fallback and attach debug id
if (!opt) { if (!opt) {
opt = { responseType: 'buffer' } as AuthRequestOptions; opt = { responseType: 'buffer' } as AuthRequestOptions;
} }
return await this.auth.request(url, opt, body); return await this.auth.request (url, opt, body);
}); });
// Update the queue to continue after this request (success or failure) // Update the queue to continue after this request (success or failure)
@@ -416,7 +430,8 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI
// If already loading, wait for the existing promise // If already loading, wait for the existing promise
if (this.loadCapabilitiesPromise) { if (this.loadCapabilitiesPromise) {
return this.loadCapabilitiesPromise; await this.loadCapabilitiesPromise;
return;
} }
// Start loading and store the promise // Start loading and store the promise
@@ -654,23 +669,35 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI
this.console.error ('Failed to set phone number record:', e); this.console.error ('Failed to set phone number record:', e);
} }
// Set call button configuration // Small delay to allow device to process previous request
await new Promise (resolve => setTimeout (resolve, 500));
// Set call button configuration with retry logic
const keyCfgData = `<?xml version="1.0" encoding="UTF-8"?><KeyCfg xmlns="http://www.isapi.org/ver20/XMLSchema" version="2.0"><id>${buttonNumber}</id><callNumber>${roomNumber}</callNumber><moduleId>1</moduleId><templateNo>0</templateNo></KeyCfg>`; const keyCfgData = `<?xml version="1.0" encoding="UTF-8"?><KeyCfg xmlns="http://www.isapi.org/ver20/XMLSchema" version="2.0"><id>${buttonNumber}</id><callNumber>${roomNumber}</callNumber><moduleId>1</moduleId><templateNo>0</templateNo></KeyCfg>`;
try { for (let attempt = 0; attempt < 2; attempt++) {
const response = await this.request ({ try {
url: `http://${this.endpoint}/ISAPI/VideoIntercom/keyCfg/${buttonNumber}`, const response = await this.request ({
method: 'PUT', url: `http://${this.endpoint}/ISAPI/VideoIntercom/keyCfg/${buttonNumber}`,
headers: { method: 'PUT',
'Content-Type': 'application/x-www-form-urlencoded' headers: {
}, 'Content-Type': 'application/xml'
responseType: 'text', },
}, keyCfgData); responseType: 'text',
}, keyCfgData);
this.console.debug (`Call button ${buttonNumber} configured for room ${roomNumber}: ${response.body}`);
} this.console.debug (`Call button ${buttonNumber} configured for room ${roomNumber}: ${response.body}`);
catch (e) { break;
this.console.error (`Failed to configure call button ${buttonNumber}:`, e); }
catch (e) {
if (attempt === 0 && (e.code === 'EPIPE' || e.code === 'ECONNRESET')) {
this.console.warn (`Call button ${buttonNumber} configuration failed (${e.code}), retrying...`);
await new Promise (resolve => setTimeout (resolve, 1000));
continue;
}
this.console.error (`Failed to configure call button ${buttonNumber}:`, e);
break;
}
} }
@@ -719,8 +746,8 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI
private isCallPollingActive: boolean = false; private isCallPollingActive: boolean = false;
// ACS event polling properties // ACS event polling properties
private acsEventPollingInterval?: NodeJS.Timeout;
private lastAcsEventTime: Date = new Date(); private lastAcsEventTime: Date = new Date();
private isAcsPollingInProgress: boolean = false;
// Timezone properties // Timezone properties
private deviceTimezone?: string; // GMT offset in format like '+03:00' private deviceTimezone?: string; // GMT offset in format like '+03:00'
@@ -901,6 +928,7 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI
const response = await this.request ({ const response = await this.request ({
url: `http://${this.endpoint}/ISAPI/AccessControl/AcsEvent?format=json`, url: `http://${this.endpoint}/ISAPI/AccessControl/AcsEvent?format=json`,
method: 'POST', method: 'POST',
timeout: acsPollingTimeoutSec * 1000,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json', 'Accept': 'application/json',
@@ -961,8 +989,15 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI
* This method can be called periodically to check for new events * This method can be called periodically to check for new events
* @param lastEventTime - Optional timestamp to filter events newer than this time * @param lastEventTime - Optional timestamp to filter events newer than this time
*/ */
private async pollAndProcessAcsEvents (lastEventTime?: Date): Promise<void> private async pollAndProcessAcsEvents (lastEventTime?: Date, isRetry: boolean = false): Promise<void>
{ {
// Prevent multiple concurrent polling requests
if (this.isAcsPollingInProgress) {
this.console.debug ('ACS polling already in progress, skipping');
return;
}
this.isAcsPollingInProgress = true;
try { try {
const eventResponse = await this.getAcsEvents(); const eventResponse = await this.getAcsEvents();
let latestEventTime: Date | undefined; let latestEventTime: Date | undefined;
@@ -993,7 +1028,16 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI
} catch (error) { } catch (error) {
this.console.error (`Failed to poll and process ACS events: ${error}`); this.console.error (`Failed to poll and process ACS events: ${error}`);
throw error;
// Retry once after a short delay if this was the first attempt
if (!isRetry) {
this.console.debug (`Retrying ACS polling after ${acsPollingTimeoutSec} seconds...`);
this.isAcsPollingInProgress = false;
await new Promise (resolve => setTimeout (resolve, acsPollingTimeoutSec * 1000));
return this.pollAndProcessAcsEvents (lastEventTime, true);
}
} finally {
this.isAcsPollingInProgress = false;
} }
} }
@@ -1061,7 +1105,8 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI
url: `http://${this.endpoint}/ISAPI/Event/notification/alertStream`, url: `http://${this.endpoint}/ISAPI/Event/notification/alertStream`,
responseType: 'readable', responseType: 'readable',
headers: { headers: {
'Accept': '*/*' 'Accept': '*/*',
'Connection': 'keep-alive'
} }
}); });
@@ -1162,6 +1207,7 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI
this.console.debug (`AlertStream JSON: ${JSON.stringify (eventData, null, 2)}`); this.console.debug (`AlertStream JSON: ${JSON.stringify (eventData, null, 2)}`);
// Poll ACS events (errors are handled internally)
this.pollAndProcessAcsEvents (this.lastAcsEventTime); this.pollAndProcessAcsEvents (this.lastAcsEventTime);
} }
} }

View File

@@ -93,6 +93,14 @@ export class HikvisionCameraDoorbell extends HikvisionCamera implements Camera,
const debugEnabled = this.storage.getItem ('debug'); const debugEnabled = this.storage.getItem ('debug');
this.debugController.setDebugEnabled (debugEnabled === 'true'); this.debugController.setDebugEnabled (debugEnabled === 'true');
// Add global unhandledRejection handler to prevent silent failures
process.on ('unhandledRejection', (reason: any, promise: Promise<any>) => {
this.console.error (`Unhandled Promise Rejection: ${reason}`);
if (reason?.stack) {
this.console.error (`Stack trace: ${reason.stack}`);
}
});
this.updateSip(); this.updateSip();
} }
@@ -210,6 +218,8 @@ export class HikvisionCameraDoorbell extends HikvisionCamera implements Camera,
this.stopIntercom(); this.stopIntercom();
}); });
} }
}).catch(e => {
this.console.error('Failed to stop call during reconnection:', e);
}); });
return; return;
} }
@@ -1152,6 +1162,9 @@ export class HikvisionCameraDoorbell extends HikvisionCamera implements Camera,
this.httpStreamSwitcher.destroy(); this.httpStreamSwitcher.destroy();
this.httpStreamSwitcher = undefined; this.httpStreamSwitcher = undefined;
} }
} catch (error) {
this.console.error (`Failed to stop intercom: ${error}`);
// Don't throw - we want to ensure cleanup happens
} finally { } finally {
// Always reset state // Always reset state
this.intercomBusy = false; this.intercomBusy = false;
@@ -1168,6 +1181,7 @@ export class HikvisionCameraDoorbell extends HikvisionCamera implements Camera,
private createEventApi(): HikvisionDoorbellAPI private createEventApi(): HikvisionDoorbellAPI
{ {
// Event API only listens for events, skip door capabilities initialization
return new HikvisionDoorbellAPI ( return new HikvisionDoorbellAPI (
this.getIPAddress(), this.getIPAddress(),
this.getHttpPort(), this.getHttpPort(),
@@ -1175,7 +1189,8 @@ export class HikvisionCameraDoorbell extends HikvisionCamera implements Camera,
this.getPassword(), this.getPassword(),
this.isCallPolling(), this.isCallPolling(),
this.console, this.console,
this.storage this.storage,
true // skipCapabilitiesInit
); );
} }
@@ -1218,7 +1233,11 @@ export class HikvisionCameraDoorbell extends HikvisionCamera implements Camera,
} catch (e) { } catch (e) {
this.console.error (`Error installing fake SIP settings: ${e}`); this.console.error (`Error installing fake SIP settings: ${e}`);
// repeat if unreached // repeat if unreached
this.installSipSettingsOnDeviceTimeout = setTimeout (() => this.installSipSettingsOnDevice(), UNREACHED_RETRY_SEC * 1000); this.installSipSettingsOnDeviceTimeout = setTimeout (() => {
this.installSipSettingsOnDevice().catch(err => {
this.console.error('Failed to retry installing SIP settings:', err);
});
}, UNREACHED_RETRY_SEC * 1000);
} }
} }
} }

View File

@@ -2,22 +2,37 @@ import sdk from '@scrypted/sdk';
import { isLoopback, isV4Format, isV6Format } from 'ip'; import { isLoopback, isV4Format, isV6Format } from 'ip';
import dgram from 'node:dgram'; import dgram from 'node:dgram';
const MAX_RETRIES = 10;
const RETRY_DELAY_SEC = 10;
export async function localServiceIpAddress (doorbellIp: string): Promise<string> export async function localServiceIpAddress (doorbellIp: string): Promise<string>
{ {
let host = "localhost"; const typeCheck = isV4Format (doorbellIp) ? isV4Format : isV6Format;
try {
const typeCheck = isV4Format (doorbellIp) ? isV4Format : isV6Format; for (let attempt = 0; attempt < MAX_RETRIES; attempt++)
for (const address of await sdk.endpointManager.getLocalAddresses()) { {
if (!isLoopback(address) && typeCheck(address)) { try
host = address; {
break; const addresses = await sdk.endpointManager.getLocalAddresses();
for (const address of addresses || [])
{
if (!isLoopback (address) && typeCheck (address))
{
return address;
}
} }
} }
} catch (e) {
catch (e) { }
// Wait before retry if addresses not available yet
if (attempt < MAX_RETRIES - 1) {
await awaitTimeout (RETRY_DELAY_SEC * 1000);
}
} }
return host; throw new Error('Could not find local service IP address');
} }
export function udpSocketType (ip: string): dgram.SocketType { export function udpSocketType (ip: string): dgram.SocketType {