Compare commits

..

115 Commits

Author SHA1 Message Date
Koushik Dutta
bde3dfb9a8 server: verup 2025-10-28 10:12:56 -07:00
Koushik Dutta
d751ac8871 postbeta 2025-10-23 07:57:30 -07:00
Roman Sokolov
d6afbcef26 hikvision-doorbell: Added signaling to listenLoop and updated README.md (#1911) 2025-10-22 08:07:28 -07:00
Koushik Dutta
457fbc594e client: improve base url detection 2025-10-21 23:34:45 -07:00
Koushik Dutta
aadb190c13 client: dont sent query token to connectRPCObject if accessing without a CORS request. 2025-10-21 10:34:29 -07:00
Koushik Dutta
f9a1668e5d core: publish lxc docker image fix 2025-10-13 08:56:58 -07:00
Koushik Dutta
70672e2a87 Merge branch 'main' of github.com:koush/scrypted 2025-10-12 21:28:04 -07:00
Koushik Dutta
cab0afaa53 proxmox: reimplement image cleanup 2025-10-12 21:28:00 -07:00
Roman Sokolov
e0764a54cc hikvision-doorbell: Version 2 of the hikvision-doorbell plugin (#1907) 2025-10-12 09:28:53 -07:00
Koushik Dutta
1e825b84bc diagnostics: use cloudflare to check date. check scrypted services 2025-10-08 08:32:42 -07:00
Koushik Dutta
946e8d3414 openvino: rollback pypi package 2025-10-07 19:29:04 -07:00
Koushik Dutta
3043b058d7 openvino: publish with new openvino, add m model 2025-10-07 09:51:29 -07:00
Koushik Dutta
65fa8dd7f9 unifi-protect: Fix 2 way 2025-10-03 08:54:13 -07:00
Koushik Dutta
c6a93cf245 sdk/client: update 2025-10-03 08:36:14 -07:00
Koushik Dutta
911b3f6014 sdk/client: update 2025-10-03 08:03:47 -07:00
Koushik Dutta
8b5d3eaeae docker: remove apt-key usage 2025-10-02 22:04:21 -07:00
Koushik Dutta
8099df4a2a rebroadcast: prevent buffering from buggy RTSP clients like frigate from causing memory leaks in scrypted 2025-10-01 09:16:30 -07:00
Koushik Dutta
e703efc1aa core: fix missing module types 2025-09-28 16:18:14 -07:00
Koushik Dutta
e9dc5a4254 server: package-lock.json 2025-09-28 12:15:23 -07:00
Koushik Dutta
5ae0bb10ff postbeta 2025-09-28 12:15:18 -07:00
Koushik Dutta
da417f3d5c server: @scrypted/node-pty update 2025-09-28 12:15:08 -07:00
Koushik Dutta
b00fd7e684 docker: split out amd flavor for future rocm 2025-09-28 11:19:46 -07:00
Koushik Dutta
d5ce4e24c4 docker: split out amd flavor for future rocm 2025-09-28 11:18:55 -07:00
Koushik Dutta
1e09b62795 install: update amd links 2025-09-28 11:12:53 -07:00
Koushik Dutta
dd256e7a39 install: update amd links 2025-09-28 11:11:55 -07:00
Koushik Dutta
f6457bf475 install: update amd links 2025-09-28 10:59:43 -07:00
Koushik Dutta
5008220c26 install: update amd links 2025-09-28 10:58:03 -07:00
Koushik Dutta
8504319b27 install: update amd links 2025-09-28 10:45:19 -07:00
Koushik Dutta
61cc544313 install: update intel compute runtime 2025-09-28 10:34:24 -07:00
Koushik Dutta
4e6066a7c9 videoanalysis: make zone config less weird 2025-09-27 08:52:00 -07:00
Koushik Dutta
22b790c7f5 Update install-scrypted-docker-compose.sh 2025-09-26 09:12:31 -07:00
Koushik Dutta
65ab977d4f install/docker: switch to global dns 2025-09-25 12:34:36 -07:00
Koushik Dutta
6e1f5cbfa7 install/docker: switch to global dns 2025-09-25 12:28:29 -07:00
Koushik Dutta
4d7be52b98 install/docker: switch to global dns 2025-09-25 12:19:23 -07:00
Koushik Dutta
0b0a43fefc unifi-protect: switch to discovery mode, allow device reassociation in case ids flap 2025-09-24 21:02:51 -07:00
Koushik Dutta
1449debbd3 unifi-protect: switch to discovery mode, allow device reassociation in case ids flap 2025-09-24 20:45:25 -07:00
Koushik Dutta
4e24e44246 unifi-protect: switch to discovery mode, allow device reassociation in case ids flap 2025-09-24 12:57:56 -07:00
Koushik Dutta
e4d62668b7 server/rpc: fixup rpc serializer buffer serialization 2025-09-23 22:45:44 -07:00
Koushik Dutta
a4a3731b94 rebroadcast: always use scrypted rtsp parser for consistency 2025-09-23 20:01:29 -07:00
Koushik Dutta
f4a55ee76b rebroadcast: always use scrypted rtsp parser for consistency 2025-09-23 12:47:16 -07:00
Koushik Dutta
29714f82d5 client: include query headers in connectRPCObject 2025-09-22 22:43:49 -07:00
Koushik Dutta
c2054fc7e0 client: fix connectRPCObject always using scrypted cloud 2025-09-22 22:36:49 -07:00
Koushik Dutta
50b312b290 client: fix connectRPCObject always using scrypted cloud 2025-09-22 22:20:53 -07:00
Koushik Dutta
db8a3ec40b docker: auto detect devices 2025-09-22 20:10:43 -07:00
Koushik Dutta
ef07691eef docker: auto detect devices 2025-09-22 20:08:00 -07:00
Koushik Dutta
8f1c5fdf3c snapshot/sdk: add resolution property 2025-09-19 11:41:48 -07:00
Koushik Dutta
f7ac2883ec core: fix bug where changing password screws up user provider native id 2025-09-19 09:28:03 -07:00
Koushik Dutta
1a87e0daa1 hikvision: publish 2025-09-19 08:31:38 -07:00
Koushik Dutta
2e17e58060 client: cluster peer connect cleanups 2025-09-18 11:17:20 -07:00
Koushik Dutta
c9b88e6d8f client: implement connectRPCObject timeouts, fix typo 2025-09-17 09:00:49 -07:00
Koushik Dutta
eaa6da005b sdk/client: add optional dedicated connections and lifetime to connectRPCObject 2025-09-17 08:31:44 -07:00
Koushik Dutta
ca855bb9a6 client: fix http connection type reporting 2025-09-06 08:41:24 -07:00
Koushik Dutta
774f987a66 common: using-holder 2025-09-06 07:59:02 -07:00
Koushik Dutta
0b6ef28ae8 router: use go tar 2025-09-05 11:57:27 -07:00
Koushik Dutta
677e78e328 openvino: update deps to latest, publish beta 2025-09-05 10:03:28 -07:00
Koushik Dutta
0f1f1c56fb install: intel npu script fix 2025-09-05 09:13:38 -07:00
Koushik Dutta
27e7e4c9e2 install: intel npu script fix 2025-09-05 09:08:05 -07:00
Koushik Dutta
b6b193bf80 install: update intel npu driver 2025-09-05 08:43:41 -07:00
Koushik Dutta
25f52eb528 install: update intel npu driver check for proxmox 2025-09-05 08:41:59 -07:00
Koushik Dutta
69d110b234 install: update intel compute runtime 2025-09-05 08:36:38 -07:00
Koushik Dutta
7240f328b3 client: update ScryptedClientConnectionType 2025-09-03 12:11:00 -07:00
Koushik Dutta
7bf133745b werift: update 2025-09-03 08:48:45 -07:00
Koushik Dutta
77ba56cf38 webrtc: werift fixes + object leaks 2025-09-02 21:48:53 -07:00
Koushik Dutta
ea6d404f12 webrtc: fix typing and variable scope 2025-09-02 12:03:42 -07:00
Koushik Dutta
40a1221f11 werift: update 2025-09-01 12:38:42 -07:00
Koushik Dutta
22444eb63d server/webrtc: restructure 2025-08-31 21:51:15 -07:00
Koushik Dutta
2a6f542e06 sdk: update 2025-08-31 21:26:17 -07:00
Koushik Dutta
ec49e4630f webrtc: update werift, datachannel connectRPCObject, publish 2025-08-31 21:14:42 -07:00
Koushik Dutta
9de2b480ff webrtc: wip connectRPCObject 2025-08-28 11:31:37 -07:00
Koushik Dutta
442e8d53f7 server: package-lock 2025-08-28 09:45:57 -07:00
Koushik Dutta
f718d435bd homekit: update tsconfig 2025-08-28 09:44:48 -07:00
Koushik Dutta
8bbd112f60 webrtc: wip datachannels 2025-08-28 09:44:41 -07:00
Koushik Dutta
6c98fa62be client: rpc exports 2025-08-28 08:55:49 -07:00
Koushik Dutta
2e56a7f7a9 homekit: update deps 2025-08-28 08:40:21 -07:00
Koushik Dutta
8304c1d065 postbeta 2025-08-28 08:32:22 -07:00
Koushik Dutta
21d0ca99e6 Merge branch 'main' of github.com:koush/scrypted 2025-08-27 09:20:56 -07:00
Koushik Dutta
fa14f4ca83 webrtc: fix intercom detection regression 2025-08-27 09:20:52 -07:00
Koushik Dutta
8ae0a33cbe werift: update 2025-08-26 16:44:19 -07:00
Koushik Dutta
dea55e4fcd server: fix bug in connectRPCObject 2025-08-26 16:30:01 -07:00
Koushik Dutta
9eab88572e postbeta 2025-08-26 09:51:54 -07:00
Koushik Dutta
427c3e2f7b Merge branch 'main' of github.com:koush/scrypted 2025-08-26 09:51:42 -07:00
Koushik Dutta
98f97a51e8 postbeta 2025-08-26 09:51:32 -07:00
Koushik Dutta
529b4d30fb postbeta 2025-08-26 09:48:40 -07:00
Brett Jia
eaabd02bfe server: bind single address if cluster address is 127.0.0.1 (python) (#1877)
A continuation of https://github.com/koush/scrypted/pull/1820 for the missing Python half.
2025-08-26 09:23:32 -07:00
Koushik Dutta
7a67c70ef7 webrtc: wip transmission window updates 2025-08-25 16:48:04 -07:00
Koushik Dutta
b784995ebb webrtc: wip transmission window updates 2025-08-25 16:33:25 -07:00
Koushik Dutta
d4da11bb2c webrtc: wip data channel generator 2025-08-25 12:03:30 -07:00
Koushik Dutta
f556ae7ff3 webrtc/sdk: initial lossless datachannel api 2025-08-25 10:02:39 -07:00
Koushik Dutta
8bb999aa64 webrtc: clean up intercom setup 2025-08-25 09:12:43 -07:00
Koushik Dutta
de99d59162 server: package lock 2025-08-02 11:41:05 -07:00
Koushik Dutta
438a6d7fe9 postbeta 2025-08-02 11:41:02 -07:00
Koushik Dutta
b9b3a48a08 server: improve plugin connection errors 2025-08-02 11:40:53 -07:00
Koushik Dutta
5c42740ab1 server: package lock 2025-08-02 11:39:51 -07:00
Koushik Dutta
e988e5fb96 postbeta 2025-08-02 11:39:48 -07:00
Koushik Dutta
9c8cbc750a server: improve plugin connection errors 2025-08-02 11:39:36 -07:00
Koushik Dutta
01e15fb070 server: package lock 2025-08-02 11:17:22 -07:00
Koushik Dutta
7aa02d6e4a postbeta 2025-08-02 11:17:08 -07:00
Koushik Dutta
9c9be9db22 server: improve plugin connection errors 2025-08-02 11:16:59 -07:00
Koushik Dutta
cc78c072ce rpc: support pickling 2025-07-31 20:54:48 -07:00
Koushik Dutta
48c489b898 rpc: support dataclasses annotation, fix formatting 2025-07-31 11:05:55 -07:00
Koushik Dutta
2cbcc05428 server: formatting 2025-07-31 11:04:16 -07:00
Koushik Dutta
58a722cfa8 ha: bump version 2025-07-31 11:03:50 -07:00
Koushik Dutta
2c16c4625e doorbird: publish 2025-07-31 11:03:24 -07:00
Nils Sowen
60613ee947 doorbird: fixing hangups in HomeKit and broken audio transmission; adding audio filter options (#1858)
* doorbird: working example with large buffer

* doorbird: improving documentation; improving audio delays; fixed audio transmission

* doorbird: add audio filtering options for more speech clarity

* doorbird: reverting accidental deletion

* doorbird: reverting unwanted change

* doorbird: reverted unwanted changes

* doorbird: reverted unwanted changes

* doorbird: fixed non-working echo cancellation; included Copilot comments

* doorbird: remove throttling as it is not really needed

* doorbird: remove throttling as it is not really needed
2025-07-29 09:16:27 -07:00
Koushik Dutta
f69dd06513 Merge branch 'main' of github.com:koush/scrypted 2025-07-28 11:09:33 -07:00
Koushik Dutta
d011419208 diagnostics: validate system time 2025-07-28 11:09:27 -07:00
Koushik Dutta
789be6bd57 install: nvidia lxc/docker notes 2025-07-27 11:57:39 -07:00
Koushik Dutta
45e1b7091e install: more nvidia on proxmox fixes 2025-07-27 11:33:19 -07:00
Koushik Dutta
f2ab923c79 install: Update install-nvidia-container-toolkit.sh 2025-07-27 11:04:49 -07:00
apocaliss92
4c3d5133f6 sdk: sourceId on detection
* Add utility attributes to get camera data

* Remove NativebjectDetector

* Remove zones

---------

Co-authored-by: Gianluca Ruocco <gianluca.ruocco@xarvio.com>
2025-07-26 14:15:33 -07:00
Koushik Dutta
d2edfc5ecc core: publish 2025-07-25 11:53:58 -07:00
Koushik Dutta
4c5ae94c7c github: fix docker action 2025-07-25 11:40:35 -07:00
Koushik Dutta
f30efbecec server: verup 2025-07-25 11:04:53 -07:00
Koushik Dutta
4ecb1f3c85 postbeta 2025-07-25 11:04:34 -07:00
Koushik Dutta
3ca0234530 postrelease 2025-07-25 11:04:27 -07:00
108 changed files with 6385 additions and 3434 deletions

View File

@@ -84,7 +84,7 @@ jobs:
strategy:
matrix:
BASE: ["noble"]
VENDOR: ["nvidia", "intel"]
VENDOR: ["nvidia", "intel", "amd"]
steps:
- name: Check out the repo
uses: actions/checkout@v3

View File

@@ -22,6 +22,7 @@ jobs:
BASE: [
["noble-nvidia", ".s6", "noble-nvidia", "nvidia"],
["noble-intel", ".s6", "noble-intel", "intel"],
["noble-amd", ".s6", "noble-amd", "amd"],
["noble-full", ".s6", "noble-full", "full"],
["noble-lite", "", "noble-lite", "lite"],
["noble-lite", ".router", "noble-router", "router"],
@@ -109,7 +110,7 @@ jobs:
${{ format('koush/scrypted:v{0}-{1}', github.event.inputs.publish_tag || steps.package-version.outputs.NPM_VERSION, matrix.BASE[2]) }}
${{ matrix.BASE[2] == 'noble-full' && format('koush/scrypted:{0}', github.event.inputs.tag) || '' }}
${{ github.event.inputs.tag == 'latest' && format('koush/scrypted:{0}', matrix.BASE[3]) || '' }}
${{ github.event.inputs.tag != 'latest' && format('koush/scrypted:{0}-{1}', github.event.inputs.tag, matrix.BASE[3]) }}
${{ github.event.inputs.tag != 'latest' && format('koush/scrypted:{0}-{1}', github.event.inputs.tag, matrix.BASE[3]) || '' }}
${{ format('ghcr.io/koush/scrypted:v{1}-{0}', matrix.BASE[0], github.event.inputs.publish_tag || steps.package-version.outputs.NPM_VERSION) }}
${{ matrix.BASE[2] == 'noble-full' && format('ghcr.io/koush/scrypted:{0}', github.event.inputs.tag) || '' }}

View File

@@ -15,14 +15,14 @@
"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.5.29",
"version": "0.5.39",
"license": "ISC",
"dependencies": {
"@babel/preset-typescript": "^7.27.1",
@@ -3340,11 +3340,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.11.0",
"version": "20.19.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.11.tgz",
"integrity": "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
"undici-types": "~6.21.0"
}
},
"node_modules/acorn": {
@@ -3479,7 +3481,9 @@
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},

View File

@@ -17,7 +17,7 @@
"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"
}

View File

@@ -9,6 +9,16 @@ export function createAsyncQueue<T>() {
const waiting: Deferred<T>[] = [];
const queued: { item: T, dequeued?: Deferred<void> }[] = [];
const wait = async (index: number) => {
const q = queued[index];
if (!q)
return;
if (!q.dequeued) {
q.dequeued = new Deferred<void>();
}
return q.dequeued.promise;
}
const dequeue = async () => {
if (queued.length) {
const { item, dequeued: enqueue } = queued.shift()!;
@@ -66,7 +76,7 @@ export function createAsyncQueue<T>() {
dequeued?.reject(new Error('abort'));
};
dequeued?.promise.catch(() => {}).finally(() => signal.removeEventListener('abort', h));
dequeued?.promise.catch(() => { }).finally(() => signal.removeEventListener('abort', h));
signal.addEventListener('abort', h);
return true;
@@ -154,13 +164,14 @@ export function createAsyncQueue<T>() {
dequeue,
get queue() {
return queue();
}
},
wait,
}
}
export function createAsyncQueueFromGenerator<T>(generator: AsyncGenerator<T>) {
const q = createAsyncQueue<T>();
(async() => {
(async () => {
try {
for await (const i of generator) {
await q.enqueue(i);

View File

@@ -2,6 +2,7 @@ import type * as monacoEditor from 'monaco-editor';
export interface StandardLibs {
'@types/node/globals.d.ts': string,
'@types/node/module.d.ts': string,
'@types/node/buffer.d.ts': string,
'@types/node/process.d.ts': string,
'@types/node/events.d.ts': string,

View File

@@ -116,6 +116,7 @@ export async function scryptedEval(device: ScryptedDeviceBase, script: string, e
export function createMonacoEvalDefaults(extraLibs: { [lib: string]: string }) {
const standardlibs: StandardLibs = {
"@types/node/globals.d.ts": readFileAsString('@types/node/globals.d.ts'),
"@types/node/module.d.ts": readFileAsString('@types/node/module.d.ts'),
"@types/node/buffer.d.ts": readFileAsString('@types/node/buffer.d.ts'),
"@types/node/process.d.ts": readFileAsString('@types/node/process.d.ts'),
"@types/node/events.d.ts": readFileAsString('@types/node/events.d.ts'),

View File

@@ -2,7 +2,6 @@ import { Socket as DatagramSocket } from "dgram";
import { once } from "events";
import { Duplex } from "stream";
import { FFMPEG_FRAGMENTED_MP4_OUTPUT_ARGS, MP4Atom, parseFragmentedMP4 } from "./ffmpeg-mp4-parser-session";
import { readLength } from "./read-stream";
export interface StreamParser {
container: string;
@@ -25,59 +24,11 @@ export interface StreamParserOptions {
export interface StreamChunk {
startStream?: Buffer;
chunks: Buffer[];
type?: string;
type: string;
width?: number;
height?: number;
}
// function checkTsPacket(pkt: Buffer) {
// const pid = ((pkt[1] & 0x1F) << 8) | pkt[2];
// if (pid == 256) {
// // found video stream
// if ((pkt[3] & 0x20) && (pkt[4] > 0)) {
// // have AF
// if (pkt[5] & 0x40) {
// // found keyframe
// console.log('keyframe');
// }
// }
// }
// }
function createLengthParser(length: number, verify?: (concat: Buffer) => void) {
async function* parse(socket: Duplex): AsyncGenerator<StreamChunk> {
let pending: Buffer[] = [];
let pendingSize = 0;
while (true) {
const data: Buffer = socket.read();
if (!data) {
await once(socket, 'readable');
continue;
}
pending.push(data);
pendingSize += data.length;
if (pendingSize < length)
continue;
const concat = Buffer.concat(pending);
verify?.(concat);
const remaining = concat.length % length;
const left = concat.slice(0, concat.length - remaining);
const right = concat.slice(concat.length - remaining);
pending = [right];
pendingSize = right.length;
yield {
chunks: [left],
};
}
}
return parse;
}
export function createDgramParser() {
async function* parse(socket: DatagramSocket, width: number, height: number, type: string) {
while (true) {
@@ -91,65 +42,6 @@ export function createDgramParser() {
return parse;
}
export function createMpegTsParser(options?: StreamParserOptions): StreamParser {
return {
container: 'mpegts',
outputArguments: [
...(options?.vcodec || []),
...(options?.acodec || []),
'-f', 'mpegts',
],
parse: createLengthParser(188, concat => {
if (concat[0] != 0x47) {
throw new Error('Invalid sync byte in mpeg-ts packet. Terminating stream.')
}
}),
findSyncFrame(streamChunks): StreamChunk[] {
for (let prebufferIndex = 0; prebufferIndex < streamChunks.length; prebufferIndex++) {
const streamChunk = streamChunks[prebufferIndex];
for (let chunkIndex = 0; chunkIndex < streamChunk.chunks.length; chunkIndex++) {
const chunk = streamChunk.chunks[chunkIndex];
let offset = 0;
while (offset + 188 < chunk.length) {
const pkt = chunk.subarray(offset, offset + 188);
const pid = ((pkt[1] & 0x1F) << 8) | pkt[2];
if (pid == 256) {
// found video stream
if ((pkt[3] & 0x20) && (pkt[4] > 0)) {
// have AF
if (pkt[5] & 0x40) {
// we found the sync frame, but also need to send the pat and pmt
// which might be at the start of this chunk before the keyframe.
// yolo!
return streamChunks.slice(prebufferIndex);
// const chunks = streamChunk.chunks.slice(chunkIndex + 1);
// const take = chunk.subarray(offset);
// chunks.unshift(take);
// const remainingChunks = streamChunks.slice(prebufferIndex + 1);
// const ret = Object.assign({}, streamChunk);
// ret.chunks = chunks;
// return [
// ret,
// ...remainingChunks
// ];
}
}
}
offset += 188;
}
}
}
return findSyncFrame(streamChunks);
}
}
}
export async function* parseMp4StreamChunks(parser: AsyncGenerator<MP4Atom>) {
let ftyp: MP4Atom;
let moov: MP4Atom;
@@ -213,54 +105,3 @@ export const PIXEL_FORMAT_RGB24: RawVideoPixelFormat = {
name: 'rgb24',
computeLength: (width, height) => width * height * 3,
}
export function createRawVideoParser(options: RawVideoParserOptions): StreamParser {
const pixelFormat = options?.pixelFormat || PIXEL_FORMAT_YUV420P;
let filter: string;
const { size, everyNFrames } = options;
if (size) {
filter = `scale=${size.width}:${size.height}`;
}
if (everyNFrames && everyNFrames > 1) {
if (filter)
filter += ',';
else
filter = '';
filter = filter + `select=not(mod(n\\,${everyNFrames}))`
}
const inputArguments: string[] = [];
if (options.size)
inputArguments.push('-s', `${options.size.width}x${options.size.height}`);
inputArguments.push('-pix_fmt', pixelFormat.name);
return {
inputArguments,
container: 'rawvideo',
outputArguments: [
'-s', `${options.size.width}x${options.size.height}`,
'-an',
'-vcodec', 'rawvideo',
'-pix_fmt', pixelFormat.name,
'-f', 'rawvideo',
],
async *parse(socket: Duplex, width: number, height: number): AsyncGenerator<StreamChunk> {
width = size?.width || width;
height = size?.height || height
if (!width || !height)
throw new Error("error parsing rawvideo, unknown width and height");
const toRead = pixelFormat.computeLength(width, height);
while (true) {
const buffer = await readLength(socket, toRead);
yield {
chunks: [buffer],
width,
height,
}
}
},
findSyncFrame,
}
}

View File

@@ -0,0 +1,98 @@
export abstract class AsyncUsingHolderBase<T> {
constructor(private _value: T) {
}
get value(): T {
return this._value;
}
async [Symbol.asyncDispose]() {
await this.release();
}
abstract asyncDispose(value: T): Promise<void>;
detach() {
const value = this._value;
this._value = undefined;
return value;
}
async replace(value: T) {
this.release();
this._value = value;
}
async release() {
const released = this.detach();
if (released)
await this.asyncDispose(released);
}
}
export abstract class UsingHolderBase<T> {
constructor(private _value: T) {
}
get value(): T {
return this._value;
}
[Symbol.dispose]() {
this.release();
}
abstract dispose(value: T): void;
detach() {
const value = this._value;
this._value = undefined;
return value;
}
replace(value: T) {
this.release();
this._value = value;
}
release() {
const released = this.detach();
if (released)
this.dispose(released);
}
}
export class UsingHolder<T extends Disposable> extends UsingHolderBase<T> {
dispose(value: T) {
value?.[Symbol.dispose]();
}
transferClosure<V>(closure: (value: UsingHolder<T>) => Promise<V>) {
return (async () => {
using attached = new UsingHolder(this.detach());
return await closure(attached);
})();
}
}
export class AsyncUsingHolder<T extends AsyncDisposable> extends AsyncUsingHolderBase<T> {
async asyncDispose(value: T) {
value?.[Symbol.asyncDispose]();
}
transferClosure<V>(closure: (value: AsyncUsingHolder<T>) => Promise<V>) {
return (async () => {
await using attached = new AsyncUsingHolder(this.detach());
return await closure(attached);
})();
}
}
export class DisposableHolder<T> extends UsingHolderBase<T> {
constructor(value: T, private _dispose: (value: T) => void) {
super(value);
}
dispose(value: T) {
this._dispose(value);
}
}

View File

@@ -52,7 +52,12 @@ export function createZygote<T>(options?: ForkOptions): Zygote<T> {
}
const gen = next();
return () => gen.next().value as PluginFork<T>;
return () => {
const ret = gen.next();
if (ret.done || !ret.value)
throw new Error('Zygote exhausted');
return ret.value;
};
}

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"module": "commonjs",
"module": "Node16",
"moduleResolution": "Node16",
"target": "esnext",
"noImplicitAny": true,

View File

@@ -1,6 +1,6 @@
# Home Assistant Addon Configuration
name: Scrypted
version: "v0.139.0-noble-full"
version: "v0.141.0-noble-full"
slug: scrypted
description: Scrypted is a high performance home video integration and automation platform
url: "https://github.com/koush/scrypted"

View File

@@ -0,0 +1,7 @@
ARG BASE="ghcr.io/koush/scrypted-common:20-jammy-full"
FROM $BASE
ENV SCRYPTED_DOCKER_FLAVOR="amd"
# amd opencl
RUN curl https://raw.githubusercontent.com/koush/scrypted/main/install/docker/install-amd-graphics.sh | bash

View File

@@ -92,7 +92,7 @@ RUN python3.9 -m pip install debugpy
# Coral Edge TPU
# https://coral.ai/docs/accelerator/get-started/#runtime-on-linux
RUN echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" | tee /etc/apt/sources.list.d/coral-edgetpu.list
RUN curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key add -
RUN curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /etc/apt/trusted.gpg.d/coral-edgetpu.gpg
RUN apt-get -y update && apt-get -y install libedgetpu1-std
# set default shell to bash

View File

@@ -9,7 +9,15 @@ RUN rm -f /etc/systemd/system/multi-user.target.wants/dnsmasq.service
RUN rm -f /etc/systemd/system/sysinit.target.wants/systemd-resolved.service
# go + caddy
RUN apt -y install golang-go
RUN GO_VERSION=1.25.1 && ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ]; then GOARCH="amd64"; \
elif [ "$ARCH" = "arm64" ]; then GOARCH="arm64"; \
elif [ "$ARCH" = "armhf" ]; then GOARCH="armv6l"; \
else echo "Unsupported architecture: $ARCH" && exit 1; fi && \
curl -LO "https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" && \
tar -C /usr/local -xzf "go${GO_VERSION}.linux-${GOARCH}.tar.gz" && \
rm "go${GO_VERSION}.linux-${GOARCH}.tar.gz"
ENV PATH=$PATH:/usr/local/go/bin
RUN apt install -y debian-keyring debian-archive-keyring apt-transport-https
RUN curl -1sLf 'https://dl.cloudsmith.io/public/caddy/xcaddy/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-xcaddy-archive-keyring.gpg
RUN curl -1sLf 'https://dl.cloudsmith.io/public/caddy/xcaddy/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-xcaddy.list

View File

@@ -0,0 +1,45 @@
import os
from ruamel.yaml import YAML
# Define the devices to check for
devices_to_check = [
"/dev/dri",
"/dev/accel",
"/dev/apex_0",
"/dev/apex_1",
"/dev/kfd",
"/dev/bus/usb"
]
# Use ruamel.yaml with better formatting preservation
yaml = YAML()
yaml.preserve_quotes = True
# Explicitly set roundtrip mode for comment preservation
yaml.typ = 'rt'
# Match the original formatting - 4 space indentation
yaml.indent = 4
# No special block sequence indentation
yaml.block_seq_indent = 0
# Don't wrap lines
yaml.width = None
# Preserve unicode
yaml.allow_unicode = True
# Read the docker-compose.yml file
with open('docker-compose.yml', 'r') as file:
compose_data = yaml.load(file)
# Get a direct reference to the devices key
scrypted_service = compose_data['services']['scrypted']
devices = scrypted_service.setdefault('devices', [])
# Check for devices and add them if they exist
for device_path in devices_to_check:
if os.path.exists(device_path):
device_mapping = f"{device_path}:{device_path}"
if device_mapping not in devices:
devices.append(device_mapping)
# Write the modified docker-compose.yml file (preserving comments and formatting)
with open('docker-compose.yml', 'w') as file:
yaml.dump(compose_data, file)

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# run as privileged so all the devices can be detected and only the necessary ones passed through.
docker run --rm \
--privileged \
-v "$(pwd):/app" \
-w /app \
python:3.12-slim \
sh -c "pip install -q --root-user-action=ignore ruamel.yaml && python docker-compose-setup.py"

View File

@@ -132,6 +132,12 @@ services:
labels:
- "com.centurylinklabs.watchtower.scope=scrypted"
# Use global DNS servers to avoid issues with some local DNS servers.
# particularly with npm registry, etc.
dns:
- ${SCRYPTED_DNS_SERVER_0:-1.1.1.1}
- ${SCRYPTED_DNS_SERVER_1:-8.8.8.8}
# watchtower manages updates for Scrypted.
watchtower:
environment:
@@ -152,3 +158,9 @@ services:
- 10444:8080
# check for updates once an hour (interval is in seconds)
command: --interval 3600 --cleanup --scope scrypted
# Use global DNS servers to avoid issues with some local DNS servers.
# particularly with npm registry, etc.
dns:
- ${SCRYPTED_DNS_SERVER_0:-1.1.1.1}
- ${SCRYPTED_DNS_SERVER_1:-8.8.8.8}

View File

@@ -21,23 +21,34 @@ else
distro="noble"
fi
apt -y update
apt -y install rsync gpg
# the deb no longer seems to install a key?
gpg --keyserver keyserver.ubuntu.com --recv-keys 9386B48A1A693C5C
gpg --export --armor 9386B48A1A693C5C | tee /etc/apt/trusted.gpg.d/amdgpu.asc
# https://amdgpu-install.readthedocs.io/en/latest/install-prereq.html#installing-the-installer-package
FILENAME=$(curl -s -L https://repo.radeon.com/amdgpu-install/latest/ubuntu/$distro/ | grep -o 'amdgpu-install_[^ ]*' | cut -d'"' -f1)
if [ -z "$FILENAME" ]
then
echo "AMD graphics package can not be installed. Could not find the package name."
exit 1
fi
# AMD keeps breaking these links. Use hard links.
# FILENAME=$(curl -s -L https://repo.radeon.com/amdgpu-install/latest/ubuntu/$distro/ | grep -o 'amdgpu-install_[^ ]*' | cut -d'"' -f1)
# if [ -z "$FILENAME" ]
# then
# echo "AMD graphics package can not be installed. Could not find the package name."
# exit 1
# fi
set -e
mkdir -p /tmp/amd
cd /tmp/amd
curl -O -L http://repo.radeon.com/amdgpu-install/latest/ubuntu/$distro/$FILENAME
apt -y update
apt -y install rsync
# curl -O -L https://repo.radeon.com/amdgpu-install/latest/ubuntu/$distro/$FILENAME
FILENAME=amdgpu-install_7.0.1.70001-1_all.deb
curl -O -L https://repo.radeon.com/amdgpu-install/7.0.1/ubuntu/$distro/$FILENAME
dpkg -i $FILENAME
apt -y update
amdgpu-install --usecase=opencl --no-dkms -y --accept-eula
cd /tmp
rm -rf /tmp/amd

View File

@@ -87,15 +87,15 @@ rm -f *.deb
# https://github.com/intel/compute-runtime/releases
# note that at time of commit, IGC supports ubuntu 24.04 only possibly due to their builder being on 24.04.
curl -O -L https://github.com/intel/intel-graphics-compiler/releases/download/v2.12.5/intel-igc-core-2_2.12.5+19302_amd64.deb
curl -O -L https://github.com/intel/intel-graphics-compiler/releases/download/v2.12.5/intel-igc-opencl-2_2.12.5+19302_amd64.deb
# curl -O -L https://github.com/intel/compute-runtime/releases/download/25.22.33944.8/intel-ocloc-dbgsym_25.22.33944.8-0_amd64.ddeb
curl -O -L https://github.com/intel/compute-runtime/releases/download/25.22.33944.8/intel-ocloc_25.22.33944.8-0_amd64.deb
# curl -O -L https://github.com/intel/compute-runtime/releases/download/25.22.33944.8/intel-opencl-icd-dbgsym_25.22.33944.8-0_amd64.ddeb
curl -O -L https://github.com/intel/compute-runtime/releases/download/25.22.33944.8/intel-opencl-icd_25.22.33944.8-0_amd64.deb
curl -O -L https://github.com/intel/compute-runtime/releases/download/25.22.33944.8/libigdgmm12_22.7.0_amd64.deb
# curl -O -L https://github.com/intel/compute-runtime/releases/download/25.22.33944.8/libze-intel-gpu1-dbgsym_25.22.33944.8-0_amd64.ddeb
curl -O -L https://github.com/intel/compute-runtime/releases/download/25.22.33944.8/libze-intel-gpu1_25.22.33944.8-0_amd64.deb
curl -O -L https://github.com/intel/intel-graphics-compiler/releases/download/v2.18.5/intel-igc-core-2_2.18.5+19820_amd64.deb
curl -O -L https://github.com/intel/intel-graphics-compiler/releases/download/v2.18.5/intel-igc-opencl-2_2.18.5+19820_amd64.deb
# curl -O -L https://github.com/intel/compute-runtime/releases/download/25.35.35096.9/intel-ocloc-dbgsym_25.35.35096.9-0_amd64.ddeb
curl -O -L https://github.com/intel/compute-runtime/releases/download/25.35.35096.9/intel-ocloc_25.35.35096.9-0_amd64.deb
# curl -O -L https://github.com/intel/compute-runtime/releases/download/25.35.35096.9/intel-opencl-icd-dbgsym_25.35.35096.9-0_amd64.ddeb
curl -O -L https://github.com/intel/compute-runtime/releases/download/25.35.35096.9/intel-opencl-icd_25.35.35096.9-0_amd64.deb
curl -O -L https://github.com/intel/compute-runtime/releases/download/25.35.35096.9/libigdgmm12_22.8.1_amd64.deb
# curl -O -L https://github.com/intel/compute-runtime/releases/download/25.35.35096.9/libze-intel-gpu1-dbgsym_25.35.35096.9-0_amd64.ddeb
curl -O -L https://github.com/intel/compute-runtime/releases/download/25.35.35096.9/libze-intel-gpu1_25.35.35096.9-0_amd64.deb
set +e
dpkg -i *.deb

View File

@@ -9,11 +9,17 @@ UBUNTU_24_04=$(lsb_release -r | grep "24.04")
if [ -z "$UBUNTU_22_04" ] && [ -z "$UBUNTU_24_04" ]
then
# proxmox is compatible with ubuntu 22.04, check for /etc/pve directory
if [ -d "/etc/pve" ]
then
# proxmox is compatible with intel's ubuntu builds, check for /etc/pve directory
# then determine debian version
version=$(cat /etc/debian_version 2>/dev/null)
# Determine if it's Debian 12 or 13
if [[ "$version" == 12* ]]; then
UBUNTU_22_04=true
elif [[ "$version" == 13* ]]; then
UBUNTU_24_04=true
fi
fi
# needs either ubuntu 22.0.4 or 24.04
@@ -25,8 +31,10 @@ fi
if [ -n "$UBUNTU_22_04" ]
then
ubuntu_distro=ubuntu2204
distro="22.04_amd64"
else
ubuntu_distro=ubuntu2404
distro="24.04_amd64"
fi
@@ -38,22 +46,24 @@ set -e
rm -rf /tmp/npu && mkdir -p /tmp/npu && cd /tmp/npu
# level zero must also be installed
LEVEL_ZERO_VERSION=1.22.4
LEVEL_ZERO_VERSION=1.24.2
# https://github.com/oneapi-src/level-zero
curl -O -L https://github.com/oneapi-src/level-zero/releases/download/v"$LEVEL_ZERO_VERSION"/level-zero_"$LEVEL_ZERO_VERSION"+u$distro.deb
curl -O -L https://github.com/oneapi-src/level-zero/releases/download/v"$LEVEL_ZERO_VERSION"/level-zero-devel_"$LEVEL_ZERO_VERSION"+u$distro.deb
# npu driver
# https://github.com/intel/linux-npu-driver
NPU_VERSION=1.19.0
NPU_VERSION_DATE=20250707-16111289554
curl -O -L https://github.com/intel/linux-npu-driver/releases/download/v"$NPU_VERSION"/intel-driver-compiler-npu_$NPU_VERSION."$NPU_VERSION_DATE"_ubuntu$distro.deb
NPU_VERSION=1.23.0
NPU_VERSION_DATE=20250827-17270089246
NPU_TAR_FILENAME=linux-npu-driver-v"$NPU_VERSION"."$NPU_VERSION_DATE"-$ubuntu_distro.tar.gz
curl -O -L https://github.com/intel/linux-npu-driver/releases/download/v"$NPU_VERSION"/"$NPU_TAR_FILENAME"
tar xzvf "$NPU_TAR_FILENAME"
# firmware can only be installed on host. will cause problems inside container.
if [ -n "$INTEL_FW_NPU" ]
if [ ! -z "$INTEL_FW_NPU" ]
then
curl -O -L https://github.com/intel/linux-npu-driver/releases/download/v"$NPU_VERSION"/intel-fw-npu_$NPU_VERSION."$NPU_VERSION_DATE"_ubuntu$distro.deb
rm *fw-npu*
fi
curl -O -L https://github.com/intel/linux-npu-driver/releases/download/v"$NPU_VERSION"/intel-level-zero-npu_$NPU_VERSION."$NPU_VERSION_DATE"_ubuntu$distro.deb
apt -y update
apt -y install libtbb12

View File

@@ -1,3 +1,5 @@
apt -y install lsb-release
UBUNTU_22_04=$(lsb_release -r | grep "22.04")
UBUNTU_24_04=$(lsb_release -r | grep "24.04")
@@ -9,6 +11,16 @@ set -e
# https://developer.nvidia.com/cuda-downloads?target_os=Linux&target_arch=x86_64&Distribution=Ubuntu&target_version=24.04&target_type=deb_network
# Do not apt install nvidia-open, must use cuda-drivers.
if [ -z "$UBUNTU_22_04" ] && [ -z "$UBUNTU_24_04" ]
then
# proxmox is compatible with ubuntu 22.04, check for /etc/pve directory
if [ -d "/etc/pve" ]
then
apt -y install pve-headers-$(uname -r)
UBUNTU_22_04=true
fi
fi
if [ -z "$UBUNTU_22_04" ] && [ -z "$UBUNTU_24_04" ]
then
echo "NVIDIA container toolkit can not be installed. Ubuntu version could not be detected when checking lsb-release and /etc/os-release."
@@ -38,7 +50,14 @@ apt -y update
# is there a way to get a versioned package automatically?
# cuda-drivers does not work with blackwell for some reason, container toolkit it broken IIRC.
apt -y install nvidia-open
apt -y install nvidia-container-toolkit
nvidia-ctk runtime configure --runtime=docker
systemctl restart docker
if [ ! -d "/etc/pve" ]
then
apt -y install nvidia-container-toolkit
nvidia-ctk runtime configure --runtime=docker
systemctl restart docker
fi
# need this if running inside lxc...
# nvidia-ctk config --set nvidia-container-cli.no-cgroups --in-place

View File

@@ -186,4 +186,4 @@ echo
echo
echo "Optional:"
echo "Scrypted NVR Recording storage directory can be configured with an additional script located at:"
echo "https://docs.scrypted.app/scrypted-nvr/recording-storage.html#docker-volume"
echo "https://docs.scrypted.app/scrypted-nvr/storage/docker.html"

View File

@@ -32,7 +32,7 @@ RUN python3.9 -m pip install debugpy
# Coral Edge TPU
# https://coral.ai/docs/accelerator/get-started/#runtime-on-linux
RUN echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" | tee /etc/apt/sources.list.d/coral-edgetpu.list
RUN curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key add -
RUN curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /etc/apt/trusted.gpg.d/coral-edgetpu.gpg
RUN apt-get -y update && apt-get -y install libedgetpu1-std
# set default shell to bash

View File

@@ -4,7 +4,7 @@
# install script.
################################################################
ARG BASE="noble"
FROM ubuntu:${BASE} as header
FROM ubuntu:${BASE} AS header
ENV DEBIAN_FRONTEND=noninteractive

View File

@@ -7,10 +7,25 @@ export DEBIAN_FRONTEND=noninteractive
yes | dpkg --configure -a
apt -y --fix-broken install && apt -y update && apt -y dist-upgrade
function cleanup() {
IS_UP=$(docker compose ps scrypted -a | grep Up)
# Only clean up when scrypted is running to safely free space without risking its image deletion
if [ -z "$IS_UP" ]; then
echo "scrypted is not running, skipping cleanup to preserve its image"
return
fi
echo $(date) > .last_cleanup
echo "scrypted is running, proceeding with cleanup to free space"
docker container prune -f
docker image prune -a -f
}
# force a pull to ensure we have the latest images.
# not using --pull always cause that fails everything on network down
docker compose pull
(sleep 60 && cleanup) &
# do not daemonize, when it exits, systemd will restart it.
# force a recreate as .env may have changed.
# furthermore force recreate gets the container back into a known state

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/client",
"version": "1.3.17",
"version": "1.3.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/client",
"version": "1.3.17",
"version": "1.3.26",
"license": "ISC",
"dependencies": {
"engine.io-client": "^6.6.3",
@@ -21,7 +21,7 @@
"typescript": "^5.8.3"
},
"peerDependencies": {
"@scrypted/types": "^0.5.23"
"@scrypted/types": "^0.5.45"
}
},
"node_modules/@cspotcode/source-map-support": {
@@ -37,6 +37,27 @@
"node": ">=12"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -65,9 +86,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
@@ -83,58 +104,13 @@
}
},
"node_modules/@scrypted/types": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.5.23.tgz",
"integrity": "sha512-is/UJHgS3lvEuXyb+C/OPeIP5CKp+M6SQt1l/WFJr1Oj+KYYHGU8Ztlh/qOmAWgONhg286N4/cLNzTtAAh4YnA==",
"version": "0.5.45",
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.5.45.tgz",
"integrity": "sha512-ysySpWkGUrUpNj0BoTZpyn2HeVCyN0kfsQ2qyUoegdj7O8Z4VWROQa1mSrrPAAftM8zhTHrgYw8RcvMsfh0BTQ==",
"license": "ISC",
"peer": true,
"dependencies": {
"openai": "^5.3.0"
}
},
"node_modules/@scrypted/types/node_modules/openai": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-5.3.0.tgz",
"integrity": "sha512-VIKmoF7y4oJCDOwP/oHXGzM69+x0dpGFmN9QmYO+uPbLFOmmnwO+x1GbsgUtI+6oraxomGZ566Y421oYVu191w==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/@scrypted/types/node_modules/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
"openai": "^6.1.0"
}
},
"node_modules/@socket.io/component-emitter": {
@@ -182,13 +158,13 @@
}
},
"node_modules/@types/node": {
"version": "24.0.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.10.tgz",
"integrity": "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA==",
"version": "24.5.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.1.tgz",
"integrity": "sha512-/SQdmUP2xa+1rdx7VwB9yPq8PaKej8TD5cQ+XfKDPWWC+VDJU4rvVVagXqKUzhKjtFoNA8rXDJAkCxQPAe00+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.8.0"
"undici-types": "~7.12.0"
}
},
"node_modules/@types/ws": {
@@ -202,9 +178,9 @@
}
},
"node_modules/acorn": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"bin": {
@@ -228,9 +204,9 @@
}
},
"node_modules/ansi-regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -240,9 +216,9 @@
}
},
"node_modules/ansi-styles": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -258,21 +234,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -364,6 +325,27 @@
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
@@ -374,9 +356,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
@@ -410,14 +392,14 @@
}
},
"node_modules/glob": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.1.tgz",
"integrity": "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw==",
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz",
"integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^4.0.1",
"minimatch": "^10.0.0",
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.0.3",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
@@ -448,9 +430,9 @@
"license": "ISC"
},
"node_modules/jackspeak": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.0.tgz",
"integrity": "sha512-9DDdhb5j6cpeitCbvLO7n7J4IxnbM6hoF6O1g4HQ5TfhvvKN8ywDM7668ZhMHRqVmxqhps/F6syWK2KcPxYlkw==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
@@ -463,9 +445,9 @@
}
},
"node_modules/lru-cache": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.2.tgz",
"integrity": "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==",
"version": "11.2.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.1.tgz",
"integrity": "sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==",
"license": "ISC",
"engines": {
"node": "20 || >=22"
@@ -479,12 +461,12 @@
"license": "ISC"
},
"node_modules/minimatch": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz",
"integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==",
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
"integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "20 || >=22"
@@ -508,6 +490,28 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/openai": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.1.0.tgz",
"integrity": "sha512-5sqb1wK67HoVgGlsPwcH2bUbkg66nnoIYKoyV9zi5pZPqh7EWlmSrSDjAh4O5jaIg/0rIlcDKBtWvZBuacmGZg==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -651,9 +655,9 @@
}
},
"node_modules/strip-ansi": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
@@ -732,9 +736,9 @@
}
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"version": "5.9.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -746,9 +750,9 @@
}
},
"node_modules/undici-types": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz",
"integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==",
"dev": true,
"license": "MIT"
},
@@ -865,27 +869,6 @@
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/client",
"version": "1.3.17",
"version": "1.3.26",
"description": "",
"main": "dist/packages/client/src/index.js",
"scripts": {
@@ -19,7 +19,7 @@
"typescript": "^5.8.3"
},
"peerDependencies": {
"@scrypted/types": "^0.5.23"
"@scrypted/types": "^0.5.45"
},
"dependencies": {
"engine.io-client": "^6.6.3",

View File

@@ -1,10 +1,7 @@
import { MediaObjectCreateOptions, RTCConnectionManagement, RTCSignalingSession, ScryptedStatic } from "@scrypted/types";
import { ConnectRPCObjectOptions, MediaObjectCreateOptions, ScryptedStatic } from "@scrypted/types";
import * as eio from 'engine.io-client';
import { SocketOptions } from 'engine.io-client';
import { Deferred } from "../../../common/src/deferred";
import { timeoutPromise } from "../../../common/src/promise-utils";
import { BrowserSignalingSession, waitPeerConnectionIceConnected, waitPeerIceConnectionClosed } from "../../../common/src/rtc-signaling";
import { DataChannelDebouncer } from "../../../plugins/webrtc/src/datachannel-debouncer";
import type { ClusterObject, ConnectRPCObject } from '../../../server/src/cluster/connect-rpc-object';
import type { IOSocket } from '../../../server/src/io';
import { MediaObject } from '../../../server/src/plugin/mediaobject';
@@ -17,6 +14,9 @@ import { isIPAddress } from "./ip";
import { domFetch } from "../../../server/src/fetch";
import { httpFetch } from '../../../server/src/fetch/http-fetch';
export * as rpc from '../../../server/src/rpc';
export * as rpc_serializer from '../../../server/src/rpc-serializer';
let fetcher: typeof httpFetch | typeof domFetch;
try {
if (process.arch === 'browser' as any)
@@ -52,7 +52,13 @@ function once(socket: IOClientSocket, event: 'open' | 'message') {
});
}
export type ScryptedClientConnectionType = 'http' | 'webrtc' | 'http-direct';
/**
* The type of connection used by the Scrypted client.
* http-cloud is through Scrypted Cloud
* http-direct is a direct connection to the Scrypted server via one of the local network interfaces or public IP addresses.
* http is a direct connection with the base url or browser url.
*/
export type ScryptedClientConnectionType = 'http-cloud' | 'http-direct' | 'http';
export interface ScryptedClientStatic extends ScryptedStatic {
userId?: string;
@@ -60,8 +66,6 @@ export interface ScryptedClientStatic extends ScryptedStatic {
admin: boolean;
disconnect(): void;
onClose?: Function;
rtcConnectionManagement?: RTCConnectionManagement;
browserSignalingSession?: BrowserSignalingSession;
address?: string;
connectionType: ScryptedClientConnectionType;
rpcPeer: RpcPeer;
@@ -71,7 +75,6 @@ export interface ScryptedClientStatic extends ScryptedStatic {
export interface ScryptedConnectionOptions {
direct?: boolean;
local?: boolean;
webrtc?: boolean;
baseUrl?: string;
previousLoginResult?: ScryptedClientLoginResult;
}
@@ -112,19 +115,51 @@ export async function logoutScryptedClient(baseUrl?: string) {
return response.body;
}
export function getCurrentBaseUrl() {
// an endpoint within scrypted will be served at /endpoint/[org/][id]
// find the endpoint prefix and anything prior to that will be the server base url.
const url = new URL(window.location.href);
url.search = '';
url.hash = '';
let endpointPath = window.location.pathname;
const parts = endpointPath.split('/');
const index = parts.findIndex(p => p === 'endpoint');
if (index === -1) {
// console.warn('path not recognized, does not contain the segment "endpoint".')
return undefined;
function getBaseUrl(href: string) {
try {
// an endpoint within scrypted will be served at /endpoint/[org/][id]
// find the endpoint prefix and anything prior to that will be the server base url.
const url = new URL(href);
url.search = '';
url.hash = '';
let endpointPath = url.pathname;
const parts = endpointPath.split('/');
const index = parts.findIndex(p => p === 'endpoint');
if (index === -1) {
// console.warn('path not recognized, does not contain the segment "endpoint".')
return;
}
return { url, parts, index };
}
catch (e) {
}
}
function importMetaUrlWithoutAssetsPath() {
// @ts-ignore
const url = new URL(import.meta.url);
const parts = url.pathname.split('/');
parts.pop();
parts.pop();
parts.push('public')
parts.push('');
url.pathname = parts.join('/');
return url.toString();
}
export function getCurrentBaseUrlRaw() {
const url = getBaseUrl(window.location.href)
|| getBaseUrl(document.baseURI)
|| getBaseUrl(importMetaUrlWithoutAssetsPath());
return url;
}
export function getCurrentBaseUrl() {
const s = getCurrentBaseUrlRaw();
if (!s) {
return;
}
const { url, parts, index } = s;
const keep = parts.slice(0, index);
keep.push('');
url.pathname = keep.join('/');
@@ -385,11 +420,11 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
const eioPath = `endpoint/${pluginId}/engine.io/api`;
const eioEndpoint = baseUrl ? new URL(eioPath, baseUrl).pathname : '/' + eioPath;
// https://github.com/socketio/engine.io/issues/690
const cacehBust = Math.random().toString(36).substring(3, 10);
const cacheBust = Math.random().toString(36).substring(3, 10);
const eioOptions: Partial<SocketOptions> = {
path: eioEndpoint,
query: {
cacehBust,
cacheBust,
},
withCredentials: true,
extraHeaders,
@@ -399,10 +434,6 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
const explicitBaseUrl = baseUrl || `${globalThis.location.protocol}//${globalThis.location.host}`;
// underlying webrtc rpc transport may queue up messages before its ready to be to be handled.
// watch for this flush.
const flush = new Deferred<void>();
const addresses: string[] = [];
const localAddressDefault = isNotChromeOrIsInstalledApp;
@@ -426,25 +457,9 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
}
const tryAddresses = !!addresses.length;
const webrtcLastFailedKey = 'webrtcLastFailed';
const canUseWebrtc = !!globalThis.RTCPeerConnection;
let tryWebrtc = canUseWebrtc && options.webrtc;
// try webrtc by default on scrypted cloud.
// but webrtc takes a while to fail, so backoff if it fails to prevent continual slow starts.
if (scryptedCloud && canUseWebrtc && globalThis.localStorage && options.webrtc === undefined) {
tryWebrtc = true;
const webrtcLastFailed = parseFloat(localStorage.getItem(webrtcLastFailedKey));
// if webrtc has failed in the past day, dont attempt to use it.
const now = Date.now();
if (webrtcLastFailed < now && webrtcLastFailed > now - 1 * 24 * 60 * 60 * 1000) {
tryWebrtc = false;
console.warn('WebRTC API connection recently failed. Skipping.')
}
}
console.log({
tryLocalAddressess: tryAddresses,
tryWebrtc,
});
const localEioOptions: Partial<SocketOptions> = {
@@ -484,145 +499,11 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
}
}
if (tryWebrtc) {
console.log('trying webrtc');
const webrtcEioOptions: Partial<SocketOptions> = {
path: '/endpoint/@scrypted/webrtc/engine.io/',
query: {
cacehBust,
},
withCredentials: true,
extraHeaders,
rejectUnauthorized: false,
transports: options?.transports,
};
const check = new eio.Socket(explicitBaseUrl, webrtcEioOptions) as IOClientSocket;
sockets.push(check);
promises.push((async () => {
await once(check, 'open');
const connectionManagementId = `connectionManagement-${Math.random()}`;
const updateSessionId = `updateSessionId-${Math.random()}`;
check.send(JSON.stringify({
pluginId,
updateSessionId,
connectionManagementId,
}));
const dcDeferred = new Deferred<RTCDataChannel>();
const session = new BrowserSignalingSession();
const droppedMessages: any[] = [];
session.onPeerConnection = async pc => {
pc.ondatachannel = e => {
e.channel.onmessage = message => droppedMessages.push(message);
e.channel.binaryType = 'arraybuffer';
dcDeferred.resolve(e.channel)
};
}
const pcPromise = session.pcDeferred.promise;
const serializer = createRpcSerializer({
sendMessageBuffer: buffer => check.send(buffer),
sendMessageFinish: message => check.send(JSON.stringify(message)),
});
const upgradingPeer = new RpcPeer(clientName || 'webrtc-upgrade', "api", (message, reject, serializationContext) => {
try {
serializer.sendMessage(message, reject, serializationContext);
}
catch (e) {
reject?.(e as Error);
}
});
check.on('message', data => {
if (data.constructor === Buffer || data.constructor === ArrayBuffer) {
serializer.onMessageBuffer(Buffer.from(data as string));
}
else {
serializer.onMessageFinish(JSON.parse(data as string));
}
});
serializer.setupRpcPeer(upgradingPeer);
// is this an issue?
// const readyClose = new Promise<RpcPeer>((resolve, reject) => {
// check.on('close', () => reject(new Error('closed')))
// })
upgradingPeer.params['session'] = session;
const pc = await pcPromise;
console.log('peer connection received');
await waitPeerConnectionIceConnected(pc);
console.log('waiting for data channel');
const dc = await dcDeferred.promise;
console.log('datachannel received', Date.now() - start);
const debouncer = new DataChannelDebouncer(dc, e => {
console.error('datachannel send error', e);
rpcPeer.kill('datachannel send error');
});
const dcSerializer = createRpcDuplexSerializer({
write: (data) => debouncer.send(data),
});
while (droppedMessages.length) {
const message = droppedMessages.shift();
dc.dispatchEvent(message);
}
const rpcPeer = new RpcPeer('webrtc-client', "api", (message, reject, serializationContext) => {
try {
dcSerializer.sendMessage(message, reject, serializationContext);
}
catch (e) {
reject?.(e as Error);
pc.close();
}
});
dcSerializer.setupRpcPeer(rpcPeer);
rpcPeer.params['connectionManagementId'] = connectionManagementId;
rpcPeer.params['updateSessionId'] = updateSessionId;
rpcPeer.params['browserSignalingSession'] = session;
waitPeerIceConnectionClosed(pc).then(() => check.close());
check.on('close', () => {
console.log('datachannel upgrade cancelled/closed');
pc.close()
});
await new Promise(resolve => {
let buffers: Buffer[] = [];
dc.onmessage = message => {
buffers.push(Buffer.from(message.data));
resolve(undefined);
flush.promise.finally(() => {
if (buffers) {
for (const buffer of buffers) {
dcSerializer.onData(Buffer.from(buffer));
}
buffers = undefined;
}
dc.onmessage = message => dcSerializer.onData(Buffer.from(message.data));
});
};
});
return {
ready: check,
connectionType: 'webrtc',
rpcPeer,
};
})());
}
const p2pPromises = [...promises];
promises.push((async () => {
const waitDuration = tryWebrtc ? 10000 : (tryAddresses ? 1000 : 0);
const waitDuration = tryAddresses ? 1000 : 0;
console.log('waiting', waitDuration);
if (waitDuration) {
// give the peer to peers a second, but then try connecting directly.
@@ -643,16 +524,13 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
return {
ready: check,
address: explicitBaseUrl,
connectionType: 'http',
connectionType: scryptedCloud ? 'http-cloud' : 'http',
};
})());
const any = Promise.any(promises);
let { ready, connectionType, address, rpcPeer } = await any;
if (tryWebrtc && connectionType !== 'webrtc')
localStorage.setItem(webrtcLastFailedKey, Date.now().toString());
console.log('connected', connectionType, address)
socket = ready;
@@ -692,7 +570,6 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
serializer.setupRpcPeer(rpcPeer);
}
setTimeout(() => flush.resolve(undefined), 0);
const scrypted = await attachPluginRemote(rpcPeer, undefined);
const {
serverVersion,
@@ -708,20 +585,7 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
return new MediaObject(mimeType, data, options) as any;
}
const { browserSignalingSession, connectionManagementId, updateSessionId } = rpcPeer.params;
if (updateSessionId && browserSignalingSession) {
systemManager.getComponent('plugins').then(async plugins => {
const updateSession: (session: RTCSignalingSession) => Promise<void> = await plugins.getHostParam('@scrypted/webrtc', updateSessionId);
if (!updateSession)
return;
await updateSession(browserSignalingSession);
console.log('signaling channel upgraded.');
socket.removeAllListeners();
socket.close();
});
}
const [admin, rtcConnectionManagement] = await Promise.all([
const [admin] = await Promise.all([
(async () => {
try {
// info is
@@ -732,18 +596,6 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
}
return false;
})(),
(async () => {
let rtcConnectionManagement: RTCConnectionManagement;
if (connectionManagementId) {
try {
const plugins = await systemManager.getComponent('plugins');
rtcConnectionManagement = await plugins.getHostParam('@scrypted/webrtc', connectionManagementId);
return rtcConnectionManagement;
}
catch (e) {
}
}
})(),
]);
console.log('api initialized', Date.now() - start);
@@ -753,62 +605,137 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
.find(device => device.pluginId === '@scrypted/core' && device.nativeId === `user:${username}`);
const clusterPeers = new Map<number, Promise<RpcPeer>>();
const ensureClusterPeer = (clusterObject: ClusterObject) => {
let clusterPeerPromise = clusterPeers.get(clusterObject.port);
if (!clusterPeerPromise) {
clusterPeerPromise = (async () => {
const eioPath = 'engine.io/connectRPCObject';
const eioEndpoint = baseUrl ? new URL(eioPath, baseUrl).pathname : '/' + eioPath;
const clusterPeerOptions = {
path: eioEndpoint,
query: {
cacehBust,
clusterObject: JSON.stringify(clusterObject),
},
withCredentials: true,
extraHeaders,
rejectUnauthorized: false,
transports: options?.transports,
};
const finalizationRegistry = new FinalizationRegistry((clusterPeer: RpcPeer) => {
clusterPeer.kill('object finalized');
});
const ensureClusterPeer = (clusterObject: ClusterObject, connectRPCObjectOptions?: ConnectRPCObjectOptions) => {
// If dedicatedTransport is true, don't reuse existing cluster peers
if (!connectRPCObjectOptions?.dedicatedTransport) {
let clusterPeerPromise = clusterPeers.get(clusterObject.port);
if (clusterPeerPromise)
return clusterPeerPromise;
}
const clusterPeerSocket = new eio.Socket(explicitBaseUrl, clusterPeerOptions);
let peerReady = false;
clusterPeerSocket.on('close', () => {
clusterPeers.delete(clusterObject.port);
if (!peerReady) {
throw new Error("peer disconnected before setup completed");
const clusterPeerPromise = (async () => {
const eioPath = 'engine.io/connectRPCObject';
const eioEndpoint = new URL(eioPath, address).pathname;
const eioQueryToken = connectionType === 'http' ? undefined : queryToken;
const clusterPeerOptions = {
path: eioEndpoint,
query: {
cacheBust,
clusterObject: JSON.stringify(clusterObject),
...eioQueryToken,
},
withCredentials: true,
extraHeaders,
rejectUnauthorized: false,
transports: options?.transports,
};
const clusterPeerSocket = new eio.Socket(address, clusterPeerOptions);
let peerReady = false;
// Timeout handling for dedicated transports
let receiveTimeout: NodeJS.Timeout | undefined;
let sendTimeout: NodeJS.Timeout | undefined;
let clusterPeer: RpcPeer | undefined;
const clearTimers = () => {
if (receiveTimeout) {
clearTimeout(receiveTimeout);
receiveTimeout = undefined;
}
if (sendTimeout) {
clearTimeout(sendTimeout);
sendTimeout = undefined;
}
};
const resetReceiveTimeout = connectRPCObjectOptions?.dedicatedTransport?.receiveTimeout ? () => {
if (receiveTimeout) {
clearTimeout(receiveTimeout);
}
receiveTimeout = setTimeout(() => {
if (clusterPeer) {
clusterPeer.kill('receive timeout');
}
}, connectRPCObjectOptions.dedicatedTransport.receiveTimeout);
} : undefined;
const resetSendTimeout = connectRPCObjectOptions?.dedicatedTransport?.sendTimeout ? () => {
if (sendTimeout) {
clearTimeout(sendTimeout);
}
sendTimeout = setTimeout(() => {
if (clusterPeer) {
clusterPeer.kill('send timeout');
}
}, connectRPCObjectOptions.dedicatedTransport.sendTimeout);
} : undefined;
clusterPeerSocket.on('close', () => {
clusterPeer?.kill('socket closed');
// Only remove from clusterPeers if it's not a dedicated transport
if (!connectRPCObjectOptions?.dedicatedTransport) {
clusterPeers.delete(clusterObject.port);
}
if (!peerReady) {
throw new Error("peer disconnected before setup completed");
}
});
try {
await once(clusterPeerSocket, 'open');
const serializer = createRpcDuplexSerializer({
write: data => {
resetSendTimeout?.();
clusterPeerSocket.send(data);
},
});
try {
await once(clusterPeerSocket, 'open');
clusterPeerSocket.on('message', data => {
resetReceiveTimeout?.();
serializer.onData(Buffer.from(data));
});
const serializer = createRpcDuplexSerializer({
write: data => clusterPeerSocket.send(data),
});
clusterPeerSocket.on('message', data => serializer.onData(Buffer.from(data)));
const clusterPeer = new RpcPeer(clientName || 'engine.io-client', "cluster-proxy", (message, reject, serializationContext) => {
try {
serializer.sendMessage(message, reject, serializationContext);
}
catch (e) {
reject?.(e as Error);
}
});
serializer.setupRpcPeer(clusterPeer);
clusterPeer.tags.localPort = sourcePeerId;
peerReady = true;
return clusterPeer;
}
catch (e) {
console.error('failure ipc connect', e);
clusterPeer = new RpcPeer(clientName || 'engine.io-client', "cluster-proxy", (message, reject, serializationContext) => {
try {
resetSendTimeout?.();
serializer.sendMessage(message, reject, serializationContext);
}
catch (e) {
reject?.(e as Error);
}
});
clusterPeer.killedSafe.finally(() => {
clearTimers();
clusterPeerSocket.close();
throw e;
}
})();
});
serializer.setupRpcPeer(clusterPeer);
clusterPeer.tags.localPort = sourcePeerId;
peerReady = true;
// Initialize timeouts if configured
resetReceiveTimeout?.();
resetSendTimeout?.();
return clusterPeer;
}
catch (e) {
clearTimers();
console.error('failure ipc connect', e);
clusterPeerSocket.close();
throw e;
}
})();
// Only store in clusterPeers if it's not a dedicated transport
if (!connectRPCObjectOptions?.dedicatedTransport) {
clusterPeers.set(clusterObject.port, clusterPeerPromise);
}
return clusterPeerPromise;
};
@@ -822,7 +749,7 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
return null;
}
const connectRPCObject = async (value: any) => {
const connectRPCObject = async (value: any, options?: ConnectRPCObjectOptions) => {
const clusterObject: ClusterObject = value?.__cluster;
if (!clusterObject) {
return value;
@@ -837,13 +764,29 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
}
try {
const clusterPeerPromise = ensureClusterPeer(clusterObject);
const clusterPeerPromise = ensureClusterPeer(clusterObject, options);
const clusterPeer = await clusterPeerPromise;
const connectRPCObject: ConnectRPCObject = await clusterPeer.getParam('connectRPCObject');
const newValue = await connectRPCObject(clusterObject);
if (!newValue)
throw new Error('ipc object not found?');
return newValue;
try {
const newValue = await connectRPCObject(clusterObject);
if (!newValue)
throw new Error('ipc object not found?');
// If dedicatedTransport is true, register the object for cleanup
if (options?.dedicatedTransport) {
finalizationRegistry.register(newValue, clusterPeer);
}
return newValue;
}
catch (e) {
// If we have a clusterPeer and this is a dedicated transport, kill the connection
// to prevent resource leaks when connectRPCObject fails
if (options?.dedicatedTransport) {
clusterPeer.kill('connectRPCObject failed');
}
throw e;
}
}
catch (e) {
console.error('failure ipc', e);
@@ -868,8 +811,6 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
rpcPeer.kill('disconnect requested');
},
pluginHostAPI: undefined,
rtcConnectionManagement,
browserSignalingSession,
rpcPeer,
loginResult: {
username,

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/core",
"version": "0.3.129",
"version": "0.3.135",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/core",
"version": "0.3.129",
"version": "0.3.135",
"license": "Apache-2.0",
"dependencies": {
"@scrypted/common": "file:../../common",
@@ -77,6 +77,7 @@
"license": "ISC",
"dependencies": {
"@scrypted/sdk": "file:../sdk",
"@scrypted/types": "^0.5.27",
"http-auth-utils": "^5.0.1",
"typescript": "^5.5.3"
},
@@ -88,28 +89,29 @@
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.3.100",
"version": "0.5.33",
"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": "^5.3.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": {
@@ -122,9 +124,9 @@
"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"
}
},
"node_modules/@scrypted/common": {
@@ -276,6 +278,7 @@
"version": "file:../../common",
"requires": {
"@scrypted/sdk": "file:../sdk",
"@scrypted/types": "^0.5.27",
"@types/node": "^20.11.0",
"http-auth-utils": "^5.0.1",
"monaco-editor": "^0.50.0",
@@ -286,28 +289,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": "^5.3.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"
}
},

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/core",
"version": "0.3.129",
"version": "0.3.135",
"description": "Scrypted Core plugin. Provides the UI, websocket, and engine.io APIs.",
"author": "Scrypted",
"license": "Apache-2.0",

View File

@@ -111,7 +111,7 @@ export class User extends ScryptedDeviceBase implements Settings, ScryptedUser {
const { username, admin } = user;
const nativeId = `user:${username}`;
const aclId = await sdk.deviceManager.onDeviceDiscovered({
providerNativeId: this.nativeId,
providerNativeId: UsersNativeId,
name: username.toString(),
nativeId,
interfaces: [

View File

@@ -1,4 +1,4 @@
{
"scrypted.debugHost": "koushik-winvm",
"scrypted.debugHost": "scrypted-nvr",
}

View File

@@ -1,19 +1,19 @@
{
"name": "@scrypted/diagnostics",
"version": "0.0.19",
"version": "0.0.21",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/diagnostics",
"version": "0.0.19",
"version": "0.0.21",
"dependencies": {
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"sharp": "^0.33.5"
},
"devDependencies": {
"@types/node": "^22.5.4"
"@types/node": "^22.18.8"
}
},
"../../common": {
@@ -22,32 +22,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.69",
"version": "0.5.48",
"license": "ISC",
"dependencies": {
"@babel/preset-typescript": "^7.26.0",
"@babel/preset-typescript": "^7.27.1",
"@rollup/plugin-commonjs": "^28.0.5",
"@rollup/plugin-json": "^6.1.0",
"@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.7",
"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.43.0",
"tmp": "^0.2.3",
"ts-loader": "^9.5.1",
"typescript": "^5.5.4",
"webpack": "^5.95.0",
"ts-loader": "^9.5.2",
"tslib": "^2.8.1",
"typescript": "^5.8.3",
"webpack": "^5.99.9",
"webpack-bundle-analyzer": "^4.10.2"
},
"bin": {
@@ -60,11 +69,9 @@
"scrypted-webpack": "bin/scrypted-webpack.js"
},
"devDependencies": {
"@types/node": "^22.8.1",
"@types/stringify-object": "^4.0.5",
"stringify-object": "^3.3.0",
"@types/node": "^24.0.1",
"ts-node": "^10.9.2",
"typedoc": "^0.26.10"
"typedoc": "^0.28.5"
}
},
"node_modules/@emnapi/runtime": {
@@ -427,12 +434,13 @@
"link": true
},
"node_modules/@types/node": {
"version": "22.5.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz",
"integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==",
"version": "22.18.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.8.tgz",
"integrity": "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
"undici-types": "~6.21.0"
}
},
"node_modules/color": {
@@ -549,10 +557,11 @@
"optional": true
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"dev": true
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
},
"dependencies": {
@@ -710,7 +719,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",
@@ -720,33 +730,39 @@
"@scrypted/sdk": {
"version": "file:../../sdk",
"requires": {
"@babel/preset-typescript": "^7.26.0",
"@types/node": "^22.8.1",
"@types/stringify-object": "^4.0.5",
"@babel/preset-typescript": "^7.27.1",
"@rollup/plugin-commonjs": "^28.0.5",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@rollup/plugin-virtual": "^3.0.2",
"@types/node": "^24.0.1",
"adm-zip": "^0.5.16",
"axios": "^1.7.7",
"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",
"stringify-object": "^3.3.0",
"rollup": "^4.43.0",
"tmp": "^0.2.3",
"ts-loader": "^9.5.1",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"typedoc": "^0.26.10",
"typescript": "^5.5.4",
"webpack": "^5.95.0",
"tslib": "^2.8.1",
"typedoc": "^0.28.5",
"typescript": "^5.8.3",
"webpack": "^5.99.9",
"webpack-bundle-analyzer": "^4.10.2"
}
},
"@types/node": {
"version": "22.5.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz",
"integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==",
"version": "22.18.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.8.tgz",
"integrity": "sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==",
"dev": true,
"requires": {
"undici-types": "~6.19.2"
"undici-types": "~6.21.0"
}
},
"color": {
@@ -839,9 +855,9 @@
"optional": true
},
"undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/diagnostics",
"version": "0.0.19",
"version": "0.0.21",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",
"prescrypted-setup-project": "scrypted-package-json",
@@ -32,6 +32,6 @@
"sharp": "^0.33.5"
},
"devDependencies": {
"@types/node": "^22.5.4"
"@types/node": "^22.18.8"
}
}

View File

@@ -331,6 +331,26 @@ class DiagnosticsPlugin extends ScryptedDeviceBase implements Settings {
timeout: 5000,
}).then(r => r.body.trim()));
await this.validate(this.console, 'System Time Accuracy', async () => {
const response = await httpFetch({
url: 'https://cloudflare.com',
responseType: 'text',
timeout: 10000,
});
const dateHeader = response.headers.get('date');
if (!dateHeader) {
throw new Error('No date header in response');
}
const serverTime = new Date(dateHeader).getTime(); const localTime = Date.now();
const difference = Math.abs(serverTime - localTime);
const differenceSeconds = Math.floor(difference / 1000);
if (differenceSeconds > 5) {
throw new Error(`Time drift detected: ${differenceSeconds} seconds difference from accurate time source.`);
}
});
await this.validate(this.console, 'IPv6 (wtfismyip.com)', httpFetch({
url: 'https://wtfismyip.com/text',
family: 6,
@@ -338,6 +358,30 @@ class DiagnosticsPlugin extends ScryptedDeviceBase implements Settings {
timeout: 5000,
}).then(r => r.body.trim()));
await this.validate(this.console, 'Scrypted Cloud Services', async () => {
const endpoints = [
'https://home.scrypted.app',
'https://billing.scrypted.app'
];
for (const endpoint of endpoints) {
try {
const response = await httpFetch({
url: endpoint,
timeout: 5000,
});
if (response.statusCode >= 400) {
throw new Error(`${endpoint} returned status ${response.statusCode}`);
}
} catch (error) {
throw new Error(`${endpoint} is not accessible: ${(error as Error).message}`);
}
}
return 'Both endpoints accessible';
});
await this.validate(this.console, 'Scrypted Server Address', async () => {
const addresses = await sdk.endpointManager.getLocalAddresses();
const hasIPv4 = addresses?.find(address => net.isIPv4(address));

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/doorbird",
"version": "0.0.4",
"version": "0.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/doorbird",
"version": "0.0.4",
"version": "0.0.6",
"dependencies": {
"doorbird": "2.6.0"
},
@@ -24,6 +24,7 @@
"license": "ISC",
"dependencies": {
"@scrypted/sdk": "file:../sdk",
"@scrypted/types": "^0.5.27",
"http-auth-utils": "^5.0.1",
"typescript": "^5.5.3"
},
@@ -35,29 +36,30 @@
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.3.108",
"version": "0.5.33",
"dev": true,
"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": "^5.3.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": {
@@ -70,9 +72,9 @@
"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"
}
},
"node_modules/@scrypted/common": {

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/doorbird",
"version": "0.0.4",
"version": "0.0.6",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",
"prescrypted-setup-project": "scrypted-package-json",

View File

@@ -1,15 +1,35 @@
import { authHttpFetch } from "@scrypted/common/src/http-auth-fetch";
import { listenZero } from '@scrypted/common/src/listen-cluster';
import { ffmpegLogInitialOutput, safePrintFFmpegArguments } from "@scrypted/common/src/media-helpers";
import { readLength } from "@scrypted/common/src/read-stream";
import sdk, { BinarySensor, Camera, DeviceCreator, DeviceCreatorSettings, DeviceInformation, DeviceProvider, FFmpegInput, Intercom, MediaObject, MotionSensor, PictureOptions, ResponseMediaStreamOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, VideoCamera } from '@scrypted/sdk';
import child_process, { ChildProcess } from 'child_process';
import { randomBytes } from 'crypto';
import {httpFetch} from '../../../server/src/fetch/http-fetch';
import {listenZero} from '@scrypted/common/src/listen-cluster';
import {ffmpegLogInitialOutput, safePrintFFmpegArguments} from "@scrypted/common/src/media-helpers";
import {readLength, StreamEndError} from "@scrypted/common/src/read-stream";
import sdk, {
BinarySensor,
Camera,
DeviceCreator,
DeviceCreatorSettings,
DeviceInformation,
DeviceProvider,
FFmpegInput,
Intercom,
MediaObject,
MotionSensor,
PictureOptions,
ResponseMediaStreamOptions,
ScryptedDeviceBase,
ScryptedDeviceType,
ScryptedInterface,
ScryptedMimeTypes,
Setting,
Settings,
VideoCamera
} from '@scrypted/sdk';
import child_process, {ChildProcess} from 'child_process';
import {randomBytes} from 'crypto';
import net from 'net';
import { PassThrough, Readable } from "stream";
import { ApiMotionEvent, ApiRingEvent, DoorbirdAPI } from "./doorbird-api";
import {PassThrough, Readable} from "stream";
import {ApiMotionEvent, ApiRingEvent, DoorbirdAPI} from "./doorbird-api";
const { deviceManager, mediaManager } = sdk;
const {deviceManager, mediaManager} = sdk;
class DoorbirdCamera extends ScryptedDeviceBase implements Intercom, Camera, VideoCamera, Settings, BinarySensor, MotionSensor {
doorbirdApi: DoorbirdAPI | undefined;
@@ -22,6 +42,8 @@ class DoorbirdCamera extends ScryptedDeviceBase implements Intercom, Camera, Vid
audioRXClientSocket: net.Socket;
pendingPicture: Promise<MediaObject>;
private static readonly TRANSMIT_AUDIO_CHUNK_SIZE: number = 256;
constructor(nativeId: string, public provider: DoorbirdCamProvider) {
super(nativeId);
this.binaryState = false;
@@ -89,7 +111,7 @@ class DoorbirdCamera extends ScryptedDeviceBase implements Intercom, Camera, Vid
public async getPictureOptions(): Promise<PictureOptions[]> {
return [{
id: 'VGA',
picture: { width: 640, height: 480 }
picture: {width: 640, height: 480}
}];
}
@@ -141,6 +163,22 @@ class DoorbirdCamera extends ScryptedDeviceBase implements Intercom, Camera, Vid
placeholder: 'rtsp://192.168.2.100/my_doorbird_video_stream',
value: this.storage.getItem('rtspUrl'),
description: 'Use this in case you are already using another RTSP server/proxy (e.g. mediamtx, go2rtc, etc.) to limit the number of streams from the camera.',
},
{
key: 'audioDenoise',
type: 'boolean',
subgroup: 'Advanced',
title: 'Denoise',
value: this.storage.getItem('audioDenoise') === 'true',
description: 'Denoise both input and output audio streams to reduce background noises.',
},
{
key: 'audioSpeechEnhancement',
type: 'boolean',
subgroup: 'Advanced',
title: 'Speech Enhancement',
value: this.storage.getItem('audioSpeechEnhancement') === 'true',
description: 'Apply band filtering and dynamic normalization to both audio streams.',
}
];
}
@@ -159,25 +197,47 @@ class DoorbirdCamera extends ScryptedDeviceBase implements Intercom, Camera, Vid
}
async startAudioTransmitter(media: MediaObject): Promise<void> {
this.console.log('Doorbird: Init audio transmitter...');
const ffmpegInput: FFmpegInput = JSON.parse((await mediaManager.convertMediaObjectToBuffer(media, ScryptedMimeTypes.FFmpegInput)).toString());
const ffmpegArgs = ffmpegInput.inputArguments.slice();
ffmpegArgs.push(
'-vn', '-dn', '-sn',
// Do not process video streams (disable video)
'-vn',
// Do not process data streams (e.g. timed metadata)
'-dn',
// Do not process subtitle streams
'-sn',
// Encode audio using PCM µ-law (G.711 codec, 8-bit logarithmic compression)
'-acodec', 'pcm_mulaw',
'-flags', '+global_header',
// Bypass internal I/O buffering (write directly to output)
"-avioflags", "direct",
// Disable input buffering
'-fflags', '+flush_packets+nobuffer',
// Force flushing packets after every frame
'-flush_packets', '1',
// Use global headers (required by some muxers) and enable low-latency flags
'-flags', '+global_header+low_delay',
// Set number of audio channels to mono
'-ac', '1',
'-ar', '8k',
// Set audio sample rate to 8000 Hz (expected by Doorbird)
'-ar', '8000',
// Force raw µ-law output format (no container)
'-f', 'mulaw',
// Do not buffer or delay packets in the muxer
'-muxdelay', '0',
// --- Audio Filtering ---
...(this.getAudioFilter()),
// Output to file descriptor 3 (e.g. pipe:3, for inter-process communication)
'pipe:3'
);
safePrintFFmpegArguments(console, ffmpegArgs);
safePrintFFmpegArguments(this.console, ffmpegArgs);
const cp = child_process.spawn(await mediaManager.getFFmpegPath(), ffmpegArgs, {
stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
});
this.audioTXProcess = cp;
ffmpegLogInitialOutput(console, cp);
ffmpegLogInitialOutput(this.console, cp);
cp.on('exit', () => this.console.log('Doorbird: Audio transmitter ended.'));
cp.stdout.on('data', data => this.console.log(data.toString()));
cp.stderr.on('data', data => this.console.log(data.toString()));
@@ -188,43 +248,60 @@ class DoorbirdCamera extends ScryptedDeviceBase implements Intercom, Camera, Vid
const password: string = this.getPassword();
const audioTxUrl: string = `${this.getHttpBaseAddress()}/bha-api/audio-transmit.cgi`;
this.console.log('Doorbird: Starting audio transmitter...');
(async () => {
this.console.log('Doorbird: audio transmitter started.');
this.console.log('Doorbird: Audio transmitter started.');
const passthrough = new PassThrough();
authHttpFetch({
method: 'POST',
url: audioTxUrl,
credential: {
username,
password,
},
headers: {
'Content-Type': 'audio/basic',
'Content-Length': '9999999'
},
data: passthrough,
});
const abortController = new AbortController();
let totalBytesWritten: number = 0;
try {
while (true) {
const data = await readLength(socket, 1024);
passthrough.push(data);
}
}
catch (e) {
}
finally {
this.console.log('Doorbird: audio transmitter finished.');
passthrough.end();
}
// Perform POST request instantly instead of unneeded handling with DIGEST authentication.
// Credentials will be thrown into network by all other requests anyway.
httpFetch({
url: audioTxUrl,
method: 'POST',
headers: {
'Content-Type': 'audio/basic',
'Content-Length': '9999999',
'Authorization': this.getBasicAuthorization(username, password),
},
signal: abortController.signal,
body: passthrough,
responseType: 'readable',
})
this.stopAudioTransmitter();
while (true) { // Loop will be broken by StreamEndError.
// Read the next chunk of audio data from the Doorbird camera.
const data = await readLength(socket, DoorbirdCamera.TRANSMIT_AUDIO_CHUNK_SIZE);
if (data.length === 0) {
break;
}
// Actually write the data to the passthrough stream.
passthrough.push(data);
// Add the length of the data to the total bytes written.
totalBytesWritten += data.length;
}
} catch (e) {
if (!(e instanceof StreamEndError)) {
this.console.error('Doorbird: Audio transmitter error', e);
}
} finally {
this.console.log(`Doorbird: Audio transmitter finished. bytesOut=${totalBytesWritten}ms`);
passthrough.destroy();
abortController.abort();
}
this.stopIntercom();
})();
}
private getBasicAuthorization(username: string, password: string) {
return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
}
stopAudioTransmitter() {
this.audioTXProcess?.kill('SIGKILL');
this.audioTXProcess = undefined;
@@ -238,18 +315,53 @@ class DoorbirdCamera extends ScryptedDeviceBase implements Intercom, Camera, Vid
const ffmpegPath = await mediaManager.getFFmpegPath();
const audioFilters = this.getAudioFilter();
const ffmpegArgs = [
// Suppress printing the FFmpeg banner. Keeps logs clean.
'-hide_banner',
// Disable periodic progress/statistics logging. Reduces noise and CPU usage.
'-nostats',
// --- Low-latency Input Flags ---
// Reduce input buffer latency by flushing packets immediately and disabling demuxer buffering.
'-fflags', '+flush_packets+nobuffer',
// Do not spend time analyzing the stream to determine properties. Crucial for live streams.
'-analyzeduration', '0',
// Set a very small probe size to speed up initial connection, as we already know the format.
'-probesize', '32',
// Read input at its native frame rate to ensure real-time processing.
'-re',
// --- Input Format Specification ---
// Set the audio sample rate to 8000 Hz, matching the Doorbird's stream.
'-ar', '8000',
// Set the number of audio channels to 1 (mono).
'-ac', '1',
// Force the input format to be interpreted as G.711 µ-law.
'-f', 'mulaw',
// Specify the input URL for the Doorbird's audio stream.
'-i', `${audioRxUrl}`,
'-acodec', 'copy',
// --- Audio Filtering ---
...(audioFilters),
// --- Low-latency Output Flags ---
// Enable low-delay flags in the encoder, preventing frame buffering for lookahead.
'-flags', '+global_header+low_delay',
// Bypass FFmpeg's internal I/O buffering, writing directly to the output pipe.
'-avioflags', 'direct',
// Force flushing packets to the output immediately after encoding.
'-flush_packets', '1',
// Set the maximum demux-decode delay to zero, preventing buffering in the muxer.
'-muxdelay', '0',
// --- Output Format Specification ---
// Re-encode the audio to PCM µ-law after the filter has been applied, or just copy it if no filters are applied.
'-acodec', (audioFilters.length > 0 ? 'pcm_mulaw' : 'copy'),
// Force the output container format to raw µ-law.
'-f', 'mulaw',
// Output the processed audio to file descriptor 3 (the pipe).
'pipe:3'
];
@@ -262,7 +374,7 @@ class DoorbirdCamera extends ScryptedDeviceBase implements Intercom, Camera, Vid
cp.on('exit', () => {
this.console.log('Doorbird: audio receiver ended.')
this.audioRXProcess = undefined;
this.stopIntercom();
});
cp.stdout.on('data', data => this.console.log(data.toString()));
cp.stderr.on('data', data => this.console.log(data.toString()));
@@ -298,24 +410,60 @@ class DoorbirdCamera extends ScryptedDeviceBase implements Intercom, Camera, Vid
async getVideoStream(options?: ResponseMediaStreamOptions): Promise<MediaObject> {
const port = await this.startAudioRXServer();
const audioRtspStreamPort = await this.startAudioRXServer();
const ffmpegInput: FFmpegInput = {
url: undefined,
inputArguments: [
// --- Low-latency Input Flags (for both streams) ---
// Suppress printing the FFmpeg banner.
'-hide_banner',
// Disable periodic progress/statistics logging.
'-nostats',
// Set the log level to 'error' to suppress verbose informational messages.
'-loglevel', 'error',
// Reduce input buffer latency by flushing packets immediately and disabling demuxer buffering.
// '+nobuffer' is particularly important for live streams.
'-fflags', '+flush_packets+nobuffer',
// Do not spend time analyzing the stream to determine properties. Crucial for live streams.
'-analyzeduration', '0',
// Set a very small probe size to speed up initial connection, as we know the formats.
'-probesize', '32',
'-fflags', 'nobuffer',
// Request low-delay flags from decoders.
'-flags', 'low_delay',
// --- Video Input (Input 0) ---
// Force the input format to be interpreted as RTSP.
'-f', 'rtsp',
// Use TCP for RTSP transport for better reliability over potentially lossy networks.
'-rtsp_transport', 'tcp',
// Specify the input URL for the Doorbird's RTSP video stream.
'-i', `${this.getRtspAddress()}`,
// --- Audio Input (Input 1) ---
// Force the format of the second input to be interpreted as G.711 µ-law.
'-f', 'mulaw',
// Set the number of audio channels to 1 (mono) for the audio input.
'-ac', '1',
// Set the audio sample rate to 8000 Hz for the audio input.
'-ar', '8000',
// Explicitly define the channel layout as mono.
'-channel_layout', 'mono',
'-use_wallclock_as_timestamps', 'true',
'-i', `tcp://127.0.0.1:${port}?tcp_nodelay=1`,
// Use the system's wall clock for timestamps. This helps synchronize the separate audio
// and video streams, which do not share a common clock source.
'-use_wallclock_as_timestamps', '1',
// Specify the second input as the local TCP socket providing the audio stream.
// `tcp_nodelay=1` disables Nagle's algorithm, reducing latency for small packets.
'-i', `tcp://127.0.0.1:${audioRtspStreamPort}?tcp_nodelay=1`,
// --- Output Stream Handling ---
// Increase the maximum delay for the muxing queue to 5 seconds (in microseconds).
// This prevents the "Delay between the first packet and last packet" error
// by allowing more time for packets from different streams to arrive.
'-max_delay', '5000000',
// Finish encoding when the shortest input stream (the video) ends.
// This ensures ffmpeg terminates if the video stream is interrupted by Doorbird.
'-shortest',
],
mediaStreamOptions: options,
};
@@ -332,12 +480,29 @@ class DoorbirdCamera extends ScryptedDeviceBase implements Intercom, Camera, Vid
const ffmpegPath = await mediaManager.getFFmpegPath();
const ffmpegArgs = [
// Suppress printing the FFmpeg banner.
'-hide_banner',
// Disable periodic progress/statistics logging.
'-nostats',
// Read input at its native frame rate to ensure real-time processing.
'-re',
// Use the lavfi (libavfilter) virtual input device.
'-f', 'lavfi',
// Specify the input source as a null audio source (silence) with a sample rate of 8000 Hz and mono channel layout.
'-i', 'anullsrc=r=8000:cl=mono',
// --- Low-latency Output Flags ---
// Bypass FFmpeg's internal I/O buffering, writing directly to the output pipe.
'-avioflags', 'direct',
// Force flushing packets to the output immediately after encoding.
'-flush_packets', '1',
// Set the maximum demux-decode delay to zero, preventing buffering in the muxer.
'-muxdelay', '0',
// --- Output Format Specification ---
// Force the output container format to raw µ-law.
'-f', 'mulaw',
// Output the processed audio to file descriptor 3 (the pipe).
'pipe:3'
];
@@ -370,6 +535,7 @@ class DoorbirdCamera extends ScryptedDeviceBase implements Intercom, Camera, Vid
const server = net.createServer(async (clientSocket) => {
clearTimeout(serverTimeout);
this.console.log(`Doorbird: audio connection from client ${JSON.stringify(clientSocket.address())}`);
this.audioRXClientSocket = clientSocket;
@@ -379,13 +545,14 @@ class DoorbirdCamera extends ScryptedDeviceBase implements Intercom, Camera, Vid
this.stopSilenceGenerator();
this.audioRXClientSocket = null;
});
});
const serverTimeout = setTimeout(() => {
this.console.log('Doorbird: timed out waiting for tcp client from ffmpeg');
server.close();
}, 30000);
const port = await listenZero(server, '127.0.0.1');
this.console.log(`Doorbird: audio server started on port ${port}`);
return port;
}
@@ -412,8 +579,7 @@ class DoorbirdCamera extends ScryptedDeviceBase implements Intercom, Camera, Vid
getRtspAddress() {
if (this.storage.getItem('rtspUrl') !== undefined) {
return this.storage.getItem('rtspUrl');
}
else {
} else {
return this.getRtspDefaultAddress();
}
}
@@ -437,6 +603,52 @@ class DoorbirdCamera extends ScryptedDeviceBase implements Intercom, Camera, Vid
getPassword() {
return this.storage.getItem('password');
}
setAudioDenoise(enabled: boolean) {
this.storage.setItem('audioDenoise', enabled.toString());
}
getAudioDenoise(): boolean {
return this.storage.getItem('audioDenoise') === 'true';
}
setAudioSpeechEnhancement(enabled: boolean) {
this.storage.setItem('audioSpeechEnhancement', enabled.toString());
}
getAudioSpeechEnhancement(): boolean {
return this.storage.getItem('audioSpeechEnhancement') === 'true';
}
private getAudioFilter() {
const filters: string[] = [];
if (this.getAudioDenoise()) {
// Apply noise reduction using the 'afftdn' filter.
// - 'afftdn=nf=-50' removes background noise below -50 dB (e.g. hiss, hum)
// - 'agate=threshold=0.06:attack=20:release=250' gates quiet sounds:
// threshold=0.06 → suppresses signals below ~-24 dBFS (breaths, room noise)
// attack=20 → gate opens smoothly in 20 ms to preserve speech onset
// release=250 → gate closes slowly in 250 ms to avoid cutting word ends
filters.push('afftdn=nf=-50', 'agate=threshold=0.06:attack=20:release=250');
}
if (this.getAudioSpeechEnhancement()) {
// Apply high-pass and low-pass filters to remove frequencies outside the human voice range and apply dynamic normalization.
// - 'highpass=f=200' → removes low rumbles below 200 Hz (e.g. touch intercom while speaking, low street noise)
// - 'lowpass=f=3000' → removes harsh highs above 3 kHz to reduce hiss/sibilance
// - 'acompressor=threshold=0.1:ratio=4:attack=20:release=200'
// threshold=0.1 → starts compressing above ~-20 dBFS
// ratio=4 → reduces dynamic range by a 4:1 ratio
// attack=20 → begins compression quickly to catch loud speech
// release=200 → smooths out gain after loud parts
// - 'volume=4' → boosts output gain 4x after compression
filters.push('highpass=f=200', 'lowpass=f=3000', 'acompressor=threshold=0.1:ratio=4:attack=20:release=200', 'volume=4');
}
if (filters.length === 0) {
return [];
}
return ['-af', filters.join(',')];
}
}
export class DoorbirdCamProvider extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
@@ -472,8 +684,7 @@ export class DoorbirdCamProvider extends ScryptedDeviceBase implements DevicePro
info.mac = deviceInfo.serialNumber;
info.manufacturer = 'Bird Home Automation GmbH';
info.managementUrl = 'https://webadmin.doorbird.com';
}
catch (e) {
} catch (e) {
this.console.error('Error adding Doorbird camera', e);
throw e;
}
@@ -490,6 +701,8 @@ export class DoorbirdCamProvider extends ScryptedDeviceBase implements DevicePro
device.putSetting('password', password);
device.setIPAddress(settings.ip.toString());
device.setHttpPortOverride(settings.httpPort?.toString());
device.setAudioDenoise(settings.audioDenoise === 'true');
device.setAudioSpeechEnhancement(settings.audioSpeechEnhancement === 'true');
return nativeId;
}

View File

@@ -32,11 +32,11 @@ export function syncResponse(device: ScryptedDevice, type: string): homegraph_v1
defaultNames: [],
nicknames: [],
},
otherDeviceIds: [
otherDeviceIds: (device.type !== ScryptedDeviceType.Camera && device.type !== ScryptedDeviceType.Doorbell) ? [
{
deviceId: device.id,
}
],
] : undefined,
attributes: {},
traits: [],
type,

View File

@@ -8,6 +8,18 @@ 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.
### Firmware Requirements
This version **requires firmware v3.7 or higher**. Older firmware versions are not supported.
## 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.2",
"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/**/*"

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/hikvision",
"version": "0.0.165",
"version": "0.0.166",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/hikvision",
"version": "0.0.165",
"version": "0.0.166",
"license": "Apache",
"dependencies": {
"@scrypted/common": "file:../../common",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/hikvision",
"version": "0.0.165",
"version": "0.0.166",
"description": "Hikvision Plugin for Scrypted",
"author": "Scrypted",
"license": "Apache",

View File

@@ -20,7 +20,7 @@
"@scrypted/sdk": "file:../../sdk",
"@types/debug": "^4.1.12",
"@types/lodash": "^4.17.7",
"@types/node": "^20.14.11",
"@types/node": "^22.18.0",
"@types/qrcode-svg": "^1.1.5",
"@types/url-parse": "^1.4.11"
}
@@ -32,6 +32,7 @@
"license": "ISC",
"dependencies": {
"@scrypted/sdk": "file:../sdk",
"@scrypted/types": "^0.5.27",
"http-auth-utils": "^5.0.1",
"typescript": "^5.5.3"
},
@@ -49,23 +50,21 @@
"examples/*"
],
"devDependencies": {
"@biomejs/biome": "^1.4.1",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.6",
"jest": "^29.7.0",
"knip": "^3.9.0",
"node-actionlint": "^1.2.2",
"@biomejs/biome": "1.9.4",
"@types/node": "^22.13.4",
"knip": "^5.44.1",
"npm-run-all2": "^7.0.2",
"organize-imports-cli": "^0.10.0",
"process": "^0.11.10",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typedoc": "0.25.5",
"typedoc-plugin-markdown": "3.17.1",
"typescript": "5.3.3"
"tsx": "^4.19.3",
"typedoc": "0.27.7",
"typedoc-plugin-markdown": "4.4.2",
"typescript": "5.7.3",
"vitest": "3.0.5"
},
"engines": {
"node": ">=16"
"node": ">=18"
}
},
"../../external/werift/packages/webrtc": {
@@ -123,24 +122,31 @@
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.3.45",
"version": "0.5.38",
"dev": true,
"license": "ISC",
"dependencies": {
"@babel/preset-typescript": "^7.18.6",
"adm-zip": "^0.4.13",
"axios": "^1.6.5",
"babel-loader": "^9.1.0",
"babel-plugin-const-enum": "^1.1.0",
"esbuild": "^0.15.9",
"@babel/preset-typescript": "^7.27.1",
"@rollup/plugin-commonjs": "^28.0.5",
"@rollup/plugin-json": "^6.1.0",
"@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.10.0",
"babel-loader": "^10.0.0",
"babel-plugin-const-enum": "^1.2.0",
"ncp": "^2.0.0",
"openai": "^5.3.0",
"raw-loader": "^4.0.2",
"rimraf": "^3.0.2",
"tmp": "^0.2.1",
"ts-loader": "^9.4.2",
"typescript": "^4.9.4",
"webpack": "^5.75.0",
"webpack-bundle-analyzer": "^4.5.0"
"rimraf": "^6.0.1",
"rollup": "^4.43.0",
"tmp": "^0.2.3",
"ts-loader": "^9.5.2",
"tslib": "^2.8.1",
"typescript": "^5.8.3",
"webpack": "^5.99.9",
"webpack-bundle-analyzer": "^4.10.2"
},
"bin": {
"scrypted-changelog": "bin/scrypted-changelog.js",
@@ -152,11 +158,9 @@
"scrypted-webpack": "bin/scrypted-webpack.js"
},
"devDependencies": {
"@types/node": "^18.11.18",
"@types/stringify-object": "^4.0.0",
"stringify-object": "^3.3.0",
"ts-node": "^10.4.0",
"typedoc": "^0.23.21"
"@types/node": "^24.0.1",
"ts-node": "^10.9.2",
"typedoc": "^0.28.5"
}
},
"../HAP-NodeJS": {
@@ -271,12 +275,13 @@
"dev": true
},
"node_modules/@types/node": {
"version": "20.14.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz",
"integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==",
"version": "22.18.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz",
"integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qrcode-svg": {
@@ -340,6 +345,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/check-disk-space": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.4.0.tgz",
@@ -438,18 +456,30 @@
"node": ">=6"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/duplexer": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
},
"node_modules/es-define-property": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
"integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
"dependencies": {
"get-intrinsic": "^1.2.4"
},
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
@@ -481,6 +511,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/event-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/event-stream/-/event-stream-4.0.1.tgz",
@@ -538,15 +580,21 @@
}
},
"node_modules/get-intrinsic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
"integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"has-proto": "^1.0.1",
"has-symbols": "^1.0.3",
"hasown": "^2.0.0"
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
@@ -555,12 +603,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gopd": {
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
"integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"get-intrinsic": "^1.1.3"
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -666,21 +728,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz",
"integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
@@ -937,6 +989,15 @@
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz",
"integrity": "sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ=="
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -1210,10 +1271,11 @@
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/which-boxed-primitive": {
"version": "1.0.2",
@@ -1311,20 +1373,18 @@
"@koush/werift-src": {
"version": "file:../../external/werift",
"requires": {
"@biomejs/biome": "^1.4.1",
"@types/jest": "^29.5.11",
"@types/node": "^20.10.6",
"jest": "^29.7.0",
"knip": "^3.9.0",
"node-actionlint": "^1.2.2",
"@biomejs/biome": "1.9.4",
"@types/node": "^22.13.4",
"knip": "^5.44.1",
"npm-run-all2": "^7.0.2",
"organize-imports-cli": "^0.10.0",
"process": "^0.11.10",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"typedoc": "0.25.5",
"typedoc-plugin-markdown": "3.17.1",
"typescript": "5.3.3"
"tsx": "^4.19.3",
"typedoc": "0.27.7",
"typedoc-plugin-markdown": "4.4.2",
"typescript": "5.7.3",
"vitest": "3.0.5"
}
},
"@leichtgewicht/ip-codec": {
@@ -1336,6 +1396,7 @@
"version": "file:../../common",
"requires": {
"@scrypted/sdk": "file:../sdk",
"@scrypted/types": "^0.5.27",
"@types/node": "^20.11.0",
"http-auth-utils": "^5.0.1",
"monaco-editor": "^0.50.0",
@@ -1346,25 +1407,30 @@
"@scrypted/sdk": {
"version": "file:../../sdk",
"requires": {
"@babel/preset-typescript": "^7.18.6",
"@types/node": "^18.11.18",
"@types/stringify-object": "^4.0.0",
"adm-zip": "^0.4.13",
"axios": "^1.6.5",
"babel-loader": "^9.1.0",
"babel-plugin-const-enum": "^1.1.0",
"esbuild": "^0.15.9",
"@babel/preset-typescript": "^7.27.1",
"@rollup/plugin-commonjs": "^28.0.5",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@rollup/plugin-virtual": "^3.0.2",
"@types/node": "^24.0.1",
"adm-zip": "^0.5.16",
"axios": "^1.10.0",
"babel-loader": "^10.0.0",
"babel-plugin-const-enum": "^1.2.0",
"ncp": "^2.0.0",
"openai": "^5.3.0",
"raw-loader": "^4.0.2",
"rimraf": "^3.0.2",
"stringify-object": "^3.3.0",
"tmp": "^0.2.1",
"ts-loader": "^9.4.2",
"ts-node": "^10.4.0",
"typedoc": "^0.23.21",
"typescript": "^4.9.4",
"webpack": "^5.75.0",
"webpack-bundle-analyzer": "^4.5.0"
"rimraf": "^6.0.1",
"rollup": "^4.43.0",
"tmp": "^0.2.3",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tslib": "^2.8.1",
"typedoc": "^0.28.5",
"typescript": "^5.8.3",
"webpack": "^5.99.9",
"webpack-bundle-analyzer": "^4.10.2"
}
},
"@types/debug": {
@@ -1389,12 +1455,12 @@
"dev": true
},
"@types/node": {
"version": "20.14.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz",
"integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==",
"version": "22.18.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz",
"integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==",
"dev": true,
"requires": {
"undici-types": "~5.26.4"
"undici-types": "~6.21.0"
}
},
"@types/qrcode-svg": {
@@ -1440,6 +1506,15 @@
"set-function-length": "^1.2.1"
}
},
"call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"requires": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
}
},
"check-disk-space": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.4.0.tgz",
@@ -1506,18 +1581,25 @@
"@leichtgewicht/ip-codec": "^2.0.1"
}
},
"dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"requires": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
}
},
"duplexer": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz",
"integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg=="
},
"es-define-property": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
"integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==",
"requires": {
"get-intrinsic": "^1.2.4"
}
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
},
"es-errors": {
"version": "1.3.0",
@@ -1540,6 +1622,14 @@
"stop-iteration-iterator": "^1.0.0"
}
},
"es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"requires": {
"es-errors": "^1.3.0"
}
},
"event-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/event-stream/-/event-stream-4.0.1.tgz",
@@ -1588,24 +1678,35 @@
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="
},
"get-intrinsic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
"integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"requires": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"has-proto": "^1.0.1",
"has-symbols": "^1.0.3",
"hasown": "^2.0.0"
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
}
},
"get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"requires": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
}
},
"gopd": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
"integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==",
"requires": {
"get-intrinsic": "^1.1.3"
}
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
},
"hap-nodejs": {
"version": "1.1.0",
@@ -1688,15 +1789,10 @@
"es-define-property": "^1.0.0"
}
},
"has-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz",
"integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg=="
},
"has-symbols": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A=="
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
},
"has-tostringtag": {
"version": "1.0.2",
@@ -1857,6 +1953,11 @@
"resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz",
"integrity": "sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ=="
},
"math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
},
"minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -2049,9 +2150,9 @@
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="
},
"undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true
},
"which-boxed-primitive": {

View File

@@ -48,7 +48,7 @@
"@scrypted/sdk": "file:../../sdk",
"@types/debug": "^4.1.12",
"@types/lodash": "^4.17.7",
"@types/node": "^20.14.11",
"@types/node": "^22.18.0",
"@types/qrcode-svg": "^1.1.5",
"@types/url-parse": "^1.4.11"
}

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"module": "commonjs",
"module": "Node16",
"target": "ES2021",
"resolveJsonModule": true,
"moduleResolution": "Node16",

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/objectdetector",
"version": "0.1.72",
"version": "0.1.73",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/objectdetector",
"version": "0.1.72",
"version": "0.1.73",
"license": "Apache-2.0",
"dependencies": {
"@scrypted/common": "file:../../common",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/objectdetector",
"version": "0.1.72",
"version": "0.1.73",
"description": "Scrypted Video Analysis Plugin. Installed alongside a detection service like OpenCV or TensorFlow.",
"author": "Scrypted",
"license": "Apache-2.0",

View File

@@ -56,10 +56,11 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
zones: {
title: 'Zones',
type: 'string',
description: 'Enter the name of a new zone or delete an existing zone.',
description: 'Add a new zone by name to create a configurable detection area.',
multiple: true,
combobox: true,
choices: [],
immediate: true,
},
motionSensorSupplementation: {
title: 'Built-In Motion Sensor',

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/openvino",
"version": "0.1.185",
"version": "0.1.188",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/openvino",
"version": "0.1.185",
"version": "0.1.188",
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
}

View File

@@ -50,5 +50,5 @@
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
},
"version": "0.1.185"
"version": "0.1.188"
}

