├── .gitignore ├── LICENSE ├── README.md ├── icons ├── device.png ├── discovery.png ├── event.png ├── imaging.png ├── media.png ├── ptz.png └── recording.png ├── images ├── onvif_config_comm.png ├── onvif_config_node.png ├── onvif_device.png ├── onvif_discovery_debug.png ├── onvif_discovery_flow.png ├── onvif_discovery_status.png ├── onvif_events.png ├── onvif_example_profile.png ├── onvif_get_profiles.png ├── onvif_media_audio.png ├── onvif_media_profiles.png ├── onvif_media_snapshot.png ├── onvif_media_snapshot_http.png ├── onvif_media_snapshot_short.png ├── onvif_media_streamurl.png ├── onvif_media_video.png ├── onvif_ptz_flow.png ├── onvif_ptz_home.png ├── onvif_ptz_info.png ├── onvif_ptz_preset.png └── onvif_snapshot_debug.png ├── onvif_config.html ├── onvif_config.js ├── onvif_device.html ├── onvif_device.js ├── onvif_discovery.html ├── onvif_discovery.js ├── onvif_events.html ├── onvif_events.js ├── onvif_imaging.html ├── onvif_imaging.js ├── onvif_media.html ├── onvif_media.js ├── onvif_ptz.html ├── onvif_ptz.js ├── onvif_recording.html ├── onvif_recording.js ├── package.json └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /icons/device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/icons/device.png -------------------------------------------------------------------------------- /icons/discovery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/icons/discovery.png -------------------------------------------------------------------------------- /icons/event.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/icons/event.png -------------------------------------------------------------------------------- /icons/imaging.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/icons/imaging.png -------------------------------------------------------------------------------- /icons/media.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/icons/media.png -------------------------------------------------------------------------------- /icons/ptz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/icons/ptz.png -------------------------------------------------------------------------------- /icons/recording.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/icons/recording.png -------------------------------------------------------------------------------- /images/onvif_config_comm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_config_comm.png -------------------------------------------------------------------------------- /images/onvif_config_node.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_config_node.png -------------------------------------------------------------------------------- /images/onvif_device.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_device.png -------------------------------------------------------------------------------- /images/onvif_discovery_debug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_discovery_debug.png -------------------------------------------------------------------------------- /images/onvif_discovery_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_discovery_flow.png -------------------------------------------------------------------------------- /images/onvif_discovery_status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_discovery_status.png -------------------------------------------------------------------------------- /images/onvif_events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_events.png -------------------------------------------------------------------------------- /images/onvif_example_profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_example_profile.png -------------------------------------------------------------------------------- /images/onvif_get_profiles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_get_profiles.png -------------------------------------------------------------------------------- /images/onvif_media_audio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_media_audio.png -------------------------------------------------------------------------------- /images/onvif_media_profiles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_media_profiles.png -------------------------------------------------------------------------------- /images/onvif_media_snapshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_media_snapshot.png -------------------------------------------------------------------------------- /images/onvif_media_snapshot_http.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_media_snapshot_http.png -------------------------------------------------------------------------------- /images/onvif_media_snapshot_short.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_media_snapshot_short.png -------------------------------------------------------------------------------- /images/onvif_media_streamurl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_media_streamurl.png -------------------------------------------------------------------------------- /images/onvif_media_video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_media_video.png -------------------------------------------------------------------------------- /images/onvif_ptz_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_ptz_flow.png -------------------------------------------------------------------------------- /images/onvif_ptz_home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_ptz_home.png -------------------------------------------------------------------------------- /images/onvif_ptz_info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_ptz_info.png -------------------------------------------------------------------------------- /images/onvif_ptz_preset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_ptz_preset.png -------------------------------------------------------------------------------- /images/onvif_snapshot_debug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bartbutenaers/node-red-contrib-onvif-nodes/de93eb7c0e27760cae4e3fdbdf593d7b96685457/images/onvif_snapshot_debug.png -------------------------------------------------------------------------------- /onvif_config.html: -------------------------------------------------------------------------------- 1 | 13 | 48 | 49 | 82 | 83 | 97 | -------------------------------------------------------------------------------- /onvif_config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Bart Butenaers 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | **/ 16 | module.exports = function(RED) { 17 | var settings = RED.settings; 18 | const onvif = require('onvif'); 19 | 20 | function setOnvifStatus(node, onvifStatus) { 21 | node.onvifStatus = onvifStatus; 22 | // Pass the new status to all the available listeners 23 | node.emit('onvif_status', onvifStatus); 24 | } 25 | 26 | function OnVifConfigNode(config) { 27 | RED.nodes.createNode(this, config); 28 | this.xaddress = config.xaddress; 29 | this.port = parseInt(config.port || 80); 30 | this.name = config.name; 31 | this.timeout = config.timeout || 3; 32 | this.checkConnectionInterval = config.checkConnectionInterval || 5; 33 | // Remark: user name and password are stored in this.credentials 34 | 35 | var node = this; 36 | 37 | // All Onvif nodes can add a listener to track the 'onvif_status' events. 38 | // However by default only 10 listeners are allowed, which results in a warning when more Onvif nodes use this config node: 39 | // MaxListenersExceededWarning: Possible EventEmitter memory leak detected 40 | // To avoid that, we will allow an infinite number of listeners. 41 | // Caution: when you have suspicion that the listeners are leaking, put the next line in comment !!! 42 | node.setMaxListeners(0); 43 | 44 | this.getProfiles = function(clientConfig, response) { 45 | var profileNames = []; 46 | var config = {}; 47 | 48 | // TODO checken of er altijd een this.credentials bestaat, indien username en paswoord niet ingevuld is. 49 | 50 | // The client credentials will only contain the data (i.e. user name or password) which has changed. 51 | // The other data is not changed, so we will need use the original data stored on the server. 52 | clientConfig.username = clientConfig.user || this.credentials.user; 53 | clientConfig.password = clientConfig.password || this.credentials.password; 54 | 55 | // When the user appends some new text to the existing password, then the original password is passed via the client as __PWRD__ 56 | // So replace __PWRD__ again by the original password. 57 | if (clientConfig.password && this.credentials.password) { 58 | clientConfig.password.replace('___PWRD__', this.credentials.password); 59 | } 60 | 61 | if (this.credentials.user !== clientConfig.user || this.credentials.password !== clientConfig.password || this.xaddress !== clientConfig.hostname){ 62 | var cam = new onvif.Cam(clientConfig, function(err) { 63 | if (!err) { 64 | if (cam.profiles) { 65 | for(var i = 0; i < cam.profiles.length; i++) { 66 | profileNames.push({ 67 | label: cam.profiles[i].name, 68 | value: cam.profiles[i].$.token 69 | }); 70 | } 71 | } 72 | 73 | response.json(profileNames); 74 | } 75 | }); 76 | } 77 | else { 78 | if (this.cam.profiles) { 79 | // The current deployed cam is still up-to-date, so let’s use that one (for performance reasons) 80 | for(var i = 0; i < this.cam.profiles.length; i++) { 81 | profileNames.push({ 82 | label: this.cam.profiles[i].name, 83 | value: this.cam.profiles[i].$.token 84 | }); 85 | } 86 | } 87 | 88 | response.json(profileNames); 89 | } 90 | } 91 | 92 | this.getProfileTokenByName = function(profileName) { 93 | if (this.cam.profiles) { 94 | // Try to find a profile with the specified name, and return the token 95 | for(var i = 0; i < this.cam.profiles.length; i++) { 96 | if (this.cam.profiles[i].name === profileName) { 97 | return this.cam.profiles[i].$.token; 98 | } 99 | } 100 | } 101 | 102 | // No token found with the specified name 103 | return null; 104 | } 105 | 106 | // This should be called by all nodes that use this config node 107 | this.initialize = function() { 108 | // This config node can only be initialized oncellchange 109 | if (this.cam) { 110 | return; 111 | } 112 | 113 | // Without an xaddress, it is impossible to connect to an Onvif device 114 | if (!this.xaddress) { 115 | // Make sure the Catch-node can catch the error 116 | node.error( "Cannot connect to unconfigured Onvif device", {} ); 117 | 118 | this.cam = null; 119 | setOnvifStatus(node, "unconfigured"); 120 | return; 121 | } 122 | 123 | setOnvifStatus(node, "initializing"); 124 | 125 | var options = {}; 126 | options.hostname = this.xaddress; 127 | options.port = this.port; 128 | options.timeout = this.timeout * 1000; 129 | 130 | if (this.credentials && this.credentials.user) { 131 | options.username = this.credentials.user; 132 | options.password = this.credentials.password; 133 | } 134 | 135 | // Create a new camera instance, which will automatically connect to the device (to load configuration data) 136 | this.cam = new onvif.Cam(options, function(err) { 137 | if (err) { 138 | // Make sure the Catch-node can catch the error 139 | node.error( err, {} ); 140 | 141 | setOnvifStatus(node, "disconnected"); 142 | } 143 | else { 144 | setOnvifStatus(node, "connected"); 145 | } 146 | }); 147 | 148 | // When an checkConnection timer is running, then stop it 149 | if (this.checkConnectionTimer) { 150 | clearInterval(this.checkConnectionTimer); 151 | this.checkConnectionTimer = null; 152 | } 153 | 154 | if (this.checkConnectionInterval > 0) { 155 | // Start a new checkConnection timer, that checks at the specified interval whether the Onvif device is disconnected. 156 | // This way we can keep the node status in the flow editor up to date ... 157 | this.checkConnectionTimer = setInterval(function() { 158 | // Check whether the Onvif device is connected, by calling the device system and time 159 | node.cam.getSystemDateAndTime(function(err, date, xml) { 160 | if (err) { 161 | setOnvifStatus(node, "disconnected"); 162 | } 163 | else { 164 | if (!node.cam.capabilities && !node.cam.services) { 165 | // Probably the device was unavailable when this node was being started, so the autoConnect was not able 166 | // to load all the data from the camera (like e.g. its capabilities). Afterwards the device became available, 167 | // so it is now time to load the device data into the Cam instance (by calling its 'connect' function). 168 | // Note that this needs to be executed before setOnvifStatus, because that function uses the capabilities. 169 | node.cam.connect(function(err, date, xml) { 170 | if (err) { 171 | node.error("The device is now connected, but the data cannot be loaded"); 172 | } 173 | }); 174 | } 175 | 176 | setOnvifStatus(node, "connected"); 177 | } 178 | }); 179 | }, this.checkConnectionInterval * 1000); 180 | } 181 | } 182 | 183 | node.on('close', function(){ 184 | setOnvifStatus(this, ""); 185 | 186 | this.removeAllListeners("onvif_status"); 187 | 188 | // When an checkConnection timer is running, then stop it 189 | if (this.checkConnectionTimer) { 190 | clearInterval(this.checkConnectionTimer); 191 | this.checkConnectionTimer = null; 192 | } 193 | }); 194 | } 195 | 196 | RED.nodes.registerType("onvif-config",OnVifConfigNode,{ 197 | credentials: { 198 | user: {type:"text"}, 199 | password: {type: "password"} 200 | } 201 | }); 202 | 203 | // Make all the available profiles accessible for the node's config screen 204 | RED.httpAdmin.get('/onvifdevice/:cmd/:config_node_id', RED.auth.needsPermission('onvifdevice.read'), function(req, res){ 205 | var configNode = RED.nodes.getNode(req.params.config_node_id); 206 | 207 | switch (req.params.cmd) { 208 | case "profiles": 209 | if (!configNode) { 210 | console.log("Cannot determine profile list from node " + req.params.config_node_id); 211 | return; 212 | } 213 | 214 | // Get the profiles of the camera, based on the config data on the client, instead of the config data 215 | // stored inside this config node. Reason is that the config data on the client might be 'dirty', i.e. changed 216 | // by the user but not deployed yet on this config node. But the client still needs to be able to get the profiles 217 | // corresponding to that dirty config node. That way the config screen can be filled with profiles already... 218 | // But when the config data is not dirty, we will just use the profiles already loaded in this config node (which is faster). 219 | // See https://discourse.nodered.org/t/initializing-config-screen-based-on-new-config-node/7327/10?u=bartbutenaers 220 | configNode.getProfiles(req.query, res); 221 | 222 | break; 223 | } 224 | }); 225 | } 226 | -------------------------------------------------------------------------------- /onvif_device.html: -------------------------------------------------------------------------------- 1 | 13 | 37 | 38 | 66 | 67 | 74 | 75 | -------------------------------------------------------------------------------- /onvif_device.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Bart Butenaers 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | **/ 16 | module.exports = function(RED) { 17 | var settings = RED.settings; 18 | const onvif = require('onvif'); 19 | const utils = require('./utils'); 20 | 21 | function OnVifDeviceNode(config) { 22 | RED.nodes.createNode(this, config); 23 | this.action = config.action; 24 | 25 | var node = this; 26 | 27 | // Retrieve the config node, where the device is configured 28 | node.deviceConfig = RED.nodes.getNode(config.deviceConfig); 29 | 30 | if (node.deviceConfig) { 31 | node.listener = function(onvifStatus) { 32 | utils.setNodeStatus(node, 'device', onvifStatus); 33 | } 34 | 35 | // Start listening for Onvif config nodes status changes 36 | node.deviceConfig.addListener("onvif_status", node.listener); 37 | 38 | // Show the current Onvif config node status already 39 | utils.setNodeStatus(node, 'device', node.deviceConfig.onvifStatus); 40 | 41 | node.deviceConfig.initialize(); 42 | } 43 | 44 | node.on("input", function(msg) { 45 | var newMsg = {}; 46 | 47 | var action = node.action || msg.action; 48 | 49 | if (!action) { 50 | console.warn('When no action specified in the node, it should be specified in the msg.action'); 51 | return; 52 | } 53 | 54 | // Don't perform these checks when e.g. the device is currently disconnected (because then e.g. no capabilities are loaded yet) 55 | if (action !== "reconnect") { 56 | if (!node.deviceConfig || node.deviceConfig.onvifStatus !== "connected") { 57 | node.error("This node is not connected to a device"); 58 | return; 59 | } 60 | 61 | if (!utils.hasService(node.deviceConfig.cam, 'device')) { 62 | node.error("The device has no support for a device service"); 63 | return; 64 | } 65 | } 66 | 67 | newMsg.xaddr = this.deviceConfig.xaddress; 68 | newMsg.action = action; 69 | 70 | try { 71 | switch (action) { 72 | case "getDeviceInformation": 73 | node.deviceConfig.cam.getDeviceInformation(function(err, date, xml) { 74 | utils.handleResult(node, err, date, xml, newMsg); 75 | }); 76 | break; 77 | case "getHostname": 78 | node.deviceConfig.cam.getHostname(function(err, date, xml) { 79 | utils.handleResult(node, err, date, xml, newMsg); 80 | }); 81 | break; 82 | case "getSystemDateAndTime": 83 | node.deviceConfig.cam.getSystemDateAndTime(function(err, date, xml) { 84 | utils.handleResult(node, err, date, xml, newMsg); 85 | }); 86 | break; 87 | case "getServices": 88 | node.deviceConfig.cam.getCapabilities(function(err, date, xml) { 89 | utils.handleResult(node, err, date, xml, newMsg); 90 | }); 91 | break; 92 | case "getCapabilities": 93 | node.deviceConfig.cam.getCapabilities(function(err, date, xml) { 94 | utils.handleResult(node, err, date, xml, newMsg); 95 | }); 96 | break; 97 | case "getScopes": 98 | node.deviceConfig.cam.getScopes(function(err, date, xml) { 99 | utils.handleResult(node, err, date, xml, newMsg); 100 | }); 101 | break; 102 | case "systemReboot": 103 | node.deviceConfig.cam.systemReboot(function(err, date, xml) { 104 | utils.handleResult(node, err, date, xml, newMsg); 105 | }); 106 | break; 107 | case "getServiceCapabilities": 108 | node.deviceConfig.cam.getCapabilities(function(err, date, xml) { 109 | utils.handleResult(node, err, date, xml, newMsg); 110 | }); 111 | break; 112 | case "reconnect": 113 | node.deviceConfig.cam.connect(function(err) { 114 | utils.handleResult(node, err, "", null, newMsg); 115 | }); 116 | break 117 | default: 118 | //node.status({fill:"red",shape:"dot",text: "unsupported action"}); 119 | node.error("Action " + action + " is not supported"); 120 | } 121 | } 122 | catch (exc) { 123 | node.error("Action " + action + " failed: " + exc); 124 | } 125 | }); 126 | 127 | node.on("close",function() { 128 | if (node.listener) { 129 | node.deviceConfig.removeListener("onvif_status", node.listener); 130 | } 131 | }); 132 | } 133 | RED.nodes.registerType("onvif-device",OnVifDeviceNode); 134 | } 135 | -------------------------------------------------------------------------------- /onvif_discovery.html: -------------------------------------------------------------------------------- 1 | 13 | 30 | 31 | 47 | 48 | 56 | -------------------------------------------------------------------------------- /onvif_discovery.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Bart Butenaers 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | **/ 16 | module.exports = function(RED) { 17 | var settings = RED.settings; 18 | const onvif = require('onvif'); 19 | 20 | function OnVifDiscoveryNode(config) { 21 | RED.nodes.createNode(this, config); 22 | this.timeout = parseInt(config.timeout) * 1000; // Seconds 23 | this.separate = config.separate; 24 | this.discovering = false; 25 | 26 | var node = this; 27 | 28 | // Simplify the result, so it becomes more easy to parse it with Node-Red 29 | function simplifyResult(result) { 30 | var probeMatch; 31 | 32 | // Reduce the property depth 33 | probeMatch = result.probeMatches.probeMatch; 34 | 35 | // The result can be an array of strings, or a long (space separated) string. 36 | // In case of a long string, split it to an array of strings... 37 | 38 | if (typeof probeMatch.types === 'string' || probeMatch.types instanceof String) { 39 | probeMatch.types = probeMatch.types.trim().split(" "); 40 | } 41 | 42 | if (typeof probeMatch.scopes === 'string' || probeMatch.scopes instanceof String) { 43 | probeMatch.scopes = probeMatch.scopes.trim().split(" "); 44 | } 45 | 46 | if (typeof probeMatch.XAddrs === 'string' || probeMatch.XAddrs instanceof String) { 47 | probeMatch.XAddrs = probeMatch.XAddrs.trim().split(" "); 48 | } 49 | 50 | return probeMatch; 51 | } 52 | 53 | node.on("input", function(msg) { 54 | if (node.discovering) { 55 | console.info("Discovery request ignored, since other discovery is active"); 56 | return; 57 | } 58 | 59 | node.status({fill:"yellow",shape:"dot",text:"discovering"}); 60 | node.discovering = true; 61 | 62 | var options = { 63 | timeout: node.timeout, // Discovery should end after the specified timeout 64 | resolve: false // Return discovered devices as data objects, instead of Cam instances 65 | }; 66 | 67 | // For every discovery we will need to remove all previous ('device' and 'error') listeners, and add new listeners. 68 | // See https://discourse.nodered.org/t/object-property-becomes-undefined/50647/2?u=bartbutenaers 69 | onvif.Discovery.removeAllListeners(); 70 | 71 | // When a separate output message per device is required, then listen to every separate device being detected 72 | if (node.separate) { 73 | onvif.Discovery.on('device', function (result) { 74 | // Since the same input message will be resend for every Onvif device found, we need to clone the input message 75 | var outputMsg = RED.util.cloneMessage(msg); 76 | outputMsg.payload = simplifyResult(result); 77 | 78 | // Send a separate output message for every discovered OnVif-compliant IP device 79 | node.send(outputMsg); 80 | }); 81 | } 82 | 83 | // The discovery must have an error handler to catch bad replies from the network (which cannot be parsed by this library) 84 | onvif.Discovery.on('error', function (err, xml) { 85 | node.error('Discovery error ' + err); 86 | }); 87 | 88 | // Start discovery of the ONVIF network devices. 89 | // The callback function will be called only once, when the broadcast is finished (after the timeout). 90 | onvif.Discovery.probe(options, function(err, result) { 91 | if (err) { 92 | node.error(err.message); 93 | node.status({fill:"red",shape:"dot",text: "failed"}); 94 | } 95 | else { 96 | node.status({fill:"green",shape:"dot",text: "completed (" + result.length + "x)"}); 97 | } 98 | 99 | // When a single message needs to be send (containing all discovered devices)... 100 | if (!node.separate) { 101 | var devices = []; 102 | 103 | // Convert the array to an easy format 104 | for (var i = 0; i < result.length; i++) { 105 | devices.push(simplifyResult(result[i])); 106 | } 107 | 108 | // Send a single message, containing an array of ALL discovered OnVif-compliant IP devices 109 | var outputMsg = msg; 110 | outputMsg.payload = devices; 111 | node.send(outputMsg); 112 | } 113 | 114 | node.discovering = false; 115 | }); 116 | }); 117 | 118 | node.on('close', function(){ 119 | node.status({}); 120 | onvif.Discovery.removeAllListeners(); 121 | node.discovering = false; 122 | }); 123 | } 124 | RED.nodes.registerType("onvif-discovery",OnVifDiscoveryNode); 125 | } 126 | 127 | -------------------------------------------------------------------------------- /onvif_events.html: -------------------------------------------------------------------------------- 1 | 13 | 37 | 38 | 61 | 62 | 69 | 70 | -------------------------------------------------------------------------------- /onvif_events.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Bart Butenaers 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | **/ 16 | module.exports = function(RED) { 17 | var settings = RED.settings; 18 | const onvif = require('onvif'); 19 | const utils = require('./utils'); 20 | 21 | function OnVifEventsNode(config) { 22 | RED.nodes.createNode(this, config); 23 | this.action = config.action; 24 | 25 | var node = this; 26 | 27 | // Retrieve the config node, where the device is configured 28 | node.deviceConfig = RED.nodes.getNode(config.deviceConfig); 29 | 30 | if (node.deviceConfig) { 31 | node.listener = function(onvifStatus) { 32 | utils.setNodeStatus(node, 'event', onvifStatus); 33 | 34 | if (onvifStatus !== "connected" && node.eventListener) { 35 | // When the device isn't connected anymore, stop listening to events from the camera 36 | node.deviceConfig.cam.removeListener('events', node.eventListener); 37 | node.eventListener = null; 38 | } 39 | } 40 | 41 | // Start listening for Onvif config nodes status changes 42 | node.deviceConfig.addListener("onvif_status", node.listener); 43 | 44 | // Show the current Onvif config node status already 45 | utils.setNodeStatus(node, 'event', node.deviceConfig.onvifStatus); 46 | 47 | node.deviceConfig.initialize(); 48 | } 49 | 50 | node.on("input", function(msg) { 51 | var newMsg = {}; 52 | 53 | // Note: the node's config screen has no 'action' input field yet ... 54 | var action = node.action || msg.action; 55 | 56 | if (!action) { 57 | // When no action specified in the node, it should be specified in the msg.action 58 | node.error("No action specified (in node or msg)"); 59 | return; 60 | } 61 | 62 | // Don't perform these checks when e.g. the device is currently disconnected (because then e.g. no capabilities are loaded yet) 63 | if (action !== "reconnect") { 64 | if (!node.deviceConfig || node.deviceConfig.onvifStatus != "connected") { 65 | node.error("This node is not connected to a device"); 66 | return; 67 | } 68 | 69 | if (!utils.hasService(node.deviceConfig.cam, 'event')) { 70 | node.error("The device has no support for an event service"); 71 | return; 72 | } 73 | } 74 | 75 | // Seems that some Axis cams support pull point, although they return WSPullPointSupport 'false' 76 | /*if (!node.deviceConfig.cam.capabilities.events.WSPullPointSupport == true) { 77 | //console.warn('Ignoring input message since the device does not support pull point subscription'); 78 | return; 79 | }*/ 80 | 81 | newMsg.xaddr = this.deviceConfig.xaddress; 82 | newMsg.action = action; 83 | 84 | try { 85 | switch (action) { 86 | case "start": 87 | if (node.eventListener) { 88 | node.error("This node is already listening to device events"); 89 | return; 90 | } 91 | 92 | // Overwrite the device status text 93 | node.status({fill:"green",shape:"dot",text:"listening"}); 94 | 95 | node.eventListener = function(camMessage) { 96 | var sourceName = null; 97 | var sourceValue = null; 98 | var dataName = null; 99 | var dataValue = null; 100 | 101 | // Events have a Topic 102 | // Events have (optionally) a Source, a Key and Data fields 103 | // The Source,Key and Data fields can be single items or an array of items 104 | // The Source,Key and Data fields can be of type SimpleItem or a Complex Item 105 | // - Topic 106 | // - Message/Message/$ 107 | // - Message/Message/Source... 108 | // - Message/Message/Key... 109 | // - Message/Message/Data/SimpleItem/[index]/$/name (array of items) 110 | // OR - Message/Message/Data/SimpleItem/$/name (single item) 111 | // - Message/Message/Data/SimpleItem/[index]/$/value (array of items) 112 | // OR - Message/Message/Data/SimpleItem/$/value (single item) 113 | 114 | var eventTopic = camMessage.topic._; 115 | 116 | // Strip the namespaces from the topic (e.g. tns1:MediaControl/tnsavg:ConfigurationUpdateAudioEncCfg) 117 | // Split on '/', then remove any namespace for each part, and at the end recombine parts that were split with '/' 118 | let parts = eventTopic.split('/'); 119 | eventTopic = ""; 120 | for (var index = 0; index < parts.length; index++) { 121 | var stringNoNamespace = parts[index].split(':').pop(); 122 | if (eventTopic.length == 0) { 123 | eventTopic += stringNoNamespace; 124 | } else { 125 | eventTopic += '/' + stringNoNamespace; 126 | } 127 | } 128 | 129 | var outputMsg = { 130 | topic: eventTopic, 131 | time: camMessage.message.message.$.UtcTime, 132 | property: camMessage.message.message.$.PropertyOperation // Initialized, Deleted or Changed but missing/undefined on the Avigilon 4 channel encoder 133 | }; 134 | 135 | // Only handle simpleItem 136 | // Only handle one 'source' item 137 | // Ignore the 'key' item (nothing I own produces it) 138 | // Handle all the 'Data' items 139 | 140 | // SOURCE (Name:Value) 141 | if (camMessage.message.message.source && camMessage.message.message.source.simpleItem) { 142 | if (Array.isArray(camMessage.message.message.source.simpleItem)) { 143 | // TODO : currently we only process the first event source item ... 144 | outputMsg.source = { 145 | name: camMessage.message.message.source.simpleItem[0].$.Name, 146 | value: camMessage.message.message.source.simpleItem[0].$.Value 147 | } 148 | } 149 | else { 150 | outputMsg.source = { 151 | name: camMessage.message.message.source.simpleItem.$.Name, 152 | value: camMessage.message.message.source.simpleItem.$.Value 153 | } 154 | } 155 | } 156 | 157 | //KEY 158 | if (camMessage.message.message.key) { 159 | outputMsg.key = camMessage.message.message.key; 160 | } 161 | 162 | // DATA (Name:Value) 163 | if (camMessage.message.message.data && camMessage.message.message.data.simpleItem) { 164 | if (Array.isArray(camMessage.message.message.data.simpleItem)) { 165 | outputMsg.data = []; 166 | for (var x = 0; x < camMessage.message.message.data.simpleItem.length; x++) { 167 | outputMsg.data.push({ 168 | name: camMessage.message.message.data.simpleItem[x].$.Name, 169 | value: camMessage.message.message.data.simpleItem[x].$.Value 170 | }) 171 | } 172 | } 173 | else { 174 | outputMsg.data = { 175 | name: camMessage.message.message.data.simpleItem.$.Name, 176 | value: camMessage.message.message.data.simpleItem.$.Value 177 | } 178 | } 179 | } 180 | else if (camMessage.message.message.data && camMessage.message.message.data.elementItem) { 181 | outputMsg.data = { 182 | dataName: 'elementItem', 183 | dataValue: JSON.stringify(camMessage.message.message.data.elementItem) 184 | } 185 | } 186 | 187 | // As soon as we get an event from the camera, we will send it to the output of this node 188 | node.send(outputMsg); 189 | } 190 | 191 | // Start listening to events from the camera 192 | node.deviceConfig.cam.on('event', node.eventListener); 193 | break; 194 | case "stop": 195 | if (!node.eventListener) { 196 | node.error("This node was not listening to events anyway"); 197 | return; 198 | } 199 | 200 | // Stop listening to events from the camera 201 | node.deviceConfig.cam.removeListener('event', node.eventListener); 202 | node.eventListener = null; 203 | 204 | // Overwrite the device status text 205 | node.status({fill:"green",shape:"ring",text:"not listening"}); 206 | break; 207 | case "getEventProperties": 208 | node.deviceConfig.cam.getEventProperties(function(err, date, xml) { 209 | if (!err) { 210 | var simplifiedDate = {}; 211 | 212 | // Simplify the soap message to a compact message, by keeping only all relevant information 213 | function simplifyNode(node, simplifiedDateChild) { 214 | // loop over all the child nodes in this node 215 | for (const child in node) { 216 | switch (child) { 217 | case "$": 218 | // Continue to the next child in the list (same level) 219 | continue; 220 | case "messageDescription": 221 | // Collect the details that belong to the event 222 | var source = ''; 223 | var date = ''; 224 | 225 | if (node[child].source && node[child].source.simpleItemDescription) { 226 | simplifiedDateChild.source = node[child].source.simpleItemDescription.$; 227 | } 228 | if (node[child].data && node[child].data.simpleItemDescriptio) { 229 | simplifiedDateChild.date = node[child].data.simpleItemDescription.$; 230 | } 231 | 232 | return; 233 | default: 234 | // Decend recursively into the child node, looking for the messageDescription 235 | simplifiedDateChild[child] = {}; 236 | simplifyNode(node[child], simplifiedDateChild[child]); 237 | } 238 | } 239 | } 240 | simplifyNode(date.topicSet, simplifiedDate) 241 | } 242 | 243 | utils.handleResult(node, err, simplifiedDate, null, newMsg); 244 | }); 245 | break; 246 | case "getEventServiceCapabilities": 247 | node.deviceConfig.cam.getEventServiceCapabilities(function(err, date, xml) { 248 | utils.handleResult(node, err, date, xml, newMsg); 249 | }); 250 | break; 251 | case "reconnect": 252 | node.deviceConfig.cam.connect(function(err) { 253 | utils.handleResult(node, err, "", null, newMsg); 254 | }); 255 | break 256 | default: 257 | //node.status({fill:"red",shape:"dot",text: "unsupported action"}); 258 | node.error("Action " + action + " is not supported"); 259 | } 260 | } 261 | catch (exc) { 262 | node.error("Action " + action + " failed: " + exc); 263 | } 264 | }); 265 | 266 | node.on("close",function() { 267 | if (node.listener) { 268 | node.deviceConfig.removeListener("onvif_status", node.listener); 269 | } 270 | 271 | // Stop listening to events from the camera 272 | if (node.eventListener) { 273 | node.deviceConfig.cam.removeListener('event', node.eventListener); 274 | node.eventListener = null; 275 | } 276 | }); 277 | } 278 | RED.nodes.registerType("onvif-events",OnVifEventsNode); 279 | } 280 | -------------------------------------------------------------------------------- /onvif_imaging.html: -------------------------------------------------------------------------------- 1 | 13 | 38 | 39 | 65 | 66 | 73 | -------------------------------------------------------------------------------- /onvif_imaging.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Bart Butenaers 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | **/ 16 | module.exports = function(RED) { 17 | var settings = RED.settings; 18 | const onvif = require('onvif'); 19 | const utils = require('./utils'); 20 | 21 | function OnVifImagingNode(config) { 22 | RED.nodes.createNode(this, config); 23 | this.action = config.action; 24 | 25 | var node = this; 26 | 27 | // Retrieve the config node, where the device is configured 28 | node.deviceConfig = RED.nodes.getNode(config.deviceConfig); 29 | 30 | if (node.deviceConfig) { 31 | node.listener = function(onvifStatus) { 32 | utils.setNodeStatus(node, 'imaging', onvifStatus); 33 | } 34 | 35 | // Start listening for Onvif config nodes status changes 36 | node.deviceConfig.addListener("onvif_status", node.listener); 37 | 38 | // Show the current Onvif config node status already 39 | utils.setNodeStatus(node, 'imaging', node.deviceConfig.onvifStatus); 40 | 41 | node.deviceConfig.initialize(); 42 | } 43 | 44 | node.on("input", function(msg) { 45 | var newMsg = {}; 46 | 47 | var action = node.action || msg.action; 48 | 49 | if (!action) { 50 | // When no action specified in the node, it should be specified in the msg.action 51 | node.error("No action specified (in node or msg)"); 52 | return; 53 | } 54 | 55 | // Don't perform these checks when e.g. the device is currently disconnected (because then e.g. no capabilities are loaded yet) 56 | if (action !== "reconnect") { 57 | if (!node.deviceConfig || node.deviceConfig.onvifStatus != "connected") { 58 | node.error("This node is not connected to a device"); 59 | return; 60 | } 61 | 62 | if (!utils.hasService(node.deviceConfig.cam, 'imaging')) { 63 | node.error("The device has no support for an imaging service"); 64 | return; 65 | } 66 | } 67 | 68 | newMsg.xaddr = this.deviceConfig.xaddress; 69 | newMsg.action = action; 70 | 71 | try { 72 | switch (action) { 73 | case 'getImagingSettings': 74 | node.deviceConfig.cam.getImagingSettings(function(err, stream, xml) { 75 | utils.handleResult(node, err, stream, xml, newMsg); 76 | }); 77 | break; 78 | case 'setImagingSettings': 79 | var options = {}; 80 | 81 | if (msg.payload) { 82 | if (msg.payload.backlightCompensation) { 83 | options.backlightCompensation = msg.payload.backlightCompensation; 84 | } 85 | 86 | if (msg.payload.brightness) { 87 | options.brightness = msg.payload.brightness; 88 | } 89 | 90 | if (msg.payload.colorSaturation) { 91 | options.colorSaturation = msg.payload.colorSaturation; 92 | } 93 | 94 | if (msg.payload.contrast) { 95 | options.contrast = msg.payload.contrast; 96 | } 97 | 98 | if (msg.payload.exposure) { 99 | options.exposure = msg.payload.exposure; 100 | } 101 | 102 | if (msg.payload.focus) { 103 | options.focus = msg.payload.focus; 104 | } 105 | 106 | if (msg.payload.irCutFilter) { 107 | options.irCutFilter = msg.payload.irCutFilter; 108 | } 109 | 110 | if (msg.payload.sharpness) { 111 | options.sharpness = msg.payload.sharpness; 112 | } 113 | 114 | if (msg.payload.wideDynamicRange) { 115 | options.wideDynamicRange = msg.payload.wideDynamicRange; 116 | } 117 | 118 | if (msg.payload.whiteBalance) { 119 | options.whiteBalances = msg.payload.whiteBalance; 120 | } 121 | 122 | if (msg.payload.focus) { 123 | options.focus = msg.payload.focus; 124 | } 125 | 126 | if (msg.payload.extension) { 127 | options.extension = msg.payload.extension; 128 | } 129 | } 130 | 131 | // TODO we should be able to specify (in the options) the video source token, because by default the active video source will be updated. 132 | 133 | if (Object.keys(options).length === 0) { 134 | node.error('No image settings have been specified.'); 135 | return; 136 | } 137 | 138 | node.deviceConfig.cam.setImagingSettings(options, function(err, stream, xml) { 139 | utils.handleResult(node, err, stream, xml, newMsg); 140 | }); 141 | break; 142 | case 'getServiceCapabilities': 143 | node.deviceConfig.cam.getImagingServiceCapabilities(function(err, stream, xml) { 144 | utils.handleResult(node, err, stream, xml, newMsg); 145 | }); 146 | break; 147 | case "reconnect": 148 | node.deviceConfig.cam.connect(); 149 | break; 150 | default: 151 | //node.status({fill:"red",shape:"dot",text: "unsupported action"}); 152 | node.error("Action " + action + " is not supported"); 153 | } 154 | } 155 | catch (exc) { 156 | node.error("Action " + action + " failed: " + exc); 157 | } 158 | }); 159 | 160 | node.on("close",function() { 161 | if (node.listener) { 162 | node.deviceConfig.removeListener("onvif_status", node.listener); 163 | } 164 | }); 165 | } 166 | RED.nodes.registerType("onvif-imaging",OnVifImagingNode); 167 | } 168 | -------------------------------------------------------------------------------- /onvif_media.html: -------------------------------------------------------------------------------- 1 | 13 | 146 | 147 | 230 | 231 | 240 | 241 | -------------------------------------------------------------------------------- /onvif_media.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Bart Butenaers 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | **/ 16 | module.exports = function(RED) { 17 | var settings = RED.settings; 18 | const onvif = require('onvif'); 19 | const url = require('url'); 20 | const https = require('http'); 21 | const utils = require('./utils'); 22 | 23 | function OnVifMediaNode(config) { 24 | RED.nodes.createNode(this, config); 25 | this.action = config.action; 26 | this.profileToken = config.profileToken; 27 | this.profileName = config.profileName; 28 | this.videoEncoderConfigToken = config.videoEncoderConfigToken; 29 | this.protocol = config.protocol; 30 | this.stream = config.stream; 31 | this.snapshotUriMap = new Map(); 32 | 33 | var node = this; 34 | 35 | // Retrieve the config node, where the device is configured 36 | node.deviceConfig = RED.nodes.getNode(config.deviceConfig); 37 | 38 | if (node.deviceConfig) { 39 | node.listener = function(onvifStatus) { 40 | utils.setNodeStatus(node, 'media', onvifStatus); 41 | } 42 | 43 | // Start listening for Onvif config nodes status changes 44 | node.deviceConfig.addListener("onvif_status", node.listener); 45 | 46 | // Show the current Onvif config node status already 47 | utils.setNodeStatus(node, 'media', node.deviceConfig.onvifStatus); 48 | 49 | node.deviceConfig.initialize(); 50 | } 51 | 52 | function getSnapshot(uri, newMsg) { 53 | require('request').get(uri, { 54 | 'auth': { 55 | 'user': node.deviceConfig.credentials.user, 56 | 'pass': node.deviceConfig.credentials.password, 57 | 'sendImmediately': false 58 | } 59 | }).on('response', function(response) { 60 | if(response.statusCode === 200) { 61 | // Concatenate all the data chunks, to get a complete image (as a Buffer) 62 | newMsg.payload = response.body; 63 | 64 | newMsg.contentType = response.headers['content-type']; 65 | if(!newMsg.contentType) { 66 | newMsg.contentType = 'image/jpeg'; 67 | } 68 | 69 | // Remember all body chunks and concatenate them into a single buffer when entire image received 70 | var bodyChunks = []; 71 | response.on("data", function (chunk) { 72 | bodyChunks.push(chunk); 73 | }); 74 | response.on("end", function () { 75 | newMsg.payload = Buffer.concat(bodyChunks); 76 | node.send(newMsg); 77 | }); 78 | } 79 | else { 80 | console.log(response.statusCode + ' ' + response.statusMessage); 81 | } 82 | }).on('error', function(err) { 83 | console.error(err.message) 84 | }) 85 | } 86 | 87 | node.on("input", function(msg) { 88 | var newMsg = {}; 89 | 90 | var action = node.action || msg.action; 91 | 92 | if (!action) { 93 | // When no action specified in the node, it should be specified in the msg.action 94 | node.error("No action specified (in node or msg)"); 95 | return; 96 | } 97 | 98 | // Don't perform these checks when e.g. the device is currently disconnected (because then e.g. no capabilities are loaded yet) 99 | if (action !== "reconnect") { 100 | if (!node.deviceConfig || node.deviceConfig.onvifStatus != "connected") { 101 | node.error("This node is not connected to a device"); 102 | return; 103 | } 104 | 105 | if (!utils.hasService(node.deviceConfig.cam, 'media')) { 106 | node.error("The device has no support for a media service"); 107 | return; 108 | } 109 | } 110 | 111 | var protocol = node.protocol || msg.protocol; 112 | var stream = node.stream || msg.stream; 113 | var profileToken = node.profileToken || msg.profileToken; 114 | var profileName = node.profileName || msg.profileName; 115 | var videoEncoderConfigToken = node.videoEncoderConfigToken || msg.videoEncoderConfigToken; 116 | 117 | // TODO check this only for actions where profileToken is needed 118 | // TODO when device disconnected, this gives "Cannot read property '$' of undefined" due to missing videosources... 119 | /*if (!profileToken) { 120 | if (!node.deviceConfig.cam.getActiveSources()) { 121 | console.warn('No default video source available'); 122 | return; 123 | } 124 | }*/ 125 | 126 | // To make things easier for a user, we will let the user specify profile names. 127 | // The corresponding profile token (which is required in Onvif to work with profiles) will be searched here... 128 | if (!profileToken && profileName) { 129 | profileToken = node.deviceConfig.getProfileTokenByName(profileName); 130 | } 131 | 132 | newMsg.xaddr = this.deviceConfig.xaddress; 133 | newMsg.action = action; 134 | 135 | try { 136 | switch (action) { 137 | case "getStreamUri": 138 | // All these 3 fields are optional. If not specified, the device will use the defaults. 139 | var options = { 140 | 'stream': stream, 141 | 'profileToken': profileToken, 142 | 'protocol': protocol 143 | }; 144 | 145 | node.deviceConfig.cam.getStreamUri(options, function(err, stream, xml) { 146 | utils.handleResult(node, err, stream, xml, newMsg); 147 | }); 148 | break; 149 | case "getSnapshotUri": 150 | // The profileToken is optional. When not specified, the device will use the default profile token. 151 | var options = { 152 | 'profileToken': profileToken 153 | }; 154 | 155 | node.deviceConfig.cam.getSnapshotUri(options, function(err, stream, xml) { 156 | utils.handleResult(node, err, stream, xml, newMsg); 157 | }); 158 | break; 159 | case "getVideoEncoderConfiguration": 160 | // If the videoEncoderConfigToken doesn't exist, the first element from the videoEncoderConfigurations array will be returned 161 | node.deviceConfig.cam.getVideoEncoderConfiguration(videoEncoderConfigToken, function(err, stream, xml) { 162 | utils.handleResult(node, err, stream, xml, newMsg); 163 | }); 164 | break; 165 | case "getVideoEncoderConfigurations": 166 | node.deviceConfig.cam.getVideoEncoderConfigurations(function(err, stream, xml) { 167 | utils.handleResult(node, err, stream, xml, newMsg); 168 | }); 169 | break; 170 | case "getVideoEncoderConfigurationOptions": 171 | // If the videoEncoderConfigToken doesn't exist, the first element from the videoEncoderConfigurations array will be used 172 | node.deviceConfig.cam.getVideoEncoderConfigurationOptions(videoEncoderConfigToken, function(err, stream, xml) { 173 | utils.handleResult(node, err, stream, xml, newMsg); 174 | }); 175 | break; 176 | case "getProfiles": 177 | node.deviceConfig.cam.getProfiles(function(err, stream, xml) { 178 | utils.handleResult(node, err, stream, xml, newMsg); 179 | }); 180 | break; 181 | case "getVideoSources": 182 | node.deviceConfig.cam.getVideoSources(function(err, stream, xml) { 183 | utils.handleResult(node, err, stream, xml, newMsg); 184 | }); 185 | break; 186 | case "getVideoSourceConfigurations": 187 | node.deviceConfig.cam.getVideoSourceConfigurations(function(err, stream, xml) { 188 | utils.handleResult(node, err, stream, xml, newMsg); 189 | }); 190 | break; 191 | case "getAudioSources": 192 | node.deviceConfig.cam.getAudioSources(function(err, stream, xml) { 193 | utils.handleResult(node, err, stream, xml, newMsg); 194 | }); 195 | break; 196 | case "getAudioSourceConfigurations": 197 | node.deviceConfig.cam.getAudioSourceConfigurations(function(err, stream, xml) { 198 | utils.handleResult(node, err, stream, xml, newMsg); 199 | }); 200 | break; 201 | case "getAudioEncoderConfigurations": 202 | node.deviceConfig.cam.getAudioEncoderConfigurations(function(err, stream, xml) { 203 | utils.handleResult(node, err, stream, xml, newMsg); 204 | }); 205 | break; 206 | case "getAudioOutputs": 207 | node.deviceConfig.cam.getAudioOutputs(function(err, stream, xml) { 208 | utils.handleResult(node, err, stream, xml, newMsg); 209 | }); 210 | break; 211 | case "getAudioOutputConfigurations": 212 | node.deviceConfig.cam.getAudioOutputConfigurations(function(err, stream, xml) { 213 | utils.handleResult(node, err, stream, xml, newMsg); 214 | }); 215 | break; 216 | case "getOSDs": 217 | // If the videoEncoderConfigToken doesn't exist, all available OSD's will be requested 218 | node.deviceConfig.cam.getOSDs(videoEncoderConfigToken, function(err, stream, xml) { 219 | // Onvif OSD implementation is optional and most manufactures did not implement it. 220 | // This means we will arrive here often ... 221 | // Most camera's will offer another REST interface to check and configure OSD texts, but not based on OnVif. 222 | utils.handleResult(node, err, stream, xml, newMsg); 223 | }); 224 | break; 225 | case "getSnapshot": 226 | var snapshotUri = node.snapshotUriMap.get(profileToken); 227 | 228 | // Get the uri once (and cache it), when it hasn't been retrieved yet 229 | if (!snapshotUri) { 230 | // When no token is specified, the camera will use the default token 231 | var options = { 232 | 'profileToken': profileToken 233 | }; 234 | 235 | node.deviceConfig.cam.getSnapshotUri(options, function(err, stream, xml) { 236 | utils.handleResult(node, err, stream, xml, newMsg); 237 | 238 | if (!err) { 239 | // Cache the URL for the next time 240 | node.snapshotUriMap.set(profileToken, stream.uri); 241 | getSnapshot(stream.uri, newMsg); 242 | } 243 | }); 244 | } 245 | else { 246 | getSnapshot(snapshotUri, newMsg); 247 | } 248 | 249 | break; 250 | case "createProfile": 251 | // The profile name is optional 252 | var options = { 253 | 'name': profileName, 254 | 'profileToken': profileToken // TODO This is not being used (the device generates a new token) ... 255 | }; 256 | 257 | // Create an empty new deletable media profile 258 | node.deviceConfig.cam.createProfile(options, function(err, stream, xml) { 259 | utils.handleResult(node, err, stream, xml, newMsg); 260 | }); 261 | 262 | break; 263 | case "deleteProfile": 264 | node.deviceConfig.cam.deleteProfile(profileToken, function(err, stream, xml) { 265 | utils.handleResult(node, err, stream, xml, newMsg); 266 | }); 267 | 268 | break; 269 | /* TODO 270 | case "setVideoEncoderConfiguration": 271 | var options = { 272 | 'token': node.videoEncoderConfigToken, 273 | 'name': node.videoEncoderConfigName, 274 | //'useCount': ???, 275 | 'encoding': node.videoEncoderConfigEncoding, //JPEG | H264 | MPEG4 276 | 'resolution': { 277 | 'width': node.videoEncoderConfigWidth, 278 | 'height': node.videoEncoderConfigHeight, 279 | }, 280 | //'quality': ?????, 281 | 'rateControl': { 282 | 'frameRateLimit': node.videoEncoderConfigFrameRateLimit, 283 | 'encodingInterval': node.videoEncoderConfigEncodingInterval, 284 | 'bitrateLimit': node.videoEncoderConfigBitrateLimit 285 | }, 286 | 'MPEG4': { 287 | 'govLength': node.videoEncoderConfigMpeg4GovLength, 288 | 'profile': node.videoEncoderConfigMpeg4Profile // SP | ASP 289 | }, 290 | 'H264': { 291 | 'govLength': node.videoEncoderConfigH264GovLength, 292 | 'profile': node.videoEncoderConfigH264Profile // Baseline | Main | Extended | High 293 | }, 294 | 'multicast': { 295 | 'address': node.videoEncoderConfigMulticastAddress, 296 | 'type': node.videoEncoderConfigMulticastType, // IPv4 | IPv6 297 | 'IPv4Address': node.videoEncoderConfigMulticastIpv4Address, 298 | 'IPv6Address': node.videoEncoderConfigMulticastIpv6Address, 299 | 'port': node.videoEncoderConfigMulticastPort, 300 | 'TTL': node.videoEncoderConfigMulticastTtl, 301 | 'autoStart': node.videoEncoderConfigMulticastAutoStart, 302 | }, 303 | 'sessionTimeout': node.videoEncoderConfigName 304 | }; 305 | 306 | node.deviceConfig.cam.setVideoEncoderConfiguration(options, function(err, stream, xml) { 307 | utils.handleResult(node, err, stream, xml, newMsg); 308 | });; 309 | 310 | break; */ 311 | case "reconnect": 312 | node.deviceConfig.cam.connect(); 313 | break; 314 | default: 315 | //node.status({fill:"red",shape:"dot",text: "unsupported action"}); 316 | node.error("Action " + action + " is not supported"); 317 | } 318 | } 319 | catch (exc) { 320 | node.error("Action " + action + " failed: " + exc); 321 | } 322 | }); 323 | 324 | node.on("close",function() { 325 | if (node.listener) { 326 | node.deviceConfig.removeListener("onvif_status", node.listener); 327 | } 328 | }); 329 | } 330 | RED.nodes.registerType("onvif-media",OnVifMediaNode); 331 | } 332 | -------------------------------------------------------------------------------- /onvif_ptz.html: -------------------------------------------------------------------------------- 1 | 13 | 187 | 188 | 293 | 294 | 311 | -------------------------------------------------------------------------------- /onvif_ptz.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Bart Butenaers 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | **/ 16 | module.exports = function(RED) { 17 | var settings = RED.settings; 18 | const onvif = require('onvif'); 19 | const utils = require('./utils'); 20 | 21 | function OnVifPtzNode(config) { 22 | RED.nodes.createNode(this, config); 23 | this.panSpeed = parseFloat(config.panSpeed); 24 | this.tiltSpeed = parseFloat(config.tiltSpeed); 25 | this.zoomSpeed = parseFloat(config.zoomSpeed); 26 | this.panPosition = parseFloat(config.panPosition); 27 | this.tiltPosition = parseFloat(config.tiltPosition); 28 | this.zoomPosition = parseFloat(config.zoomPosition); 29 | this.panTranslation = parseFloat(config.panTranslation); 30 | this.tiltTranslation = parseFloat(config.tiltTranslation); 31 | this.zoomTranslation = parseFloat(config.zoomTranslation); 32 | this.time = parseInt(config.time); 33 | this.profileName = config.profileName; 34 | this.action = config.action; 35 | this.preset = config.preset; 36 | this.presetName = config.presetName; 37 | this.stopPanTilt = config.stopPanTilt; 38 | this.stopZoom = config.stopZoom 39 | this.configurationToken = config.configurationToken; 40 | 41 | var node = this; 42 | 43 | // Retrieve the config node, where the device is configured 44 | node.deviceConfig = RED.nodes.getNode(config.deviceConfig); 45 | 46 | if (node.deviceConfig) { 47 | node.listener = function(onvifStatus) { 48 | utils.setNodeStatus(node, 'ptz', onvifStatus); 49 | } 50 | 51 | // Start listening for Onvif config nodes status changes 52 | node.deviceConfig.addListener("onvif_status", node.listener); 53 | 54 | // Show the current Onvif config node status already 55 | utils.setNodeStatus(node, 'ptz', node.deviceConfig.onvifStatus); 56 | 57 | node.deviceConfig.initialize(); 58 | } 59 | 60 | node.on("input", function(msg) { 61 | var newMsg = {}; 62 | 63 | var profileToken = msg.profileToken; 64 | var profileName = node.profileName || msg.profileName; 65 | 66 | var panSpeed = node.panSpeed; 67 | var tiltSpeed = node.tiltSpeed; 68 | var zoomSpeed = node.zoomSpeed; 69 | var panPosition = node.panPosition; 70 | var tiltPosition = node.tiltPosition; 71 | var zoomPosition = node.zoomPosition; 72 | var panTranslation = node.panTranslation; 73 | var tiltTranslation = node.tiltTranslation; 74 | var zoomTranslation = node.zoomTranslation; 75 | var stopPanTilt = node.stopPanTilt; 76 | var stopZoom = node.stopZoom; 77 | var configurationToken = node.configurationToken; 78 | 79 | var action = node.action || msg.action; 80 | 81 | if (!action) { 82 | // When no action specified in the node, it should be specified in the msg.action 83 | node.error("No action specified (in node or msg)"); 84 | return; 85 | } 86 | 87 | // Don't perform these checks when e.g. the device is currently disconnected (because then e.g. no capabilities are loaded yet) 88 | if (action !== "reconnect") { 89 | if (!node.deviceConfig || node.deviceConfig.onvifStatus != "connected") { 90 | node.error("This node is not connected to a device"); 91 | return; 92 | } 93 | 94 | if (!utils.hasService(node.deviceConfig.cam, 'ptz')) { 95 | node.error("The device has no support for a ptz service"); 96 | return; 97 | } 98 | } 99 | 100 | var preset = node.preset || msg.preset; 101 | var presetName = node.presetName || msg.presetName; 102 | 103 | /*var currentProfile = node.device.getCurrentProfile(); 104 | 105 | if (!currentProfile) { 106 | // Avoid errors during ptzMove, by ensuring that the device has a profile. 107 | // This can be a temporary issue at flow startup, since the device initialization above can take some time... 108 | console.warn('Ignoring input message because the OnVif device has no current profile'); 109 | return; 110 | }*/ 111 | 112 | // TODO check whether the node.token exists in the ONVIF device 113 | 114 | // Check whether a 'pan_speed' value is specified in the input message 115 | if (msg.hasOwnProperty('pan_speed') || msg.pan_speed < -1.0 || msg.pan_speed > 1.0) { 116 | if (isNaN(msg.pan_speed)) { 117 | node.error('The msg.pan_speed value should be a number between -1.0 and 1.0'); 118 | } 119 | else { 120 | panSpeed = msg.pan_speed; 121 | } 122 | } 123 | 124 | // Check whether a 'tilt_speed' value is specified in the input message 125 | if (msg.hasOwnProperty('tilt_speed') || msg.tilt_speed < -1.0 || msg.tilt_speed > 1.0) { 126 | if (isNaN(msg.tilt_speed)) { 127 | node.error('The msg.tilt_speed value should be a number between -1.0 and 1.0'); 128 | } 129 | else { 130 | tiltSpeed = msg.tilt_speed; 131 | } 132 | } 133 | 134 | // Check whether a 'zoom_speed' value is specified in the input message 135 | if (msg.hasOwnProperty('zoom_speed')) { 136 | if (isNaN(msg.zoom_speed) || msg.zoom_speed < -1.0 || msg.zoom_speed > 1.0) { 137 | node.error('The msg.zoom_speed value should be a number between -1.0 and 1.0'); 138 | } 139 | else { 140 | zoomSpeed = msg.zoom_speed; 141 | } 142 | } 143 | 144 | // Check whether a 'pan_position' value is specified in the input message 145 | if (msg.hasOwnProperty('pan_position') /*TODO CHECK VIA PROFILE RANGE || msg.pan_position < -1.0 || msg.pan_position > 1.0*/) { 146 | if (isNaN(msg.pan_position)) { 147 | node.error('The msg.pan_position value should be a number between ?? and ??'); // TODO find boundaries in profile 148 | } 149 | else { 150 | panPosition = msg.pan_position; 151 | } 152 | } 153 | 154 | // Check whether a 'tilt_position' value is specified in the input message 155 | if (msg.hasOwnProperty('tilt_position') /*TODO CHECK VIA PROFILE RANGE || msg.tilt_position < -1.0 || msg.tilt_position > 1.0*/) { 156 | if (isNaN(msg.tilt_position)) { 157 | node.error('The msg.tilt_position value should be a number between ?? and ??'); // TODO find boundaries in profile 158 | } 159 | else { 160 | tiltPosition = msg.tilt_position; 161 | } 162 | } 163 | 164 | // Check whether a 'zoom_position' value is specified in the input message 165 | if (msg.hasOwnProperty('zoom_position') /*TODO CHECK VIA PROFILE RANGE || msg.zoom_position < -1.0 || msg.zoom_position > 1.0*/) { 166 | if (isNaN(msg.zoom_position)) { 167 | node.error('The msg.zoom_position value should be a number between ?? and ??'); // TODO find boundaries in profile 168 | } 169 | else { 170 | zoomPosition = msg.zoom_position; 171 | } 172 | } 173 | 174 | // Check whether a 'pan_translation' value is specified in the input message 175 | if (msg.hasOwnProperty('pan_translation') /*TODO CHECK VIA PROFILE RANGE || msg.pan_translation < -1.0 || msg.pan_translation > 1.0*/) { 176 | if (isNaN(msg.pan_translation)) { 177 | node.error('The msg.pan_translation value should be a number between ?? and ??'); // TODO find boundaries in profile 178 | } 179 | else { 180 | panTranslation = msg.pan_translation; 181 | } 182 | } 183 | 184 | // Check whether a 'tilt_translation' value is specified in the input message 185 | if (msg.hasOwnProperty('tilt_translation') /*TODO CHECK VIA PROFILE RANGE || msg.tilt_translation < -1.0 || msg.tilt_translation > 1.0*/) { 186 | if (isNaN(msg.tilt_translation)) { 187 | node.error('The msg.tilt_translation value should be a number between ?? and ??'); // TODO find boundaries in profile 188 | } 189 | else { 190 | tiltTranslation = msg.tilt_translation; 191 | } 192 | } 193 | 194 | // Check whether a 'zoom_translation' value is specified in the input message 195 | if (msg.hasOwnProperty('zoom_translation') /*TODO CHECK VIA PROFILE RANGE || msg.zoom_translation < -1.0 || msg.zoom_translation > 1.0*/) { 196 | if (isNaN(msg.zoom_translation)) { 197 | node.error('The msg.zoom_translation value should be a number between ?? and ??'); // TODO find boundaries in profile 198 | } 199 | else { 200 | zoomTranslation = msg.zoom_translation; 201 | } 202 | } 203 | 204 | // Make sure the values are between -1.0 and 1.0 205 | panSpeed = Math.min(Math.max(panSpeed, -1.0), 1.0); 206 | tiltSpeed = Math.min(Math.max(tiltSpeed, -1.0), 1.0); 207 | zoomSpeed = Math.min(Math.max(zoomSpeed, -1.0), 1.0); 208 | 209 | // To make things easier for a user, we will let the user specify profile names. 210 | // The corresponding profile token (which is required in Onvif to work with profiles) will be searched here... 211 | if (!profileToken && profileName) { 212 | profileToken = node.deviceConfig.getProfileTokenByName(profileName); 213 | } 214 | 215 | newMsg.xaddr = this.deviceConfig.xaddress; 216 | newMsg.action = action; 217 | 218 | try { 219 | switch (action) { 220 | case "continuousMove": 221 | var options = { 222 | 'profileToken': profileToken, 223 | 'x': panSpeed, 224 | 'y': tiltSpeed, 225 | 'zoom': zoomSpeed, 226 | 'timeout': node.time 227 | }; 228 | 229 | // Move the camera with the specified speed(s) and during the specified time 230 | node.deviceConfig.cam.continuousMove(options, function(err, stream, xml) { 231 | utils.handleResult(node, err, stream, xml, newMsg); 232 | }); 233 | 234 | break; 235 | case "absoluteMove": 236 | // TODO The 'position' value range is specified in the profile 237 | var options = { 238 | 'profileToken': profileToken, 239 | 'x': panPosition, 240 | 'y': tiltPosition, 241 | 'zoom': zoomPosition, 242 | 'speed': { 243 | 'x': panSpeed, 244 | 'y': tiltSpeed, 245 | 'zoom': zoomSpeed 246 | } 247 | }; 248 | 249 | // Move the camera with the specified speed(s) and during the specified time 250 | node.deviceConfig.cam.absoluteMove(options, function(err, stream, xml) { 251 | utils.handleResult(node, err, stream, xml, newMsg); 252 | }); 253 | 254 | break; 255 | case "relativeMove": 256 | var options = { 257 | 'profileToken': profileToken, 258 | 'x': panTranslation, 259 | 'y': tiltTranslation, 260 | 'zoom': zoomTranslation, 261 | 'speed': { 262 | 'x': panSpeed, 263 | 'y': tiltSpeed, 264 | 'zoom': zoomSpeed 265 | } 266 | }; 267 | 268 | // Move the camera with the specified speed(s) and during the specified time 269 | node.deviceConfig.cam.relativeMove(options, function(err, stream, xml) { 270 | utils.handleResult(node, err, stream, xml, newMsg); 271 | }); 272 | 273 | break; 274 | case "gotoHomePosition": 275 | var options = { 276 | 'profileToken': profileToken, 277 | 'speed': { 278 | 'x': panSpeed, 279 | 'y': tiltSpeed, 280 | 'zoom': zoomSpeed 281 | } 282 | }; 283 | 284 | // Let the camera go to the home position. 285 | // Make sure a home position is set in advance, otherwise you get a 'No HomePosition' error. 286 | node.deviceConfig.cam.gotoHomePosition(options, function(err, stream, xml) { 287 | utils.handleResult(node, err, stream, xml, newMsg); 288 | }); 289 | 290 | break; 291 | case "setHomePosition": 292 | var options = { 293 | 'profileToken': profileToken, 294 | }; 295 | 296 | // Set the CURRENT camera position as the home position 297 | node.deviceConfig.cam.setHomePosition(options, function(err, stream, xml) { 298 | utils.handleResult(node, err, stream, xml, newMsg); 299 | }); 300 | 301 | break; 302 | case "getPresets": 303 | var options = { 304 | 'profileToken': profileToken, 305 | }; 306 | 307 | node.deviceConfig.cam.getPresets(options, function(err, stream, xml) { 308 | utils.handleResult(node, err, stream, xml, newMsg); 309 | }); 310 | 311 | break; 312 | case "setPreset": 313 | var options = { 314 | 'profileToken': profileToken 315 | }; 316 | 317 | // We will not ask the user to specify the preset token in the input message, to avoid that the preset tokens will 318 | // need to be stored somewhere in the Node-Red flow. Instead the user can specify a preset NAME, and we will lookup 319 | // to which existing preset token this name corresponds... 320 | node.deviceConfig.cam.getPresets(options, function(err, stream, xml) { 321 | // Get the preset token of the specified preset name 322 | var presetToken = stream[presetName]; 323 | 324 | // When the preset token exists, we are dealing with an already existing preset. 325 | // Then pass the preset token to the device, so it will UPDATE the existing preset. 326 | // When the preset token is not passed to the device, the device will create a NEW preset (and a new preset token). 327 | if (presetToken) { 328 | options.presetToken = presetToken; 329 | } 330 | 331 | options.presetName = presetName; 332 | 333 | // Create/update the preset, based on the preset token. The device will save the current camera parameters 334 | // (XY coordinates, zoom level and a focus adjustment) so that the device can move afterwards to that saved 335 | // preset position (via the GotoPreset action). 336 | node.deviceConfig.cam.setPreset(options, function(err, stream, xml) { 337 | // The response contains the PresetToken which uniquely identifies the Preset. 338 | // The operation will fail when the PTZ device is moving during the SetPreset operation. 339 | utils.handleResult(node, err, stream, xml, newMsg); 340 | }); 341 | }); 342 | 343 | break; 344 | case "removePreset": 345 | var options = { 346 | 'profileToken': profileToken 347 | }; 348 | 349 | // We will not ask the user to specify the preset token in the input message, to avoid that the preset tokens will 350 | // need to be stored somewhere in the Node-Red flow. Instead the user can specify a preset NAME, and we will lookup 351 | // to which existing preset token this name corresponds... 352 | node.deviceConfig.cam.getPresets(options, function(err, stream, xml) { 353 | // Get the preset token of the specified preset name 354 | var presetToken = stream[presetName]; 355 | 356 | // When the preset token exists, we are dealing with an already existing preset. 357 | // Then pass the preset token to the device, so it will UPDATE the existing preset. 358 | // When the preset token is not passed to the device, the device will create a NEW preset (and a new preset token). 359 | if (presetToken) { 360 | options.presetToken = presetToken; 361 | } 362 | 363 | options.presetName = presetName; 364 | 365 | // Create/update the preset, based on the preset token. The device will save the current camera parameters 366 | // (XY coordinates, zoom level and a focus adjustment) so that the device can move afterwards to that saved 367 | // preset position (via the GotoPreset action). 368 | node.deviceConfig.cam.removePreset(options, function(err, stream, xml) { 369 | utils.handleResult(node, err, stream, xml, newMsg); 370 | }); 371 | }); 372 | 373 | break; 374 | case "gotoPreset": 375 | var options = { 376 | 'profileToken': profileToken 377 | }; 378 | 379 | // We will not ask the user to specify the preset token in the input message, to avoid that the preset tokens will 380 | // need to be stored somewhere in the Node-Red flow. Instead the user can specify a preset NAME, and we will lookup 381 | // to which existing preset token this name corresponds... 382 | node.deviceConfig.cam.getPresets(options, function(err, stream, xml) { 383 | // Get the preset token of the specified preset name 384 | var preset = stream[presetName]; 385 | 386 | // When the preset token doesn't exists, we cannot goto it ... 387 | if (!preset) { 388 | console.warn("Preset token with name " + presetName + " does not exist."); 389 | return; 390 | } 391 | 392 | options.preset = preset; 393 | 394 | // TODO enkel doorgeven indien gedefinieerd. 395 | options.speed = { 396 | x: panSpeed, 397 | y: tiltSpeed, 398 | z: zoomSpeed 399 | }; 400 | 401 | // Create/update the preset, based on the preset token. The device will save the current camera parameters 402 | // (XY coordinates, zoom level and a focus adjustment) so that the device can move afterwards to that saved 403 | // preset position (via the GotoPreset action). 404 | node.deviceConfig.cam.gotoPreset(options, function(err, stream, xml) { 405 | utils.handleResult(node, err, stream, xml, newMsg); 406 | }); 407 | }); 408 | 409 | break; 410 | case "getNodes": 411 | node.deviceConfig.cam.getNodes(function(err, stream, xml) { 412 | utils.handleResult(node, err, stream, xml, newMsg); 413 | }); 414 | 415 | break; 416 | case "getConfigurations": 417 | node.deviceConfig.cam.getConfigurations(function(err, stream, xml) { 418 | utils.handleResult(node, err, stream, xml, newMsg); 419 | }); 420 | 421 | break; 422 | case "getConfigurationOptions": 423 | configurationToken = 'PtzConf1'; // TODO make adjustable (is reeds voorzien op config screen, maar nog niet via input message) 424 | 425 | // getConfigurationOptions will result in an uncaught exception when getConfigurations hasn't been called in advance... 426 | if (!node.deviceConfig.cam.configurations) { 427 | node.deviceConfig.cam.getConfigurations(function(err, stream, xml) { 428 | node.deviceConfig.cam.getConfigurationOptions(configurationToken, function(err, stream, xml) { 429 | utils.handleResult(node, err, stream, xml, newMsg); 430 | }); 431 | }); 432 | } 433 | else { 434 | node.deviceConfig.cam.getConfigurationOptions(configurationToken, function(err, stream, xml) { 435 | utils.handleResult(node, err, stream, xml, newMsg); 436 | }); 437 | } 438 | 439 | break; 440 | case "getStatus": 441 | // TODO error in onvif library due to missing zoom in Panasonic cam (see https://github.com/agsh/onvif/issues/103) 442 | 443 | var options = { 444 | 'profileToken': profileToken, 445 | }; 446 | 447 | node.deviceConfig.cam.getStatus(options, function(err, stream, xml) { 448 | utils.handleResult(node, err, stream, xml, newMsg); 449 | }); 450 | 451 | break; 452 | case "stop": 453 | var options = { 454 | 'profileToken': profileToken, 455 | 'panTilt': node.stopPanTilt, 456 | 'zoom': node.stopZoom 457 | }; 458 | 459 | node.deviceConfig.cam.stop(options, function(err, stream, xml) { 460 | utils.handleResult(node, err, stream, xml, newMsg); 461 | }); 462 | 463 | break; 464 | case "reconnect": 465 | node.deviceConfig.cam.connect(); 466 | break; 467 | default: 468 | //node.status({fill:"red",shape:"dot",text: "unsupported action"}); 469 | node.error("Action " + action + " is not supported"); 470 | } 471 | } 472 | catch (exc) { 473 | node.error("Action " + action + " failed: " + exc); 474 | } 475 | }); 476 | 477 | node.on("close",function() { 478 | if (node.listener) { 479 | node.deviceConfig.removeListener("onvif_status", node.listener); 480 | } 481 | }); 482 | } 483 | RED.nodes.registerType("onvif-ptz",OnVifPtzNode); 484 | } 485 | -------------------------------------------------------------------------------- /onvif_recording.html: -------------------------------------------------------------------------------- 1 | 13 | 36 | 37 | 49 | 50 | 55 | -------------------------------------------------------------------------------- /onvif_recording.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Bart Butenaers 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | **/ 16 | module.exports = function(RED) { 17 | var settings = RED.settings; 18 | const onvif = require('onvif'); 19 | const utils = require('./utils'); 20 | 21 | function OnVifRecordingNode(config) { 22 | RED.nodes.createNode(this, config); 23 | 24 | var node = this; 25 | 26 | // Retrieve the config node, where the device is configured 27 | node.deviceConfig = RED.nodes.getNode(config.deviceConfig); 28 | 29 | if (node.deviceConfig) { 30 | node.listener = function(onvifStatus) { 31 | utils.setNodeStatus(node, 'recording', onvifStatus); 32 | } 33 | 34 | // Start listening for Onvif config nodes status changes 35 | node.deviceConfig.addListener("onvif_status", node.listener); 36 | 37 | // Show the current Onvif config node status already 38 | utils.setNodeStatus(node, 'recording', node.deviceConfig.onvifStatus); 39 | 40 | node.deviceConfig.initialize(); 41 | } 42 | 43 | node.on("input", function(msg) { 44 | var newMsg = {}; 45 | 46 | if (!node.deviceConfig || node.deviceConfig.onvifStatus != "connected") { 47 | node.error("This node is not connected to a device"); 48 | return; 49 | } 50 | 51 | if (!utils.hasService(node.deviceConfig.cam, 'recording')) { 52 | node.error("The device does no support for a recording service"); 53 | return; 54 | } 55 | 56 | newMsg.xaddr = this.deviceConfig.xaddress; 57 | 58 | // TODO deze function call geeft steeds "Error: Wrong ONVIF SOAP response" 59 | // Kan het zijn dat panasonic geen recording service heeft ??? 60 | node.deviceConfig.cam.getRecordings(function(err, stream, xml) { 61 | utils.handleResult(node, err, stream, xml, newMsg); 62 | }); 63 | }); 64 | 65 | node.on("close",function() { 66 | if (node.listener) { 67 | node.deviceConfig.removeListener("onvif_status", node.listener); 68 | } 69 | }); 70 | } 71 | RED.nodes.registerType("onvif-recording",OnVifRecordingNode); 72 | } 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "node-red-contrib-onvif-nodes", 3 | "version" : "0.0.1-beta.16", 4 | "description" : "Node Red nodes for communicating with OnVif compliant IP devices", 5 | "dependencies": { 6 | "onvif": "^0.6.7", 7 | "request": ">= 2.88.2" 8 | }, 9 | "author": { 10 | "name": "Bart Butenaers" 11 | }, 12 | "license": "Apache-2.0", 13 | "keywords": [ "onvif", "ip", "camera", "snapshot", "ptz", "rtsp", "stream", "surveillance" ], 14 | "bugs": { 15 | "url": "https://github.com/bartbutenaers/node-red-contrib-onvif-nodes/issues" 16 | }, 17 | "homepage": "https://github.com/bartbutenaers/node-red-contrib-onvif-nodes", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/bartbutenaers/node-red-contrib-onvif-nodes.git" 21 | }, 22 | "node-red" : { 23 | "nodes": { 24 | "onvif-config": "onvif_config.js", 25 | "onvif-device": "onvif_device.js", 26 | "onvif-discovery": "onvif_discovery.js", 27 | "onvif-imaging": "onvif_imaging.js", 28 | "onvif-media": "onvif_media.js", 29 | "onvif-ptz": "onvif_ptz.js", 30 | "onvif-recording": "onvif_recording.js", 31 | "onvif-events": "onvif_events.js" 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2018 Bart Butenaers 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | **/ 16 | exports.setNodeStatus = function(node, serviceName, onvifStatus) { 17 | switch(onvifStatus) { 18 | case "unconfigured": 19 | node.status({fill:"red",shape:"ring",text:onvifStatus}); 20 | break; 21 | case "initializing": 22 | node.status({fill:"yellow",shape:"dot",text:onvifStatus}); 23 | break; 24 | case "disconnected": 25 | node.status({fill:"red",shape:"ring",text:onvifStatus}); 26 | break; 27 | case "connected": 28 | // Starting from agsh/onvif version 0.6.5, the cam.capabilities have become obsolete. 29 | // Use services instead. See pull request https://github.com/agsh/onvif/pull/155 30 | // When the cam doesn't offer services, the agsh/onvif library has a fallback to the obsolete capabilities. 31 | if (node.deviceConfig.cam.services || node.deviceConfig.cam.capabilities) { 32 | // When connected to the Onvif device, the status depends on whether the device supports the specified service 33 | if (exports.hasService(node.deviceConfig.cam, serviceName)) { 34 | node.status({fill:"green",shape:"dot",text:onvifStatus}); 35 | } 36 | else { 37 | node.status({fill:"red",shape:"ring",text:"unsupported"}); 38 | } 39 | } 40 | else { 41 | node.status({fill:"red",shape:"ring",text:"no services"}); 42 | } 43 | break; 44 | case "": 45 | node.status({}); 46 | break; 47 | default: 48 | node.status({fill:"red",shape:"ring",text:"unknown"}); 49 | } 50 | }; 51 | 52 | exports.handleResult = function(node, err, date, xml, newMsg) { 53 | if (err) { 54 | node.error(err.message); 55 | 56 | var lowercase = err.message.toLowerCase(); 57 | 58 | // Sometimes the OnVif device responds with errors like "Method Not Found", "Action Not Implemented", ... 59 | // In that case we will show an error indicating that the action is not supported by the device. 60 | // WE WON'T SET A TEMPORARY NODE STATUS, BECAUSE OTHERWISE WE SHOULD SHOW THE ORIGINAL STATUS AGAIN AFTER SOME TIME 61 | // (WHICH IS NOT RELEVANT)/ see https://github.com/bartbutenaers/node-red-contrib-onvif-nodes/issues/12 62 | //if (lowercase.includes("not found") || lowercase.includes("not implemented")) { 63 | // node.status({fill:"red",shape:"dot",text: "unsupported action"}); 64 | //} 65 | //else { 66 | // node.status({fill:"red",shape:"dot",text: "failed"}); 67 | //} 68 | 69 | // When a reconnect action fails, then the status needs to become 'disconnected' (because that is no temporary status unlike the others) 70 | if (newMsg.action == "reconnect") { 71 | node.status({fill:"red",shape:"dot",text: "disconnected"}); 72 | } 73 | } 74 | else { 75 | if (newMsg) { 76 | newMsg.payload = date; 77 | node.send(newMsg); 78 | } 79 | 80 | // When a reconnect action succeeds, then the status needs to become 'connected' (because that is no temporary status unlike the others) 81 | if (newMsg.action == "reconnect") { 82 | node.status({fill:"blue",shape:"dot",text: "connected"}); 83 | } 84 | } 85 | } 86 | 87 | exports.hasService = function (cam, serviceName) { 88 | if (cam.services) { 89 | // Check whether there is a service available, whose XAddr contains the specified service name 90 | var hasService = cam.services.some(function (service) { 91 | return service.XAddr && service.XAddr.toLowerCase().includes(serviceName.toLowerCase()); 92 | }); 93 | 94 | if (!hasService) { 95 | // Check whether there is a service available, whose namespace contains the specified service name 96 | hasService = cam.services.some(function (service) { 97 | return service.namespace && service.namespace.toLowerCase().includes(serviceName.toLowerCase()); 98 | }); 99 | } 100 | 101 | return hasService; 102 | } 103 | else if (cam.capabilities) { 104 | // When the cam doesn't offer services, the agsh/onvif library has a fallback to the obsolete capabilities 105 | return Object.keys(cam.capabilities).some(function (capabilityName) { 106 | var service = cam.capabilities[capabilityName]; 107 | return service.XAddr && capabilityName.toLowerCase().includes(serviceName.toLowerCase()); 108 | }); 109 | } 110 | else { 111 | return false; 112 | } 113 | } 114 | --------------------------------------------------------------------------------