This commit is contained in:
Koushik Dutta
2021-12-28 20:39:47 -08:00
parent 89a78e098b
commit 14660ccb63
18 changed files with 1144 additions and 18 deletions

View File

@@ -4,7 +4,7 @@ The Google Device Access Plugin allows you to import and control your Nest and G
## Setup
Fllow steps at the link below to create your personal Google Device Access developer account Google Cloud developer account:
Follow steps at the link below to create your personal Google Device Access developer account Google Cloud developer account:
* Google Device Access Project aka GDA ($5)
* Google Cloud Project aka GCP (might be within the free tier)

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/google-device-access",
"version": "0.0.42",
"version": "0.0.43",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/google-device-access",
"version": "0.0.42",
"version": "0.0.43",
"dependencies": {
"@googleapis/smartdevicemanagement": "^0.2.0",
"axios": "^0.21.1",

View File

@@ -43,5 +43,5 @@
"@types/node": "^14.17.11",
"@types/url-parse": "^1.4.3"
},
"version": "0.0.42"
"version": "0.0.43"
}

View File

@@ -1,15 +1,16 @@
# @scrypted/google-home
# Google Home Plugin for Scrypted
## npm commands
* npm run scrypted-webpack
* npm run scrypted-deploy <ipaddress>
* npm run scrypted-debug <ipaddress>
The Google Home Plugin lets you control your Scrypted devices from Google Assistant.
## scrypted distribution via npm
1. Ensure package.json is set up properly for publishing on npm.
2. npm publish
## Setup
## Visual Studio Code configuration
1. Install the Scrypted Cloud plugin (@scrypted/cloud).
2. Log in to Scrypted Cloud.
3. Install this plugin.
4. Open the Google Home app on your mobile device.
5. Add Device.
6. Click "Works with Google".
7. Search for "Scrypted".
8. Add Scrypted, and log in to Scrypted with the same account from the step 2.
* If using a remote server, edit [.vscode/settings.json](blob/master/.vscode/settings.json) to specify the IP Address of the Scrypted server.
* Launch Scrypted Debugger from the launch menu.
Your devices should now sync with Google Home.

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/google-home",
"version": "0.0.30",
"version": "0.0.31",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/google-home",
"version": "0.0.30",
"version": "0.0.31",
"dependencies": {
"@googleapis/homegraph": "^2.0.0",
"@homebridge/ciao": "^1.1.3",

View File

@@ -42,5 +42,5 @@
"@types/lodash": "^4.14.168",
"@types/url-parse": "^1.4.3"
},
"version": "0.0.30"
"version": "0.0.31"
}