View File

@@ -8,7 +8,7 @@ import traceback
from typing import Any, Tuple
import numpy as np
import openvino.runtime as ov
import openvino as ov
import scrypted_sdk
from PIL import Image
from scrypted_sdk.other import SettingValue
@@ -38,6 +38,7 @@ prepareExecutor = concurrent.futures.ThreadPoolExecutor(
availableModels = [
"Default",
"scrypted_yolov9c_relu_int8_320",
"scrypted_yolov9m_relu_int8_320",
"scrypted_yolov9s_relu_int8_320",
"scrypted_yolov9t_relu_int8_320",
"scrypted_yolov9c_int8_320",

View File

@@ -4,7 +4,7 @@ import asyncio
from typing import Any
import numpy as np
import openvino.runtime as ov
import openvino as ov
from PIL import Image
from ov import async_infer

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import numpy as np
import openvino.runtime as ov
import openvino as ov
from PIL import Image
from ov import async_infer

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import numpy as np
import openvino.runtime as ov
import openvino as ov
from PIL import Image
from ov import async_infer

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import asyncio
import numpy as np
import openvino.runtime as ov
import openvino as ov
from ov import async_infer
from predict.text_recognize import TextRecognition

View File

@@ -1,3 +1,4 @@
# openvino 2025.3.0 is failing to load on 9700, this may be because models need to be reexported.
# openvino 2025.0.0 does not detect CPU on 13500H
# openvino 2024.5.0 crashes NPU. Update: NPU can not be used with AUTO in this version
# openvino 2024.4.0 crashes legacy systems.

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/prebuffer-mixin",
"version": "0.10.59",
"version": "0.10.61",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/prebuffer-mixin",
"version": "0.10.59",
"version": "0.10.61",
"license": "Apache-2.0",
"dependencies": {
"@scrypted/common": "file:../../common",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/prebuffer-mixin",
"version": "0.10.59",
"version": "0.10.61",
"description": "Video Stream Rebroadcast, Prebuffer, and Management Plugin for Scrypted.",
"author": "Scrypted",
"license": "Apache-2.0",

View File

@@ -1,12 +1,12 @@
import { AutoenableMixinProvider } from '@scrypted/common/src/autoenable-mixin-provider';
import { ListenZeroSingleClientTimeoutError, closeQuiet, listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
import { readLength } from '@scrypted/common/src/read-stream';
import { H264_NAL_TYPE_FU_B, H264_NAL_TYPE_IDR, H264_NAL_TYPE_MTAP16, H264_NAL_TYPE_MTAP32, H264_NAL_TYPE_RESERVED0, H264_NAL_TYPE_RESERVED30, H264_NAL_TYPE_RESERVED31, H264_NAL_TYPE_SEI, H264_NAL_TYPE_SPS, H264_NAL_TYPE_STAP_B, RtspServer, RtspTrack, createRtspParser, findH264NaluType, getStartedH264NaluTypes, listenSingleRtspClient } from '@scrypted/common/src/rtsp-server';
import { H264_NAL_TYPE_IDR, H264_NAL_TYPE_SPS, RtspServer, RtspTrack, createRtspParser, findH264NaluType, listenSingleRtspClient } from '@scrypted/common/src/rtsp-server';
import { addTrackControls, getSpsPps, parseSdp } from '@scrypted/common/src/sdp-utils';
import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from "@scrypted/common/src/settings-mixin";
import { sleep } from '@scrypted/common/src/sleep';
import { StreamChunk, StreamParser } from '@scrypted/common/src/stream-parser';
import sdk, { BufferConverter, ChargeState, EventListenerRegister, FFmpegInput, ForkWorker, H264Info, MediaObject, MediaStreamDestination, MediaStreamOptions, MixinProvider, RequestMediaStreamOptions, ResponseMediaStreamOptions, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, SettingValue, Settings, VideoCamera, VideoCameraConfiguration, WritableDeviceState } from '@scrypted/sdk';
import sdk, { BufferConverter, ChargeState, EventListenerRegister, FFmpegInput, ForkWorker, MediaObject, MediaStreamDestination, MediaStreamOptions, MixinProvider, RequestMediaStreamOptions, ResponseMediaStreamOptions, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, SettingValue, Settings, VideoCamera, VideoCameraConfiguration, WritableDeviceState } from '@scrypted/sdk';
import { StorageSettings } from '@scrypted/sdk/storage-settings';
import crypto from 'crypto';
import { once } from 'events';
@@ -38,18 +38,6 @@ interface PrebufferStreamChunk extends StreamChunk {
time?: number;
}
function hasOddities(h264Info: H264Info) {
const h264Oddities = h264Info.fuab
|| h264Info.mtap16
|| h264Info.mtap32
|| h264Info.sei
|| h264Info.stapb
|| h264Info.reserved0
|| h264Info.reserved30
|| h264Info.reserved31;
return h264Oddities;
}
type PrebufferParsers = 'rtsp';
class PrebufferSession {
@@ -72,7 +60,6 @@ class PrebufferSession {
ffmpegInputArgumentsKey: string;
ffmpegOutputArgumentsKey: string;
lastDetectedAudioCodecKey: string;
lastH264ProbeKey: string;
rtspParserKey: string;
rtspServerPath: string;
rtspServerMutedPath: string;
@@ -88,7 +75,6 @@ class PrebufferSession {
this.ffmpegInputArgumentsKey = 'ffmpegInputArguments-' + this.streamId;
this.ffmpegOutputArgumentsKey = 'ffmpegOutputArguments-' + this.streamId;
this.lastDetectedAudioCodecKey = 'lastDetectedAudioCodec-' + this.streamId;
this.lastH264ProbeKey = 'lastH264Probe-' + this.streamId;
this.rtspParserKey = 'rtspParser-' + this.streamId;
const rtspServerPathKey = 'rtspServerPathKey-' + this.streamId;
const rtspServerMutedPathKey = 'rtspServerMutedPathKey-' + this.streamId;
@@ -112,24 +98,6 @@ class PrebufferSession {
return !this.enabled || this.shouldDisableBatteryPrebuffer();
}
getLastH264Probe(): H264Info {
const str = this.storage.getItem(this.lastH264ProbeKey);
if (!str) {
return {};
}
try {
return JSON.parse(str);
}
catch (e) {
return {};
}
}
getLastH264Oddities() {
return hasOddities(this.getLastH264Probe());
}
getDetectedIdrInterval() {
const durations: number[] = [];
if (this.rtspPrebuffer.length) {
@@ -384,7 +352,7 @@ class PrebufferSession {
group,
subgroup,
title: 'RTSP Parser',
description: `The RTSP Parser used to read the stream. The default is "${defaultValue}" for this container.`,
description: `The RTSP Parser used to read the stream. The default is "${defaultValue}" for this stream.`,
value: currentParser,
choices: [
STRING_DEFAULT,
@@ -403,20 +371,6 @@ class PrebufferSession {
addFFmpegInputSettings();
}
const addOddities = () => {
settings.push(
{
key: 'detectedOddities',
group,
subgroup,
title: 'Detected H264 Oddities',
readonly: true,
value: JSON.stringify(this.getLastH264Probe()),
description: 'Cameras with oddities in the H264 video stream may not function correctly with Scrypted RTSP Parsers or Senders.',
}
)
};
if (session) {
const codecInfo = await this.parseCodecs();
const resolution = codecInfo.inputVideoResolution?.width && codecInfo.inputVideoResolution?.height
@@ -453,7 +407,6 @@ class PrebufferSession {
value: (idrInterval || 0) / 1000 || 'unknown',
},
);
addOddities();
}
else {
settings.push(
@@ -467,7 +420,6 @@ class PrebufferSession {
readonly: true,
},
);
addOddities();
}
settings.push({
@@ -544,8 +496,6 @@ class PrebufferSession {
this.storage.removeItem(this.lastDetectedAudioCodecKey);
this.usingScryptedParser = false;
const h264Oddities = this.getLastH264Oddities();
if (isRfc4571) {
this.usingScryptedParser = true;
this.console.log('bypassing ffmpeg: using scrypted rfc4571 parser')
@@ -569,17 +519,6 @@ class PrebufferSession {
this.usingScryptedParser = parser === SCRYPTED_PARSER_TCP || parser === SCRYPTED_PARSER_UDP;
this.usingScryptedUdpParser = parser === SCRYPTED_PARSER_UDP;
// prefer ffmpeg if this is a prebuffered stream.
if (isDefault
&& this.usingScryptedParser
&& h264Oddities
&& !this.stopInactive
&& sessionMso.tool !== 'scrypted') {
this.console.warn('H264 oddities were detected in prebuffered video stream, the Default Scrypted RTSP Parser will not be used. Falling back to FFmpeg. This can be overriden by setting the RTSP Parser to Scrypted.');
this.usingScryptedParser = false;
parser = FFMPEG_PARSER_TCP;
}
if (this.usingScryptedParser) {
const rtspParser = createRtspParser();
rbo.parsers.rtsp = rtspParser;
@@ -646,64 +585,6 @@ class PrebufferSession {
console.error('rebroadcast error', e)
});
if (this.usingScryptedParser && !isRfc4571) {
// watch the stream for 10 seconds to see if an weird nalu is encountered.
// if one is found and using scrypted parser as default, will need to restart rebroadcast to prevent
// downstream issues.
const h264Probe: H264Info = {};
let reportedOddity = false;
const oddityProbe = (chunk: StreamChunk) => {
if (chunk.type !== 'h264')
return;
const types = getStartedH264NaluTypes(chunk);
h264Probe.fuab ||= types.has(H264_NAL_TYPE_FU_B);
h264Probe.stapb ||= types.has(H264_NAL_TYPE_STAP_B);
h264Probe.mtap16 ||= types.has(H264_NAL_TYPE_MTAP16);
h264Probe.mtap32 ||= types.has(H264_NAL_TYPE_MTAP32);
h264Probe.sei ||= types.has(H264_NAL_TYPE_SEI);
h264Probe.reserved0 ||= types.has(H264_NAL_TYPE_RESERVED0);
h264Probe.reserved30 ||= types.has(H264_NAL_TYPE_RESERVED30);
h264Probe.reserved31 ||= types.has(H264_NAL_TYPE_RESERVED31);
const oddity = hasOddities(h264Probe);
if (oddity && !reportedOddity) {
reportedOddity = true;
let { isDefault } = this.getParser(sessionMso);
this.console.warn('H264 oddity detected.');
if (!isDefault) {
this.console.warn('If there are issues streaming, consider using the Default parser.');
return;
}
if (sessionMso.tool === 'scrypted') {
this.console.warn('Stream tool is marked safe as "scrypted", ignoring oddity. If there are issues streaming, consider switching to FFmpeg parser.');
return;
}
// don't restart the stream if it is not a prebuffered stream.
// allow this specific request to continue, and possibly fail.
// the next time the stream is requested, ffmpeg will be used.
if (!this.stopInactive) {
this.console.warn('Oddity in prebuffered stream. Restarting rebroadcast to use FFmpeg instead.');
session.kill(new Error('restarting due to H264 oddity detection'));
this.storage.setItem(this.lastH264ProbeKey, JSON.stringify(h264Probe));
removeOddityProbe();
this.startPrebufferSession();
return;
}
// this.console.warn('Oddity in non prebuffered stream. Next restart will use FFmpeg instead.');
}
}
const removeOddityProbe = () => session.removeListener('rtsp', oddityProbe);
session.killed.finally(() => clearTimeout(oddityTimeout));
session.on('rtsp', oddityProbe);
const oddityTimeout = setTimeout(() => {
removeOddityProbe();
this.storage.setItem(this.lastH264ProbeKey, JSON.stringify(h264Probe));
}, h264Oddities ? 60000 : 10000);
}
await session.sdp;
this.parserSession = session;
session.killed.finally(() => {
@@ -955,8 +836,7 @@ class PrebufferSession {
// if starting on a sync frame, ffmpeg will skip the first segment while initializing
// on live sources like rtsp. the buffer before the sync frame stream will be enough
// for ffmpeg to analyze and start up in time for the sync frame.
// If h264 oddities are detected, assume ffmpeg will be used.
if (!options.findSyncFrame || this.getLastH264Oddities()) {
if (!options.findSyncFrame) {
for (const chunk of prebufferContainer) {
if (chunk.time < now - requestedPrebuffer)
continue;
@@ -1006,10 +886,6 @@ class PrebufferSession {
const codecInfo = await this.parseCodecs(true);
const mediaStreamOptions: ResponseMediaStreamOptions = session.negotiateMediaStream(options, codecInfo.inputVideoCodec, codecInfo.inputAudioCodec);
let sdp = await this.sdp;
if (!mediaStreamOptions.video?.h264Info && this.usingScryptedParser) {
mediaStreamOptions.video ||= {};
mediaStreamOptions.video.h264Info = this.getLastH264Probe();
}
if (this.mixin.streamSettings.storageSettings.values.noAudio)
mediaStreamOptions.audio = null;
@@ -1053,6 +929,7 @@ class PrebufferSession {
header.writeUInt8(channel, 1);
chunks[0] = header;
chunk = {
type: chunk.type,
startStream: chunk.startStream,
chunks,
}
@@ -1292,8 +1169,14 @@ class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera> implements Vid
requestedPrebuffer,
filter: (chunk, prebuffer) => {
const track = map.get(chunk.type);
if (track)
if (track) {
server.sendTrack(track, chunk.chunks[1], false);
const buffered = server.client.writableLength;
if (buffered > 100000000) {
this.console.log('more than 100MB has been buffered to RTSP Client, did downstream die? killing connection.');
client.destroy();
}
}
return undefined;
}
});
@@ -1570,10 +1453,6 @@ class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera> implements Vid
const session = this.sessions.get(mso.id);
if (session?.parserSession || enabledStreams.includes(mso))
mso.prebuffer = prebufferDurationMs;
if (session && !mso.video?.h264Info) {
mso.video ||= {};
mso.video.h264Info = session.getLastH264Probe();
}
if (!mso.destinations) {
mso.destinations = [];
for (const [k, v] of map.entries()) {

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/snapshot",
"version": "0.2.59",
"version": "0.2.60",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/snapshot",
"version": "0.2.59",
"version": "0.2.60",
"dependencies": {
"@types/node": "^22.10.2",
"sharp": "^0.33.5",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/snapshot",
"version": "0.2.59",
"version": "0.2.60",
"description": "Snapshot Plugin for Scrypted",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",

View File

@@ -2,17 +2,17 @@ import { AutoenableMixinProvider } from "@scrypted/common/src/autoenable-mixin-p
import { AuthFetchCredentialState, authHttpFetch } from '@scrypted/common/src/http-auth-fetch';
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, ResponsePictureOptions, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, SettingValue, Settings, Sleep, VideoCamera, WritableDeviceState } from "@scrypted/sdk";
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 { StorageSettings } from "@scrypted/sdk/storage-settings";
import https from 'https';
import os from 'os';
import path from 'path';
import url from 'url';
import { fixLegacyClipPath } from '../../objectdetector/src/polygon';
import { ffmpegFilterImage, ffmpegFilterImageBuffer } from './ffmpeg-image-filter';
import { ImageConverter, ImageConverterNativeId } from './image-converter';
import { ImageReader, ImageReaderNativeId, loadSharp, loadVipsImage } from './image-reader';
import { ImageWriter, ImageWriterNativeId } from './image-writer';
import { fixLegacyClipPath, normalizeBox, polygonIntersectsBoundingBox } from '../../objectdetector/src/polygon';
const { mediaManager, systemManager } = sdk;
if (os.cpus().find(cpu => cpu.model?.toLowerCase().includes('qemu'))) {
@@ -31,7 +31,7 @@ class PrebufferUnavailableError extends Error {
}
class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera, Resolution {
storageSettings = new StorageSettings(this, {
defaultSnapshotChannel: {
title: 'Default Snapshot Channel',
@@ -390,6 +390,8 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
if (loadSharp()) {
const vips = await loadVipsImage(rawPicture.picture, this.id);
if (this.resolution?.[0] !== vips.width || this.resolution?.[1] !== vips.height)
this.resolution = [vips.width, vips.height];
try {
const ret = await vips.toBuffer({
resize: options?.picture,
@@ -459,29 +461,6 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
}
}
// try {
// const mo = await mediaManager.createMediaObject(picture, 'image/jpeg');
// const image = await mediaManager.convertMediaObject<Image>(mo, ScryptedMimeTypes.Image);
// const left = image.width * xmin;
// const width = image.width * (xmax - xmin);
// const top = image.height * ymin;
// const height = image.height * (ymax - ymin);
// return await image.toBuffer({
// crop: {
// left,
// width,
// top,
// height,
// },
// format: 'jpg',
// });
// }
// catch (e) {
// if (!e.message?.includes('no converter found'))
// throw e;
// }
return ffmpegFilterImageBuffer(picture, {
console: this.debugConsole,
ffmpegPath: await mediaManager.getFFmpegPath(),
@@ -767,7 +746,7 @@ export class SnapshotPlugin extends AutoenableMixinProvider implements MixinProv
async canMixin(type: ScryptedDeviceType, interfaces: string[]): Promise<string[]> {
if ((type === ScryptedDeviceType.Camera || type === ScryptedDeviceType.Doorbell) && interfaces.includes(ScryptedInterface.VideoCamera))
return [ScryptedInterface.Camera, ScryptedInterface.Settings];
return [ScryptedInterface.Camera, ScryptedInterface.Settings, ScryptedInterface.Resolution];
return undefined;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@scrypted/unifi-protect",
"type": "module",
"version": "0.0.164",
"version": "0.1.0",
"description": "Unifi Protect Plugin for Scrypted",
"author": "Scrypted",
"license": "Apache",
@@ -27,6 +27,7 @@
"name": "Unifi Protect Plugin",
"type": "DeviceProvider",
"interfaces": [
"DeviceDiscovery",
"DeviceProvider",
"Settings"
],
@@ -41,8 +42,8 @@
"dependencies": {
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"axios": "^1.7.9",
"axios": "^1.12.2",
"unifi-protect": "^4.21.0",
"ws": "^8.18.2"
"ws": "^8.18.3"
}
}

View File

@@ -170,7 +170,7 @@ export class UnifiCamera extends ScryptedDeviceBase implements Notifier, Interco
const ffmpegInput = JSON.parse(buffer.toString()) as FFmpegInput;
const camera = this.findCamera();
const endpoint = new URL(this.protect.api.getApiEndpoint("talkback"));
const endpoint = new URL(this.protect.api.getApiEndpoint("websocket") + "/talkback");
endpoint.searchParams.set('camera', camera.id);
const response = await this.protect.loginFetch(endpoint.toString());
const tb = response.data as Record<string, string>;

View File

@@ -1,15 +1,15 @@
import { createInstanceableProviderPlugin, enableInstanceableProviderMode, isInstanceableProviderModeEnabled } from '@scrypted/common/src/provider-plugin';
import { sleep } from "@scrypted/common/src/sleep";
import sdk, { Device, DeviceProvider, ObjectDetectionResult, ObjectsDetected, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings } from "@scrypted/sdk";
import sdk, { AdoptDevice, Device, DeviceDiscovery, DeviceProvider, DiscoveredDevice, ObjectDetectionResult, ObjectsDetected, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings } from "@scrypted/sdk";
import { StorageSettings } from "@scrypted/sdk/storage-settings";
import axios, {ResponseType} from "axios";
import axios, { ResponseType } from "axios";
import https from 'https';
import { UnifiCamera } from "./camera";
import { debounceFingerprintDetected, debounceMotionDetected } from "./camera-sensors";
import { UnifiLight } from "./light";
import { UnifiLock } from "./lock";
import { UnifiSensor } from "./sensor";
import { ProtectApi, ProtectCameraConfigInterface, ProtectEventAddInterface, ProtectEventPacket } from "./unifi-protect";
import https from 'https';
const httpsAgent = new https.Agent({
rejectUnauthorized: false,
@@ -31,7 +31,7 @@ const filter = [
'wifiConnectionState',
];
export class UnifiProtect extends ScryptedDeviceBase implements Settings, DeviceProvider {
export class UnifiProtect extends ScryptedDeviceBase implements Settings, DeviceProvider, DeviceDiscovery {
authorization: string | undefined;
accessKey: string | undefined;
cameras = new Map<string, UnifiCamera>()
@@ -45,7 +45,7 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
constructor(nativeId?: string) {
super(nativeId);
this.startup = this.discoverDevices(0)
this.startup = this.connectProtect()
this.updateManagementUrl();
}
@@ -285,11 +285,338 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
this.api?.reset();
this.console.error('Event Listener reconnecting in 10 seconds:', reason);
await sleep(10000);
this.discoverDevices(0);
this.connectProtect();
}
}
async discoverDevices(duration: number) {
async discoverDevices(): Promise<DiscoveredDevice[]> {
return this.discoverDevicesInternal(false);
}
async discoverDevicesInternal(skipCheck: boolean): Promise<DiscoveredDevice[]> {
if (!this.api?.bootstrap)
return [];
let settings: Setting[] = undefined;
if (this.failedDevices.size) {
settings = [
{
title: 'Add Device',
key: 'addDevice',
type: 'radiopanel',
choices: [
'Add New Device',
'Reassociate Existing Device'
],
value: 'Add New Device',
},
{
radioGroups: ['Reassociate Existing Device'],
key: 'reassociate',
title: 'Device',
description: 'These devices previously failed to load. Select one to reassociate it with a new Unifi Protect device.',
choices: Array.from(this.failedDevices.values()),
}
];
}
const nativeIds = new Set(deviceManager.getNativeIds());
const checkNativeId = (device: any) => {
if (skipCheck)
return false;
const nativeId = this.getNativeId(device, true);
if (nativeId && nativeIds.has(nativeId))
return true;
return false;
}
const devices: DiscoveredDevice[] = [];
for (const camera of this.api.bootstrap.cameras) {
if (!camera.isAdopted || camera.isAdoptedByOther) {
continue;
}
if (checkNativeId(camera))
continue;
const managementUrl = `https://${this.storage.getItem('ip')}/protect/timelapse/${camera.id}`;
const isDoorbell = camera.featureFlags.isDoorbell || camera.featureFlags.hasChime;
const d: DiscoveredDevice = {
settings,
description: camera.host || camera.id,
name: camera.name,
nativeId: camera.id,
info: {
manufacturer: camera.isThirdPartyCamera ? undefined : 'Ubiquiti',
model: camera.type,
firmware: camera.firmwareVersion,
version: camera.hardwareRevision,
ip: camera.host,
serialNumber: camera.id,
mac: camera.mac,
managementUrl,
},
interfaces: [
ScryptedInterface.Settings,
ScryptedInterface.Camera,
ScryptedInterface.VideoCamera,
ScryptedInterface.VideoCameraMask,
ScryptedInterface.VideoCameraConfiguration,
ScryptedInterface.MotionSensor,
],
type: isDoorbell
? ScryptedDeviceType.Doorbell
: ScryptedDeviceType.Camera,
};
if (isDoorbell) {
d.interfaces.push(ScryptedInterface.BinarySensor);
}
if (camera.featureFlags.hasSpeaker) {
d.interfaces.push(ScryptedInterface.Intercom);
}
if (camera.featureFlags.hasLcdScreen) {
d.interfaces.push(ScryptedInterface.Notifier);
}
if (camera.featureFlags.hasPackageCamera) {
d.interfaces.push(ScryptedInterface.DeviceProvider);
}
if (camera.featureFlags.hasLedStatus) {
d.interfaces.push(ScryptedInterface.OnOff);
}
if (camera.featureFlags.canOpticalZoom) {
d.interfaces.push(ScryptedInterface.PanTiltZoom);
}
d.interfaces.push(ScryptedInterface.ObjectDetector);
devices.push(d);
}
for (const sensor of this.api.bootstrap.sensors || []) {
if (!sensor.isAdopted || sensor.isAdoptedByOther) {
continue;
}
if (checkNativeId(sensor))
continue;
const d: DiscoveredDevice = {
settings,
description: sensor.host || sensor.id,
name: sensor.name,
nativeId: sensor.id,
info: {
manufacturer: 'Ubiquiti',
model: sensor.type,
ip: sensor.host,
firmware: sensor.firmwareVersion,
version: sensor.hardwareRevision,
serialNumber: sensor.id,
},
interfaces: [
// todo light sensor
ScryptedInterface.Thermometer,
ScryptedInterface.HumiditySensor,
ScryptedInterface.AudioSensor,
ScryptedInterface.BinarySensor,
ScryptedInterface.MotionSensor,
ScryptedInterface.FloodSensor,
],
type: ScryptedDeviceType.Sensor,
};
devices.push(d);
}
for (const light of this.api.bootstrap.lights || []) {
if (!light.isAdopted || light.isAdoptedByOther) {
continue;
}
if (checkNativeId(light))
continue;
const d: DiscoveredDevice = {
settings,
description: light.host || light.id,
name: light.name,
nativeId: light.id,
info: {
manufacturer: 'Ubiquiti',
model: light.type,
ip: light.host,
firmware: light.firmwareVersion,
version: light.hardwareRevision,
serialNumber: light.id,
},
interfaces: [
// todo light sensor
ScryptedInterface.OnOff,
ScryptedInterface.Brightness,
ScryptedInterface.MotionSensor,
],
type: ScryptedDeviceType.Light,
};
devices.push(d);
}
for (const lock of (this.api.bootstrap.doorlocks as any) || []) {
if (!lock.isAdopted || lock.isAdoptedByOther) {
continue;
}
if (checkNativeId(lock))
continue;
const d: DiscoveredDevice = {
settings,
description: lock.host || lock.id,
name: lock.name,
nativeId: lock.id,
info: {
manufacturer: 'Ubiquiti',
model: lock.type,
ip: lock.host,
firmware: lock.firmwareVersion,
version: lock.hardwareRevision.toString(),
serialNumber: lock.id,
},
interfaces: [
ScryptedInterface.Lock,
],
type: ScryptedDeviceType.Lock,
};
devices.push(d);
}
return devices;
}
async adoptDevice(device: AdoptDevice): Promise<string> {
let mappedNativeId = device.nativeId;
if (device.settings?.addDevice === 'Reassociate Existing Device') {
if (!device.settings.reassociate)
throw new Error('Select a device to reassociate.');
const failedNativeId = [...this.failedDevices.entries()].find(([id, name]) => name === device.settings.reassociate)?.[0];
if (!failedNativeId)
throw new Error('Failed to find device to reassociate.');
const idToNativeId = this.storageSettings.values.idToNativeId || {};
idToNativeId[device.nativeId] = failedNativeId;
this.storageSettings.values.idToNativeId = idToNativeId;
mappedNativeId = failedNativeId;
return this.adoptDeviceInternal(device, true, mappedNativeId);
}
return this.adoptDeviceInternal(device, false, mappedNativeId);
}
async adoptDeviceInternal(device: { nativeId: string, settings?: any }, skipCheck: boolean, mappedNativeId = device.nativeId): Promise<string> {
const discoveredDevices = await this.discoverDevicesInternal(skipCheck);
const d = discoveredDevices.find(d => d.nativeId === device.nativeId);
if (!d)
throw new Error('device not found');
const id = await deviceManager.onDeviceDiscovered({
...d,
nativeId: mappedNativeId,
interfaces: d.interfaces!,
providerNativeId: this.nativeId,
});
this.getDevice(mappedNativeId).then(device => device?.updateState());
let camera = this.api.bootstrap.cameras.find(c => c.id === d.nativeId);
if (camera) {
let needUpdate = false;
for (const channel of camera.channels) {
if (channel.idrInterval !== 4 || !channel.isRtspEnabled) {
if (channel.idrInterval !== 4)
this.console.log('attempting to change invalid idr interval. if this message shows up again on plugin reload, it failed. idr:', channel.idrInterval);
channel.idrInterval = 4;
channel.isRtspEnabled = true;
needUpdate = true;
}
}
if (needUpdate) {
const updated = await this.api.updateDevice(camera, {
channels: camera.channels,
});
if (!camera) {
this.log.a('Unable to enable RTSP and IDR interval on camera. Is this an admin account?');
}
else {
camera = updated;
}
}
const devices: Device[] = [];
const providerNativeId = this.getNativeId(camera, true);
if (camera.featureFlags.hasPackageCamera) {
const nativeId = providerNativeId + '-packageCamera';
const d: Device = {
providerNativeId,
name: camera.name + ' Package Camera',
nativeId,
info: {
manufacturer: 'Ubiquiti',
model: camera.type,
firmware: camera.firmwareVersion,
version: camera.hardwareRevision,
serialNumber: camera.id,
},
interfaces: [
ScryptedInterface.Camera,
ScryptedInterface.VideoCamera,
ScryptedInterface.MotionSensor,
],
type: ScryptedDeviceType.Camera,
};
devices.push(d);
}
if (camera.featureFlags.hasFingerprintSensor) {
const nativeId = providerNativeId + '-fingerprintSensor';
const d: Device = {
providerNativeId,
name: camera.name + ' Fingerprint Sensor',
nativeId,
info: {
manufacturer: 'Ubiquiti',
model: camera.type,
firmware: camera.firmwareVersion,
version: camera.hardwareRevision,
serialNumber: camera.id,
},
interfaces: [
ScryptedInterface.BinarySensor,
],
type: ScryptedDeviceType.Sensor,
};
devices.push(d);
}
if (devices.length) {
await deviceManager.onDevicesChanged({
providerNativeId: mappedNativeId,
devices,
});
}
}
return id;
}
async connectProtect() {
this.api?.reset();
this.reconnecting = false;
clearTimeout(this.wsTimeout);
@@ -347,243 +674,35 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
this.api.on('message', message => {
resetWsTimeout();
this.listener(message);
})
const devices: Device[] = [];
if (!this.api.bootstrap.cameras.length) {
this.console.warn('no cameras found. is this an admin account? cancelling sync.');
return;
}
for (let camera of this.api.bootstrap.cameras || []) {
if (camera.isAdoptedByOther) {
this.console.log('skipping camera that is adopted by another nvr', camera.id, camera.name);
continue;
}
if (!camera.isAdopted) {
this.console.log('skipping camera that is not adopted', camera.id, camera.name);
continue;
}
let needUpdate = false;
for (const channel of camera.channels) {
if (channel.idrInterval !== 4 || !channel.isRtspEnabled) {
if (channel.idrInterval !== 4)
this.console.log('attempting to change invalid idr interval. if this message shows up again on plugin reload, it failed. idr:', channel.idrInterval);
channel.idrInterval = 4;
channel.isRtspEnabled = true;
needUpdate = true;
}
}
if (needUpdate) {
camera = await this.api.updateDevice(camera, {
channels: camera.channels,
});
if (!camera) {
this.log.a('Unable to enable RTSP and IDR interval on camera. Is this an admin account?');
continue;
}
}
const managementUrl = `https://${this.storage.getItem('ip')}/protect/timelapse/${camera.id}`;
const isDoorbell = camera.featureFlags.isDoorbell || camera.featureFlags.hasChime;
const d: Device = {
providerNativeId: this.nativeId,
name: camera.name,
nativeId: this.getNativeId(camera, true),
info: {
manufacturer: 'Ubiquiti',
model: camera.type,
firmware: camera.firmwareVersion,
version: camera.hardwareRevision,
ip: camera.host,
serialNumber: camera.id,
mac: camera.mac,
managementUrl,
},
interfaces: [
ScryptedInterface.Settings,
ScryptedInterface.Camera,
ScryptedInterface.VideoCamera,
ScryptedInterface.VideoCameraMask,
ScryptedInterface.VideoCameraConfiguration,
ScryptedInterface.MotionSensor,
],
type: isDoorbell
? ScryptedDeviceType.Doorbell
: ScryptedDeviceType.Camera,
};
if (isDoorbell) {
d.interfaces.push(ScryptedInterface.BinarySensor);
}
if (camera.featureFlags.hasSpeaker) {
d.interfaces.push(ScryptedInterface.Intercom);
}
if (camera.featureFlags.hasLcdScreen) {
d.interfaces.push(ScryptedInterface.Notifier);
}
if (camera.featureFlags.hasPackageCamera) {
d.interfaces.push(ScryptedInterface.DeviceProvider);
}
if (camera.featureFlags.hasLedStatus) {
d.interfaces.push(ScryptedInterface.OnOff);
}
if (camera.featureFlags.canOpticalZoom) {
d.interfaces.push(ScryptedInterface.PanTiltZoom);
}
d.interfaces.push(ScryptedInterface.ObjectDetector);
devices.push(d);
}
for (const sensor of this.api.bootstrap.sensors || []) {
const d: Device = {
providerNativeId: this.nativeId,
name: sensor.name,
nativeId: this.getNativeId(sensor, true),
info: {
manufacturer: 'Ubiquiti',
model: sensor.type,
ip: sensor.host,
firmware: sensor.firmwareVersion,
version: sensor.hardwareRevision,
serialNumber: sensor.id,
},
interfaces: [
// todo light sensor
ScryptedInterface.Thermometer,
ScryptedInterface.HumiditySensor,
ScryptedInterface.AudioSensor,
ScryptedInterface.BinarySensor,
ScryptedInterface.MotionSensor,
ScryptedInterface.FloodSensor,
],
type: ScryptedDeviceType.Sensor,
};
devices.push(d);
}
for (const light of this.api.bootstrap.lights || []) {
const d: Device = {
providerNativeId: this.nativeId,
name: light.name,
nativeId: this.getNativeId(light, true),
info: {
manufacturer: 'Ubiquiti',
model: light.type,
ip: light.host,
firmware: light.firmwareVersion,
version: light.hardwareRevision,
serialNumber: light.id,
},
interfaces: [
// todo light sensor
ScryptedInterface.OnOff,
ScryptedInterface.Brightness,
ScryptedInterface.MotionSensor,
],
type: ScryptedDeviceType.Light,
};
devices.push(d);
}
for (const lock of (this.api.bootstrap.doorlocks as any) || []) {
const d: Device = {
providerNativeId: this.nativeId,
name: lock.name,
nativeId: this.getNativeId(lock, true),
info: {
manufacturer: 'Ubiquiti',
model: lock.type,
ip: lock.host,
firmware: lock.firmwareVersion,
version: lock.hardwareRevision.toString(),
serialNumber: lock.id,
},
interfaces: [
ScryptedInterface.Lock,
],
type: ScryptedDeviceType.Lock,
};
devices.push(d);
}
if (!devices.length) {
this.console.warn('no devices found. is this an admin account? cancelling sync.');
return;
}
await deviceManager.onDevicesChanged({
providerNativeId: this.nativeId,
devices,
});
for (const device of devices) {
this.getDevice(device.nativeId).then(device => device?.updateState());
const nativeIds = new Set(deviceManager.getNativeIds());
// refresh all adopted devices and update state.
const adoptedDevices = [
...this.api.bootstrap.cameras || [],
...this.api.bootstrap.sensors || [],
...this.api.bootstrap.lights || [],
...(this.api.bootstrap.doorlocks as any) || [],
]
.filter(device => device.isAdopted && !device.isAdoptedByOther);
if (adoptedDevices.length) {
// clean up the idToNativeId mapping
const idToNativeId = this.storageSettings.values.idToNativeId || {};
for (const k of Object.keys(idToNativeId)) {
if (!adoptedDevices.find(d => d.id === k)) {
delete idToNativeId[k];
}
}
this.storageSettings.values.idToNativeId = idToNativeId;
}
// handle package cameras as a sub device
for (const camera of this.api.bootstrap.cameras) {
const devices: Device[] = [];
const providerNativeId = this.getNativeId(camera, true);
if (camera.featureFlags.hasPackageCamera) {
const nativeId = providerNativeId + '-packageCamera';
const d: Device = {
providerNativeId,
name: camera.name + ' Package Camera',
nativeId,
info: {
manufacturer: 'Ubiquiti',
model: camera.type,
firmware: camera.firmwareVersion,
version: camera.hardwareRevision,
serialNumber: camera.id,
},
interfaces: [
ScryptedInterface.Camera,
ScryptedInterface.VideoCamera,
ScryptedInterface.MotionSensor,
],
type: ScryptedDeviceType.Camera,
};
devices.push(d);
for (const device of adoptedDevices) {
const nativeId = this.getNativeId(device, true);
if (nativeId && nativeIds.has(nativeId)) {
this.adoptDeviceInternal({ nativeId: device.id }, true, nativeId).catch(() => { });
}
if (camera.featureFlags.hasFingerprintSensor) {
const nativeId = providerNativeId + '-fingerprintSensor';
const d: Device = {
providerNativeId,
name: camera.name + ' Fingerprint Sensor',
nativeId,
info: {
manufacturer: 'Ubiquiti',
model: camera.type,
firmware: camera.firmwareVersion,
version: camera.hardwareRevision,
serialNumber: camera.id,
},
interfaces: [
ScryptedInterface.BinarySensor,
],
type: ScryptedDeviceType.Sensor,
};
devices.push(d);
}
if (!devices.length)
continue;
await deviceManager.onDevicesChanged({
providerNativeId: this.getNativeId(camera, true),
devices,
});
}
}
catch (e) {
@@ -593,44 +712,63 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
}
async releaseDevice(id: string, nativeId: string): Promise<void> {
this.cameras.delete(nativeId);
this.unifiSensors.delete(nativeId);
this.lights.delete(nativeId);
this.locks.delete(nativeId);
}
failedDevices = new Map<string, string>();
async getDevice(nativeId: string): Promise<UnifiCamera | UnifiLight | UnifiSensor | UnifiLock> {
await this.startup;
if (this.cameras.has(nativeId))
return this.cameras.get(nativeId);
if (this.unifiSensors.has(nativeId))
return this.unifiSensors.get(nativeId);
if (this.lights.has(nativeId))
return this.lights.get(nativeId);
if (this.locks.has(nativeId))
return this.locks.get(nativeId);
try {
if (this.cameras.has(nativeId))
return this.cameras.get(nativeId);
if (this.unifiSensors.has(nativeId))
return this.unifiSensors.get(nativeId);
if (this.lights.has(nativeId))
return this.lights.get(nativeId);
if (this.locks.has(nativeId))
return this.locks.get(nativeId);
const id = this.findId(nativeId);
const camera = this.api.bootstrap.cameras.find(camera => camera.id === id);
if (camera) {
const ret = new UnifiCamera(this, nativeId, camera);
this.cameras.set(nativeId, ret);
return ret;
const id = this.findId(nativeId);
const camera = this.api.bootstrap.cameras.find(camera => camera.id === id);
if (camera) {
const ret = new UnifiCamera(this, nativeId, camera);
this.cameras.set(nativeId, ret);
return ret;
}
const sensor = this.api.bootstrap.sensors.find(sensor => sensor.id === id);
if (sensor) {
const ret = new UnifiSensor(this, nativeId, sensor);
this.unifiSensors.set(nativeId, ret);
return ret;
}
const light = this.api.bootstrap.lights.find(light => light.id === id);
if (light) {
const ret = new UnifiLight(this, nativeId, light);
this.lights.set(nativeId, ret);
return ret;
}
const lock = (this.api.bootstrap.doorlocks as any)?.find(lock => lock.id === id);
if (lock) {
const ret = new UnifiLock(this, nativeId, lock);
this.locks.set(nativeId, ret);
return ret;
}
}
const sensor = this.api.bootstrap.sensors.find(sensor => sensor.id === id);
if (sensor) {
const ret = new UnifiSensor(this, nativeId, sensor);
this.unifiSensors.set(nativeId, ret);
return ret;
}
const light = this.api.bootstrap.lights.find(light => light.id === id);
if (light) {
const ret = new UnifiLight(this, nativeId, light);
this.lights.set(nativeId, ret);
return ret;
}
const lock = (this.api.bootstrap.doorlocks as any)?.find(lock => lock.id === id);
if (lock) {
const ret = new UnifiLock(this, nativeId, lock);
this.locks.set(nativeId, ret);
return ret;
finally {
this.failedDevices.delete(nativeId);
}
const logger = deviceManager.getDeviceLogger(nativeId);
logger.a('Device not found in Unifi Protect. This may be caused by Unifi Protect changing the device id. Reassociate the device in the Unifi Protect plugin to continue using it.');
const d = new ScryptedDeviceBase(nativeId);
const uniqueName = `${d.name} (${nativeId})`;
this.failedDevices.set(nativeId, uniqueName);
throw new Error('device not found?');
}
@@ -638,25 +776,25 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
return this.storage.getItem(key);
}
rediscover() {
this.discoverDevices(0);
forceReconnect() {
this.connectProtect();
this.updateManagementUrl();
}
storageSettings = new StorageSettings(this, {
username: {
title: 'Username',
onPut: () => this.rediscover(),
onPut: () => this.forceReconnect(),
},
password: {
title: 'Password',
type: 'password',
onPut: () => this.rediscover(),
onPut: () => this.forceReconnect(),
},
ip: {
title: 'Unifi Protect IP',
placeholder: '192.168.1.100',
onPut: () => this.rediscover(),
onPut: () => this.forceReconnect(),
},
useConnectionHost: {
title: 'Use Connection Host',
@@ -670,6 +808,11 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
group: 'Advanced',
type: 'boolean',
},
idToNativeId: {
hide: true,
json: true,
defaultValue: {},
},
idMaps: {
hide: true,
json: true,
@@ -681,11 +824,33 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
});
findId(nativeId: string) {
// the native id should be mapped to an id...
return this.storageSettings.values.idMaps.nativeId?.[nativeId] || nativeId;
// the id and nativeId will be the same unless unifi clobbers the id.
// new path
const found = Object.entries(this.storageSettings.values.idToNativeId || {}).find(([id, nid]) => nid === nativeId);
if (found)
return found[0];
// legacy path
const id = this.storageSettings.values.idMaps.nativeId?.[nativeId] || nativeId;
const existingNativeId = this.storageSettings.values.idToNativeId?.[id];
if (!existingNativeId || nativeId === existingNativeId)
return id;
return nativeId;
}
getNativeId(device: { id?: string, mac?: string; anonymousDeviceId?: string, host?: string }, update: boolean) {
getNativeId(device: { id?: string, mac?: string; anonymousDeviceId?: string, host?: string }, update: boolean): string {
if (device.id) {
const nativeId = this.storageSettings.values.idToNativeId?.[device.id];
if (nativeId)
return nativeId;
// at some point later this will return the id itself and update the mapping.
// return device.id;
// for now fall back to old behavior which will be removed at a later date.
}
const { id, mac, anonymousDeviceId, host } = device;
const idMaps = this.storageSettings.values.idMaps;
@@ -741,6 +906,11 @@ export class UnifiProtect extends ScryptedDeviceBase implements Settings, Device
idMaps.nativeId[nativeId] = id;
this.storageSettings.values.idMaps = idMaps;
// update mappings for new behavior.
const idToNativeId = this.storageSettings.values.idToNativeId || {};
idToNativeId[id] = nativeId;
this.storageSettings.values.idToNativeId = idToNativeId;
return nativeId;
}

View File

@@ -1,20 +1,21 @@
{
"name": "@scrypted/webrtc",
"version": "0.2.79",
"version": "0.2.86",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/webrtc",
"version": "0.2.79",
"version": "0.2.86",
"dependencies": {
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"@scrypted/server": "file:../../server",
"ip": "^2.0.1"
},
"devDependencies": {
"@types/ip": "^1.1.3",
"@types/node": "^22.1.0"
"@types/node": "^22.18.0"
}
},
"../../common": {
@@ -23,6 +24,7 @@
"license": "ISC",
"dependencies": {
"@scrypted/sdk": "file:../sdk",
"@scrypted/types": "^0.5.27",
"http-auth-utils": "^5.0.1",
"typescript": "^5.5.3"
},
@@ -83,28 +85,29 @@
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.5.11",
"version": "0.5.38",
"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": "^5.3.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": {
@@ -117,9 +120,63 @@
"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.142.8",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"@scrypted/ffmpeg-static": "^6.1.0-build3",
"@scrypted/node-pty": "^1.0.24",
"@scrypted/types": "^0.5.36",
"adm-zip": "^0.5.16",
"body-parser": "^2.2.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.5.0",
"engine.io": "^6.6.4",
"express": "^5.1.0",
"follow-redirects": "^1.15.9",
"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": "^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.8.3",
"whatwg-mimetype": "^4.0.0",
"ws": "^8.18.2"
},
"bin": {
"scrypted-serve": "bin/scrypted-serve"
},
"devDependencies": {
"@types/adm-zip": "^0.5.7",
"@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.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.7.0",
"@types/source-map-support": "^0.5.10",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"rimraf": "^6.0.1"
}
},
"node_modules/@scrypted/common": {
@@ -130,6 +187,10 @@
"resolved": "../../sdk",
"link": true
},
"node_modules/@scrypted/server": {
"resolved": "../../server",
"link": true
},
"node_modules/@types/ip": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@types/ip/-/ip-1.1.3.tgz",
@@ -140,12 +201,13 @@
}
},
"node_modules/@types/node": {
"version": "22.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.1.0.tgz",
"integrity": "sha512-AOmuRF0R2/5j1knA3c6G3HOk523Ga+l+ZXltX8SF1+5oqcXijjfTd8fY3XRZqSihEu9XhtQnKYLmkFaoxgsJHw==",
"version": "22.18.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz",
"integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.13.0"
"undici-types": "~6.21.0"
}
},
"node_modules/ip": {
@@ -154,10 +216,11 @@
"integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ=="
},
"node_modules/undici-types": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.13.0.tgz",
"integrity": "sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==",
"dev": true
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
},
"dependencies": {
@@ -165,6 +228,7 @@
"version": "file:../../common",
"requires": {
"@scrypted/sdk": "file:../sdk",
"@scrypted/types": "^0.5.27",
"@types/node": "^20.11.0",
"http-auth-utils": "^5.0.1",
"monaco-editor": "^0.50.0",
@@ -175,31 +239,78 @@
"@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": "^5.3.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"
}
},
"@scrypted/server": {
"version": "file:../../server",
"requires": {
"@scrypted/ffmpeg-static": "^6.1.0-build3",
"@scrypted/node-pty": "^1.0.24",
"@scrypted/types": "^0.5.36",
"@types/adm-zip": "^0.5.7",
"@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.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.7.0",
"@types/source-map-support": "^0.5.10",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"adm-zip": "^0.5.16",
"body-parser": "^2.2.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.5.0",
"engine.io": "^6.6.4",
"express": "^5.1.0",
"follow-redirects": "^1.15.9",
"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": "^11.2.0",
"py": "npm:@bjia56/portable-python@^0.1.141",
"rimraf": "^6.0.1",
"semver": "^7.7.2",
"sharp": "^0.34.2",
"source-map-support": "^0.5.21",
"tar": "^7.4.3",
"tslib": "^2.8.1",
"typescript": "^5.8.3",
"whatwg-mimetype": "^4.0.0",
"ws": "^8.18.2"
}
},
"@types/ip": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@types/ip/-/ip-1.1.3.tgz",
@@ -210,12 +321,12 @@
}
},
"@types/node": {
"version": "22.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.1.0.tgz",
"integrity": "sha512-AOmuRF0R2/5j1knA3c6G3HOk523Ga+l+ZXltX8SF1+5oqcXijjfTd8fY3XRZqSihEu9XhtQnKYLmkFaoxgsJHw==",
"version": "22.18.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz",
"integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==",
"dev": true,
"requires": {
"undici-types": "~6.13.0"
"undici-types": "~6.21.0"
}
},
"ip": {
@@ -224,9 +335,9 @@
"integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ=="
},
"undici-types": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.13.0.tgz",
"integrity": "sha512-xtFJHudx8S2DSoujjMd1WeWvn7KKWFRESZTMeL1RptAYERu29D6jphMjjY+vn96jvN3kVPDNxU/E13VTaXj6jg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/webrtc",
"version": "0.2.79",
"version": "0.2.86",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",
"prescrypted-setup-project": "scrypted-package-json",
@@ -30,12 +30,13 @@
]
},
"dependencies": {
"@scrypted/server": "file:../../server",
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"ip": "^2.0.1"
},
"devDependencies": {
"@types/ip": "^1.1.3",
"@types/node": "^22.1.0"
"@types/node": "^22.18.0"
}
}

View File

@@ -1,44 +0,0 @@
const maxPacketSize = 1 << 16;
export class DataChannelDebouncer {
timeout: NodeJS.Timeout;
pending: Buffer;
maxWait = 10;
constructor(public dc: { send: (buffer: Buffer) => void }, public kill: (e: Error) => void) {
}
send(data: Buffer) {
// if this buffer would exceed the max packet size, flush now to ensure only large packets are flushed.
if (this.pending?.length + data.length >= maxPacketSize)
this.flush();
if (!this.pending)
this.pending = data;
else
this.pending = Buffer.concat([this.pending, data]);
clearTimeout(this.timeout);
this.timeout = setTimeout(() => this.flush(), this.maxWait);
// if this buffer exceeds the max packet size, flush now to send the entire message immediately.
if (this.pending?.length + data.length >= maxPacketSize)
this.flush();
}
flush() {
try {
let offset = 0;
while (offset < this.pending.length) {
this.dc.send(this.pending.slice(offset, offset + maxPacketSize));
offset += maxPacketSize;
}
this.pending = undefined;
clearTimeout(this.timeout);
this.timeout = undefined;
}
catch (e) {
this.kill(e as Error);
}
}
}

View File

@@ -1,21 +1,18 @@
import { MediaStreamTrack, PeerConfig, RTCPeerConnection, RTCRtpCodecParameters, RTCRtpTransceiver, RtpPacket } from "./werift";
import { Deferred } from "@scrypted/common/src/deferred";
import sdk, { FFmpegInput, FFmpegTranscodeStream, Intercom, MediaObject, MediaStreamDestination, MediaStreamFeedback, RequestMediaStream, RTCAVSignalingSetup, RTCConnectionManagement, RTCInputMediaObjectTrack, RTCOutputMediaObjectTrack, RTCSignalingOptions, RTCSignalingSession, ScryptedDevice, ScryptedMimeTypes } from "@scrypted/sdk";
import { ScryptedSessionControl } from "./session-control";
import { optionalVideoCodec, opusAudioCodecOnly, requiredAudioCodecs, requiredVideoCodec } from "./webrtc-required-codecs";
import { logIsLocalIceTransport } from "./werift-util";
import { addVideoFilterArguments } from "@scrypted/common/src/ffmpeg-helpers";
import { connectRTCSignalingClients, legacyGetSignalingSessionOptions } from "@scrypted/common/src/rtc-signaling";
import { getSpsPps, getSpsPpsVps, MSection } from "@scrypted/common/src/sdp-utils";
import sdk, { FFmpegInput, FFmpegTranscodeStream, Intercom, MediaObject, MediaStreamDestination, MediaStreamFeedback, RequestMediaStream, RTCAVSignalingSetup, RTCConnectionManagement, RTCInputMediaObjectTrack, RTCOutputMediaObjectTrack, RTCSignalingOptions, RTCSignalingSession, ScryptedInterface, ScryptedMimeTypes } from "@scrypted/sdk";
import { H264Repacketizer } from "../../homekit/src/types/camera/h264-packetizer";
import { OpusRepacketizer } from "../../homekit/src/types/camera/opus-repacketizer";
import { H265Repacketizer } from "./h265-packetizer";
import { logConnectionState, waitClosed, waitConnected, waitIceConnected } from "./peerconnection-util";
import { RtpCodecCopy, RtpTrack, RtpTracks, startRtpForwarderProcess } from "./rtp-forwarders";
import { getAudioCodec, getFFmpegRtpAudioOutputArguments } from "./webrtc-required-codecs";
import { ScryptedSessionControl } from "./session-control";
import { getAudioCodec, getFFmpegRtpAudioOutputArguments, optionalVideoCodec, opusAudioCodecOnly, requiredAudioCodecs, requiredVideoCodec } from "./webrtc-required-codecs";
import { MediaStreamTrack, PeerConfig, RTCPeerConnection, RTCRtpTransceiver, RtpPacket } from "./werift";
import { WeriftSignalingSession } from "./werift-signaling-session";
import { logIsLocalIceTransport } from "./werift-util";
function getDebugModeH264EncoderArgs() {
return [
@@ -100,17 +97,18 @@ export async function createTrackForwarder(options: {
if (!mo)
return;
let mediaStreamFeedback: MediaStreamFeedback;
let hasMediaStreamFeedback = false;
try {
mediaStreamFeedback = await sdk.mediaManager.convertMediaObject(mo, ScryptedMimeTypes.MediaStreamFeedback);
const mediaStreamFeedback = await sdk.connectRPCObject(await sdk.mediaManager.convertMediaObject<MediaStreamFeedback>(mo, ScryptedMimeTypes.MediaStreamFeedback));
if (mediaStreamFeedback) {
videoTransceiver.sender.onRtcp.subscribe(rtcp => {
mediaStreamFeedback.onRtcp(rtcp.serialize());
});
hasMediaStreamFeedback = true;
}
}
catch (e) {
}
if (mediaStreamFeedback) {
videoTransceiver.sender.onRtcp.subscribe(rtcp => {
mediaStreamFeedback.onRtcp(rtcp.serialize());
});
}
const console = sdk.deviceManager.getMixinConsole(mo.sourceId);
const ffmpegInput = await sdk.mediaManager.convertMediaObjectToJSON<FFmpegInput>(mo, ScryptedMimeTypes.FFmpegInput);
@@ -256,7 +254,7 @@ export async function createTrackForwarder(options: {
}
}
if (mediaStreamFeedback)
if (hasMediaStreamFeedback)
needPacketization = false;
let opusRepacketizer: OpusRepacketizer;
@@ -460,9 +458,9 @@ export function parseOptions(options: RTCSignalingOptions) {
// Some devices return a `screen width` value that is not a multiple of 2, which is not allowed for the h264 codec.
// Convert to a smaller even value.
const screenWidthForTranscodeH264 = !options?.screen?.width
? 960
: Math.trunc(options?.screen?.width / 2) * 2;
const screenWidthForTranscodeH264 = !options?.screen?.width
? 960
: Math.trunc(options?.screen?.width / 2) * 2;
const transcodeWidth = Math.max(640, Math.min(screenWidthForTranscodeH264, 1280));
const devicePixelRatio = options?.screen?.devicePixelRatio || 1;
@@ -579,24 +577,36 @@ export class WebRTCConnectionManagement implements RTCConnectionManagement {
async probe() {
}
async createTracks(mediaObject: MediaObject, intercomId?: string) {
async createTracks(mediaObject: MediaObject) {
let requestMediaStream: RequestMediaStream;
try {
requestMediaStream = await sdk.mediaManager.convertMediaObject(mediaObject, ScryptedMimeTypes.RequestMediaStream);
requestMediaStream = await sdk.connectRPCObject(await sdk.mediaManager.convertMediaObject(mediaObject, ScryptedMimeTypes.RequestMediaStream));
}
catch (e) {
mediaObject = await sdk.connectRPCObject(mediaObject);
requestMediaStream = async () => mediaObject;
}
const intercom = sdk.systemManager.getDeviceById<Intercom>(intercomId);
const wrapped: RequestMediaStream = async options => {
if (!requestMediaStream)
throw new Error("RequestMediaStream can't be called twice");
const rms = requestMediaStream;
requestMediaStream = undefined;
const mo = rms(options);
return sdk.connectRPCObject(mo);
}
let intercom = sdk.systemManager.getDeviceById<Intercom>(mediaObject.sourceId);
if (!intercom.interfaces?.includes(ScryptedInterface.Intercom))
intercom = undefined;
const vtrack = new MediaStreamTrack({
kind: "video",
});
const atrack = new MediaStreamTrack({ kind: "audio" });
const console = sdk.deviceManager.getMixinConsole(mediaObject?.sourceId || intercomId);
const console = sdk.deviceManager.getMixinConsole(mediaObject.sourceId);
const timeStart = Date.now();
@@ -608,7 +618,7 @@ export class WebRTCConnectionManagement implements RTCConnectionManagement {
const ret = await createTrackForwarder({
timeStart,
...logIsLocalIceTransport(console, this.pc),
requestMediaStream,
requestMediaStream: wrapped,
videoTransceiver,
audioTransceiver,
maximumCompatibilityMode: this.maximumCompatibilityMode,
@@ -653,28 +663,29 @@ export class WebRTCConnectionManagement implements RTCConnectionManagement {
}
}
addInputTrack(options: { videoMid?: string; audioMid?: string; }): Promise<RTCInputMediaObjectTrack> {
throw new Error('not implemented');
}
async addTrack(mediaObject: MediaObject, options?: {
videoMid?: string,
audioMid?: string,
/**
* @deprecated
*/
intercomId?: string,
videoDirection?: 'sendrecv' | 'sendonly' | 'recvonly',
audioDirection?: 'sendrecv' | 'sendonly' | 'recvonly',
}) {
const { atrack, vtrack, createTrackForwarder, intercom } = await this.createTracks(mediaObject, options?.intercomId);
const { atrack, vtrack, createTrackForwarder, intercom } = await this.createTracks(mediaObject);
// no support for 2 way video yet.
const videoDirection = 'sendonly';
let audioDirection = options?.audioDirection || 'sendrecv';
if (!intercom) {
audioDirection = 'sendonly';
}
const videoTransceiver = this.pc.addTransceiver(vtrack, {
direction: 'sendonly',
direction: videoDirection,
});
videoTransceiver.mid = options?.videoMid;
const audioTransceiver = this.pc.addTransceiver(atrack, {
direction: intercom ? 'sendrecv' : 'sendonly',
direction: audioDirection,
});
audioTransceiver.mid = options?.audioMid;
@@ -704,6 +715,11 @@ export class WebRTCConnectionManagement implements RTCConnectionManagement {
return ret;
}
async connectRPCObject(o: any) {
// this should never actually be called as the client side should call the getParam version.
return sdk.connectRPCObject(o);
}
async close(): Promise<void> {
for (const track of this.activeTracks) {
track.cleanup(false);
@@ -725,7 +741,7 @@ export class WebRTCConnectionManagement implements RTCConnectionManagement {
export async function createRTCPeerConnectionSink(
clientSignalingSession: RTCSignalingSession,
console: Console,
intercom: ScryptedDevice & Intercom,
audioDirection: 'sendrecv' | 'sendonly' | 'recvonly',
mo: MediaObject,
requireOpus: boolean,
maximumCompatibilityMode: boolean,
@@ -742,7 +758,7 @@ export async function createRTCPeerConnectionSink(
});
const track = await connection.addTrack(mo, {
intercomId: intercom?.id,
audioDirection,
});
track.control.killed.promise.then(() => {
@@ -752,7 +768,7 @@ export async function createRTCPeerConnectionSink(
const setup: Partial<RTCAVSignalingSetup> = {
audio: {
direction: intercom ? 'sendrecv' : 'recvonly',
direction: audioDirection,
},
video: {
direction: 'recvonly',

View File

@@ -4,14 +4,13 @@ import { timeoutPromise } from '@scrypted/common/src/promise-utils';
import { legacyGetSignalingSessionOptions } from '@scrypted/common/src/rtc-signaling';
import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from '@scrypted/common/src/settings-mixin';
import { createZygote } from '@scrypted/common/src/zygote';
import sdk, { DeviceCreator, DeviceCreatorSettings, DeviceProvider, FFmpegInput, ForkWorker, Intercom, MediaConverter, MediaObject, MediaObjectOptions, MixinProvider, RTCSessionControl, RTCSignalingChannel, RTCSignalingClient, RTCSignalingOptions, RTCSignalingSession, RequestMediaStream, RequestMediaStreamOptions, ResponseMediaStreamOptions, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, ScryptedNativeId, Setting, SettingValue, Settings, VideoCamera, WritableDeviceState } from '@scrypted/sdk';
import sdk, { DeviceCreator, DeviceCreatorSettings, DeviceProvider, FFmpegInput, Intercom, MediaConverter, MediaObject, MediaObjectOptions, MixinProvider, RTCSessionControl, RTCSignalingChannel, RTCSignalingClient, RTCSignalingOptions, RTCSignalingSession, RequestMediaStream, RequestMediaStreamOptions, ResponseMediaStreamOptions, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, ScryptedNativeId, Setting, SettingValue, Settings, VideoCamera, WritableDeviceState } from '@scrypted/sdk';
import { StorageSettings } from '@scrypted/sdk/storage-settings';
import { RpcPeer } from '@scrypted/server/src/rpc';
import { createDataChannelSerializer } from '@scrypted/server/src/rpc-serializer';
import crypto from 'crypto';
import ip from 'ip';
import net from 'net';
import os from 'os';
import worker_threads from 'worker_threads';
import { DataChannelDebouncer } from './datachannel-debouncer';
import { WebRTCConnectionManagement, createRTCPeerConnectionSink, createTrackForwarder } from "./ffmpeg-to-wrtc";
import { stunServers, turnServers, weriftStunServers, weriftTurnServers } from './ice-servers';
import { waitClosed } from './peerconnection-util';
@@ -28,9 +27,12 @@ defaultPeerConfig.headerExtensions = {
audio: [],
};
function delayWorkerExit(worker: ForkWorker) {
function delayWorkerExit(f: ReturnType<WebRTCPlugin['createTrackedFork']>) {
setTimeout(() => {
worker.terminate();
f.result.then(rr => rr.exit()).catch(() => { });
}, 1000);
setTimeout(() => {
f.worker.terminate();
}, 10000);
}
@@ -130,7 +132,7 @@ class WebRTCMixin extends SettingsMixinDeviceBase<RTCSignalingClient & VideoCame
return createRTCPeerConnectionSink(
session,
this.console,
hasIntercom ? device : undefined,
hasIntercom ? 'sendrecv' : 'recvonly',
mo,
this.plugin.storageSettings.values.requireOpus,
this.plugin.storageSettings.values.maximumCompatibilityMode,
@@ -176,13 +178,13 @@ class WebRTCMixin extends SettingsMixinDeviceBase<RTCSignalingClient & VideoCame
const pcc = pcClose();
pcc.finally(() => {
this.webrtcIntercom = undefined;
delayWorkerExit(result.worker);
delayWorkerExit(result);
});
return mediaObject;
}
catch (e) {
delayWorkerExit(result.worker);
delayWorkerExit(result);
throw e;
}
}
@@ -270,7 +272,7 @@ export class WebRTCPlugin extends AutoenableMixinProvider implements DeviceCreat
},
});
activeConnections = 0;
zygote = createZygote<ReturnType<typeof fork>>();
zygote = createZygote<Awaited<ReturnType<typeof fork>>>();
constructor() {
super();
@@ -364,7 +366,7 @@ export class WebRTCPlugin extends AutoenableMixinProvider implements DeviceCreat
let connection: WebRTCConnectionManagement;
try {
const { createConnection } = await result.result;
connection = await createConnection({}, undefined, session,
connection = await createConnection(session,
this.storageSettings.values.requireOpus,
maximumCompatibilityMode,
clientOptions,
@@ -376,7 +378,7 @@ export class WebRTCPlugin extends AutoenableMixinProvider implements DeviceCreat
);
}
catch (e) {
delayWorkerExit(result.worker);
delayWorkerExit(result);
throw e;
}
await connection.negotiateRTCSignalingSession();
@@ -405,7 +407,7 @@ export class WebRTCPlugin extends AutoenableMixinProvider implements DeviceCreat
const mediaStreamUrl = rtcSource.mediaObject;
return await mediaManager.convertMediaObjectToJSON<FFmpegInput>(mediaStreamUrl, ScryptedMimeTypes.FFmpegInput);
} catch (e) {
delayWorkerExit(result.worker);
delayWorkerExit(result);
throw e;
}
}
@@ -425,11 +427,11 @@ export class WebRTCPlugin extends AutoenableMixinProvider implements DeviceCreat
try {
const connection = await timeoutPromise(2 * 60 * 1000, this.convertToRTCConnectionManagement(result, data, fromMimeType, toMimeType, options));
// wait a bit to allow ffmpegs to get terminated by the thread.
connection.waitClosed().finally(() => delayWorkerExit(result.worker));
connection.waitClosed().finally(() => delayWorkerExit(result));
return connection;
}
catch (e) {
delayWorkerExit(result.worker);
delayWorkerExit(result);
throw e;
}
}
@@ -439,7 +441,7 @@ export class WebRTCPlugin extends AutoenableMixinProvider implements DeviceCreat
return await timeoutPromise(2 * 60 * 1000, this.convertToFFmpegInput(result, data, fromMimeType, toMimeType, options));
}
catch (e) {
delayWorkerExit(result.worker);
delayWorkerExit(result);
throw e;
}
}
@@ -627,8 +629,7 @@ function delayProcessExit() {
}, 1000);
}
async function createConnection(message: any,
port: number,
async function createConnection(
clientSession: RTCSignalingSession,
requireOpus: boolean,
maximumCompatibilityMode: boolean,
@@ -680,42 +681,33 @@ async function createConnection(message: any,
const { pc } = connection;
waitClosed(pc).then(() => cleanup.resolve('peer connection closed'));
const { connectionManagementId, updateSessionId } = message;
if (connectionManagementId || updateSessionId) {
const plugins = await systemManager.getComponent('plugins');
if (connectionManagementId) {
plugins.setHostParam('@scrypted/webrtc', connectionManagementId, connection);
const dc = pc.createDataChannel('rpc');
const serializer = createDataChannelSerializer(dc);
const rpcPeer = new RpcPeer('webrtc-plugin', 'webrtc-client', serializer.sendMessage);
serializer.setupRpcPeer(rpcPeer);
dc.onmessage = (event) => {
if (event.data instanceof Buffer) {
serializer.onData(event.data);
}
if (updateSessionId) {
await plugins.setHostParam('@scrypted/webrtc', updateSessionId, (session: RTCSignalingSession) => connection.clientSession = session);
}
}
};
if (port) {
const socket = net.connect(port, '127.0.0.1');
cleanup.promise.finally(() => socket.destroy());
const dc = pc.createDataChannel('rpc');
dc.onMessage.subscribe(message => socket.write(message));
const debouncer = new DataChannelDebouncer({
send: u8 => dc.send(Buffer.from(u8)),
}, e => {
this.console.error('datachannel send error', e);
socket.destroy();
});
socket.on('data', data => debouncer.send(data));
socket.on('close', () => cleanup.resolve('socket closed'));
socket.on('error', () => cleanup.resolve('socket error'));
}
else {
pc.createDataChannel('dummy');
}
// connect webrtc plugin directly to another plugin's object and proxy it over the datachannel.
// useful for generators.
const connectRPCObject: typeof sdk.connectRPCObject = async (o) => {
const ret = await sdk.connectRPCObject(o);
return ret;
};
rpcPeer.params.connectRPCObject = connectRPCObject;
return connection;
}
export async function fork() {
return {
exit() {
delayProcessExit();
},
async createRTCPeerConnectionSource(options: {
__json_copy_serialize_children: true,
mixinId: string,
@@ -725,13 +717,15 @@ export async function fork() {
maximumCompatibilityMode: boolean,
}): Promise<RTCPeerConnectionPipe> {
try {
return await createRTCPeerConnectionSource({
const ret = await createRTCPeerConnectionSource({
nativeId: this.nativeId,
mixinId: options.mixinId,
mediaStreamOptions: options.mediaStreamOptions,
startRTCSignalingSession: (session) => options.startRTCSignalingSession(session),
maximumCompatibilityMode: options.maximumCompatibilityMode,
});
ret.pcClose().finally(() => delayProcessExit());
return ret;
}
catch (e) {
delayProcessExit();
@@ -739,8 +733,7 @@ export async function fork() {
}
},
async createConnection(message: any,
port: number,
async createConnection(
clientSession: RTCSignalingSession,
requireOpus: boolean,
maximumCompatibilityMode: boolean,
@@ -752,7 +745,16 @@ export async function fork() {
ipv4Ban?: string[];
}) {
try {
return await createConnection(message, port, clientSession, requireOpus, maximumCompatibilityMode, clientOptions, options);
const ret = await createConnection(clientSession, requireOpus, maximumCompatibilityMode, clientOptions, options);
ret.waitClosed().finally(() => delayProcessExit());
// let states: typeof ret.pc.iceConnectionState[] = [];
// ret.pc.iceConnectionStateChange.subscribe(state => {
// states.push(state);
// });
// setInterval(() => {
// console.log('Connection is still active', ret.pc.connectionState, ret.pc.iceConnectionState);
// }, 10000);
return ret;
}
catch (e) {
delayProcessExit();

View File

@@ -54,14 +54,15 @@ export function waitIceConnected(pc: RTCPeerConnection) {
})
}
export function waitClosed(pc: RTCPeerConnection) {
export async function waitClosed(pc: RTCPeerConnection) {
const connectPromise = statePromise(pc.connectionStateChange, () => {
return isPeerConnectionClosed(pc);
});
const iceConnectPromise = statePromise(pc.iceConnectionStateChange, () => {
return isPeerIceConnectionClosed(pc);
});
return Promise.any([connectPromise, iceConnectPromise]);
await Promise.any([connectPromise, iceConnectPromise]);
await pc.close();
}
export function logConnectionState(console: Console, pc: RTCPeerConnection) {

View File

@@ -367,10 +367,6 @@ export async function createRTCPeerConnectionSource(options: {
};
}
interface ReceivedRtpPacket extends RtpPacket {
uptime?: number;
}
export function getRTCMediaStreamOptions(id: string, name: string): ResponseMediaStreamOptions {
return {
// set by consumer

34
sdk/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/sdk",
"version": "0.5.33",
"version": "0.5.48",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/sdk",
"version": "0.5.33",
"version": "0.5.48",
"license": "ISC",
"dependencies": {
"@babel/preset-typescript": "^7.27.1",
@@ -20,7 +20,7 @@
"babel-loader": "^10.0.0",
"babel-plugin-const-enum": "^1.2.0",
"ncp": "^2.0.0",
"openai": "^5.3.0",
"openai": "^6.1.0",
"raw-loader": "^4.0.2",
"rimraf": "^6.0.1",
"rollup": "^4.43.0",
@@ -1448,13 +1448,13 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@@ -2053,9 +2053,9 @@
}
},
"node_modules/form-data": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -2631,16 +2631,16 @@
"license": "MIT"
},
"node_modules/openai": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-5.3.0.tgz",
"integrity": "sha512-VIKmoF7y4oJCDOwP/oHXGzM69+x0dpGFmN9QmYO+uPbLFOmmnwO+x1GbsgUtI+6oraxomGZ566Y421oYVu191w==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.1.0.tgz",
"integrity": "sha512-5sqb1wK67HoVgGlsPwcH2bUbkg66nnoIYKoyV9zi5pZPqh7EWlmSrSDjAh4O5jaIg/0rIlcDKBtWvZBuacmGZg==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
@@ -3276,9 +3276,9 @@
}
},
"node_modules/tmp": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz",
"integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==",
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"license": "MIT",
"engines": {
"node": ">=14.14"

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/sdk",
"version": "0.5.33",
"version": "0.5.48",
"description": "",
"main": "dist/src/index.js",
"exports": {
@@ -39,7 +39,7 @@
"babel-loader": "^10.0.0",
"babel-plugin-const-enum": "^1.2.0",
"ncp": "^2.0.0",
"openai": "^5.3.0",
"openai": "^6.1.0",
"raw-loader": "^4.0.2",
"rimraf": "^6.0.1",
"rollup": "^4.43.0",

View File

@@ -1,28 +1,28 @@
{
"name": "@scrypted/types",
"version": "0.5.31",
"version": "0.5.45",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/types",
"version": "0.5.31",
"version": "0.5.45",
"license": "ISC",
"dependencies": {
"openai": "^5.3.0"
"openai": "^6.1.0"
}
},
"node_modules/openai": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-5.3.0.tgz",
"integrity": "sha512-VIKmoF7y4oJCDOwP/oHXGzM69+x0dpGFmN9QmYO+uPbLFOmmnwO+x1GbsgUtI+6oraxomGZ566Y421oYVu191w==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.1.0.tgz",
"integrity": "sha512-5sqb1wK67HoVgGlsPwcH2bUbkg66nnoIYKoyV9zi5pZPqh7EWlmSrSDjAh4O5jaIg/0rIlcDKBtWvZBuacmGZg==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
@@ -36,9 +36,9 @@
},
"dependencies": {
"openai": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-5.3.0.tgz",
"integrity": "sha512-VIKmoF7y4oJCDOwP/oHXGzM69+x0dpGFmN9QmYO+uPbLFOmmnwO+x1GbsgUtI+6oraxomGZ566Y421oYVu191w==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.1.0.tgz",
"integrity": "sha512-5sqb1wK67HoVgGlsPwcH2bUbkg66nnoIYKoyV9zi5pZPqh7EWlmSrSDjAh4O5jaIg/0rIlcDKBtWvZBuacmGZg==",
"requires": {}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/types",
"version": "0.5.31",
"version": "0.5.45",
"description": "",
"main": "dist/index.js",
"author": "",
@@ -11,6 +11,6 @@
},
"types": "dist/index.d.ts",
"dependencies": {
"openai": "^5.3.0"
"openai": "^6.1.0"
}
}

View File

@@ -181,6 +181,7 @@ class ScryptedInterface(str, Enum):
Readme = "Readme"
Reboot = "Reboot"
Refresh = "Refresh"
Resolution = "Resolution"
RTCSignalingChannel = "RTCSignalingChannel"
RTCSignalingClient = "RTCSignalingClient"
Scene = "Scene"
@@ -518,8 +519,8 @@ class ChatCompletionChunk(TypedDict):
id: str # A unique identifier for the chat completion. Each chunk has the same ID.
model: str # The model to generate the completion.
object: Any # The object type, which is always .
service_tier: Any | Any | Any # Specifies the latency tier to use for processing the request. This parameter is relevant for customers subscribed to the scale tier service: - If set to 'auto', and the Project is Scale tier enabled, the system will utilize scale tier credits until they are exhausted. - If set to 'auto', and the Project is not Scale tier enabled, the request will be processed using the default service tier with a lower uptime SLA and no latency guarantee. - If set to 'default', the request will be processed using the default service tier with a lower uptime SLA and no latency guarantee. - If set to 'flex', the request will be processed with the Flex Processing service tier. [Learn more](https://platform.openai.com/docs/guides/flex-processing). - When not set, the default behavior is 'auto'. When this parameter is set, the response body will include the utilized.
system_fingerprint: str # This fingerprint represents the backend configuration that the model runs with. Can be used in conjunction with the request parameter to understand when backend changes have been made that might impact determinism.
service_tier: Any | Any | Any | Any | Any # Specifies the processing type used for serving the request. - If set to 'auto', then the request will be processed with the service tier configured in the Project settings. Unless otherwise configured, the Project will use 'default'. - If set to 'default', then the request will be processed with the standard pricing and performance for the selected model. - If set to '[flex](https://platform.openai.com/docs/guides/flex-processing)' or '[priority](https://openai.com/api-priority-processing/)', then the request will be processed with the corresponding service tier. - When not set, the default behavior is 'auto'. When the parameter is set, the response body will include the value based on the processing mode actually used to serve the request. This response value may be different from the value set in the parameter.
system_fingerprint: str
usage: CompletionUsage # An optional field that will only be present when you set in your request. When present, it contains a null value **except for the last chunk** which contains the token usage statistics for the entire request. **NOTE:** If the stream is interrupted or cancelled, you may not receive the final usage chunk which contains the total token usage for the request.
class ChatCompletionCreateParamsNonStreaming(TypedDict):
@@ -528,7 +529,7 @@ class ChatCompletionCreateParamsNonStreaming(TypedDict):
frequency_penalty: float # Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim.
function_call: Any | Any | ChatCompletionFunctionCallOption
functions: list[Function]
logit_bias: Mapping[str, float] # Modify the likelihood of specified tokens appearing in the completion. Accepts a JSON object that maps tokens (specified by their token ID in the tokenizer) to an associated bias value from -100 to 100. Mathematically, the bias is added to the logits generated by the model prior to sampling. The exact effect will vary per model, but values between -1 and 1 should decrease or increase likelihood of selection; values like -100 or 100 should result in a ban or exclusive selection of the relevant token.
logit_bias: Any # Modify the likelihood of specified tokens appearing in the completion. Accepts a JSON object that maps tokens (specified by their token ID in the tokenizer) to an associated bias value from -100 to 100. Mathematically, the bias is added to the logits generated by the model prior to sampling. The exact effect will vary per model, but values between -1 and 1 should decrease or increase likelihood of selection; values like -100 or 100 should result in a ban or exclusive selection of the relevant token.
logprobs: bool # Whether to return log probabilities of the output tokens or not. If true, returns the log probabilities of each output token returned in the of .
max_completion_tokens: float # An upper bound for the number of tokens that can be generated for a completion, including visible output tokens and [reasoning tokens](https://platform.openai.com/docs/guides/reasoning).
max_tokens: float
@@ -540,23 +541,27 @@ class ChatCompletionCreateParamsNonStreaming(TypedDict):
parallel_tool_calls: bool # Whether to enable [parallel function calling](https://platform.openai.com/docs/guides/function-calling#configuring-parallel-function-calling) during tool use.
prediction: ChatCompletionPredictionContent # Static predicted output content, such as the content of a text file that is being regenerated.
presence_penalty: float # Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics.
reasoning_effort: ReasoningEffort # **o-series models only** Constrains effort on reasoning for [reasoning models](https://platform.openai.com/docs/guides/reasoning). Currently supported values are , , and . Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response.
prompt_cache_key: str # Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the field. [Learn more](https://platform.openai.com/docs/guides/prompt-caching).
reasoning_effort: ReasoningEffort # Constrains effort on reasoning for [reasoning models](https://platform.openai.com/docs/guides/reasoning). Currently supported values are , , , and . Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response.
response_format: ResponseFormatText | ResponseFormatJSONSchema | ResponseFormatJSONObject # An object specifying the format that the model must output. Setting to enables Structured Outputs which ensures the model will match your supplied JSON schema. Learn more in the [Structured Outputs guide](https://platform.openai.com/docs/guides/structured-outputs). Setting to enables the older JSON mode, which ensures the message the model generates is valid JSON. Using is preferred for models that support it.
seed: float # This feature is in Beta. If specified, our system will make a best effort to sample deterministically, such that repeated requests with the same and parameters should return the same result. Determinism is not guaranteed, and you should refer to the response parameter to monitor changes in the backend.
service_tier: Any | Any | Any # Specifies the latency tier to use for processing the request. This parameter is relevant for customers subscribed to the scale tier service: - If set to 'auto', and the Project is Scale tier enabled, the system will utilize scale tier credits until they are exhausted. - If set to 'auto', and the Project is not Scale tier enabled, the request will be processed using the default service tier with a lower uptime SLA and no latency guarantee. - If set to 'default', the request will be processed using the default service tier with a lower uptime SLA and no latency guarantee. - If set to 'flex', the request will be processed with the Flex Processing service tier. [Learn more](https://platform.openai.com/docs/guides/flex-processing). - When not set, the default behavior is 'auto'. When this parameter is set, the response body will include the utilized.
safety_identifier: str # A stable identifier used to help detect users of your application that may be violating OpenAI's usage policies. The IDs should be a string that uniquely identifies each user. We recommend hashing their username or email address, in order to avoid sending us any identifying information. [Learn more](https://platform.openai.com/docs/guides/safety-best-practices#safety-identifiers).
seed: float
service_tier: Any | Any | Any | Any | Any # Specifies the processing type used for serving the request. - If set to 'auto', then the request will be processed with the service tier configured in the Project settings. Unless otherwise configured, the Project will use 'default'. - If set to 'default', then the request will be processed with the standard pricing and performance for the selected model. - If set to '[flex](https://platform.openai.com/docs/guides/flex-processing)' or '[priority](https://openai.com/api-priority-processing/)', then the request will be processed with the corresponding service tier. - When not set, the default behavior is 'auto'. When the parameter is set, the response body will include the value based on the processing mode actually used to serve the request. This response value may be different from the value set in the parameter.
stop: str | list[str] # Not supported with latest reasoning models and . Up to 4 sequences where the API will stop generating further tokens. The returned text will not contain the stop sequence.
store: bool # Whether or not to store the output of this chat completion request for use in our [model distillation](https://platform.openai.com/docs/guides/distillation) or [evals](https://platform.openai.com/docs/guides/evals) products.
store: bool # Whether or not to store the output of this chat completion request for use in our [model distillation](https://platform.openai.com/docs/guides/distillation) or [evals](https://platform.openai.com/docs/guides/evals) products. Supports text and image inputs. Note: image inputs over 8MB will be dropped.
stream: Any # If set to true, the model response data will be streamed to the client as it is generated using [server-sent events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format). See the [Streaming section below](https://platform.openai.com/docs/api-reference/chat/streaming) for more information, along with the [streaming responses](https://platform.openai.com/docs/guides/streaming-responses) guide for more information on how to handle the streaming events.
stream_options: ChatCompletionStreamOptions # Options for streaming response. Only set this when you set .
temperature: float # What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. We generally recommend altering this or but not both.
tool_choice: ChatCompletionToolChoiceOption # Controls which (if any) tool is called by the model. means the model will not call any tool and instead generates a message. means the model can pick between generating a message or calling one or more tools. means the model must call one or more tools. Specifying a particular tool via forces the model to call that tool. is the default when no tools are present. is the default if tools are present.
tools: list[ChatCompletionTool] # A list of tools the model may call. Currently, only functions are supported as a tool. Use this to provide a list of functions the model may generate JSON inputs for. A max of 128 functions are supported.
tools: list[ChatCompletionTool] # A list of tools the model may call. You can provide either [custom tools](https://platform.openai.com/docs/guides/function-calling#custom-tools) or [function tools](https://platform.openai.com/docs/guides/function-calling).
top_logprobs: float # An integer between 0 and 20 specifying the number of most likely tokens to return at each token position, each with an associated log probability. must be set to if this parameter is used.
top_p: float # An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or but not both.
user: str # A stable identifier for your end-users. Used to boost cache hit rates by better bucketing similar requests and to help OpenAI detect and prevent abuse. [Learn more](https://platform.openai.com/docs/guides/safety-best-practices#end-user-ids).
user: str
verbosity: Any | Any | Any # Constrains the verbosity of the model's response. Lower values will result in more concise responses, while higher values will result in more verbose responses. Currently supported values are , , and .
web_search_options: WebSearchOptions # This tool searches the web for relevant results to use in a response. Learn more about the [web search tool](https://platform.openai.com/docs/guides/tools-web-search?api-mode=chat).
class ChatCompletionTool(TypedDict):
class ChatCompletionFunctionTool(TypedDict):
"""A function tool that can be used to generate a response."""
function: FunctionDefinition
type: Any # The type of the tool. Currently, only is supported.
@@ -823,6 +828,7 @@ class ObjectsDetected(TypedDict):
detections: list[ObjectDetectionResult]
inputDimensions: tuple[float, float]
resources: VideoResource
sourceId: str # The id of the generation source. Can be a camera id or a plugin id
timestamp: float
class PanTiltZoomCapabilities(TypedDict):
@@ -861,6 +867,7 @@ class RecordedEventOptions(TypedDict):
count: float
endTime: float
exclude: list[str]
startTime: float
class RecordingStreamThumbnailOptions(TypedDict):
@@ -1086,7 +1093,7 @@ class TamperState(TypedDict):
pass
TYPES_VERSION = "0.5.31"
TYPES_VERSION = "0.5.45"
class AirPurifier:
@@ -1344,7 +1351,7 @@ class LLMTools:
async def callLLMTool(self, name: str, parameters: Mapping[str, Any]) -> CallToolResult:
pass
async def getLLMTools(self) -> list[ChatCompletionTool]:
async def getLLMTools(self) -> list[ChatCompletionFunctionTool]:
pass
@@ -1573,6 +1580,10 @@ class Refresh:
pass
class Resolution:
resolution: list[float]
class Scene:
"""Scenes control multiple different devices into a given state."""
@@ -2062,6 +2073,7 @@ class ScryptedInterfaceProperty(str, Enum):
temperature = "temperature"
temperatureUnit = "temperatureUnit"
humidity = "humidity"
resolution = "resolution"
audioVolumes = "audioVolumes"
recordingActive = "recordingActive"
ptzCapabilities = "ptzCapabilities"
@@ -2451,6 +2463,14 @@ class DeviceState:
def humidity(self, value: float):
self.setScryptedProperty("humidity", value)
@property
def resolution(self) -> list[float]:
return self.getScryptedProperty("resolution")
@resolution.setter
def resolution(self, value: list[float]):
self.setScryptedProperty("resolution", value)
@property
def audioVolumes(self) -> AudioVolumes:
return self.getScryptedProperty("audioVolumes")
@@ -2936,6 +2956,13 @@ ScryptedInterfaceDescriptors = {
],
"properties": []
},
"Resolution": {
"name": "Resolution",
"methods": [],
"properties": [
"resolution"
]
},
"Microphone": {
"name": "Microphone",
"methods": [

View File

@@ -1,10 +1,10 @@
import type { ChildProcess as NodeChildProcess } from 'child_process';
import type { Socket as NodeNetSocket } from 'net';
import type { ChatCompletionStreamParams } from 'openai/lib/ChatCompletionStream';
import type { ChatCompletionChunk, ChatCompletionCreateParamsNonStreaming, ChatCompletionMessageParam, ChatCompletion as ChatCompletionResponse, ChatCompletionTool } from 'openai/resources';
import type { ChatCompletionChunk, ChatCompletionCreateParamsNonStreaming, ChatCompletionFunctionTool, ChatCompletionMessageParam, ChatCompletion as ChatCompletionResponse } from 'openai/resources';
import type { Worker as NodeWorker } from 'worker_threads';
import { CallToolResult } from './mcp';
export type { ChatCompletionChunk, ChatCompletionCreateParamsNonStreaming, ChatCompletionCreateParamsStreaming, ChatCompletionMessageParam, ChatCompletion as ChatCompletionResponse, ChatCompletionTool } from 'openai/resources';
export type { ChatCompletionChunk, ChatCompletionCreateParamsNonStreaming, ChatCompletionCreateParamsStreaming, ChatCompletionFunctionTool, ChatCompletionMessageParam, ChatCompletion as ChatCompletionResponse } from 'openai/resources';
export type * from './mcp';
export type ScryptedNativeId = string | undefined;
@@ -475,6 +475,10 @@ export interface Camera {
getPictureOptions(): Promise<ResponsePictureOptions[]>;
}
export interface Resolution {
resolution?: number[];
}
export interface H264Info {
sei?: boolean;
stapb?: boolean;
@@ -931,6 +935,7 @@ export interface RecordedEventOptions {
startTime?: number;
endTime?: number;
count?: number;
exclude?: string[];
}
export interface EventRecorder {
@@ -1070,7 +1075,7 @@ export interface PanTiltZoomCommand {
export interface LLMTools {
getLLMTools(): Promise<ChatCompletionTool[]>;
getLLMTools(): Promise<ChatCompletionFunctionTool[]>;
callLLMTool(name: string, parameters: Record<string, any>): Promise<CallToolResult>;
}
@@ -1673,6 +1678,11 @@ export interface ObjectsDetected {
inputDimensions?: [number, number],
timestamp: number;
resources?: VideoResource;
/**
* The id of the generation source.
* Can be a camera id or a plugin id
*/
sourceId?: string;
}
export type ObjectDetectionClass = 'motion' | 'face' | 'person' | string;
export interface ObjectDetectionTypes {
@@ -2380,6 +2390,7 @@ export enum ScryptedInterface {
Thermometer = "Thermometer",
HumiditySensor = "HumiditySensor",
Camera = "Camera",
Resolution = "Resolution",
Microphone = "Microphone",
AudioVolumeControl = "AudioVolumeControl",
Display = "Display",
@@ -2552,6 +2563,10 @@ export interface RTCMediaObjectTrack {
stop(): Promise<void>;
}
export interface RTCGeneratorDataChannel {
close(): Promise<void>;
}
/**
* @category WebRTC Reference
*/
@@ -2577,15 +2592,10 @@ export interface RTCConnectionManagement {
addTrack(mediaObject: MediaObject, options?: {
videoMid?: string,
audioMid?: string,
/**
* @deprecated
*/
intercomId?: string,
videoDirection?: 'sendrecv' | 'sendonly' | 'recvonly',
audioDirection?: 'sendrecv' | 'sendonly' | 'recvonly',
}): Promise<RTCOutputMediaObjectTrack>;
addInputTrack(options: {
videoMid?: string,
audioMid?: string,
}): Promise<RTCInputMediaObjectTrack>;
connectRPCObject<T>(value: T): Promise<T>;
close(): Promise<void>;
probe(): Promise<void>;
}
@@ -2832,6 +2842,13 @@ export interface ClusterManager {
getClusterWorkers(): Promise<Record<string, ClusterWorker>>;
}
export interface ConnectRPCObjectOptions {
dedicatedTransport?: {
receiveTimeout?: number;
sendTimeout?: number;
};
}
export interface ScryptedStatic {
/**
* @deprecated
@@ -2866,5 +2883,5 @@ export interface ScryptedStatic {
* through the Scrypted Server which typically manages plugin communication.
* This is ideal for sending large amounts of data.
*/
connectRPCObject<T>(value: T): Promise<T>;
connectRPCObject<T>(value: T, options?: ConnectRPCObjectOptions): Promise<T>;
}

View File

@@ -1,18 +1,18 @@
{
"name": "@scrypted/server",
"version": "0.141.0",
"version": "0.142.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/server",
"version": "0.141.0",
"version": "0.142.10",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"@scrypted/ffmpeg-static": "^6.1.0-build3",
"@scrypted/node-pty": "^1.0.24",
"@scrypted/types": "^0.5.23",
"@scrypted/node-pty": "^1.0.25",
"@scrypted/types": "^0.5.43",
"adm-zip": "^0.5.16",
"body-parser": "^2.2.0",
"cookie-parser": "^1.4.7",
@@ -584,9 +584,9 @@
}
},
"node_modules/@scrypted/node-pty": {
"version": "1.0.24",
"resolved": "https://registry.npmjs.org/@scrypted/node-pty/-/node-pty-1.0.24.tgz",
"integrity": "sha512-UyrsMRsH0hRazVDUeA+oBrIPcoryDhXXD5fVS/z19q4zHube20Tj7xvP1AWte1NGGQ7AnIZiK8EPlQgweXC15g==",
"version": "1.0.25",
"resolved": "https://registry.npmjs.org/@scrypted/node-pty/-/node-pty-1.0.25.tgz",
"integrity": "sha512-ko1Yoq/ecEnmfZDRzlIV7Gn+jlpGmeR/fK9OQWj9H2TyzD6ZL40JR/f/hMnEBMF9iiK6811dEsokO/Ldr8AEmg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -594,9 +594,9 @@
}
},
"node_modules/@scrypted/types": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.5.23.tgz",
"integrity": "sha512-is/UJHgS3lvEuXyb+C/OPeIP5CKp+M6SQt1l/WFJr1Oj+KYYHGU8Ztlh/qOmAWgONhg286N4/cLNzTtAAh4YnA==",
"version": "0.5.43",
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.5.43.tgz",
"integrity": "sha512-mA+UirHLUpSyf6w5G1sSs/FTOYywORqiAZS8nEbcTCEZBuia0m8ZLMJBIUO3CUQYIsqpP6rhgap9DpusxWHcaQ==",
"license": "ISC",
"dependencies": {
"openai": "^5.3.0"

View File

@@ -1,11 +1,11 @@
{
"name": "@scrypted/server",
"version": "0.141.0",
"version": "0.143.0",
"description": "",
"dependencies": {
"@scrypted/ffmpeg-static": "^6.1.0-build3",
"@scrypted/node-pty": "^1.0.24",
"@scrypted/types": "^0.5.23",
"@scrypted/node-pty": "^1.0.25",
"@scrypted/types": "^0.5.43",
"adm-zip": "^0.5.16",
"body-parser": "^2.2.0",
"cookie-parser": "^1.4.7",

Some files were not shown because too many files have changed in this diff Show More