This commit is contained in:
Koushik Dutta
2021-09-15 23:38:31 -07:00
parent 6980a6f98d
commit 20a51f54dc
11 changed files with 798 additions and 0 deletions

4
plugins/onvif/.gitignore vendored Normal file
View File

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

4
plugins/onvif/.npmignore Normal file
View File

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

22
plugins/onvif/.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"
}
]
}

4
plugins/onvif/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"scrypted.debugHost": "127.0.0.1",
}

20
plugins/onvif/.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}",
},
]
}

15
plugins/onvif/README.md Normal file
View File

@@ -0,0 +1,15 @@
# ONVIF Camera Plugin
## 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.

353
plugins/onvif/package-lock.json generated Normal file
View File

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

View File

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

View File

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

71
plugins/onvif/src/main.ts Normal file
View File

@@ -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<MediaObject> {
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();

View File

@@ -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<string> {
return new Promise((resolve, reject) => this.cam.getStreamUri({ protocol: 'RTSP' }, (err: Error, uri: string) => err ? reject(err) : resolve(uri)));
}
async jpegSnapshot(): Promise<Buffer> {
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);
}