From 14660ccb63b060493ad92b41347ebef30f6c9d01 Mon Sep 17 00:00:00 2001 From: Koushik Dutta Date: Tue, 28 Dec 2021 20:39:47 -0800 Subject: [PATCH] readmes --- plugins/google-device-access/README.md | 2 +- .../google-device-access/package-lock.json | 4 +- plugins/google-device-access/package.json | 2 +- plugins/google-home/README.md | 23 +- plugins/google-home/package-lock.json | 4 +- plugins/google-home/package.json | 2 +- plugins/gstreamer-camera/.gitignore | 4 + plugins/gstreamer-camera/.npmignore | 8 + plugins/gstreamer-camera/.vscode/launch.json | 22 ++ .../gstreamer-camera/.vscode/settings.json | 4 + plugins/gstreamer-camera/.vscode/tasks.json | 20 ++ plugins/gstreamer-camera/README.md | 15 + plugins/gstreamer-camera/package-lock.json | 264 +++++++++++++++ plugins/gstreamer-camera/package.json | 40 +++ plugins/gstreamer-camera/src/common.ts | 292 +++++++++++++++++ plugins/gstreamer-camera/src/main.ts | 139 ++++++++ plugins/gstreamer-camera/src/recommend.ts | 7 + plugins/thermostat/src/main.ts | 310 ++++++++++++++++++ 18 files changed, 1144 insertions(+), 18 deletions(-) create mode 100644 plugins/gstreamer-camera/.gitignore create mode 100644 plugins/gstreamer-camera/.npmignore create mode 100644 plugins/gstreamer-camera/.vscode/launch.json create mode 100644 plugins/gstreamer-camera/.vscode/settings.json create mode 100644 plugins/gstreamer-camera/.vscode/tasks.json create mode 100644 plugins/gstreamer-camera/README.md create mode 100644 plugins/gstreamer-camera/package-lock.json create mode 100644 plugins/gstreamer-camera/package.json create mode 100644 plugins/gstreamer-camera/src/common.ts create mode 100644 plugins/gstreamer-camera/src/main.ts create mode 100644 plugins/gstreamer-camera/src/recommend.ts create mode 100644 plugins/thermostat/src/main.ts diff --git a/plugins/google-device-access/README.md b/plugins/google-device-access/README.md index 0f0fe4dde..c7b15757b 100644 --- a/plugins/google-device-access/README.md +++ b/plugins/google-device-access/README.md @@ -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) diff --git a/plugins/google-device-access/package-lock.json b/plugins/google-device-access/package-lock.json index 131d02e0d..d736c7e88 100644 --- a/plugins/google-device-access/package-lock.json +++ b/plugins/google-device-access/package-lock.json @@ -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", diff --git a/plugins/google-device-access/package.json b/plugins/google-device-access/package.json index 9dcf2e719..51d5ebdf2 100644 --- a/plugins/google-device-access/package.json +++ b/plugins/google-device-access/package.json @@ -43,5 +43,5 @@ "@types/node": "^14.17.11", "@types/url-parse": "^1.4.3" }, - "version": "0.0.42" + "version": "0.0.43" } diff --git a/plugins/google-home/README.md b/plugins/google-home/README.md index 5588840ae..decd6fb92 100644 --- a/plugins/google-home/README.md +++ b/plugins/google-home/README.md @@ -1,15 +1,16 @@ -# @scrypted/google-home +# Google Home Plugin for Scrypted -## npm commands - * npm run scrypted-webpack - * npm run scrypted-deploy - * npm run scrypted-debug +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. diff --git a/plugins/google-home/package-lock.json b/plugins/google-home/package-lock.json index a8580d5b0..c49e834a5 100644 --- a/plugins/google-home/package-lock.json +++ b/plugins/google-home/package-lock.json @@ -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", diff --git a/plugins/google-home/package.json b/plugins/google-home/package.json index 04a77247e..097998caa 100644 --- a/plugins/google-home/package.json +++ b/plugins/google-home/package.json @@ -42,5 +42,5 @@ "@types/lodash": "^4.14.168", "@types/url-parse": "^1.4.3" }, - "version": "0.0.30" + "version": "0.0.31" } diff --git a/plugins/gstreamer-camera/.gitignore b/plugins/gstreamer-camera/.gitignore new file mode 100644 index 000000000..9cdb546bf --- /dev/null +++ b/plugins/gstreamer-camera/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +out/ +node_modules/ +dist/ diff --git a/plugins/gstreamer-camera/.npmignore b/plugins/gstreamer-camera/.npmignore new file mode 100644 index 000000000..ff2824293 --- /dev/null +++ b/plugins/gstreamer-camera/.npmignore @@ -0,0 +1,8 @@ +.DS_Store +out/ +node_modules/ +*.map +fs +src +.vscode +dist/*.js diff --git a/plugins/gstreamer-camera/.vscode/launch.json b/plugins/gstreamer-camera/.vscode/launch.json new file mode 100644 index 000000000..0669f79b4 --- /dev/null +++ b/plugins/gstreamer-camera/.vscode/launch.json @@ -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": [ + "/**" + ], + "preLaunchTask": "scrypted: deploy+debug", + "sourceMaps": true, + "localRoot": "${workspaceFolder}/out", + "remoteRoot": "/plugin/", + "type": "pwa-node" + } + ] +} \ No newline at end of file diff --git a/plugins/gstreamer-camera/.vscode/settings.json b/plugins/gstreamer-camera/.vscode/settings.json new file mode 100644 index 000000000..c2da58593 --- /dev/null +++ b/plugins/gstreamer-camera/.vscode/settings.json @@ -0,0 +1,4 @@ + +{ + "scrypted.debugHost": "192.168.2.119", +} \ No newline at end of file diff --git a/plugins/gstreamer-camera/.vscode/tasks.json b/plugins/gstreamer-camera/.vscode/tasks.json new file mode 100644 index 000000000..4d922a539 --- /dev/null +++ b/plugins/gstreamer-camera/.vscode/tasks.json @@ -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}", + }, + ] +} diff --git a/plugins/gstreamer-camera/README.md b/plugins/gstreamer-camera/README.md new file mode 100644 index 000000000..688d48314 --- /dev/null +++ b/plugins/gstreamer-camera/README.md @@ -0,0 +1,15 @@ +# RTSP Cameras and Streams Plugin + +## npm commands + * npm run scrypted-webpack + * npm run scrypted-deploy + * npm run scrypted-debug + +## 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. diff --git a/plugins/gstreamer-camera/package-lock.json b/plugins/gstreamer-camera/package-lock.json new file mode 100644 index 000000000..ed1b87784 --- /dev/null +++ b/plugins/gstreamer-camera/package-lock.json @@ -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" + } + } + } +} diff --git a/plugins/gstreamer-camera/package.json b/plugins/gstreamer-camera/package.json new file mode 100644 index 000000000..44f3501db --- /dev/null +++ b/plugins/gstreamer-camera/package.json @@ -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" + } +} diff --git a/plugins/gstreamer-camera/src/common.ts b/plugins/gstreamer-camera/src/common.ts new file mode 100644 index 000000000..e98689fc0 --- /dev/null +++ b/plugins/gstreamer-camera/src/common.ts @@ -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 extends ScryptedDeviceBase implements Camera, VideoCamera, Settings { + snapshotAuth: AxiosDigestAuth; + pendingPicture: Promise; + + constructor(nativeId: string, public provider: CameraProviderBase) { + super(nativeId); + } + + getSnapshotUrl() { + return this.storage.getItem('snapshotUrl'); + } + + async takePicture(option?: PictureOptions): Promise { + if (!this.pendingPicture) { + this.pendingPicture = this.takePictureThrottled(option); + this.pendingPicture.finally(() => this.pendingPicture = undefined); + } + + return this.pendingPicture; + } + + async takePictureThrottled(option?: PictureOptions): Promise { + 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 { + 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 { + let vsos = this.getRawVideoStreamOptions(); + return this.getDefaultOrderedVideoStreamOptions(vsos); + } + + abstract getRawVideoStreamOptions(): T[]; + + isAudioDisabled() { + return this.storage.getItem('noAudio') === 'true'; + } + + async getVideoStream(options?: T): Promise { + 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; + + async getSnapshotUrlSettings(): Promise { + 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 { + return [ + ...await this.getSnapshotUrlSettings(), + ]; + } + + getUsername() { + return this.storage.getItem('username'); + } + + getPassword() { + return this.storage.getItem('password'); + } + + async getOtherSettings(): Promise { + 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 { + 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 { + 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 extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator { + devices = new Map(); + + constructor(nativeId?: string) { + super(nativeId); + + for (const camId of deviceManager.getNativeIds()) { + if (camId) + this.getDevice(camId); + } + + recommendRebroadcast(); + } + + async createDevice(settings: DeviceCreatorSettings): Promise { + const nativeId = randomBytes(4).toString('hex'); + const name = settings.newCamera.toString(); + await this.updateDevice(nativeId, name, this.getInterfaces()); + return nativeId; + } + + async getCreateDeviceSettings(): Promise { + 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; + + getDevice(nativeId: string) { + let ret = this.devices.get(nativeId); + if (!ret) { + ret = this.createCamera(nativeId); + if (ret) + this.devices.set(nativeId, ret); + } + return ret; + } +} diff --git a/plugins/gstreamer-camera/src/main.ts b/plugins/gstreamer-camera/src/main.ts new file mode 100644 index 000000000..cd5716632 --- /dev/null +++ b/plugins/gstreamer-camera/src/main.ts @@ -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 { + 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 { + 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 { + return [ + ...await this.getSnapshotUrlSettings(), + ...await this.getGStreamerInputSettings(), + ]; + } + + async createVideoStream(options?: MediaStreamOptions): Promise { + 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 { + createCamera(nativeId: string): GStreamerCamera { + return new GStreamerCamera(nativeId, this); + } +} + +export default new GStreamerProvider(); diff --git a/plugins/gstreamer-camera/src/recommend.ts b/plugins/gstreamer-camera/src/recommend.ts new file mode 100644 index 000000000..07110d5c8 --- /dev/null +++ b/plugins/gstreamer-camera/src/recommend.ts @@ -0,0 +1,7 @@ +import { alertRecommendedPlugins } from "@scrypted/common/src/alert-recommended-plugins"; + +export async function recommendRebroadcast() { + alertRecommendedPlugins({ + '@scrypted/prebuffer-mixin': 'Rebroadcast', + }); +} diff --git a/plugins/thermostat/src/main.ts b/plugins/thermostat/src/main.ts new file mode 100644 index 000000000..72a0abfed --- /dev/null +++ b/plugins/thermostat/src/main.ts @@ -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(localStorage.getItem('sensor')); +const heater = systemManager.getDeviceById(localStorage.getItem('heater')); +const cooler = systemManager.getDeviceById(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 { + 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;