From ba2ef85873cc8f17f7919a8763278e9359b98204 Mon Sep 17 00:00:00 2001 From: Matthew Lieder Date: Wed, 3 Nov 2021 22:19:55 -0500 Subject: [PATCH] Plugin for Synology Surveillance Station --- plugins/synology-ss/.gitignore | 4 + plugins/synology-ss/.npmignore | 6 + plugins/synology-ss/.vscode/launch.json | 22 ++ plugins/synology-ss/.vscode/settings.json | 4 + plugins/synology-ss/.vscode/tasks.json | 20 ++ plugins/synology-ss/README.md | 15 + plugins/synology-ss/package-lock.json | 135 +++++++++ plugins/synology-ss/package.json | 39 +++ .../src/api/synology-api-client.ts | 188 ++++++++++++ plugins/synology-ss/src/main.ts | 283 ++++++++++++++++++ 10 files changed, 716 insertions(+) create mode 100644 plugins/synology-ss/.gitignore create mode 100644 plugins/synology-ss/.npmignore create mode 100644 plugins/synology-ss/.vscode/launch.json create mode 100644 plugins/synology-ss/.vscode/settings.json create mode 100644 plugins/synology-ss/.vscode/tasks.json create mode 100644 plugins/synology-ss/README.md create mode 100644 plugins/synology-ss/package-lock.json create mode 100644 plugins/synology-ss/package.json create mode 100644 plugins/synology-ss/src/api/synology-api-client.ts create mode 100644 plugins/synology-ss/src/main.ts diff --git a/plugins/synology-ss/.gitignore b/plugins/synology-ss/.gitignore new file mode 100644 index 000000000..9cdb546bf --- /dev/null +++ b/plugins/synology-ss/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +out/ +node_modules/ +dist/ diff --git a/plugins/synology-ss/.npmignore b/plugins/synology-ss/.npmignore new file mode 100644 index 000000000..9f0b27ca4 --- /dev/null +++ b/plugins/synology-ss/.npmignore @@ -0,0 +1,6 @@ +out/ +*.map +fs +src +.vscode +dist/*.js diff --git a/plugins/synology-ss/.vscode/launch.json b/plugins/synology-ss/.vscode/launch.json new file mode 100644 index 000000000..0669f79b4 --- /dev/null +++ b/plugins/synology-ss/.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/synology-ss/.vscode/settings.json b/plugins/synology-ss/.vscode/settings.json new file mode 100644 index 000000000..f5aba09f8 --- /dev/null +++ b/plugins/synology-ss/.vscode/settings.json @@ -0,0 +1,4 @@ + +{ + "scrypted.debugHost": "127.0.0.1" +} \ No newline at end of file diff --git a/plugins/synology-ss/.vscode/tasks.json b/plugins/synology-ss/.vscode/tasks.json new file mode 100644 index 000000000..4d922a539 --- /dev/null +++ b/plugins/synology-ss/.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/synology-ss/README.md b/plugins/synology-ss/README.md new file mode 100644 index 000000000..560b33a9b --- /dev/null +++ b/plugins/synology-ss/README.md @@ -0,0 +1,15 @@ +# @scrypted/synology-ss + +## 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/synology-ss/package-lock.json b/plugins/synology-ss/package-lock.json new file mode 100644 index 000000000..4e736e287 --- /dev/null +++ b/plugins/synology-ss/package-lock.json @@ -0,0 +1,135 @@ +{ + "name": "@scrypted/synology-ss", + "version": "0.0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@scrypted/synology-ss", + "version": "0.0.1", + "license": "Apache", + "dependencies": { + "axios": "^0.24.0" + }, + "devDependencies": { + "@scrypted/sdk": "file:../../sdk", + "@types/node": "^16.6.1" + } + }, + "../../sdk": { + "name": "@scrypted/sdk", + "version": "0.0.93", + "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", + "raw-loader": "^4.0.2", + "rimraf": "^3.0.2", + "ts-loader": "^9.2.6", + "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" + } + }, + "node_modules/@scrypted/sdk": { + "resolved": "../../sdk", + "link": true + }, + "node_modules/@types/node": { + "version": "16.11.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz", + "integrity": "sha512-ua7PgUoeQFjmWPcoo9khiPum3Pd60k4/2ZGXt18sm2Slk0W0xZTqt5Y0Ny1NyBiN1EVQ/+FaF9NcY4Qe6rwk5w==", + "dev": true + }, + "node_modules/axios": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", + "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", + "dependencies": { + "follow-redirects": "^1.14.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz", + "integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + } + }, + "dependencies": { + "@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", + "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", + "raw-loader": "^4.0.2", + "rimraf": "^3.0.2", + "ts-loader": "^9.2.6", + "typescript-json-schema": "^0.50.1", + "webpack": "^5.59.0" + } + }, + "@types/node": { + "version": "16.11.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.6.tgz", + "integrity": "sha512-ua7PgUoeQFjmWPcoo9khiPum3Pd60k4/2ZGXt18sm2Slk0W0xZTqt5Y0Ny1NyBiN1EVQ/+FaF9NcY4Qe6rwk5w==", + "dev": true + }, + "axios": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", + "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", + "requires": { + "follow-redirects": "^1.14.4" + } + }, + "follow-redirects": { + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz", + "integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==" + } + } +} diff --git a/plugins/synology-ss/package.json b/plugins/synology-ss/package.json new file mode 100644 index 000000000..c8f5f15a6 --- /dev/null +++ b/plugins/synology-ss/package.json @@ -0,0 +1,39 @@ +{ + "name": "@scrypted/synology-ss", + "version": "0.0.1", + "description": "A Synology Surveillance Station 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", + "synology", + "surveillance station" + ], + "scrypted": { + "name": "Synology Surveillance Station", + "type": "DeviceProvider", + "interfaces": [ + "DeviceProvider", + "Settings" + ] + }, + "dependencies": { + "axios": "^0.24.0" + }, + "devDependencies": { + "@scrypted/sdk": "file:../../sdk", + "@types/node": "^16.6.1" + } +} diff --git a/plugins/synology-ss/src/api/synology-api-client.ts b/plugins/synology-ss/src/api/synology-api-client.ts new file mode 100644 index 000000000..1de7df8a6 --- /dev/null +++ b/plugins/synology-ss/src/api/synology-api-client.ts @@ -0,0 +1,188 @@ +import axios, { AxiosInstance } from 'axios'; + +const errorCodeDescriptions = { + '100': 'Unknown error', + '101': 'Invalid parameters', + '102': 'API does not exist', + '103': 'Method does not exist', + '104': 'This API version is not supported', + '105': 'Insufficient user privilege', + '106': 'Connection time out', + '107': 'Multiple login detected' +} + +export class SynologyApiClient { + private apiInfo: Promise>; + private readonly client: AxiosInstance; + + public readonly url: string; + + constructor(url: string) { + this.url = url; + this.client = axios.create({ + baseURL: `${url}/webapi/`, + timeout: 10000, + }); + + // Fetch info about API method paths and versions in the background + this.apiInfo = this.queryApiInfo(); + } + + public async getCameraLiveViewPath(cameraIds: string[]): Promise { + const params = { + api: 'SYNO.SurveillanceStation.Camera', + version: 9, + method: 'GetLiveViewPath', + idList: cameraIds.join(',') + }; + + return await this.sendRequest(params); + } + + public async getCameraSnapshot(cameraId: number | string) { + const params = { + api: 'SYNO.SurveillanceStation.Camera', + version: 9, + method: 'GetSnapshot', + id: cameraId + }; + + const response = await this.client.get(await this.getApiPath(params.api), { params, responseType: 'arraybuffer' }); + + return response.data; + } + + public async listCameras(): Promise { + const params = { + api: 'SYNO.SurveillanceStation.Camera', + version: 9, + method: 'List', + privCamType: 1, + streamInfo: true, + basic: true + }; + + const response = await this.sendRequest(params); + + return response.cameras; + } + + public async login(account: string, password: string): Promise { + const params = { + api: 'SYNO.API.Auth', + version: 6, + method: 'login', + session: 'SurveillanceStation', + account: account, + passwd: password + }; + + const errorCodeDescs = { + '400': 'Invalid password', + '401': 'Guest or disabled account', + '402': 'Permission denied', + '403': 'One time password not specified', + '404': 'One time password authenticate failed', + '405': 'App portal incorrect', + '406': 'OTP code enforced', + '407': 'Max Tries (if auto blocking is set to true)', + '408': 'Password Expired Can not Change', + '409': 'Password Expired', + '410': 'Password must change (when first time use or after reset password by admin)', + '411': 'Account Locked (when account max try exceed)' + }; + + await this.sendRequest(params, null, true, errorCodeDescs); + } + + private async queryApiInfo(): Promise> { + const params = { + api: 'SYNO.API.Info', + version: 1, + method: 'Query', + query: 'SYNO.API.Auth,SYNO.SurveillanceStation.' + }; + + return await this.sendRequest>(params, 'query.cgi'); + } + + private async getApiPath(api: string): Promise { + return (await this.apiInfo)[api].path; + } + + private async sendRequest(params: SynologyApiRequestParams, url?: string, storeCookies: boolean = false, + extraErrorCodes?: Record): Promise { + const response = await this.client.get>(url ?? await this.getApiPath(params.api), { params }); + + if (!response.data?.success) { + if (response.data?.error?.code) { + const errorCodeLookup = { ...errorCodeDescriptions, ...extraErrorCodes} + throw new Error(`${errorCodeLookup[response.data.error.code]} (error code ${response.data.error.code})`) + } else { + throw new Error(`Synology API call failed with status code ${response.status}`); + } + } + + if (storeCookies) { + this.client.defaults.headers.common['Cookie'] = response.headers["set-cookie"].join('; '); + } + + return response.data.data; + } +} + +export interface SynologyApiInfo { + path: string; + minVersion: number; + maxVersion: number; +} + +export interface SynologyApiError { + code: string; +} + +interface SynologyApiRequestParams { + api: string, + version: number, + method: string, +} + +interface SynologyApiResponse { + data?: T; + error?: SynologyApiError; + success: boolean; +} + +interface SynologyApiListCamerasResponse { + total: number; + cameras: SynologyCamera[]; +} + +export interface SynologyCamera { + firmware: string; + id: number; + model: string; + newName: string; + stream1?: SynologyCameraStream; + stream2?: SynologyCameraStream; + stream3?: SynologyCameraStream; + vendor: string; +} + +export interface SynologyCameraLiveViewPath { + id: string; + mjpegHttpPath: string; + multicstPath: string; + mxpegHttpPath: string; + rtspOverHttpPath: string; + rtspPath: string; +} + +export interface SynologyCameraStream { + id: string; + fps?: number; + resolution?: string; + bitrateCtrl?: number; + quality?: string; + constantBitrate?: string; +} diff --git a/plugins/synology-ss/src/main.ts b/plugins/synology-ss/src/main.ts new file mode 100644 index 000000000..f7a1b1676 --- /dev/null +++ b/plugins/synology-ss/src/main.ts @@ -0,0 +1,283 @@ +import sdk, { ScryptedDeviceBase, DeviceProvider, Settings, Setting, ScryptedDeviceType, VideoCamera, MediaObject, Device, ScryptedInterface, Camera, MediaStreamOptions, PictureOptions } from "@scrypted/sdk"; +import { recommendRebroadcast } from "../../rtsp/src/recommend"; +import {SynologyApiClient, SynologyCameraStream, SynologyCamera} from "./api/synology-api-client"; + +const { log, deviceManager, mediaManager } = sdk; + +class SynologyCameraDevice extends ScryptedDeviceBase implements Camera, Settings, VideoCamera { + private provider: SynologySurveillanceStation; + private streams: SynologyCameraStream[]; + + constructor(provider: SynologySurveillanceStation, nativeId: string, camera: SynologyCamera) { + super(nativeId); + this.provider = provider; + + this.streams = [ + { ...camera.stream1, id: '1' }, + { ...camera.stream2, id: '2' }, + { ...camera.stream3, id: '3' }, + ].filter(s => !!s.resolution); + + this.motionDetected = false; + if (this.interfaces.includes(ScryptedInterface.BinarySensor)) { + this.binaryState = false; + } + } + + private getDefaultOrderedVideoStreamOptions(vsos: MediaStreamOptions[]) { + 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; + } + + private getDefaultStream(vsos: MediaStreamOptions[]) { + let defaultStreamIndex = vsos.findIndex(vso => vso.id === this.storage.getItem('defaultStream')); + if (defaultStreamIndex === -1) + defaultStreamIndex = 0; + + return vsos[defaultStreamIndex]; + } + + public async getSettings(): Promise { + const vsos = await this.getVideoStreamOptions(); + 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', + } + ]; + } + + public async putSetting(key: string, value: string | number | boolean) { + 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.onDeviceEvent(ScryptedInterface.Settings, undefined); + } + + private async getSnapshot(options?: PictureOptions): Promise { + const data = await this.provider.api.getCameraSnapshot(this.nativeId); + + return Buffer.from(data); + } + + public async takePicture(options?: PictureOptions): Promise { + const buffer = await this.getSnapshot(options); + return mediaManager.createMediaObject(buffer, 'image/jpeg'); + } + + public async getVideoStream(options?: MediaStreamOptions): Promise { + const vsos = await this.getVideoStreamOptions(); + const vso = vsos.find(check => check.id === options?.id) || this.getDefaultStream(vsos); + + const rtspChannel = this.streams.find(check => check.id === vso.id); + + const liveViewPaths = await this.provider.api.getCameraLiveViewPath([this.nativeId]); + if (!liveViewPaths?.length) + throw new Error(`Unable to locate RTSP stream for camera ${this.nativeId}`); + + return mediaManager.createFFmpegMediaObject({ + inputArguments: [ + "-rtsp_transport", + "tcp", + '-analyzeduration', '15000000', + '-probesize', '100000000', + "-reorder_queue_size", + "1024", + "-max_delay", + "20000000", + "-i", + liveViewPaths[0].rtspPath, + ], + mediaStreamOptions: this.createMediaStreamOptions(rtspChannel), + }); + } + + private createMediaStreamOptions(stream: SynologyCameraStream) { + const ret: MediaStreamOptions = { + id: stream.id, + name: stream.id, + video: { + codec: 'h264', + width: parseInt(stream.resolution.substring(0, stream.resolution.indexOf('x'))), + height: parseInt(stream.resolution.substring(stream.resolution.indexOf('x') + 1)), + bitrate: parseInt(stream.constantBitrate, 10), + fps: stream.fps + }, + audio: { + codec: 'aac', + }, + }; + return ret; + } + + public async getVideoStreamOptions(): Promise { + const video = this.streams.map(channel => this.createMediaStreamOptions(channel)); + + return this.getDefaultOrderedVideoStreamOptions(video); + } + + public async getPictureOptions(): Promise { + return; + } +} + +class SynologySurveillanceStation extends ScryptedDeviceBase implements Settings, DeviceProvider { + private cameras: SynologyCamera[]; + private cameraDevices: Map = new Map(); + api: SynologyApiClient; + private startup: Promise; + + constructor() { + super(); + + this.startup = this.discoverDevices(0); + recommendRebroadcast(); + } + + public async discoverDevices(duration: number): Promise { + const url = this.getSetting('url'); + const username = this.getSetting('username'); + const password = this.getSetting('password'); + + this.log.clearAlerts(); + + if (!url) { + this.log.a('Must provide URL.'); + return + } + + if (!username) { + this.log.a('Must provide username.'); + return + } + + if (!password) { + this.log.a('Must provide password.'); + return + } + + if (!this.api || url !== this.api.url) { + this.api = new SynologyApiClient(url); + } + + try { + await this.api.login(username, password); + + this.cameras = await this.api.listCameras(); + + if (!this.cameras) { + this.console.error('Cameras failed to load. Retrying in 10 seconds.'); + setTimeout(() => { + this.discoverDevices(0); + }, 100000); + return; + } + + this.console.info(`Discovered ${this.cameras.length} camera(s)`); + + const devices: Device[] = []; + for (let camera of this.cameras) { + const d: Device = { + providerNativeId: this.nativeId, + name: camera.newName, + nativeId: '' + camera.id, + info: { + manufacturer: camera.vendor, + model: camera.model, + firmware: camera.firmware, + serialNumber: '' + camera.id + }, + interfaces: [ + ScryptedInterface.Settings, + ScryptedInterface.Camera, + ScryptedInterface.VideoCamera, + ], + type: ScryptedDeviceType.Camera + }; + + devices.push(d); + } + + for (const d of devices) { + await deviceManager.onDeviceDiscovered(d); + } + + // todo: this was done, october 31st. remove sometime later. + // todo: uncomment after implementing per providerNativeId onDevicesChanged. + // await deviceManager.onDevicesChanged({ + // providerNativeId: this.nativeId, + // devices + // }); + + for (const device of devices) { + this.getDevice(device.nativeId); + } + } + catch (e) { + this.log.a(`login error: ${e}`); + this.console.error('login error', e); + } + } + + async getDevice(nativeId: string): Promise { + await this.startup; + if (this.cameraDevices.has(nativeId)) + return this.cameraDevices.get(nativeId); + const camera = this.cameras.find(camera => ('' + camera.id) === nativeId); + if (!camera) + throw new Error('camera not found?'); + const ret = new SynologyCameraDevice(this, nativeId, camera); + this.cameraDevices.set(nativeId, ret); + return ret; + } + + getSetting(key: string): string { + return this.storage.getItem(key); + } + + async getSettings(): Promise { + const ret: Setting[] = [ + { + key: 'username', + title: 'Username', + value: this.getSetting('username'), + }, + { + key: 'password', + title: 'Password', + type: 'password', + value: this.getSetting('password'), + }, + { + key: 'url', + title: 'Synology Surveillance Station URL', + placeholder: 'http://192.168.1.100:5000', + value: this.getSetting('url'), + }, + ]; + + return ret; + } + + async putSetting(key: string, value: string | number) { + this.storage.setItem(key, value.toString()); + this.discoverDevices(0); + } +} + +export default new SynologySurveillanceStation();