Compare commits

...

35 Commits

Author SHA1 Message Date
Koushik Dutta
6336407f15 prepublish 2023-02-28 11:17:26 -08:00
Koushik Dutta
38e3492137 rebroadcast: use new rtsp code with auth and extenral access 2023-02-28 11:15:32 -08:00
Koushik Dutta
255e426e2d client: update 2023-02-28 11:10:47 -08:00
Koushik Dutta
fed1cf2a0d cli: update 2023-02-28 11:10:22 -08:00
Koushik Dutta
a5cb8c3fdc sdk: update 2023-02-28 11:09:07 -08:00
Koushik Dutta
514d86144f sdk: update 2023-02-28 11:07:47 -08:00
Koushik Dutta
21db7934c9 Merge branch 'main' of github.com:koush/scrypted 2023-02-28 08:16:00 -08:00
Koushik Dutta
14e4b5c0e3 rebroadcast/rtsp: initial support for clustering 2023-02-28 08:15:56 -08:00
Koushik Duta
6478ad0411 Merge branch 'main' of github.com:koush/scrypted 2023-02-27 22:55:45 -08:00
Koushik Duta
81b7d432e9 docker: add gstreamer vaapi decoder 2023-02-27 22:55:30 -08:00
Koushik Dutta
dcbf5094f9 Merge branch 'main' of github.com:koush/scrypted 2023-02-27 22:38:42 -08:00
Koushik Dutta
69ba181dfa docker: add intel vaapi compose example 2023-02-27 22:38:35 -08:00
Koushik Duta
88654275c1 hikvision: trim deps 2023-02-27 22:27:23 -08:00
Koushik Duta
62f28271ed sdk: disable link on build 2023-02-27 22:25:27 -08:00
Koushik Dutta
0538130e78 amcrest: publish support for multiple call buttons on dahua models 2023-02-27 13:28:09 -08:00
KoljaV
95a3a16227 amcrest: New DahuaEventType and introduction of CallerId for Dahua Intercoms with multiple Bell Buttons. (#592)
* Update main.ts

Some Dahua Intercom support multiple Call Buttons. Therefore a CallerID is introduced. Plus a new event (DahuaCallDeny) that resets the ring status.

* Update amcrest-api.ts

New Dahua Event Type for Call Deny.

* tested multiple times and sorted out two bugs

---------

Co-authored-by: Kolja Vornholt <kvornholt@MBP-von-Kolja.lan>
2023-02-27 13:27:34 -08:00
Koushik Dutta
bfbf89ff69 Merge branch 'main' of github.com:koush/scrypted 2023-02-27 13:20:36 -08:00
Koushik Dutta
e630589489 cameras: auto detect two way audio 2023-02-27 13:19:32 -08:00
Alex Leeds
99d1e51f36 ring: add support for various sensor types (#591)
* ring: add support for various sensor types

* ring: bump version
2023-02-27 10:52:50 -08:00
Koushik Dutta
ab42ccd889 cameras: publish 2023-02-26 23:21:47 -08:00
Koushik Dutta
767af25aa0 cameras: create probe utils 2023-02-26 22:09:30 -08:00
Koushik Dutta
7575dd82ce onvif: unescape xml strings 2023-02-26 16:52:32 -08:00
Koushik Dutta
9307bbd09e Merge branch 'main' of github.com:koush/scrypted 2023-02-26 14:21:59 -08:00
Koushik Dutta
68a9ec09e6 docker: readd google gpg keys that were failing 2023-02-26 14:21:40 -08:00
Brett Jia
f8a548401f remote: allow doorbells and filter out irrelevant plugins (#588) 2023-02-26 11:44:47 -08:00
Koushik Dutta
26d1f8e58c cameras: include ip in device info 2023-02-26 11:42:52 -08:00
Koushik Dutta
8772e25c8e sdk/client: update 2023-02-26 11:16:28 -08:00
Koushik Dutta
378ac82c8c cameras: refresh device info on startup 2023-02-26 09:54:25 -08:00
Koushik Dutta
fcb1292ffd Update Dockerfile.full.header 2023-02-25 19:17:24 -08:00
Brett Jia
18112ee40f remote: allow API type and use ObjectDetection (#586)
* include implementation from standalone repo

* simplify monkeypatching

* allow API types and use ObjectDetection interface
2023-02-25 19:00:57 -08:00
Brett Jia
fa8b9dfe99 remote: Scrypted remote plugin (#585)
* include implementation from standalone repo

* simplify monkeypatching
2023-02-25 18:46:33 -08:00
Alex Leeds
e7dff4edc9 add fan support to nest thermostats (#583)
* add fan support to nest thermostats

* fix fan refresh
2023-02-25 17:58:23 -08:00
Koushik Dutta
7614d12363 prepublish 2023-02-25 14:25:24 -08:00
Koushik Dutta
3189317b2d server: additional admin login traps for HA ingress 2023-02-25 14:25:16 -08:00
Koushik Dutta
410d11248f snapshot: improve failure resiliency with prebuffer fallback and ignoring ffpmeg errors when buffers are returned 2023-02-25 13:41:41 -08:00
68 changed files with 3614 additions and 1911 deletions

View File

@@ -7,7 +7,7 @@ import net from 'net';
import { Duplex, Readable } from 'stream';
import tls from 'tls';
import { Deferred } from './deferred';
import { closeQuiet, createBindUdp, createBindZero } from './listen-cluster';
import { closeQuiet, createBindUdp, createBindZero, listenZeroSingleClient } from './listen-cluster';
import { timeoutPromise } from './promise-utils';
import { readLength, readLine } from './read-stream';
import { MSection, parseSdp } from './sdp-utils';
@@ -1053,3 +1053,33 @@ export class RtspServer {
}
}
}
export async function listenSingleRtspClient<T extends RtspServer>(options?: {
hostname?: string,
pathToken?: string,
createServer?(duplex: Duplex): T,
}) {
const pathToken = options?.pathToken || crypto.randomBytes(8).toString('hex');
let { url, clientPromise, server } = await listenZeroSingleClient(options?.hostname);
const rtspServerPath = '/' + pathToken;
url = url.replace('tcp:', 'rtsp:') + rtspServerPath;
const rtspServerPromise = clientPromise.then(client => {
const createServer = options?.createServer || (duplex => new RtspServer(duplex));
const rtspServer = createServer(client);
rtspServer.checkRequest = async (method, url, headers, message) => {
rtspServer.checkRequest = undefined;
const u = new URL(url);
return u.pathname === rtspServerPath;
};
return rtspServer as T;
});
return {
url,
rtspServerPromise,
server,
}
}

View File

@@ -31,21 +31,16 @@ RUN apt-get -y install \
build-essential \
cmake \
gcc \
gir1.2-gtk-3.0 \
libcairo2-dev \
libgirepository1.0-dev \
libglib2.0-dev \
libjpeg-dev \
libgif-dev \
libopenjp2-7 \
libpango1.0-dev \
librsvg2-dev \
pkg-config
# gstreamer native https://gstreamer.freedesktop.org/documentation/installing/on-linux.html?gi-language=c#install-gstreamer-on-ubuntu-or-debian
RUN apt-get -y install \
gstreamer1.0-tools libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libgstreamer-plugins-bad1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav gstreamer1.0-alsa
RUN bash -c 'if [ $(uname -m) == "x86_64" ]; then apt-get -y install gstreamer1.0-vaapi; fi'
# python native
RUN apt-get -y install \
python3 \
@@ -64,7 +59,6 @@ RUN apt-get -y install \
# python pip
RUN python3 -m pip install --upgrade pip
RUN python3 -m pip install aiofiles debugpy typing_extensions typing psutil
RUN python3 -m pip install dlib
################################################################
# End section generated from template/Dockerfile.full.header

View File

@@ -38,6 +38,8 @@ services:
# - /dev/ttyACM0:/dev/ttyACM0
# all usb devices, such as coral tpu
# - /dev/bus/usb:/dev/bus/usb
# intel hardware accelerated video decoding
# - /dev/dri:/dev/dri
volumes:
- ~/.scrypted/volume:/server/volume

View File

@@ -26,22 +26,18 @@ RUN apt-get -y upgrade
# base development stuff
RUN apt-get -y install \
build-essential \
cmake \
gcc \
gir1.2-gtk-3.0 \
libcairo2-dev \
libgirepository1.0-dev \
libglib2.0-dev \
libjpeg-dev \
libgif-dev \
libopenjp2-7 \
libpango1.0-dev \
librsvg2-dev \
pkg-config
# gstreamer native https://gstreamer.freedesktop.org/documentation/installing/on-linux.html?gi-language=c#install-gstreamer-on-ubuntu-or-debian
RUN apt-get -y install \
gstreamer1.0-tools libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libgstreamer-plugins-bad1.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav gstreamer1.0-alsa
RUN bash -c 'if [ $(uname -m) == "x86_64" ]; then apt-get -y install gstreamer1.0-vaapi; fi'
# python native
RUN apt-get -y install \
python3 \

View File

@@ -6,7 +6,7 @@
"configurations": [
{
"console": "integratedTerminal",
"type": "pwa-node",
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
@@ -20,8 +20,9 @@
"ts-node/register"
],
"args": [
"install",
"@scrypted/google-device-access"
"ffplay",
"Kitchen",
"getVideoStream"
],
"sourceMaps": true,
"resolveSourceMapLocations": [

View File

@@ -1,12 +1,12 @@
{
"name": "scrypted",
"version": "1.0.57",
"version": "1.0.58",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "scrypted",
"version": "1.0.57",
"version": "1.0.58",
"license": "ISC",
"dependencies": {
"@scrypted/types": "^0.0.6",

View File

@@ -1,6 +1,6 @@
{
"name": "scrypted",
"version": "1.0.57",
"version": "1.0.58",
"description": "",
"main": "./dist/packages/cli/src/main.js",
"bin": {

View File

@@ -8,10 +8,14 @@ import https from 'https';
import mkdirp from 'mkdirp';
import { installServe, serveMain } from './service';
import { connectScryptedClient } from '../../client/src/index';
import { ScryptedMimeTypes, FFMpegInput } from '@scrypted/types';
import { ScryptedMimeTypes, FFmpegInput } from '../../../sdk/types/src/types.input';
import semver from 'semver';
import child_process from 'child_process';
const httpsAgent = new https.Agent({
rejectUnauthorized: false,
});
if (!semver.gte(process.version, '16.0.0')) {
throw new Error('"node" version out of date. Please update node to v16 or higher.')
}
@@ -57,6 +61,7 @@ async function doLogin(host: string) {
password,
},
url,
httpsAgent,
}, axiosConfig));
mkdirp.sync(scryptedHome);
@@ -112,6 +117,9 @@ async function runCommand() {
pluginId: '@scrypted/core',
username: login.username,
password: login.token,
axiosConfig: {
httpsAgent,
}
});
const device: any = sdk.systemManager.getDeviceById(idOrName) || sdk.systemManager.getDeviceByName(idOrName);
@@ -157,9 +165,15 @@ async function main() {
}
else if (process.argv[2] === 'ffplay') {
const { sdk, pendingResult } = await runCommand();
const ffinput = await sdk.mediaManager.convertMediaObjectToJSON<FFMpegInput>(await pendingResult, ScryptedMimeTypes.FFmpegInput);
console.log(ffinput);
child_process.spawn('ffplay', ffinput.inputArguments, {
const ffmpegInput = await sdk.mediaManager.convertMediaObjectToJSON<FFmpegInput>(await pendingResult, ScryptedMimeTypes.FFmpegInput);
if (ffmpegInput.url && ffmpegInput.urls?.[0]) {
const url = new URL(ffmpegInput.url);
if (url.hostname === '127.0.0.1' && ffmpegInput.urls?.[0]) {
ffmpegInput.inputArguments = ffmpegInput.inputArguments.map(i => i === ffmpegInput.url ? ffmpegInput.urls?.[0] : i);
}
}
console.log('ffplay', ...ffmpegInput.inputArguments);
child_process.spawn('ffplay', ffmpegInput.inputArguments, {
stdio: 'inherit',
});
sdk.disconnect();

View File

@@ -1,15 +1,15 @@
{
"name": "@scrypted/client",
"version": "1.1.38",
"version": "1.1.40",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/client",
"version": "1.1.38",
"version": "1.1.40",
"license": "ISC",
"dependencies": {
"@scrypted/types": "^0.2.64",
"@scrypted/types": "^0.2.65",
"axios": "^0.25.0",
"engine.io-client": "^6.2.2",
"rimraf": "^3.0.2"
@@ -50,9 +50,9 @@
"extraneous": true
},
"node_modules/@scrypted/types": {
"version": "0.2.64",
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.64.tgz",
"integrity": "sha512-8x+EVlsJ5MGJ5HxPcVxV5p5RakP9zivqhTkzgEUUbfGDUXUmv1BYlNy/AESkSNKR26idEiZrKD1VfE67hPIH8A=="
"version": "0.2.65",
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.65.tgz",
"integrity": "sha512-V/gfPy+xeRds6WMHwU6trt2YBkH9qcC/3Bx9q5hOxpE+rZSL4ru+nvlaumCRM3mSNWXBav4nbd23JCoGJ0F2eA=="
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.0",
@@ -289,9 +289,9 @@
},
"dependencies": {
"@scrypted/types": {
"version": "0.2.64",
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.64.tgz",
"integrity": "sha512-8x+EVlsJ5MGJ5HxPcVxV5p5RakP9zivqhTkzgEUUbfGDUXUmv1BYlNy/AESkSNKR26idEiZrKD1VfE67hPIH8A=="
"version": "0.2.65",
"resolved": "https://registry.npmjs.org/@scrypted/types/-/types-0.2.65.tgz",
"integrity": "sha512-V/gfPy+xeRds6WMHwU6trt2YBkH9qcC/3Bx9q5hOxpE+rZSL4ru+nvlaumCRM3mSNWXBav4nbd23JCoGJ0F2eA=="
},
"@socket.io/component-emitter": {
"version": "3.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/client",
"version": "1.1.38",
"version": "1.1.40",
"description": "",
"main": "dist/packages/client/src/index.js",
"scripts": {
@@ -17,7 +17,7 @@
"typescript": "^4.7.4"
},
"dependencies": {
"@scrypted/types": "^0.2.64",
"@scrypted/types": "^0.2.65",
"axios": "^0.25.0",
"engine.io-client": "^6.2.2",
"rimraf": "^3.0.2"

View File

@@ -49,6 +49,7 @@ export interface ScryptedClientStatic extends ScryptedStatic {
connectionType: ScryptedClientConnectionType;
authorization?: string;
queryToken?: { [parameter: string]: string };
rpcPeer: RpcPeer,
}
export interface ScryptedConnectionOptions {
@@ -587,6 +588,7 @@ export async function connectScryptedClient(options: ScryptedClientOptions): Pro
browserSignalingSession,
authorization,
queryToken,
rpcPeer,
}
socket.on('close', () => {

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/amcrest",
"version": "0.0.115",
"version": "0.0.119",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/amcrest",
"version": "0.0.115",
"version": "0.0.119",
"license": "Apache",
"dependencies": {
"@koush/axios-digest-auth": "^0.8.5",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/amcrest",
"version": "0.0.115",
"version": "0.0.119",
"description": "Amcrest Plugin for Scrypted",
"author": "Scrypted",
"license": "Apache",

View File

@@ -2,6 +2,7 @@ import AxiosDigestAuth from '@koush/axios-digest-auth';
import { Readable } from 'stream';
import https from 'https';
import { IncomingMessage } from 'http';
import { amcrestHttpsAgent, getDeviceInfo } from './probe';
export enum AmcrestEvent {
MotionStart = "Code=VideoMotion;action=Start",
@@ -17,12 +18,10 @@ export enum AmcrestEvent {
PhoneCallDetectStop = "Code=PhoneCallDetect;action=Stop",
DahuaTalkInvite = "Code=CallNoAnswered;action=Start",
DahuaTalkHangup = "Code=PassiveHungup;action=Start",
DahuaCallDeny = "Code=HungupPhone;action=Pulse",
DahuaTalkPulse = "Code=_CallNoAnswer_;action=Pulse",
}
export const amcrestHttpsAgent = new https.Agent({
rejectUnauthorized: false,
});
export class AmcrestCameraClient {
digestAuth: AxiosDigestAuth;
@@ -34,6 +33,16 @@ export class AmcrestCameraClient {
});
}
async checkTwoWayAudio() {
const response = await this.digestAuth.request({
httpsAgent: amcrestHttpsAgent,
method: "GET",
responseType: 'text',
url: `http://${this.ip}/cgi-bin/devAudioOutput.cgi?action=getCollect`,
});
return (response.data as string).includes('result=1');
}
// appAutoStart=true
// deviceType=IP4M-1041B
// hardwareVersion=1.00
@@ -42,30 +51,7 @@ export class AmcrestCameraClient {
// updateSerial=IPC-AW46WN-S2
// updateSerialCloudUpgrade=IPC-AW46WN-.....
async getDeviceInfo() {
const response = await this.digestAuth.request({
httpsAgent: amcrestHttpsAgent,
method: "GET",
responseType: 'text',
url: `http://${this.ip}/cgi-bin/magicBox.cgi?action=getSystemInfo`,
});
const lines = (response.data as string).split('\n');
const vals: {
[key: string]: string,
} = {};
for (const line of lines) {
let index = line.indexOf('=');
if (index === -1)
index = line.length;
const k = line.substring(0, index);
const v = line.substring(index + 1);
vals[k] = v.trim();
}
return {
deviceType: vals.deviceType,
hardwareVersion: vals.hardwareVersion,
serialNumber: vals.serialNumber,
}
return getDeviceInfo(this.digestAuth, this.ip);
}
async jpegSnapshot(): Promise<Buffer> {

View File

@@ -5,7 +5,8 @@ import child_process, { ChildProcess } from 'child_process';
import { PassThrough, Readable, Stream } from "stream";
import { OnvifIntercom } from "../../onvif/src/onvif-intercom";
import { RtspProvider, RtspSmartCamera, UrlMediaStreamOptions } from "../../rtsp/src/rtsp";
import { AmcrestCameraClient, AmcrestEvent, amcrestHttpsAgent } from "./amcrest-api";
import { AmcrestCameraClient, AmcrestEvent } from "./amcrest-api";
import { amcrestHttpsAgent } from './probe';
const { mediaManager } = sdk;
@@ -37,19 +38,6 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
}
this.updateDeviceInfo();
this.updateManagementUrl();
}
updateManagementUrl() {
const ip = this.storage.getItem('ip');
if (!ip)
return;
const info = this.info || {};
const managementUrl = `http://${ip}`;
if (info.managementUrl !== managementUrl) {
info.managementUrl = managementUrl;
this.info = info;
}
}
getRecordingStreamCurrentTime(recordingStream: MediaObject): Promise<number> {
@@ -80,9 +68,16 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
}
async updateDeviceInfo(): Promise<void> {
if (this.info)
const ip = this.storage.getItem('ip');
if (!ip)
return;
const deviceInfo = {};
const managementUrl = `http://${ip}`;
const deviceInfo: DeviceInformation = {
...this.info,
ip,
managementUrl,
};
const deviceParameters = [
{ action: "getVendor", replace: "vendor=", parameter: "manufacturer" },
@@ -161,6 +156,8 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
const client = new AmcrestCameraClient(this.getHttpAddress(), this.getUsername(), this.getPassword(), this.console);
const events = await client.listenEvents();
const doorbellType = this.storage.getItem('doorbellType');
const callerId = this.storage.getItem('callerID');
const multipleCallIds = this.storage.getItem('multipleCallIds') === 'true';
let pulseTimeout: NodeJS.Timeout;
@@ -187,11 +184,21 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
|| event === AmcrestEvent.PhoneCallDetectStart
|| event === AmcrestEvent.AlarmIPCStart
|| event === AmcrestEvent.DahuaTalkInvite) {
this.binaryState = true;
if (event === AmcrestEvent.DahuaTalkInvite && payload && multipleCallIds)
{
if (payload.includes(callerId))
{
this.binaryState = true;
}
} else
{
this.binaryState = true;
}
}
else if (event === AmcrestEvent.TalkHangup
|| event === AmcrestEvent.PhoneCallDetectStop
|| event === AmcrestEvent.AlarmIPCStop
|| event === AmcrestEvent.DahuaCallDeny
|| event === AmcrestEvent.DahuaTalkHangup) {
this.binaryState = false;
}
@@ -246,6 +253,36 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
if (!twoWayAudio)
twoWayAudio = isDoorbell ? 'Amcrest' : 'None';
if (doorbellType == DAHUA_DOORBELL_TYPE)
{
ret.push(
{
title: 'Multiple Call Buttons',
key: 'multipleCallIds',
description: 'Some Dahua Doorbells integrate multiple Call Buttons for apartment buildings.',
type: 'boolean',
value: (this.storage.getItem('multipleCallIds') === 'true').toString(),
}
);
}
const multipleCallIds = this.storage.getItem('multipleCallIds');
if (multipleCallIds)
{
ret.push(
{
title: 'Caller ID',
key: 'callerID',
description: 'Caller ID',
type: 'number',
value: this.storage.getItem('callerID'),
}
)
}
ret.push(
{
@@ -266,7 +303,11 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
);
return ret;
}
async takeSmartCameraPicture(option?: PictureOptions): Promise<MediaObject> {
return this.createMediaObject(await this.getClient().jpegSnapshot(), 'image/jpeg');
@@ -441,7 +482,7 @@ class AmcrestCamera extends RtspSmartCamera implements VideoCameraConfiguration,
interfaces.push(ScryptedInterface.VideoRecorder);
this.provider.updateDevice(this.nativeId, this.name, interfaces, type);
this.updateManagementUrl();
this.updateDeviceInfo();
}
async startIntercom(media: MediaObject): Promise<void> {
@@ -550,9 +591,10 @@ class AmcrestProvider extends RtspProvider {
const username = settings.username?.toString();
const password = settings.password?.toString();
const skipValidate = settings.skipValidate === 'true';
let twoWayAudio: string;
if (!skipValidate) {
const api = new AmcrestCameraClient(httpAddress, username, password, this.console);
try {
const api = new AmcrestCameraClient(httpAddress, username, password, this.console);
const deviceInfo = await api.getDeviceInfo();
settings.newCamera = deviceInfo.deviceType;
@@ -563,6 +605,16 @@ class AmcrestProvider extends RtspProvider {
this.console.error('Error adding Amcrest camera', e);
throw e;
}
try {
if (await api.checkTwoWayAudio()) {
// onvif seems to work better than Amcrest, except for AD110.
twoWayAudio = 'ONVIF';
}
}
catch (e) {
this.console.warn('Error probing two way audio', e);
}
}
settings.newCamera ||= 'Hikvision Camera';
@@ -574,6 +626,8 @@ class AmcrestProvider extends RtspProvider {
device.putSetting('password', password);
device.setIPAddress(settings.ip?.toString());
device.setHttpPortOverride(settings.httpPort?.toString());
if (twoWayAudio)
device.putSetting('twoWayAudio', twoWayAudio);
return nativeId;
}

View File

@@ -0,0 +1,42 @@
import https from 'https';
export const amcrestHttpsAgent = new https.Agent({
rejectUnauthorized: false,
});
// appAutoStart=true
// deviceType=IP4M-1041B
// hardwareVersion=1.00
// processor=SSC327DE
// serialNumber=12345
// updateSerial=IPC-AW46WN-S2
import AxiosDigestAuth from "@koush/axios-digest-auth";
// updateSerialCloudUpgrade=IPC-AW46WN-.....
export async function getDeviceInfo(digestAuth: AxiosDigestAuth, address: string) {
const response = await digestAuth.request({
httpsAgent: amcrestHttpsAgent,
method: "GET",
responseType: 'text',
url: `http://${address}/cgi-bin/magicBox.cgi?action=getSystemInfo`,
});
const lines = (response.data as string).split('\n');
const vals: {
[key: string]: string,
} = {};
for (const line of lines) {
let index = line.indexOf('=');
if (index === -1)
index = line.length;
const k = line.substring(0, index);
const v = line.substring(index + 1);
vals[k] = v.trim();
}
return {
deviceType: vals.deviceType,
hardwareVersion: vals.hardwareVersion,
serialNumber: vals.serialNumber,
}
}

View File

@@ -208,6 +208,7 @@ export abstract class CameraProviderBase<T extends ResponseMediaStreamOptions> e
name,
interfaces,
type: type || ScryptedDeviceType.Camera,
info: deviceManager.getNativeIds().includes(nativeId) ? deviceManager.getDeviceState(nativeId)?.info : undefined,
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -39,18 +39,15 @@
"dependencies": {
"@scrypted/sdk": "file:../../sdk",
"@scrypted/common": "file:../../common",
"@googleapis/smartdevicemanagement": "^0.2.0",
"axios": "^0.21.1",
"@googleapis/smartdevicemanagement": "^1.0.0",
"axios": "^1.3.4",
"client-oauth2": "^4.3.3",
"lodash": "^4.17.21",
"query-string": "^7.0.0",
"url-parse": "^1.5.1"
"lodash": "^4.17.21"
},
"devDependencies": {
"@types/debug": "^4.1.5",
"@types/lodash": "^4.14.168",
"@types/node": "^14.17.11",
"@types/url-parse": "^1.4.3"
"@types/debug": "^4.1.7",
"@types/lodash": "^4.14.191",
"@types/node": "^18.14.1"
},
"version": "0.0.95"
"version": "0.0.96"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "appengine-hello-world",
"description": "Simple Hello World Node.js sample for Google App Engine Standard Environment.",
"version": "0.0.3",
"version": "0.0.4",
"private": true,
"license": "Apache-2.0",
"author": "Google Inc.",
@@ -10,24 +10,24 @@
"url": "https://github.com/GoogleCloudPlatform/nodejs-docs-samples.git"
},
"engines": {
"node": ">=14.0.0"
"node": ">=18.0.0"
},
"scripts": {
"build": "tsc",
"start": "node dist/app.js"
},
"dependencies": {
"@google-cloud/datastore": "^6.5.0",
"axios": "^0.21.1",
"@google-cloud/datastore": "^7.3.2",
"axios": "^1.3.4",
"client-oauth2": "^4.3.3",
"express": "^4.17.1",
"typescript": "^4.4.2"
"express": "^4.18.2",
"typescript": "^4.9.5"
},
"devDependencies": {
"@types/express": "^4.17.13",
"@types/node": "^16.7.10",
"mocha": "^9.0.0",
"supertest": "^6.0.0",
"ts-node": "^10.2.1"
"@types/express": "^4.17.17",
"@types/node": "^18.14.1",
"mocha": "^10.2.0",
"supertest": "^6.3.3",
"ts-node": "^10.9.1"
}
}

View File

@@ -1,12 +1,12 @@
import sdk, { ScryptedDeviceBase, DeviceManifest, DeviceProvider, HttpRequest, HttpRequestHandler, HttpResponse, HumiditySensor, MediaObject, MotionSensor, OauthClient, Refresh, ScryptedDeviceType, ScryptedInterface, Setting, Settings, TemperatureSetting, TemperatureUnit, Thermometer, ThermostatMode, VideoCamera, BinarySensor, DeviceInformation, RTCAVSignalingSetup, Camera, PictureOptions, ObjectsDetected, ObjectDetector, ObjectDetectionTypes, FFmpegInput, RequestMediaStreamOptions, Readme, RTCSignalingChannel, RTCSessionControl, RTCSignalingSession, ResponseMediaStreamOptions, RTCSignalingSendIceCandidate, ScryptedMimeTypes, MediaStreamUrl, TemperatureCommand, OnOff } from '@scrypted/sdk';
import { connectRTCSignalingClients } from '@scrypted/common/src/rtc-signaling';
import { sleep } from '@scrypted/common/src/sleep';
import sdk, { BinarySensor, Camera, DeviceInformation, DeviceManifest, DeviceProvider, FFmpegInput, HttpRequest, HttpRequestHandler, HttpResponse, HumiditySensor, MediaObject, MediaStreamUrl, MotionSensor, OauthClient, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, PictureOptions, Readme, Refresh, RequestMediaStreamOptions, ResponseMediaStreamOptions, RTCAVSignalingSetup, RTCSessionControl, RTCSignalingChannel, RTCSignalingSendIceCandidate, RTCSignalingSession, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, TemperatureCommand, TemperatureSetting, TemperatureUnit, Thermometer, ThermostatMode, VideoCamera } from '@scrypted/sdk';
import axios from 'axios';
import ClientOAuth2 from 'client-oauth2';
import { randomBytes } from 'crypto';
import fs from 'fs';
import throttle from 'lodash/throttle';
import qs from 'query-string';
import querystring from "querystring";
import { URL } from 'url';
const { deviceManager, mediaManager, endpointManager, systemManager } = sdk;
@@ -334,11 +334,12 @@ for (const [k, v] of setpointMap.entries()) {
setpointReverseMap.set(v, k);
}
class NestThermostat extends ScryptedDeviceBase implements HumiditySensor, Thermometer, TemperatureSetting, Settings, Refresh {
class NestThermostat extends ScryptedDeviceBase implements HumiditySensor, Thermometer, TemperatureSetting, Settings, Refresh, OnOff {
device: any;
provider: GoogleSmartDeviceAccess;
executeCommandSetMode: any = undefined;
executeCommandSetCelsius: any = undefined;
executeCommandSetTimer: any = undefined;
executeThrottle = throttle(async () => {
if (this.executeCommandSetCelsius) {
@@ -365,6 +366,12 @@ class NestThermostat extends ScryptedDeviceBase implements HumiditySensor, Therm
this.console.log('executeCommandSetCelsius', command);
return this.provider.authPost(`/devices/${this.nativeId}:executeCommand`, command);
}
if (this.executeCommandSetTimer) {
const command = this.executeCommandSetTimer;
this.executeCommandSetTimer = undefined;
this.console.log('executeCommandSetTimer', command);
return this.provider.authPost(`/devices/${this.nativeId}:executeCommand`, command);
}
}, 12000)
constructor(provider: GoogleSmartDeviceAccess, device: any) {
@@ -425,6 +432,33 @@ class NestThermostat extends ScryptedDeviceBase implements HumiditySensor, Therm
// not supported by API. throw?
}
async turnOff(): Promise<void> {
// You can't turn the fan off when the HVAC unit is currently running.
if (this.thermostatActiveMode !== ThermostatMode.Off) {
this.on = false;
await this.refresh(null, true); // Refresh the state to turn the fan switch back to active.
return;
}
this.executeCommandSetTimer = {
command: 'sdm.devices.commands.Fan.SetTimer',
params: {
timerMode: 'OFF',
},
}
await this.executeThrottle();
await this.refresh(null, true);
}
async turnOn(): Promise<void> {
this.executeCommandSetTimer = {
command: 'sdm.devices.commands.Fan.SetTimer',
params: {
timerMode: 'ON',
},
}
await this.executeThrottle();
await this.refresh(null, true);
}
reload() {
const device = this.device;
@@ -478,6 +512,9 @@ class NestThermostat extends ScryptedDeviceBase implements HumiditySensor, Therm
setpoint,
availableModes: modes,
}
// Set Fan Status
this.on = this.thermostatActiveMode !== ThermostatMode.Off || device.traits?.['sdm.devices.traits.Fan']?.timerMode === "ON";
}
async refresh(refreshInterface: string, userInitiated: boolean): Promise<void> {
@@ -764,7 +801,7 @@ export class GoogleSmartDeviceAccess extends ScryptedDeviceBase implements Oauth
response_type: 'code',
scope: 'https://www.googleapis.com/auth/sdm.service',
}
return `${this.authorizationUri}?${qs.stringify(params)}`;
return `${this.authorizationUri}?${querystring.stringify(params)}`;
}
async onOauthCallback(callbackUrl: string) {
const cb = new URL(callbackUrl);
@@ -833,6 +870,7 @@ export class GoogleSmartDeviceAccess extends ScryptedDeviceBase implements Oauth
ScryptedInterface.HumiditySensor,
ScryptedInterface.Thermometer,
ScryptedInterface.Settings,
ScryptedInterface.OnOff
],
info,
})

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/hikvision",
"version": "0.0.120",
"version": "0.0.124",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/hikvision",
"version": "0.0.120",
"version": "0.0.124",
"license": "Apache",
"dependencies": {
"@koush/axios-digest-auth": "^0.8.5",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/hikvision",
"version": "0.0.120",
"version": "0.0.124",
"description": "Hikvision Plugin for Scrypted",
"author": "Scrypted",
"license": "Apache",
@@ -38,16 +38,10 @@
"@koush/axios-digest-auth": "^0.8.5",
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"@types/highland": "^2.12.14",
"@types/lodash": "^4.14.172",
"@types/multiparty": "^0.0.33",
"@types/node": "^16.9.1",
"@types/xml2js": "^0.4.9",
"axios": "^0.23.0",
"highland": "^2.13.5",
"lodash": "^4.17.21",
"multiparty": "^4.2.2",
"net-keepalive": "^3.0.0",
"xml2js": "^0.4.23"
}
}

View File

@@ -1,10 +1,6 @@
import AxiosDigestAuth from '@koush/axios-digest-auth';
import { IncomingMessage } from 'http';
import https from 'https';
export const hikvisionHttpsAgent = new https.Agent({
rejectUnauthorized: false,
});
import { getDeviceInfo, hikvisionHttpsAgent } from './probe';
export function getChannel(channel: string) {
return channel || '101';
@@ -44,31 +40,18 @@ export class HikvisionCameraAPI {
}
async getDeviceInfo() {
try {
const response = await this.digestAuth.request({
httpsAgent: hikvisionHttpsAgent,
method: "GET",
responseType: 'text',
url: `http://${this.ip}/ISAPI/System/deviceInfo`,
});
const deviceModel = response.data.match(/>(.*?)<\/model>/)?.[1];
const deviceName = response.data.match(/>(.*?)<\/deviceName>/)?.[1];
const serialNumber = response.data.match(/>(.*?)<\/serialNumber>/)?.[1];
const macAddress = response.data.match(/>(.*?)<\/macAddress>/)?.[1];
const firmwareVersion = response.data.match(/>(.*?)<\/firmwareVersion>/)?.[1];
return {
deviceModel,
deviceName,
serialNumber,
macAddress,
firmwareVersion,
};
}
catch (e) {
if (e?.response?.data?.includes('notActivated'))
throw new Error(`Camera must be first be activated at http://${this.ip}.`)
throw e;
}
return getDeviceInfo(this.digestAuth, this.ip);
}
async checkTwoWayAudio() {
const response = await this.digestAuth.request({
httpsAgent: hikvisionHttpsAgent,
method: "GET",
responseType: 'text',
url: `http://${this.ip}/ISAPI/System/TwoWayAudio/channels`,
});
return (response.data as string).includes('Speaker');
}
async checkDeviceModel(): Promise<string> {

View File

@@ -1,13 +1,13 @@
import { ffmpegLogInitialOutput } from '@scrypted/common/src/media-helpers';
import { readLength } from '@scrypted/common/src/read-stream';
import sdk, { Camera, DeviceCreatorSettings, DeviceInformation, FFmpegInput, Intercom, MediaObject, MediaStreamOptions, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, VideoStreamOptions } from "@scrypted/sdk";
import sdk, { Camera, DeviceCreatorSettings, DeviceInformation, FFmpegInput, Intercom, MediaObject, MediaStreamOptions, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting } from "@scrypted/sdk";
import child_process, { ChildProcess } from 'child_process';
import { PassThrough, Readable } from "stream";
import { sleep } from "../../../common/src/sleep";
import xml2js from 'xml2js';
import { OnvifIntercom } from "../../onvif/src/onvif-intercom";
import { RtspProvider, RtspSmartCamera, UrlMediaStreamOptions } from "../../rtsp/src/rtsp";
import { getChannel, HikvisionCameraAPI, HikvisionCameraEvent, hikvisionHttpsAgent } from "./hikvision-camera-api";
import xml2js from 'xml2js';
import { HikvisionCameraAPI, HikvisionCameraEvent } from "./hikvision-camera-api";
import { hikvisionHttpsAgent } from './probe';
const { mediaManager } = sdk;
@@ -26,19 +26,29 @@ class HikvisionCamera extends RtspSmartCamera implements Camera, Intercom {
constructor(nativeId: string, provider: RtspProvider) {
super(nativeId, provider);
this.updateManagementUrl();
this.updateDeviceInfo();
}
updateManagementUrl() {
async updateDeviceInfo() {
const ip = this.storage.getItem('ip');
if (!ip)
return;
const info = this.info || {};
const managementUrl = `http://${ip}`;
if (info.managementUrl !== managementUrl) {
info.managementUrl = managementUrl;
this.info = info;
const info: DeviceInformation = {
...this.info,
managementUrl,
ip,
manufacturer: 'Hikvision',
};
const client = this.getClient();
const deviceInfo = await client.getDeviceInfo().catch(() => { });
if (deviceInfo) {
info.model = deviceInfo.deviceModel;
info.mac = deviceInfo.macAddress;
info.firmware = deviceInfo.firmwareVersion;
info.serialNumber = deviceInfo.serialNumber;
}
this.info = info;
}
async listenEvents() {
@@ -284,7 +294,7 @@ class HikvisionCamera extends RtspSmartCamera implements Camera, Intercom {
this.provider.updateDevice(this.nativeId, this.name, interfaces, type);
this.updateManagementUrl();
this.updateDeviceInfo();
}
async getOtherSettings(): Promise<Setting[]> {
@@ -526,9 +536,10 @@ class HikvisionProvider extends RtspProvider {
const username = settings.username?.toString();
const password = settings.password?.toString();
const skipValidate = settings.skipValidate === 'true';
let twoWayAudio: string;
if (!skipValidate) {
const api = new HikvisionCameraAPI(httpAddress, username, password, this.console);
try {
const api = new HikvisionCameraAPI(httpAddress, username, password, this.console);
const deviceInfo = await api.getDeviceInfo();
settings.newCamera = deviceInfo.deviceName;
@@ -542,6 +553,15 @@ class HikvisionProvider extends RtspProvider {
this.console.error('Error adding Hikvision camera', e);
throw e;
}
try {
if (await api.checkTwoWayAudio()) {
twoWayAudio = 'Hikvision';
}
}
catch (e) {
this.console.warn('Error probing two way audio', e);
}
}
settings.newCamera ||= 'Hikvision Camera';
@@ -553,6 +573,8 @@ class HikvisionProvider extends RtspProvider {
device.putSetting('password', password);
device.setIPAddress(settings.ip?.toString());
device.setHttpPortOverride(settings.httpPort?.toString());
if (twoWayAudio)
device.putSetting('twoWayAudio', twoWayAudio);
return nativeId;
}

View File

@@ -0,0 +1,34 @@
import https from 'https';
import AxiosDigestAuth from '@koush/axios-digest-auth';
export const hikvisionHttpsAgent = new https.Agent({
rejectUnauthorized: false,
});
export async function getDeviceInfo(digestAuth: AxiosDigestAuth, address: string) {
try {
const response = await digestAuth.request({
httpsAgent: hikvisionHttpsAgent,
method: "GET",
responseType: 'text',
url: `http://${address}/ISAPI/System/deviceInfo`,
});
const deviceModel = response.data.match(/>(.*?)<\/model>/)?.[1];
const deviceName = response.data.match(/>(.*?)<\/deviceName>/)?.[1];
const serialNumber = response.data.match(/>(.*?)<\/serialNumber>/)?.[1];
const macAddress = response.data.match(/>(.*?)<\/macAddress>/)?.[1];
const firmwareVersion = response.data.match(/>(.*?)<\/firmwareVersion>/)?.[1];
return {
deviceModel,
deviceName,
serialNumber,
macAddress,
firmwareVersion,
};
}
catch (e) {
if (e?.response?.data?.includes('notActivated'))
throw new Error(`Camera must be first be activated at http://${address}.`)
throw e;
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/onvif",
"version": "0.0.109",
"version": "0.0.114",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/onvif",
"version": "0.0.109",
"version": "0.0.114",
"license": "Apache",
"dependencies": {
"@koush/axios-digest-auth": "^0.8.5",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/onvif",
"version": "0.0.109",
"version": "0.0.114",
"description": "ONVIF Camera Plugin for Scrypted",
"author": "Scrypted",
"license": "Apache",

View File

@@ -1,4 +1,5 @@
import sdk, { AdoptDevice, Device, DeviceCreatorSettings, DeviceDiscovery, DeviceInformation, DiscoveredDevice, Intercom, MediaObject, MediaStreamOptions, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, PictureOptions, ScryptedDeviceType, ScryptedInterface, ScryptedNativeId, Setting, Settings, SettingValue, VideoCamera, VideoCameraConfiguration } from "@scrypted/sdk";
import { AddressInfo } from "net";
import onvif from 'onvif';
import { Stream } from "stream";
import xml2js from 'xml2js';
@@ -37,7 +38,7 @@ class OnvifCamera extends RtspSmartCamera implements ObjectDetector, Intercom, V
constructor(nativeId: string, provider: RtspProvider) {
super(nativeId, provider);
this.updateManagementUrl();
this.updateDeviceInfo();
this.updateDevice();
}
@@ -94,16 +95,30 @@ class OnvifCamera extends RtspSmartCamera implements ObjectDetector, Intercom, V
});
}
updateManagementUrl() {
async updateDeviceInfo() {
const ip = this.storage.getItem('ip');
if (!ip)
return;
const info = this.info || {};
const client = await this.getClient();
const onvifInfo = await client.getDeviceInformation().catch(() => { });
const managementUrl = `http://${ip}`;
if (info.managementUrl !== managementUrl) {
info.managementUrl = managementUrl;
this.info = info;
let info = {
...this.info,
managementUrl,
ip,
};
if (onvifInfo) {
info = {
...info,
serialNumber: onvifInfo.serialNumber,
manufacturer: onvifInfo.manufacturer,
firmware: onvifInfo.firmwareVersion,
model: onvifInfo.model,
}
}
this.info = info;
}
getDetectionInput(detectionId: any, eventId?: any): Promise<MediaObject> {
@@ -382,7 +397,7 @@ class OnvifCamera extends RtspSmartCamera implements ObjectDetector, Intercom, V
this.client = undefined;
this.rtspMediaStreamOptions = undefined;
this.updateManagementUrl();
this.updateDeviceInfo();
if (key !== 'onvifDoorbell' && key !== 'onvifTwoWay')
return super.putSetting(key, value);
@@ -414,7 +429,7 @@ class OnvifProvider extends RtspProvider implements DeviceDiscovery {
constructor(nativeId?: string) {
super(nativeId);
onvif.Discovery.on('device', (cam: any, rinfo: any, xml: any) => {
onvif.Discovery.on('device', (cam: any, rinfo: AddressInfo, xml: any) => {
// Function will be called as soon as the NVT responses
// Parsing of Discovery responses taken from my ONVIF-Audit project, part of the 2018 ONVIF Open Source Challenge
@@ -435,19 +450,29 @@ class OnvifProvider extends RtspProvider implements DeviceDiscovery {
}
const urn = result['Envelope']['Body'][0]['ProbeMatches'][0]['ProbeMatch'][0]['EndpointReference'][0]['Address'][0].payload;
const xaddrs = result['Envelope']['Body'][0]['ProbeMatches'][0]['ProbeMatch'][0]['XAddrs'][0].payload;
let name: string;
const knownScopes = {
'onvif://www.onvif.org/name/': '',
'onvif://www.onvif.org/MAC/': '',
'onvif://www.onvif.org/hardware/': '',
};
this.console.log('discovered device payload', xml);
try {
let scopes = result['Envelope']['Body'][0]['ProbeMatches'][0]['ProbeMatch'][0]['Scopes'][0].payload;
scopes = scopes.split(" ");
const splitScopes = scopes.split(" ") as string[];
for (let i = 0; i < scopes.length; i++) {
if (scopes[i].includes('onvif://www.onvif.org/name')) { name = decodeURI(scopes[i].substring(27)); }
for (const scope of splitScopes) {
for (const known of Object.keys(knownScopes)) {
if (scope.startsWith(known)) {
knownScopes[known] = decodeURIComponent(scope.substring(known.length));
}
}
}
}
catch (e) {
}
const name = knownScopes["onvif://www.onvif.org/name/"] || 'ONVIF Camera';
this.console.log('Discovery Reply from ' + rinfo.address + ' (' + name + ') (' + xaddrs + ') (' + urn + ')');
if (deviceManager.getNativeIds().includes(urn) || this.discoveredDevices.has(urn))
@@ -455,6 +480,11 @@ class OnvifProvider extends RtspProvider implements DeviceDiscovery {
const device: Device = {
name,
info: {
ip: rinfo.address,
mac: knownScopes["onvif://www.onvif.org/MAC/"] || undefined,
model: knownScopes['onvif://www.onvif.org/hardware/'] || undefined,
},
nativeId: urn,
type: ScryptedDeviceType.Camera,
interfaces: this.getInterfaces(),
@@ -531,6 +561,21 @@ class OnvifProvider extends RtspProvider implements DeviceDiscovery {
device.putSetting('password', password);
device.setIPAddress(settings.ip?.toString());
device.setHttpPortOverride(settings.httpPort?.toString());
const intercom = new OnvifIntercom(device);
try {
intercom.url = (await device.getConstructedVideoStreamOptions())[0].url;
if (await intercom.checkIntercom()) {
device.putSetting('onvifTwoWay', 'true');
}
}
catch (e) {
this.console.warn("error while probing intercom", e);
}
finally {
intercom.intercomClient?.client.destroy();
}
return nativeId;
}

View File

@@ -65,6 +65,8 @@ function* parseCodecs(audioSection: string): Generator<CodecMatch> {
}
}
const Require = 'www.onvif.org/ver20/backchannel';
export class OnvifIntercom implements Intercom {
intercomClient: RtspClient;
url: string;
@@ -72,9 +74,7 @@ export class OnvifIntercom implements Intercom {
constructor(public camera: RtspSmartCamera) {
}
async startIntercom(media: MediaObject) {
await this.stopIntercom();
async checkIntercom() {
const username = this.camera.storage.getItem("username");
const password = this.camera.storage.getItem("password");
const url = new URL(this.url);
@@ -83,7 +83,6 @@ export class OnvifIntercom implements Intercom {
this.intercomClient = new RtspClient(url.toString());
this.intercomClient.console = this.camera.console;
await this.intercomClient.options();
const Require = 'www.onvif.org/ver20/backchannel';
const describe = await this.intercomClient.describe({
Require,
@@ -95,6 +94,16 @@ export class OnvifIntercom implements Intercom {
if (!audioBackchannel)
throw new Error('ONVIF audio backchannel not found');
return audioBackchannel;
}
async startIntercom(media: MediaObject) {
await this.stopIntercom();
const audioBackchannel = await this.checkIntercom();
if (!audioBackchannel)
throw new Error('ONVIF audio backchannel not found');
const rtp = await reserveUdpPort();
const rtcp = rtp + 1;

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { Headers, RtspServer } from "@scrypted/common/src/rtsp-server";
import fs from 'fs';
import net from 'net';
import { Duplex } from "stream";
// non standard extension that dumps the rtp payload to a file.
export class FileRtspServer extends RtspServer {
@@ -8,7 +8,7 @@ export class FileRtspServer extends RtspServer {
segmentBytesWritten = 0;
writeConsole: Console;
constructor(client: net.Socket, sdp?: string, checkRequest?: (method: string, url: string, headers: Headers, rawMessage: string[]) => Promise<boolean>) {
constructor(client: Duplex, sdp?: string, checkRequest?: (method: string, url: string, headers: Headers, rawMessage: string[]) => Promise<boolean>) {
super(client, sdp, undefined, checkRequest);
this.client.on('close', () => {

View File

@@ -5,7 +5,7 @@ import { addVideoFilterArguments } from '@scrypted/common/src/ffmpeg-helpers';
import { handleRebroadcasterClient, ParserOptions, ParserSession, startParserSession } from '@scrypted/common/src/ffmpeg-rebroadcast';
import { closeQuiet, listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
import { readLength } from '@scrypted/common/src/read-stream';
import { createRtspParser, findH264NaluType, getNaluTypes, H264_NAL_TYPE_FU_B, H264_NAL_TYPE_IDR, H264_NAL_TYPE_MTAP16, H264_NAL_TYPE_MTAP32, H264_NAL_TYPE_RESERVED0, H264_NAL_TYPE_RESERVED30, H264_NAL_TYPE_RESERVED31, H264_NAL_TYPE_SEI, H264_NAL_TYPE_STAP_B, RtspServer, RtspTrack } from '@scrypted/common/src/rtsp-server';
import { createRtspParser, findH264NaluType, getNaluTypes, H264_NAL_TYPE_FU_B, H264_NAL_TYPE_IDR, H264_NAL_TYPE_MTAP16, H264_NAL_TYPE_MTAP32, H264_NAL_TYPE_RESERVED0, H264_NAL_TYPE_RESERVED30, H264_NAL_TYPE_RESERVED31, H264_NAL_TYPE_SEI, H264_NAL_TYPE_STAP_B, listenSingleRtspClient, RtspServer, RtspTrack } from '@scrypted/common/src/rtsp-server';
import { addTrackControls, parseSdp } from '@scrypted/common/src/sdp-utils';
import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from "@scrypted/common/src/settings-mixin";
import { sleep } from '@scrypted/common/src/sleep';
@@ -1063,6 +1063,7 @@ class PrebufferSession {
let socketPromise: Promise<Duplex>;
let url: string;
let urls: string[];
let filter: (chunk: StreamChunk, prebuffer: boolean) => StreamChunk;
let interleavePassthrough = false;
const interleavedMap = new Map<string, number>();
@@ -1114,17 +1115,19 @@ class PrebufferSession {
return chunk;
}
const hostname = options?.route === 'external' ? '0.0.0.0' : undefined;
const client = await listenZeroSingleClient(hostname);
const rtspServerPath = '/' + crypto.randomBytes(8).toString('hex');
socketPromise = client.clientPromise.then(async (socket) => {
sdp = addTrackControls(sdp);
server = new FileRtspServer(socket, sdp, async (method, url, headers, rawMessage) => {
server.checkRequest = undefined;
const u = new URL(url);
return u.pathname === rtspServerPath;
});
server.writeConsole = this.console;
const hostname = options?.route === 'internal' ? undefined : '0.0.0.0';
const clientPromise = await listenSingleRtspClient({
hostname,
createServer: duplex => {
sdp = addTrackControls(sdp);
server = new FileRtspServer(duplex, sdp);
server.writeConsole = this.console;
return server;
}
});
socketPromise = clientPromise.rtspServerPromise.then(async server => {
if (session.parserSpecific) {
const parserSpecific = session.parserSpecific as RtspSessionParserSpecific;
server.resolveInterleaved = msection => {
@@ -1134,7 +1137,7 @@ class PrebufferSession {
}
// server.console = this.console;
await server.handlePlayback();
server.handleTeardown().finally(() => socket.destroy());
server.handleTeardown().finally(() => server.client.destroy());
for (const track of Object.values(server.setupTracks)) {
if (track.protocol === 'udp') {
serverPortMap.set(track.codec, track);
@@ -1146,20 +1149,27 @@ class PrebufferSession {
}
interleavePassthrough = session.parserSpecific && serverPortMap.size === 0;
return socket;
return server.client;
})
url = client.url.replace('tcp://', 'rtsp://') + rtspServerPath;
url = clientPromise.url;
if (hostname) {
try {
const addresses = await sdk.endpointManager.getLocalAddresses();
const [address] = addresses;
if (address) {
if (address && options?.route === 'external') {
const u = new URL(url);
u.hostname = address;
url = u.toString();
}
urls = addresses.map(address => {
const u = new URL(url);
u.hostname = address;
return u.toString();
})
}
catch (e) {
this.console.warn('Error determining external addresses. Is Scrypted Server Address configured?');
}
}
}
@@ -1226,6 +1236,7 @@ class PrebufferSession {
const ffmpegInput: FFmpegInput = {
url,
urls,
container,
inputArguments: [
...inputArguments,

4
plugins/remote/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.DS_Store
out/
node_modules/
dist/

10
plugins/remote/.npmignore Normal file
View File

@@ -0,0 +1,10 @@
.DS_Store
out/
node_modules/
*.map
fs
src
.vscode
dist/*.js
.venv
tsconfig.json

22
plugins/remote/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,22 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Scrypted Debugger",
"address": "${config:scrypted.debugHost}",
"port": 10081,
"request": "attach",
"skipFiles": [
"<node_internals>/**"
],
"preLaunchTask": "scrypted: deploy+debug",
"sourceMaps": true,
"localRoot": "${workspaceFolder}/out",
"remoteRoot": "/plugin/",
"type": "pwa-node"
}
]
}

4
plugins/remote/.vscode/settings.json vendored Normal file
View File

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

20
plugins/remote/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "scrypted: deploy+debug",
"type": "shell",
"presentation": {
"echo": true,
"reveal": "silent",
"focus": false,
"panel": "shared",
"showReuseMessage": true,
"clear": false
},
"command": "npm run scrypted-vscode-launch ${config:scrypted.debugHost}",
},
]
}

3
plugins/remote/README.md Normal file
View File

@@ -0,0 +1,3 @@
# Scrypted Remote Plugin
The Scrypted Remote Plugin allows connecting this Scrypted instance to a remote Scrypted server, using the Scrypted Client API. Devices from the remote Scrypted server will be imported to this current Scrypted instance.

95
plugins/remote/package-lock.json generated Normal file
View File

@@ -0,0 +1,95 @@
{
"name": "@scrypted/scrypted-remote",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/scrypted-remote",
"version": "0.0.1",
"dependencies": {
"@scrypted/client": "file:../../packages/client",
"@scrypted/sdk": "file:../../sdk",
"uuid": "^9.0.0"
},
"devDependencies": {
"@types/node": "^17.0.24"
}
},
"../../packages/client": {
"name": "@scrypted/client",
"version": "1.1.38",
"license": "ISC",
"dependencies": {
"@scrypted/types": "^0.2.64",
"axios": "^0.25.0",
"engine.io-client": "^6.2.2",
"rimraf": "^3.0.2"
},
"devDependencies": {
"@types/ip": "^1.1.0",
"@types/node": "^17.0.17",
"typescript": "^4.7.4"
}
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.2.69",
"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",
"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"
},
"bin": {
"scrypted-changelog": "bin/scrypted-changelog.js",
"scrypted-debug": "bin/scrypted-debug.js",
"scrypted-deploy": "bin/scrypted-deploy.js",
"scrypted-deploy-debug": "bin/scrypted-deploy-debug.js",
"scrypted-package-json": "bin/scrypted-package-json.js",
"scrypted-setup-project": "bin/scrypted-setup-project.js",
"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"
}
},
"node_modules/@scrypted/client": {
"resolved": "../../packages/client",
"link": true
},
"node_modules/@scrypted/sdk": {
"resolved": "../../sdk",
"link": true
},
"node_modules/@types/node": {
"version": "17.0.45",
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz",
"integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==",
"dev": true
},
"node_modules/uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
"bin": {
"uuid": "dist/bin/uuid"
}
}
}
}

View File

@@ -0,0 +1,39 @@
{
"name": "@scrypted/scrypted-remote",
"version": "0.0.1",
"description": "Scrypted Remote Plugin",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",
"prescrypted-setup-project": "scrypted-package-json",
"build": "scrypted-webpack",
"prepublishOnly": "NODE_ENV=production scrypted-webpack",
"prescrypted-vscode-launch": "scrypted-webpack",
"scrypted-vscode-launch": "scrypted-deploy-debug",
"scrypted-deploy-debug": "scrypted-deploy-debug",
"scrypted-debug": "scrypted-debug",
"scrypted-deploy": "scrypted-deploy",
"scrypted-readme": "scrypted-readme",
"scrypted-package-json": "scrypted-package-json"
},
"keywords": [
"scrypted",
"plugin",
"remote"
],
"scrypted": {
"name": "Scrypted Remote Plugin",
"type": "DeviceCreator",
"interfaces": [
"DeviceProvider",
"DeviceCreator"
]
},
"dependencies": {
"@scrypted/client": "file:../../packages/client",
"@scrypted/sdk": "file:../../sdk",
"uuid": "^9.0.0"
},
"devDependencies": {
"@types/node": "^17.0.24"
}
}

332
plugins/remote/src/main.ts Normal file
View File

@@ -0,0 +1,332 @@
import { Device, DeviceProvider, DeviceCreator, DeviceCreatorSettings, ScryptedDevice, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings, Battery, VideoCamera, SettingValue, RequestMediaStreamOptions, MediaObject, DeviceManifest} from '@scrypted/sdk';
import sdk from '@scrypted/sdk';
import { StorageSettings } from '@scrypted/sdk/storage-settings';
import { connectScryptedClient, ScryptedClientStatic } from '@scrypted/client';
import https from 'https';
import { v4 as uuidv4 } from 'uuid';
const { deviceManager } = sdk;
class ScryptedRemoteInstance extends ScryptedDeviceBase implements DeviceProvider, Settings {
client: ScryptedClientStatic = null;
devices = new Map<string, ScryptedDevice>();
settingsStorage = new StorageSettings(this, {
baseUrl: {
title: 'Base URL',
placeholder: 'https://localhost:10443',
onPut: async () => await this.clearTryDiscoverDevices(),
},
username: {
title: 'Username',
onPut: async () => await this.clearTryDiscoverDevices(),
},
password: {
title: 'Password',
type: 'password',
onPut: async () => await this.clearTryDiscoverDevices(),
},
});
constructor(nativeId: string) {
super(nativeId);
this.clearTryDiscoverDevices();
}
/**
* Checks the given remote device to see if it can be correctly imported by this plugin.
* Returns the (potentially modified) device that is allowed, or null if the device cannot
* be imported.
*
* @param device
* The local device representation. Will be modified in-place and returned.
*/
filtered(device: Device): Device {
// only permit the following device types through
const allowedTypes = [
ScryptedDeviceType.Camera,
ScryptedDeviceType.Doorbell,
ScryptedDeviceType.DeviceProvider,
ScryptedDeviceType.API,
]
if (!allowedTypes.includes(device.type)) {
return null;
}
// only permit the following functional interfaces through
const allowedInterfaces = [
ScryptedInterface.VideoCamera,
ScryptedInterface.Camera,
ScryptedInterface.RTCSignalingChannel,
ScryptedInterface.Battery,
ScryptedInterface.MotionSensor,
ScryptedInterface.AudioSensor,
ScryptedInterface.DeviceProvider,
ScryptedInterface.ObjectDetection,
];
const intersection = allowedInterfaces.filter(i => device.interfaces.includes(i));
if (intersection.length == 0) {
return null;
}
// explicitly drop plugins if all they do is provide devices
if (device.interfaces.includes(ScryptedInterface.ScryptedPlugin) && intersection.length == 1 && intersection[0] == ScryptedInterface.DeviceProvider) {
return null;
}
// some extra interfaces that are nice to expose, but not needed
const nonessentialInterfaces = [
ScryptedInterface.Readme,
];
const nonessentialIntersection = nonessentialInterfaces.filter(i => device.interfaces.includes(i));
device.interfaces = intersection.concat(nonessentialIntersection);
return device;
}
/**
* Configures relevant proxies for the local device representation and the remote device.
* Listeners are added for interface property updates, and select remote function calls are
* intercepted to tweak arguments for better remote integration.
*
* @param device
* The local device representation.
*
* @param remoteDevice
* The RPC reference to the remote device.
*/
setupProxies(device: Device, remoteDevice: ScryptedDevice) {
// set up event listeners for all the relevant interfaces
device.interfaces.map(iface => remoteDevice.listen(iface, (source, details, data) => {
if (!details.property) {
deviceManager.onDeviceEvent(device.nativeId, details.eventInterface, data);
} else {
deviceManager.getDeviceState(device.nativeId)[details.property] = data;
}
}));
// for certain interfaces with fixed state, transfer the initial values over
if (device.interfaces.includes(ScryptedInterface.Battery)) {
deviceManager.getDeviceState(device.nativeId).batteryLevel = (<Battery>remoteDevice).batteryLevel;
}
// since the remote may be using rebroadcast, explicitly request the external
// address for video streams
if (device.interfaces.includes(ScryptedInterface.VideoCamera)) {
const remoteGetVideoStream = (<VideoCamera><any>remoteDevice).getVideoStream;
(<VideoCamera><any>remoteDevice).getVideoStream = async (options?: RequestMediaStreamOptions): Promise<MediaObject> => {
if (!options) {
options = {};
}
(<any>options).route = "external";
return await remoteGetVideoStream(options);
}
}
// for device providers, we need to translate the nativeId
if (device.interfaces.includes(ScryptedInterface.DeviceProvider)) {
const plugin = this;
(<DeviceProvider><any>remoteDevice).getDevice = async (nativeId: string): Promise<Device> => {
return <Device>plugin.devices.get(nativeId);
}
(<DeviceProvider><any>remoteDevice).releaseDevice = async (id: string, nativeId: string): Promise<any> => {
// don't delete the device from the remote
plugin.releaseDevice(id, nativeId);
}
}
}
/**
* Resets the connection to the remote Scrypted server and attempts to reconnect
* and rediscover remoted devices.
*/
async clearTryDiscoverDevices(): Promise<void> {
await this.tryLogin();
// bjia56:
// there's some race condition with multi-tier device discovery that I haven't
// sorted out, but it appears to work fine if we run discovery twice
await this.discoverDevices(0);
await this.discoverDevices(0);
}
async tryLogin(): Promise<void> {
this.client = null;
if (!this.settingsStorage.values.baseUrl || !this.settingsStorage.values.username || !this.settingsStorage.values.password) {
this.console.log("Initializing remote Scrypted login requires the base URL, username, and password");
return;
}
const httpsAgent = new https.Agent({
rejectUnauthorized: false,
});
this.client = await connectScryptedClient({
baseUrl: this.settingsStorage.values.baseUrl,
pluginId: '@scrypted/core',
username: this.settingsStorage.values.username,
password: this.settingsStorage.values.password,
axiosConfig: {
httpsAgent,
},
})
this.console.log(`Connected to remote Scrypted server. Remote server version: ${this.client.serverVersion}`)
}
getSettings(): Promise<Setting[]> {
return this.settingsStorage.getSettings();
}
putSetting(key: string, value: SettingValue): Promise<void> {
return this.settingsStorage.putSetting(key, value);
}
async discoverDevices(duration: number): Promise<void> {
if (!this.client) {
return
}
// construct initial (flat) list of devices from the remote server
const state = this.client.systemManager.getSystemState();
const devices = <Device[]>[];
for (const id in state) {
const remoteDevice = this.client.systemManager.getDeviceById(id);
const remoteProviderDevice = this.client.systemManager.getDeviceById(remoteDevice.providerId);
const remoteProviderNativeId = remoteProviderDevice?.id == remoteDevice.id ? undefined : remoteProviderDevice?.id;
const nativeId = `${this.nativeId}:${remoteDevice.id}`;
const device = this.filtered(<Device>{
name: remoteDevice.name,
type: remoteDevice.type,
interfaces: remoteDevice.interfaces,
info: remoteDevice.info,
nativeId: nativeId,
providerNativeId: remoteProviderNativeId ? `${this.nativeId}:${remoteProviderNativeId}` : this.nativeId,
});
if (!device) {
this.console.log(`Device ${remoteDevice.name} is not supported, ignoring`)
continue;
}
this.console.log(`Found ${remoteDevice.name}\n${JSON.stringify(device, null, 2)}`);
this.devices.set(device.nativeId, remoteDevice);
devices.push(device)
}
// it may be that a parent device was filtered out, so reparent these child devices to
// the top level
devices.map(device => {
if (!this.devices.has(device.providerNativeId)) {
device.providerNativeId = this.nativeId;
}
});
// group devices by parent provider id
const providerDeviceMap = new Map<string, Device[]>();
devices.map(device => {
// group devices by parent provider id
if (!providerDeviceMap.has(device.providerNativeId)) {
providerDeviceMap.set(device.providerNativeId, [device]);
} else {
providerDeviceMap.get(device.providerNativeId).push(device);
}
})
// first register the top level devices, then register the remaining
// devices by provider id
await deviceManager.onDevicesChanged(<DeviceManifest>{
devices: providerDeviceMap.get(this.nativeId),
providerNativeId: this.nativeId,
});
for (let [providerNativeId, devices] of providerDeviceMap) {
await deviceManager.onDevicesChanged(<DeviceManifest>{
devices,
providerNativeId,
});
}
// setup relevant proxies and monkeypatches for all devices
devices.map(device => this.setupProxies(device, this.devices.get(device.nativeId)));
this.console.log(`Discovered ${devices.length} devices`);
}
async getDevice(nativeId: string): Promise<Device> {
if (!this.devices.has(nativeId)) {
throw new Error(`${nativeId} does not exist`);
}
return <Device>this.devices.get(nativeId);
}
async releaseDevice(id: string, nativeId: string): Promise<void> {
this.devices.delete(nativeId)
}
}
class ScryptedRemotePlugin extends ScryptedDeviceBase implements DeviceCreator, DeviceProvider {
remotes = new Map<string, ScryptedRemoteInstance>();
constructor() {
super();
}
async getDevice(nativeId: string): Promise<Device> {
if (!this.remotes.has(nativeId)) {
this.remotes.set(nativeId, new ScryptedRemoteInstance(nativeId));
}
return this.remotes.get(nativeId) as Device;
}
async releaseDevice(id: string, nativeId: string): Promise<void> {
return;
}
async getCreateDeviceSettings(): Promise<Setting[]> {
return [
{
key: 'name',
title: 'Name',
},
{
key: 'baseUrl',
title: 'Base URL',
placeholder: 'https://localhost:10443',
},
{
key: 'username',
title: 'Username',
},
{
key: 'password',
title: 'Password',
type: 'password',
},
];
}
async createDevice(settings: DeviceCreatorSettings): Promise<string> {
const name = settings.name?.toString();
const url = settings.baseUrl?.toString();
const username = settings.username?.toString();
const password = settings.password?.toString();
const nativeId = uuidv4();
await deviceManager.onDeviceDiscovered(<Device>{
nativeId,
name,
interfaces: [
ScryptedInterface.Settings,
ScryptedInterface.DeviceProvider
],
type: ScryptedDeviceType.DeviceProvider,
});
const remote = await this.getDevice(nativeId) as ScryptedRemoteInstance;
remote.storage.setItem("baseUrl", url);
remote.storage.setItem("username", username);
remote.storage.setItem("password", password);
await remote.clearTryDiscoverDevices();
return nativeId;
}
}
export default new ScryptedRemotePlugin();

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2021",
"resolveJsonModule": true,
"moduleResolution": "Node16",
"esModuleInterop": true,
"sourceMap": true
},
"include": [
"src/**/*"
]
}

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/reolink",
"version": "0.0.13",
"version": "0.0.16",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/reolink",
"version": "0.0.13",
"version": "0.0.16",
"license": "Apache",
"dependencies": {
"@koush/axios-digest-auth": "^0.8.5",

View File

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

View File

@@ -20,11 +20,10 @@ class ReolinkCamera extends RtspSmartCamera implements Camera {
if (!ip)
return;
const info = this.info || {};
const managementUrl = `http://${ip}`;
if (info.managementUrl !== managementUrl) {
info.managementUrl = managementUrl;
this.info = info;
}
info.ip = ip;
info.manufacturer = 'Reolink';
info.managementUrl = `http://${ip}`;
this.info = info;
}
getClient() {

View File

@@ -0,0 +1,23 @@
import AxiosDigestAuth from "@koush/axios-digest-auth";
import https from 'https';
export const reolinkHttpsAgent = new https.Agent({
rejectUnauthorized: false,
});
export async function getMotionState(digestAuth: AxiosDigestAuth, username: string, password: string, address: string, channelId: number) {
const url = new URL(`http://${address}/cgi-bin/api.cgi`);
const params = url.searchParams;
params.set('cmd', 'GetMdState');
params.set('channel', channelId.toString());
params.set('user', username);
params.set('password', password);
const response = await digestAuth.request({
url: url.toString(),
httpsAgent: reolinkHttpsAgent,
});
return {
value: !!response.data?.[0]?.value?.state,
data: response.data,
};
}

View File

@@ -1,18 +1,13 @@
import axios from 'axios';
import AxiosDigestAuth from "@koush/axios-digest-auth/dist";
import AxiosDigestAuth from "@koush/axios-digest-auth";
import https from 'https';
import { getMotionState, reolinkHttpsAgent } from './probe';
export class ReolinkCameraClient {
digestAuth: AxiosDigestAuth;
axios = axios.create({
httpsAgent: new https.Agent({
rejectUnauthorized: false,
})
});
constructor(public host: string, public username: string, public password: string, public channelId: number, public console: Console) {
this.digestAuth = new AxiosDigestAuth({
axios: this.axios,
password,
username,
});
@@ -28,19 +23,7 @@ export class ReolinkCameraClient {
// }
// ]
async getMotionState() {
const url = new URL(`http://${this.host}/cgi-bin/api.cgi`);
const params = url.searchParams;
params.set('cmd', 'GetMdState');
params.set('channel', this.channelId.toString());
params.set('user', this.username);
params.set('password', this.password);
const response = await this.digestAuth.request({
url: url.toString(),
});
return {
value: !!response.data?.[0]?.value?.state,
data: response.data,
};
return getMotionState(this.digestAuth, this.username, this.password, this.host, this.channelId);
}
async jpegSnapshot() {
@@ -54,7 +37,8 @@ export class ReolinkCameraClient {
const response = await this.digestAuth.request({
url: url.toString(),
responseType: 'arraybuffer'
responseType: 'arraybuffer',
httpsAgent: reolinkHttpsAgent,
});
return Buffer.from(response.data);

View File

@@ -1,24 +1,23 @@
{
"name": "@scrypted/ring",
"version": "0.0.94",
"version": "0.0.96",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@scrypted/ring",
"version": "0.0.94",
"hasInstallScript": true,
"version": "0.0.96",
"dependencies": {
"@koush/ring-client-api": "file:../../external/ring-client-api",
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"@types/node": "^16.6.1",
"axios": "^0.24.0",
"rxjs": "^7.5.5"
"@types/node": "^16.9.0",
"axios": "^1.3.4",
"rxjs": "^7.8.0"
},
"optionalDependencies": {
"got": "11.8.2",
"socket.io-client": "^2.4.0"
"got": "11.8.6",
"socket.io-client": "^2.5.0"
}
},
"../../common": {
@@ -107,7 +106,7 @@
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.2.69",
"version": "0.2.70",
"license": "ISC",
"dependencies": {
"@babel/preset-typescript": "^7.18.6",
@@ -231,12 +230,19 @@
"integrity": "sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==",
"optional": true
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz",
"integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.3.4.tgz",
"integrity": "sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==",
"dependencies": {
"follow-redirects": "^1.14.4"
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/backo2": {
@@ -299,6 +305,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/component-bind": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz",
@@ -362,6 +379,14 @@
"node": ">=10"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
@@ -422,6 +447,19 @@
}
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
@@ -438,9 +476,9 @@
}
},
"node_modules/got": {
"version": "11.8.2",
"resolved": "https://registry.npmjs.org/got/-/got-11.8.2.tgz",
"integrity": "sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==",
"version": "11.8.6",
"resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
"integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
"optional": true,
"dependencies": {
"@sindresorhus/is": "^4.0.0",
@@ -448,7 +486,7 @@
"@types/cacheable-request": "^6.0.1",
"@types/responselike": "^1.0.0",
"cacheable-lookup": "^5.0.3",
"cacheable-request": "^7.0.1",
"cacheable-request": "^7.0.2",
"decompress-response": "^6.0.0",
"http2-wrapper": "^1.0.0-beta.5.2",
"lowercase-keys": "^2.0.0",
@@ -532,6 +570,25 @@
"node": ">=8"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mimic-response": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
@@ -589,6 +646,11 @@
"integrity": "sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==",
"optional": true
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/pump": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",

View File

@@ -36,13 +36,13 @@
"@koush/ring-client-api": "file:../../external/ring-client-api",
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"@types/node": "^16.6.1",
"axios": "^0.24.0",
"rxjs": "^7.5.5"
"@types/node": "^16.9.0",
"axios": "^1.3.4",
"rxjs": "^7.8.0"
},
"optionalDependencies": {
"got": "11.8.2",
"socket.io-client": "^2.4.0"
"got": "11.8.6",
"socket.io-client": "^2.5.0"
},
"version": "0.0.95"
"version": "0.0.96"
}

View File

@@ -4,7 +4,7 @@ import { connectRTCSignalingClients } from '@scrypted/common/src/rtc-signaling';
import { RtspServer } from '@scrypted/common/src/rtsp-server';
import { addTrackControls, parseSdp, replacePorts } from '@scrypted/common/src/sdp-utils';
import { StorageSettings } from '@scrypted/sdk/storage-settings';
import sdk, { BinarySensor, Camera, Device, DeviceProvider, EntrySensor, FFmpegInput, MediaObject, MediaStreamUrl, MotionSensor, OnOff, PictureOptions, RequestMediaStreamOptions, RequestPictureOptions, ResponseMediaStreamOptions, RTCAVSignalingSetup, RTCSessionControl, RTCSignalingChannel, RTCSignalingSendIceCandidate, RTCSignalingSession, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, SecuritySystem, SecuritySystemMode, Setting, Settings, SettingValue, VideoCamera } from '@scrypted/sdk';
import sdk, { Battery, BinarySensor, Camera, Device, DeviceProvider, EntrySensor, FFmpegInput, FloodSensor, MediaObject, MediaStreamUrl, MotionSensor, OnOff, PictureOptions, RequestMediaStreamOptions, RequestPictureOptions, ResponseMediaStreamOptions, RTCAVSignalingSetup, RTCSessionControl, RTCSignalingChannel, RTCSignalingSendIceCandidate, RTCSignalingSession, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, SecuritySystem, SecuritySystemMode, Setting, Settings, SettingValue, TamperSensor, VideoCamera } from '@scrypted/sdk';
import child_process, { ChildProcess } from 'child_process';
import dgram from 'dgram';
import { RtcpReceiverInfo, RtcpRrPacket } from '../../../external/werift/packages/rtp/src/rtcp/rr';
@@ -661,9 +661,13 @@ class RingCameraDevice extends ScryptedDeviceBase implements DeviceProvider, Cam
}
}
class RingContactSensor extends ScryptedDeviceBase implements EntrySensor {
class RingSensor extends ScryptedDeviceBase implements TamperSensor, Battery, EntrySensor, MotionSensor, FloodSensor {
updateState(data: RingDeviceData) {
this.entryOpen = data.faulted
this.tampered = data.tamperStatus === 'tamper';
this.batteryLevel = data.batteryLevel;
this.entryOpen = data.faulted;
this.motionDetected = data.faulted;
this.flooded = data.flood?.faulted || data.faulted;
}
}
@@ -761,7 +765,7 @@ export class RingLocationDevice extends ScryptedDeviceBase implements DeviceProv
async getDevice(nativeId: string) {
if (!this.devices.has(nativeId)) {
if (nativeId.endsWith('-sensor')) {
const sensor = new RingContactSensor(nativeId);
const sensor = new RingSensor(nativeId);
this.devices.set(nativeId, sensor);
} else {
const camera = new RingCameraDevice(this.plugin, this, nativeId);
@@ -1025,31 +1029,61 @@ class RingPlugin extends ScryptedDeviceBase implements DeviceProvider, Settings
}
const sensors = (await location.getDevices()).filter(x => {
return x.data.status !== 'disabled' && (x.data.deviceType === RingDeviceType.ContactSensor || x.data.deviceType === RingDeviceType.RetrofitZone)
const supportedSensors = [
RingDeviceType.ContactSensor,
RingDeviceType.RetrofitZone,
RingDeviceType.TiltSensor,
RingDeviceType.MotionSensor,
RingDeviceType.FloodFreezeSensor,
RingDeviceType.WaterSensor,
]
return x.data.status !== 'disabled' && (supportedSensors.includes(x.data.deviceType))
});
for (const sensor of sensors) {
const nativeId = sensor.id.toString() + '-sensor';
const data: RingDeviceData = sensor.data;
const interfaces = [ScryptedInterface.TamperSensor];
switch (data.deviceType){
case RingDeviceType.ContactSensor:
case RingDeviceType.RetrofitZone:
case RingDeviceType.TiltSensor:
interfaces.push(ScryptedInterface.EntrySensor);
break;
case RingDeviceType.MotionSensor:
interfaces.push(ScryptedInterface.MotionSensor);
break;
case RingDeviceType.FloodFreezeSensor:
case RingDeviceType.WaterSensor:
interfaces.push(ScryptedInterface.FloodSensor);
break;
default: break;
}
if (data.batteryStatus !== 'none')
interfaces.push(ScryptedInterface.Battery);
const device: Device = {
info: {
model: sensor.data.deviceType,
model: data.deviceType,
manufacturer: 'Ring',
serialNumber: sensor.data.serialNumber ?? 'Unknown'
serialNumber: data.serialNumber ?? 'Unknown'
},
providerNativeId: location.id,
nativeId: nativeId,
name: sensor.name,
type: ScryptedDeviceType.Sensor,
interfaces: [ScryptedInterface.EntrySensor],
interfaces,
};
devices.push(device);
const getScryptedDevice = async () => {
const locationDevice = await this.getDevice(location.id);
const scryptedDevice = await locationDevice?.getDevice(nativeId);
return scryptedDevice as RingContactSensor;
return scryptedDevice as RingSensor;
}
sensor.onData.subscribe(async data => {
sensor.onData.subscribe(async (data: RingDeviceData) => {
const scryptedDevice = await getScryptedDevice();
scryptedDevice?.updateState(data)
});

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/snapshot",
"version": "0.0.43",
"version": "0.0.45",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/snapshot",
"version": "0.0.43",
"version": "0.0.45",
"dependencies": {
"@koush/axios-digest-auth": "^0.8.5",
"@types/node": "^16.6.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/snapshot",
"version": "0.0.43",
"version": "0.0.45",
"description": "Snapshot Plugin for Scrypted",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",
@@ -22,6 +22,7 @@
"camera"
],
"scrypted": {
"realfs": true,
"name": "Snapshot Plugin",
"type": "API",
"interfaces": [

View File

@@ -1,3 +1,4 @@
import fs from 'fs';
import { addVideoFilterArguments } from '@scrypted/common/src/ffmpeg-helpers';
import { ffmpegLogInitialOutput, safeKillFFmpeg, safePrintFFmpegArguments } from '@scrypted/common/src/media-helpers';
import { sleep } from '@scrypted/common/src/sleep';
@@ -115,7 +116,11 @@ export async function ffmpegFilterImageBuffer(inputJpeg: Buffer, options: FFmpeg
input.write(inputJpeg);
input.end();
return ffmpegFilterImageInternal(cp, options);
return ffmpegFilterImageInternal(cp, options)
.catch(e => {
fs.writeFileSync("/tmp/test.jpg", inputJpeg);
throw e;
})
}
export async function ffmpegFilterImage(inputArguments: string[], options: FFmpegImageFilterOptions) {
@@ -168,14 +173,16 @@ export async function ffmpegFilterImageInternal(cp: ChildProcess, options: FFmpe
cp.stdio[3].on('data', data => buffers.push(data));
const to = options.timeout ? setTimeout(() => {
console.log('ffmpeg stream to image conversion timed out.');
console.log('ffmpeg input to image conversion timed out.');
safeKillFFmpeg(cp);
}, 10000) : undefined;
const [exitCode] = await once(cp, 'exit');
const exit = once(cp, 'exit');
await once(cp.stdio[3], 'end').catch(() => {});
const [exitCode] = await exit;
clearTimeout(to);
if (exitCode && !buffers.length)
throw new Error(`ffmpeg stream to image conversion failed with exit code: ${exitCode}`);
throw new Error(`ffmpeg input to image conversion failed with exit code: ${exitCode}, ${cp.spawnargs.join(' ')}`);
return Buffer.concat(buffers);
}
@@ -207,7 +214,7 @@ export async function ffmpegFilterImageStream(cp: ChildProcess, options: FFmpegI
if (last)
resolve(last);
else
reject(new Error(`ffmpeg stream to image conversion failed with exit code: ${exitCode}`));
reject(new Error(`ffmpeg stream to image conversion failed with exit code: ${exitCode}, ${cp.spawnargs.join(' ')}`));
})
});

View File

@@ -1,10 +1,10 @@
import AxiosDigestAuth from '@koush/axios-digest-auth';
import { AutoenableMixinProvider } from "@scrypted/common/src/autoenable-mixin-provider";
import { createMapPromiseDebouncer, RefreshPromise, singletonPromise, TimeoutError, timeoutPromise } from "@scrypted/common/src/promise-utils";
import { StorageSettings } from "@scrypted/sdk/storage-settings";
import { createMapPromiseDebouncer, RefreshPromise, singletonPromise, TimeoutError } from "@scrypted/common/src/promise-utils";
import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from "@scrypted/common/src/settings-mixin";
import sdk, { BufferConverter, BufferConvertorOptions, Camera, FFmpegInput, MediaObject, MixinProvider, RequestMediaStreamOptions, RequestPictureOptions, ResponsePictureOptions, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera } from "@scrypted/sdk";
import axios, { Axios } from "axios";
import { StorageSettings } from "@scrypted/sdk/storage-settings";
import axios, { AxiosInstance } from "axios";
import https from 'https';
import path from 'path';
import MimeType from 'whatwg-mimetype';
@@ -72,22 +72,12 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
},
snapshotsFromPrebuffer: {
title: 'Snapshots from Prebuffer',
description: 'Prefer snapshots from the Rebroadcast Plugin prebuffer when available. This setting uses considerable CPU to convert a video stream into a snapshot.',
type: 'boolean',
defaultValue: !this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera),
},
snapshotMode: {
title: 'Snapshot Mode',
description: 'Set the snapshot mode to accomodate cameras with slow snapshots that may hang HomeKit.\nSetting the mode to "Never Wait" will only use recently available snapshots.\nSetting the mode to "Timeout" will cancel slow snapshots.',
description: 'Prefer snapshots from the Rebroadcast Plugin prebuffer when available. This setting uses considerable CPU to convert a video stream into a snapshot. The Default setting will use the camera snapshot and fall back to prebuffer on failure.',
choices: [
'Default',
'Never Wait',
'Timeout',
'Enabled',
'Disabled',
],
mapGet(value) {
// renamed the setting value.
return value === 'Normal' ? 'Default' : value;
},
defaultValue: 'Default',
},
snapshotResolution: {
@@ -107,7 +97,6 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
type: 'clippath',
},
});
axiosClient: Axios | AxiosDigestAuth;
snapshotDebouncer = createMapPromiseDebouncer<Buffer>();
errorPicture: RefreshPromise<Buffer>;
timeoutPicture: RefreshPromise<Buffer>;
@@ -117,6 +106,7 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
lastErrorImagesClear = 0;
static lastGeneratedErrorImageTime = 0;
lastAvailablePicture: Buffer;
psos: ResponsePictureOptions[];
constructor(public plugin: SnapshotPlugin, options: SettingsMixinDeviceOptions<Camera>) {
super(options);
@@ -132,7 +122,40 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
let needSoftwareResize = !!(options?.picture?.width || options?.picture?.height);
let takePicture: (options?: RequestPictureOptions) => Promise<Buffer>;
if (this.storageSettings.values.snapshotsFromPrebuffer) {
const { snapshotsFromPrebuffer } = this.storageSettings.values;
let usePrebufferSnapshots: boolean;
switch (snapshotsFromPrebuffer) {
case 'true':
case 'Enabled':
usePrebufferSnapshots = true;
break;
case 'Disabled':
usePrebufferSnapshots = false;
break;
default:
if (!this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera))
usePrebufferSnapshots = true;
break;
}
// unifi cameras send stale snapshots which are unusable for events,
// so force a prebuffer snapshot in this instance.
// if prebuffer is not available, it will fall back.
if (eventSnapshot && usePrebufferSnapshots !== false) {
try {
const psos = await this.getPictureOptions();
if (psos?.[0]?.staleDuration) {
usePrebufferSnapshots = true;
}
}
catch (e) {
}
}
let takePrebufferPicture: () => Promise<Buffer>;
const preparePrebufferSnapshot = async () => {
if (takePrebufferPicture)
return takePrebufferPicture;
try {
const realDevice = systemManager.getDeviceById<VideoCamera>(this.id);
const msos = await realDevice.getVideoStreamOptions();
@@ -148,114 +171,124 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
request.prebuffer = eventSnapshot ? 1000 : 6000;
if (this.lastAvailablePicture)
request.refresh = false;
takePicture = async () => mediaManager.convertMediaObjectToBuffer(await realDevice.getVideoStream(request), 'image/jpeg');
this.console.log('snapshotting active prebuffer');
takePrebufferPicture = async () => {
// this.console.log('snapshotting active prebuffer');
return mediaManager.convertMediaObjectToBuffer(await realDevice.getVideoStream(request), 'image/jpeg');
};
return takePrebufferPicture;
}
}
catch (e) {
}
}
if (usePrebufferSnapshots) {
takePicture = await preparePrebufferSnapshot();
}
if (!takePicture) {
if (!this.storageSettings.values.snapshotUrl) {
if (this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera)) {
takePicture = async (options?: RequestPictureOptions) => {
const internalTakePicture = async () => {
if (!options?.id && this.storageSettings.values.defaultSnapshotChannel !== 'Camera Default') {
try {
if (!psos)
psos = await this.mixinDevice.getPictureOptions();
const pso = psos.find(pso => pso.name === this.storageSettings.values.defaultSnapshotChannel);
if (!options)
options = {};
options.id = pso.id;
}
catch (e) {
}
}
return this.mixinDevice.takePicture(options).then(mo => mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg'))
}
if (this.storageSettings.values.snapshotUrl) {
let username: string;
let password: string;
// full resolution setging ignores resize.
if (this.storageSettings.values.snapshotResolution === 'Full Resolution') {
if (options)
options.picture = undefined;
return internalTakePicture();
}
// if resize wasn't requested, continue as normal.
const resizeRequested = !!options?.picture;
if (!resizeRequested)
return internalTakePicture();
// resize was requested
// crop and scale needs to operate on the full resolution image.
if (this.storageSettings.values.snapshotCropScale?.length) {
options.picture = undefined;
// resize after the cop and scale.
needSoftwareResize = resizeRequested;
return internalTakePicture();
}
// determine see if that can be handled by camera hardware
let psos: ResponsePictureOptions[];
try {
if (!psos)
psos = await this.mixinDevice.getPictureOptions();
if (!psos?.[0]?.canResize) {
needSoftwareResize = true;
}
}
catch (e) {
}
if (needSoftwareResize)
options.picture = undefined;
return internalTakePicture();
};
if (this.mixinDeviceInterfaces.includes(ScryptedInterface.Settings)) {
const settings = await this.mixinDevice.getSettings();
username = settings?.find(setting => setting.key === 'username')?.value?.toString();
password = settings?.find(setting => setting.key === 'password')?.value?.toString();
}
else if (this.storageSettings.values.snapshotsFromPrebuffer) {
takePicture = async () => {
throw new PrebufferUnavailableError();
}
let axiosClient: AxiosDigestAuth | AxiosInstance;
if (username && password) {
axiosClient = new AxiosDigestAuth({
username,
password,
});
}
else {
takePicture = () => {
throw new Error('Snapshot Unavailable (snapshotUrl empty)');
}
}
}
else {
if (!this.axiosClient) {
let username: string;
let password: string;
if (this.mixinDeviceInterfaces.includes(ScryptedInterface.Settings)) {
const settings = await this.mixinDevice.getSettings();
username = settings?.find(setting => setting.key === 'username')?.value?.toString();
password = settings?.find(setting => setting.key === 'password')?.value?.toString();
}
if (username && password) {
this.axiosClient = new AxiosDigestAuth({
username,
password,
});
}
else {
this.axiosClient = axios;
}
axiosClient = axios;
}
takePicture = () => this.axiosClient.request({
takePicture = () => axiosClient.request({
httpsAgent,
method: "GET",
responseType: 'arraybuffer',
url: this.storageSettings.values.snapshotUrl,
}).then(async (response: { data: any; }) => response.data);
}
else if (this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera)) {
takePicture = async (options?: RequestPictureOptions) => {
const internalTakePicture = async () => {
if (!options?.id && this.storageSettings.values.defaultSnapshotChannel !== 'Camera Default') {
try {
const psos = await this.getPictureOptions();
const pso = psos.find(pso => pso.name === this.storageSettings.values.defaultSnapshotChannel);
if (!options)
options = {};
options.id = pso.id;
}
catch (e) {
}
}
return this.mixinDevice.takePicture(options).then(mo => mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg'))
}
// full resolution setging ignores resize.
if (this.storageSettings.values.snapshotResolution === 'Full Resolution') {
if (options)
options.picture = undefined;
return internalTakePicture();
}
// if resize wasn't requested, continue as normal.
const resizeRequested = !!options?.picture;
if (!resizeRequested)
return internalTakePicture();
// resize was requested
// crop and scale needs to operate on the full resolution image.
if (this.storageSettings.values.snapshotCropScale?.length) {
options.picture = undefined;
// resize after the cop and scale.
needSoftwareResize = resizeRequested;
return internalTakePicture();
}
// determine see if that can be handled by camera hardware
try {
const psos = await this.getPictureOptions();
if (!psos?.[0]?.canResize) {
needSoftwareResize = true;
}
}
catch (e) {
}
if (needSoftwareResize)
options.picture = undefined;
return internalTakePicture()
.catch(async e => {
// the camera snapshot failed, try to fallback to prebuffer snapshot.
if (usePrebufferSnapshots === false)
throw e;
const fallback = await preparePrebufferSnapshot();
if (!fallback)
throw e;
return fallback();
})
};
}
else if (usePrebufferSnapshots) {
takePicture = async () => {
throw new PrebufferUnavailableError();
}
}
else {
takePicture = () => {
throw new Error('Snapshot Unavailable (snapshotUrl empty)');
}
}
}
const pendingPicture = this.snapshotDebouncer(options, async () => {
@@ -286,34 +319,20 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
}, 60000);
}
catch (e) {
this.console.error('Snapshot failed', e);
// do not mask event snapshots, as they're used for detections and not
// user facing display.
if (eventSnapshot)
throw e;
// allow reusing the current picture to mask errors
picture = await this.createErrorImage(e);
}
return picture;
});
let { snapshotMode } = this.storageSettings.values;
if (eventSnapshot) {
// event snapshots must be fulfilled
snapshotMode = 'Default';
}
else if (snapshotMode === 'Never Wait' && !options?.periodicRequest) {
// non periodic snapshots should use a short timeout.
snapshotMode = 'Timeout';
}
let data: Buffer;
try {
switch (snapshotMode) {
case 'Never Wait':
throw new NeverWaitError();
case 'Timeout':
data = await timeoutPromise(1000, pendingPicture);
break;
default:
data = await pendingPicture;
break;
}
data = await pendingPicture;
}
catch (e) {
// allow reusing the current picture to mask errors
@@ -443,7 +462,9 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
}
async getPictureOptions() {
return this.mixinDevice.getPictureOptions();
if (!this.psos)
this.psos = await this.mixinDevice.getPictureOptions();
return this.psos;
}
getMixinSettings(): Promise<Setting[]> {

4
sdk/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/sdk",
"version": "0.2.69",
"version": "0.2.71",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/sdk",
"version": "0.2.69",
"version": "0.2.71",
"license": "ISC",
"dependencies": {
"@babel/preset-typescript": "^7.18.6",

View File

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

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/types",
"version": "0.2.64",
"version": "0.2.66",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/types",
"version": "0.2.64",
"version": "0.2.66",
"license": "ISC",
"devDependencies": {
"@types/rimraf": "^3.0.2",

View File

@@ -1,13 +1,13 @@
{
"name": "@scrypted/types",
"version": "0.2.64",
"version": "0.2.66",
"description": "",
"main": "dist/index.js",
"author": "",
"license": "ISC",
"scripts": {
"prepublishOnly": "npm run build",
"build": "rimraf dist gen && typedoc && ts-node ./src/build.ts && tsc && npm link"
"build": "rimraf dist gen && typedoc && ts-node ./src/build.ts && tsc"
},
"devDependencies": {
"@types/rimraf": "^3.0.2",

View File

@@ -316,6 +316,7 @@ class DeviceCreatorSettings(TypedDict):
class DeviceInformation(TypedDict):
firmware: str
ip: str
mac: str
managementUrl: str
manufacturer: str
@@ -368,6 +369,7 @@ class FFmpegInput(TypedDict):
inputArguments: list[str]
mediaStreamOptions: ResponseMediaStreamOptions
url: str
urls: list[str]
videoDecoderArguments: list[str]
pass
@@ -536,7 +538,7 @@ class RequestMediaStreamOptions(TypedDict):
prebuffer: float
prebufferBytes: float
refresh: bool
route: Any | Any
route: Any | Any | Any
tool: MediaStreamTool
video: VideoStreamOptions
pass
@@ -564,7 +566,7 @@ class RequestRecordingStreamOptions(TypedDict):
prebuffer: float
prebufferBytes: float
refresh: bool
route: Any | Any
route: Any | Any | Any
startTime: float
tool: MediaStreamTool
video: VideoStreamOptions

View File

@@ -574,7 +574,7 @@ export interface RequestMediaStreamOptions extends MediaStreamOptions {
* such as an NVR or restreamers.
* An external route will request that that provided route is exposed to the local network.
*/
route?: 'external' | 'direct';
route?: 'external' | 'direct' | 'internal';
/**
* Specify the stream refresh behavior when this stream is requested.
@@ -1416,12 +1416,22 @@ export interface MediaManager {
*/
getFilesPath(): Promise<string>;
}
export interface MediaStreamUrl {
url: string;
export interface MediaContainer {
container?: string;
mediaStreamOptions?: ResponseMediaStreamOptions;
}
export interface FFmpegInput extends MediaStreamUrl {
export interface MediaStreamUrl extends MediaContainer {
url: string;
}
export interface FFmpegInput extends MediaContainer {
/**
* The media url for this FFmpegInput.
*/
url?: string;
/**
* Alternate media urls for this FFmpegInput.
*/
urls?: string[];
inputArguments?: string[];
destinationVideoBitrate?: number;
h264EncoderArguments?: string[];
@@ -1434,6 +1444,7 @@ export interface DeviceInformation {
version?: string;
firmware?: string;
serialNumber?: string;
ip?: string;
mac?: string;
metadata?: any;
managementUrl?: string;

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/server",
"version": "0.6.22",
"version": "0.6.24",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/server",
"version": "0.6.22",
"version": "0.6.24",
"license": "ISC",
"dependencies": {
"@ffmpeg-installer/ffmpeg": "^1.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/server",
"version": "0.6.23",
"version": "0.6.26",
"description": "",
"dependencies": {
"@ffmpeg-installer/ffmpeg": "^1.1.0",

View File

@@ -26,10 +26,14 @@ export async function listenZeroSingleClient(hostname?: string) {
clientPromise.catch(() => { });
let host = hostname;
if (!host || host === '0.0.0.0')
host = '127.0.0.1';
return {
server,
url: `tcp://127.0.0.1:${port}`,
host: '127.0.0.1',
url: `tcp://${host}:${port}`,
host,
port,
clientPromise,
}

View File

@@ -196,7 +196,7 @@ async function start() {
const checkToken = (token: string) => {
if (process.env.SCRYPTED_ADMIN_USERNAME && process.env.SCRYPTED_ADMIN_TOKEN === token) {
res.locals.username = process.env.SCRYPTED_ADMIN_USERNAME;
res.locals.aclId = process.env.SCRYPTED_ADMIN_TOKEN;
res.locals.aclId = undefined;
return;
}
@@ -550,8 +550,31 @@ async function start() {
await checkResetLogin();
const hostname = os.hostname()?.split('.')?.[0];
const addresses = ((await scrypted.addressSettings.getLocalAddresses()) || getHostAddresses(true, true)).map(address => `https://${address}:${SCRYPTED_SECURE_PORT}`);
// env/header based admin login
if (res.locals.username && res.locals.username === process.env.SCRYPTED_ADMIN_USERNAME) {
res.send({
username: res.locals.username,
token: process.env.SCRYPTED_ADMIN_TOKEN,
addresses,
hostname,
});
return;
}
// env based anon admin login
if (process.env.SCRYPTED_DISABLE_AUTHENTICATION === 'true') {
res.send({
expiration: ONE_DAY_MILLISECONDS,
username: 'anonymous',
addresses,
hostname,
})
return;
}
// basic auth
if (req.protocol === 'https' && req.headers.authorization) {
const username = await new Promise(resolve => {
const basicChecker = basicAuth.check((req) => {
@@ -576,16 +599,7 @@ async function start() {
return;
}
if (process.env.SCRYPTED_DISABLE_AUTHENTICATION === 'true') {
res.send({
expiration: ONE_DAY_MILLISECONDS,
username: 'anonymous',
addresses,
hostname,
})
return;
}
// cookie auth
try {
const login_user_token = getSignedLoginUserTokenRawValue(req);
if (!login_user_token)