mirror of
https://github.com/koush/scrypted.git
synced 2026-02-03 22:23:27 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6336407f15 | ||
|
|
38e3492137 | ||
|
|
255e426e2d | ||
|
|
fed1cf2a0d | ||
|
|
a5cb8c3fdc | ||
|
|
514d86144f | ||
|
|
21db7934c9 | ||
|
|
14e4b5c0e3 | ||
|
|
6478ad0411 | ||
|
|
81b7d432e9 | ||
|
|
dcbf5094f9 | ||
|
|
69ba181dfa | ||
|
|
88654275c1 | ||
|
|
62f28271ed | ||
|
|
0538130e78 | ||
|
|
95a3a16227 | ||
|
|
bfbf89ff69 | ||
|
|
e630589489 | ||
|
|
99d1e51f36 | ||
|
|
ab42ccd889 | ||
|
|
767af25aa0 | ||
|
|
7575dd82ce | ||
|
|
9307bbd09e | ||
|
|
68a9ec09e6 | ||
|
|
f8a548401f | ||
|
|
26d1f8e58c | ||
|
|
8772e25c8e | ||
|
|
378ac82c8c | ||
|
|
fcb1292ffd | ||
|
|
18112ee40f | ||
|
|
fa8b9dfe99 | ||
|
|
e7dff4edc9 | ||
|
|
7614d12363 | ||
|
|
3189317b2d | ||
|
|
410d11248f |
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
7
packages/cli/.vscode/launch.json
vendored
7
packages/cli/.vscode/launch.json
vendored
@@ -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": [
|
||||
|
||||
4
packages/cli/package-lock.json
generated
4
packages/cli/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "scrypted",
|
||||
"version": "1.0.57",
|
||||
"version": "1.0.58",
|
||||
"description": "",
|
||||
"main": "./dist/packages/cli/src/main.js",
|
||||
"bin": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
18
packages/client/package-lock.json
generated
18
packages/client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
4
plugins/amcrest/package-lock.json
generated
4
plugins/amcrest/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/amcrest",
|
||||
"version": "0.0.115",
|
||||
"version": "0.0.119",
|
||||
"description": "Amcrest Plugin for Scrypted",
|
||||
"author": "Scrypted",
|
||||
"license": "Apache",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
42
plugins/amcrest/src/probe.ts
Normal file
42
plugins/amcrest/src/probe.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
762
plugins/google-device-access/package-lock.json
generated
762
plugins/google-device-access/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
2942
plugins/google-device-access/pubsub-server/package-lock.json
generated
2942
plugins/google-device-access/pubsub-server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
4
plugins/hikvision/package-lock.json
generated
4
plugins/hikvision/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
34
plugins/hikvision/src/probe.ts
Normal file
34
plugins/hikvision/src/probe.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
4
plugins/onvif/package-lock.json
generated
4
plugins/onvif/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
4
plugins/prebuffer-mixin/package-lock.json
generated
4
plugins/prebuffer-mixin/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
4
plugins/remote/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.DS_Store
|
||||
out/
|
||||
node_modules/
|
||||
dist/
|
||||
10
plugins/remote/.npmignore
Normal file
10
plugins/remote/.npmignore
Normal 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
22
plugins/remote/.vscode/launch.json
vendored
Normal 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
4
plugins/remote/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
{
|
||||
"scrypted.debugHost": "127.0.0.1",
|
||||
}
|
||||
20
plugins/remote/.vscode/tasks.json
vendored
Normal file
20
plugins/remote/.vscode/tasks.json
vendored
Normal 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
3
plugins/remote/README.md
Normal 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
95
plugins/remote/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
plugins/remote/package.json
Normal file
39
plugins/remote/package.json
Normal 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
332
plugins/remote/src/main.ts
Normal 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();
|
||||
13
plugins/remote/tsconfig.json
Normal file
13
plugins/remote/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "ES2021",
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "Node16",
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
4
plugins/reolink/package-lock.json
generated
4
plugins/reolink/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/reolink",
|
||||
"version": "0.0.13",
|
||||
"version": "0.0.16",
|
||||
"description": "Reolink Plugin for Scrypted",
|
||||
"author": "Scrypted",
|
||||
"license": "Apache",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
23
plugins/reolink/src/probe.ts
Normal file
23
plugins/reolink/src/probe.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
96
plugins/ring/package-lock.json
generated
96
plugins/ring/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
4
plugins/snapshot/package-lock.json
generated
4
plugins/snapshot/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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(' ')}`));
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
Submodule sdk/developer.scrypted.app updated: c4724e2e90...62a1d3d9e7
4
sdk/package-lock.json
generated
4
sdk/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/sdk",
|
||||
"version": "0.2.69",
|
||||
"version": "0.2.71",
|
||||
"description": "",
|
||||
"main": "dist/src/index.js",
|
||||
"exports": {
|
||||
|
||||
4
sdk/types/package-lock.json
generated
4
sdk/types/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scrypted/server",
|
||||
"version": "0.6.23",
|
||||
"version": "0.6.26",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user