From fa8b9dfe9983c38e5bc4554e3811939d1e843802 Mon Sep 17 00:00:00 2001 From: Brett Jia Date: Sat, 25 Feb 2023 21:46:33 -0500 Subject: [PATCH] remote: Scrypted remote plugin (#585) * include implementation from standalone repo * simplify monkeypatching --- plugins/remote/.gitignore | 4 + plugins/remote/.npmignore | 10 + plugins/remote/.vscode/launch.json | 22 ++ plugins/remote/.vscode/settings.json | 4 + plugins/remote/.vscode/tasks.json | 20 ++ plugins/remote/README.md | 3 + plugins/remote/package-lock.json | 95 +++++++++ plugins/remote/package.json | 39 ++++ plugins/remote/src/main.ts | 307 +++++++++++++++++++++++++++ plugins/remote/tsconfig.json | 13 ++ 10 files changed, 517 insertions(+) create mode 100644 plugins/remote/.gitignore create mode 100644 plugins/remote/.npmignore create mode 100644 plugins/remote/.vscode/launch.json create mode 100644 plugins/remote/.vscode/settings.json create mode 100644 plugins/remote/.vscode/tasks.json create mode 100644 plugins/remote/README.md create mode 100644 plugins/remote/package-lock.json create mode 100644 plugins/remote/package.json create mode 100644 plugins/remote/src/main.ts create mode 100644 plugins/remote/tsconfig.json diff --git a/plugins/remote/.gitignore b/plugins/remote/.gitignore new file mode 100644 index 0000000000..9cdb546bf3 --- /dev/null +++ b/plugins/remote/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +out/ +node_modules/ +dist/ diff --git a/plugins/remote/.npmignore b/plugins/remote/.npmignore new file mode 100644 index 0000000000..55f4206fbe --- /dev/null +++ b/plugins/remote/.npmignore @@ -0,0 +1,10 @@ +.DS_Store +out/ +node_modules/ +*.map +fs +src +.vscode +dist/*.js +.venv +tsconfig.json diff --git a/plugins/remote/.vscode/launch.json b/plugins/remote/.vscode/launch.json new file mode 100644 index 0000000000..0669f79b4e --- /dev/null +++ b/plugins/remote/.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/remote/.vscode/settings.json b/plugins/remote/.vscode/settings.json new file mode 100644 index 0000000000..77ccdbd6db --- /dev/null +++ b/plugins/remote/.vscode/settings.json @@ -0,0 +1,4 @@ + +{ + "scrypted.debugHost": "127.0.0.1", +} \ No newline at end of file diff --git a/plugins/remote/.vscode/tasks.json b/plugins/remote/.vscode/tasks.json new file mode 100644 index 0000000000..4d922a5391 --- /dev/null +++ b/plugins/remote/.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/remote/README.md b/plugins/remote/README.md new file mode 100644 index 0000000000..d950ed5e83 --- /dev/null +++ b/plugins/remote/README.md @@ -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. \ No newline at end of file diff --git a/plugins/remote/package-lock.json b/plugins/remote/package-lock.json new file mode 100644 index 0000000000..36000f34b8 --- /dev/null +++ b/plugins/remote/package-lock.json @@ -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" + } + } + } +} diff --git a/plugins/remote/package.json b/plugins/remote/package.json new file mode 100644 index 0000000000..2ce722a396 --- /dev/null +++ b/plugins/remote/package.json @@ -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" + } +} diff --git a/plugins/remote/src/main.ts b/plugins/remote/src/main.ts new file mode 100644 index 0000000000..56c2312400 --- /dev/null +++ b/plugins/remote/src/main.ts @@ -0,0 +1,307 @@ +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(); + + 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.DeviceProvider, + ] + if (!allowedTypes.includes(device.type)) { + return null; + } + + // only permit the following interfaces through + const allowedInterfaces = [ + ScryptedInterface.Readme, + ScryptedInterface.VideoCamera, + ScryptedInterface.Camera, + ScryptedInterface.RTCSignalingChannel, + ScryptedInterface.Battery, + ScryptedInterface.MotionSensor, + ScryptedInterface.AudioSensor, + ScryptedInterface.DeviceProvider, + ScryptedInterface.ObjectDetector, + ]; + const intersection = allowedInterfaces.filter(i => device.interfaces.includes(i)); + if (intersection.length == 0) { + return null; + } + device.interfaces = intersection; + + 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 = (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 = (remoteDevice).getVideoStream; + (remoteDevice).getVideoStream = async (options?: RequestMediaStreamOptions): Promise => { + if (!options) { + options = {}; + } + (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; + (remoteDevice).getDevice = async (nativeId: string): Promise => { + return plugin.devices.get(nativeId); + } + (remoteDevice).releaseDevice = async (id: string, nativeId: string): Promise => { + // 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 { + 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 { + 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 { + return this.settingsStorage.getSettings(); + } + + putSetting(key: string, value: SettingValue): Promise { + return this.settingsStorage.putSetting(key, value); + } + + async discoverDevices(duration: number): Promise { + if (!this.client) { + return + } + + const state = this.client.systemManager.getSystemState(); + const devices = []; + 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({ + 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) + } + + const providerDeviceMap = new Map(); + 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); + } + }) + + await deviceManager.onDevicesChanged({ + devices: providerDeviceMap.get(this.nativeId), // first register the top level devices + providerNativeId: this.nativeId, + }); + for (let [providerNativeId, devices] of providerDeviceMap) { + await deviceManager.onDevicesChanged({ + devices, + providerNativeId, + }); + } + + devices.map(device => this.setupProxies(device, this.devices.get(device.nativeId))); + this.console.log(`Discovered ${devices.length} devices`); + } + + async getDevice(nativeId: string): Promise { + if (!this.devices.has(nativeId)) { + throw new Error(`${nativeId} does not exist`); + } + return this.devices.get(nativeId); + } + + async releaseDevice(id: string, nativeId: string): Promise { + this.devices.delete(nativeId) + } +} + +class ScryptedRemotePlugin extends ScryptedDeviceBase implements DeviceCreator, DeviceProvider { + remotes = new Map(); + + constructor() { + super(); + } + + async getDevice(nativeId: string): Promise { + 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 { + return; + } + + async getCreateDeviceSettings(): Promise { + 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 { + 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({ + 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(); diff --git a/plugins/remote/tsconfig.json b/plugins/remote/tsconfig.json new file mode 100644 index 0000000000..34a847ad82 --- /dev/null +++ b/plugins/remote/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2021", + "resolveJsonModule": true, + "moduleResolution": "Node16", + "esModuleInterop": true, + "sourceMap": true + }, + "include": [ + "src/**/*" + ] +} \ No newline at end of file