mirror of
https://github.com/koush/scrypted.git
synced 2026-02-03 14:13:28 +00:00
Compare commits
6 Commits
2fff8b0044
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d1d3727dc | ||
|
|
079878b663 | ||
|
|
0d02ea8f08 | ||
|
|
f23ad06eef | ||
|
|
3c8b513c31 | ||
|
|
35df17334c |
4
plugins/core/package-lock.json
generated
4
plugins/core/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/core",
|
||||
"version": "0.3.144",
|
||||
"version": "0.3.146",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/core",
|
||||
"version": "0.3.144",
|
||||
"version": "0.3.146",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@scrypted/common": "file:../../common",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/core",
|
||||
"version": "0.3.144",
|
||||
"version": "0.3.146",
|
||||
"description": "Scrypted Core plugin. Provides the UI, websocket, and engine.io APIs.",
|
||||
"author": "Scrypted",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import sdk, { ClusterForkInterface, ClusterForkInterfaceOptions, ScryptedDeviceBase, ScryptedInterface, ScryptedNativeId, StreamService, TTYSettings } from "@scrypted/sdk";
|
||||
import type { IPty, spawn as ptySpawn } from 'node-pty';
|
||||
import { createAsyncQueue } from '@scrypted/common/src/async-queue'
|
||||
import { createAsyncQueue } from '@scrypted/common/src/async-queue';
|
||||
import sdk, { ClusterForkInterface, ClusterForkInterfaceOptions, ScryptedDeviceBase, ScryptedInterface, ScryptedNativeId, StreamService, TTY, TTYSettings } from "@scrypted/sdk";
|
||||
import { ChildProcess, spawn as childSpawn } from "child_process";
|
||||
import type { IPty, spawn as ptySpawn } from 'node-pty';
|
||||
import path from 'path';
|
||||
|
||||
export const TerminalServiceNativeId = 'terminalservice';
|
||||
@@ -19,12 +19,24 @@ function toSpawnPathEnv(paths: string[]): string {
|
||||
class InteractiveTerminal {
|
||||
cp: IPty
|
||||
|
||||
constructor(cmd: string[], paths: string[], spawn: typeof ptySpawn) {
|
||||
constructor(cmd: string[], paths: string[], spawn: typeof ptySpawn, cwd?: string) {
|
||||
const spawnPath = toSpawnPathEnv(paths);
|
||||
if (cmd?.length) {
|
||||
this.cp = spawn(cmd[0], cmd.slice(1), { env: { ...process.env, PATH: spawnPath } });
|
||||
this.cp = spawn(cmd[0], cmd.slice(1), {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: spawnPath,
|
||||
},
|
||||
cwd,
|
||||
});
|
||||
} else {
|
||||
this.cp = spawn(process.env.SHELL as string, [], { env: { ...process.env, PATH: spawnPath } });
|
||||
this.cp = spawn(process.env.SHELL as string, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: spawnPath,
|
||||
},
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +123,7 @@ class NoninteractiveTerminal {
|
||||
}
|
||||
|
||||
|
||||
export class TerminalService extends ScryptedDeviceBase implements StreamService<Buffer | string, Buffer>, ClusterForkInterface {
|
||||
export class TerminalService extends ScryptedDeviceBase implements StreamService<Buffer | string, Buffer>, ClusterForkInterface, TTY {
|
||||
private forks: { [clusterWorkerId: string]: TerminalService } = {};
|
||||
private forkClients: 0;
|
||||
|
||||
@@ -186,7 +198,7 @@ export class TerminalService extends ScryptedDeviceBase implements StreamService
|
||||
async connectStream(input: AsyncGenerator<Buffer | string, void>, options?: any): Promise<AsyncGenerator<Buffer, void>> {
|
||||
let cp: InteractiveTerminal | NoninteractiveTerminal = null;
|
||||
const queue = createAsyncQueue<Buffer>();
|
||||
const extraPaths = await this.getExtraPaths();
|
||||
const extraPaths = [...options?.env?.PATH?.split(path.delimiter) || [], ...await this.getExtraPaths()];
|
||||
|
||||
if (this.isFork) {
|
||||
this.forkClients++;
|
||||
@@ -259,7 +271,7 @@ export class TerminalService extends ScryptedDeviceBase implements StreamService
|
||||
let spawn: typeof ptySpawn;
|
||||
try {
|
||||
spawn = require('@scrypted/node-pty').spawn as typeof ptySpawn;
|
||||
cp = new InteractiveTerminal(cmd, extraPaths, spawn);
|
||||
cp = new InteractiveTerminal(cmd, extraPaths, spawn, options?.cwd);
|
||||
}
|
||||
catch (e) {
|
||||
this.console.error('Error starting pty', e);
|
||||
|
||||
148
plugins/hikvision-doorbell/package-lock.json
generated
148
plugins/hikvision-doorbell/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@vityevato/hikvision-doorbell",
|
||||
"version": "1.0.1",
|
||||
"version": "2.0.0d",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@vityevato/hikvision-doorbell",
|
||||
"version": "1.0.1",
|
||||
"version": "2.0.0d",
|
||||
"license": "Apache",
|
||||
"dependencies": {
|
||||
"@scrypted/common": "file:../../common",
|
||||
@@ -30,39 +30,41 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@scrypted/sdk": "file:../sdk",
|
||||
"@scrypted/types": "^0.5.27",
|
||||
"http-auth-utils": "^5.0.1",
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/node": "^20.19.11",
|
||||
"monaco-editor": "^0.50.0",
|
||||
"ts-node": "^10.9.2"
|
||||
}
|
||||
},
|
||||
"../../sdk": {
|
||||
"name": "@scrypted/sdk",
|
||||
"version": "0.3.118",
|
||||
"version": "0.5.48",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@rollup/plugin-commonjs": "^28.0.1",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@rollup/plugin-commonjs": "^28.0.5",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||
"@rollup/plugin-typescript": "^12.1.1",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@rollup/plugin-virtual": "^3.0.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axios": "^1.7.8",
|
||||
"babel-loader": "^9.2.1",
|
||||
"axios": "^1.10.0",
|
||||
"babel-loader": "^10.0.0",
|
||||
"babel-plugin-const-enum": "^1.2.0",
|
||||
"ncp": "^2.0.0",
|
||||
"openai": "^6.1.0",
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"rollup": "^4.27.4",
|
||||
"rollup": "^4.43.0",
|
||||
"tmp": "^0.2.3",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-loader": "^9.5.2",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.6.3",
|
||||
"webpack": "^5.96.1",
|
||||
"typescript": "^5.8.3",
|
||||
"webpack": "^5.99.9",
|
||||
"webpack-bundle-analyzer": "^4.10.2"
|
||||
},
|
||||
"bin": {
|
||||
@@ -75,60 +77,62 @@
|
||||
"scrypted-webpack": "bin/scrypted-webpack.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/node": "^24.0.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typedoc": "^0.26.11"
|
||||
"typedoc": "^0.28.5"
|
||||
}
|
||||
},
|
||||
"../../server": {
|
||||
"name": "@scrypted/server",
|
||||
"version": "0.138.1",
|
||||
"version": "0.142.9",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@scrypted/ffmpeg-static": "^6.1.0-build3",
|
||||
"@scrypted/node-pty": "^1.0.22",
|
||||
"@scrypted/types": "^0.3.108",
|
||||
"@scrypted/node-pty": "^1.0.25",
|
||||
"@scrypted/types": "^0.5.43",
|
||||
"adm-zip": "^0.5.16",
|
||||
"body-parser": "^1.20.3",
|
||||
"body-parser": "^2.2.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^16.4.5",
|
||||
"engine.io": "^6.6.2",
|
||||
"express": "^4.21.1",
|
||||
"dotenv": "^16.5.0",
|
||||
"engine.io": "^6.6.4",
|
||||
"express": "^5.1.0",
|
||||
"follow-redirects": "^1.15.9",
|
||||
"http-auth": "^4.2.0",
|
||||
"level": "^8.0.1",
|
||||
"http-auth": "^4.2.1",
|
||||
"level": "^10.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mime-types": "^3.0.1",
|
||||
"node-dijkstra": "^2.5.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"node-gyp": "^10.2.0",
|
||||
"py": "npm:@bjia56/portable-python@^0.1.112",
|
||||
"semver": "^7.6.3",
|
||||
"sharp": "^0.33.5",
|
||||
"node-gyp": "^11.2.0",
|
||||
"py": "npm:@bjia56/portable-python@^0.1.141",
|
||||
"semver": "^7.7.2",
|
||||
"sharp": "^0.34.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"tar": "^7.4.3",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript": "^5.8.3",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"ws": "^8.18.0"
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"bin": {
|
||||
"scrypted-serve": "bin/scrypted-serve"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/follow-redirects": "^1.14.4",
|
||||
"@types/http-auth": "^4.1.4",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/node": "^24.0.3",
|
||||
"@types/node-dijkstra": "^2.5.6",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@types/ws": "^8.18.1",
|
||||
"rimraf": "^6.0.1"
|
||||
}
|
||||
},
|
||||
@@ -249,7 +253,8 @@
|
||||
"version": "file:../../common",
|
||||
"requires": {
|
||||
"@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",
|
||||
"monaco-editor": "^0.50.0",
|
||||
"ts-node": "^10.9.2",
|
||||
@@ -259,28 +264,29 @@
|
||||
"@scrypted/sdk": {
|
||||
"version": "file:../../sdk",
|
||||
"requires": {
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@rollup/plugin-commonjs": "^28.0.1",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@rollup/plugin-commonjs": "^28.0.5",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||
"@rollup/plugin-typescript": "^12.1.1",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@rollup/plugin-virtual": "^3.0.2",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/node": "^24.0.1",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axios": "^1.7.8",
|
||||
"babel-loader": "^9.2.1",
|
||||
"axios": "^1.10.0",
|
||||
"babel-loader": "^10.0.0",
|
||||
"babel-plugin-const-enum": "^1.2.0",
|
||||
"ncp": "^2.0.0",
|
||||
"openai": "^6.1.0",
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"rollup": "^4.27.4",
|
||||
"rollup": "^4.43.0",
|
||||
"tmp": "^0.2.3",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tslib": "^2.8.1",
|
||||
"typedoc": "^0.26.11",
|
||||
"typescript": "^5.6.3",
|
||||
"webpack": "^5.96.1",
|
||||
"typedoc": "^0.28.5",
|
||||
"typescript": "^5.8.3",
|
||||
"webpack": "^5.99.9",
|
||||
"webpack-bundle-analyzer": "^4.10.2"
|
||||
}
|
||||
},
|
||||
@@ -288,44 +294,46 @@
|
||||
"version": "file:../../server",
|
||||
"requires": {
|
||||
"@scrypted/ffmpeg-static": "^6.1.0-build3",
|
||||
"@scrypted/node-pty": "^1.0.22",
|
||||
"@scrypted/types": "^0.3.108",
|
||||
"@scrypted/node-pty": "^1.0.25",
|
||||
"@scrypted/types": "^0.5.43",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/cookie-parser": "^1.4.9",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/follow-redirects": "^1.14.4",
|
||||
"@types/http-auth": "^4.1.4",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/node": "^24.0.3",
|
||||
"@types/node-dijkstra": "^2.5.6",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@types/ws": "^8.18.1",
|
||||
"adm-zip": "^0.5.16",
|
||||
"body-parser": "^1.20.3",
|
||||
"body-parser": "^2.2.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^16.4.5",
|
||||
"engine.io": "^6.6.2",
|
||||
"express": "^4.21.1",
|
||||
"dotenv": "^16.5.0",
|
||||
"engine.io": "^6.6.4",
|
||||
"express": "^5.1.0",
|
||||
"follow-redirects": "^1.15.9",
|
||||
"http-auth": "^4.2.0",
|
||||
"level": "^8.0.1",
|
||||
"http-auth": "^4.2.1",
|
||||
"level": "^10.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mime-types": "^3.0.1",
|
||||
"node-dijkstra": "^2.5.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"node-gyp": "^10.2.0",
|
||||
"py": "npm:@bjia56/portable-python@^0.1.112",
|
||||
"node-gyp": "^11.2.0",
|
||||
"py": "npm:@bjia56/portable-python@^0.1.141",
|
||||
"rimraf": "^6.0.1",
|
||||
"semver": "^7.6.3",
|
||||
"sharp": "^0.33.5",
|
||||
"semver": "^7.7.2",
|
||||
"sharp": "^0.34.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"tar": "^7.4.3",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript": "^5.8.3",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"ws": "^8.18.0"
|
||||
"ws": "^8.18.2"
|
||||
}
|
||||
},
|
||||
"@types/ip": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vityevato/hikvision-doorbell",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.8",
|
||||
"description": "Hikvision Doorbell Plugin for Scrypted",
|
||||
"author": "Roman Sokolov",
|
||||
"license": "Apache",
|
||||
|
||||
@@ -42,34 +42,44 @@ export class AuthRequst {
|
||||
|
||||
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) => {
|
||||
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
|
||||
// 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;
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
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));
|
||||
}
|
||||
resolve(await this.parseResponse(opt.responseType, resp));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -169,6 +179,10 @@ export class AuthRequst {
|
||||
readable.once('end', () => {
|
||||
resolve(result);
|
||||
});
|
||||
|
||||
readable.once('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -184,6 +198,10 @@ export class AuthRequst {
|
||||
readable.once('end', () => {
|
||||
resolve(result);
|
||||
});
|
||||
|
||||
readable.once('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ interface AcsEventResponse {
|
||||
const maxEventAgeSeconds = 30; // Ignore events older than this many seconds
|
||||
const callPollingIntervalSec = 1; // Call status polling interval in seconds
|
||||
const alertTickTimeoutSec = 60; // Alert stream tick timeout in seconds
|
||||
const acsPollingTimeoutSec = 5; // ACS polling request timeout in seconds
|
||||
|
||||
const EventCodeMap = new Map<string, HikvisionDoorbellEvent>([
|
||||
['5,25', HikvisionDoorbellEvent.DoorOpened],
|
||||
@@ -113,16 +114,19 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI
|
||||
password: string,
|
||||
callStatusPolling: boolean,
|
||||
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);
|
||||
this.endpoint = endpoint;
|
||||
this.auth = new AuthRequst (username, password, console);
|
||||
|
||||
// Initialize door capabilities
|
||||
this.initializeDoorCapabilities();
|
||||
// Initialize door capabilities (skip for event-only API instances)
|
||||
if (!skipCapabilitiesInit) {
|
||||
this.initializeDoorCapabilities();
|
||||
}
|
||||
this.useCallStatusPolling = callStatusPolling;
|
||||
}
|
||||
|
||||
@@ -136,26 +140,36 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI
|
||||
{
|
||||
// Create a promise for this specific request to prevent queue blocking
|
||||
const requestPromise = this.requestQueue.then(async () => {
|
||||
let url: string = urlOrOptions as string;
|
||||
let url: string | undefined;
|
||||
let opt: AuthRequestOptions | undefined;
|
||||
if (typeof urlOrOptions !== 'string') {
|
||||
url = urlOrOptions.url as string;
|
||||
if (typeof urlOrOptions.url !== 'string') {
|
||||
url = (urlOrOptions.url as URL).toString();
|
||||
|
||||
if (typeof urlOrOptions === 'string') {
|
||||
url = urlOrOptions;
|
||||
} else {
|
||||
if (urlOrOptions.url) {
|
||||
url = typeof urlOrOptions.url === 'string'
|
||||
? urlOrOptions.url
|
||||
: urlOrOptions.url.toString();
|
||||
}
|
||||
opt = {
|
||||
method: urlOrOptions.method,
|
||||
responseType: urlOrOptions.responseType || 'buffer',
|
||||
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
|
||||
if (!opt) {
|
||||
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)
|
||||
@@ -416,7 +430,8 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI
|
||||
|
||||
// If already loading, wait for the existing promise
|
||||
if (this.loadCapabilitiesPromise) {
|
||||
return this.loadCapabilitiesPromise;
|
||||
await this.loadCapabilitiesPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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>`;
|
||||
|
||||
try {
|
||||
const response = await this.request ({
|
||||
url: `http://${this.endpoint}/ISAPI/VideoIntercom/keyCfg/${buttonNumber}`,
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
responseType: 'text',
|
||||
}, keyCfgData);
|
||||
|
||||
this.console.debug (`Call button ${buttonNumber} configured for room ${roomNumber}: ${response.body}`);
|
||||
}
|
||||
catch (e) {
|
||||
this.console.error (`Failed to configure call button ${buttonNumber}:`, e);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const response = await this.request ({
|
||||
url: `http://${this.endpoint}/ISAPI/VideoIntercom/keyCfg/${buttonNumber}`,
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/xml'
|
||||
},
|
||||
responseType: 'text',
|
||||
}, keyCfgData);
|
||||
|
||||
this.console.debug (`Call button ${buttonNumber} configured for room ${roomNumber}: ${response.body}`);
|
||||
break;
|
||||
}
|
||||
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;
|
||||
|
||||
// ACS event polling properties
|
||||
private acsEventPollingInterval?: NodeJS.Timeout;
|
||||
private lastAcsEventTime: Date = new Date();
|
||||
private isAcsPollingInProgress: boolean = false;
|
||||
|
||||
// Timezone properties
|
||||
private deviceTimezone?: string; // GMT offset in format like '+03:00'
|
||||
@@ -901,6 +928,7 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI
|
||||
const response = await this.request ({
|
||||
url: `http://${this.endpoint}/ISAPI/AccessControl/AcsEvent?format=json`,
|
||||
method: 'POST',
|
||||
timeout: acsPollingTimeoutSec * 1000,
|
||||
headers: {
|
||||
'Content-Type': '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
|
||||
* @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 {
|
||||
const eventResponse = await this.getAcsEvents();
|
||||
let latestEventTime: Date | undefined;
|
||||
@@ -993,7 +1028,16 @@ export class HikvisionDoorbellAPI extends HikvisionCameraAPI
|
||||
|
||||
} catch (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`,
|
||||
responseType: 'readable',
|
||||
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)}`);
|
||||
|
||||
// Poll ACS events (errors are handled internally)
|
||||
this.pollAndProcessAcsEvents (this.lastAcsEventTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,14 @@ export class HikvisionCameraDoorbell extends HikvisionCamera implements Camera,
|
||||
const debugEnabled = this.storage.getItem ('debug');
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -210,6 +218,8 @@ export class HikvisionCameraDoorbell extends HikvisionCamera implements Camera,
|
||||
this.stopIntercom();
|
||||
});
|
||||
}
|
||||
}).catch(e => {
|
||||
this.console.error('Failed to stop call during reconnection:', e);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1152,6 +1162,9 @@ export class HikvisionCameraDoorbell extends HikvisionCamera implements Camera,
|
||||
this.httpStreamSwitcher.destroy();
|
||||
this.httpStreamSwitcher = undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
this.console.error (`Failed to stop intercom: ${error}`);
|
||||
// Don't throw - we want to ensure cleanup happens
|
||||
} finally {
|
||||
// Always reset state
|
||||
this.intercomBusy = false;
|
||||
@@ -1168,6 +1181,7 @@ export class HikvisionCameraDoorbell extends HikvisionCamera implements Camera,
|
||||
|
||||
private createEventApi(): HikvisionDoorbellAPI
|
||||
{
|
||||
// Event API only listens for events, skip door capabilities initialization
|
||||
return new HikvisionDoorbellAPI (
|
||||
this.getIPAddress(),
|
||||
this.getHttpPort(),
|
||||
@@ -1175,7 +1189,8 @@ export class HikvisionCameraDoorbell extends HikvisionCamera implements Camera,
|
||||
this.getPassword(),
|
||||
this.isCallPolling(),
|
||||
this.console,
|
||||
this.storage
|
||||
this.storage,
|
||||
true // skipCapabilitiesInit
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1218,7 +1233,11 @@ export class HikvisionCameraDoorbell extends HikvisionCamera implements Camera,
|
||||
} catch (e) {
|
||||
this.console.error (`Error installing fake SIP settings: ${e}`);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,37 @@ import sdk from '@scrypted/sdk';
|
||||
import { isLoopback, isV4Format, isV6Format } from 'ip';
|
||||
import dgram from 'node:dgram';
|
||||
|
||||
const MAX_RETRIES = 10;
|
||||
const RETRY_DELAY_SEC = 10;
|
||||
|
||||
export async function localServiceIpAddress (doorbellIp: string): Promise<string>
|
||||
{
|
||||
let host = "localhost";
|
||||
try {
|
||||
const typeCheck = isV4Format (doorbellIp) ? isV4Format : isV6Format;
|
||||
for (const address of await sdk.endpointManager.getLocalAddresses()) {
|
||||
if (!isLoopback(address) && typeCheck(address)) {
|
||||
host = address;
|
||||
break;
|
||||
const typeCheck = isV4Format (doorbellIp) ? isV4Format : isV6Format;
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
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 {
|
||||
|
||||
4
plugins/snapshot/package-lock.json
generated
4
plugins/snapshot/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/snapshot",
|
||||
"version": "0.2.67",
|
||||
"version": "0.2.68",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/snapshot",
|
||||
"version": "0.2.67",
|
||||
"version": "0.2.68",
|
||||
"dependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"sharp": "^0.33.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/snapshot",
|
||||
"version": "0.2.67",
|
||||
"version": "0.2.68",
|
||||
"description": "Snapshot Plugin for Scrypted",
|
||||
"scripts": {
|
||||
"scrypted-setup-project": "scrypted-setup-project",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AuthFetchCredentialState, authHttpFetch } from '@scrypted/common/src/ht
|
||||
import { RefreshPromise, TimeoutError, createMapPromiseDebouncer, singletonPromise, timeoutPromise } from "@scrypted/common/src/promise-utils";
|
||||
import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from "@scrypted/common/src/settings-mixin";
|
||||
import sdk, { BufferConverter, Camera, DeviceManifest, DeviceProvider, FFmpegInput, HttpRequest, HttpRequestHandler, HttpResponse, MediaObject, MediaObjectOptions, MixinProvider, RequestMediaStreamOptions, RequestPictureOptions, Resolution, ResponsePictureOptions, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, SettingValue, Settings, Sleep, VideoCamera, WritableDeviceState } from "@scrypted/sdk";
|
||||
import { checkUserId } from "@scrypted/sdk/acl";
|
||||
import { StorageSettings } from "@scrypted/sdk/storage-settings";
|
||||
import fs from 'fs';
|
||||
import https from 'https';
|
||||
@@ -776,6 +777,13 @@ export class SnapshotPlugin extends AutoenableMixinProvider implements MixinProv
|
||||
pathname = pathname.substring('/hotlink-ok'.length);
|
||||
|
||||
const [_, id, iface] = pathname.split('/');
|
||||
if (!request.username || (request.aclId && !await checkUserId(id, request.aclId))) {
|
||||
response.send('', {
|
||||
code: 401,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (iface !== ScryptedInterface.Camera && iface !== ScryptedInterface.VideoCamera)
|
||||
throw new Error();
|
||||
|
||||
4
sdk/package-lock.json
generated
4
sdk/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/sdk",
|
||||
"version": "0.5.55",
|
||||
"version": "0.5.58",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/sdk",
|
||||
"version": "0.5.55",
|
||||
"version": "0.5.58",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/sdk",
|
||||
"version": "0.5.55",
|
||||
"version": "0.5.58",
|
||||
"description": "",
|
||||
"main": "dist/src/index.js",
|
||||
"exports": {
|
||||
".": "./dist/src/index.js",
|
||||
"./acl": "./dist/src/acl.js",
|
||||
"./promise-debounce": "./dist/src/promise-debounce.js",
|
||||
"./storage-settings": "./dist/src/storage-settings.js",
|
||||
"./settings-mixin": "./dist/src/settings-mixin.js"
|
||||
},
|
||||
|
||||
132
sdk/src/acl.ts
132
sdk/src/acl.ts
@@ -1,4 +1,5 @@
|
||||
import { ScryptedDeviceAccessControl, ScryptedInterface, ScryptedInterfaceDescriptors, ScryptedUserAccessControl } from ".";
|
||||
import sdk, { EventDetails, ScryptedDeviceAccessControl, ScryptedInterface, ScryptedInterfaceDescriptors, ScryptedUser, ScryptedUserAccessControl } from ".";
|
||||
import { createCachingMapPromiseDebouncer } from './promise-debounce';
|
||||
|
||||
export function addAccessControlsForInterface(id: string, ...scryptedInterfaces: ScryptedInterface[]): ScryptedDeviceAccessControl {
|
||||
const methods = scryptedInterfaces.map(scryptedInterface => ScryptedInterfaceDescriptors[scryptedInterface]?.methods || []).flat();
|
||||
@@ -20,3 +21,132 @@ export function mergeDeviceAccessControls(accessControls: ScryptedUserAccessCont
|
||||
accessControls.devicesAccessControls.push(...dacls);
|
||||
return accessControls;
|
||||
}
|
||||
|
||||
export class AccessControls {
|
||||
constructor(public acl: ScryptedUserAccessControl) {
|
||||
}
|
||||
|
||||
deny(reason: string = 'User does not have permission') {
|
||||
throw new Error(reason);
|
||||
}
|
||||
|
||||
shouldRejectDevice(id: string) {
|
||||
if (this.acl.devicesAccessControls === null)
|
||||
return false;
|
||||
|
||||
if (!this.acl.devicesAccessControls)
|
||||
return true;
|
||||
|
||||
const dacls = this.acl.devicesAccessControls.filter(dacl => dacl.id === id);
|
||||
return !dacls.length;
|
||||
}
|
||||
|
||||
shouldRejectProperty(id: string, property: string) {
|
||||
if (this.acl.devicesAccessControls === null)
|
||||
return false;
|
||||
|
||||
if (!this.acl.devicesAccessControls)
|
||||
return true;
|
||||
|
||||
const dacls = this.acl.devicesAccessControls.filter(dacl => dacl.id === id);
|
||||
|
||||
for (const dacl of dacls) {
|
||||
if (!dacl.properties || dacl.properties.includes(property))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
shouldRejectEvent(id: string, eventDetails: EventDetails) {
|
||||
if (this.acl.devicesAccessControls === null)
|
||||
return false;
|
||||
|
||||
if (!this.acl.devicesAccessControls)
|
||||
return true;
|
||||
|
||||
const dacls = this.acl.devicesAccessControls.filter(dacl => dacl.id === id);
|
||||
|
||||
const { property } = eventDetails;
|
||||
if (property) {
|
||||
for (const dacl of dacls) {
|
||||
if (!dacl.properties || dacl.properties.includes(property))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const { eventInterface } = eventDetails;
|
||||
|
||||
for (const dacl of dacls) {
|
||||
if (!dacl.interfaces || dacl.interfaces.includes(eventInterface!))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
shouldRejectInterface(id: string, scryptedInterface: ScryptedInterface) {
|
||||
if (this.acl.devicesAccessControls === null)
|
||||
return false;
|
||||
|
||||
if (!this.acl.devicesAccessControls)
|
||||
return true;
|
||||
|
||||
const dacls = this.acl.devicesAccessControls.filter(dacl => dacl.id === id);
|
||||
|
||||
for (const dacl of dacls) {
|
||||
if (!dacl.interfaces || dacl.interfaces.includes(scryptedInterface))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
shouldRejectMethod(id: string, method: string) {
|
||||
if (this.acl.devicesAccessControls === null)
|
||||
return false;
|
||||
|
||||
if (!this.acl.devicesAccessControls)
|
||||
return true;
|
||||
|
||||
const dacls = this.acl.devicesAccessControls.filter(dacl => dacl.id === id);
|
||||
|
||||
for (const dacl of dacls) {
|
||||
if (!dacl.methods || dacl.methods.includes(method))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const accessControls = createCachingMapPromiseDebouncer<AccessControls|undefined>(60 * 1000);
|
||||
|
||||
export async function checkUserId(id: string, userId: string) {
|
||||
const user = sdk.systemManager.getDeviceById<ScryptedUser>(userId);
|
||||
if (!user || !user.interfaces?.includes(ScryptedInterface.ScryptedUser)) {
|
||||
// console.error('Error delivering notification, invalid user id:', userId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sdk.systemManager.getDeviceById(id))
|
||||
return;
|
||||
|
||||
try {
|
||||
const acl = await accessControls(userId, async () => {
|
||||
const acls = await user.getScryptedUserAccessControl();
|
||||
const acl = acls ? new AccessControls(acls) : undefined;
|
||||
return acl;
|
||||
});
|
||||
if (acl?.shouldRejectDevice(id)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// console.error('Error delivering notification, ACL check failed.', e);
|
||||
return;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
14
sdk/src/promise-debounce.ts
Normal file
14
sdk/src/promise-debounce.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export function createCachingMapPromiseDebouncer<T>(duration: number) {
|
||||
const map = new Map<string, Promise<T>>();
|
||||
|
||||
return (key: any, func: () => Promise<T>): Promise<T> => {
|
||||
const keyStr = JSON.stringify(key);
|
||||
let value = map.get(keyStr);
|
||||
if (!value) {
|
||||
value = func();
|
||||
map.set(keyStr, value);
|
||||
setTimeout(() => map.delete(keyStr), duration);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
4
sdk/types/package-lock.json
generated
4
sdk/types/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scrypted/types",
|
||||
"version": "0.5.52",
|
||||
"version": "0.5.54",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@scrypted/types",
|
||||
"version": "0.5.52",
|
||||
"version": "0.5.54",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"openai": "^6.1.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/types",
|
||||
"version": "0.5.52",
|
||||
"version": "0.5.54",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"author": "",
|
||||
|
||||
@@ -1131,7 +1131,7 @@ class TamperState(TypedDict):
|
||||
pass
|
||||
|
||||
|
||||
TYPES_VERSION = "0.5.52"
|
||||
TYPES_VERSION = "0.5.54"
|
||||
|
||||
|
||||
class AirPurifier:
|
||||
|
||||
Reference in New Issue
Block a user