Plugin for Synology Surveillance Station

This commit is contained in:
Matthew Lieder
2021-11-03 22:19:55 -05:00
parent eb1f3c32f0
commit ba2ef85873
10 changed files with 716 additions and 0 deletions

4
plugins/synology-ss/.gitignore vendored Normal file
View File

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

View File

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

22
plugins/synology-ss/.vscode/launch.json vendored Normal file
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": "127.0.0.1"
}

20
plugins/synology-ss/.vscode/tasks.json vendored Normal file
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 @@
# @scrypted/synology-ss
## 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.

135
plugins/synology-ss/package-lock.json generated Normal file
View File

@@ -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=="
}
}
}

View File

@@ -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"
}
}

View File

@@ -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<Record<string, SynologyApiInfo>>;
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<SynologyCameraLiveViewPath[]> {
const params = {
api: 'SYNO.SurveillanceStation.Camera',
version: 9,
method: 'GetLiveViewPath',
idList: cameraIds.join(',')
};
return await this.sendRequest<SynologyCameraLiveViewPath[]>(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<ArrayBuffer>(await this.getApiPath(params.api), { params, responseType: 'arraybuffer' });
return response.data;
}
public async listCameras(): Promise<SynologyCamera[]> {
const params = {
api: 'SYNO.SurveillanceStation.Camera',
version: 9,
method: 'List',
privCamType: 1,
streamInfo: true,
basic: true
};
const response = await this.sendRequest<SynologyApiListCamerasResponse>(params);
return response.cameras;
}
public async login(account: string, password: string): Promise<void> {
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<Record<string, SynologyApiInfo>> {
const params = {
api: 'SYNO.API.Info',
version: 1,
method: 'Query',
query: 'SYNO.API.Auth,SYNO.SurveillanceStation.'
};
return await this.sendRequest<Record<string, SynologyApiInfo>>(params, 'query.cgi');
}
private async getApiPath(api: string): Promise<string> {
return (await this.apiInfo)[api].path;
}
private async sendRequest<T>(params: SynologyApiRequestParams, url?: string, storeCookies: boolean = false,
extraErrorCodes?: Record<string, string>): Promise<T> {
const response = await this.client.get<SynologyApiResponse<T>>(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<T> {
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;
}

View File

@@ -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<Setting[]> {
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<Buffer> {
const data = await this.provider.api.getCameraSnapshot(this.nativeId);
return Buffer.from(data);
}
public async takePicture(options?: PictureOptions): Promise<MediaObject> {
const buffer = await this.getSnapshot(options);
return mediaManager.createMediaObject(buffer, 'image/jpeg');
}
public async getVideoStream(options?: MediaStreamOptions): Promise<MediaObject> {
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<MediaStreamOptions[]> {
const video = this.streams.map(channel => this.createMediaStreamOptions(channel));
return this.getDefaultOrderedVideoStreamOptions(video);
}
public async getPictureOptions(): Promise<PictureOptions[]> {
return;
}
}
class SynologySurveillanceStation extends ScryptedDeviceBase implements Settings, DeviceProvider {
private cameras: SynologyCamera[];
private cameraDevices: Map<string, SynologyCameraDevice> = new Map();
api: SynologyApiClient;
private startup: Promise<void>;
constructor() {
super();
this.startup = this.discoverDevices(0);
recommendRebroadcast();
}
public async discoverDevices(duration: number): Promise<void> {
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<any> {
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<Setting[]> {
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();