Compare commits

..

23 Commits

Author SHA1 Message Date
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
27 changed files with 411 additions and 74 deletions

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

@@ -1,12 +1,12 @@
{
"name": "@scrypted/core",
"version": "0.3.108",
"version": "0.3.111",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/core",
"version": "0.3.108",
"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.108",
"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

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

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

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

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/objectdetector",
"version": "0.1.65",
"version": "0.1.66",
"description": "Scrypted Video Analysis Plugin. Installed alongside a detection service like OpenCV or TensorFlow.",
"author": "Scrypted",
"license": "Apache-2.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

@@ -10,6 +10,7 @@ import { fixLegacyClipPath, normalizeBox, polygonContainsBoundingBox, polygonInt
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;
@@ -1056,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.
@@ -1195,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,12 +1,12 @@
{
"name": "@scrypted/prebuffer-mixin",
"version": "0.10.45",
"version": "0.10.46",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/prebuffer-mixin",
"version": "0.10.45",
"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.45",
"version": "0.10.46",
"description": "Video Stream Rebroadcast, Prebuffer, and Management Plugin for Scrypted.",
"author": "Scrypted",
"license": "Apache-2.0",

View File

@@ -63,8 +63,6 @@ class PrebufferSession {
usingScryptedParser = false;
usingScryptedUdpParser = false;
audioDisabled = false;
mixinDevice: VideoCamera;
console: Console;
storage: Storage;
@@ -507,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,
@@ -604,7 +597,6 @@ class PrebufferSession {
if (audioSoftMuted) {
// no audio? explicitly disable it.
acodec = ['-an'];
this.audioDisabled = true;
}
else {
acodec = [
@@ -628,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];
@@ -1024,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[];
@@ -1134,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;

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

@@ -725,7 +725,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

@@ -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, Online, RequestMediaStreamOptions, RequestPictureOptions, ResponsePictureOptions, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, SettingValue, Settings, 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 & Online>(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.Battery) && !realDevice.online)
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();

4
sdk/package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

@@ -186,6 +186,7 @@ class ScryptedInterface(str, Enum):
ScryptedUser = "ScryptedUser"
SecuritySystem = "SecuritySystem"
Settings = "Settings"
Sleep = "Sleep"
StartStop = "StartStop"
StreamService = "StreamService"
TamperSensor = "TamperSensor"
@@ -201,6 +202,7 @@ class ScryptedInterface(str, Enum):
VideoFrameGenerator = "VideoFrameGenerator"
VideoRecorder = "VideoRecorder"
VideoRecorderManagement = "VideoRecorderManagement"
VideoTextOverlay = "VideoTextOverlay"
VOCSensor = "VOCSensor"
class ScryptedMimeTypes(str, Enum):
@@ -288,11 +290,6 @@ class ClipPath(TypedDict):
pass
class Point(TypedDict):
pass
class AudioStreamOptions(TypedDict):
bitrate: float
@@ -669,6 +666,7 @@ class NotifierOptions(TypedDict):
badge: str
body: str
bodyWithSubtitle: str
critical: bool
data: Any
dir: NotificationDirection
image: str
@@ -948,12 +946,17 @@ class MediaConverterTypes(TypedDict):
pass
class Point(TypedDict):
pass
class TamperState(TypedDict):
pass
TYPES_VERSION = "0.3.100"
TYPES_VERSION = "0.3.102"
class AirPurifier:
@@ -1535,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."""
@@ -1661,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
@@ -1885,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"
@@ -1897,6 +1913,7 @@ class ScryptedInterfaceProperty(str, Enum):
converters = "converters"
binaryState = "binaryState"
tampered = "tampered"
sleeping = "sleeping"
powerDetected = "powerDetected"
audioDetected = "audioDetected"
motionDetected = "motionDetected"
@@ -2264,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")
@@ -2360,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")
@@ -2758,6 +2807,15 @@ ScryptedInterfaceDescriptors = {
],
"properties": []
},
"VideoTextOverlay": {
"name": "VideoTextOverlay",
"methods": [],
"properties": [
"fontSize",
"origin",
"text"
]
},
"VideoRecorder": {
"name": "VideoRecorder",
"methods": [
@@ -2974,6 +3032,13 @@ ScryptedInterfaceDescriptors = {
"tampered"
]
},
"Sleep": {
"name": "Sleep",
"methods": [],
"properties": [
"sleeping"
]
},
"PowerSensor": {
"name": "PowerSensor",
"methods": [],

View File

@@ -242,6 +242,7 @@ export interface NotifierOptions {
renotify?: boolean;
requireInteraction?: boolean;
silent?: boolean;
critical?: boolean;
/**
* Collapse key/id.
*/
@@ -961,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",
@@ -1267,6 +1282,10 @@ export interface Charger {
chargeState?: ChargeState;
}
export interface Sleep {
sleeping?: boolean;
}
export interface Reboot {
reboot(): Promise<void>;
}
@@ -2278,6 +2297,7 @@ export enum ScryptedInterface {
Display = "Display",
VideoCamera = "VideoCamera",
VideoCameraMask = "VideoCameraMask",
VideoTextOverlay = "VideoTextOverlay",
VideoRecorder = "VideoRecorder",
VideoRecorderManagement = "VideoRecorderManagement",
PanTiltZoom = "PanTiltZoom",
@@ -2304,6 +2324,7 @@ export enum ScryptedInterface {
Settings = "Settings",
BinarySensor = "BinarySensor",
TamperSensor = "TamperSensor",
Sleep = "Sleep",
PowerSensor = "PowerSensor",
AudioSensor = "AudioSensor",
MotionSensor = "MotionSensor",

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/server",
"version": "0.128.3",
"version": "0.129.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/server",
"version": "0.128.3",
"version": "0.129.0",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/server",
"version": "0.128.3",
"version": "0.130.3",
"description": "",
"dependencies": {
"@scrypted/ffmpeg-static": "^6.1.0-build3",

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

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