From 8c8ad8e64108ea13f4024a940e6b2b7a9dfdc763 Mon Sep 17 00:00:00 2001 From: ddd999 <> Date: Tue, 15 Oct 2024 21:47:44 -0700 Subject: [PATCH] Refactor and add unit tests Add more unit tests for functions in videostream.js --- package-lock.json | 181 ++++++++++++++++++- package.json | 4 +- server/videostream.js | 266 +++++++++++++--------------- server/videostream.test.js | 348 ++++++++++++++++++++++++++++++++++++- 4 files changed, 648 insertions(+), 151 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7b40360a..07c1bbd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,9 @@ "npm-run-all": "^4.1.5", "pino-colada": "^2.2.2", "pino-http": "^10.3.0", - "should": "^13.2.3" + "should": "^13.2.3", + "sinon": "^19.0.2", + "sinon-chai": "^3.7.0" } }, "node_modules/@alloc/quick-lru": { @@ -4008,6 +4010,45 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons/node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true, + "license": "(Unlicense OR Apache-2.0)" + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -13022,6 +13063,13 @@ "node": ">=4.0" } }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true, + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -13235,6 +13283,13 @@ "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -13853,6 +13908,60 @@ "dev": true, "license": "MIT" }, + "node_modules/nise": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", + "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.1", + "@sinonjs/text-encoding": "^0.7.3", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.1.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.2.tgz", + "integrity": "sha512-4Bb+oqXZTSTZ1q27Izly9lv8B9dlV61CROxPiVtywwzv5SnytJqhvYe6FclHYuXml4cd1VHPo1zd5PmTeJozvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/nise/node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -21956,6 +22065,76 @@ "node": ">=10" } }, + "node_modules/sinon": { + "version": "19.0.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz", + "integrity": "sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.2", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "nise": "^6.1.1", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon-chai": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-3.7.0.tgz", + "integrity": "sha512-mf5NURdUaSdnatJx3uhoBOrY9dtL19fiOtAdT1Azxg3+lNJFiuN0uzaU3xX1LeAfL17kHQhTAJgpsfhbMJMY2g==", + "dev": true, + "license": "(BSD-2-Clause OR WTFPL)", + "peerDependencies": { + "chai": "^4.0.0", + "sinon": ">=4.0.0" + } + }, + "node_modules/sinon/node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/sinon/node_modules/@sinonjs/fake-timers": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.2.tgz", + "integrity": "sha512-4Bb+oqXZTSTZ1q27Izly9lv8B9dlV61CROxPiVtywwzv5SnytJqhvYe6FclHYuXml4cd1VHPo1zd5PmTeJozvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/sinon/node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", diff --git a/package.json b/package.json index 74af8f13..32b522da 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,9 @@ "npm-run-all": "^4.1.5", "pino-colada": "^2.2.2", "pino-http": "^10.3.0", - "should": "^13.2.3" + "should": "^13.2.3", + "sinon": "^19.0.2", + "sinon-chai": "^3.7.0" }, "nyc": { "all": true, diff --git a/server/videostream.js b/server/videostream.js index d0a2a322..90531789 100644 --- a/server/videostream.js +++ b/server/videostream.js @@ -44,7 +44,7 @@ class videoStream { this.resetVideo() } }) - this.winston.info("Media path is: ", this.savedDevice.mediaPath) + this.winston.info('Media path is: ', this.savedDevice.mediaPath) } else { // failed setup, reset settings console.log('Reset video3') @@ -219,13 +219,12 @@ class videoStream { mediaPath } - this.winston.info("from startStopStreamning(), media path is: ", this.savedDevice.mediaPath) + this.winston.info('from startStopStreamning(), media path is: ', this.savedDevice.mediaPath) // Don't start a video stream if we are in photo mode - if (this.savedDevice.usePhotoMode){ - console.log("Started photo mode") - } - else { + if (this.savedDevice.usePhotoMode) { + console.log('Started photo mode') + } else { // note that video device URL's are the alphanumeric characters only. So /dev/video0 -> devvideo0 this.populateAddresses(device.replace(/\W/g, '')) @@ -286,7 +285,6 @@ class videoStream { console.log('Started Video Streaming of ' + device) this.winston.info('Started Video Streaming of ' + device) - } // If enabled, start the camera heartbeat in either photo or video mode @@ -303,8 +301,8 @@ class videoStream { clearInterval(this.intervalObj) } - if(this.savedDevice.usePhotoMode) { - console.log("Stopped photo mode") + if (this.savedDevice.usePhotoMode) { + console.log('Stopped photo mode') this.resetVideo() } else { this.deviceStream.stdin.pause() @@ -339,13 +337,14 @@ class videoStream { this.eventEmitter.emit('cameraheartbeat', mavType, autopilot, component) }, 1000) } + async startStopStreaming (active, device, height, width, format, rotation, bitrate, fps, useUDP, usePhotoMode, useUDPIP, useUDPPort, useTimestamp, useCameraHeartbeat, useMavControl, mavStreamSelected, mediaPath, callback) { // if current state same, don't do anything if (this.active === active) { console.log('Video current same') this.winston.info('Video current same') return callback(null, this.active, this.deviceAddresses) - this.winston.info("From startstopstreaming, mediapath is: ", this.mediaPath) + this.winston.info('From startstopstreaming, mediapath is: ', this.mediaPath) } // user wants to start or stop streaming if (active) { @@ -388,7 +387,7 @@ class videoStream { } // If photo mode was selected, start the libcamera server - if (this.savedDevice.usePhotoMode){ + if (this.savedDevice.usePhotoMode) { // note that video device URL's are the alphanumeric characters only. So /dev/video0 -> devvideo0 this.populateAddresses(device.replace(/\W/g, '')) @@ -430,10 +429,7 @@ class videoStream { console.log('Started Video Streaming of ' + device) this.winston.info('Started Video Streaming of ' + device) - - } - - else { + } else { // note that video device URL's are the alphanumeric characters only. So /dev/video0 -> devvideo0 this.populateAddresses(device.replace(/\W/g, '')) @@ -491,10 +487,8 @@ class videoStream { this.deviceStream.kill() this.resetVideo() }) - console.log('Started Video Streaming of ' + device) this.winston.info('Started Video Streaming of ' + device) - } // If enabled, start the camera heartbeat in either photo or video mode @@ -511,8 +505,8 @@ class videoStream { clearInterval(this.intervalObj) } - if(this.savedDevice.usePhotoMode) { - console.log("Stopped photo mode") + if (this.savedDevice.usePhotoMode) { + console.log('Stopped photo mode') this.deviceStream.stdin.pause() this.deviceStream.kill() this.resetVideo() @@ -525,10 +519,10 @@ class videoStream { return callback(null, this.active, this.deviceAddresses) } - captureStillPhoto () { + captureStillPhoto () { // Capture a single still photo - console.log("Received capturestillphoto event") - this.deviceStream.kill('SIGUSR1'); + console.log('Received capturestillphoto event') + this.deviceStream.kill('SIGUSR1') const senderSysId = this.targetSystem const senderCompId = minimal.MavComponent.CAMERA @@ -542,139 +536,129 @@ class videoStream { this.photoSeq++ - this.eventEmitter.emit('cameratrigger', msg, senderSysId , senderCompId) + this.eventEmitter.emit('cameratrigger', msg, senderSysId, senderCompId) } - onMavPacket (packet, data) { - // FC is active - if (!this.active) { - return + sendCameraInformation (senderSysId, senderCompId, targetComponent) { + console.log('Responding to MAVLink request for CameraInformation') + this.winston.info('Responding to MAVLink request for CameraInformation') + + // const senderSysId = packet.header.sysid + // const senderCompId = minimal.MavComponent.CAMERA + // const targetComponent = packet.header.compid + + // build a CAMERA_INFORMATION packet + const msg = new common.CameraInformation() + + // TODO: implement missing attributes here + msg.timeBootMs = 0 + msg.vendorName = 0 + msg.modelName = 0 + msg.firmwareVersion = 0 + msg.focalLength = null + msg.sensorSizeH = null + msg.sensorSizeV = null + msg.resolutionH = this.savedDevice.width + msg.resolutionV = this.savedDevice.height + msg.lensId = 0 + // 256 = CAMERA_CAP_FLAGS_HAS_VIDEO_STREAM (hard-coded for now until Rpanion gains more camera capabilities) + if (this.savedDevice.usePhotoMode) { + // 2 = CAMERA_CAP_FLAGS_CAPTURE_IMAGE + msg.flags = 2 + } else { + // 256 = CAMERA_CAP_FLAGS_HAS_VIDEO_STREAM + msg.flags = 256 } - if (data.targetComponent === minimal.MavComponent.CAMERA && - packet.header.msgid === common.CommandLong.MSG_ID && - data._param1 === common.CameraInformation.MSG_ID) { - console.log('Responding to MAVLink request for CameraInformation') - this.winston.info('Responding to MAVLink request for CameraInformation') - - const senderSysId = packet.header.sysid - const senderCompId = minimal.MavComponent.CAMERA - const targetComponent = packet.header.compid - - // build a CAMERA_INFORMATION packet - const msg = new common.CameraInformation() - - // TODO: implement missing attributes here - msg.timeBootMs = 0 - msg.vendorName = 0 - msg.modelName = 0 - msg.firmwareVersion = 0 - msg.focalLength = null - msg.sensorSizeH = null - msg.sensorSizeV = null - msg.resolutionH = this.savedDevice.width - msg.resolutionV = this.savedDevice.height - msg.lensId = 0 - // 256 = CAMERA_CAP_FLAGS_HAS_VIDEO_STREAM (hard-coded for now until Rpanion gains more camera capabilities) - if(this.savedDevice.usePhotoMode){ - // 2 = CAMERA_CAP_FLAGS_CAPTURE_IMAGE - msg.flags = 2 - } else { - // 256 = CAMERA_CAP_FLAGS_HAS_VIDEO_STREAM - msg.flags = 256 - } - - msg.camDefinitionVersion = 0 - msg.camDefinitionUri = '' - msg.gimbalDeviceId = 0 - this.eventEmitter.emit('camerainfo', msg, senderSysId, senderCompId, targetComponent) - - } else if (data.targetComponent === minimal.MavComponent.CAMERA && - packet.header.msgid === common.CommandLong.MSG_ID && - data._param1 === common.VideoStreamInformation.MSG_ID && - !this.savedDevice.usePhotoMode) { - - console.log('Responding to MAVLink request for VideoStreamInformation') - this.winston.info('Responding to MAVLink request for VideoStreamInformation') + msg.camDefinitionVersion = 0 + msg.camDefinitionUri = '' + msg.gimbalDeviceId = 0 + this.eventEmitter.emit('camerainfo', msg, senderSysId, senderCompId, targetComponent) + } - const senderSysId = packet.header.sysid - const senderCompId = minimal.MavComponent.CAMERA - const targetComponent = packet.header.compid + sendVideoStreamInformation (senderSysId, senderCompId, targetComponent) { + console.log('Responding to MAVLink request for VideoStreamInformation') + this.winston.info('Responding to MAVLink request for VideoStreamInformation') + + // const senderSysId = packet.header.sysid + // const senderCompId = minimal.MavComponent.CAMERA + // const targetComponent = packet.header.compid + + // build a VIDEO_STREAM_INFORMATION packet + const msg = new common.VideoStreamInformation() + // rpanion only supports a single stream, so streamId and count will always be 1 + msg.streamId = 1 + msg.count = 1 + + // msg.type and msg.uri need to be different depending on whether RTP or RTSP is selected + if (this.savedDevice.useUDP) { + // msg.type = 0 = VIDEO_STREAM_TYPE_RTSP + // msg.type = 1 = VIDEO_STREAM_TYPE_RTPUDP + msg.type = 1 + // For RTP, just send the destination UDP port instead of a full URI + msg.uri = this.savedDevice.useUDPPort.toString() + } else { + msg.type = 0 + msg.uri = `rtsp://${this.savedDevice.mavStreamSelected}:8554/${this.savedDevice.device}` + } - // build a VIDEO_STREAM_INFORMATION packet - const msg = new common.VideoStreamInformation() + // 1 = VIDEO_STREAM_STATUS_FLAGS_RUNNING + // 2 = VIDEO_STREAM_STATUS_FLAGS_THERMAL + msg.flags = 1 + msg.framerate = this.savedDevice.fps + msg.resolutionH = this.savedDevice.width + msg.resolutionV = this.savedDevice.height + msg.bitrate = this.savedDevice.bitrate + msg.rotation = this.savedDevice.rotation + // Rpanion doesn't collect field of view values, so just set to zero + msg.hfov = 0 + msg.name = this.savedDevice.device + + this.eventEmitter.emit('videostreaminfo', msg, senderSysId, senderCompId, targetComponent) + } + sendCameraSettings (senderSysId, senderCompId, targetComponent) { + console.log('Responding to MAVLink request for CameraSettings') + this.winston.info('Responding to MAVLink request for CameraSettings') + // const senderSysId = packet.header.sysid + // const senderCompId = minimal.MavComponent.CAMERA + // const targetComponent = packet.header.compid - // rpanion only supports a single stream, so streamId and count will always be 1 - msg.streamId = 1 - msg.count = 1 + // build a CAMERA_SETTINGS packet + const msg = new common.CameraSettings() - // msg.type and msg.uri need to be different depending on whether RTP or RTSP is selected - if (this.savedDevice.useUDP) { - // msg.type = 0 = VIDEO_STREAM_TYPE_RTSP - // msg.type = 1 = VIDEO_STREAM_TYPE_RTPUDP - msg.type = 1 - // For RTP, just send the destination UDP port instead of a full URI - msg.uri = this.savedDevice.useUDPPort.toString() - } else { - msg.type = 0 - msg.uri = `rtsp://${this.savedDevice.mavStreamSelected}:8554/${this.savedDevice.device}` - } + msg.timeBootMs = 0 + // Camera modes: 0 = IMAGE, 1 = VIDEO, 2 = IMAGE_SURVEY + if (this.savedDevice.usePhotoMode) { + msg.modeId = 0 + } else { + msg.modeId = 1 + } + msg.zoomLevel = null + msg.focusLevel = null - // 1 = VIDEO_STREAM_STATUS_FLAGS_RUNNING - // 2 = VIDEO_STREAM_STATUS_FLAGS_THERMAL - msg.flags = 1 - msg.framerate = this.savedDevice.fps - msg.resolutionH = this.savedDevice.width - msg.resolutionV = this.savedDevice.height - msg.bitrate = this.savedDevice.bitrate - msg.rotation = this.savedDevice.rotation - // Rpanion doesn't collect field of view values, so just set to zero - msg.hfov = 0 - msg.name = this.savedDevice.device - - this.eventEmitter.emit('videostreaminfo', msg, senderSysId, senderCompId, targetComponent) - - } else if (data.targetComponent === minimal.MavComponent.CAMERA && - packet.header.msgid === common.CommandLong.MSG_ID && - data._param1 === common.CameraSettings.MSG_ID) { - - console.log('Responding to MAVLink request for CameraSettings') - this.winston.info('Responding to MAVLink request for CameraSettings') - - const senderSysId = packet.header.sysid - const senderCompId = minimal.MavComponent.CAMERA - const targetComponent = packet.header.compid - - // build a CAMERA_SETTINGS packet - const msg = new common.CameraSettings() - - msg.timeBootMs = 0; - // Camera modes: 0 = IMAGE, 1 = VIDEO, 2 = IMAGE_SURVEY - if(this.savedDevice.usePhotoMode){ - msg.modeId = 0; - } else { - msg.modeId = 1; - } - msg.zoomLevel = null; - msg.focusLevel = null; + this.eventEmitter.emit('camerasettings', msg, senderSysId, senderCompId, targetComponent) + } - this.eventEmitter.emit('camerasettings', msg, senderSysId, senderCompId, targetComponent) + onMavPacket (packet, data) { + // FC is active + if (!this.active) { + return + } - } else if (data.targetComponent === minimal.MavComponent.CAMERA && - packet.header.msgid === common.CommandLong.MSG_ID && + if (data.targetComponent === minimal.MavComponent.CAMERA && packet.header.msgid === common.CommandLong.MSG_ID) { + if (data._param1 === common.CameraInformation.MSG_ID) { + this.sendCameraInformation(packet.header.sysid, minimal.MavComponent.CAMERA, packet.header.compid) + } else if (data._param1 === common.VideoStreamInformation.MSG_ID && !this.savedDevice.usePhotoMode) { + this.sendVideoStreamInformation(packet.header.sysid, minimal.MavComponent.CAMERA, packet.header.compid) + } else if (data._param1 === common.CameraSettings.MSG_ID) { + this.sendCameraSettings(packet.header.sysid, minimal.MavComponent.CAMERA, packet.header.compid) // 203 = MAV_CMD_DO_DIGICAM_CONTROL - data.command === 203) { - - console.log('Received DoDigicamControl command') - - const senderSysId = packet.header.sysid - const senderCompId = minimal.MavComponent.CAMERA - const targetComponent = packet.header.compid - - this.captureStillPhoto(packet) + } else if (data.command === 203) { + console.log('Received DoDigicamControl command') + this.captureStillPhoto(packet) + } } } } - module.exports = videoStream diff --git a/server/videostream.test.js b/server/videostream.test.js index 66377459..c6ab6dbf 100644 --- a/server/videostream.test.js +++ b/server/videostream.test.js @@ -3,6 +3,12 @@ const settings = require('settings-store') const VideoStream = require('./videostream') const winston = require('./winstonconfig')(module) +const chai = require('chai') +const sinon = require('sinon') +const { expect } = chai +chai.use(require('sinon-chai')) +const { minimal, common } = require('node-mavlink') + describe('Video Functions', function () { it('#videomanagerinit()', function () { settings.clear() @@ -81,14 +87,340 @@ describe('Video Functions', function () { }) }) - // it('should fire a cameratrigger event', function(done) { - // settings.clear() - // const vManager = new VideoStream(settings, winston) + describe('#videomanagerstartInterval()', () => { + let vManager + let setIntervalStub + let emitStub + + beforeEach(() => { + vManager = new VideoStream(settings, winston) + setIntervalStub = sinon.stub(global, 'setInterval') + emitStub = sinon.stub(vManager.eventEmitter, 'emit') + }) + + afterEach(() => { + sinon.restore() + }) + + it('should start an interval and emit a "cameraheartbeat" event', () => { + const intervalId = 12345 + setIntervalStub.returns(intervalId) + + vManager.startInterval() + + // Simulate the interval execution + const mavType = minimal.MavType.CAMERA + const autopilot = minimal.MavAutopilot.INVALID + const component = minimal.MavComponent.CAMERA + + // Call the interval function manually + const intervalFunction = setIntervalStub.firstCall.args[0] // Get the first argument (the callback) + intervalFunction() // Manually invoke the callback + + expect(vManager.intervalObj).to.equal(intervalId) + expect(emitStub).to.have.been.calledWith('cameraheartbeat', mavType, autopilot, component) + }) + }) + + describe('#videomanagercaptureStillPhoto()', () => { + let vManager + let emitStub + + beforeEach(() => { + vManager = new VideoStream(settings, winston) + // Mocking deviceStream with a kill method + vManager.deviceStream = { + kill: sinon.stub() + } + sinon.stub(Date, 'now').returns(1729137498022000) // Stub to return a fixed timestamp + emitStub = sinon.stub(vManager.eventEmitter, 'emit') + }) + + afterEach(() => { + sinon.restore() + }) + + it('should emit a "cameratrigger" event', () => { + // build a CAMERA_TRIGGER packet + const expectedMsg = new common.CameraTrigger() + expectedMsg.timeUsec = BigInt(Date.now() * 1000) + expectedMsg.seq = 0 + + vManager.captureStillPhoto() // Call the method under test + + expect(emitStub).to.have.been.calledWith('cameratrigger', expectedMsg) + }) + }) + + describe('#videomanagersendCameraInformation()', () => { + let vManager + let emitStub + + beforeEach(() => { + vManager = new VideoStream(settings, winston) + emitStub = sinon.stub(vManager.eventEmitter, 'emit') + + vManager.savedDevice = { + width: 1920, + height: 1080, + usePhotoMode: true + } + }) + + afterEach(() => { + sinon.restore() + }) + + it('should emit a "camerainfo" event', () => { + // build a CAMERA_INFORMATION packet + const expectedMsg = new common.CameraInformation() + + expectedMsg.timeBootMs = 0 + expectedMsg.vendorName = 0 + expectedMsg.modelName = 0 + expectedMsg.firmwareVersion = 0 + expectedMsg.focalLength = null + expectedMsg.sensorSizeH = null + expectedMsg.sensorSizeV = null + expectedMsg.resolutionH = vManager.savedDevice.width + expectedMsg.resolutionV = vManager.savedDevice.height + expectedMsg.lensId = 0 + // 256 = CAMERA_CAP_FLAGS_HAS_VIDEO_STREAM (hard-coded for now until Rpanion gains more camera capabilities) + if (vManager.savedDevice.usePhotoMode) { + // 2 = CAMERA_CAP_FLAGS_CAPTURE_IMAGE + expectedMsg.flags = 2 + } else { + // 256 = CAMERA_CAP_FLAGS_HAS_VIDEO_STREAM + expectedMsg.flags = 256 + } + + expectedMsg.camDefinitionVersion = 0 + expectedMsg.camDefinitionUri = '' + expectedMsg.gimbalDeviceId = 0 + + const senderSysId = 1 + const senderCompId = minimal.MavComponent.CAMERA + const targetComponent = 1 + + vManager.sendCameraInformation(senderSysId, senderCompId, targetComponent) // Call the method under test + expect(emitStub).to.have.been.calledWith('camerainfo', expectedMsg, senderSysId, senderCompId, targetComponent) + + // Test again with usePhotoMode = false + vManager.savedDevice.usePhotoMode = false + vManager.sendCameraInformation(senderSysId, senderCompId, targetComponent) // Call the method under test + expect(emitStub).to.have.been.calledWith('camerainfo', expectedMsg, senderSysId, senderCompId, targetComponent) + }) + }) + + describe('#videomanagersendVideoStreamInformation()', () => { + let vManager + let emitStub + + beforeEach(() => { + vManager = new VideoStream(settings, winston) + emitStub = sinon.stub(vManager.eventEmitter, 'emit') + + vManager.savedDevice = { + useUDP: true, + useUDPPort: 9000, + mavStreamSelected: 'localhost', + device: 'camera1', + fps: 30, + width: 1920, + height: 1080, + bitrate: 6000, + rotation: 0 + } + }) + + afterEach(() => { + sinon.restore() + }) + + it('should emit a "videostreaminfo" event', () => { + // build a VIDEO_STREAM_INFORMATION packet + const expectedMsg = new common.VideoStreamInformation() + + expectedMsg.streamId = 1 + expectedMsg.count = 1 + // expectedMsg.type and expectedMsg.uri need to be different depending on whether RTP or RTSP is selected + if (vManager.savedDevice.useUDP) { + // expectedMsg.type = 0 = VIDEO_STREAM_TYPE_RTSP + // expectedMsg.type = 1 = VIDEO_STREAM_TYPE_RTPUDP + expectedMsg.type = 1 + // For RTP, just send the destination UDP port instead of a full URI + expectedMsg.uri = vManager.savedDevice.useUDPPort.toString() + } else { + expectedMsg.type = 0 + expect.uri = `rtsp://${vManager.savedDevice.mavStreamSelected}:8554/${vManager.savedDevice.device}` + } + + // 1 = VIDEO_STREAM_STATUS_FLAGS_RUNNING + // 2 = VIDEO_STREAM_STATUS_FLAGS_THERMAL + expectedMsg.flags = 1 + expectedMsg.framerate = vManager.savedDevice.fps + expectedMsg.resolutionH = vManager.savedDevice.width + expectedMsg.resolutionV = vManager.savedDevice.height + expectedMsg.bitrate = vManager.savedDevice.bitrate + expectedMsg.rotation = vManager.savedDevice.rotation + // Rpanion doesn't collect field of view values, so just set to zero + expectedMsg.hfov = 0 + expectedMsg.name = vManager.savedDevice.device + + const senderSysId = 1 + const senderCompId = minimal.MavComponent.CAMERA + const targetComponent = 1 + + vManager.sendVideoStreamInformation(senderSysId, senderCompId, targetComponent) // Call the method under test + expect(emitStub).to.have.been.calledWith('videostreaminfo', expectedMsg, senderSysId, senderCompId, targetComponent) + + // Test again with useUDP = false + vManager.savedDevice.useUDP = false + vManager.sendVideoStreamInformation(senderSysId, senderCompId, targetComponent) // Call the method under test + expect(emitStub).to.have.been.calledWith('videostreaminfo', expectedMsg, senderSysId, senderCompId, targetComponent) + }) + }) + + describe('#videomanagersendCameraSettings()', () => { + let vManager + let emitStub + + beforeEach(() => { + vManager = new VideoStream(settings, winston) + emitStub = sinon.stub(vManager.eventEmitter, 'emit') + + vManager.savedDevice = { + usePhotoMode: true + } + }) + + afterEach(() => { + sinon.restore() + }) + + it('should emit a "camerasettings" event', () => { + // build a CAMERA_SETTINGS packet + const expectedMsg = new common.CameraSettings() + expectedMsg.timeBootMs = 0 + // Camera modes: 0 = IMAGE, 1 = VIDEO, 2 = IMAGE_SURVEY + if (vManager.savedDevice.usePhotoMode) { + expectedMsg.modeId = 0 + } else { + expectedMsg.modeId = 1 + } + expectedMsg.zoomLevel = null + expectedMsg.focusLevel = null + + const senderSysId = 1 + const senderCompId = minimal.MavComponent.CAMERA + const targetComponent = 1 + + vManager.sendCameraSettings(senderSysId, senderCompId, targetComponent) // Call the method under test + expect(emitStub).to.have.been.calledWith('camerasettings', expectedMsg, senderSysId, senderCompId, targetComponent) + + // Test again with usePhotoMode = false + vManager.savedDevice.usePhotoMode = false + vManager.sendCameraSettings(senderSysId, senderCompId, targetComponent) // Call the method under test + expect(emitStub).to.have.been.calledWith('camerasettings', expectedMsg, senderSysId, senderCompId, targetComponent) + }) + }) + + describe('#videomanageronMavPacket', function () { + let instance + let packet + let data + + beforeEach(function () { + instance = { + active: true, + savedDevice: { + usePhotoMode: false + }, + sendCameraInformation: sinon.spy(), + sendVideoStreamInformation: sinon.spy(), + sendCameraSettings: sinon.spy(), + captureStillPhoto: sinon.spy(), + + // Define the onMavPacket function within the instance + onMavPacket: function (packet, data) { + if (!this.active) { + return + } + + if (data.targetComponent === minimal.MavComponent.CAMERA && packet.header.msgid === common.CommandLong.MSG_ID) { + if (data._param1 === common.CameraInformation.MSG_ID) { + this.sendCameraInformation(packet.header.sysid, minimal.MavComponent.CAMERA, packet.header.compid) + } else if (data._param1 === common.VideoStreamInformation.MSG_ID && !this.savedDevice.usePhotoMode) { + this.sendVideoStreamInformation(packet.header.sysid, minimal.MavComponent.CAMERA, packet.header.compid) + } else if (data._param1 === common.CameraSettings.MSG_ID) { + this.sendCameraSettings(packet.header.sysid, minimal.MavComponent.CAMERA, packet.header.compid) + } else if (data.command === 203) { + console.log('Received DoDigicamControl command') + this.captureStillPhoto(packet) + } + } + } + } + + packet = { + header: { + sysid: 1, + compid: 1, + msgid: 76 // example msgid for CommandLong + } + } + + data = { + targetComponent: 100, + _param1: 0, + command: null + } + }) + + it('should not process if inactive', function () { + instance.active = false + instance.onMavPacket(packet, data) + expect(instance.sendCameraInformation.called).to.be.false + expect(instance.sendVideoStreamInformation.called).to.be.false + expect(instance.sendCameraSettings.called).to.be.false + expect(instance.captureStillPhoto.called).to.be.false + }) + + it('should send camera information when _param1 matches CameraInformation MSG_ID', function () { + data.targetComponent = minimal.MavComponent.CAMERA + data._param1 = common.CameraInformation.MSG_ID + instance.onMavPacket(packet, data) + expect(instance.sendCameraInformation.calledWith(packet.header.sysid, minimal.MavComponent.CAMERA, packet.header.compid)).to.be.true + }) + + it('should send video stream information when _param1 matches VideoStreamInformation MSG_ID and usePhotoMode is false', function () { + data.targetComponent = minimal.MavComponent.CAMERA + data._param1 = common.VideoStreamInformation.MSG_ID + instance.onMavPacket(packet, data) + expect(instance.sendVideoStreamInformation.calledWith(packet.header.sysid, minimal.MavComponent.CAMERA, packet.header.compid)).to.be.true + }) - // vManager.captureStillPhoto( function () { - // //assert.equal(vManager.photoSeq, 1) - // done() - // }) - // }) + it('should send camera settings when _param1 matches CameraSettings MSG_ID', function () { + data.targetComponent = minimal.MavComponent.CAMERA + data._param1 = common.CameraSettings.MSG_ID + instance.onMavPacket(packet, data) + expect(instance.sendCameraSettings.calledWith(packet.header.sysid, minimal.MavComponent.CAMERA, packet.header.compid)).to.be.true + }) + + it('should capture still photo when command equals 203 (MAV_CMD_DO_DIGICAM_CONTROL)', function () { + data.targetComponent = minimal.MavComponent.CAMERA + data.command = 203 + instance.onMavPacket(packet, data) + expect(instance.captureStillPhoto.calledWith(packet)).to.be.true + }) + it('should not process when targetComponent is not CAMERA', function () { + data.targetComponent = 200 // not the CAMERA component + instance.onMavPacket(packet, data) + expect(instance.sendCameraInformation.called).to.be.false + expect(instance.sendVideoStreamInformation.called).to.be.false + expect(instance.sendCameraSettings.called).to.be.false + expect(instance.captureStillPhoto.called).to.be.false + }) + }) })