├── logo.png ├── docs ├── Presentation1.pdf └── Presentation1.pptx ├── .eslintrc.js ├── package.json ├── camera_list.json ├── LICENSE ├── .gitignore ├── .vscode └── launch.json ├── README.md └── onvif-audit.js /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RogerHardiman/onvif-audit/HEAD/logo.png -------------------------------------------------------------------------------- /docs/Presentation1.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RogerHardiman/onvif-audit/HEAD/docs/Presentation1.pdf -------------------------------------------------------------------------------- /docs/Presentation1.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RogerHardiman/onvif-audit/HEAD/docs/Presentation1.pptx -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "parserOptions": { 8 | "ecmaVersion": 12, 9 | "sourceType": "module" 10 | }, 11 | "rules": { 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "onvif-site-audit", 3 | "version": "1.6.0", 4 | "author": "Roger Hardiman ", 5 | "description": "ONVIF Camera Audit Tool", 6 | "main": "/audit.js", 7 | "scripts": { 8 | "eslint": "eslint" 9 | }, 10 | "contributors": [ 11 | { 12 | "name": "Roger Hardiman", 13 | "email": "contact@rjh.org.uk", 14 | "url": "http://www.rjh.org.uk" 15 | } 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/rogerhardiman/onvif-audit.git" 20 | }, 21 | "dependencies": { 22 | "commander": "^4.0.1", 23 | "nimble": "0.0.2", 24 | "node-datetime": "^2.1.2", 25 | "onvif": "https://github.com/agsh/onvif.git#cc4e57f", 26 | "request-digest": "^1.0.13", 27 | "xml2js": "^0.6.2" 28 | }, 29 | "keywords": [ 30 | "onvif", 31 | "video", 32 | "PTZ", 33 | "camera", 34 | "RTSP" 35 | ], 36 | "license": "MIT", 37 | "engines": { 38 | "node": ">=6.0" 39 | }, 40 | "devDependencies": { 41 | "eslint": "^8.57.1" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /camera_list.json: -------------------------------------------------------------------------------- 1 | { 2 | "cameralist": [ 3 | { 4 | "ipaddress": "193.159.244.134", 5 | "port": "80", 6 | "username": "service", 7 | "password": "Xbks8tr8vT", 8 | "comment": "Bosch" 9 | }, 10 | { 11 | "ipaddress": "193.159.244.132-193.159.244.132", 12 | "port": "80", 13 | "username": "service", 14 | "password": "Xbks8tr8vT", 15 | "comment": "Bosch" 16 | }, 17 | { 18 | "ipaddress": "195.60.68.239", 19 | "port": "81", 20 | "username": "operator", 21 | "password": "Onv!f2018", 22 | "comment": "Axis" 23 | }, 24 | { 25 | "ipaddress": "123.157.208.28", 26 | "port": "81", 27 | "username": "admin", 28 | "password": "abcd1234", 29 | "comment": "HikVision" 30 | }, 31 | { 32 | "ipaddress": "61.164.52.166", 33 | "port": "88", 34 | "username": "admin", 35 | "password": "Uniview2018", 36 | "comment": "UniView" 37 | }, 38 | { 39 | "ipaddress": "60.191.94.122-60.191.94.122", 40 | "port": "8086", 41 | "username": "admin", 42 | "password": "a1b2c3d4", 43 | "comment": "Dahua" 44 | } 45 | ] 46 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Roger Hardiman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.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 | 63 | # Output from this application 64 | camera_report_*.txt 65 | snapshot_*.jpg -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${workspaceFolder}/onvif-audit.js" 12 | }, 13 | { 14 | "type": "node", 15 | "request": "launch", 16 | "name": "Launch Program with camera_list.json File", 17 | "program": "${workspaceFolder}/onvif-audit.js", 18 | "args": ["--filename","./camera_list.json"] 19 | }, 20 | { 21 | "type": "node", 22 | "request": "launch", 23 | "name": "Launch Program with Discovery", 24 | "program": "${workspaceFolder}/onvif-audit.js", 25 | "args": ["--scan"] 26 | }, 27 | { 28 | { 29 | "type": "node", 30 | "request": "launch", 31 | "name": "Launch Program with 192.168.1.1", 32 | "program": "${workspaceFolder}/onvif-audit.js", 33 | "args": ["--ipaddress","192.168.1.1","--username","user","--password","pass"] 34 | }, 35 | { 36 | "type": "node", 37 | "request": "launch", 38 | "name": "Launch Program with Range 192.128.1.1 to .10", 39 | "program": "${workspaceFolder}/onvif-audit.js", 40 | "args": ["--ipaddress","192.168.1.1-192.168.1.10","--username","user","--password","pass"] 41 | } 42 | ] 43 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # onvif-audit 2 | 3 | onvif-audit will scan a network looking for ONVIF cameras and create an audit log folder containing 4 | 5 | * Text File which reports the Camera Make and Model and Serial Number and the Camera Time (to check Time Sync Errors) 6 | * JPEG Snapshot of the camera view 7 | 8 | # Installation 9 | You can use the pre-compiled packages for Windows, Linux and Mac. They can be downloaded from the Releases page https://github.com/RogerHardiman/onvif-audit/releases 10 | 11 | Or checkout the source code with `git clone` and then run `npm install` to fetch the modules and dependencies onvif-audit uses. 12 | 13 | # Command Line and Config File Usage 14 | The Audit can be controlled from the Command Line or via a Configuration File 15 | 16 | ## Command Line 17 | Command Line parameters are used to provide a single IP address or a range of IP addresses to scan, along with Username and Password. 18 | Example to scan a network for all cameras in the range 192.168.1.1 to 192.168.1.254 19 | ``` 20 | Windows Binary users:- onvif-audit.exe --ipaddress 192.168.1.1-192.168.1.254 --username user --password 1234 21 | NodeJS source users:- node onvif-audit.js --ipaddress 192.168.1.1-192.168.1.254 --username user --password 1234 22 | ``` 23 | 24 | A full list of commands can be obtained with the -h option 25 | ``` 26 | Windows Binary users:- onvif-audit.exe -h 27 | NodeJS source users:- node onvif-audit.js -h 28 | ``` 29 | 30 | ## Config File 31 | A JSON formatted Configuration File is used to give the Audit tool a list of cameras to scan. 32 | An example is shown below which first scans the range of IPs from 1.2.3.20 to 1.2.3.30 and then scans a single address of 11.22.33.44 33 | ``` 34 | Windows Binary users:- onvif-audit.exe --filename ./camera_list.json 35 | NodeJS source users:- node onvif-audit.js --filename ./camera_list.json 36 | ``` 37 | 38 | cameralist.json contains this.... 39 | ``` 40 | { 41 | "cameralist": [ 42 | { 43 | "ipaddress": "1.2.3.20-1.2.3.30", 44 | "port": "80", 45 | "username": "service", 46 | "password": "password", 47 | "comment": "Bosch" 48 | }, 49 | { 50 | "ipaddress": "11.22.33.44", 51 | "port": "80", 52 | "username": "admin", 53 | "password": "password", 54 | "comment": "HikVision" 55 | } 56 | 57 | } 58 | ``` 59 | 60 | # ONVIF Discovery vrs IP address range scan 61 | ONVIF Audit supports Discovery of devices on the local network with the --scan option. 62 | This is great for scanning the local subnet but does not work over routed networks with different IP address ranges. 63 | This is why this tool also uses IP address ranges to scan the network. 64 | 65 | # Building the Binary Executable Version 66 | The npm package called 'pkg' is used to compile the Javascript into a standalone executable for Windows, Mac and Linux. Run ```./node_modules/pkg/lib-es5/bin.js onvif-audit.js``` 67 | 68 | # Future Plans 69 | a) Use the ONVIF Absolute PTZ Position Command to take a snapshot looking in different directions 70 | b) Record a short video clip using ffmpeg or the node RTSP client called yellowstone 71 | 72 | -------------------------------------------------------------------------------- /onvif-audit.js: -------------------------------------------------------------------------------- 1 | /** 2 | * (C) Roger Hardiman 3 | * First Release - May 2018 4 | * Licenced with the MIT Licence 5 | * 6 | * Perform a brute force scan of the network looking for ONVIF devices 7 | * For each device, save the make and model and a snapshot in the audit folder 8 | * 9 | * Can also use ONVIF Discovery to trigger the scan 10 | */ 11 | 12 | var IPADDRESS = '192.168.1.1-192.168.1.254', // single address or a range 13 | PORT = '80', 14 | USERNAME = 'onvifusername', 15 | PASSWORD = 'onvifpassword'; 16 | 17 | var onvif = require('onvif'); 18 | var Cam = onvif.Cam; 19 | var flow = require('nimble'); 20 | var args = require('commander'); 21 | var fs = require('fs'); 22 | var dateTime = require('node-datetime'); 23 | var path = require('path'); 24 | var xml2js = require('xml2js') 25 | var stripPrefix = require('xml2js').processors.stripPrefix; 26 | 27 | 28 | 29 | // Show Version 30 | var version = require('./package.json').version; 31 | args.version(version); 32 | args.description('ONVIF Camera Audit'); 33 | args.option('-f, --filename ', 'Filename of JSON file with IP Address List'); 34 | args.option('-i, --ipaddress ', 'IP Address (x.x.x.x) or IP Address Range (x.x.x.x-y.y.y.y)'); 35 | args.option('-P, --port ', 'ONVIF Port. Default 80'); 36 | args.option('-u, --username ', 'ONVIF Username'); 37 | args.option('-p, --password ', 'ONVIF Password'); 38 | args.option('-s, --scan', 'Discover Network devices on local subnet'); 39 | args.parse(process.argv); 40 | 41 | if (!args) { 42 | args.help(); 43 | process.exit(1); 44 | 45 | } 46 | 47 | if (!args.filename && !args.ipaddress && !args.scan) { 48 | console.log('Requires either a Filename (-f) or an IP Address/IP Range (-i) or a Scan (-s)'); 49 | console.log('Use -h for details'); 50 | process.exit(1); 51 | } 52 | 53 | let time_now = dateTime.create(); 54 | let folder = 'onvif_audit_report_' + time_now.format('Y_m_d_H_M_S'); 55 | 56 | try { 57 | fs.mkdirSync(folder); 58 | } catch (e) { 59 | console.log('Unable to create log folder') 60 | process.exit(1) 61 | } 62 | 63 | 64 | if (args.ipaddress) { 65 | // Connection Details and IP Address supplied in the Command Line 66 | IPADDRESS = args.ipaddress; 67 | if (args.port) PORT = args.port; 68 | if (args.username) USERNAME = args.username; 69 | if (args.password) PASSWORD = args.password; 70 | 71 | 72 | // Perform an Audit of all the cameras in the IP address Range 73 | perform_audit(IPADDRESS, PORT, USERNAME, PASSWORD, folder); 74 | } 75 | 76 | if (args.filename) { 77 | // Connection details supplied in a .JSON file 78 | let contents = fs.readFileSync(args.filename); 79 | let file = JSON.parse(contents); 80 | 81 | if (file.cameralist && file.cameralist.length > 0) { 82 | // process each item in the camera list 83 | //Note - forEach is asynchronous - you don't know when it has completed 84 | file.cameralist.forEach(function (item) { 85 | // check IP range start and end 86 | if (item.ipaddress) IPADDRESS = item.ipaddress; 87 | if (item.port) PORT = item.port; 88 | if (item.username) USERNAME = item.username; 89 | if (item.password) PASSWORD = item.password; 90 | 91 | perform_audit(IPADDRESS, PORT, USERNAME, PASSWORD, folder); 92 | } 93 | ); 94 | } 95 | } 96 | 97 | if (args.scan) { 98 | console.log("Probing for 5 seconds"); 99 | 100 | let scanResults = []; 101 | 102 | // set up an event handler which is called for each device discovered 103 | onvif.Discovery.on('device', function (cam, rinfo, xml) { 104 | // function will be called as soon as the NVT responses 105 | 106 | /* Filter out xml name spaces */ 107 | xml = xml.replace(/xmlns([^=]*?)=(".*?")/g, ''); 108 | 109 | let parser = new xml2js.Parser({ 110 | attrkey: 'attr', 111 | charkey: 'payload', // this ensures the payload is called .payload regardless of whether the XML Tags have Attributes or not 112 | explicitCharkey: true, 113 | tagNameProcessors: [stripPrefix] // strip namespace eg tt:Data -> Data 114 | }); 115 | parser.parseString(xml, 116 | function (err, result) { 117 | if (err) return; 118 | 119 | // By default xml2js will return different json structures depending on whether there are 'attributes' in the XML 120 | // For example HELLO will return value=123 as the '$ field and HELLO as the '_' field 121 | // For example HELLO does not use the '$' or '_' fields. 122 | // To make things easier to handle, we use parser options to place the data we want in a 'payload' field 123 | 124 | let urn = result['Envelope']['Body'][0]['ProbeMatches'][0]['ProbeMatch'][0]['EndpointReference'][0]['Address'][0].payload.trim(); 125 | let xaddrs = result['Envelope']['Body'][0]['ProbeMatches'][0]['ProbeMatch'][0]['XAddrs'][0].payload.trim(); // Axis add whitespace on end. Remove it. 126 | let scopes = result['Envelope']['Body'][0]['ProbeMatches'][0]['ProbeMatch'][0]['Scopes'][0].payload.trim(); // Axis add whitespace on end. Remove it. 127 | scopes = scopes.split(" "); 128 | 129 | let hardware = ""; 130 | let name = ""; 131 | for (let i = 0; i < scopes.length; i++) { 132 | // use decodeUri to conver %20 to ' ' 133 | if (scopes[i].includes('onvif://www.onvif.org/name')) name = decodeURI(scopes[i].substring(27)); 134 | if (scopes[i].includes('onvif://www.onvif.org/hardware')) hardware = decodeURI(scopes[i].substring(31)); 135 | } 136 | 137 | process.stdout.write("."); 138 | 139 | const newItem = { 140 | rinfo, 141 | name, 142 | hardware, 143 | xaddrs, 144 | urn, 145 | scopes 146 | }; 147 | scanResults.push(newItem); 148 | } 149 | ); 150 | 151 | }) 152 | onvif.Discovery.on('error', function (err) { 153 | // ignore discovery errors 154 | }) 155 | 156 | // start the probe 157 | // resolve=false means Do not create Cam objects 158 | onvif.Discovery.probe({ resolve: false }, function() { 159 | // completion callback 160 | process.stdout.write("\n"); 161 | 162 | // sort the Scan Results by IP Address 163 | scanResults.sort((a,b) => { 164 | // Check we are matching IPv4 against IPv6. If so sort IPv4 first 165 | if (a.rinfo.family < b.rinfo.family) 166 | return -1; 167 | else if (a.rinfo.family > b.rinfo.family) 168 | return 1; 169 | else { 170 | // A and B are both the same "family" (IPv4 or IPv6) 171 | 172 | if (a.rinfo.family == 'IPv4') { 173 | // IPv4 - sort numerically 174 | return toLong(a.rinfo.address) - toLong(b.rinfo.address); 175 | } 176 | else if (a.rinfo.family == 'IPv6') { 177 | // IPv6 - sort by String. Could be improved 178 | if (a.rinfo.address < b.rinfo.address) return -1; 179 | else if (a.rinfo.address > b.rinfo.address) return 1; 180 | else return 0; 181 | } 182 | else { 183 | // Unknown IP family 184 | return 0; 185 | } 186 | } 187 | }); 188 | 189 | for(const item of scanResults) { 190 | let msg = item.rinfo.address + ' (' + item.name + ') (' + item.hardware + ') (' + item.xaddrs + ') (' + item.urn + ')'; 191 | console.log(msg); 192 | } 193 | console.log("Total " + scanResults.length); 194 | }); 195 | 196 | } 197 | 198 | 199 | // program ends here (just functions below) 200 | 201 | 202 | function perform_audit(ip_addresses, port, username, password, folder) { 203 | 204 | let ip_list = []; 205 | 206 | // Valid IP addresses are 207 | // a) Single address 1.2.3.4 208 | // b) Range 10.10.10.50-10.10.10.99 209 | // c) List 1.1.1.1,2.2.2.2,3.3.3.3 210 | // d) Mixture 1.2.3.4,10.10.10.50-10.10.10.99 211 | 212 | ip_addresses = ip_addresses.split(','); 213 | for (let i = 0; i < ip_addresses.length; i++) { 214 | let item = ip_addresses[i]; 215 | if (item.includes('-')) { 216 | // item contains '-'. Split on the '-' 217 | let split_str = item.split('-'); 218 | if (split_str.length != 2) { 219 | console.log('IP address format incorrect. Should by x.x.x.x-y.y.y.y'); 220 | process.exit(1); 221 | } 222 | let ip_start = split_str[0]; 223 | let ip_end = split_str[1]; 224 | 225 | let tmp_list = generate_range(ip_start, ip_end); 226 | 227 | // Copy 228 | for (let x = 0; x < tmp_list.length; x++) ip_list.push(tmp_list[x]); 229 | } 230 | else { 231 | // item does not include a '-' symbol 232 | ip_list.push(item); 233 | } 234 | } 235 | 236 | 237 | // console.log('Scanning ' + ip_list.length + ' addresses from ' + ip_list[0] + ' to ' + ip_list[ip_list.length-1]); 238 | 239 | 240 | // hide error messages 241 | console.error = function () { }; 242 | 243 | // try each IP address and each Port 244 | ip_list.forEach(function (ip_entry) { 245 | 246 | // workaround the ONVIF Library API 247 | // Cam() with a username and password tries to connect (and genertes a callback error) 248 | // and then it tries to call some SOAP methods which fails (and it generates a callback error) 249 | let shown_error = false; 250 | 251 | console.log("Connecting to " + ip_entry + ':' + port); 252 | 253 | const c = new Cam({ 254 | hostname: ip_entry, 255 | username: username, 256 | password: password, 257 | port: port, 258 | timeout: 5000 259 | }, function CamFunc(err) { 260 | if (err) { 261 | if (shown_error == false) { 262 | console.log('------------------------------'); 263 | console.log("Cannot connect to " + ip_entry + ":" + port); 264 | // cut the error at \n 265 | if (err.message) console.log(err.message); 266 | else console.log(err); 267 | console.log('------------------------------'); 268 | shown_error = true; 269 | } 270 | return; 271 | } 272 | 273 | let cam_obj = this; 274 | 275 | let got_date; 276 | let got_info; 277 | let got_videosources = []; 278 | let got_profiles = []; 279 | let bestProfile = []; // The preferred Profile indexed by Video Source. 280 | let got_snapshots = []; // JPEG Imag URLs, indexed by Video Source 281 | let got_livestreams = []; // RTSP URLs, indexed by Video Source 282 | 283 | // Use Nimble to execute each ONVIF function in turn 284 | // This is used so we can wait on all ONVIF replies before 285 | // writing to the console 286 | flow.series([ 287 | function (nimble_callback) { 288 | cam_obj.getSystemDateAndTime(function (err, date) { 289 | if (!err) got_date = date; 290 | nimble_callback(); 291 | }); 292 | }, 293 | function (nimble_callback) { 294 | cam_obj.getDeviceInformation(function (err, info) { 295 | if (!err) got_info = info; 296 | nimble_callback(); 297 | }); 298 | }, 299 | function (nimble_callback) { 300 | try { 301 | cam_obj.getVideoSources(function (err, videoSources) { 302 | if (!err) { 303 | got_videosources = videoSources; 304 | 305 | for (let i = 0; i < got_videosources.length; i++) { 306 | // create empty placeholders 307 | bestProfile.push({}); 308 | got_snapshots.push({videoSourceToken: null, uri: null}); 309 | got_livestreams.push({tcp: null, udp: null, http: null, multicast: null}); 310 | } 311 | } 312 | nimble_callback(); 313 | }); 314 | } catch { 315 | nimble_callback(); 316 | } 317 | }, 318 | function (nimble_callback) { 319 | try { 320 | cam_obj.getProfiles(function (err, profiles) { 321 | if (!err) got_profiles = profiles; 322 | nimble_callback(); 323 | }); 324 | } catch { 325 | nimble_callback(); 326 | } 327 | }, 328 | function (nimble_callback) { 329 | // Compare VideoSources with Profiles. 330 | // Get the 'best' ONVIF Profile Token for each Video Source 331 | for (let src_idx = 0; src_idx < got_videosources.length; src_idx++) { 332 | const videoSource = got_videosources[src_idx]; 333 | 334 | // Get the 'best' profile for this videoSource token 335 | // For most cameras we just find the first Profile which has the Video Source Token 336 | // but Hanwha emit the JPEG Profile first, then H264, then H265. So we have to find the 'best' Profile ourselves. 337 | // The Best one is the first H265, otherwise the first H264, otherwise the first MPEG4 otherwise the first JPEG stream 338 | let firstH265 = got_profiles.findIndex(item => 339 | item.videoSourceConfiguration && item.videoEncoderConfiguration 340 | && item.videoSourceConfiguration.sourceToken == videoSource.$.token 341 | && item.videoEncoderConfiguration.encoding == "H265"); 342 | let firstH264 = got_profiles.findIndex(item => 343 | item.videoSourceConfiguration && item.videoEncoderConfiguration 344 | && item.videoSourceConfiguration.sourceToken == videoSource.$.token 345 | && item.videoEncoderConfiguration.encoding == "H264"); 346 | let firstMPEG4 = got_profiles.findIndex(item => 347 | item.videoSourceConfiguration && item.videoEncoderConfiguration 348 | && item.videoSourceConfiguration.sourceToken == videoSource.$.token 349 | && item.videoEncoderConfiguration.encoding == "MPEG4"); 350 | let firstJPEG = got_profiles.findIndex(item => 351 | item.videoSourceConfiguration && item.videoEncoderConfiguration 352 | && item.videoSourceConfiguration.sourceToken == videoSource.$.token 353 | && item.videoEncoderConfiguration.encoding == "JPEG"); 354 | let firstOther = got_profiles.findIndex(item => 355 | item.videoSourceConfiguration && item.videoEncoderConfiguration 356 | && item.videoSourceConfiguration.sourceToken == videoSource.$.token 357 | ); 358 | 359 | if (firstH265 >= 0) bestProfile[src_idx] = got_profiles[firstH265]; 360 | else if (firstH264 >= 0) bestProfile[src_idx] = got_profiles[firstH264]; 361 | else if (firstMPEG4 >= 0) bestProfile[src_idx] = got_profiles[firstMPEG4]; 362 | else if (firstJPEG >= 0) bestProfile[src_idx] = got_profiles[firstJPEG]; 363 | else bestProfile[src_idx] = got_profiles[firstOther]; 364 | } 365 | 366 | nimble_callback(); 367 | }, 368 | function (nimble_callback) { 369 | try { 370 | // The ONVIF device may have multiple Video Sources 371 | // eg 4 channel IP encoder or Panoramic Cameras 372 | // Grab a JPEG Image from each VideoSource 373 | // Note. The Nimble Callback is only called once all ONVIF replies have been returned 374 | const reply_max = got_videosources.length; 375 | let reply_count = 0; 376 | 377 | for (let src_idx = 0; src_idx < got_videosources.length; src_idx++) { 378 | const videoSource = got_videosources[src_idx]; 379 | 380 | cam_obj.getSnapshotUri({ profileToken: bestProfile[src_idx].$.token}, (err, getUri_result) => { 381 | reply_count++; 382 | 383 | if (!err && getUri_result) { 384 | 385 | got_snapshots[src_idx] = {videoSourceToken: videoSource.$.token, uri: getUri_result.uri}; 386 | 387 | const fs = require('fs'); 388 | const url = require('url'); 389 | 390 | let filename = ""; 391 | if (got_videosources.length === 1) { 392 | filename = folder + path.sep + 'snapshot_' + ip_entry + '.jpg'; 393 | } else { 394 | // add _1, _2, _3 etc for cameras with multiple VideoSources 395 | filename = folder + path.sep + 'snapshot_' + ip_entry + '_' + (src_idx + 1) + '.jpg'; 396 | } 397 | let uri = url.parse(getUri_result.uri); 398 | 399 | // handle the case where the camera is behind NAT 400 | // ONVIF Standard now says use XAddr for camera 401 | // and ignore the IP address in the Snapshot URI 402 | uri.host = ip_entry; 403 | uri.username = username; 404 | uri.password = password; 405 | if (!uri.port) uri.port = 80; 406 | 407 | let digestRequest = require('request-digest')(username, password); 408 | digestRequest.request({ 409 | host: 'http://' + uri.host, 410 | path: uri.path, 411 | port: uri.port, 412 | encoding: null, // return data as a Buffer() 413 | method: 'GET' 414 | // headers: { 415 | // 'Custom-Header': 'OneValue', 416 | // 'Other-Custom-Header': 'OtherValue' 417 | // } 418 | }, function (error, response, body) { 419 | if (error) { 420 | // console.log('Error downloading snapshot'); 421 | // throw error; 422 | } else { 423 | 424 | fs.open(filename, 'w', function (err) { 425 | // callback for file opened, or file open error 426 | if (err) { 427 | console.log('ERROR - cannot create output log file'); 428 | console.log(err); 429 | console.log(''); 430 | process.exit(1); 431 | } 432 | fs.appendFile(filename, body, function (err) { 433 | if (err) { 434 | console.log('Error writing to file'); 435 | } 436 | }); 437 | 438 | }); 439 | } 440 | }); 441 | } 442 | 443 | if (reply_count === reply_max) nimble_callback(); // let 'flow' move on. JPEG GET is still async 444 | }); 445 | } // end for 446 | } catch (err) { nimble_callback(); } 447 | }, 448 | function (nimble_callback) { 449 | const reply_max = got_videosources.length * 4; // x4 for TCP, UDP, HTTP and MULTICAST URLs 450 | let reply_count = 0; 451 | for (let src_idx = 0; src_idx < got_videosources.length; src_idx++) { 452 | const profileToken = bestProfile[src_idx].$.token; 453 | 454 | flow.series([ 455 | function (inner_nimble_callback) { 456 | try { 457 | cam_obj.getStreamUri({ 458 | protocol: 'RTSP', 459 | stream: 'RTP-Unicast', 460 | profileToken: profileToken 461 | }, function (err, stream) { 462 | if (!err) got_livestreams[src_idx].tcp = stream.uri; 463 | reply_count++; 464 | inner_nimble_callback(); 465 | if (reply_count == reply_max) nimble_callback(); 466 | }); 467 | } catch (err) { 468 | inner_nimble_callback(); 469 | reply_count++; 470 | if (reply_count == reply_max) nimble_callback(); 471 | } 472 | }, 473 | function (inner_nimble_callback) { 474 | try { 475 | cam_obj.getStreamUri({ 476 | protocol: 'UDP', 477 | stream: 'RTP-Unicast', 478 | profileToken: profileToken 479 | }, function (err, stream) { 480 | if (!err) got_livestreams[src_idx].udp = stream.uri; 481 | reply_count++; 482 | inner_nimble_callback(); 483 | if (reply_count == reply_max) nimble_callback(); 484 | }); 485 | } catch (err) { 486 | reply_count++; 487 | inner_nimble_callback(); 488 | if (reply_count == reply_max) nimble_callback(); 489 | } 490 | }, 491 | function (inner_nimble_callback) { 492 | try { 493 | cam_obj.getStreamUri({ 494 | protocol: 'HTTP', 495 | stream: 'RTP-Unicast', 496 | profileToken: profileToken 497 | }, function (err, stream) { 498 | if (!err) got_livestreams[src_idx].http = stream.uri; 499 | reply_count++; 500 | inner_nimble_callback(); 501 | if (reply_count == reply_max) nimble_callback(); 502 | }); 503 | } catch (err) { 504 | reply_count++; 505 | inner_nimble_callback(); 506 | if (reply_count == reply_max) nimble_callback(); 507 | } 508 | }, 509 | function (inner_nimble_callback) { 510 | /* Multicast is optional in Profile S, Mandatory in Profile T but could be disabled */ 511 | try { 512 | cam_obj.getStreamUri({ 513 | protocol: 'UDP', 514 | stream: 'RTP-Multicast', 515 | profileToken: profileToken 516 | }, function (err, stream, xml) { 517 | if (!err) got_livestreams[src_idx].multicast = stream.uri; 518 | reply_count++; 519 | inner_nimble_callback(); 520 | if (reply_count == reply_max) nimble_callback(); 521 | }); 522 | } catch (err) { 523 | reply_count++; 524 | inner_nimble_callback(); 525 | if (reply_count == reply_max) nimble_callback(); 526 | } 527 | } 528 | ]); // end of inner flow 529 | } // end for loop 530 | 531 | // Note nimble_callback(); is called when all work is done 532 | }, 533 | function (nimble_callback) { 534 | console.log('------------------------------'); 535 | console.log('Host: ' + ip_entry + ' Port: ' + port); 536 | console.log('Date: = ' + got_date); 537 | console.log('Info: = ' + JSON.stringify(got_info)); 538 | for (let i = 0; i < got_videosources.length; i++) { 539 | let msg = "Video Source " + (i+1) + ' [' + got_videosources[i].$.token + '] [' + bestProfile[i].videoEncoderConfiguration.encoding + ' ' 540 | + bestProfile[i].videoEncoderConfiguration.resolution.width + 'x' + bestProfile[i].videoEncoderConfiguration.resolution.height + ']'; 541 | 542 | console.log(msg); 543 | 544 | if (got_snapshots[i].uri != null) { 545 | console.log('Snapshot URI: = ' + got_snapshots[i].uri); 546 | } 547 | if (got_livestreams[i].tcp != null) { 548 | console.log('Live TCP Stream: = ' + got_livestreams[i].tcp); 549 | } 550 | if (got_livestreams[i].udp != null) { 551 | console.log('Live UDP Stream: = ' + got_livestreams[i].udp); 552 | } 553 | if (got_livestreams[i].http != null) { 554 | console.log('Live HTTP Stream: = ' + got_livestreams[i].http); 555 | } 556 | if (got_livestreams[i].multicast != null) { 557 | console.log('Live Multicast Stream: = ' + got_livestreams[i].multicast); 558 | } 559 | console.log('------------------------------'); 560 | } 561 | 562 | let log_filename = folder + path.sep + 'camera_report_' + ip_entry + '.txt'; 563 | let log_fd; 564 | 565 | fs.open(log_filename, 'w', function (err, fd) { 566 | if (err) { 567 | console.log('ERROR - cannot create output file ' + log_filename); 568 | console.log(err); 569 | console.log(''); 570 | process.exit(1); 571 | } 572 | log_fd = fd; 573 | //console.log('Log File Open (' + log_filename + ')'); 574 | 575 | // write to log file in the Open callback 576 | let msg = 'Host:= ' + ip_entry + ' Port:= ' + port + '\r\n'; 577 | if (got_date) { 578 | msg += 'Date:= ' + got_date + '\r\n'; 579 | } else { 580 | msg += 'Date:= unknown\r\n'; 581 | } 582 | if (got_info) { 583 | msg += 'Manufacturer:= ' + got_info.manufacturer + '\r\n'; 584 | msg += 'Model:= ' + got_info.model + '\r\n'; 585 | msg += 'Firmware Version:= ' + got_info.firmwareVersion + '\r\n'; 586 | msg += 'Serial Number:= ' + got_info.serialNumber + '\r\n'; 587 | msg += 'Hardware ID:= ' + got_info.hardwareId + '\r\n'; 588 | } else { 589 | msg += 'Manufacturer:= unknown\r\n'; 590 | msg += 'Model:= unknown\r\n'; 591 | msg += 'Firmware Version:= unknown\r\n'; 592 | msg += 'Serial Number:= unknown\r\n'; 593 | msg += 'Hardware ID:= unknown\r\n'; 594 | } 595 | for (let i = 0; i < got_videosources.length; i++) { 596 | msg += "Video Source " + (i+1) + ' [' + got_videosources[i].$.token + '] [' + bestProfile[i].videoEncoderConfiguration.encoding + ' ' 597 | + bestProfile[i].videoEncoderConfiguration.resolution.width + 'x' + bestProfile[i].videoEncoderConfiguration.resolution.height + ']\r\n'; 598 | 599 | if (got_snapshots[i].uri != null) { 600 | msg += 'Snapshot URL: = ' + got_snapshots[i].uri + '\r\n'; 601 | } 602 | 603 | if (got_livestreams[i].tcp != null) { 604 | msg += 'Live TCP Stream: = ' + got_livestreams[i].tcp + '\r\n'; 605 | } 606 | if (got_livestreams[i].udp != null) { 607 | msg += 'Live UDP Stream: = ' + got_livestreams[i].udp + '\r\n'; 608 | } 609 | if (got_livestreams[i].http != null) { 610 | msg += 'Live HTTP Stream: = ' + got_livestreams[i].http + '\r\n'; 611 | } 612 | if (got_livestreams[i].multicast != null) { 613 | msg += 'Live Multicast Stream: = ' + got_livestreams[i].multicast + '\r\n'; 614 | } 615 | } 616 | fs.write(log_fd, msg, function (err) { 617 | if (err) 618 | console.log('Error writing to file'); 619 | }); 620 | 621 | }); 622 | 623 | 624 | 625 | 626 | nimble_callback(); 627 | }, 628 | 629 | ]); // end flow 630 | 631 | }); 632 | 633 | // Log ONVIF XML Messages from the Onvif Library 634 | //c.on("rawRequest", (data) => console.log("\nTX DATA:", data)); 635 | //c.on("rawResponse", (data) => console.log("\nRX DATA:", data)); 636 | 637 | }); // foreach 638 | } 639 | 640 | function generate_range(start_ip, end_ip) { 641 | let start_long = toLong(start_ip); 642 | let end_long = toLong(end_ip); 643 | if (start_long > end_long) { 644 | let tmp = start_long; 645 | start_long = end_long 646 | end_long = tmp; 647 | } 648 | let range_array = []; 649 | for (let i = start_long; i <= end_long; i++) { 650 | range_array.push(fromLong(i)); 651 | } 652 | return range_array; 653 | } 654 | 655 | //toLong taken from NPM package 'ip' 656 | function toLong(ip) { 657 | let ipl = 0; 658 | ip.split('.').forEach(function (octet) { 659 | ipl <<= 8; 660 | ipl += parseInt(octet); 661 | }); 662 | return (ipl >>> 0); 663 | } 664 | 665 | //fromLong taken from NPM package 'ip' 666 | function fromLong(ipl) { 667 | return ((ipl >>> 24) + '.' + 668 | (ipl >> 16 & 255) + '.' + 669 | (ipl >> 8 & 255) + '.' + 670 | (ipl & 255)); 671 | } 672 | --------------------------------------------------------------------------------