Compare commits

...

104 Commits

Author SHA1 Message Date
Koushik Dutta
7128af20af postbeta 2025-02-04 19:23:00 -08:00
Koushik Dutta
c651c2164b server: fixup cluster worker hook 2025-02-04 19:22:49 -08:00
Koushik Dutta
6caafd73f5 postbeta 2025-02-04 19:19:38 -08:00
Koushik Dutta
05cb505783 server: hook cluster creation for electron 2025-02-04 19:19:30 -08:00
Koushik Dutta
07baddc9c3 sdk: update detection properties 2025-02-04 13:59:39 -08:00
Koushik Dutta
76ac260bf7 hikvision: fix unhandled rejection parsing camera object detection 2025-02-04 07:45:36 -08:00
Koushik Dutta
dfee7c6b09 Merge branch 'main' of github.com:koush/scrypted 2025-02-04 07:37:46 -08:00
Koushik Dutta
b3ce6a2af3 postbeta 2025-02-04 07:37:15 -08:00
Koushik Dutta
933c0cac0f postrelease 2025-02-04 07:37:02 -08:00
apocaliss92
1fb1334a00 snapshot: Sleeping cameras should not wake for periodic snapshots (#1718)
* Preserve battery on snapshots

* Don't force snapshot below 1 min

* Online interface changes

* Pr comments fix

* Interval removed

* Debounce restored

* Branching fixes

* Fix isBattery leftover

* Remove prebuffer check

* Remove comment

* Remove unused import

* Use Sleep interface

* Disable default prebuffer for Sleep devices

* Rollback default changes

* Unused import removed

---------

Co-authored-by: Gianluca Ruocco <gianluca.ruocco@xarvio.com>
2025-02-03 10:55:58 -08:00
apocaliss92
cb45a00c25 reolink: Battery cams api fixes (#1719)
* Battery cams api fixes

* Update with new Sleep class

---------

Co-authored-by: Gianluca Ruocco <gianluca.ruocco@xarvio.com>
2025-02-03 08:51:45 -08:00
Koushik Dutta
fec59af263 core: support cluster fork for terminal 2025-02-02 22:34:44 -08:00
Koushik Dutta
5d213a4c51 Merge branch 'main' of github.com:koush/scrypted 2025-02-02 22:33:28 -08:00
Koushik Dutta
d444c4ab7c sdk: update 2025-02-02 22:33:23 -08:00
Brett Jia
590f955ca9 core: terminalservice fork across cluster (#1721)
* core: terminalservice fork across cluster

* exit cluster fork on completion

* force terminate on errors

* make isClusterFork internal to prevent callers from killing core plugin

* implement forkInterface and share forks

* use correct native id

* use correct native id in primary device construction
2025-02-01 22:33:29 -08:00
Koushik Dutta
7df4bf2723 postbeta 2025-02-01 19:28:40 -08:00
Brett Jia
3416347a1f server/python: fix hash calculation (#1720) 2025-02-01 19:28:17 -08:00
Koushik Dutta
c669bb8902 snapshot: do not wake sleeping cameras for periodic snapshots 2025-02-01 10:51:46 -08:00
Koushik Dutta
ce5fd2d4fd Merge branch 'main' of github.com:koush/scrypted 2025-01-31 20:14:00 -08:00
Koushik Dutta
fa8a756059 sdk: critical alerts 2025-01-31 20:13:58 -08:00
apocaliss92
73b85e1cd0 homekit: Fix autoadd (#1716)
Co-authored-by: Gianluca Ruocco <gianluca.ruocco@xarvio.com>
2025-01-31 14:49:12 -08:00
Koushik Dutta
1300073712 videoanalysis: publish audio sensor 2025-01-29 11:18:19 -08:00
Koushik Dutta
3e296e12a5 core: publish audio sensor ui 2025-01-29 11:11:19 -08:00
Koushik Dutta
bf98060a08 videoanalysis: fixup noisy startup 2025-01-29 11:02:13 -08:00
Koushik Dutta
d1cd380123 videoanalysis: initial implemnetation of audio sensor 2025-01-29 10:39:10 -08:00
Koushik Dutta
1a2aadfb52 rebroadcast: fix audio soft mute with adaptive bitrate and other downstream clients 2025-01-29 08:48:55 -08:00
Koushik Dutta
60c854a477 ha: publish beta 2025-01-27 13:08:45 -08:00
Koushik Dutta
0790b60122 postbeta 2025-01-27 13:03:14 -08:00
Koushik Dutta
a3caa09df4 server: fixup node modules search path on HA 2025-01-27 13:03:06 -08:00
Koushik Dutta
02ca8bd765 reolink: publish 2025-01-27 11:48:51 -08:00
apocaliss92
f9e1a94ab3 reolink: support additional trackmix (#1711)
* Add support for Trackmix Series W760

* settings restored

* Settings restored

---------

Co-authored-by: Gianluca Ruocco <gianluca.ruocco@xarvio.com>
2025-01-27 11:45:52 -08:00
Koushik Dutta
dd0da26df3 ha: publish 2025-01-27 11:44:57 -08:00
Koushik Dutta
890f2e8daf postbeta 2025-01-26 22:26:56 -08:00
Koushik Dutta
2c8babe3ce postrelease 2025-01-26 22:26:48 -08:00
Koushik Dutta
8e31b5f970 homekit: fixup exports, publish 2025-01-24 10:52:26 -08:00
Nick Berardi
0873a72848 homekit: moved humidity settings to common and added characteristics to expose settings Home Assistant (#1699) 2025-01-24 10:51:28 -08:00
Koushik Dutta
145c66e1c8 doorbird: publish 2025-01-24 10:15:04 -08:00
r3dDoX
2b60b45113 doorbird: update underlying doorbird api package (#1705) 2025-01-24 10:12:51 -08:00
Koushik Dutta
6f63927e2f core: publish 2025-01-23 19:34:41 -08:00
Koushik Dutta
528eabdfc0 sdk: improve StorageSettings deviceFilter 2025-01-23 19:33:42 -08:00
Koushik Dutta
e201ea1fc1 doorbird: fix build 2025-01-23 13:11:31 -08:00
Koushik Dutta
7790810b86 server: cleanup launch.json 2025-01-23 09:23:13 -08:00
Koushik Dutta
e9ec78909b core: Fix missing buttons 2025-01-22 13:46:38 -08:00
Koushik Dutta
26245e17ca core: publish button support 2025-01-22 13:22:49 -08:00
Koushik Dutta
5d87a1b2dd sdk: PressButtons 2025-01-22 12:57:27 -08:00
Koushik Dutta
e1efde3868 postbeta 2025-01-22 12:00:35 -08:00
Koushik Dutta
525eb028c6 sdk: Buttons interface 2025-01-22 10:14:42 -08:00
Koushik Dutta
520c6a62a1 Merge branch 'main' of github.com:koush/scrypted 2025-01-21 13:48:43 -08:00
Koushik Dutta
6e6898ce33 common/rebroadcast: change rtp packet size to 32000 since that is what is supported on darwin for some reason 2025-01-21 13:48:38 -08:00
Koushik Dutta
1344c9112c server: fixup potential unhandled errors in sdk fork 2025-01-21 09:50:44 -08:00
Koushik Dutta
f2148ce26a hikvision: publish 2025-01-20 19:37:37 -08:00
Koushik Dutta
81b00195d6 Merge branch 'main' of github.com:koush/scrypted 2025-01-20 19:36:58 -08:00
Koushik Dutta
8f71778f05 core: publish 2025-01-20 19:36:54 -08:00
George Talusan
2e5b8d90aa hikvision: add ERI-K104-P4 to the list of NVRs that doesn't support channel cap checks (#1698) 2025-01-19 00:32:36 -08:00
Koushik Dutta
780182b94a fix npm-install.sh 2025-01-18 15:04:59 -08:00
Brett Jia
57480f7606 actions: add Linux arm64 runner to tests (#1696) 2025-01-17 16:50:35 -08:00
Koushik Dutta
1478684120 Update install-nvidia-container-toolkit.sh 2025-01-17 14:58:45 -08:00
Koushik Dutta
223b302bed core: publish new ui with lxc-docker update fix 2025-01-16 13:28:28 -08:00
Koushik Dutta
f56cef1b50 postbeta 2025-01-16 12:04:53 -08:00
Koushik Dutta
83bfa30d4b server: improve abi/server change detection 2025-01-16 12:04:43 -08:00
Koushik Dutta
611674af46 rebroadcast: publish 2025-01-16 08:24:01 -08:00
Koushik Dutta
941ea7f346 Update bug_report.md 2025-01-16 08:06:59 -08:00
Koushik Dutta
2b9c2956d6 Update bug_report.md 2025-01-16 08:05:28 -08:00
Koushik Dutta
266d5bf8a3 Update bug_report.md 2025-01-16 07:19:41 -08:00
Koushik Dutta
d0007fc7bb postbeta 2025-01-15 14:53:20 -08:00
Koushik Dutta
75f90b78eb postrelease 2025-01-15 14:53:20 -08:00
Simon Marty
1e8959413e Fix path in comment (#1694) 2025-01-15 14:43:37 -08:00
Koushik Dutta
1301247ea3 docker: update base version 2025-01-15 14:41:56 -08:00
Koushik Dutta
2798fe4d3d server: document insane synology bug. 2025-01-15 14:40:18 -08:00
Koushik Dutta
55a76a86dc rebroadcast: fixup output args example 2025-01-14 12:51:34 -08:00
Koushik Dutta
cebd49fadb Update config.yaml 2025-01-09 03:49:40 -08:00
Koushik Dutta
90adb11f27 Update config.yaml 2025-01-08 20:37:59 -08:00
Koushik Dutta
cea5c95c82 dummy-switch: select which interfaces to implement 2025-01-08 00:21:58 -08:00
Koushik Dutta
0405e13181 videoanalysis: fix zone math 2025-01-07 23:48:14 -08:00
Koushik Dutta
5659499c16 videoanalysis: simplify normalization 2025-01-07 18:52:59 -08:00
Koushik Dutta
d272a4b86f rebroadcast: fix basic auth 2025-01-07 18:52:36 -08:00
Koushik Dutta
f8a8ed4241 videoanalysis: fix broken concave polygon math, optimize for intersect boolean rather than intersect polygon 2025-01-07 18:04:37 -08:00
Koushik Dutta
892b978065 common: stapa idr is techcnically valid, seen on tapo 2025-01-05 13:20:34 -08:00
Koushik Dutta
c81c55c12e homekit/webrtc: publish remove warnings 2025-01-05 12:35:42 -08:00
Koushik Dutta
bb9d98921b homekit: remove report message 2025-01-05 11:48:21 -08:00
Koushik Dutta
4c66efc4af sdk: tag === collapse key 2025-01-05 08:20:21 -08:00
Koushik Dutta
0547ed9a32 sdk: add collapseId to notifications 2025-01-05 00:13:31 -08:00
Koushik Dutta
b046822282 common: rtsp client generator read support 2025-01-04 20:08:28 -08:00
Koushik Dutta
b033d24451 rebroadcast: implement synthetic streams 2025-01-03 23:15:45 -08:00
Koushik Dutta
15464229ad wyze: improve default bitrates 2025-01-03 22:02:41 -08:00
Koushik Dutta
93ad50db73 Merge branch 'main' of github.com:koush/scrypted 2025-01-03 21:55:22 -08:00
Koushik Dutta
427139e8df rebroadcast: wip remove transcode extension 2025-01-03 21:55:20 -08:00
Koushik Dutta
b1100398ec server: log cluster connect errors 2025-01-03 19:32:42 -08:00
Mike Marcacci
b40a2eaf6e common: remove dead code path
While familiarizing myself with the architecture of this project I noticed that this block is unreachable and handled above. Figured I'd submit a quick fix.

Awesome project BTW.
2025-01-03 19:19:28 -08:00
Koushik Dutta
17c9440fd9 videoanalysis: fix package detection area 2025-01-03 09:15:57 -08:00
Koushik Dutta
ea63a96444 sdk: fixup call to setScryptedInterfaceDescriptors 2025-01-03 08:36:00 -08:00
Koushik Dutta
0f02f96b89 sdk: remove chalk 2025-01-03 08:26:01 -08:00
Koushik Dutta
6ce538bb23 rebroadcast: setting for default parser 2025-01-02 11:06:24 -08:00
Koushik Dutta
29ab0e79de rebroadcast: use large rtp packets with ffmpeg for efficient processing 2025-01-02 08:58:15 -08:00
Koushik Dutta
e07cd13ef3 core: fix lnk upgrade link 2025-01-02 08:17:45 -08:00
Koushik Dutta
0cbb26051c cloud: fix health check 2025-01-02 08:16:21 -08:00
Koushik Dutta
fcb8d938ee videoanalysis: fix smart motion sensor settings nre 2024-12-31 17:32:55 -08:00
Koushik Dutta
98fe1d412a openvino: do post processing inside callback rather than copy + thread post process 2024-12-31 15:03:16 -08:00
Koushik Dutta
c19ec63f98 openvino: fix ov.Tensor.data race condition 2024-12-31 12:44:38 -08:00
Koushik Dutta
a41e915f69 openvino: avoid ov.Tensor when using start_async due to thread safety? 2024-12-31 12:26:28 -08:00
Koushik Dutta
f0db59f6d2 openvino: fix thread affinity to possibly avoid async race conditions 2024-12-31 12:11:39 -08:00
Brett Jia
8e691ff2ee server: check if SCRYPTED_PYTHON*_PATH env points to valid path (#1670) 2024-12-31 06:44:28 -08:00
Koushik Dutta
42e0810bc0 postbeta 2024-12-30 21:37:23 -08:00
Koushik Dutta
68e91ad996 postrelease 2024-12-30 21:37:17 -08:00
86 changed files with 1488 additions and 903 deletions

View File

@@ -27,6 +27,11 @@ Created issues that do not meet these requirements or are improperly filled out
1. Delete this section and everything above it.
2. Fill out the sections below.
** Before You Submit**
- [ ] I checked that my issue isn't already filed: [Search open issues](https://github.com/koush/scrypted/issues).
- [ ] I checked the relevant camera/device and/or plugin `Log` in the `Management Console` for errors or warnings that may help identify and resolve the issue myself.
**Describe the bug**
A clear and concise description of what the bug is. The issue tracker is only for reporting bugs in Scrypted, for general support check Discord. Hardrware support requests or assistance requests will be immediately closed.
@@ -43,6 +48,9 @@ A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Logs**
Include a `Log` from the device/camera in the management console (and if applicable, the affacted plugin, like HomeKit).
**Server (please complete the following information):**
- OS: [e.g. Ubuntu]
- Installation Method: [e.g. Desktop App, Docker, Local]

View File

@@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
runner: [ubuntu-latest, macos-14, macos-13, windows-latest]
runner: [ubuntu-latest, ubuntu-24.04-arm, macos-14, macos-13, windows-latest]
steps:
- name: Checkout repository

View File

@@ -70,11 +70,7 @@ export function getH264DecoderArgs(): CodecArgs {
],
};
if (isRaspberryPi()) {
ret['Raspberry Pi'] = ['-c:v', 'h264_mmal'];
ret[V4L2] = ['-c:v', 'h264_v4l2m2m'];
}
else if (os.platform() === 'linux') {
if (os.platform() === 'linux') {
ret[V4L2] = ['-c:v', 'h264_v4l2m2m'];
}
else if (os.platform() === 'win32') {

View File

@@ -247,6 +247,8 @@ export function createRtspParser(options?: StreamParserOptions): RtspStreamParse
'tcp',
...(options?.vcodec || []),
...(options?.acodec || []),
// linux and windows seem to support 64000 but darwin is 32000?
'-pkt_size', '32000',
'-f', 'rtsp',
],
findSyncFrame(streamChunks: StreamChunk[]) {
@@ -394,7 +396,7 @@ export class RtspClient extends RtspBase {
hasGetParameter = true;
contentBase: string;
constructor(public url: string) {
constructor(public readonly url: string) {
super();
const u = new URL(url);
const port = parseInt(u.port) || 554;
@@ -511,6 +513,42 @@ export class RtspClient extends RtspBase {
}
}
async *handleStream(): AsyncGenerator<{
rtcp: boolean,
header: Buffer,
packet: Buffer,
channel: number,
}> {
while (true) {
const header = await readLength(this.client, 4);
// can this even happen? since the RTSP request method isn't a fixed
// value like the "RTSP" in the RTSP response, I don't think so?
if (header[0] !== RTSP_FRAME_MAGIC) {
if (header.toString() !== 'RTSP')
throw this.createBadHeader(header);
this.client.unshift(header);
// do what with this?
const message = await super.readMessage();
const body = await this.readBody(parseHeaders(message));
continue;
}
const length = header.readUInt16BE(2);
const packet = await readLength(this.client, length);
const id = header.readUInt8(1);
yield {
channel: id,
rtcp: id % 2 === 1,
header,
packet,
}
}
}
async readLoop() {
const deferred = new Deferred<void>();
@@ -613,7 +651,8 @@ export class RtspClient extends RtspBase {
const { parseHTTPHeadersQuotedKeyValueSet } = await import('http-auth-utils/dist/utils');
if (this.wwwAuthenticate.includes('Basic')) {
const hash = BASIC.computeHash(url);
const parsedUrl = new URL(this.url);
const hash = BASIC.computeHash({ username: parsedUrl.username, password: parsedUrl.password });
return `Basic ${hash}`;
}

View File

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

View File

@@ -16,6 +16,6 @@ ENV NODE_OPTIONS="--dns-result-order=ipv4first"
# changing this forces pip and npm to perform reinstalls.
# if this base image changes, this version must be updated.
ENV SCRYPTED_BASE_VERSION="20240321"
ENV SCRYPTED_BASE_VERSION="20250101"
CMD ["/bin/sh", "-c", "ulimit -c 0; exec npm --prefix /server exec scrypted-serve"]

View File

@@ -46,6 +46,6 @@ ENV NODE_OPTIONS="--dns-result-order=ipv4first"
# changing this forces pip and npm to perform reinstalls.
# if this base image changes, this version must be updated.
ENV SCRYPTED_BASE_VERSION="20240321"
ENV SCRYPTED_BASE_VERSION="20250101"
CMD ["/bin/sh", "-c", "ulimit -c 0; exec npm --prefix /server exec scrypted-serve"]

View File

@@ -55,7 +55,7 @@ services:
# Scrypted NVR Storage (Part 3 of 3)
# Modify to add the additional volume for Scrypted NVR.
# The following example would mount the /mnt/sda/video path on the host
# The following example would mount the /mnt/media/video path on the host
# to the /nvr path inside the docker container.
# - /mnt/media/video:/nvr

View File

@@ -36,9 +36,8 @@ curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | gpg --yes --dea
tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
apt -y update
# is there a way to get a versioned package automatically?
apt -y install nvidia-utils-560
apt -y install cuda-drivers
apt -y install nvidia-container-toolkit
nvidia-ctk runtime configure --runtime=docker
nvidia-ctk config --set nvidia-container-cli.no-cgroups --in-place
systemctl restart docker

View File

@@ -27,7 +27,7 @@ echo "external/werift > npm install"
npm install
popd
for directory in rtsp amcrest onvif hikvision reolink unifi-protect webrtc homekit
for directory in rtsp ffmpeg-camera amcrest onvif hikvision reolink unifi-protect webrtc homekit
do
echo "$directory > npm install"
pushd plugins/$directory

View File

@@ -1,25 +1,25 @@
{
"name": "@scrypted/client",
"version": "1.3.9",
"version": "1.3.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/client",
"version": "1.3.9",
"version": "1.3.10",
"license": "ISC",
"dependencies": {
"@scrypted/types": "^0.3.92",
"engine.io-client": "^6.6.1",
"@scrypted/types": "^0.3.100",
"engine.io-client": "^6.6.2",
"follow-redirects": "^1.15.9",
"rimraf": "^6.0.1"
},
"devDependencies": {
"@types/ip": "^1.1.3",
"@types/node": "^22.7.4",
"@types/node": "^22.10.7",
"@types/ws": "^8.5.13",
"ts-node": "^10.9.2",
"typescript": "^5.6.2"
"typescript": "^5.7.3"
}
},
"node_modules/@cspotcode/source-map-support": {
@@ -76,9 +76,9 @@
}
},
"node_modules/@scrypted/types": {
"version": "0.3.92",
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.3.92.tgz",
"integrity": "sha512-/M1Lg42/yoFWusj5+Lyp2S0JCiWDDWcmsjiUnTf1DahZ6/M2oZ3bwR/0KX3D9vJE79owWST1Gm0+Rdvpxuil9A==",
"version": "0.3.100",
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.3.100.tgz",
"integrity": "sha512-s/07QCxjMWqODgWj2UpLehzeo2cGFrCA9X8mvpG3owT/+q+sb8v/UUcw9TLHGSN6yIriNhceg3i9WO07kEIT6A==",
"license": "ISC"
},
"node_modules/@socket.io/component-emitter": {
@@ -120,12 +120,13 @@
}
},
"node_modules/@types/node": {
"version": "22.7.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.4.tgz",
"integrity": "sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==",
"version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
"undici-types": "~6.20.0"
}
},
"node_modules/@types/ws": {
@@ -272,9 +273,10 @@
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
},
"node_modules/engine.io-client": {
"version": "6.6.1",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.1.tgz",
"integrity": "sha512-aYuoak7I+R83M/BBPIOs2to51BmFIpC1wZe6zZzMrT2llVsHy5cvcmdsJgP2Qz6smHu+sD9oexiSUAVd8OfBPw==",
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.2.tgz",
"integrity": "sha512-TAr+NKeoVTjEVW8P3iHguO1LO6RlUz9O5Y8o7EY0fU+gY1NYqas7NN3slpFtbXEsLMHk0h90fJMfKjRkQ0qUIw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
@@ -623,10 +625,11 @@
}
},
"node_modules/typescript": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz",
"integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==",
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -636,10 +639,11 @@
}
},
"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.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/client",
"version": "1.3.9",
"version": "1.3.10",
"description": "",
"main": "dist/packages/client/src/index.js",
"scripts": {
@@ -13,14 +13,14 @@
"license": "ISC",
"devDependencies": {
"@types/ip": "^1.1.3",
"@types/node": "^22.7.4",
"@types/node": "^22.10.7",
"@types/ws": "^8.5.13",
"ts-node": "^10.9.2",
"typescript": "^5.6.2"
"typescript": "^5.7.3"
},
"dependencies": {
"@scrypted/types": "^0.3.92",
"engine.io-client": "^6.6.1",
"@scrypted/types": "^0.3.100",
"engine.io-client": "^6.6.2",
"follow-redirects": "^1.15.9",
"rimraf": "^6.0.1"
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/cloud",
"version": "0.2.48",
"version": "0.2.49",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/cloud",
"version": "0.2.48",
"version": "0.2.49",
"dependencies": {
"@eneris/push-receiver": "^4.3.0",
"@scrypted/common": "file:../../common",

View File

@@ -52,5 +52,5 @@
"@types/node": "^22.10.1",
"ts-node": "^10.9.2"
},
"version": "0.2.48"
"version": "0.2.49"
}

View File

@@ -1046,12 +1046,16 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
args['--url'] = tunnelUrl;
}
// if error messages are detected after 10 minutes from tunnel attempt start,
// kill the tunnel.
const tenMinutesMs = 10 * 60 * 1000;
const tunnelStart = Date.now();
const deferred = new Deferred<string>();
const cloudflareTunnel = cloudflared.tunnel(args);
deferred.resolvePromise(cloudflareTunnel.url);
const processData = (string: string) => {
this.console.error(string);
const lines = string.split('\n');
for (const line of lines) {
if ((line.includes('Unregistered tunnel connection')
@@ -1059,12 +1063,10 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
|| line.includes('Register tunnel error')
|| line.includes('Failed to serve tunnel')
|| line.includes('Failed to get tunnel'))
&& deferred.finished) {
this.console.warn('Cloudflare registration failed after tunnel started. The old tunnel may be invalid. Terminating.');
&& (deferred.finished || Date.now() - tunnelStart > tenMinutesMs)) {
this.console.warn('Cloudflare registration failure detected. Terminating.');
cloudflareTunnel.child.kill();
}
if (line.includes('hostname'))
this.console.log(line);
const match = /config=(".*?}")/gm.exec(line)
if (match) {
const json = match[1];
@@ -1109,7 +1111,10 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
throw e;
}
this.console.log(`cloudflare url mapped ${this.cloudflareTunnel} to ${tunnelUrl}`);
return cloudflareTunnel;
return {
url: deferred.promise,
child: cloudflareTunnel.child,
};
}, {
startingDelay: 60000,
timeMultiple: 1.2,

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ import { MediaCore } from './media-core';
import { checkLegacyLxc, checkLxc } from './platform/lxc';
import { ConsoleServiceNativeId, PluginSocketService, ReplServiceNativeId } from './plugin-socket-service';
import { ScriptCore, ScriptCoreNativeId, newScript } from './script-core';
import { TerminalService, TerminalServiceNativeId } from './terminal-service';
import { TerminalService, TerminalServiceNativeId, newTerminalService } from './terminal-service';
import { UsersCore, UsersNativeId } from './user';
import { ClusterCore, ClusterCoreNativeId } from './cluster';
@@ -140,7 +140,7 @@ class ScryptedCore extends ScryptedDeviceBase implements HttpRequestHandler, Dev
{
name: 'Terminal Service',
nativeId: TerminalServiceNativeId,
interfaces: [ScryptedInterface.StreamService, ScryptedInterface.TTY],
interfaces: [ScryptedInterface.StreamService, ScryptedInterface.TTY, ScryptedInterface.ClusterForkInterface],
type: ScryptedDeviceType.Builtin,
},
);
@@ -242,7 +242,7 @@ class ScryptedCore extends ScryptedDeviceBase implements HttpRequestHandler, Dev
if (nativeId === UsersNativeId)
return this.users ||= new UsersCore();
if (nativeId === TerminalServiceNativeId)
return this.terminalService ||= new TerminalService();
return this.terminalService ||= new TerminalService(TerminalServiceNativeId, false);
if (nativeId === ReplServiceNativeId)
return this.replService ||= new PluginSocketService(ReplServiceNativeId, 'repl');
if (nativeId === ConsoleServiceNativeId)
@@ -331,5 +331,6 @@ export async function fork() {
return {
tsCompile,
newScript,
newTerminalService,
}
}

View File

@@ -8,7 +8,7 @@ export async function checkLegacyLxc() {
if (process.env.SCRYPTED_INSTALL_ENVIRONMENT !== SCRYPTED_INSTALL_ENVIRONMENT_LXC)
return;
sdk.log.a('This system is currently running the legacy LXC installation method and must be migrated to the new LXC manually: https://docs.scrypted.app/installation.html#proxmox-ve-container-reset');
sdk.log.a('This system is currently running the legacy LXC installation method and must be migrated to the new LXC manually: https://docs.scrypted.app/install/proxmox-ve.html#proxmox-ve-container-reset');
}
const DOCKER_COMPOSE_SH_PATH = '/root/.scrypted/docker-compose.sh';

View File

@@ -1,4 +1,4 @@
import sdk, { ScryptedDeviceBase, ScryptedInterface, ScryptedNativeId, StreamService, TTYSettings } from "@scrypted/sdk";
import sdk, { ClusterForkInterface, ClusterForkInterfaceOptions, ScryptedDeviceBase, ScryptedInterface, ScryptedNativeId, StreamService, TTYSettings } from "@scrypted/sdk";
import type { IPty, spawn as ptySpawn } from 'node-pty';
import { createAsyncQueue } from '@scrypted/common/src/async-queue'
import { ChildProcess, spawn as childSpawn } from "child_process";
@@ -111,8 +111,11 @@ class NoninteractiveTerminal {
}
export class TerminalService extends ScryptedDeviceBase implements StreamService<Buffer | string, Buffer> {
constructor(nativeId?: ScryptedNativeId) {
export class TerminalService extends ScryptedDeviceBase implements StreamService<Buffer | string, Buffer>, ClusterForkInterface {
private forks: { [clusterWorkerId: string]: TerminalService } = {};
private forkClients: 0;
constructor(nativeId?: ScryptedNativeId, private isFork: boolean = false) {
super(nativeId);
}
@@ -134,6 +137,42 @@ export class TerminalService extends ScryptedDeviceBase implements StreamService
return extraPaths;
}
async forkInterface<StreamService>(forkInterface: ScryptedInterface, options?: ClusterForkInterfaceOptions): Promise<StreamService> {
if (forkInterface !== ScryptedInterface.StreamService) {
throw new Error('can only fork StreamService');
}
if (!options?.clusterWorkerId) {
throw new Error('clusterWorkerId required');
}
if (this.isFork) {
throw new Error('cannot fork a fork');
}
const clusterWorkerId = options.clusterWorkerId;
if (this.forks[clusterWorkerId]) {
return this.forks[clusterWorkerId] as StreamService;
}
const fork = sdk.fork<{
newTerminalService: typeof newTerminalService,
}>({ clusterWorkerId });
try {
const result = await fork.result;
const terminalService = await result.newTerminalService();
this.forks[clusterWorkerId] = terminalService;
fork.worker.on('exit', () => {
delete this.forks[clusterWorkerId];
});
return terminalService as StreamService;
}
catch (e) {
fork.worker.terminate();
throw e;
}
}
/*
* The input to this stream can send buffers for normal terminal data and strings
* for control messages. Control messages are JSON-formatted.
@@ -149,6 +188,19 @@ export class TerminalService extends ScryptedDeviceBase implements StreamService
const queue = createAsyncQueue<Buffer>();
const extraPaths = await this.getExtraPaths();
if (this.isFork) {
this.forkClients++;
}
queue.endPromise.then(() => {
if (this.isFork) {
this.forkClients--;
if (this.forkClients === 0) {
process.exit();
}
}
});
function registerChildListeners() {
cp.onExit(() => queue.end());
@@ -232,4 +284,8 @@ export class TerminalService extends ScryptedDeviceBase implements StreamService
return generator();
}
}
export async function newTerminalService(): Promise<TerminalService> {
return new TerminalService(TerminalServiceNativeId, true);
}

View File

@@ -1,19 +1,19 @@
{
"name": "@scrypted/doorbird",
"version": "0.0.2",
"version": "0.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/doorbird",
"version": "0.0.2",
"version": "0.0.4",
"dependencies": {
"doorbird": "^2.1.2"
"doorbird": "2.6.0"
},
"devDependencies": {
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"@types/node": "^18.15.11",
"@types/node": "^22.10.10",
"cross-env": "^7.0.3"
}
},
@@ -24,36 +24,41 @@
"license": "ISC",
"dependencies": {
"@scrypted/sdk": "file:../sdk",
"@scrypted/server": "file:../server",
"http-auth-utils": "^5.0.1",
"node-fetch-commonjs": "^3.1.1",
"typescript": "^5.3.3"
"typescript": "^5.5.3"
},
"devDependencies": {
"@types/node": "^20.10.8",
"@types/node": "^20.11.0",
"monaco-editor": "^0.50.0",
"ts-node": "^10.9.2"
}
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.3.4",
"version": "0.3.108",
"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.26.0",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-typescript": "^12.1.1",
"@rollup/plugin-virtual": "^3.0.2",
"adm-zip": "^0.5.16",
"axios": "^1.7.8",
"babel-loader": "^9.2.1",
"babel-plugin-const-enum": "^1.2.0",
"ncp": "^2.0.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.27.4",
"tmp": "^0.2.3",
"ts-loader": "^9.5.1",
"tslib": "^2.8.1",
"typescript": "^5.6.3",
"webpack": "^5.96.1",
"webpack-bundle-analyzer": "^4.10.2"
},
"bin": {
"scrypted-changelog": "bin/scrypted-changelog.js",
@@ -65,11 +70,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": "^22.10.1",
"ts-node": "^10.9.2",
"typedoc": "^0.26.11"
}
},
"node_modules/@scrypted/common": {
@@ -81,10 +84,14 @@
"link": true
},
"node_modules/@types/node": {
"version": "18.15.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.11.tgz",
"integrity": "sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==",
"dev": true
"version": "22.10.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.10.tgz",
"integrity": "sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
@@ -92,11 +99,12 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz",
"integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==",
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.4",
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
@@ -145,10 +153,11 @@
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -167,25 +176,29 @@
}
},
"node_modules/doorbird": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/doorbird/-/doorbird-2.1.2.tgz",
"integrity": "sha512-ivwwsS/nOslDnuLg3UB60Axo76w5LQuZ67mCPEeWFr5+HbGYRL7PCY3iLjWYaIakh5+IvZyFPHKR4yHAvAc1WQ==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/doorbird/-/doorbird-2.6.0.tgz",
"integrity": "sha512-HZBI5uFhwEVF8JFULQlpzXXvjSHmtQMJUNWfogq6vHe3kv7mCSmg0g/TDbeV5fVvisi8w7GxKD0/PpZCrtcGOg==",
"dependencies": {
"axios": "^1.2.1",
"axios": "^1.6.2",
"chacha-js": "^2.1.1",
"libsodium-wrappers-sumo": "^0.7.11"
"libsodium-wrappers-sumo": "^0.7.13"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/follow-redirects": {
"version": "1.15.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz",
"integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==",
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
@@ -225,16 +238,16 @@
"dev": true
},
"node_modules/libsodium-sumo": {
"version": "0.7.11",
"resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.7.11.tgz",
"integrity": "sha512-bY+7ph7xpk51Ez2GbE10lXAQ5sJma6NghcIDaSPbM/G9elfrjLa0COHl/7P6Wb/JizQzl5UQontOOP1z0VwbLA=="
"version": "0.7.15",
"resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.7.15.tgz",
"integrity": "sha512-5tPmqPmq8T8Nikpm1Nqj0hBHvsLFCXvdhBFV7SGOitQPZAA6jso8XoL0r4L7vmfKXr486fiQInvErHtEvizFMw=="
},
"node_modules/libsodium-wrappers-sumo": {
"version": "0.7.11",
"resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.11.tgz",
"integrity": "sha512-DGypHOmJbB1nZn89KIfGOAkDgfv5N6SBGC3Qvmy/On0P0WD1JQvNRS/e3UL3aFF+xC0m+MYz5M+MnRnK2HMrKQ==",
"version": "0.7.15",
"resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.15.tgz",
"integrity": "sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA==",
"dependencies": {
"libsodium-sumo": "^0.7.11"
"libsodium-sumo": "^0.7.15"
}
},
"node_modules/mime-db": {
@@ -307,6 +320,13 @@
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/doorbird",
"version": "0.0.2",
"version": "0.0.4",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",
"prescrypted-setup-project": "scrypted-package-json",
@@ -33,12 +33,12 @@
]
},
"dependencies": {
"doorbird": "^2.1.2"
"doorbird": "2.6.0"
},
"devDependencies": {
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"@types/node": "^18.15.11",
"@types/node": "^22.10.10",
"cross-env": "^7.0.3"
}
}

View File

@@ -1,13 +1,13 @@
import { listenZero } from '@scrypted/common/src/listen-cluster';
import sdk, { BinarySensor, Camera, DeviceProvider, DeviceCreator, DeviceCreatorSettings, DeviceInformation, FFmpegInput, Intercom, MediaObject, PictureOptions, ResponseMediaStreamOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, VideoCamera, MotionSensor } from '@scrypted/sdk';
import child_process, { ChildProcess } from 'child_process';
import { ffmpegLogInitialOutput, safePrintFFmpegArguments } from "@scrypted/common/src/media-helpers";
import net from 'net';
import { randomBytes } from 'crypto';
import { PassThrough, Readable } from "stream";
import { readLength } from "@scrypted/common/src/read-stream";
import { authHttpFetch } from "@scrypted/common/src/http-auth-fetch";
import { ApiRingEvent, ApiMotionEvent, DoorbirdAPI } from "./doorbird-api";
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 net from 'net';
import { PassThrough, Readable } from "stream";
import { ApiMotionEvent, ApiRingEvent, DoorbirdAPI } from "./doorbird-api";
const { deviceManager, mediaManager } = sdk;
@@ -384,7 +384,7 @@ class DoorbirdCamera extends ScryptedDeviceBase implements Intercom, Camera, Vid
this.console.log('Doorbird: timed out waiting for tcp client from ffmpeg');
server.close();
}, 30000);
const port = await listenZero(server);
const port = await listenZero(server, '127.0.0.1');
return port;
}

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/dummy-switch",
"version": "0.0.24",
"version": "0.0.25",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/dummy-switch",
"version": "0.0.24",
"version": "0.0.25",
"dependencies": {
"@types/node": "^16.6.1",
"axios": "^1.3.6"
@@ -23,35 +23,41 @@
"license": "ISC",
"dependencies": {
"@scrypted/sdk": "file:../sdk",
"@scrypted/server": "file:../server",
"http-auth-utils": "^3.0.2",
"node-fetch-commonjs": "^3.1.1",
"typescript": "^4.4.3"
"http-auth-utils": "^5.0.1",
"typescript": "^5.5.3"
},
"devDependencies": {
"@types/node": "^16.9.0"
"@types/node": "^20.11.0",
"monaco-editor": "^0.50.0",
"ts-node": "^10.9.2"
}
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.2.97",
"version": "0.3.106",
"dev": true,
"license": "ISC",
"dependencies": {
"@babel/preset-typescript": "^7.18.6",
"adm-zip": "^0.4.13",
"axios": "^0.21.4",
"babel-loader": "^9.1.0",
"babel-plugin-const-enum": "^1.1.0",
"esbuild": "^0.15.9",
"@babel/preset-typescript": "^7.26.0",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-typescript": "^12.1.1",
"@rollup/plugin-virtual": "^3.0.2",
"adm-zip": "^0.5.16",
"axios": "^1.7.8",
"babel-loader": "^9.2.1",
"babel-plugin-const-enum": "^1.2.0",
"ncp": "^2.0.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.27.4",
"tmp": "^0.2.3",
"ts-loader": "^9.5.1",
"tslib": "^2.8.1",
"typescript": "^5.6.3",
"webpack": "^5.96.1",
"webpack-bundle-analyzer": "^4.10.2"
},
"bin": {
"scrypted-changelog": "bin/scrypted-changelog.js",
@@ -63,11 +69,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": "^22.10.1",
"ts-node": "^10.9.2",
"typedoc": "^0.26.11"
}
},
"../sdk": {
@@ -92,11 +96,11 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.3.6.tgz",
"integrity": "sha512-PEcdkk7JcdPiMDkvM4K6ZBRYq9keuVJsToxm2zQIM70Qqo2WHTdJZMXcG9X+RmRp2VPNUQC8W1RAGbgt6b1yMg==",
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"dependencies": {
"follow-redirects": "^1.15.0",
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
@@ -121,9 +125,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
@@ -182,35 +186,39 @@
"version": "file:../../common",
"requires": {
"@scrypted/sdk": "file:../sdk",
"@scrypted/server": "file:../server",
"@types/node": "^16.9.0",
"http-auth-utils": "^3.0.2",
"node-fetch-commonjs": "^3.1.1",
"typescript": "^4.4.3"
"@types/node": "^20.11.0",
"http-auth-utils": "^5.0.1",
"monaco-editor": "^0.50.0",
"ts-node": "^10.9.2",
"typescript": "^5.5.3"
}
},
"@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": "^0.21.4",
"babel-loader": "^9.1.0",
"babel-plugin-const-enum": "^1.1.0",
"esbuild": "^0.15.9",
"@babel/preset-typescript": "^7.26.0",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-typescript": "^12.1.1",
"@rollup/plugin-virtual": "^3.0.2",
"@types/node": "^22.10.1",
"adm-zip": "^0.5.16",
"axios": "^1.7.8",
"babel-loader": "^9.2.1",
"babel-plugin-const-enum": "^1.2.0",
"ncp": "^2.0.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.27.4",
"tmp": "^0.2.3",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tslib": "^2.8.1",
"typedoc": "^0.26.11",
"typescript": "^5.6.3",
"webpack": "^5.96.1",
"webpack-bundle-analyzer": "^4.10.2"
}
},
"@types/node": {
@@ -224,11 +232,11 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"axios": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.3.6.tgz",
"integrity": "sha512-PEcdkk7JcdPiMDkvM4K6ZBRYq9keuVJsToxm2zQIM70Qqo2WHTdJZMXcG9X+RmRp2VPNUQC8W1RAGbgt6b1yMg==",
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"requires": {
"follow-redirects": "^1.15.0",
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
@@ -247,9 +255,9 @@
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
},
"follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA=="
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="
},
"form-data": {
"version": "4.0.0",

View File

@@ -40,5 +40,5 @@
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk"
},
"version": "0.0.24"
"version": "0.0.25"
}

View File

@@ -2,11 +2,60 @@ import { BinarySensor, DeviceCreator, DeviceCreatorSettings, DeviceProvider, Loc
import sdk from '@scrypted/sdk';
import { ReplaceMotionSensor, ReplaceMotionSensorNativeId } from './replace-motion-sensor';
import { ReplaceBinarySensor, ReplaceBinarySensorNativeId } from './replace-binary-sensor';
import { StorageSettings } from '@scrypted/sdk/storage-settings';
const { log, deviceManager } = sdk;
class DummyDevice extends ScryptedDeviceBase implements OnOff, Lock, StartStop, OccupancySensor, MotionSensor, BinarySensor, Settings {
timeout: NodeJS.Timeout;
storageSettings = new StorageSettings(this, {
reset: {
title: 'Reset Sensor',
description: 'Reset the motion sensor and binary sensor after the given seconds. Enter 0 to never reset.',
defaultValue: 10,
type: 'number',
placeholder: '10',
onPut: () => {
clearTimeout(this.timeout);
}
},
actionTypes: {
title: 'Action Types',
description: 'Select the action types to expose.',
defaultValue: [
ScryptedInterface.OnOff,
ScryptedInterface.StartStop,
ScryptedInterface.Lock,
],
multiple: true,
choices: [
ScryptedInterface.OnOff,
ScryptedInterface.StartStop,
ScryptedInterface.Lock,
],
onPut: () => {
this.reportInterfaces();
},
},
sensorTypes: {
title: 'Sensor Types',
description: 'Select the sensor types to expose.',
defaultValue: [
ScryptedInterface.MotionSensor,
ScryptedInterface.BinarySensor,
ScryptedInterface.OccupancySensor,
],
multiple: true,
choices: [
ScryptedInterface.MotionSensor,
ScryptedInterface.BinarySensor,
ScryptedInterface.OccupancySensor,
],
onPut: () => {
this.reportInterfaces();
},
}
});
constructor(nativeId: string) {
super(nativeId);
@@ -19,6 +68,22 @@ class DummyDevice extends ScryptedDeviceBase implements OnOff, Lock, StartStop,
this.occupied = false;
}
async reportInterfaces() {
const interfaces: ScryptedInterface[] = this.storageSettings.values.sensorTypes || [];
if (!interfaces.length)
interfaces.push(ScryptedInterface.MotionSensor, ScryptedInterface.BinarySensor, ScryptedInterface.OccupancySensor);
const actionTyoes = this.storageSettings.values.actionTypes || [];
if (!actionTyoes.length)
actionTyoes.push(ScryptedInterface.OnOff, ScryptedInterface.StartStop, ScryptedInterface.Lock);
await sdk.deviceManager.onDeviceDiscovered({
nativeId: this.nativeId,
interfaces: [...interfaces, ...actionTyoes, ScryptedInterface.Settings],
type: ScryptedDeviceType.Switch,
name: this.providedName,
});
}
lock(): Promise<void> {
return this.turnOff();
}
@@ -31,20 +96,12 @@ class DummyDevice extends ScryptedDeviceBase implements OnOff, Lock, StartStop,
stop(): Promise<void> {
return this.turnOff();
}
async getSettings(): Promise<Setting[]> {
return [
{
key: 'reset',
title: 'Reset Sensor',
description: 'Reset the motion sensor and binary sensor after the given seconds. Enter 0 to never reset.',
value: this.storage.getItem('reset') || '10',
placeholder: '10',
}
]
return this.storageSettings.getSettings();
}
async putSetting(key: string, value: SettingValue): Promise<void> {
this.storage.setItem(key, value.toString());
clearTimeout(this.timeout);
return this.storageSettings.putSetting(key, value);
}
// note that turnOff locks the lock
@@ -131,12 +188,6 @@ class DummyDeviceProvider extends ScryptedDeviceBase implements DeviceProvider,
const nativeId = 'shell:' + Math.random().toString();
const name = settings.name?.toString();
await this.onDiscovered(nativeId, name);
return nativeId;
}
async onDiscovered(nativeId: string, name: string) {
await deviceManager.onDeviceDiscovered({
nativeId,
name,
@@ -151,6 +202,8 @@ class DummyDeviceProvider extends ScryptedDeviceBase implements DeviceProvider,
],
type: ScryptedDeviceType.Switch,
});
return nativeId;
}
async getDevice(nativeId: string) {
@@ -163,11 +216,6 @@ class DummyDeviceProvider extends ScryptedDeviceBase implements DeviceProvider,
if (!ret) {
ret = new DummyDevice(nativeId);
// remove legacy scriptable interface
if (ret.interfaces.includes(ScryptedInterface.Scriptable)) {
setTimeout(() => this.onDiscovered(ret.nativeId, ret.providedName), 2000);
}
if (ret)
this.devices.set(nativeId, ret);
}

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/hikvision",
"version": "0.0.160",
"version": "0.0.162",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/hikvision",
"version": "0.0.160",
"version": "0.0.162",
"license": "Apache",
"dependencies": {
"@scrypted/common": "file:../../common",

View File

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

View File

@@ -116,11 +116,15 @@ export class HikvisionCameraAPI implements HikvisionAPI {
}
async checkIsOldModel() {
// The old Hikvision DS-7608NI-E2 doesn't support channel capability checks, and the requests cause errors
// The old Hikvision NVRs don't support channel capability checks, and the requests cause errors
const oldModels = [
/DS-76098NI-E2/,
/ERI-K104-P4/
];
const model = await this.checkDeviceModel();
if (!model)
return;
return !!model?.match(/DS-7608NI-E2/);
return !!oldModels.find(oldModel => model?.match(oldModel));
}
async checkStreamSetup(channel: string, isOld: boolean): Promise<HikvisionCameraStreamSetup> {

View File

@@ -161,14 +161,9 @@ export class HikvisionCamera extends RtspSmartCamera implements Camera, Intercom
const now = Date.now();
let detections: ObjectDetectionResult[] = xml.EventNotificationAlert?.DetectionRegionList?.map(region => {
const { DetectionRegionEntry } = region;
const dre = DetectionRegionEntry[0];
if (!DetectionRegionEntry)
const name = region?.DetectionRegionEntry?.[0]?.detectionTarget?.name;
if (!name)
return;
const { detectionTarget } = dre;
// const { TargetRect } = dre;
// const { X, Y, width, height } = TargetRect[0];
const [name] = detectionTarget;
return {
score: 1,
className: detectionMap[name] || name,

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/homekit",
"version": "1.2.61",
"version": "1.2.63",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/homekit",
"version": "1.2.61",
"version": "1.2.63",
"dependencies": {
"@koush/werift-src": "file:../../external/werift",
"check-disk-space": "^3.4.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/homekit",
"version": "1.2.61",
"version": "1.2.63",
"description": "HomeKit Plugin for Scrypted",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",

View File

@@ -216,6 +216,7 @@ export class HomeKitPlugin extends ScryptedDeviceBase implements MixinProvider,
throw Error(`error in device reordering, expected ${uniqueDeviceIds.size} unique devices but only got ${uniqueReorderedIds.size} entries!`);
}
const autoAdd = this.storageSettings.values.autoAdd ?? true;
for (const id of reorderedDeviceIds) {
const device = systemManager.getDeviceById<Online>(id);
const supportedType = supportedTypes[device.type];
@@ -224,8 +225,7 @@ export class HomeKitPlugin extends ScryptedDeviceBase implements MixinProvider,
try {
const mixins = (device.mixins || []).slice();
const autoAdd = this.storageSettings.values.autoAdd ?? true;
if (!mixins.includes(this.id) && autoAdd) {
if (!mixins.includes(this.id)) {
// don't sync this by default, as it's solely for automations
if (device.type === ScryptedDeviceType.Notifier)
continue;
@@ -235,6 +235,8 @@ export class HomeKitPlugin extends ScryptedDeviceBase implements MixinProvider,
continue;
if (defaultIncluded[device.id] === includeToken)
continue;
if (!autoAdd)
continue;
mixins.push(this.id);
await device.setMixins(mixins);
defaultIncluded[device.id] = includeToken;

View File

@@ -519,11 +519,15 @@ export class H264Repacketizer {
// after the codec information. so codec information can be changed between
// idr and non-idr? maybe it is not applied until next idr?
}
else if (nalType === NAL_TYPE_IDR) {
// this is uncommon but has been seen on tapo.
// i have no clue how they can fit an idr frame into a single packet stapa.
}
else if (nalType === 0) {
// nal delimiter or something. usually empty.
}
else {
this.console.warn('Skipped a stapa type. Please report this to @koush on Discord.', nalType)
this.console.warn('Skipped a stapa type.', nalType)
}
});

View File

@@ -1,4 +1,4 @@
import sdk, { AirQuality, AirQualitySensor, CO2Sensor, DeviceProvider, Fan, FanMode, NOXSensor, OnOff, PM10Sensor, PM25Sensor, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, VOCSensor } from "@scrypted/sdk";
import sdk, { AirQuality, AirQualitySensor, CO2Sensor, DeviceProvider, Fan, FanMode, HumidityMode, HumiditySensor, HumiditySetting, HumiditySettingStatus, NOXSensor, OnOff, PM10Sensor, PM25Sensor, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, VOCSensor } from "@scrypted/sdk";
import { bindCharacteristic } from "../common";
import { Accessory, Characteristic, CharacteristicEventTypes, Service, uuid } from '../hap';
import type { HomeKitPlugin } from "../main";
@@ -96,24 +96,161 @@ export function addCarbonDioxideSensor(device: ScryptedDevice & CO2Sensor, acces
return co2Service;
}
export function addFan(device: ScryptedDevice & Fan & OnOff, accessory: Accessory): Service {
if (!device.interfaces.includes(ScryptedInterface.OnOff) && !device.interfaces.includes(ScryptedInterface.Fan))
function commonHumidifierDehumidifier(mode: HumidityMode, subtype: string, name: string, device: ScryptedDevice & HumiditySetting & HumiditySensor, accessory: Accessory): Service {
function currentState(mode: HumidityMode) {
switch(mode) {
case HumidityMode.Humidify:
return Characteristic.CurrentHumidifierDehumidifierState.HUMIDIFYING;
case HumidityMode.Dehumidify:
return Characteristic.CurrentHumidifierDehumidifierState.DEHUMIDIFYING;
case HumidityMode.Off:
return Characteristic.CurrentHumidifierDehumidifierState.INACTIVE;
default:
return Characteristic.CurrentHumidifierDehumidifierState.IDLE;
}
}
function targetState(mode: HumidityMode) {
switch(mode) {
case HumidityMode.Humidify:
return Characteristic.TargetHumidifierDehumidifierState.HUMIDIFIER;
case HumidityMode.Dehumidify:
return Characteristic.TargetHumidifierDehumidifierState.DEHUMIDIFIER;
default:
return Characteristic.TargetHumidifierDehumidifierState.HUMIDIFIER_OR_DEHUMIDIFIER;
}
}
const service = accessory.addService(Service.HumidifierDehumidifier, name, subtype);
bindCharacteristic(device, ScryptedInterface.HumiditySetting, service, Characteristic.Active,
() => {
if (!device.humiditySetting?.mode)
return false;
if (device.humiditySetting.mode === mode)
return true;
if (device.humiditySetting.mode === HumidityMode.Auto)
return true;
return false;
});
service.getCharacteristic(Characteristic.Active).on(CharacteristicEventTypes.SET, (value, callback) => {
callback();
device.setHumidity({
mode: value ? mode : HumidityMode.Off
});
});
bindCharacteristic(device, ScryptedInterface.HumiditySensor, service, Characteristic.CurrentRelativeHumidity,
() => device.humidity);
bindCharacteristic(device, ScryptedInterface.HumiditySetting, service, Characteristic.CurrentHumidifierDehumidifierState,
() => currentState(device.humiditySetting?.activeMode));
bindCharacteristic(device, ScryptedInterface.HumiditySetting, service, Characteristic.TargetHumidifierDehumidifierState,
() => targetState(device.humiditySetting?.mode));
service.getCharacteristic(Characteristic.TargetHumidifierDehumidifierState).on(CharacteristicEventTypes.SET, (value, callback) => {
callback();
device.setHumidity({
mode: value === Characteristic.TargetHumidifierDehumidifierState.HUMIDIFIER
? HumidityMode.Humidify
: value === Characteristic.TargetHumidifierDehumidifierState.DEHUMIDIFIER
? HumidityMode.Dehumidify
: HumidityMode.Auto
});
});
function targetHumidity(setting: HumiditySettingStatus) {
if (!setting)
return 0;
if (setting?.availableModes.includes(HumidityMode.Humidify)
&& setting?.availableModes.includes(HumidityMode.Dehumidify)) {
if (setting?.activeMode === HumidityMode.Humidify)
return setting?.humidifierSetpoint;
if (setting?.activeMode === HumidityMode.Dehumidify)
return setting?.dehumidifierSetpoint;
return 0;
}
if (setting?.availableModes.includes(HumidityMode.Humidify))
return setting?.humidifierSetpoint;
if (setting?.availableModes.includes(HumidityMode.Dehumidify))
return setting?.dehumidifierSetpoint;
return 0;
}
bindCharacteristic(device, ScryptedInterface.HumiditySetting, service, Characteristic.TargetRelativeHumidity,
() => targetHumidity(device.humiditySetting));
return service;
}
function addHumidifier(device: ScryptedDevice & HumiditySetting & HumiditySensor, accessory: Accessory): Service {
var service = commonHumidifierDehumidifier(HumidityMode.Humidify, "humidifier", device.name + " Humidifier", device, accessory);
bindCharacteristic(device, ScryptedInterface.HumiditySetting, service, Characteristic.RelativeHumidityHumidifierThreshold,
() => device.humiditySetting?.humidifierSetpoint);
service.getCharacteristic(Characteristic.RelativeHumidityHumidifierThreshold).on(CharacteristicEventTypes.SET, (value, callback) => {
callback();
device.setHumidity({
humidifierSetpoint: value as number,
});
});
return service;
}
function addDehumidifer(device: ScryptedDevice & HumiditySetting & HumiditySensor, accessory: Accessory): Service {
var service = commonHumidifierDehumidifier(HumidityMode.Dehumidify, "dehumidifier", device.name + " Dehumidifier", device, accessory);
bindCharacteristic(device, ScryptedInterface.HumiditySetting, service, Characteristic.RelativeHumidityDehumidifierThreshold,
() => device.humiditySetting?.dehumidifierSetpoint);
service.getCharacteristic(Characteristic.RelativeHumidityDehumidifierThreshold).on(CharacteristicEventTypes.SET, (value, callback) => {
callback();
device.setHumidity({
dehumidifierSetpoint: value as number,
});
});
return service;
}
export function addHumiditySetting(device: ScryptedDevice & HumiditySetting & HumiditySensor, accessory: Accessory): Service {
if (!device.interfaces.includes(ScryptedInterface.HumiditySetting) && !device.interfaces.includes(ScryptedInterface.HumiditySensor))
return undefined;
var service;
if (device.humiditySetting?.availableModes.includes(HumidityMode.Humidify)) {
service = addHumidifier(device, accessory);
}
if (device.humiditySetting?.availableModes.includes(HumidityMode.Dehumidify)) {
service = addDehumidifer(device, accessory);
}
return service;
}
export function addFan(device: ScryptedDevice & Fan, accessory: Accessory): Service {
if (!device.interfaces.includes(ScryptedInterface.Fan))
return undefined;
const service = accessory.addService(Service.Fanv2, device.name);
if (device.interfaces.includes(ScryptedInterface.OnOff)) {
bindCharacteristic(device, ScryptedInterface.OnOff, service, Characteristic.Active,
() => !!device.on);
bindCharacteristic(device, ScryptedInterface.OnOff, service, Characteristic.Active,
() => device.fan?.active);
service.getCharacteristic(Characteristic.Active).on(CharacteristicEventTypes.SET, (value, callback) => {
callback();
if (value)
device.turnOn();
else
device.turnOff();
service.getCharacteristic(Characteristic.Active).on(CharacteristicEventTypes.SET, (value, callback) => {
callback();
device.setFan({
mode: value ? FanMode.Auto : FanMode.Manual,
});
}
});
if (device.fan?.counterClockwise !== undefined) {
bindCharacteristic(device, ScryptedInterface.Fan, service, Characteristic.RotationDirection,

View File

@@ -1,7 +1,7 @@
import { Fan, FanMode, HumidityMode, HumiditySensor, HumiditySetting, OnOff, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, TemperatureSetting, TemperatureUnit, Thermometer, ThermostatMode, AirQualitySensor, AirQuality, PM10Sensor, PM25Sensor, VOCSensor, NOXSensor, CO2Sensor } from '@scrypted/sdk';
import { Fan, FanMode, HumidityMode, HumiditySensor, HumiditySetting, OnOff, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, TemperatureSetting, TemperatureUnit, Thermometer, ThermostatMode, AirQualitySensor, AirQuality, PM10Sensor, PM25Sensor, VOCSensor, NOXSensor, CO2Sensor, HumiditySettingStatus } from '@scrypted/sdk';
import { addSupportedType, bindCharacteristic, DummyDevice, } from '../common';
import { Characteristic, CharacteristicEventTypes, CharacteristicSetCallback, CharacteristicValue, Service } from '../hap';
import { addAirQualitySensor, addCarbonDioxideSensor, addFan, makeAccessory } from './common';
import { addAirQualitySensor, addCarbonDioxideSensor, addFan, addHumiditySetting, makeAccessory } from './common';
import type { HomeKitPlugin } from "../main";
addSupportedType({
@@ -178,72 +178,60 @@ addSupportedType({
() => device.humidity || 0);
}
if (device.interfaces.includes(ScryptedInterface.HumiditySetting) && device.interfaces.includes(ScryptedInterface.HumiditySensor)) {
const humidityService = accessory.addService(Service.HumidifierDehumidifier);
// add fan state to thermostat service even though it is not required or optional,
// in order to expose to Home Assistant HomeKit Controller under their climate entity
if (device.interfaces.includes(ScryptedInterface.Fan)) {
bindCharacteristic(device, ScryptedInterface.Fan, service, Characteristic.TargetFanState,
() => device.fan?.mode === FanMode.Manual
? Characteristic.TargetFanState.MANUAL
: Characteristic.TargetFanState.AUTO);
bindCharacteristic(device, ScryptedInterface.HumiditySetting, humidityService, Characteristic.Active,
() => {
if (!device.humiditySetting?.mode)
return false;
if (device.humiditySetting.mode === HumidityMode.Off)
return false;
return true;
});
humidityService.getCharacteristic(Characteristic.Active).on(CharacteristicEventTypes.SET, (value, callback) => {
service.getCharacteristic(Characteristic.TargetFanState).on(CharacteristicEventTypes.SET, (value, callback) => {
callback();
device.setHumidity({
mode: value ? HumidityMode.Auto : HumidityMode.Off
device.setFan({
mode: value === Characteristic.TargetFanState.MANUAL ? FanMode.Manual : FanMode.Auto,
});
});
bindCharacteristic(device, ScryptedInterface.HumiditySensor, humidityService, Characteristic.CurrentRelativeHumidity,
() => device.humidity || 0);
bindCharacteristic(device, ScryptedInterface.HumiditySetting, humidityService, Characteristic.CurrentHumidifierDehumidifierState,
() => !device.humiditySetting?.activeMode
? Characteristic.CurrentHumidifierDehumidifierState.INACTIVE
: device.humiditySetting.activeMode === HumidityMode.Dehumidify
? Characteristic.CurrentHumidifierDehumidifierState.DEHUMIDIFYING
: device.humiditySetting.activeMode === HumidityMode.Humidify
? Characteristic.CurrentHumidifierDehumidifierState.HUMIDIFYING
: Characteristic.CurrentHumidifierDehumidifierState.IDLE);
bindCharacteristic(device, ScryptedInterface.HumiditySetting, humidityService, Characteristic.TargetHumidifierDehumidifierState,
() => !device.humiditySetting?.mode || device.humiditySetting?.mode === HumidityMode.Auto
? Characteristic.TargetHumidifierDehumidifierState.HUMIDIFIER_OR_DEHUMIDIFIER
: device.humiditySetting?.mode === HumidityMode.Dehumidify
? Characteristic.TargetHumidifierDehumidifierState.DEHUMIDIFIER
: Characteristic.TargetHumidifierDehumidifierState.HUMIDIFIER);
humidityService.getCharacteristic(Characteristic.TargetHumidifierDehumidifierState).on(CharacteristicEventTypes.SET, (value, callback) => {
callback();
device.setHumidity({
mode: value === Characteristic.TargetHumidifierDehumidifierState.HUMIDIFIER
? HumidityMode.Humidify
: value === Characteristic.TargetHumidifierDehumidifierState.DEHUMIDIFIER
? HumidityMode.Dehumidify
: HumidityMode.Auto
});
});
bindCharacteristic(device, ScryptedInterface.HumiditySetting, humidityService, Characteristic.RelativeHumidityHumidifierThreshold,
() => device.humiditySetting?.humidifierSetpoint || 0);
humidityService.getCharacteristic(Characteristic.RelativeHumidityHumidifierThreshold).on(CharacteristicEventTypes.SET, (value, callback) => {
callback();
device.setHumidity({
humidifierSetpoint: value as number,
});
});
bindCharacteristic(device, ScryptedInterface.HumiditySetting, humidityService, Characteristic.RelativeHumidityDehumidifierThreshold,
() => device.humiditySetting?.dehumidifierSetpoint || 0);
humidityService.getCharacteristic(Characteristic.RelativeHumidityDehumidifierThreshold).on(CharacteristicEventTypes.SET, (value, callback) => {
callback();
device.setHumidity({
dehumidifierSetpoint: value as number,
});
});
bindCharacteristic(device, ScryptedInterface.Fan, service, Characteristic.CurrentFanState,
() => !device.fan?.active
? Characteristic.CurrentFanState.INACTIVE
: !device.fan.speed
? Characteristic.CurrentFanState.IDLE
: Characteristic.CurrentFanState.BLOWING_AIR);
}
// add relataive target humidity to thermostat service even though it is not required or optional,
// in order to expose to Home Assistant HomeKit Controller under their climate entity
if (device.interfaces.includes(ScryptedInterface.HumiditySetting)) {
function targetHumidity(setting: HumiditySettingStatus) {
if (!setting)
return 0;
if (setting?.availableModes.includes(HumidityMode.Humidify)
&& setting?.availableModes.includes(HumidityMode.Dehumidify)) {
if (setting?.activeMode === HumidityMode.Humidify)
return setting?.humidifierSetpoint;
if (setting?.activeMode === HumidityMode.Dehumidify)
return setting?.dehumidifierSetpoint;
return 0;
}
if (setting?.availableModes.includes(HumidityMode.Humidify))
return setting?.humidifierSetpoint;
if (setting?.availableModes.includes(HumidityMode.Dehumidify))
return setting?.dehumidifierSetpoint;
return 0;
}
bindCharacteristic(device, ScryptedInterface.HumiditySetting, service, Characteristic.TargetRelativeHumidity,
() => targetHumidity(device.humiditySetting));
}
addHumiditySetting(device, accessory);
addFan(device, accessory);
addAirQualitySensor(device, accessory);
addCarbonDioxideSensor(device, accessory);

View File

@@ -1,22 +1,19 @@
{
"name": "@scrypted/objectdetector",
"version": "0.1.60",
"version": "0.1.66",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/objectdetector",
"version": "0.1.60",
"version": "0.1.66",
"license": "Apache-2.0",
"dependencies": {
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"polygon-clipping": "^0.15.7",
"semver": "^7.5.4"
"@scrypted/sdk": "file:../../sdk"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/semver": "^7.5.6"
"@types/node": "^20.11.0"
}
},
"../../common": {
@@ -25,34 +22,40 @@
"license": "ISC",
"dependencies": {
"@scrypted/sdk": "file:../sdk",
"@scrypted/server": "file:../server",
"http-auth-utils": "^5.0.1",
"typescript": "^5.3.3"
"typescript": "^5.5.3"
},
"devDependencies": {
"@types/node": "^20.11.0",
"monaco-editor": "^0.50.0",
"ts-node": "^10.9.2"
}
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.3.12",
"version": "0.3.106",
"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.26.0",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-typescript": "^12.1.1",
"@rollup/plugin-virtual": "^3.0.2",
"adm-zip": "^0.5.16",
"axios": "^1.7.8",
"babel-loader": "^9.2.1",
"babel-plugin-const-enum": "^1.2.0",
"ncp": "^2.0.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.27.4",
"tmp": "^0.2.3",
"ts-loader": "^9.5.1",
"tslib": "^2.8.1",
"typescript": "^5.6.3",
"webpack": "^5.96.1",
"webpack-bundle-analyzer": "^4.10.2"
},
"bin": {
"scrypted-changelog": "bin/scrypted-changelog.js",
@@ -64,11 +67,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": "^22.10.1",
"ts-node": "^10.9.2",
"typedoc": "^0.26.11"
}
},
"node_modules/@scrypted/common": {
@@ -88,67 +89,12 @@
"undici-types": "~5.26.4"
}
},
"node_modules/@types/semver": {
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
"dev": true
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/polygon-clipping": {
"version": "0.15.7",
"resolved": "https://registry.npmjs.org/polygon-clipping/-/polygon-clipping-0.15.7.tgz",
"integrity": "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==",
"dependencies": {
"robust-predicates": "^3.0.2",
"splaytree": "^3.1.0"
}
},
"node_modules/robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="
},
"node_modules/semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/splaytree": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/splaytree/-/splaytree-3.1.2.tgz",
"integrity": "sha512-4OM2BJgC5UzrhVnnJA4BkHKGtjXNzzUfpQjCO8I05xYPsfS/VuQDwjCGGMi8rYQilHEV4j8NBqTFbls/PZEE7A=="
},
"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
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node-moving-things-tracker": {
"version": "0.9.1",
"extraneous": true,
@@ -172,35 +118,39 @@
"version": "file:../../common",
"requires": {
"@scrypted/sdk": "file:../sdk",
"@scrypted/server": "file:../server",
"@types/node": "^20.11.0",
"http-auth-utils": "^5.0.1",
"monaco-editor": "^0.50.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
"typescript": "^5.5.3"
}
},
"@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.26.0",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-typescript": "^12.1.1",
"@rollup/plugin-virtual": "^3.0.2",
"@types/node": "^22.10.1",
"adm-zip": "^0.5.16",
"axios": "^1.7.8",
"babel-loader": "^9.2.1",
"babel-plugin-const-enum": "^1.2.0",
"ncp": "^2.0.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.27.4",
"tmp": "^0.2.3",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tslib": "^2.8.1",
"typedoc": "^0.26.11",
"typescript": "^5.6.3",
"webpack": "^5.96.1",
"webpack-bundle-analyzer": "^4.10.2"
}
},
"@types/node": {
@@ -212,57 +162,11 @@
"undici-types": "~5.26.4"
}
},
"@types/semver": {
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
"dev": true
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"requires": {
"yallist": "^4.0.0"
}
},
"polygon-clipping": {
"version": "0.15.7",
"resolved": "https://registry.npmjs.org/polygon-clipping/-/polygon-clipping-0.15.7.tgz",
"integrity": "sha512-nhfdr83ECBg6xtqOAJab1tbksbBAOMUltN60bU+llHVOL0e5Onm1WpAXXWXVB39L8AJFssoIhEVuy/S90MmotA==",
"requires": {
"robust-predicates": "^3.0.2",
"splaytree": "^3.1.0"
}
},
"robust-predicates": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
"integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="
},
"semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"requires": {
"lru-cache": "^6.0.0"
}
},
"splaytree": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/splaytree/-/splaytree-3.1.2.tgz",
"integrity": "sha512-4OM2BJgC5UzrhVnnJA4BkHKGtjXNzzUfpQjCO8I05xYPsfS/VuQDwjCGGMi8rYQilHEV4j8NBqTFbls/PZEE7A=="
},
"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
},
"yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/objectdetector",
"version": "0.1.60",
"version": "0.1.66",
"description": "Scrypted Video Analysis Plugin. Installed alongside a detection service like OpenCV or TensorFlow.",
"author": "Scrypted",
"license": "Apache-2.0",
@@ -46,12 +46,9 @@
},
"dependencies": {
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"polygon-clipping": "^0.15.7",
"semver": "^7.5.4"
"@scrypted/sdk": "file:../../sdk"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/semver": "^7.5.6"
"@types/node": "^20.11.0"
}
}

View File

@@ -0,0 +1,173 @@
import sdk, { AudioSensor, FFmpegInput, MixinProvider, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, SettingValue, VideoCamera, WritableDeviceState } from "@scrypted/sdk";
import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from "@scrypted/sdk/settings-mixin";
import { StorageSettings } from "@scrypted/sdk/storage-settings";
import { startRtpForwarderProcess } from '../../webrtc/src/rtp-forwarders';
import { RtpPacket } from "../../../external/werift/packages/rtp/src/rtp/rtp";
import { sleep } from "@scrypted/common/src/sleep";
function pcmU8ToDb(payload: Uint8Array): number {
let sum = 0;
const count = payload.length;
if (count === 0) return 0; // Treat empty input as silence (0 dB)
for (let i = 0; i < count; i++) {
const sample = payload[i] - 128; // Convert to signed range (-128 to 127)
sum += sample * sample;
}
const rms = Math.sqrt(sum / count);
const minRMS = 1.0; // Define a minimum reference level to avoid log(0)
if (rms < minRMS) return 0; // Silence is 0 dB
const db = 20 * Math.log10(rms / minRMS); // Scale against the minimum audible level
return db;
}
class FFmpegAudioDetectionMixin extends SettingsMixinDeviceBase<AudioSensor> implements AudioSensor {
storageSettings = new StorageSettings(this, {
decibelThreshold: {
title: 'Decibel Threshold',
type: 'number',
description: 'The decibel level at which to trigger an event.',
defaultValue: 20,
},
audioTimeout: {
title: 'Audio Timeout',
type: 'number',
description: 'The number of seconds to wait after the last audio event before resetting the audio sensor.',
defaultValue: 10,
},
});
ensureInterval: NodeJS.Timeout;
forwarder: ReturnType<typeof startRtpForwarderProcess>;
audioResetInterval: NodeJS.Timeout;
constructor(options: SettingsMixinDeviceOptions<AudioSensor>) {
super(options);
this.ensureInterval = setInterval(() => this.ensureAudioSensor(), 60000);
this.ensureAudioSensor();
};
ensureAudioSensor() {
if (!this.ensureInterval)
return;
if (this.forwarder)
return;
this.audioDetected = false;
clearInterval(this.audioResetInterval);
this.audioResetInterval = undefined;
const fp = this.ensureAudioSensorInternal();
this.forwarder = fp;
fp.catch(() => {
if (this.forwarder === fp)
this.forwarder = undefined;
});
this.forwarder.then(f => {
f.killPromise.then(() => {
if (this.forwarder === fp)
this.forwarder = undefined;
});
})
}
async ensureAudioSensorInternal() {
await sleep(5000);
if (!this.forwarder)
throw new Error('released/killed');
const realDevice = sdk.systemManager.getDeviceById<VideoCamera>(this.id);
const mo = await realDevice.getVideoStream({
video: null,
audio: {},
});
const ffmpegInput = await sdk.mediaManager.convertMediaObjectToJSON<FFmpegInput>(mo, ScryptedMimeTypes.FFmpegInput);
let lastAudio = 0;
const forwarder = await startRtpForwarderProcess(this.console, ffmpegInput, {
video: null,
audio: {
codecCopy: 'pcm_u8',
encoderArguments: [
'-acodec', 'pcm_u8',
'-ac', '1',
'-ar', '8000',
],
onRtp: rtp => {
const now = Date.now();
// if this.audioDetected is true skip the processing unless the lastAudio time is halfway through the interval
if (this.audioDetected && now - lastAudio < this.storageSettings.values.audioTimeout * 500)
return;
const packet = RtpPacket.deSerialize(rtp);
const decibels = pcmU8ToDb(packet.payload);
if (decibels < this.storageSettings.values.decibelThreshold)
return;
this.audioDetected = true;
lastAudio = now;
},
}
});
this.audioResetInterval = setInterval(() => {
if (!this.audioDetected)
return;
if (Date.now() - lastAudio < this.storageSettings.values.audioTimeout * 1000)
return;
this.audioDetected = false;
}, this.storageSettings.values.audioTimeout * 1000);
return forwarder;
}
async getMixinSettings() {
return this.storageSettings.getSettings();
}
putMixinSetting(key: string, value: SettingValue) {
return this.storageSettings.putSetting(key, value);
}
async release() {
this.forwarder?.then(f => f.kill());
this.forwarder = undefined;
clearInterval(this.ensureInterval);
this.ensureInterval = undefined;
clearTimeout(this.audioResetInterval);
this.audioResetInterval = undefined;
}
}
export class FFmpegAudioDetectionMixinProvider extends ScryptedDeviceBase implements MixinProvider {
async canMixin(type: ScryptedDeviceType, interfaces: string[]) {
if (type !== ScryptedDeviceType.Camera && type !== ScryptedDeviceType.Doorbell)
return;
if (!interfaces.includes(ScryptedInterface.VideoCamera))
return;
return [ScryptedInterface.AudioSensor, ScryptedInterface.Settings];
}
async getMixin(mixinDevice: any, mixinDeviceInterfaces: ScryptedInterface[], mixinDeviceState: WritableDeviceState): Promise<any> {
return new FFmpegAudioDetectionMixin({
group: 'Audio Detection',
groupKey: 'audio-detection',
mixinDevice,
mixinDeviceInterfaces,
mixinDeviceState,
mixinProviderNativeId: this.nativeId,
});
}
async releaseMixin(id: string, mixinDevice: any) {
await (mixinDevice as FFmpegAudioDetectionMixin)?.release();
}
}

View File

@@ -6,10 +6,11 @@ import crypto from 'crypto';
import { AutoenableMixinProvider } from "../../../common/src/autoenable-mixin-provider";
import { SettingsMixinDeviceBase } from "../../../common/src/settings-mixin";
import { FFmpegVideoFrameGenerator } from './ffmpeg-videoframes';
import { fixLegacyClipPath, insidePolygon, normalizeBoxToClipPath, polygonOverlap } from './polygon';
import { fixLegacyClipPath, normalizeBox, polygonContainsBoundingBox, polygonIntersectsBoundingBox } from './polygon';
import { SMART_MOTIONSENSOR_PREFIX, SmartMotionSensor } from './smart-motionsensor';
import { SMART_OCCUPANCYSENSOR_PREFIX, SmartOccupancySensor } from './smart-occupancy-sensor';
import { getAllDevices, safeParseJson } from './util';
import { FFmpegAudioDetectionMixinProvider } from './ffmpeg-audiosensor';
const { systemManager } = sdk;
@@ -545,7 +546,7 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
if (!o.boundingBox)
continue;
const box = normalizeBoxToClipPath(o.boundingBox, detection.inputDimensions);
const box = normalizeBox(o.boundingBox, detection.inputDimensions);
let included: boolean;
// need a way to explicitly include package zone.
@@ -572,13 +573,10 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
let match = false;
if (zoneInfo?.type === 'Contain') {
match = insidePolygon(box[0] as Point, zoneValue) &&
insidePolygon(box[1], zoneValue) &&
insidePolygon(box[2], zoneValue) &&
insidePolygon(box[3], zoneValue);
match = polygonContainsBoundingBox(zoneValue, box);
}
else {
match = polygonOverlap(box, zoneValue);
match = polygonIntersectsBoundingBox(zoneValue, box);
}
const classes = zoneInfo?.classes?.length ? zoneInfo?.classes : this.model?.classes || [];
@@ -604,7 +602,7 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
// prevents errant motion from the on screen time changing every second.
if (this.hasMotionType && included === undefined) {
const defaultInclusionZone: ClipPath = [[0, .1], [1, .1], [1, .9], [0, .9]];
included = polygonOverlap(box, defaultInclusionZone);
included = polygonIntersectsBoundingBox(defaultInclusionZone, box);
}
// if there are inclusion zones and this object
@@ -868,8 +866,10 @@ class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera
return this.storageSettings.putSetting(key, value);
}
if (value && this.model.settings?.find(s => s.key === key)?.multiple) {
vs = JSON.stringify(value);
if (value) {
const found = this.model.settings?.find(s => s.key === key);
if (found?.multiple || found?.type === 'clippath')
vs = JSON.stringify(value);
}
if (key === 'analyzeButton') {
@@ -1057,7 +1057,16 @@ export class ObjectDetectionPlugin extends AutoenableMixinProvider implements Se
ScryptedInterface.VideoFrameGenerator,
],
nativeId: 'ffmpeg',
})
});
sdk.deviceManager.onDeviceDiscovered({
name: 'FFmpeg Audio Detection',
type: ScryptedDeviceType.Builtin,
interfaces: [
ScryptedInterface.MixinProvider,
],
nativeId: 'ffmpeg-audio-detection',
});
});
// on an interval check to see if system load allows squelched detectors to start up.
@@ -1196,6 +1205,8 @@ export class ObjectDetectionPlugin extends AutoenableMixinProvider implements Se
let ret: any;
if (nativeId === 'ffmpeg')
ret = this.devices.get(nativeId) || new FFmpegVideoFrameGenerator('ffmpeg');
if (nativeId === 'ffmpeg-audio-detection')
ret = this.devices.get(nativeId) || new FFmpegAudioDetectionMixinProvider('ffmpeg-audio-detection');
if (nativeId?.startsWith(SMART_MOTIONSENSOR_PREFIX))
ret = this.devices.get(nativeId) || new SmartMotionSensor(this, nativeId);
if (nativeId?.startsWith(SMART_OCCUPANCYSENSOR_PREFIX))

View File

@@ -1,17 +1,99 @@
import type { ClipPath, Point } from '@scrypted/sdk';
import polygonClipping from 'polygon-clipping';
// const polygonOverlap = require('polygon-overlap');
// const insidePolygon = require('point-inside-polygon');
// x y w h
export type BoundingBox = [number, number, number, number];
/**
* Checks if a line segment intersects with another line segment
*/
function lineIntersects(
[x1, y1]: Point,
[x2, y2]: Point,
[x3, y3]: Point,
[x4, y4]: Point
): boolean {
// Calculate the denominators for intersection check
const denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);
if (denom === 0) return false; // Lines are parallel
export function polygonOverlap(p1: Point[], p2: Point[]) {
const intersect = polygonClipping.intersection([p1], [p2]);
return !!intersect.length;
const ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom;
const ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denom;
// Check if intersection point lies within both line segments
return ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1;
}
export function insidePolygon(point: Point, polygon: Point[]) {
const intersect = polygonClipping.intersection([polygon], [[point, [point[0] + 1, point[1]], [point[0] + 1, point[1] + 1]]]);
return !!intersect.length;
/**
* Checks if a point is inside a polygon using ray casting algorithm
*/
function pointInPolygon([x, y]: Point, polygon: ClipPath): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [xi, yi] = polygon[i];
const [xj, yj] = polygon[j];
const intersect = ((yi > y) !== (yj > y)) &&
(x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
}
/**
* Converts a bounding box to an array of its corner points
*/
function boundingBoxToPoints([x, y, w, h]: BoundingBox): Point[] {
return [
[x, y], // top-left
[x + w, y], // top-right
[x + w, y + h], // bottom-right
[x, y + h] // bottom-left
];
}
/**
* Checks if a polygon intersects with a bounding box
*/
export function polygonIntersectsBoundingBox(polygon: ClipPath, boundingBox: BoundingBox): boolean {
// Get bounding box corners
const boxPoints = boundingBoxToPoints(boundingBox);
// Check if any polygon edge intersects with any bounding box edge
for (let i = 0; i < polygon.length; i++) {
const nextI = (i + 1) % polygon.length;
const polygonPoint1 = polygon[i];
const polygonPoint2 = polygon[nextI];
// Check against all bounding box edges
for (let j = 0; j < boxPoints.length; j++) {
const nextJ = (j + 1) % boxPoints.length;
const boxPoint1 = boxPoints[j];
const boxPoint2 = boxPoints[nextJ];
if (lineIntersects(polygonPoint1, polygonPoint2, boxPoint1, boxPoint2)) {
return true;
}
}
}
// If no edges intersect, check if either shape contains a point from the other
if (pointInPolygon(polygon[0], boxPoints) || pointInPolygon(boxPoints[0], polygon))
return true;
return false;
}
/**
* Checks if a polygon completely contains a bounding box
*/
export function polygonContainsBoundingBox(polygon: ClipPath, boundingBox: BoundingBox): boolean {
// Check if all corners of the bounding box are inside the polygon
const boxPoints = boundingBoxToPoints(boundingBox);
return boxPoints.every(point => pointInPolygon(point, polygon));
}
export function normalizeBox(box: BoundingBox, dims: Point): BoundingBox {
return [box[0] / dims[0], box[1] / dims[1], box[2] / dims[0], box[3] / dims[1]];
}
export function fixLegacyClipPath(clipPath: ClipPath): ClipPath {
@@ -34,26 +116,3 @@ export function fixLegacyClipPath(clipPath: ClipPath): ClipPath {
return clipPath.map(p => p.map(c => c / 100)) as ClipPath;
}
export function normalizeBoxToClipPath(boundingBox: [number, number, number, number], inputDimensions: [number, number]): [Point, Point, Point, Point] {
let [x, y, width, height] = boundingBox;
let x2 = x + width;
let y2 = y + height;
// the zones are point paths in percentage format
x = x / inputDimensions[0];
y = y / inputDimensions[1];
x2 = x2 / inputDimensions[0];
y2 = y2 / inputDimensions[1];
return [[x, y], [x2, y], [x2, y2], [x, y2]];
}
export function polygonArea(p: Point[]): number {
let area = 0;
const n = p.length;
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
area += p[i][0] * p[j][1];
area -= p[j][0] * p[i][1];
}
return Math.abs(area / 2);
}

View File

@@ -85,7 +85,7 @@ export class SmartMotionSensor extends ScryptedDeviceBase implements Settings, R
this.storageSettings.settings.detections.onGet = async () => {
const objectDetector: ObjectDetector = this.storageSettings.values.objectDetector;
const choices = (await objectDetector?.getObjectTypes())?.classes || [];
const choices = (await objectDetector?.getObjectTypes?.())?.classes || [];
return {
hide: !objectDetector,
choices,

View File

@@ -2,7 +2,7 @@ import sdk, { Camera, ClipPath, EventListenerRegister, Image, ObjectDetection, O
import { StorageSettings } from "@scrypted/sdk/storage-settings";
import { levenshteinDistance } from "./edit-distance";
import type { ObjectDetectionPlugin } from "./main";
import { normalizeBoxToClipPath, polygonOverlap } from "./polygon";
import { normalizeBox, polygonIntersectsBoundingBox } from "./polygon";
export const SMART_OCCUPANCYSENSOR_PREFIX = 'smart-occupancysensor-';
@@ -150,8 +150,8 @@ export class SmartOccupancySensor extends ScryptedDeviceBase implements Settings
if (zone?.length >= 3) {
if (!d.boundingBox)
return false;
const detectionBoxPath = normalizeBoxToClipPath(d.boundingBox, detected.inputDimensions);
if (!polygonOverlap(detectionBoxPath, zone))
const detectionBox = normalizeBox(d.boundingBox, detected.inputDimensions);
if (!polygonIntersectsBoundingBox(zone, detectionBox))
return false;
}

View File

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

View File

@@ -48,5 +48,5 @@
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
},
"version": "0.1.148"
"version": "0.1.153"
}

View File

@@ -25,8 +25,12 @@ try:
except:
OpenVINOTextRecognition = None
predictExecutor = concurrent.futures.ThreadPoolExecutor(thread_name_prefix="OpenVINO-Predict")
prepareExecutor = concurrent.futures.ThreadPoolExecutor(thread_name_prefix="OpenVINO-Prepare")
predictExecutor = concurrent.futures.ThreadPoolExecutor(
thread_name_prefix="OpenVINO-Predict"
)
prepareExecutor = concurrent.futures.ThreadPoolExecutor(
thread_name_prefix="OpenVINO-Prepare"
)
availableModels = [
"Default",
@@ -132,7 +136,7 @@ class OpenVINOPlugin(
gpu = True
except:
pass
# AUTO mode can cause conflicts or hide errors with NPU and GPU
# so try to be explicit and fall back accordingly.
mode = self.storage.getItem("mode") or "Default"
@@ -140,7 +144,7 @@ class OpenVINOPlugin(
mode = "AUTO"
if npu:
mode = 'NPU'
mode = "NPU"
elif len(dgpus):
mode = f"AUTO:{','.join(dgpus)},CPU"
# forcing GPU can cause crashes on older GPU.
@@ -242,12 +246,42 @@ class OpenVINOPlugin(
self.requestRestart()
self.infer_queue = ov.AsyncInferQueue(self.compiled_model)
def predict(output):
if not self.yolo:
objs = []
for values in output[0][0]:
valid, index, confidence, l, t, r, b = values
if valid == -1:
break
def torelative(value: float):
return value * self.model_dim
l = torelative(l)
t = torelative(t)
r = torelative(r)
b = torelative(b)
obj = Prediction(index - 1, confidence, Rectangle(l, t, r, b))
objs.append(obj)
return objs
if self.scrypted_yolov10:
return yolo.parse_yolov10(output[0])
if self.scrypted_yolo_nas:
return yolo.parse_yolo_nas([output[1], output[0]])
return yolo.parse_yolov9(output[0])
def callback(infer_request, future: asyncio.Future):
try:
output = infer_request.get_output_tensor(0)
self.loop.call_soon_threadsafe(future.set_result, output)
output = infer_request.get_output_tensor(0).data
objs = predict(output)
self.loop.call_soon_threadsafe(future.set_result, objs)
except Exception as e:
self.loop.call_soon_threadsafe(future.set_exception, e)
self.infer_queue.set_callback(callback)
print(
@@ -323,39 +357,6 @@ class OpenVINOPlugin(
return super().get_input_format()
async def detect_once(self, input: Image.Image, settings: Any, src_size, cvss):
async def predict(input_tensor):
f = asyncio.Future(loop = self.loop)
self.infer_queue.start_async(input_tensor, f)
output_tensors = await f
if not self.yolo:
output = output_tensors
for values in output.data[0][0]:
valid, index, confidence, l, t, r, b = values
if valid == -1:
break
def torelative(value: float):
return value * self.model_dim
l = torelative(l)
t = torelative(t)
r = torelative(r)
b = torelative(b)
obj = Prediction(index - 1, confidence, Rectangle(l, t, r, b))
objs.append(obj)
return objs
output = output_tensors.data
if self.scrypted_yolov10:
return yolo.parse_yolov10(output[0])
if self.scrypted_yolo_nas:
return yolo.parse_yolo_nas([output[1], output[0]])
return yolo.parse_yolov9(output[0])
def prepare():
# the input_tensor can be created with the shared_memory=True parameter,
# but that seems to cause issues on some platforms.
@@ -376,21 +377,19 @@ class OpenVINOPlugin(
im = im.reshape((1, 3, self.model_dim, self.model_dim))
im = im.astype(np.float32) / 255.0
im = np.ascontiguousarray(im) # contiguous
input_tensor = ov.Tensor(array=im)
elif self.yolo:
input_tensor = ov.Tensor(
array=np.expand_dims(np.array(input), axis=0).astype(np.float32)
)
im = np.expand_dims(np.array(input), axis=0).astype(np.float32)
else:
input_tensor = ov.Tensor(array=np.expand_dims(np.array(input), axis=0))
return input_tensor
im = np.expand_dims(np.array(input), axis=0)
return im
try:
input_tensor = await asyncio.get_event_loop().run_in_executor(
prepareExecutor, lambda: prepare()
)
objs = await predict(input_tensor)
f = asyncio.Future(loop=self.loop)
self.infer_queue.start_async(input_tensor, f)
objs = await f
except:
traceback.print_exc()
raise

View File

@@ -17,7 +17,3 @@ Medium: 720p (500 Kbps)
Low (if available): 320p (100 Kbps)
The `Key Frame (IDR) Interval` should be set to `4` seconds. This setting is usually configured in frames. So if the camera frame rate is `30`, the interval would be `120`. If the camera frame rate is `15` the interval would be `60`. The value can be calculated as `IDR Interval = FPS * 4`.
## Transcoding
Some cameras may not allow configuration of the video codec (h264) or IDR Interval. The camera may also only have a single high bitrate stream which will fail to stream when viewing on low bandwidth remote connections. In this case, Transcoding should be enabled for `Remote Stream` and `Remote Recording Stream` to ensure there isn't a bandwidth issue.

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/prebuffer-mixin",
"version": "0.10.39",
"version": "0.10.46",
"description": "Video Stream Rebroadcast, Prebuffer, and Management Plugin for Scrypted.",
"author": "Scrypted",
"license": "Apache-2.0",
@@ -26,7 +26,7 @@
"name": "Rebroadcast Plugin",
"type": "API",
"interfaces": [
"DeviceProvider",
"Settings",
"MixinProvider",
"BufferConverter"
],

View File

@@ -1,5 +1,4 @@
import { AutoenableMixinProvider } from '@scrypted/common/src/autoenable-mixin-provider';
import { getDebugModeH264EncoderArgs, getH264EncoderArgs } from '@scrypted/common/src/ffmpeg-hardware-acceleration';
import { addVideoFilterArguments } from '@scrypted/common/src/ffmpeg-helpers';
import { ListenZeroSingleClientTimeoutError, closeQuiet, listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
import { readLength } from '@scrypted/common/src/read-stream';
@@ -8,7 +7,7 @@ import { addTrackControls, getSpsPps, parseSdp } from '@scrypted/common/src/sdp-
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, DeviceProvider, 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, H264Info, 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';
@@ -24,7 +23,6 @@ import { connectRFC4571Parser, startRFC4571Parser } from './rfc4571';
import { RtspSessionParserSpecific, startRtspSession } from './rtsp-session';
import { getSpsResolution } from './sps-resolution';
import { createStreamSettings } from './stream-settings';
import { TRANSCODE_MIXIN_PROVIDER_NATIVE_ID, TranscodeMixinProvider, getTranscodeMixinProviderId } from './transcode-settings';
const { mediaManager, log, systemManager, deviceManager } = sdk;
@@ -65,15 +63,13 @@ class PrebufferSession {
usingScryptedParser = false;
usingScryptedUdpParser = false;
audioDisabled = false;
mixinDevice: VideoCamera;
console: Console;
storage: Storage;
activeClients = 0;
inactivityTimeout: NodeJS.Timeout;
audioConfigurationKey: string;
syntheticInputIdKey: string;
ffmpegInputArgumentsKey: string;
ffmpegOutputArgumentsKey: string;
lastDetectedAudioCodecKey: string;
@@ -89,7 +85,7 @@ class PrebufferSession {
this.storage = mixin.storage;
this.console = mixin.console;
this.mixinDevice = mixin.mixinDevice;
this.audioConfigurationKey = 'audioConfiguration-' + this.streamId;
this.syntheticInputIdKey = 'syntheticInputIdKey-' + this.streamId;
this.ffmpegInputArgumentsKey = 'ffmpegInputArguments-' + this.streamId;
this.ffmpegOutputArgumentsKey = 'ffmpegOutputArguments-' + this.streamId;
this.lastDetectedAudioCodecKey = 'lastDetectedAudioCodec-' + this.streamId;
@@ -117,10 +113,6 @@ class PrebufferSession {
return !this.enabled || this.shouldDisableBatteryPrebuffer();
}
get canPrebuffer() {
return (this.advertisedMediaStreamOptions.container !== 'rawvideo' && this.advertisedMediaStreamOptions.container !== 'ffmpeg') || this.storage.getItem(this.ffmpegOutputArgumentsKey);
}
getLastH264Probe(): H264Info {
const str = this.storage.getItem(this.lastH264ProbeKey);
if (!str) {
@@ -230,12 +222,20 @@ class PrebufferSession {
getParser(mediaStreamOptions: MediaStreamOptions) {
let parser: string;
const rtspParser = this.storage.getItem(this.rtspParserKey);
let rtspParser = this.storage.getItem(this.rtspParserKey);
let isDefault = !rtspParser || rtspParser === 'Default';
if (!this.canUseRtspParser(mediaStreamOptions)) {
parser = STRING_DEFAULT;
isDefault = true;
rtspParser = undefined;
}
else {
if (isDefault) {
// use the plugin default
rtspParser = localStorage.getItem('defaultRtspParser');
}
switch (rtspParser) {
case FFMPEG_PARSER_TCP:
case FFMPEG_PARSER_UDP:
@@ -251,7 +251,7 @@ class PrebufferSession {
return {
parser,
isDefault: !rtspParser || rtspParser === 'Default',
isDefault,
}
}
@@ -326,6 +326,19 @@ class PrebufferSession {
const group = "Streams";
const subgroup = `Stream: ${this.streamName}`;
if (this.mixin.streamSettings.storageSettings.values.synthenticStreams.includes(this.streamId)) {
const nonSynthetic = [...this.mixin.sessions.keys()].filter(s => s && !s.startsWith('synthetic:'));
settings.push({
group,
subgroup,
key: this.syntheticInputIdKey,
title: 'Synthetic Stream Source',
description: 'The source stream to transcode.',
choices: nonSynthetic,
value: this.storage.getItem(this.syntheticInputIdKey),
});
}
const addFFmpegInputSettings = () => {
settings.push(
{
@@ -351,7 +364,7 @@ class PrebufferSession {
key: this.ffmpegOutputArgumentsKey,
value: this.storage.getItem(this.ffmpegOutputArgumentsKey),
choices: [
'-c:v libx264 -pix_fmt yuvj420p -preset ultrafast -bf 0'
'-c:v libx264 -pix_fmt yuvj420p -preset ultrafast -bf 0 -g 60 -r 15 -b:v 500000 -bufsize 1000000 -maxrate 500000'
],
combobox: true,
},
@@ -364,11 +377,6 @@ class PrebufferSession {
const parser = this.getParser(this.advertisedMediaStreamOptions);
const defaultValue = parser.parser;
const scryptedOptions = [
SCRYPTED_PARSER_TCP,
SCRYPTED_PARSER_UDP,
];
const currentParser = parser.isDefault ? STRING_DEFAULT : parser.parser;
settings.push(
@@ -381,7 +389,8 @@ class PrebufferSession {
value: currentParser,
choices: [
STRING_DEFAULT,
...scryptedOptions,
SCRYPTED_PARSER_TCP,
SCRYPTED_PARSER_UDP,
FFMPEG_PARSER_TCP,
FFMPEG_PARSER_UDP,
],
@@ -496,20 +505,15 @@ class PrebufferSession {
catch (e) {
}
// audio codecs are determined by probing the camera to see what it reports.
// if the camera does not specify a codec, rebroadcast will force audio off
// to determine the codec without causing a parse failure.
// camera may explicity request that its audio stream be muted via a null.
// respect that setting.
const audioSoftMuted = mso?.audio === null;
const advertisedAudioCodec = mso?.audio?.codec;
const advertisedAudioCodec = !audioSoftMuted && mso?.audio?.codec;
let detectedAudioCodec = this.storage.getItem(this.lastDetectedAudioCodecKey) || undefined;
if (detectedAudioCodec === 'null')
detectedAudioCodec = null;
this.audioDisabled = false;
const rbo: ParserOptions<PrebufferParsers> = {
console: this.console,
timeout: 60000,
@@ -518,7 +522,19 @@ class PrebufferSession {
};
this.parsers = rbo.parsers;
const mo = await this.mixinDevice.getVideoStream(mso);
let mo: MediaObject;
if (this.mixin.streamSettings.storageSettings.values.synthenticStreams.includes(this.streamId)) {
const syntheticInputId = this.storage.getItem(this.syntheticInputIdKey);
if (!syntheticInputId)
throw new Error('synthetic stream has not been configured with an input');
const realDevice = systemManager.getDeviceById<VideoCamera>(this.mixin.id);
mo = await realDevice.getVideoStream({
id: syntheticInputId,
});
}
else {
mo = await this.mixinDevice.getVideoStream(mso);
}
const isRfc4571 = mo.mimeType === 'x-scrypted/x-rfc4571';
let session: ParserSession<PrebufferParsers>;
@@ -581,7 +597,6 @@ class PrebufferSession {
if (audioSoftMuted) {
// no audio? explicitly disable it.
acodec = ['-an'];
this.audioDisabled = true;
}
else {
acodec = [
@@ -605,9 +620,6 @@ class PrebufferSession {
const extraInputArguments = userInputArguments || DEFAULT_FFMPEG_INPUT_ARGUMENTS;
const extraOutputArguments = this.storage.getItem(this.ffmpegOutputArgumentsKey) || '';
ffmpegInput.inputArguments.unshift(...extraInputArguments.split(' '));
// ehh this seems to cause issues with frames being updated in the webassembly decoder..?
// if (!userInputArguments && (ffmpegInput.container === 'rtmp' || ffmpegInput.url?.startsWith('rtmp:')))
// ffmpegInput.inputArguments.unshift('-use_wallclock_as_timestamps', '1');
if (ffmpegInput.h264EncoderArguments?.length) {
vcodec = [...ffmpegInput.h264EncoderArguments];
@@ -1001,6 +1013,9 @@ class PrebufferSession {
mediaStreamOptions.video.h264Info = this.getLastH264Probe();
}
if (this.mixin.streamSettings.storageSettings.values.noAudio)
mediaStreamOptions.audio = null;
let socketPromise: Promise<Duplex>;
let url: string;
let urls: string[];
@@ -1111,10 +1126,7 @@ class PrebufferSession {
mediaStreamOptions.prebuffer = requestedPrebuffer;
if (this.audioDisabled) {
mediaStreamOptions.audio = null;
}
else if (audioSection) {
if (audioSection) {
mediaStreamOptions.audio ||= {};
mediaStreamOptions.audio.codec ||= audioSection.rtpmap.codec;
mediaStreamOptions.audio.sampleRate ||= audioSection.rtpmap.clock;
@@ -1282,9 +1294,6 @@ class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera> implements Vid
}
async getVideoStream(options?: RequestMediaStreamOptions): Promise<MediaObject> {
if (options?.route === 'direct')
return this.mixinDevice.getVideoStream(options);
await this.ensurePrebufferSessions();
let id = options?.id;
@@ -1294,8 +1303,6 @@ class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera> implements Vid
let videoFilterArguments: string;
let destinationVideoBitrate: number;
const transcodingEnabled = this.mixins?.includes(getTranscodeMixinProviderId());
const msos = await this.mixinDevice.getVideoStreamOptions();
let result: {
stream: ResponseMediaStreamOptions,
@@ -1355,58 +1362,23 @@ class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera> implements Vid
}
id = result.stream.id;
// this.console.log('Selected stream', result.stream.name);
// transcoding video should never happen transparently since it is CPU intensive.
// encourage users at every step to configure proper codecs.
// for this reason, do not automatically supply h264 encoder arguments
// even if h264 is requested, to force a visible failure.
if (transcodingEnabled && this.streamSettings.storageSettings.values.transcodeStreams?.includes(result.title)) {
h264EncoderArguments = transcodeStorageSettings.h264EncoderArguments?.split(' ');
if (this.streamSettings.storageSettings.values.videoFilterArguments)
videoFilterArguments = this.streamSettings.storageSettings.values.videoFilterArguments;
}
}
let session = this.sessions.get(id);
let ffmpegInput: FFmpegInput;
if (!session.canPrebuffer) {
this.console.log('Source container can not be prebuffered. Using a direct media stream.');
session = undefined;
}
if (!session) {
const mo = await this.mixinDevice.getVideoStream(options);
if (!transcodingEnabled)
return mo;
ffmpegInput = await mediaManager.convertMediaObjectToJSON(mo, ScryptedMimeTypes.FFmpegInput);
}
else {
// ffmpeg probing works better if the stream does NOT start on a sync frame. the pre-sps/pps data is used
// as part of the stream analysis, and sync frame is immediately used. otherwise the sync frame is
// read and tossed during rtsp analysis.
// if ffmpeg is not in used (ie, not transcoding or implicitly rtsp),
// trust that downstream is not using ffmpeg and start with a sync frame.
const findSyncFrame = !transcodingEnabled
&& (!options?.container || options?.container === 'rtsp')
&& options?.tool !== 'ffmpeg';
ffmpegInput = await session.getVideoStream(findSyncFrame, options);
}
if (!session)
throw new Error('stream not found');
ffmpegInput = await session.getVideoStream(true, options);
ffmpegInput.h264EncoderArguments = h264EncoderArguments;
ffmpegInput.destinationVideoBitrate = destinationVideoBitrate;
if (transcodingEnabled && this.streamSettings.storageSettings.values.missingCodecParameters) {
if (!ffmpegInput.mediaStreamOptions)
ffmpegInput.mediaStreamOptions = { id };
ffmpegInput.mediaStreamOptions.oobCodecParameters = true;
}
if (ffmpegInput.h264FilterArguments && videoFilterArguments)
addVideoFilterArguments(ffmpegInput.h264FilterArguments, videoFilterArguments)
else if (videoFilterArguments)
ffmpegInput.h264FilterArguments = ['-filter_complex', videoFilterArguments];
if (transcodingEnabled)
ffmpegInput.videoDecoderArguments = this.streamSettings.storageSettings.values.videoDecoderArguments?.split(' ');
return mediaManager.createFFmpegMediaObject(ffmpegInput, {
sourceId: this.id,
});
@@ -1489,6 +1461,22 @@ class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera> implements Vid
})();
}
for (const synthetic of this.streamSettings.storageSettings.values.synthenticStreams) {
const id = `synthetic:${synthetic}`;
toRemove.delete(id);
let session = this.sessions.get(id);
if (session)
continue;
session = new PrebufferSession(this, {
id: synthetic,
}, false, false);
this.sessions.set(id, session);
this.console.log('stream', synthetic, 'is synthetic and will be rebroadcast on demand.');
}
if (!this.sessions.has(undefined)) {
const defaultStreamName = this.streamSettings.storageSettings.values.defaultStream;
let defaultSession = this.sessions.get(msos?.find(mso => mso.name === defaultStreamName)?.id);
@@ -1617,30 +1605,39 @@ function millisUntilMidnight() {
return (midnight.getTime() - new Date().getTime());
}
export class RebroadcastPlugin extends AutoenableMixinProvider implements MixinProvider, BufferConverter, Settings, DeviceProvider {
export class RebroadcastPlugin extends AutoenableMixinProvider implements MixinProvider, BufferConverter, Settings, Settings {
// no longer in use, but kept for future use.
storageSettings = new StorageSettings(this, {});
storageSettings = new StorageSettings(this, {
defaultRtspParser: {
group: 'Advanced',
title: 'Default RTSP Parser',
description: `Experimental: The Default parser used to read RTSP streams. The default is "${SCRYPTED_PARSER_TCP}".`,
defaultValue: STRING_DEFAULT,
choices: [
STRING_DEFAULT,
SCRYPTED_PARSER_TCP,
SCRYPTED_PARSER_UDP,
FFMPEG_PARSER_TCP,
FFMPEG_PARSER_UDP,
],
onPut: () => {
this.log.a('Rebroadcast Plugin will restart momentarily.');
sdk.deviceManager.requestRestart();
}
}
});
transcodeStorageSettings = new StorageSettings(this, {
remoteStreamingBitrate: {
group: 'Advanced',
title: 'Remote Streaming Bitrate',
type: 'number',
defaultValue: 1000000,
defaultValue: 500000,
description: 'The bitrate to use when remote streaming. This setting will only be used when transcoding or adaptive bitrate is enabled on a camera.',
onPut() {
sdk.deviceManager.onDeviceEvent('transcode', ScryptedInterface.Settings, undefined);
},
},
h264EncoderArguments: {
title: 'H264 Encoder Arguments',
description: 'FFmpeg arguments used to encode h264 video. This is not camera specific and is used to setup the hardware accelerated encoder on your Scrypted server. This setting will only be used when transcoding is enabled on a camera.',
choices: Object.keys(getH264EncoderArgs()),
defaultValue: getDebugModeH264EncoderArgs().join(' '),
combobox: true,
mapPut: (oldValue, newValue) => getH264EncoderArgs()[newValue]?.join(' ') || newValue || getDebugModeH264EncoderArgs().join(' '),
onPut() {
sdk.deviceManager.onDeviceEvent('transcode', ScryptedInterface.Settings, undefined);
},
}
});
currentMixins = new Map<PrebufferMixin, {
worker: ForkWorker,
@@ -1650,6 +1647,8 @@ export class RebroadcastPlugin extends AutoenableMixinProvider implements MixinP
constructor(nativeId?: string) {
super(nativeId);
this.log.clearAlerts();
this.fromMimeType = 'x-scrypted/x-rfc4571';
this.toMimeType = ScryptedMimeTypes.FFmpegInput;
@@ -1669,40 +1668,24 @@ export class RebroadcastPlugin extends AutoenableMixinProvider implements MixinP
}
});
// schedule restarts at 2am
// removed as the mp4 containerization leak used way back when is defunct.
// const midnight = millisUntilMidnight();
// const twoAM = midnight + 2 * 60 * 60 * 1000;
// this.log.i(`Rebroadcaster scheduled for restart at 2AM: ${Math.round(twoAM / 1000 / 60)} minutes`)
// setTimeout(() => deviceManager.requestRestart(), twoAM);
process.nextTick(() => {
deviceManager.onDeviceDiscovered({
nativeId: TRANSCODE_MIXIN_PROVIDER_NATIVE_ID,
name: 'Transcoding',
interfaces: [
"SystemSettings",
ScryptedInterface.Settings,
ScryptedInterface.MixinProvider,
],
type: ScryptedDeviceType.API,
// legacy transcode extension that needs to be removed.
if (sdk.deviceManager.getNativeIds().includes('transcode')) {
process.nextTick(() => {
deviceManager.onDeviceRemoved('transcode');
});
});
}
}
async releaseDevice(id: string, nativeId: string): Promise<void> {
}
async getDevice(nativeId: string) {
if (nativeId === TRANSCODE_MIXIN_PROVIDER_NATIVE_ID)
return new TranscodeMixinProvider(this);
}
getSettings(): Promise<Setting[]> {
return this.storageSettings.getSettings();
async getSettings(): Promise<Setting[]> {
return [
...await this.storageSettings.getSettings(),
...await this.transcodeStorageSettings.getSettings(),
];
}
putSetting(key: string, value: SettingValue): Promise<void> {
if (this.transcodeStorageSettings.keys[key])
return this.transcodeStorageSettings.putSetting(key, value);
return this.storageSettings.putSetting(key, value);
}

View File

@@ -1,7 +1,6 @@
import { getH264DecoderArgs } from "@scrypted/common/src/ffmpeg-hardware-acceleration";
import { StorageSetting, StorageSettings } from "@scrypted/sdk/storage-settings";
import { MixinDeviceBase, ResponseMediaStreamOptions, VideoCamera } from "@scrypted/sdk";
import { getTranscodeMixinProviderId } from "./transcode-settings";
import { StorageSetting, StorageSettings } from "@scrypted/sdk/storage-settings";
export type StreamStorageSetting = StorageSetting & {
prefersPrebuffer: boolean,
@@ -102,45 +101,16 @@ export function createStreamSettings(device: MixinDeviceBase<VideoCamera>) {
type: 'number',
hide: false,
},
transcodeStreams: {
group: 'Transcoding',
title: 'Transcode Streams',
description: 'The media streams to transcode. Transcoding audio and video is not recommended and should only be used when necessary. The Rebroadcast Plugin manages the system-wide Transcode settings. See the Rebroadcast Readme for optimal configuration.',
synthenticStreams: {
subgroup,
title: 'Synthetic Streams',
description: 'Create additional streams by transcoding the existing streams. This can be useful for creating streams with different resolutions or bitrates.',
immediate: true,
multiple: true,
choices: Object.values(streamTypes).map(st => st.title),
hide: true,
},
videoDecoderArguments: {
group: 'Transcoding',
title: 'Video Decoder Arguments',
description: 'FFmpeg arguments used to decode input video when transcoding a stream.',
placeholder: '-hwaccel auto',
choices: Object.keys(getH264DecoderArgs()),
combobox: true,
mapPut: (oldValue, newValue) => getH264DecoderArgs()[newValue]?.join(' ') || newValue || '',
hide: true,
},
videoFilterArguments: {
group: 'Transcoding',
title: 'Video Filter Arguments',
description: 'FFmpeg arguments used to filter input video when transcoding a stream. This can be used to crops, scale, rotates, etc.',
placeholder: 'transpose=1',
hide: true,
},
// 3/6/2022
// Ran into an issue where the RTSP source had SPS/PPS in the SDP,
// and none in the bitstream. Codec copy will not add SPS/PPS before IDR frames
// unless this flag is used.
// 3/7/2022
// This flag was enabled by default, but I believe this is causing issues with some users.
// Make it a setting.
missingCodecParameters: {
group: 'Transcoding',
title: 'Out of Band Codec Parameters',
description: 'Some cameras do not include H264 codec parameters in the stream and this causes live streaming to always fail (but recordings may be working). This is a inexpensive video filter and does not perform a transcode. Enable this setting only as necessary.',
type: 'boolean',
hide: true,
},
choices: [],
defaultValue: [],
}
});
function getDefaultPrebufferedStreams(msos: ResponseMediaStreamOptions[]) {
@@ -177,10 +147,18 @@ export function createStreamSettings(device: MixinDeviceBase<VideoCamera>) {
const v: StreamStorageSetting = storageSettings.settings[key];
const value = storageSettings.values[key];
let isDefault = value === 'Default';
let stream = msos?.find(mso => mso.name === value);
if (isDefault || !stream) {
isDefault = true;
stream = getDefaultMediaStream(v, msos);
if (storageSettings.values.synthenticStreams.includes(value)) {
stream = {
id: `synthetic:${value}`,
};
}
else {
if (isDefault || !stream) {
isDefault = true;
stream = getDefaultMediaStream(v, msos);
}
}
return {
title: streamTypes[key].title,
@@ -193,6 +171,7 @@ export function createStreamSettings(device: MixinDeviceBase<VideoCamera>) {
const choices = [
'Default',
...msos.map(mso => mso.name),
...storageSettings.values.synthenticStreams,
];
const defaultValue = getDefaultMediaStream(v, msos).name;
@@ -209,16 +188,6 @@ export function createStreamSettings(device: MixinDeviceBase<VideoCamera>) {
onGet: async () => {
let enabledStreams: StorageSetting;
const hideTranscode = device.mixins?.includes(getTranscodeMixinProviderId()) ? {
hide: false,
} : {};
const hideTranscodes = {
transcodeStreams: hideTranscode,
missingCodecParameters: hideTranscode,
videoDecoderArguments: hideTranscode,
videoFilterArguments: hideTranscode,
};
try {
const msos = await device.mixinDevice.getVideoStreamOptions();
@@ -236,13 +205,11 @@ export function createStreamSettings(device: MixinDeviceBase<VideoCamera>) {
lowResolutionStream: createStreamOptions(streamTypes.lowResolutionStream, msos),
recordingStream: createStreamOptions(streamTypes.recordingStream, msos),
remoteRecordingStream: createStreamOptions(streamTypes.remoteRecordingStream, msos),
...hideTranscodes,
}
}
else {
return {
enabledStreams,
...hideTranscodes,
}
}
}
@@ -251,7 +218,6 @@ export function createStreamSettings(device: MixinDeviceBase<VideoCamera>) {
}
return {
...hideTranscodes,
}
}
}

View File

@@ -1,53 +0,0 @@
import sdk, { MixinProvider, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings, SettingValue } from "@scrypted/sdk";
import { RebroadcastPlugin } from "./main";
import { REBROADCAST_MIXIN_INTERFACE_TOKEN } from "./rebroadcast-mixin-token";
const { deviceManager } = sdk;
export const TRANSCODE_MIXIN_PROVIDER_NATIVE_ID = 'transcode';
export function getTranscodeMixinProviderId() {
if (!deviceManager.getNativeIds().includes(TRANSCODE_MIXIN_PROVIDER_NATIVE_ID))
return;
const transcodeMixin = deviceManager.getDeviceState(TRANSCODE_MIXIN_PROVIDER_NATIVE_ID);
return transcodeMixin?.id;
}
export class TranscodeMixinProvider extends ScryptedDeviceBase implements MixinProvider, Settings {
constructor(public plugin: RebroadcastPlugin) {
super(TRANSCODE_MIXIN_PROVIDER_NATIVE_ID);
}
getSettings(): Promise<Setting[]> {
return this.plugin.transcodeStorageSettings.getSettings();
}
putSetting(key: string, value: SettingValue): Promise<void> {
return this.plugin.transcodeStorageSettings.putSetting(key, value);
}
async canMixin(type: ScryptedDeviceType, interfaces: string[]): Promise<string[]> {
if (!interfaces.includes(REBROADCAST_MIXIN_INTERFACE_TOKEN))
return;
return [
ScryptedInterface.Settings,
];
}
invalidateSettings(id: string) {
process.nextTick(async () => {
for (const [mixin, v] of this.plugin.currentMixins.entries()) {
if (v.id === id)
mixin?.onDeviceEvent(ScryptedInterface.Settings, undefined)
}
});
}
async getMixin(mixinDevice: any, mixinDeviceInterfaces: ScryptedInterface[], mixinDeviceState: { [key: string]: any; }): Promise<any> {
this.invalidateSettings(mixinDeviceState.id);
return mixinDevice;
}
async releaseMixin(id: string, mixinDevice: any): Promise<void> {
this.invalidateSettings(id);
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/reolink",
"version": "0.0.100",
"version": "0.0.104",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/reolink",
"version": "0.0.100",
"version": "0.0.104",
"license": "Apache",
"dependencies": {
"@scrypted/common": "file:../../common",
@@ -35,22 +35,29 @@
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.3.67",
"version": "0.3.108",
"dev": true,
"license": "ISC",
"dependencies": {
"@babel/preset-typescript": "^7.24.7",
"adm-zip": "^0.5.14",
"axios": "^1.7.3",
"babel-loader": "^9.1.3",
"@babel/preset-typescript": "^7.26.0",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-typescript": "^12.1.1",
"@rollup/plugin-virtual": "^3.0.2",
"adm-zip": "^0.5.16",
"axios": "^1.7.8",
"babel-loader": "^9.2.1",
"babel-plugin-const-enum": "^1.2.0",
"ncp": "^2.0.0",
"raw-loader": "^4.0.2",
"rimraf": "^6.0.1",
"rollup": "^4.27.4",
"tmp": "^0.2.3",
"ts-loader": "^9.5.1",
"typescript": "^5.5.4",
"webpack": "^5.93.0",
"tslib": "^2.8.1",
"typescript": "^5.6.3",
"webpack": "^5.96.1",
"webpack-bundle-analyzer": "^4.10.2"
},
"bin": {
@@ -63,11 +70,9 @@
"scrypted-webpack": "bin/scrypted-webpack.js"
},
"devDependencies": {
"@types/node": "^22.1.0",
"@types/stringify-object": "^4.0.5",
"stringify-object": "^3.3.0",
"@types/node": "^22.10.1",
"ts-node": "^10.9.2",
"typedoc": "^0.26.5"
"typedoc": "^0.26.11"
}
},
"../onvif/onvif": {

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/reolink",
"version": "0.0.100",
"version": "0.0.104",
"description": "Reolink Plugin for Scrypted",
"author": "Scrypted",
"license": "Apache",

View File

@@ -1,5 +1,5 @@
import { sleep } from '@scrypted/common/src/sleep';
import sdk, { Brightness, Camera, Device, DeviceCreatorSettings, DeviceInformation, DeviceProvider, Intercom, MediaObject, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, OnOff, PanTiltZoom, PanTiltZoomCommand, Reboot, RequestPictureOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting } from "@scrypted/sdk";
import sdk, { Sleep, Brightness, Camera, Device, DeviceCreatorSettings, DeviceInformation, DeviceProvider, Intercom, MediaObject, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, OnOff, PanTiltZoom, PanTiltZoomCommand, Reboot, RequestPictureOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting } from "@scrypted/sdk";
import { StorageSettings } from '@scrypted/sdk/storage-settings';
import { EventEmitter } from "stream";
import { createRtspMediaStreamOptions, Destroyable, RtspProvider, RtspSmartCamera, UrlMediaStreamOptions } from "../../rtsp/src/rtsp";
@@ -78,7 +78,7 @@ class ReolinkCameraFloodlight extends ScryptedDeviceBase implements OnOff, Brigh
}
}
class ReolinkCamera extends RtspSmartCamera implements Camera, DeviceProvider, Reboot, Intercom, ObjectDetector, PanTiltZoom {
class ReolinkCamera extends RtspSmartCamera implements Camera, DeviceProvider, Reboot, Intercom, ObjectDetector, PanTiltZoom, Sleep {
client: ReolinkCameraClient;
clientWithToken: ReolinkCameraClient;
onvifClient: OnvifCameraAPI;
@@ -362,7 +362,7 @@ class ReolinkCamera extends RtspSmartCamera implements Camera, DeviceProvider, R
if (this.hasSiren() || this.hasFloodlight())
interfaces.push(ScryptedInterface.DeviceProvider);
if (this.hasBattery()) {
interfaces.push(ScryptedInterface.Battery, ScryptedInterface.Online);
interfaces.push(ScryptedInterface.Battery, ScryptedInterface.Sleep);
this.startBatteryCheckInterval();
}
@@ -378,14 +378,20 @@ class ReolinkCamera extends RtspSmartCamera implements Camera, DeviceProvider, R
const api = this.getClientWithToken();
try {
const { batteryPercent, sleep } = await api.getBatteryInfo();
const { batteryPercent, sleeping } = await api.getBatteryInfo();
this.batteryLevel = batteryPercent;
this.online = !sleep;
if (sleeping !== this.sleeping) {
this.sleeping = sleeping;
}
if (batteryPercent !== this.batteryLevel) {
this.batteryLevel = batteryPercent;
}
}
catch (e) {
this.console.log('Error in getting battery info', e);
}
}, 1000 * 60 * 30);
}, 1000 * 10);
}
async reboot() {
@@ -557,10 +563,19 @@ class ReolinkCamera extends RtspSmartCamera implements Camera, DeviceProvider, R
(async () => {
while (!killed) {
try {
const { value, data } = await client.getMotionState();
if (value)
triggerMotion();
ret.emit('data', JSON.stringify(data));
// Battey cameras do not have AI state, they just send events in case of PIR sensor triggered
// which equals a motion detected
if (this.hasBattery()) {
const { value, data } = await client.getPidActive();
if (value)
triggerMotion();
ret.emit('data', JSON.stringify(data));
} else {
const { value, data } = await client.getMotionState();
if (value)
triggerMotion();
ret.emit('data', JSON.stringify(data));
}
}
catch (e) {
ret.emit('error', e);
@@ -725,7 +740,8 @@ class ReolinkCamera extends RtspSmartCamera implements Camera, DeviceProvider, R
[
"Reolink TrackMix PoE",
"Reolink TrackMix WiFi",
"RLC-81MA"
"RLC-81MA",
"Trackmix Series W760"
].includes(deviceInfo?.model)) {
streams.push({
name: '',

View File

@@ -492,7 +492,35 @@ export class ReolinkCameraClient {
return {
batteryPercent: batteryInfoEntry?.batteryPercent,
sleep: channelStatusEntry?.sleep === 1,
sleeping: channelStatusEntry?.sleep === 1,
}
}
async getPidActive() {
const url = new URL(`http://${this.host}/api.cgi`);
const body = [
{
cmd: "GetEvents",
action: 0,
param: { channel: this.channelId }
},
];
const response = await this.requestWithLogin({
url,
responseType: 'json',
method: 'POST',
}, this.createReadable(body));
const error = response.body?.find(elem => elem.error)?.error;
if (error) {
this.console.error('error during call to getEvents', error);
}
return {
value: !!response.body?.[0]?.value?.ai?.other?.alarm_state,
data: response.body,
};
}
}

View File

@@ -2,7 +2,7 @@ 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, VideoCamera, WritableDeviceState } from "@scrypted/sdk";
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 { StorageSettings } from "@scrypted/sdk/storage-settings";
import https from 'https';
import os from 'os';
@@ -127,9 +127,8 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
return this.console;
}
async takePictureInternal(options?: RequestPictureOptions): Promise<Buffer> {
this.debugConsole?.log("Picture requested from camera", options);
const eventSnapshot = options?.reason === 'event';
async takePictureInternal(id: string, eventSnapshot: boolean): Promise<Buffer> {
this.debugConsole?.log("Picture requested from camera", { id, eventSnapshot });
const { snapshotsFromPrebuffer } = this.storageSettings.values;
let usePrebufferSnapshots: boolean;
switch (snapshotsFromPrebuffer) {
@@ -162,11 +161,12 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
}
}
const realDevice = systemManager.getDeviceById<VideoCamera & Sleep>(this.id);
let takePrebufferPicture: () => Promise<Buffer>;
const preparePrebufferSnapshot = async () => {
if (takePrebufferPicture)
return takePrebufferPicture;
const realDevice = systemManager.getDeviceById<VideoCamera>(this.id);
const msos = await realDevice.getVideoStreamOptions();
let prebufferChannel = msos?.find(mso => mso.prebuffer);
if (prebufferChannel || !this.lastAvailablePicture) {
@@ -250,7 +250,7 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
if (this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera)) {
let takePictureOptions: RequestPictureOptions;
if (!options?.id && this.storageSettings.values.defaultSnapshotChannel !== 'Camera Default') {
if (!id && this.storageSettings.values.defaultSnapshotChannel !== 'Camera Default') {
try {
const psos = await this.getPictureOptions();
const pso = psos.find(pso => pso.name === this.storageSettings.values.defaultSnapshotChannel);
@@ -262,6 +262,9 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
}
}
try {
// consider waking the camera if
if (!eventSnapshot && this.mixinDeviceInterfaces.includes(ScryptedInterface.Sleep) && realDevice.sleeping)
throw new Error('Not waking sleeping camera for periodic snapshot.');
return await this.mixinDevice.takePicture(takePictureOptions).then(mo => mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg'))
}
catch (e) {
@@ -289,7 +292,7 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
event: options?.reason === 'event',
}, eventSnapshot ? 0 : 4000, async () => {
const snapshotTimer = Date.now();
let picture = await this.takePictureInternal();
let picture = await this.takePictureInternal(undefined, eventSnapshot);
picture = await this.cropAndScale(picture);
this.clearCachedPictures();
const pictureTime = Date.now();

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/webrtc",
"version": "0.2.57",
"version": "0.2.58",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/webrtc",
"version": "0.2.57",
"version": "0.2.58",
"dependencies": {
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/webrtc",
"version": "0.2.57",
"version": "0.2.58",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",
"prescrypted-setup-project": "scrypted-package-json",

View File

@@ -1,6 +1,6 @@
{
"scrypted.debugHost": "127.0.0.1",
"scrypted.debugHost": "scrypted-nvr",
"python.analysis.extraPaths": [
"./node_modules/@scrypted/sdk/types/scrypted_python"

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/wyze",
"version": "0.0.56",
"version": "0.0.57",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/wyze",
"version": "0.0.56",
"version": "0.0.57",
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
}

View File

@@ -38,5 +38,5 @@
"devDependencies": {
"@scrypted/sdk": "file:../../sdk"
},
"version": "0.0.56"
"version": "0.0.57"
}

View File

@@ -135,7 +135,7 @@ class WyzeCamera(scrypted_sdk.ScryptedDeviceBase, VideoCamera, Settings, PanTilt
except:
if default:
return "Default"
return 120 if self.camera.is_2k else 60
return 240 if self.camera.is_2k else 160
async def getSettings(self):
ret: List[Setting] = []
@@ -152,8 +152,9 @@ class WyzeCamera(scrypted_sdk.ScryptedDeviceBase, VideoCamera, Settings, PanTilt
"500",
"750",
"1000",
"1200",
"1400",
"1800",
"2000",
],
}
)

View File

@@ -7,8 +7,6 @@ const axios = require('axios').create({
const process = require('process');
const path = require('path');
const fs = require('fs');
const chalk = require('chalk');
function getUserHome() {
const ret = process.env[(process.platform == 'win32') ? 'USERPROFILE' : 'HOME'];
@@ -127,7 +125,7 @@ exports.deploy = function (debugHost, noRebind) {
.catch((err) => {
console.error(err.message);
if (err.response && err.response.data) {
console.log(chalk.red(err.response.data));
console.log('\x1b[31m%s\x1b[0m', err.response.data);
}
reject(err);
});
@@ -160,7 +158,7 @@ exports.debug = function (debugHost, entryPoint) {
.catch((err) => {
console.error(err.message);
if (err.response && err.response.data) {
console.log(chalk.red(err.response.data));
console.log('\x1b[31m%s\x1b[0m', err.response.data);
}
reject(err);
});

4
sdk/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/sdk",
"version": "0.3.102",
"version": "0.3.111",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/sdk",
"version": "0.3.102",
"version": "0.3.111",
"license": "ISC",
"dependencies": {
"@babel/preset-typescript": "^7.26.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/sdk",
"version": "0.3.102",
"version": "0.3.111",
"description": "",
"main": "dist/src/index.js",
"exports": {

View File

@@ -266,7 +266,7 @@ try {
}
try {
(systemManager as any).setScryptedInterfaceDescriptors?.(TYPES_VERSION, ScryptedInterfaceDescriptors)?.catch(() => { });
(sdk.systemManager as any).setScryptedInterfaceDescriptors?.(TYPES_VERSION, ScryptedInterfaceDescriptors)?.catch(() => { });
}
catch (e) {
}

View File

@@ -1,4 +1,4 @@
import sdk, { ScryptedInterface, Setting, Settings, SettingValue } from ".";
import sdk, { ScryptedDeviceType, ScryptedInterface, Setting, Settings, SettingValue } from ".";
const { systemManager } = sdk;
@@ -57,9 +57,9 @@ function parseValue(value: string | null | undefined, setting: StorageSetting, r
return value || readDefaultValue();
}
export type HideFunction = (device: any) => boolean;
export interface StorageSetting extends Omit<Setting, 'deviceFilter'> {
deviceFilter?: string | ((test: { id: string, deviceInterface: string, interfaces: string[], type: ScryptedDeviceType, ScryptedDeviceType: typeof ScryptedDeviceType, ScryptedInterface: typeof ScryptedInterface }) => boolean);
export interface StorageSetting extends Setting {
defaultValue?: any;
persistedDefaultValue?: any;
onPut?: (oldValue: any, newValue: any) => void;
@@ -140,7 +140,9 @@ export class StorageSettings<T extends string> implements Settings {
continue;
s.key = key;
s.value = this.getItemInternal(key as T, s, true);
ret.push(s);
if (typeof s.deviceFilter === 'function')
s.deviceFilter = s.deviceFilter.toString();
ret.push(s as Setting);
delete s.onPut;
delete s.onGet;
delete s.mapPut;

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/types",
"version": "0.3.94",
"version": "0.3.103",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/types",
"version": "0.3.94",
"version": "0.3.103",
"license": "ISC"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/types",
"version": "0.3.94",
"version": "0.3.103",
"description": "",
"main": "dist/index.js",
"author": "",

View File

@@ -117,6 +117,7 @@ class ScryptedInterface(str, Enum):
BinarySensor = "BinarySensor"
Brightness = "Brightness"
BufferConverter = "BufferConverter"
Buttons = "Buttons"
Camera = "Camera"
Charger = "Charger"
ClusterForkInterface = "ClusterForkInterface"
@@ -166,6 +167,7 @@ class ScryptedInterface(str, Enum):
PM25Sensor = "PM25Sensor"
PositionSensor = "PositionSensor"
PowerSensor = "PowerSensor"
PressButtons = "PressButtons"
Program = "Program"
PushHandler = "PushHandler"
Readme = "Readme"
@@ -184,6 +186,7 @@ class ScryptedInterface(str, Enum):
ScryptedUser = "ScryptedUser"
SecuritySystem = "SecuritySystem"
Settings = "Settings"
Sleep = "Sleep"
StartStop = "StartStop"
StreamService = "StreamService"
TamperSensor = "TamperSensor"
@@ -199,6 +202,7 @@ class ScryptedInterface(str, Enum):
VideoFrameGenerator = "VideoFrameGenerator"
VideoRecorder = "VideoRecorder"
VideoRecorderManagement = "VideoRecorderManagement"
VideoTextOverlay = "VideoTextOverlay"
VOCSensor = "VOCSensor"
class ScryptedMimeTypes(str, Enum):
@@ -286,11 +290,6 @@ class ClipPath(TypedDict):
pass
class Point(TypedDict):
pass
class AudioStreamOptions(TypedDict):
bitrate: float
@@ -322,8 +321,8 @@ class ObjectDetectionResult(TypedDict):
boundingBox: tuple[float, float, float, float] # x, y, width, height
className: str # The detection class of the object.
clipPaths: list[ClipPath] # The detection clip paths that outlines various features or segments, like traced facial features.
clipped: bool # Flag that indicates whether the detection was clipped by the detection input and may not be a full bounding box.
cost: float # The certainty that this is correct tracked object.
descriptor: str # A base64 encoded Float32Array that represents the vector descriptor of the detection. Can be used to compute euclidian distance to determine similarity.
embedding: str # Base64 encoded embedding float32 vector.
history: ObjectDetectionHistory
id: str # The id of the tracked object.
@@ -667,6 +666,7 @@ class NotifierOptions(TypedDict):
badge: str
body: str
bodyWithSubtitle: str
critical: bool
data: Any
dir: NotificationDirection
image: str
@@ -676,7 +676,7 @@ class NotifierOptions(TypedDict):
requireInteraction: bool
silent: bool
subtitle: str
tag: str
tag: str # Collapse key/id.
timestamp: float
vibrate: VibratePattern
@@ -946,12 +946,17 @@ class MediaConverterTypes(TypedDict):
pass
class Point(TypedDict):
pass
class TamperState(TypedDict):
pass
TYPES_VERSION = "0.3.94"
TYPES_VERSION = "0.3.103"
class AirPurifier:
@@ -1006,6 +1011,10 @@ class BufferConverter:
pass
class Buttons:
buttons: list[str]
class Camera:
"""Camera devices can take still photos."""
@@ -1376,6 +1385,12 @@ class PowerSensor:
powerDetected: bool
class PressButtons:
async def pressButton(self, button: str) -> None:
pass
class Program:
async def run(self, variables: Any = None) -> Any:
@@ -1523,6 +1538,10 @@ class Settings:
pass
class Sleep:
sleeping: bool
class StartStop:
"""StartStop represents a device that can be started, stopped, and possibly paused and resumed. Typically vacuum cleaners or washers."""
@@ -1649,6 +1668,12 @@ class VideoRecorderManagement:
pass
class VideoTextOverlay:
fontSize: float
origin: Point # The top left position of the overlay in the image, normalized to 0-1.
text: str
class VOCSensor:
vocDensity: float
@@ -1864,6 +1889,7 @@ class ScryptedInterfaceProperty(str, Enum):
colorTemperature = "colorTemperature"
rgb = "rgb"
hsv = "hsv"
buttons = "buttons"
running = "running"
paused = "paused"
docked = "docked"
@@ -1872,6 +1898,9 @@ class ScryptedInterfaceProperty(str, Enum):
temperatureUnit = "temperatureUnit"
humidity = "humidity"
audioVolumes = "audioVolumes"
fontSize = "fontSize"
origin = "origin"
text = "text"
recordingActive = "recordingActive"
ptzCapabilities = "ptzCapabilities"
lockState = "lockState"
@@ -1884,6 +1913,7 @@ class ScryptedInterfaceProperty(str, Enum):
converters = "converters"
binaryState = "binaryState"
tampered = "tampered"
sleeping = "sleeping"
powerDetected = "powerDetected"
audioDetected = "audioDetected"
motionDetected = "motionDetected"
@@ -1924,6 +1954,7 @@ class ScryptedInterfaceMethods(str, Enum):
setColorTemperature = "setColorTemperature"
setRgb = "setRgb"
setHsv = "setHsv"
pressButton = "pressButton"
sendNotification = "sendNotification"
start = "start"
stop = "stop"
@@ -2178,6 +2209,14 @@ class DeviceState:
def hsv(self, value: ColorHsv):
self.setScryptedProperty("hsv", value)
@property
def buttons(self) -> list[str]:
return self.getScryptedProperty("buttons")
@buttons.setter
def buttons(self, value: list[str]):
self.setScryptedProperty("buttons", value)
@property
def running(self) -> bool:
return self.getScryptedProperty("running")
@@ -2242,6 +2281,30 @@ class DeviceState:
def audioVolumes(self, value: AudioVolumes):
self.setScryptedProperty("audioVolumes", value)
@property
def fontSize(self) -> float:
return self.getScryptedProperty("fontSize")
@fontSize.setter
def fontSize(self, value: float):
self.setScryptedProperty("fontSize", value)
@property
def origin(self) -> Point:
return self.getScryptedProperty("origin")
@origin.setter
def origin(self, value: Point):
self.setScryptedProperty("origin", value)
@property
def text(self) -> str:
return self.getScryptedProperty("text")
@text.setter
def text(self, value: str):
self.setScryptedProperty("text", value)
@property
def recordingActive(self) -> bool:
return self.getScryptedProperty("recordingActive")
@@ -2338,6 +2401,14 @@ class DeviceState:
def tampered(self, value: TamperState):
self.setScryptedProperty("tampered", value)
@property
def sleeping(self) -> bool:
return self.getScryptedProperty("sleeping")
@sleeping.setter
def sleeping(self, value: bool):
self.setScryptedProperty("sleeping", value)
@property
def powerDetected(self) -> bool:
return self.getScryptedProperty("powerDetected")
@@ -2612,6 +2683,20 @@ ScryptedInterfaceDescriptors = {
"hsv"
]
},
"Buttons": {
"name": "Buttons",
"methods": [],
"properties": [
"buttons"
]
},
"PressButtons": {
"name": "PressButtons",
"methods": [
"pressButton"
],
"properties": []
},
"Notifier": {
"name": "Notifier",
"methods": [
@@ -2722,6 +2807,15 @@ ScryptedInterfaceDescriptors = {
],
"properties": []
},
"VideoTextOverlay": {
"name": "VideoTextOverlay",
"methods": [],
"properties": [
"fontSize",
"origin",
"text"
]
},
"VideoRecorder": {
"name": "VideoRecorder",
"methods": [
@@ -2938,6 +3032,13 @@ ScryptedInterfaceDescriptors = {
"tampered"
]
},
"Sleep": {
"name": "Sleep",
"methods": [],
"properties": [
"sleeping"
]
},
"PowerSensor": {
"name": "PowerSensor",
"methods": [],

View File

@@ -214,6 +214,14 @@ export interface ColorHsv {
v?: number;
}
export interface Buttons {
buttons?: ('doorbell' | string)[];
}
export interface PressButtons {
pressButton(button: string): Promise<void>;
}
export interface NotificationAction {
action: string;
icon?: string;
@@ -234,6 +242,10 @@ export interface NotifierOptions {
renotify?: boolean;
requireInteraction?: boolean;
silent?: boolean;
critical?: boolean;
/**
* Collapse key/id.
*/
tag?: string;
timestamp?: number;
vibrate?: VibratePattern;
@@ -950,6 +962,20 @@ export interface VideoCameraMask {
setPrivacyMasks(masks: PrivacyMasks): Promise<void>;
}
export interface VideoTextOverlay {
/**
* The top left position of the overlay in the image, normalized to 0-1.
*/
origin?: Point;
fontSize?: number;
text?: string;
}
export interface VideoTextOverlays {
getVideoTextOverlays(): Promise<Record<string, string>>;
setVideoTextOverlay(id: string, value: VideoTextOverlay): Promise<void>;
}
export enum PanTiltZoomMovement {
Absolute = "Absolute",
Relative = "Relative",
@@ -1256,6 +1282,10 @@ export interface Charger {
chargeState?: ChargeState;
}
export interface Sleep {
sleeping?: boolean;
}
export interface Reboot {
reboot(): Promise<void>;
}
@@ -1528,6 +1558,10 @@ export interface ObjectDetectionResult extends BoundingBoxResult {
* The certainty that this is correct tracked object.
*/
cost?: number;
/**
* Flag that indicates whether the detection was clipped by the detection input and may not be a full bounding box.
*/
clipped?: boolean;
/**
* The detection class of the object.
*/
@@ -1544,11 +1578,6 @@ export interface ObjectDetectionResult extends BoundingBoxResult {
* The score of the label.
*/
labelScore?: number;
/**
* A base64 encoded Float32Array that represents the vector descriptor of the detection.
* Can be used to compute euclidian distance to determine similarity.
*/
descriptor?: string;
/**
* The detection landmarks, like key points in a face landmarks.
*/
@@ -2252,6 +2281,8 @@ export enum ScryptedInterface {
ColorSettingTemperature = "ColorSettingTemperature",
ColorSettingRgb = "ColorSettingRgb",
ColorSettingHsv = "ColorSettingHsv",
Buttons = "Buttons",
PressButtons = "PressButtons",
Notifier = "Notifier",
StartStop = "StartStop",
Pause = "Pause",
@@ -2265,6 +2296,7 @@ export enum ScryptedInterface {
Display = "Display",
VideoCamera = "VideoCamera",
VideoCameraMask = "VideoCameraMask",
VideoTextOverlay = "VideoTextOverlay",
VideoRecorder = "VideoRecorder",
VideoRecorderManagement = "VideoRecorderManagement",
PanTiltZoom = "PanTiltZoom",
@@ -2291,6 +2323,7 @@ export enum ScryptedInterface {
Settings = "Settings",
BinarySensor = "BinarySensor",
TamperSensor = "TamperSensor",
Sleep = "Sleep",
PowerSensor = "PowerSensor",
AudioSensor = "AudioSensor",
MotionSensor = "MotionSensor",

View File

@@ -99,11 +99,6 @@
"env": {
"SCRYPTED_PYTHON310_PATH": "/opt/homebrew/bin/python3.10",
"DYLD_LIBRARY_PATH": "/usr/local/lib",
"SCRYPTED_CLUSTER_WORKER_NAME": "Macaroni 2",
"SCRYPTED_CLUSTER_LABELS": "@scrypted/coreml,@scrypted/tensorflow-lite,compute,compute.preferred",
"SCRYPTED_CLUSTER_MODE": "client",
"SCRYPTED_CLUSTER_SERVER": "192.168.2.130",
"SCRYPTED_CLUSTER_SECRET": "swordfish",
"SCRYPTED_CAN_RESTART": "true",
"SCRYPTED_VOLUME": "/Users/koush/.scrypted-cluster/volume-client",
}

View File

@@ -1,18 +1,18 @@
{
"name": "@scrypted/server",
"version": "0.125.2",
"version": "0.132.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/server",
"version": "0.125.2",
"version": "0.132.1",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"@scrypted/ffmpeg-static": "^6.1.0-build3",
"@scrypted/node-pty": "^1.0.22",
"@scrypted/types": "^0.3.92",
"@scrypted/types": "^0.3.100",
"adm-zip": "^0.5.16",
"body-parser": "^1.20.3",
"cookie-parser": "^1.4.7",
@@ -557,9 +557,9 @@
}
},
"node_modules/@scrypted/types": {
"version": "0.3.92",
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.3.92.tgz",
"integrity": "sha512-/M1Lg42/yoFWusj5+Lyp2S0JCiWDDWcmsjiUnTf1DahZ6/M2oZ3bwR/0KX3D9vJE79owWST1Gm0+Rdvpxuil9A==",
"version": "0.3.100",
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.3.100.tgz",
"integrity": "sha512-s/07QCxjMWqODgWj2UpLehzeo2cGFrCA9X8mvpG3owT/+q+sb8v/UUcw9TLHGSN6yIriNhceg3i9WO07kEIT6A==",
"license": "ISC"
},
"node_modules/@types/adm-zip": {

View File

@@ -1,11 +1,11 @@
{
"name": "@scrypted/server",
"version": "0.125.3",
"version": "0.132.3",
"description": "",
"dependencies": {
"@scrypted/ffmpeg-static": "^6.1.0-build3",
"@scrypted/node-pty": "^1.0.22",
"@scrypted/types": "^0.3.92",
"@scrypted/types": "^0.3.100",
"adm-zip": "^0.5.16",
"body-parser": "^1.20.3",
"cookie-parser": "^1.4.7",

View File

@@ -144,7 +144,11 @@ class ClusterSetup:
m = hashlib.sha256()
m.update(
bytes(
f"{o['id']}{o.get('address', '')}{o['port']}{o.get('sourceKey', '')}{o['proxyId']}{self.clusterSecret}",
# The use of ` o.get(key, None) or '' ` is to ensure that optional fields
# are omitted from the hash, matching the JS implementation. Otherwise, since
# the dict may contain the keys initialized to None, ` o.get(key, '') ` would
# return None instead of ''.
f"{o['id']}{o.get('address', None) or ''}{o['port']}{o.get('sourceKey', None) or ''}{o['proxyId']}{self.clusterSecret}",
"utf8",
)
)

View File

@@ -13,6 +13,15 @@ export async function getScryptedFFmpegPath(): Promise<string> {
return f;
}
// strange behavior on synology and possibly unraid
// where environment variables are not necessarily kept
// in their container manager thing.
// so even if the Dockerfile sets SCRYPTED_FFMPEG_PATH,
// it is not gauranteed to be present in the environment.
// this causes issues with @scrypted/ffmpeg-static,
// which looks at that environment variable at build time
// to determine whether to install ffmpeg.
// try to get the ffmpeg path from a variable
// ie:
// export SCRYPTED_FFMPEG_PATH=/usr/local/bin/ffmpeg
@@ -21,5 +30,8 @@ export async function getScryptedFFmpegPath(): Promise<string> {
return f;
const defaultPath = os.platform() === 'win32' ? 'ffmpeg.exe' : 'ffmpeg';
return getFfmpegPath() || defaultPath;
const scryptedFfmpegStatic = getFfmpegPath();
if (scryptedFfmpegStatic && fs.existsSync(scryptedFfmpegStatic))
return scryptedFfmpegStatic;
return defaultPath;
}

View File

@@ -4,7 +4,6 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import process from 'process';
import semver from 'semver';
import { ensurePluginVolume } from "./plugin-volume";
export function defaultNpmExec(args: string[], options: child_process.SpawnOptions) {
@@ -26,8 +25,16 @@ export function setNpmExecFunction(f: typeof npmExecFunction) {
export function getPluginNodePath(name: string) {
const pluginVolume = ensurePluginVolume(name);
const nodeMajorVersion = semver.parse(process.version).major;
let nodeVersionedDirectory = `node${nodeMajorVersion}-${process.platform}-${process.arch}`;
const abi = process.versions.modules;
let runtime = process.env.npm_config_runtime;
if (!runtime && process.versions.electron)
runtime = 'electron';
if (!runtime)
runtime = 'node';
const { platform, arch } = process;
let nodeVersionedDirectory = `n-${runtime}-v${abi}-${platform}-${arch}`;
const scryptedBase = process.env.SCRYPTED_BASE_VERSION;
if (scryptedBase)
nodeVersionedDirectory += '-' + scryptedBase;
@@ -98,7 +105,7 @@ export async function installOptionalDependencies(console: Console, packageJson:
if (!de.isDirectory())
return;
if (de.name.startsWith('linux') || de.name.startsWith('darwin') || de.name.startsWith('win32')
|| de.name.startsWith('python') || de.name.startsWith('node')) {
|| de.name.startsWith('python') || de.name.startsWith('node') || de.name.startsWith('n-')) {
console.log('Removing old dependencies:', filePath);
try {
await fs.promises.rm(filePath, {

View File

@@ -24,6 +24,7 @@ import { NodeThreadWorker } from './runtime/node-thread-worker';
import { prepareZip } from './runtime/node-worker-common';
import { getBuiltinRuntimeHosts } from './runtime/runtime-host';
import { RuntimeWorker, RuntimeWorkerOptions } from './runtime/runtime-worker';
import { Deferred } from '../deferred';
const serverVersion = require('../../package.json').version;
@@ -291,6 +292,14 @@ export function startPluginRemote(mainFilename: string, pluginId: string, peerSe
forkPeer = Promise.resolve(localPeer);
}
const exitDeferred = new Deferred<string>();
runtimeWorker.on('exit', () => {
exitDeferred.resolve('worker exited');
});
runtimeWorker.on('error', e => {
exitDeferred.resolve('worker error' + e);
});
// thread workers inherit main console. pipe anything else.
if (!(runtimeWorker instanceof NodeThreadWorker)) {
const console = options?.id ? getMixinConsole(options.id, options.nativeId) : undefined;
@@ -299,6 +308,9 @@ export function startPluginRemote(mainFilename: string, pluginId: string, peerSe
const result = (async () => {
const threadPeer = await forkPeer;
exitDeferred.promise.then(reason => {
threadPeer.kill(reason);
});
// todo: handle nested forks and skip wrap. this is probably buggy.
class PluginForkAPI extends PluginAPIProxy {
@@ -319,13 +331,7 @@ export function startPluginRemote(mainFilename: string, pluginId: string, peerSe
const remote = await setupPluginRemote(threadPeer, forkApi, pluginId, { serverVersion }, () => systemManager.getSystemState());
forks.add(remote);
runtimeWorker.on('exit', () => {
threadPeer.kill('worker exited');
forkApi.removeListeners();
forks.delete(remote);
});
runtimeWorker.on('error', e => {
threadPeer.kill('worker error ' + e);
exitDeferred.promise.then(reason => {
forkApi.removeListeners();
forks.delete(remote);
});

View File

@@ -48,7 +48,12 @@ export class NodeForkWorker extends ChildProcessWorker {
this.pluginId
];
const nodePaths: string[] = [path.resolve(__dirname, '..', '..', '..', 'node_modules')];
const nodePaths: string[] = [
// /server/node_modules/@scrypted/server/node_modules
path.resolve(__dirname, '..', '..', '..', 'node_modules'),
// /server/node_modules
path.resolve(process.cwd(), 'node_modules'),
];
if (env?.NODE_PATH)
nodePaths.push(env.NODE_PATH);
if (process.env.NODE_PATH)

View File

@@ -132,7 +132,7 @@ export class PythonRuntimeWorker extends ChildProcessWorker {
const strippedPythonVersion = pluginPythonVersion.replace('.', '');
const envPython = !process.env.SCRYPTED_PORTABLE_PYTHON && process.env[`SCRYPTED_PYTHON${strippedPythonVersion}_PATH`];
if (envPython) {
if (envPython && fs.existsSync(envPython)) {
pythonPath = envPython;
setup();
this.peerin = this.worker.stdio[3] as Writable;

View File

@@ -12,6 +12,7 @@ import { computeClusterObjectHash } from './cluster/cluster-hash';
import { getClusterLabels, getClusterWorkerWeight } from './cluster/cluster-labels';
import { getScryptedClusterMode, InitializeCluster, setupCluster } from './cluster/cluster-setup';
import type { ClusterObject } from './cluster/connect-rpc-object';
import { getScryptedFFmpegPath } from './plugin/ffmpeg-path';
import { getPluginVolume, getScryptedVolume } from './plugin/plugin-volume';
import { prepareZip } from './plugin/runtime/node-worker-common';
import { getBuiltinRuntimeHosts } from './plugin/runtime/runtime-host';
@@ -23,15 +24,20 @@ import { EnvControl } from './services/env';
import { Info } from './services/info';
import { ServiceControl } from './services/service-control';
import { sleep } from './sleep';
import { getScryptedFFmpegPath } from './plugin/ffmpeg-path';
installSourceMapSupport({
environment: 'node',
});
async function start(mainFilename: string, serviceControl?: ServiceControl) {
serviceControl ||= new ServiceControl();
startClusterClient(mainFilename, serviceControl);
async function start(mainFilename: string, options?: {
onClusterWorkerCreated?: (options?: {
clusterPluginHosts?: ReturnType<typeof getBuiltinRuntimeHosts>,
}) => Promise<void>,
serviceControl?: ServiceControl;
}) {
options ||= {};
options.serviceControl ||= new ServiceControl();
startClusterClient(mainFilename, options);
}
export default start;
@@ -122,12 +128,11 @@ export interface ClusterForkResultInterface {
export type ClusterForkParam = (runtime: string, options: RuntimeWorkerOptions, peerLiveness: PeerLiveness, getZip: () => Promise<Buffer>) => Promise<ClusterForkResultInterface>;
function createClusterForkParam(mainFilename: string, clusterId: string, clusterSecret: string, clusterWorkerId: string) {
function createClusterForkParam(mainFilename: string, clusterId: string, clusterSecret: string, clusterWorkerId: string, clusterPluginHosts: ReturnType<typeof getBuiltinRuntimeHosts>) {
const clusterForkParam: ClusterForkParam = async (runtime, runtimeWorkerOptions, peerLiveness, getZip) => {
let runtimeWorker: RuntimeWorker;
const builtins = getBuiltinRuntimeHosts();
const rt = builtins.get(runtime);
const rt = clusterPluginHosts.get(runtime);
if (!rt)
throw new Error('unknown runtime ' + runtime);
@@ -205,7 +210,12 @@ function createClusterForkParam(mainFilename: string, clusterId: string, cluster
return clusterForkParam;
}
export function startClusterClient(mainFilename: string, serviceControl?: ServiceControl) {
export function startClusterClient(mainFilename: string, options?: {
onClusterWorkerCreated?: (options?: {
clusterPluginHosts?: ReturnType<typeof getBuiltinRuntimeHosts>,
}) => Promise<void>,
serviceControl?: ServiceControl;
}) {
console.log('Cluster client starting.');
const envControl = new EnvControl();
@@ -217,6 +227,10 @@ export function startClusterClient(mainFilename: string, serviceControl?: Servic
const clusterSecret = process.env.SCRYPTED_CLUSTER_SECRET;
const clusterMode = getScryptedClusterMode();
const [, host, port] = clusterMode;
const clusterPluginHosts = getBuiltinRuntimeHosts();
options?.onClusterWorkerCreated?.({ clusterPluginHosts });
(async () => {
while (true) {
// this sleep is here to prevent a tight loop if the server is down.
@@ -259,7 +273,7 @@ export function startClusterClient(mainFilename: string, serviceControl?: Servic
process.env.SCRYPTED_CLUSTER_ADDRESS = socket.localAddress;
const peer = preparePeer(socket, 'client');
peer.params['service-control'] = serviceControl;
peer.params['service-control'] = options?.serviceControl;
peer.params['env-control'] = envControl;
peer.params['info'] = new Info();
peer.params['fs.promises'] = {
@@ -294,7 +308,7 @@ export function startClusterClient(mainFilename: string, serviceControl?: Servic
const clusterPeerSetup = setupCluster(peer);
await clusterPeerSetup.initializeCluster({ clusterId, clusterSecret, clusterWorkerId });
peer.params['fork'] = createClusterForkParam(mainFilename, clusterId, clusterSecret, clusterWorkerId);
peer.params['fork'] = createClusterForkParam(mainFilename, clusterId, clusterSecret, clusterWorkerId, clusterPluginHosts);
await peer.killed;
}
@@ -316,7 +330,7 @@ export function createClusterServer(mainFilename: string, scryptedRuntime: Scryp
labels: getClusterLabels(),
id: scryptedRuntime.serverClusterWorkerId,
peer: undefined,
fork: Promise.resolve(createClusterForkParam(mainFilename, scryptedRuntime.clusterId, scryptedRuntime.clusterSecret, scryptedRuntime.serverClusterWorkerId)),
fork: Promise.resolve(createClusterForkParam(mainFilename, scryptedRuntime.clusterId, scryptedRuntime.clusterSecret, scryptedRuntime.serverClusterWorkerId, scryptedRuntime.pluginHosts)),
name: process.env.SCRYPTED_CLUSTER_WORKER_NAME || os.hostname(),
address: process.env.SCRYPTED_CLUSTER_ADDRESS,
weight: getClusterWorkerWeight(),
@@ -371,6 +385,7 @@ export function createClusterServer(mainFilename: string, scryptedRuntime: Scryp
console.log('Cluster client authenticated.', socket.remoteAddress, socket.remotePort, properties);
}
catch (e) {
console.error('Cluster client authentication failed.', socket.remoteAddress, socket.remotePort, e);
peer.kill(e);
socket.destroy();
}

View File

@@ -8,6 +8,7 @@ import vm from 'vm';
import { getScryptedClusterMode } from './cluster/cluster-setup';
import { PluginError } from './plugin/plugin-error';
import { isNodePluginWorkerProcess } from './plugin/runtime/node-fork-worker';
import type { getBuiltinRuntimeHosts } from './plugin/runtime/runtime-host';
import { RPCResultError, startPeriodicGarbageCollection } from './rpc';
import type { Runtime } from './scrypted-server-main';
import { getDotEnvPath } from './services/env';
@@ -16,6 +17,9 @@ import type { ServiceControl } from './services/service-control';
function start(mainFilename: string, options?: {
serviceControl?: ServiceControl,
onRuntimeCreated?: (runtime: Runtime) => Promise<void>,
onClusterWorkerCreated?: (options?: {
clusterPluginHosts?: ReturnType<typeof getBuiltinRuntimeHosts>,
}) => Promise<void>,
}) {
// Allow including a custom file path for platforms that require
// compatibility hacks. For example, Android may need to patch
@@ -71,7 +75,7 @@ function start(mainFilename: string, options?: {
const clusterMode = getScryptedClusterMode();
if (clusterMode?.[0] === 'client') {
const start = require('./scrypted-cluster-main').default;
return start(mainFilename, options?.serviceControl);
return start(mainFilename, options);
}
else {
const start = require('./scrypted-server-main').default;