4
plugins/gstreamer-camera/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,8 @@
.DS_Store
out/
node_modules/
*.map
fs
src
.vscode
dist/*.js

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
# RTSP Cameras and Streams Plugin
## npm commands
* npm run scrypted-webpack
* npm run scrypted-deploy <ipaddress>
* npm run scrypted-debug <ipaddress>
## scrypted distribution via npm
1. Ensure package.json is set up properly for publishing on npm.
2. npm publish
## Visual Studio Code configuration
* If using a remote server, edit [.vscode/settings.json](blob/master/.vscode/settings.json) to specify the IP Address of the Scrypted server.
* Launch Scrypted Debugger from the launch menu.

264
plugins/gstreamer-camera/package-lock.json generated Normal file
View File

@@ -0,0 +1,264 @@
{
"name": "@scrypted/rtsp",
"version": "0.0.3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/rtsp",
"version": "0.0.3",
"license": "Apache",
"dependencies": {
"@koush/axios-digest-auth": "^0.8.5",
"axios": "^0.23.0",
"url-parse": "^1.4.7"
},
"devDependencies": {
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"@types/node": "^16.9.6"
}
},
"../../common": {
"name": "@scrypted/common",
"version": "1.0.1",
"dev": true,
"license": "ISC",
"dependencies": {
"@scrypted/sdk": "file:../sdk",
"typescript": "^4.4.3"
},
"devDependencies": {
"@types/node": "^16.9.0"
}
},
"../../sdk": {
"name": "@scrypted/sdk",
"version": "0.0.123",
"dev": true,
"license": "ISC",
"dependencies": {
"@babel/plugin-proposal-class-properties": "^7.14.5",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.5",
"@babel/plugin-proposal-numeric-separator": "^7.14.5",
"@babel/plugin-proposal-optional-chaining": "^7.14.5",
"@babel/plugin-transform-modules-commonjs": "^7.15.4",
"@babel/plugin-transform-typescript": "^7.15.8",
"@babel/preset-typescript": "^7.15.0",
"@types/node": "^16.11.1",
"adm-zip": "^0.4.13",
"axios": "^0.21.4",
"babel-loader": "^8.2.3",
"babel-plugin-const-enum": "^1.1.0",
"esbuild": "^0.13.8",
"ncp": "^2.0.0",
"raw-loader": "^4.0.2",
"rimraf": "^3.0.2",
"stringify-object": "^3.3.0",
"tmp": "^0.2.1",
"ts-loader": "^9.2.6",
"typedoc": "^0.22.8",
"typescript-json-schema": "^0.50.1",
"webpack": "^5.59.0"
},
"bin": {
"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-readme": "bin/scrypted-readme.js",
"scrypted-webpack": "bin/scrypted-webpack.js"
},
"devDependencies": {
"@types/stringify-object": "^4.0.0",
"ts-node": "^10.4.0"
}
},
"../sdk": {
"extraneous": true
},
"node_modules/@koush/axios-digest-auth": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/@koush/axios-digest-auth/-/axios-digest-auth-0.8.5.tgz",
"integrity": "sha512-EZMM0gMJ3hMUD4EuUqSwP6UGt5Vmw2TZtY7Ypec55AnxkExSXM0ySgPtqkAcnL43g1R27yAg/dQL7dRTLMqO3Q==",
"dependencies": {
"auth-header": "^1.0.0",
"axios": "^0.21.4"
}
},
"node_modules/@koush/axios-digest-auth/node_modules/axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"dependencies": {
"follow-redirects": "^1.14.0"
}
},
"node_modules/@scrypted/common": {
"resolved": "../../common",
"link": true
},
"node_modules/@scrypted/sdk": {
"resolved": "../../sdk",
"link": true
},
"node_modules/@types/node": {
"version": "16.9.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.6.tgz",
"integrity": "sha512-YHUZhBOMTM3mjFkXVcK+WwAcYmyhe1wL4lfqNtzI0b3qAy7yuSetnM7QJazgE5PFmgVTNGiLOgRFfJMqW7XpSQ==",
"dev": true
},
"node_modules/auth-header": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/auth-header/-/auth-header-1.0.0.tgz",
"integrity": "sha512-CPPazq09YVDUNNVWo4oSPTQmtwIzHusZhQmahCKvIsk0/xH6U3QsMAv3sM+7+Q0B1K2KJ/Q38OND317uXs4NHA=="
},
"node_modules/axios": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.23.0.tgz",
"integrity": "sha512-NmvAE4i0YAv5cKq8zlDoPd1VLKAqX5oLuZKs8xkJa4qi6RGn0uhCYFjWtHHC9EM/MwOwYWOs53W+V0aqEXq1sg==",
"dependencies": {
"follow-redirects": "^1.14.4"
}
},
"node_modules/follow-redirects": {
"version": "1.14.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
"integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/querystringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.1.1.tgz",
"integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA=="
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
},
"node_modules/url-parse": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz",
"integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
}
},
"dependencies": {
"@koush/axios-digest-auth": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/@koush/axios-digest-auth/-/axios-digest-auth-0.8.5.tgz",
"integrity": "sha512-EZMM0gMJ3hMUD4EuUqSwP6UGt5Vmw2TZtY7Ypec55AnxkExSXM0ySgPtqkAcnL43g1R27yAg/dQL7dRTLMqO3Q==",
"requires": {
"auth-header": "^1.0.0",
"axios": "^0.21.4"
},
"dependencies": {
"axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"requires": {
"follow-redirects": "^1.14.0"
}
}
}
},
"@scrypted/common": {
"version": "file:../../common",
"requires": {
"@scrypted/sdk": "file:../sdk",
"@types/node": "^16.9.0",
"typescript": "^4.4.3"
}
},
"@scrypted/sdk": {
"version": "file:../../sdk",
"requires": {
"@babel/plugin-proposal-class-properties": "^7.14.5",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.5",
"@babel/plugin-proposal-numeric-separator": "^7.14.5",
"@babel/plugin-proposal-optional-chaining": "^7.14.5",
"@babel/plugin-transform-modules-commonjs": "^7.15.4",
"@babel/plugin-transform-typescript": "^7.15.8",
"@babel/preset-typescript": "^7.15.0",
"@types/node": "^16.11.1",
"@types/stringify-object": "^4.0.0",
"adm-zip": "^0.4.13",
"axios": "^0.21.4",
"babel-loader": "^8.2.3",
"babel-plugin-const-enum": "^1.1.0",
"esbuild": "^0.13.8",
"ncp": "^2.0.0",
"raw-loader": "^4.0.2",
"rimraf": "^3.0.2",
"stringify-object": "^3.3.0",
"tmp": "^0.2.1",
"ts-loader": "^9.2.6",
"ts-node": "^10.4.0",
"typedoc": "^0.22.8",
"typescript-json-schema": "^0.50.1",
"webpack": "^5.59.0"
}
},
"@types/node": {
"version": "16.9.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.6.tgz",
"integrity": "sha512-YHUZhBOMTM3mjFkXVcK+WwAcYmyhe1wL4lfqNtzI0b3qAy7yuSetnM7QJazgE5PFmgVTNGiLOgRFfJMqW7XpSQ==",
"dev": true
},
"auth-header": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/auth-header/-/auth-header-1.0.0.tgz",
"integrity": "sha512-CPPazq09YVDUNNVWo4oSPTQmtwIzHusZhQmahCKvIsk0/xH6U3QsMAv3sM+7+Q0B1K2KJ/Q38OND317uXs4NHA=="
},
"axios": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.23.0.tgz",
"integrity": "sha512-NmvAE4i0YAv5cKq8zlDoPd1VLKAqX5oLuZKs8xkJa4qi6RGn0uhCYFjWtHHC9EM/MwOwYWOs53W+V0aqEXq1sg==",
"requires": {
"follow-redirects": "^1.14.4"
}
},
"follow-redirects": {
"version": "1.14.4",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
"integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g=="
},
"querystringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.1.1.tgz",
"integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA=="
},
"requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
},
"url-parse": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz",
"integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==",
"requires": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
}
}
}

View File

@@ -0,0 +1,40 @@
{
"name": "@scrypted/gstreamer-camera",
"version": "0.0.3",
"description": "GStreamer Camera Plugin for Scrypted",
"author": "Scrypted",
"license": "Apache",
"scripts": {
"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",
"scrypted-webpack": "scrypted-webpack"
},
"keywords": [
"scrypted",
"plugin",
"gstreamer",
"camera"
],
"scrypted": {
"name": "GStreamer Camera Plugin",
"type": "DeviceProvider",
"interfaces": [
"DeviceProvider",
"DeviceCreator"
]
},
"devDependencies": {
"@scrypted/common": "file:../../common",
"@scrypted/sdk": "file:../../sdk",
"@types/node": "^16.9.6"
},
"dependencies": {
"url-parse": "^1.4.7"
}
}

View File

@@ -0,0 +1,292 @@
import sdk, { ScryptedDeviceBase, DeviceProvider, Settings, Setting, ScryptedDeviceType, VideoCamera, MediaObject, MediaStreamOptions, ScryptedInterface, FFMpegInput, Camera, PictureOptions, SettingValue, DeviceCreator, DeviceCreatorSettings } from "@scrypted/sdk";
import { recommendRebroadcast } from "./recommend";
import AxiosDigestAuth from '@koush/axios-digest-auth';
import https from 'https';
import { randomBytes } from "crypto";
const { log, deviceManager, mediaManager } = sdk;
const httpsAgent = new https.Agent({
rejectUnauthorized: false
});
export interface UrlMediaStreamOptions extends MediaStreamOptions {
url: string;
}
export abstract class CameraBase<T extends MediaStreamOptions> extends ScryptedDeviceBase implements Camera, VideoCamera, Settings {
snapshotAuth: AxiosDigestAuth;
pendingPicture: Promise<MediaObject>;
constructor(nativeId: string, public provider: CameraProviderBase<T>) {
super(nativeId);
}
getSnapshotUrl() {
return this.storage.getItem('snapshotUrl');
}
async takePicture(option?: PictureOptions): Promise<MediaObject> {
if (!this.pendingPicture) {
this.pendingPicture = this.takePictureThrottled(option);
this.pendingPicture.finally(() => this.pendingPicture = undefined);
}
return this.pendingPicture;
}
async takePictureThrottled(option?: PictureOptions): Promise<MediaObject> {
const snapshotUrl = this.getSnapshotUrl();
if (!snapshotUrl) {
throw new Error('Camera has no snapshot URL');
}
if (!this.snapshotAuth) {
this.snapshotAuth = new AxiosDigestAuth({
username: this.getUsername(),
password: this.getPassword(),
});
}
const response = await this.snapshotAuth.request({
httpsAgent,
method: "GET",
responseType: 'arraybuffer',
url: snapshotUrl,
});
return mediaManager.createMediaObject(Buffer.from(response.data), response.headers['Content-Type'] || 'image/jpeg');
}
async getPictureOptions(): Promise<PictureOptions[]> {
return;
}
getDefaultOrderedVideoStreamOptions(vsos: T[]) {
if (!vsos || !vsos.length)
return vsos;
const defaultStream = this.getDefaultStream(vsos);
if (!defaultStream)
return vsos;
vsos = vsos.filter(vso => vso.id !== defaultStream?.id);
vsos.unshift(defaultStream);
return vsos;
}
async getVideoStreamOptions(): Promise<T[]> {
let vsos = this.getRawVideoStreamOptions();
return this.getDefaultOrderedVideoStreamOptions(vsos);
}
abstract getRawVideoStreamOptions(): T[];
isAudioDisabled() {
return this.storage.getItem('noAudio') === 'true';
}
async getVideoStream(options?: T): Promise<MediaObject> {
const vsos = await this.getVideoStreamOptions();
const vso = vsos?.find(s => s.id === options?.id) || this.getDefaultStream(vsos);
return this.createVideoStream(vso);
}
abstract createVideoStream(options?: T): Promise<MediaObject>;
async getSnapshotUrlSettings(): Promise<Setting[]> {
return [
{
key: 'snapshotUrl',
title: 'Snapshot URL',
placeholder: 'http://192.168.1.100[:80]/snapshot.jpg',
value: this.getSnapshotUrl(),
description: 'Optional: The snapshot URL that will returns the current JPEG image.'
},
];
}
async getUrlSettings(): Promise<Setting[]> {
return [
...await this.getSnapshotUrlSettings(),
];
}
getUsername() {
return this.storage.getItem('username');
}
getPassword() {
return this.storage.getItem('password');
}
async getOtherSettings(): Promise<Setting[]> {
return [];
}
getDefaultStream(vsos: T[]) {
let defaultStreamIndex = vsos?.findIndex(vso => vso.id === this.storage.getItem('defaultStream'));
if (defaultStreamIndex === -1)
defaultStreamIndex = 0;
defaultStreamIndex = defaultStreamIndex || 0;
return vsos?.[defaultStreamIndex];
}
async getStreamSettings(): Promise<Setting[]> {
try {
const vsos = await this.getVideoStreamOptions();
if (!vsos?.length || vsos?.length === 1)
return [];
const defaultStream = this.getDefaultStream(vsos);
return [
{
title: 'Default Stream',
key: 'defaultStream',
value: defaultStream?.name,
choices: vsos.map(vso => vso.name),
description: 'The default stream to use when not specified',
}
];
}
catch (e) {
return [];
}
}
getUsernameDescription(): string {
return 'Optional: Username for snapshot http requests.';
}
getPasswordDescription(): string {
return 'Optional: Password for snapshot http requests.';
}
async getSettings(): Promise<Setting[]> {
return [
{
key: 'username',
title: 'Username',
value: this.getUsername(),
description: this.getUsernameDescription(),
},
{
key: 'password',
title: 'Password',
value: this.getPassword(),
type: 'password',
description: this.getPasswordDescription(),
},
...await this.getUrlSettings(),
...await this.getStreamSettings(),
...await this.getOtherSettings(),
{
key: 'noAudio',
title: 'No Audio',
description: 'Enable this setting if the camera does not have audio or to mute audio.',
type: 'boolean',
value: (this.isAudioDisabled()).toString(),
},
];
}
async putSettingBase(key: string, value: SettingValue) {
if (key === 'defaultStream') {
const vsos = await this.getVideoStreamOptions();
const stream = vsos.find(vso => vso.name === value);
this.storage.setItem('defaultStream', stream?.id);
}
else {
this.storage.setItem(key, value.toString());
}
this.snapshotAuth = undefined;
this.onDeviceEvent(ScryptedInterface.Settings, undefined);
}
async putSetting(key: string, value: SettingValue) {
this.putSettingBase(key, value);
if (key === 'snapshotUrl') {
let interfaces = this.providedInterfaces;
if (!value)
interfaces = interfaces.filter(iface => iface !== ScryptedInterface.Camera)
else
interfaces.push(ScryptedInterface.Camera);
this.provider.updateDevice(this.nativeId, this.providedName, interfaces);
}
}
}
export abstract class CameraProviderBase<T extends MediaStreamOptions> extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
devices = new Map<string, any>();
constructor(nativeId?: string) {
super(nativeId);
for (const camId of deviceManager.getNativeIds()) {
if (camId)
this.getDevice(camId);
}
recommendRebroadcast();
}
async createDevice(settings: DeviceCreatorSettings): Promise<string> {
const nativeId = randomBytes(4).toString('hex');
const name = settings.newCamera.toString();
await this.updateDevice(nativeId, name, this.getInterfaces());
return nativeId;
}
async getCreateDeviceSettings(): Promise<Setting[]> {
return [
{
key: 'newCamera',
title: 'Add Camera',
placeholder: 'Camera name, e.g.: Back Yard Camera, Baby Camera, etc',
}
]
}
getAdditionalInterfaces(): string[] {
return [
];
}
getInterfaces() {
return [ScryptedInterface.VideoCamera,
ScryptedInterface.Settings, ...this.getAdditionalInterfaces()];
}
updateDevice(nativeId: string, name: string, interfaces: string[], type?: ScryptedDeviceType) {
return deviceManager.onDeviceDiscovered({
nativeId,
name,
interfaces,
type: type || ScryptedDeviceType.Camera,
});
}
async putSetting(key: string, value: string | number) {
// generate a random id
const nativeId = randomBytes(4).toString('hex');
const name = value.toString();
this.updateDevice(nativeId, name, this.getInterfaces());
}
abstract createCamera(nativeId: string): CameraBase<T>;
getDevice(nativeId: string) {
let ret = this.devices.get(nativeId);
if (!ret) {
ret = this.createCamera(nativeId);
if (ret)
this.devices.set(nativeId, ret);
}
return ret;
}
}

View File

@@ -0,0 +1,139 @@
import sdk, { FFMpegInput, MediaObject, MediaStreamOptions, Setting, SettingValue } from "@scrypted/sdk";
import child_process from "child_process";
import { CameraProviderBase, CameraBase, UrlMediaStreamOptions } from "./common";
// import {} from "../../../common/src/stream-parser"
// import {} from "../../../common/src/ffmpeg-rebroadcast"
import net from 'net';
import {listenZeroCluster} from "../../../common/src/listen-cluster"
const { log, deviceManager, mediaManager } = sdk;
class GStreamerCamera extends CameraBase<MediaStreamOptions> {
createGStreamerMediaStreamOptions(gstreamerInput: string, index: number): MediaStreamOptions {
return {
id: `channel${index}`,
name: `Stream ${index + 1}`,
video: {
},
audio: this.isAudioDisabled() ? null : {},
};
}
getGStreamerInputs() {
let gstreamerInputs: string[] = [];
try {
gstreamerInputs = JSON.parse(this.storage.getItem('gstreamerInputs'));
}
catch (e) {
}
return gstreamerInputs;
}
getRawVideoStreamOptions(): MediaStreamOptions[] {
const gstreamerInputs = this.getGStreamerInputs();
// filter out empty strings.
const ret = gstreamerInputs
.filter(gstreamerInput => !!gstreamerInput)
.map((gstreamerInput, index) => this.createGStreamerMediaStreamOptions(gstreamerInput, index));
if (!ret.length)
return;
return ret;
}
async getGStreamerInputSettings(): Promise<Setting[]> {
return [
{
key: 'gstreamerInputs',
title: 'GStreamer Input Stream Arguments',
description: 'GStreamer input arguments passed to the command line gst-launch-1.0 tool. A camera may have multiple streams with different bitrates.',
placeholder: '-i rtmp://[user:password@]192.168.1.100[:1935]/channel/101',
value: this.getGStreamerInputs(),
multiple: true,
},
];
}
async putSettingBase(key: string, value: SettingValue) {
if (key === 'gstreamerInputs') {
this.putGStreamerInputs(value as string[]);
}
else {
super.putSettingBase(key, value);
}
}
async putGStreamerInputs(gstreamerInputs: string[]) {
this.storage.setItem('gstreamerInputs', JSON.stringify(gstreamerInputs.filter(url => !!url)));
}
async getUrlSettings(): Promise<Setting[]> {
return [
...await this.getSnapshotUrlSettings(),
...await this.getGStreamerInputSettings(),
];
}
async createVideoStream(options?: MediaStreamOptions): Promise<MediaObject> {
const index = this.getRawVideoStreamOptions()?.findIndex(vso => vso.id === options.id);
const gstreamerInputs = this.getGStreamerInputs();
const gstreamerInput = gstreamerInputs[index];
if (!gstreamerInput)
throw new Error('video streams not set up or no longer exists.');
const server = net.createServer(async (clientSocket) => {
clearTimeout(serverTimeout);
server.close();
const gstreamerServer = net.createServer(gstreamerSocket => {
clearTimeout(gstreamerTimeout);
gstreamerServer.close();
clientSocket.pipe(gstreamerSocket).pipe(clientSocket);
});
const gstreamerTimeout = setTimeout(() => {
this.console.log('timed out waiting for gstreamer');
gstreamerServer.close();
}, 30000);
const gstreamerPort = await listenZeroCluster(gstreamerServer);
const args = gstreamerInput.split(' ');
args.push('!', 'mpegtsmux', '!', 'tcpclientsink', `port=${gstreamerPort}`, 'sync=false');
this.console.log(args);
const cp = child_process.spawn('gst-launch-1.0', args);
cp.stdout.on('data', data => this.console.log(data.toString()));
cp.stderr.on('data', data => this.console.log(data.toString()));
clientSocket.on('close', () => cp.kill());
});
const serverTimeout = setTimeout(() => {
this.console.log('timed out waiting for client');
server.close();
}, 30000);
const port = await listenZeroCluster(server);
const ret: FFMpegInput = {
url: undefined,
inputArguments: [
'-f',
'mpegts',
'-i',
`tcp://127.0.0.1:${port}`
],
mediaStreamOptions: options,
};
return mediaManager.createFFmpegMediaObject(ret);
}
}
class GStreamerProvider extends CameraProviderBase<MediaStreamOptions> {
createCamera(nativeId: string): GStreamerCamera {
return new GStreamerCamera(nativeId, this);
}
}
export default new GStreamerProvider();

View File

@@ -0,0 +1,7 @@
import { alertRecommendedPlugins } from "@scrypted/common/src/alert-recommended-plugins";
export async function recommendRebroadcast() {
alertRecommendedPlugins({
'@scrypted/prebuffer-mixin': 'Rebroadcast',
});
}

View File

@@ -0,0 +1,310 @@
import sdk, { HumidityCommand, HumidityMode, HumiditySensor, HumiditySetting, OnOff, ScryptedDeviceBase, TemperatureSetting, TemperatureUnit, Thermometer, ThermostatMode } from '@scrypted/sdk';
const { deviceManager, log, systemManager } = sdk;
const sensor = systemManager.getDeviceById<Thermometer & HumiditySensor>(localStorage.getItem('sensor'));
const heater = systemManager.getDeviceById<OnOff>(localStorage.getItem('heater'));
const cooler = systemManager.getDeviceById<OnOff>(localStorage.getItem('cooler'));
class ThermostatDevice extends ScryptedDeviceBase implements TemperatureSetting, Thermometer, HumiditySensor, HumiditySetting {
constructor() {
super();
this.temperature = sensor.temperature;
// copy the current state from the sensor.
var unit;
if (unit = localStorage.getItem('temperatureUnit')) {
this.temperatureUnit = unit === 'F' ? TemperatureUnit.F : TemperatureUnit.C;
}
else {
log.a('Please specify temperatureUnit C or F in Script Settings.');
this.temperatureUnit = sensor.temperatureUnit;
}
this.humidity = sensor.humidity;
this.humiditySetting = {
mode: HumidityMode.Off,
setpoint: 50,
activeMode: HumidityMode.Off,
availableModes: [HumidityMode.Auto, HumidityMode.Humidify, HumidityMode.Dehumidify],
};
var modes: ThermostatMode[] = [];
modes.push(ThermostatMode.Off);
if (cooler) {
modes.push(ThermostatMode.Cool);
}
if (heater) {
modes.push(ThermostatMode.Heat);
}
if (heater && cooler) {
modes.push(ThermostatMode.HeatCool);
}
modes.push(ThermostatMode.On);
this.thermostatAvailableModes = modes;
try {
if (!this.thermostatMode) {
this.thermostatMode = ThermostatMode.Off;
}
}
catch (e) {
}
}
async setHumidity(humidity: HumidityCommand): Promise<void> {
this.humiditySetting = {
mode: humidity.mode,
setpoint: 50,
activeMode: HumidityMode.Off,
availableModes: [HumidityMode.Auto, HumidityMode.Humidify, HumidityMode.Dehumidify],
}
}
// whenever the temperature changes, or a new command is sent, this updates the current state accordingly.
updateState() {
var threshold = 2;
var thermostatMode = this.thermostatMode || ThermostatMode.Off;
if (!thermostatMode) {
log.e('thermostat mode not set');
return;
}
// this holds the last known state of the thermostat.
// ie, what it decided to do, the last time it updated its state.
var thermostatState = localStorage.getItem('thermostatState');
// set the state before turning any devices on or off.
// on/off events will need to be resolved by looking at the state to
// determine if it is manual user input.
function setState(state) {
if (state == thermostatState) {
// log.i('Thermostat state unchanged. ' + state)
return;
}
log.i('Thermostat state changed. ' + state);
localStorage.setItem('thermostatState', state);
}
function manageSetpoint(temperatureDifference, er, other, ing, ed) {
if (!er) {
log.e('Thermostat mode set to ' + thermostatMode + ', but ' + thermostatMode + 'er variable is not defined.');
return;
}
// turn off the other one. if heating, turn off cooler. if cooling, turn off heater.
if (other && other.on) {
other.turnOff();
}
if (temperatureDifference < 0) {
setState(ed);
if (er.on) {
er.turnOff();
}
return;
}
// start cooling/heating if way over threshold, or if it is not in the cooling/heating state
if (temperatureDifference > threshold || thermostatState != ing) {
setState(ing);
if (!er.on) {
er.turnOn();
}
return;
}
setState(ed);
if (er.on) {
er.turnOff();
}
}
function allOff() {
if (heater && heater.on) {
heater.turnOff();
}
if (cooler && cooler.on) {
cooler.turnOff();
}
}
if (thermostatMode == 'Off') {
setState('Off');
allOff();
return;
} else if (thermostatMode == 'Cool') {
let thermostatSetpoint = this.thermostatSetpoint || sensor.temperature;
if (!thermostatSetpoint) {
log.e('No thermostat setpoint is defined.');
return;
}
var temperatureDifference = sensor.temperature - thermostatSetpoint;
manageSetpoint(temperatureDifference, cooler, heater, 'Cooling', 'Cooled');
return;
} else if (thermostatMode == 'Heat') {
let thermostatSetpoint = this.thermostatSetpoint || sensor.temperature;
if (!thermostatSetpoint) {
log.e('No thermostat setpoint is defined.');
return;
}
var temperatureDifference = thermostatSetpoint - sensor.temperature;
manageSetpoint(temperatureDifference, heater, cooler, 'Heating', 'Heated');
return;
} else if (thermostatMode == 'HeatCool') {
var temperature = sensor.temperature;
var thermostatSetpointLow = this.thermostatSetpointLow || sensor.temperature;
var thermostatSetpointHigh = this.thermostatSetpointHigh || sensor.temperature;
if (!thermostatSetpointLow || !thermostatSetpointHigh) {
log.e('No thermostat setpoint low/high is defined.');
return;
}
// see if this is within HeatCool tolerance. This prevents immediately cooling after heating all the way to the high setpoint.
if ((thermostatState == 'HeatCooled' || thermostatState == 'Heated' || thermostatState == 'Cooled')
&& temperature > thermostatSetpointLow - threshold
&& temperature < thermostatSetpointHigh + threshold) {
// normalize the state into HeatCooled
setState('HeatCooled');
allOff();
return;
}
// if already heating or cooling or way out of tolerance, continue doing it until state changes.
if (temperature < thermostatSetpointLow || thermostatState == 'Heating') {
var temperatureDifference = thermostatSetpointHigh - temperature;
manageSetpoint(temperatureDifference, heater, null, 'Heating', 'Heated');
return;
} else if (temperature > thermostatSetpointHigh || thermostatState == 'Cooling') {
var temperatureDifference = temperature - thermostatSetpointLow;
manageSetpoint(temperatureDifference, cooler, null, 'Cooling', 'Cooled');
return;
}
// temperature is within tolerance, so this is now HeatCooled
setState('HeatCooled');
allOff();
return;
}
log.e('Unknown mode ' + thermostatMode);
}
// implementation of TemperatureSetting
async setThermostatSetpoint(thermostatSetpoint) {
log.i('thermostatSetpoint changed ' + thermostatSetpoint);
this.thermostatSetpoint = thermostatSetpoint;
this.updateState();
}
async setThermostatSetpointLow(thermostatSetpointLow) {
log.i('thermostatSetpointLow changed ' + thermostatSetpointLow);
this.thermostatSetpointLow = thermostatSetpointLow;
this.updateState();
}
async setThermostatSetpointHigh(thermostatSetpointHigh) {
log.i('thermostatSetpointHigh changed ' + thermostatSetpointHigh);
this.thermostatSetpointHigh = thermostatSetpointHigh;
this.updateState();
}
async setThermostatMode(mode) {
log.i('thermostat mode set to ' + mode);
if (mode == 'On') {
mode = localStorage.getItem("lastThermostatMode");
}
else if (mode != 'Off') {
localStorage.setItem("lastThermostatMode", mode);
}
this.thermostatMode = mode;
this.updateState();
}
// end implementation of TemperatureSetting
// If the heater or cooler gets turned on or off manually (or programatically),
// make this resolve with the current state. This relies on the state being set
// before any devices are turned on or off (as mentioned above) to avoid race
// conditions.
manageEvent(on, ing) {
var state = localStorage.getItem('thermostatState');
if (on) {
// on implies it must be heating/cooling
if (state != ing) {
// should this be Heat/Cool?
this.setThermostatMode('On');
return;
}
return;
}
// off implies that it must NOT be heating/cooling
if (state == ing) {
this.setThermostatMode('Off');
return;
}
}
}
var thermostatDevice = new ThermostatDevice();
function alertAndThrow(msg) {
log.a(msg);
throw new Error(msg);
}
try {
if (!sensor)
throw new Error();
}
catch {
alertAndThrow('Setup Incomplete: Assign a thermometer and humidity sensor to the "sensor" variable.');
}
log.clearAlerts();
if (!heater && !cooler) {
alertAndThrow('Setup Incomplete: Assign an OnOff device to the "heater" and/or "cooler" OnOff variables.');
}
log.clearAlerts();
// register to listen for temperature change events
sensor.listen('Thermometer', function(source, event, data) {
thermostatDevice[event.property] = data;
if (event.property == 'temperature') {
log.i('temperature event: ' + data);
thermostatDevice.updateState();
}
});
// listen to humidity events too, and pass those along
sensor.listen('HumiditySensor', function(source, event, data) {
thermostatDevice[event.property] = data;
});
// Watch for on/off events, some of them may be physical
// button presses, and those will need to be resolved by
// checking the state versus the event.
if (heater) {
heater.listen('OnOff', function(source, event, on) {
thermostatDevice.manageEvent(on, 'Heating');
});
}
if (cooler) {
cooler.listen('OnOff', function(source, event, on) {
thermostatDevice.manageEvent(on, 'Cooling');
});
}
export default thermostatDevice;