From 20a51f54dc900c08218b04d5d4cff93e58d66cac Mon Sep 17 00:00:00 2001 From: Koushik Dutta Date: Wed, 15 Sep 2021 23:38:31 -0700 Subject: [PATCH] onvif --- plugins/onvif/.gitignore | 4 + plugins/onvif/.npmignore | 4 + plugins/onvif/.vscode/launch.json | 22 ++ plugins/onvif/.vscode/settings.json | 4 + plugins/onvif/.vscode/tasks.json | 20 ++ plugins/onvif/README.md | 15 ++ plugins/onvif/package-lock.json | 353 ++++++++++++++++++++++++++++ plugins/onvif/package.json | 39 +++ plugins/onvif/src/digest-client.js | 194 +++++++++++++++ plugins/onvif/src/main.ts | 71 ++++++ plugins/onvif/src/onvif-api.ts | 72 ++++++ 11 files changed, 798 insertions(+) create mode 100644 plugins/onvif/.gitignore create mode 100644 plugins/onvif/.npmignore create mode 100644 plugins/onvif/.vscode/launch.json create mode 100644 plugins/onvif/.vscode/settings.json create mode 100644 plugins/onvif/.vscode/tasks.json create mode 100644 plugins/onvif/README.md create mode 100644 plugins/onvif/package-lock.json create mode 100644 plugins/onvif/package.json create mode 100644 plugins/onvif/src/digest-client.js create mode 100644 plugins/onvif/src/main.ts create mode 100644 plugins/onvif/src/onvif-api.ts diff --git a/plugins/onvif/.gitignore b/plugins/onvif/.gitignore new file mode 100644 index 000000000..9cdb546bf --- /dev/null +++ b/plugins/onvif/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +out/ +node_modules/ +dist/ diff --git a/plugins/onvif/.npmignore b/plugins/onvif/.npmignore new file mode 100644 index 000000000..295d8caf5 --- /dev/null +++ b/plugins/onvif/.npmignore @@ -0,0 +1,4 @@ +.DS_Store +out/ +node_modules/ +dist/*.map \ No newline at end of file diff --git a/plugins/onvif/.vscode/launch.json b/plugins/onvif/.vscode/launch.json new file mode 100644 index 000000000..0669f79b4 --- /dev/null +++ b/plugins/onvif/.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/onvif/.vscode/settings.json b/plugins/onvif/.vscode/settings.json new file mode 100644 index 000000000..77ccdbd6d --- /dev/null +++ b/plugins/onvif/.vscode/settings.json @@ -0,0 +1,4 @@ + +{ + "scrypted.debugHost": "127.0.0.1", +} \ No newline at end of file diff --git a/plugins/onvif/.vscode/tasks.json b/plugins/onvif/.vscode/tasks.json new file mode 100644 index 000000000..4d922a539 --- /dev/null +++ b/plugins/onvif/.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/onvif/README.md b/plugins/onvif/README.md new file mode 100644 index 000000000..7e985fc61 --- /dev/null +++ b/plugins/onvif/README.md @@ -0,0 +1,15 @@ +# ONVIF Camera 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/onvif/package-lock.json b/plugins/onvif/package-lock.json new file mode 100644 index 000000000..73d5e5f6a --- /dev/null +++ b/plugins/onvif/package-lock.json @@ -0,0 +1,353 @@ +{ + "name": "@scrypted/onvif", + "version": "0.0.3", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@scrypted/onvif", + "version": "0.0.3", + "license": "Apache", + "dependencies": { + "@scrypted/sdk": "file:../../sdk", + "@types/node": "^16.9.1", + "base-64": "^1.0.0", + "md5": "^2.3.0", + "node-fetch": "^3.0.0", + "onvif": "^0.6.5" + } + }, + "../../sdk": { + "name": "@scrypted/sdk", + "version": "0.0.72", + "license": "ISC", + "dependencies": { + "@babel/core": "^7.2.2", + "@babel/plugin-proposal-class-properties": "^7.4.4", + "@babel/plugin-proposal-optional-chaining": "^7.13.12", + "@babel/plugin-transform-modules-commonjs": "^7.2.0", + "@babel/plugin-transform-typescript": "^7.15.0", + "@babel/polyfill": "^7.2.5", + "@babel/preset-env": "^7.2.3", + "@babel/preset-typescript": "^7.15.0", + "@types/node": "^16.6.1", + "adm-zip": "^0.4.13", + "axios": "^0.21.1", + "babel-loader": "^8.0.4", + "babel-plugin-const-enum": "^1.1.0", + "babel-plugin-minify-dead-code-elimination": "^0.5.1", + "babel-polyfill": "^6.26.0", + "babel-template": "^6.26.0", + "browserify-buffertools": "^1.0.2", + "bytebuffer": "^5.0.1", + "chalk": "^2.4.2", + "clean-webpack-plugin": "^3.0.0", + "engine.io-client": "^3.3.2", + "event-target-shim": "^5.0.1", + "events": "^3.0.0", + "long": "^4.0.0", + "node-cmd": "^3.0.0", + "node-ip": "^0.1.2", + "raw-loader": "^1.0.0", + "terser": "^3.14.1", + "ts-loader": "^5.4.5", + "tsconfig-paths-webpack-plugin": "^3.2.0", + "typescript": "^4.3.5", + "webpack": "^4.28.1", + "webpack-cli": "^3.1.2", + "webpack-inject-plugin": "^1.0.2" + }, + "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": { + "typescript-json-schema": "^0.50.1" + } + }, + "../sdk": { + "extraneous": true + }, + "node_modules/@scrypted/sdk": { + "resolved": "../../sdk", + "link": true + }, + "node_modules/@types/node": { + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", + "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==" + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=", + "engines": { + "node": "*" + } + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=", + "engines": { + "node": "*" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz", + "integrity": "sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/fetch-blob": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.1.2.tgz", + "integrity": "sha512-hunJbvy/6OLjCD0uuhLdp0mMPzP/yd2ssd1t2FCJsaA7wkWhpbp9xfuNVpv7Ll4jFhzp6T4LAupSiV9uOeg0VQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "dependencies": { + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/node-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.0.0.tgz", + "integrity": "sha512-bKMI+C7/T/SPU1lKnbQbwxptpCrG9ashG+VkytmXCPZyuM9jB6VU+hY0oi4lC8LxTtAeWdckNCTa3nrGsAdA3Q==", + "dependencies": { + "data-uri-to-buffer": "^3.0.1", + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/onvif": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/onvif/-/onvif-0.6.5.tgz", + "integrity": "sha512-PbszQC+H9Y+JcTVizxpaSEnTjV6K0FU1LiZ0jXcas+Seqt8PGK/sI/kSBPX+REg5IeWi5CRqp3uWKzSoKuXkVA==", + "dependencies": { + "lodash.get": "^4.4.2", + "xml2js": "^0.4.19" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, + "node_modules/web-streams-polyfill": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.1.1.tgz", + "integrity": "sha512-Czi3fG883e96T4DLEPRvufrF2ydhOOW1+1a6c3gNjH2aIh50DNFBdfwh2AKoOf1rXvpvavAoA11Qdq9+BKjE0Q==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "engines": { + "node": ">=4.0" + } + } + }, + "dependencies": { + "@scrypted/sdk": { + "version": "file:../../sdk", + "requires": { + "@babel/core": "^7.2.2", + "@babel/plugin-proposal-class-properties": "^7.4.4", + "@babel/plugin-proposal-optional-chaining": "^7.13.12", + "@babel/plugin-transform-modules-commonjs": "^7.2.0", + "@babel/plugin-transform-typescript": "^7.15.0", + "@babel/polyfill": "^7.2.5", + "@babel/preset-env": "^7.2.3", + "@babel/preset-typescript": "^7.15.0", + "@types/node": "^16.6.1", + "adm-zip": "^0.4.13", + "axios": "^0.21.1", + "babel-loader": "^8.0.4", + "babel-plugin-const-enum": "^1.1.0", + "babel-plugin-minify-dead-code-elimination": "^0.5.1", + "babel-polyfill": "^6.26.0", + "babel-template": "^6.26.0", + "browserify-buffertools": "^1.0.2", + "bytebuffer": "^5.0.1", + "chalk": "^2.4.2", + "clean-webpack-plugin": "^3.0.0", + "engine.io-client": "^3.3.2", + "event-target-shim": "^5.0.1", + "events": "^3.0.0", + "long": "^4.0.0", + "node-cmd": "^3.0.0", + "node-ip": "^0.1.2", + "raw-loader": "^1.0.0", + "terser": "^3.14.1", + "ts-loader": "^5.4.5", + "tsconfig-paths-webpack-plugin": "^3.2.0", + "typescript": "^4.3.5", + "typescript-json-schema": "^0.50.1", + "webpack": "^4.28.1", + "webpack-cli": "^3.1.2", + "webpack-inject-plugin": "^1.0.2" + } + }, + "@types/node": { + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.1.tgz", + "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==" + }, + "base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" + }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=" + }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=" + }, + "data-uri-to-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz", + "integrity": "sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==" + }, + "fetch-blob": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.1.2.tgz", + "integrity": "sha512-hunJbvy/6OLjCD0uuhLdp0mMPzP/yd2ssd1t2FCJsaA7wkWhpbp9xfuNVpv7Ll4jFhzp6T4LAupSiV9uOeg0VQ==", + "requires": { + "web-streams-polyfill": "^3.0.3" + } + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + }, + "md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "requires": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.0.0.tgz", + "integrity": "sha512-bKMI+C7/T/SPU1lKnbQbwxptpCrG9ashG+VkytmXCPZyuM9jB6VU+hY0oi4lC8LxTtAeWdckNCTa3nrGsAdA3Q==", + "requires": { + "data-uri-to-buffer": "^3.0.1", + "fetch-blob": "^3.1.2" + } + }, + "onvif": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/onvif/-/onvif-0.6.5.tgz", + "integrity": "sha512-PbszQC+H9Y+JcTVizxpaSEnTjV6K0FU1LiZ0jXcas+Seqt8PGK/sI/kSBPX+REg5IeWi5CRqp3uWKzSoKuXkVA==", + "requires": { + "lodash.get": "^4.4.2", + "xml2js": "^0.4.19" + } + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, + "web-streams-polyfill": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.1.1.tgz", + "integrity": "sha512-Czi3fG883e96T4DLEPRvufrF2ydhOOW1+1a6c3gNjH2aIh50DNFBdfwh2AKoOf1rXvpvavAoA11Qdq9+BKjE0Q==" + }, + "xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + } + }, + "xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" + } + } +} diff --git a/plugins/onvif/package.json b/plugins/onvif/package.json new file mode 100644 index 000000000..c695e4706 --- /dev/null +++ b/plugins/onvif/package.json @@ -0,0 +1,39 @@ +{ + "name": "@scrypted/onvif", + "version": "0.0.3", + "description": "ONVIF 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", + "onvif" + ], + "scrypted": { + "name": "ONVIF Camera Controller", + "type": "DeviceProvider", + "interfaces": [ + "DeviceProvider", + "Settings" + ] + }, + "dependencies": { + "@scrypted/sdk": "file:../../sdk", + "@types/node": "^16.9.1", + "base-64": "^1.0.0", + "md5": "^2.3.0", + "node-fetch": "^3.0.0", + "onvif": "^0.6.5" + } +} diff --git a/plugins/onvif/src/digest-client.js b/plugins/onvif/src/digest-client.js new file mode 100644 index 000000000..f7e409b6c --- /dev/null +++ b/plugins/onvif/src/digest-client.js @@ -0,0 +1,194 @@ +/// !----------------------------------------------------------------------------------------------------------- +/// | +// | `digest-fetch` is a wrapper of `node-fetch` or `fetch` to provide http digest authentication boostraping. +// | +/// !----------------------------------------------------------------------------------------------------------- + +var fetch = require('node-fetch').default; +const md5 = require('md5') +const base64 = require('base-64') + +const supported_algorithms = ['MD5', 'MD5-sess'] + +const parse = (raw, field, trim=true) => { + const regex = new RegExp(`${field}=("[^"]*"|[^,]*)`, "i") + const match = regex.exec(raw) + if (match) + return trim ? match[1].replace(/[\s"]/g, '') : match[1] + return null +} + +class DigestClient { + constructor(user, password, options={}) { + this.user = user + this.password = password + this.nonceRaw = 'abcdef0123456789' + this.logger = options.logger + this.precomputedHash = options.precomputedHash + + let algorithm = options.algorithm || 'MD5' + if (!supported_algorithms.includes(algorithm)) { + if (this.logger) this.logger.warn(`Unsupported algorithm ${algorithm}, will try with MD5`) + algorithm = 'MD5' + } + this.digest = { nc: 0, algorithm, realm: '' } + this.hasAuth = false + const _cnonceSize = parseInt(options.cnonceSize) + this.cnonceSize = isNaN(_cnonceSize) ? 32 : _cnonceSize // cnonce length 32 as default + + // Custom authentication failure code for avoiding browser prompt: + // https://stackoverflow.com/questions/9859627/how-to-prevent-browser-to-invoke-basic-auth-popup-and-handle-401-error-using-jqu + this.statusCode = options.statusCode + this.basic = options.basic || false + } + + async fetch (url, options={}) { + if (this.basic) return fetch(url, this.addBasicAuth(options)) + const resp = await fetch(url, this.addAuth(url, options)) + if (resp.status == 401 || (resp.status == this.statusCode && this.statusCode)) { + this.hasAuth = false + await this.parseAuth(resp.headers.get('www-authenticate')) + if (this.hasAuth) { + const respFinal = await fetch(url, this.addAuth(url, options)) + if (respFinal.status == 401 || respFinal.status == this.statusCode) { + this.hasAuth = false + } else { + this.digest.nc++ + } + return respFinal + } + } else this.digest.nc++ + return resp + } + + addBasicAuth (options={}) { + let _options = {} + if (typeof(options.factory) == 'function') { + _options = options.factory() + } else { + _options = options + } + + const auth = 'Basic ' + base64.encode(this.user + ":" + this.password) + _options.headers = _options.headers || {} + _options.headers.Authorization = auth; + if (typeof(_options.headers.set) == 'function') { + _options.headers.set('Authorization', auth) + } + + if (this.logger) this.logger.debug(options) + return _options + } + + static computeHash(user, realm, password) { + return md5(`${user}:${realm}:${password}`); + } + + addAuth (url, options) { + if (typeof(options.factory) == 'function') options = options.factory() + if (!this.hasAuth) return options + if (this.logger) this.logger.info(`requesting with auth carried`) + + const isRequest = typeof(url) === 'object' && typeof(url.url) === 'string' + const urlStr = isRequest ? url.url : url + const _url = urlStr.replace('//', '') + const uri = _url.indexOf('/') == -1 ? '/' : _url.slice(_url.indexOf('/')) + const method = options.method ? options.method.toUpperCase() : 'GET' + + let ha1 = this.precomputedHash ? this.password : DigestClient.computeHash(this.user, this.digest.realm, this.password) + if (this.digest.algorithm === 'MD5-sess') { + ha1 = md5(`${ha1}:${this.digest.nonce}:${this.digest.cnonce}`); + } + + // optional MD5(entityBody) for 'auth-int' + let _ha2 = '' + if (this.digest.qop === 'auth-int') { + // not implemented for auth-int + if (this.logger) this.logger.warn('Sorry, auth-int is not implemented in this plugin') + // const entityBody = xxx + // _ha2 = ':' + md5(entityBody) + } + const ha2 = md5(`${method}:${uri}${_ha2}`); + + const ncString = ('00000000'+this.digest.nc).slice(-8) + + let _response = `${ha1}:${this.digest.nonce}:${ncString}:${this.digest.cnonce}:${this.digest.qop}:${ha2}` + if (!this.digest.qop) _response = `${ha1}:${this.digest.nonce}:${ha2}` + const response = md5(_response); + + const opaqueString = this.digest.opaque !== null ? `opaque="${this.digest.opaque}",` : '' + const qopString = this.digest.qop ? `qop="${this.digest.qop}",` : '' + const digest = `${this.digest.scheme} username="${this.user}",realm="${this.digest.realm}",\ +nonce="${this.digest.nonce}",uri="${uri}",${opaqueString}${qopString}\ +algorithm="${this.digest.algorithm}",response="${response}",nc=${ncString},cnonce="${this.digest.cnonce}"` + options.headers = options.headers || {} + options.headers.Authorization = digest + if (typeof(options.headers.set) == 'function') { + options.headers.set('Authorization', digest) + } + + if (this.logger) this.logger.debug(options) + + // const {factory, ..._options} = options + const _options = {} + Object.assign(_options, options) + delete _options.factory + return _options; + } + + async parseAuth (h) { + this.lastAuth = h + + if (!h || h.length < 5) { + this.hasAuth = false + return + } + + this.hasAuth = true + + this.digest.scheme = h.split(/\s/)[0] + + this.digest.realm = (parse(h, 'realm', false) || '').replace(/["]/g, '') + + this.digest.qop = this.parseQop(h) + + this.digest.opaque = parse(h, 'opaque') + + this.digest.nonce = parse(h, 'nonce') || '' + + this.digest.cnonce = this.makeNonce() + this.digest.nc++ + } + + parseQop (rawAuth) { + // Following https://en.wikipedia.org/wiki/Digest_access_authentication + // to parse valid qop + // Samples + // : qop="auth,auth-init",realm= + // : qop=auth,realm= + const _qop = parse(rawAuth, 'qop') + + if (_qop !== null) { + const qops = _qop.split(',') + if (qops.includes('auth')) return 'auth' + else if (qops.includes('auth-int')) return 'auth-int' + } + // when not specified + return null + } + + makeNonce () { + let uid = '' + for (let i = 0; i < this.cnonceSize; ++i) { + uid += this.nonceRaw[Math.floor(Math.random() * this.nonceRaw.length)]; + } + return uid + } + + static parse(...args) { + return parse(...args) + } +} + +if (typeof(window) === "object") window.DigestFetch = DigestClient +module.exports = DigestClient diff --git a/plugins/onvif/src/main.ts b/plugins/onvif/src/main.ts new file mode 100644 index 000000000..1c7946176 --- /dev/null +++ b/plugins/onvif/src/main.ts @@ -0,0 +1,71 @@ +import sdk, { MediaObject, Camera, ScryptedInterface } from "@scrypted/sdk"; +import { Stream } from "stream"; +import { RtspSmartCamera, RtspProvider } from "../../rtsp/src/rtsp"; +import { connectCameraAPI, OnvifEvent } from "./onvif-api"; + +const { mediaManager } = sdk; + + +class OnvifCamera extends RtspSmartCamera implements Camera { + eventStream: Stream; + + constructor(nativeId: string) { + super(nativeId); + + this.createMotionStream(); + } + + async createMotionStream() { + while (true) { + try { + this.motionDetected = false; + this.audioDetected = false; + + const api = await this.createClient(); + for await (const event of api.listenEvents()) { + if (event === OnvifEvent.MotionStart) + this.motionDetected = true; + else if (event === OnvifEvent.MotionStop) + this.motionDetected = false; + else if (event === OnvifEvent.AudioStart) + this.audioDetected = true; + else if (event === OnvifEvent.AudioStop) + this.audioDetected = false; + } + } + catch (e) { + console.error('event listener failure', e); + await new Promise(resolve => setTimeout(resolve, 10000)); + } + } + } + + createClient() { + return connectCameraAPI(this.storage.getItem('ip'), this.getUsername(), this.getPassword()); + } + + async takePicture(): Promise { + const api = await this.createClient(); + return mediaManager.createMediaObject(api.jpegSnapshot(), 'image/jpeg'); + } + + async getConstructedStreamUrl() { + return `rtsp://${this.getRtspAddress()}/cam/realmonitor?channel=1&subtype=0`; + } +} + +class OnvifProvider extends RtspProvider { + getAdditionalInterfaces() { + return [ + ScryptedInterface.Camera, + ScryptedInterface.AudioSensor, + ScryptedInterface.MotionSensor, + ]; + } + + getDevice(nativeId: string): object { + return new OnvifCamera(nativeId); + } +} + +export default new OnvifProvider(); diff --git a/plugins/onvif/src/onvif-api.ts b/plugins/onvif/src/onvif-api.ts new file mode 100644 index 000000000..c1224dfb2 --- /dev/null +++ b/plugins/onvif/src/onvif-api.ts @@ -0,0 +1,72 @@ +import { EventEmitter, once } from 'events'; +import DigestClient from './digest-client'; + +const onvif = require('onvif'); +const { Cam } = onvif; + +export enum OnvifEvent { + MotionStart, + MotionStop, + AudioStart, + AudioStop, +} + +export class OnvifCameraAPI extends EventEmitter { + digestAuth: DigestClient; + + constructor(public cam: any, username: string, password: string) { + super(); + + this.digestAuth = new DigestClient(username, password); + } + + async* listenEvents() { + + this.cam.on('event', (event: any) => { + const value = event.message?.message?.data?.simpleItem?.$?.Value; + if (event.topic?._?.indexOf('MotionAlarm') !== -1) { + if (value === true) + this.emit('event', OnvifEvent.MotionStart) + else if (value === false) + this.emit('event', OnvifEvent.MotionStop) + } + else if (event.topic?._?.indexOf('DetectedSound') !== -1) { + if (value === true) + this.emit('event', OnvifEvent.AudioStart) + if (value === false) + this.emit('event', OnvifEvent.AudioStop) + } + }); + + while (true) { + const [event] = await once(this, 'event'); + yield event as OnvifEvent; + } + } + + async getStreamUrl(): Promise { + return new Promise((resolve, reject) => this.cam.getStreamUri({ protocol: 'RTSP' }, (err: Error, uri: string) => err ? reject(err) : resolve(uri))); + } + + async jpegSnapshot(): Promise { + const url: string = (await new Promise((resolve, reject) => this.cam.getSnapshotUri((err: Error, uri: string) => err ? reject(err) : resolve(uri))) as any).uri; + + const response = await this.digestAuth.fetch( url); + const buffer = await response.arrayBuffer(); + + return Buffer.from(buffer); + } +} + +export async function connectCameraAPI(hostname: string, username: string, password: string) { + const cam = await new Promise((resolve, reject) => { + const cam = new Cam({ + hostname, + username, + password, + }, (err: Error) => err ? reject(err) : resolve(cam) + ) + }); + + return new OnvifCameraAPI(cam, username, password); +} \ No newline at end of file