• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

stephendade / Rpanion-server / 21427132926

28 Jan 2026 06:05AM UTC coverage: 35.302% (-0.4%) from 35.716%
21427132926

Pull #295

github

web-flow
Merge 65845d783 into b851c7273
Pull Request #295: Video: Add local still image and video recording

358 of 1357 branches covered (26.38%)

Branch coverage included in aggregate %.

135 of 407 new or added lines in 3 files covered. (33.17%)

5 existing lines in 2 files now uncovered.

1154 of 2926 relevant lines covered (39.44%)

3.47 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

37.11
/server/videostream.js
1
const { exec, execSync, spawn } = require('child_process')
3✔
2
const os = require('os')
3✔
3
const si = require('systeminformation')
3✔
4
const events = require('events')
3✔
5
const { minimal, common } = require('node-mavlink')
3✔
6
const logpaths = require('./paths.js')
3✔
7
const fs = require('fs')
3✔
8

9
class videoStream {
10
  constructor (settings) {
11
    this.settings = settings
36✔
12

13
    // Properties used in all modes
14
    this.active = false
36✔
15
    this.deviceStream = null
36✔
16
    this.deviceAddresses = []
36✔
17
    this.cameraMode = null; // 'streaming', 'photo', or 'video'
36✔
18
    this.photoSeq = 0;
36✔
19

20
    // Interval to send camera heartbeat events
21
    this.intervalObj = null;
36✔
22
    this.eventEmitter = new events.EventEmitter()
36✔
23

24
    // Mode-specific hardware lists/settings
25
    this.devices = null;      // Video devices
36✔
26
    this.stillDevices = null; // Still devices
36✔
27
    this.videoSettings = null;
36✔
28
    this.stillSettings = null;
36✔
29

30
    // Load saved settings from the 'camera' namespace
31
    this.active = this.settings.value('camera.active', false);
36✔
32
    this.cameraMode = this.settings.value('camera.mode', 'streaming');
36✔
33
    this.useCameraHeartbeat = this.settings.value('camera.useHeartbeat', false);
36✔
34

35
    // Load specific settings based on mode
36
    this.videoSettings = this.settings.value('camera.videoSettings', null);
36✔
37
    this.stillSettings = this.settings.value('camera.stillSettings', null);
36✔
38

39
    // if it's an active device, stop then start it up
40
    // need to scan for video devices first though
41
    if (this.active) {
36!
42
      this.active = false
×
NEW
43
      this.initialize()
×
44
    }
45
  }
46

47
  async initialize() {
UNCOV
48
    try {
×
49
      // Discover Video Hardware and wait for data
NEW
50
      await new Promise((resolve, reject) => {
×
NEW
51
        this.getVideoDevices((err, data) => {  // Get the data
×
NEW
52
          if (err) {
×
NEW
53
            reject(err);
×
54
          } else {
55
            // this.devices is now populated inside getVideoDevices callback
NEW
56
            resolve();
×
57
          }
58
        });
59
      });
60

61
      // 2. Discover Still Photo Hardware (We will add this function in Step 2)
62
      // For now, we wrap it in a try/catch so it doesn't crash if the helper isn't there yet
NEW
63
      try {
×
NEW
64
        await new Promise((resolve, reject) => {
×
NEW
65
          this.getStillDevices((err) => err ? reject(err) : resolve());
×
66
        });
67
      } catch (e) {
NEW
68
        console.log("Still hardware discovery skipped or failed.");
×
69
      }
70

71
      // 3. Start the camera in the last saved mode
NEW
72
      this.startCamera((err) => {
×
NEW
73
        if (err) {
×
NEW
74
          console.error('Camera start error during init:', err);
×
NEW
75
          this.resetCamera();
×
76
        } else {
NEW
77
          this.active = true;
×
78
        }
79
      });
80
    } catch (error) {
NEW
81
      console.log('Resetting camera - initialization failed:', error);
×
NEW
82
      this.resetCamera();
×
83
    }
84
  }
85

86
  getVideoDevicesPromise() {
87
    // Promise wrapper for getVideoDevices
88
    return new Promise((resolve, reject) => {
×
89
      this.getVideoDevices((error) => {
×
90
        if (error) {
×
91
          reject(error)
×
92
        } else {
93
          resolve()
×
94
        }
95
      })
96
    })
97
  }
98

99
  startCameraPromise() {
UNCOV
100
    return new Promise((resolve, reject) => {
×
NEW
101
      this.startCamera((err, result) => {
×
102
        if (err) {
×
103
          reject(err)
×
104
        } else {
NEW
105
          resolve(result)
×
106
        }
107
      })
108
    })
109
  }
110

111
  // Format and store all the possible rtsp addresses
112
  populateAddresses (factory) {
113
    // set up the avail addresses
114
    this.ifaces = this.scanInterfaces()
3✔
115
    this.deviceAddresses = []
3✔
116

117
    // Remove leading slash if it exists to prevent double slashes
118
    let mountPoint = factory.toString();
3✔
119
    if (mountPoint.startsWith('/')) {
3!
NEW
120
      mountPoint = mountPoint.substring(1);
×
121
    }
122

123
    for (let j = 0; j < this.ifaces.length; j++) {
3✔
124
      if (factory.includes('rtsp://')) {
6!
125
        // remove any rtsp username or passwords, format rtsp://admin:admin@192.168.1.217:554/11
126
        let rtspfactory = factory
×
127
        rtspfactory = factory.replace('rtsp://', '')
×
128
        if (rtspfactory.includes('@')) {
×
129
          rtspfactory = rtspfactory.split('@')[1]
×
130
        }
131
        this.deviceAddresses.push('rtsp://' + this.ifaces[j] + ':8554/' + rtspfactory.replace(/\W/g, ''))
×
132
      } else {
133
        // note that video device URL's are the alphanumeric characters only. So /dev/video0 -> devvideo0
134
        this.deviceAddresses.push('rtsp://' + this.ifaces[j] + ':8554/' + factory.replace(/\W/g, ''))
6✔
135
      }
136
    }
137
  }
138

139
  getCompressionSelect(val) {
140
    // return the compression select object for a given value
141
    const options = [
6✔
142
      { value: 'H264', label: 'H.264' },
143
      { value: 'H265', label: 'H.265' },
144
    ]
145
    const sel = options.filter(it => it.value === val)
12✔
146
    if (sel.length === 1) {
6✔
147
      return sel[0]
3✔
148
    } else {
149
      return options[0]
3✔
150
    }
151
  }
152

153
  getTransportSelect(val) {
154
    // return the transport select object for a given value
155
    const options = [
6✔
156
      { value: 'RTP', label: 'RTP' },
157
      { value: 'RTSP', label: 'RTSP' },
158
    ]
159
    const sel = options.filter(it => it.value === val)
12✔
160
    if (sel.length === 1) {
6✔
161
      return sel[0]
3✔
162
    } else {
163
      return options[1]
3✔
164
    }
165
  }
166

167
  getTransportOptions(){
168
    // get transport options
169
    return [
3✔
170
      { value: 'RTP', label: 'RTP' },
171
      { value: 'RTSP', label: 'RTSP' },
172
    ];
173
  }
174

175
  // video streaming
176
  getVideoDevices (callback) {
177
    // get all video device details
178
    //dont update if streaming is running, as some camera won't be detected if in use
179
    const networkInterfaces = this.scanInterfaces();
3✔
180

181
    // Don't re-scan hardware if a stream is already running
182
    if (this.deviceStream !== null) {
3!
NEW
183
      console.log("Camera active; returning cached hardware details.");
×
184

NEW
185
      const responseData = {
×
186
        devices: this.devices || [],
×
187
        networkInterfaces: networkInterfaces,
188
        active: this.active,
189
        cameraMode: this.cameraMode,
190
        streamAddresses: this.deviceAddresses,
191
        selectedDevice: null,
192
        selectedCap: null,
193
        resolutionCaps: [],
194
        fpsOptions: [],
195
        fpsMax: 0
196
      };
197

198
      // 1. Find the device and capability currently being used
NEW
199
      if (this.videoSettings && this.devices) {
×
NEW
200
        responseData.selectedDevice = this.devices.find(d => d.value === this.videoSettings.device);
×
NEW
201
        if (responseData.selectedDevice) {
×
NEW
202
          const formatShort = this.videoSettings.format.split('/')[1];
×
NEW
203
          const capVal = `${this.videoSettings.width}x${this.videoSettings.height}x${formatShort}`;
×
204

NEW
205
          responseData.selectedCap = responseData.selectedDevice.caps.find(cap => cap.value === capVal);
×
NEW
206
          responseData.resolutionCaps = responseData.selectedDevice.caps;
×
NEW
207
          responseData.fpsMax = responseData.selectedCap?.fpsmax || 0;
×
NEW
208
          responseData.fpsOptions = responseData.selectedCap?.fps || [];
×
209
        }
210

211
        // 2. Map raw numbers back to the UI's Object format
NEW
212
        responseData.selectedRotation = {
×
213
          label: (this.videoSettings.rotation || 0) + '°',
×
214
          value: (this.videoSettings.rotation || 0)
×
215
        };
NEW
216
        responseData.selectedMavStreamURI = {
×
217
          label: this.videoSettings.mavStreamSelected?.toString() || '127.0.0.1',
×
218
          value: this.videoSettings.mavStreamSelected || '127.0.0.1'
×
219
        };
220

221
        // 3. Map simple values
NEW
222
        responseData.selectedBitrate = this.videoSettings.bitrate || 1100;
×
NEW
223
        responseData.selectedFps = this.videoSettings.fps || 30;
×
NEW
224
        responseData.selectedUseUDP = this.videoSettings.useUDP || false;
×
NEW
225
        responseData.selectedUseUDPIP = this.videoSettings.useUDPIP || '127.0.0.1';
×
NEW
226
        responseData.selectedUseUDPPort = this.videoSettings.useUDPPort || 5600;
×
NEW
227
        responseData.selectedUseTimestamp = this.videoSettings.useTimestamp || false;
×
NEW
228
        responseData.selectedUseCameraHeartbeat = this.useCameraHeartbeat || false;
×
229
      }
230

NEW
231
      return callback(null, responseData);
×
232
    }
233

234
    // If not streaming, proceed with hardware discovery
235
    const pythonPath = logpaths.getPythonPath();
3✔
236
    exec(`${pythonPath} ./python/gstcaps.py`, (error, stdout, stderr) => {
3✔
237
      const responseData = {
3✔
238
        devices: [],
239
        networkInterfaces: networkInterfaces,
240
        active: false,
241
        cameraMode: this.cameraMode,
242
        streamAddresses: [],
243
        selectedDevice: null,
244
        selectedCap: null,
245
        selectedRotation: { label: '0°', value: 0 },
246
        selectedBitrate: 1100,
247
        selectedFps: null,
248
        selectedUseUDP: false,
249
        selectedUseUDPIP: '127.0.0.1',
250
        selectedUseUDPPort: 5400,
251
        selectedUseTimestamp: false,
252
        fpsOptions: [],
253
        fpsMax: 0,
254
        resolutionCaps: [],
255
        selectedUseCameraHeartbeat: this.useCameraHeartbeat,
256
        selectedMavStreamURI: { label: '127.0.0.1', value: '127.0.0.1' }
257
      };
258

259
      const warnstrings = ['DeprecationWarning', 'gst_element_message_full_with_details', 'camera_manager.cpp', 'Unsupported V4L2 pixel format'];
3✔
260
      if (stderr && !warnstrings.some(wrn => stderr.includes(wrn))) {
3!
NEW
261
        return callback(stderr, responseData);
×
262
      }
263

264
      try {
3✔
265
        const devices = JSON.parse(stdout);
3✔
266
        // Add RTSP mocks
267
        devices.push({ label: 'RTSP Source (H.264)', value: 'rtspsourceh264', caps: [{ label: 'Custom RTSP Source', value: '1x1xx-h264', width: 1, height: 1, format: 'video/x-h264', fps: [{ label: 'N/A', value: 1 }], fpsmax: 0 }] });
3✔
268
        devices.push({ label: 'RTSP Source (H.265)', value: 'rtspsourceh265', caps: [{ label: 'Custom RTSP Source', value: '1x1xx-h265', width: 1, height: 1, format: 'video/x-h265', fps: [{ label: 'N/A', value: 1 }], fpsmax: 0 }] });
3✔
269

270
        this.devices = devices;
3✔
271
        responseData.devices = devices;
3✔
272

273
        // Populate defaults or saved settings as before...
274
        let selectedDevice = devices[0];
3✔
275
        let selectedCap = selectedDevice?.caps[0];
3✔
276

277
        responseData.selectedDevice = selectedDevice;
3✔
278
        responseData.selectedCap = selectedCap;
3✔
279
        responseData.resolutionCaps = selectedDevice?.caps || [];
3!
280
        responseData.fpsOptions = selectedCap?.fps || [];
3!
281
        responseData.fpsMax = selectedCap?.fpsmax || 0;
3!
282
        responseData.selectedFps = responseData.fpsMax > 0 ? responseData.fpsMax : (responseData.fpsOptions[0]?.value ?? 30);
3!
283

284
        return callback(null, responseData);
3✔
285
      } catch (e) {
NEW
286
        return callback('Failed to process video devices', responseData);
×
287
      }
288
    });
289
  }
290

291
  getStillDevices(callback) {
NEW
292
    const pythonPath = logpaths.getPythonPath();
×
NEW
293
    exec(`${pythonPath} ./python/get_camera_caps.py`, (error, stdout, stderr) => {
×
NEW
294
      if (error) return callback(stderr || error.message);
×
295

NEW
296
      try {
×
NEW
297
        const cameraDevices = JSON.parse(stdout);
×
NEW
298
        this.stillDevices = cameraDevices;
×
299

NEW
300
        const defaultDevice = cameraDevices.find(dev => dev.caps && dev.caps.length > 0);
×
NEW
301
        const defaultCap = defaultDevice?.caps[0];
×
302

NEW
303
        return callback(null, {
×
304
          devices: cameraDevices,
305
          selectedDevice: defaultDevice,
306
          selectedCap: defaultCap
307
        });
308
      } catch (e) {
NEW
309
        return callback('Invalid JSON from get_camera_caps.py');
×
310
      }
311
    });
312
  }
313

314

315
  saveSettings() {
316
    try {
3✔
317
      this.settings.setValue('camera.active', this.active);
3✔
318
      this.settings.setValue('camera.mode', this.cameraMode);
3✔
319
      this.settings.setValue('camera.useHeartbeat', this.useCameraHeartbeat);
3✔
320

321
      if (this.cameraMode === 'streaming' || this.cameraMode === 'video') {
3!
322
        this.settings.setValue('camera.videoSettings', this.videoSettings);
3✔
NEW
323
      } else if (this.cameraMode === 'photo') {
×
NEW
324
        this.settings.setValue('camera.stillSettings', this.stillSettings);
×
325
      }
326
    } catch (e) {
NEW
327
      console.error('Error saving camera settings:', e);
×
328
    }
329
  }
330

331
  resetCamera() {
332
    this.active = false;
3✔
333
    this.videoSettings = null;
3✔
334
    this.stillSettings = null;
3✔
335
    try {
3✔
336
      this.settings.setValue('camera.active', false);
3✔
337
      this.settings.setValue('camera.mode', 'streaming');
3✔
338
      this.settings.setValue('camera.videoSettings', null);
3✔
339
      this.settings.setValue('camera.stillSettings', null);
3✔
340
      this.settings.setValue('camera.useHeartbeat', false);
3✔
341
    } catch (e) {
NEW
342
      console.log('Error saving reset settings:', e);
×
343
    }
344
    console.log('Camera System Reset');
3✔
345
  }
346

347
  scanInterfaces() {
348
    // scan for available IP (v4 only) interfaces
349
    const iface = []
6✔
350
    const ifaces = os.networkInterfaces()
6✔
351

352
    for (const ifacename in ifaces) {
6✔
353
      let alias = 0
12✔
354
      for (let j = 0; j < ifaces[ifacename].length; j++) {
12✔
355
        if (ifaces[ifacename][j].family === 'IPv4' && alias >= 1) {
24!
356
          // this single interface has multiple ipv4 addresses
357
          // console.log("Found IP " + ifacename + ':' + alias, ifaces[ifacename][j].address);
358
          iface.push(ifaces[ifacename][j].address)
×
359
        } else if (ifaces[ifacename][j].family === 'IPv4') {
24✔
360
          // this interface has only one ipv4 adress
361
          // console.log("Found IP " + ifacename, ifaces[ifacename][j].address);
362
          iface.push(ifaces[ifacename][j].address)
12✔
363
        }
364
        ++alias
24✔
365
      }
366
    }
367
    return iface
6✔
368
  }
369

370
  startCamera(callback) {
NEW
371
    console.log(`Attempting to start camera in mode: ${this.cameraMode}`);
×
NEW
372
    try {
×
NEW
373
      if (this.cameraMode === 'streaming') {
×
374
        // startVideoStreaming is async, so we must catch rejections
NEW
375
        this.startVideoStreaming(callback).catch(err => {
×
NEW
376
          console.error("Async Start Error:", err);
×
NEW
377
          callback(err);
×
378
        });
NEW
379
      } else if (this.cameraMode === 'photo') {
×
NEW
380
        this.startPhotoMode(callback);
×
NEW
381
      } else if (this.cameraMode === 'video') {
×
NEW
382
        this.startVideoMode(callback);
×
383
      } else {
NEW
384
        callback(new Error(`Unsupported camera mode: ${this.cameraMode}`));
×
385
      }
386
    } catch (syncErr) {
NEW
387
      console.error("Sync Start Error:", syncErr);
×
NEW
388
      callback(syncErr);
×
389
    }
390
  }
391

392
  async startVideoStreaming(callback) {
NEW
393
    if (!this.videoSettings) return callback(new Error('No video settings provided'));
×
394

NEW
395
    let device = this.videoSettings.device;
×
NEW
396
    let format = this.videoSettings.format;
×
397

398
    // Ubuntu RPI camera mapping
NEW
399
    if (await this.isUbuntu() && device === 'rpicam') {
×
NEW
400
      device = '/dev/video0';
×
NEW
401
      format = 'video/x-raw';
×
402
    }
403

NEW
404
    this.populateAddresses(this.videoSettings.device);
×
405

NEW
406
    const args = [
×
407
      '-u', // force the stdout and stderr streams to be unbuffered
408
      './python/video-server.py',
409
      '--video=' + device,
410
      '--height=' + this.videoSettings.height,
411
      '--width=' + this.videoSettings.width,
412
      '--format=' + format,
413
      '--bitrate=' + this.videoSettings.bitrate,
414
      '--rotation=' + this.videoSettings.rotation,
415
      '--fps=' + this.videoSettings.fps,
416
      '--udp=' + (this.videoSettings.useUDP ? `${this.videoSettings.useUDPIP}:${this.videoSettings.useUDPPort}` : '0'),
×
417
      '--compression=' + this.videoSettings.compression
418
    ];
419

NEW
420
    if (this.videoSettings.useTimestamp) args.push('--timestamp');
×
421

NEW
422
    this.deviceStream = spawn('python3', args);
×
NEW
423
    this.setupStreamEvents('Streaming', callback);
×
424

425
    // Start MAVLink heartbeats if enabled
NEW
426
    if (this.useCameraHeartbeat) {
×
NEW
427
      this.startHeartbeatInterval();
×
NEW
428
      this.sendVideoStreamInformation(null, minimal.MavComponent.CAMERA, null);
×
429
    }
430
  }
431

432
  startPhotoMode(callback) {
NEW
433
    if (!this.stillSettings) return callback(new Error('No still settings provided'));
×
434

NEW
435
    const args = [
×
436
      '-u', // force the stdout and stderr streams to be unbuffered
437
      './python/photovideo.py',
438
      '--mode=photo'
439
    ];
NEW
440
    if (this.stillSettings.device) args.push('--device=' + this.stillSettings.device);
×
NEW
441
    if (this.stillSettings.width) args.push('--width=' + this.stillSettings.width);
×
NEW
442
    if (this.stillSettings.height) args.push('--height=' + this.stillSettings.height);
×
443

NEW
444
    this.deviceStream = spawn('python3', args);
×
NEW
445
    this.setupStreamEvents('Photo Mode', callback);
×
446

447
    // Start MAVLink heartbeats if enabled
NEW
448
    if (this.useCameraHeartbeat) {
×
NEW
449
      this.startHeartbeatInterval();
×
NEW
450
      this.sendCameraInformation(null, minimal.MavComponent.CAMERA, null);
×
451
    }
452
  }
453

454
  startVideoMode(callback) {
NEW
455
    if (!this.videoSettings) return callback(new Error('No video settings provided'));
×
456

457
    // Convert bitrate from kbps to bps Picamera2
NEW
458
    const bitrateBps = this.videoSettings.bitrate * 1000;
×
459

NEW
460
    const args = [
×
461
      '-u', // force the stdout and stderr streams to be unbuffered
462
      './python/photovideo.py',
463
      '--mode=video',
464
      '--device=' + this.videoSettings.device,
465
      '--width=' + this.videoSettings.width,
466
      '--height=' + this.videoSettings.height,
467
      '--fps=' + this.videoSettings.fps,
468
      '--bitrate=' + bitrateBps,
469
      '--rotation=' + this.videoSettings.rotation,
470
      '--format=' + this.videoSettings.format
471
    ];
472

NEW
473
    this.deviceStream = spawn('python3', args);
×
NEW
474
    this.setupStreamEvents('Video Mode', callback);
×
475

476
    // Start MAVLink heartbeats if enabled
NEW
477
    if (this.useCameraHeartbeat) {
×
NEW
478
      this.startHeartbeatInterval();
×
NEW
479
      this.sendCameraInformation(null, minimal.MavComponent.CAMERA, null);
×
480
    }
481
  }
482

483
  setupStreamEvents(modeName, callback) {
NEW
484
    let callbackCalled = false;
×
NEW
485
    let stdoutBuffer = ''; // Buffer for accumulating data chunks
×
486

487
    // Importing cv2 and Picamera2 on a Pi can take a minute or more
488
    // Safety Timeout: If nothing happens in 60 seconds, unblock the UI
NEW
489
    const timeout = setTimeout(() => {
×
490

NEW
491
      if (!callbackCalled) {
×
NEW
492
        callbackCalled = true;
×
NEW
493
        console.log(`${modeName}: No response from script after 60s, assuming start.`);
×
NEW
494
        this.active = true;
×
NEW
495
        this.saveSettings();
×
NEW
496
        callback(null, { active: true, addresses: this.deviceAddresses });
×
497
      }
498
    }, 60000);
499

NEW
500
    this.deviceStream.on('error', (err) => {
×
NEW
501
      clearTimeout(timeout);
×
NEW
502
      console.error(`Failed to spawn ${modeName}:`, err);
×
NEW
503
      if (!callbackCalled) {
×
NEW
504
        callbackCalled = true;
×
NEW
505
        callback(err);
×
506
      }
507
    });
508

509
    // Listen continuously until we hear "Camera is ready"
510
    // or in streaming mode
NEW
511
    this.deviceStream.stdout.on('data', (data) => {
×
512

NEW
513
      const chunk = data.toString();
×
NEW
514
      stdoutBuffer += chunk;
×
515

516
      // Print chunk for debugging immediately
NEW
517
      const chunkTrimmed = chunk.trim();
×
NEW
518
      if (chunkTrimmed) console.log(`${modeName} chunk: ${chunkTrimmed}`);
×
519

NEW
520
      if (!callbackCalled) {
×
521
        // For Photo/Video mode (photovideo.py): Wait for "Camera is ready"
522
        // For Streaming (video-server.py): Wait for any data (assumed ready)
523

NEW
524
        let isReady = false;
×
525

NEW
526
        if (modeName === 'Streaming') {
×
NEW
527
          isReady = true; // Assuming first output from Gstreamer script means it's running
×
528
        } else {
529
          // photovideo.py prints "Camera is ready in..."
NEW
530
          if (stdoutBuffer.includes("Camera is ready")) {
×
NEW
531
            isReady = true;
×
532
          }
533
        }
534

NEW
535
        if (isReady) {
×
NEW
536
          clearTimeout(timeout);
×
NEW
537
          console.log(`${modeName} process is fully initialized.`);
×
NEW
538
          this.active = true;
×
NEW
539
          this.saveSettings();
×
NEW
540
          callbackCalled = true;
×
NEW
541
          callback(null, { active: true, addresses: this.deviceAddresses });
×
542
        }
543
      }
544
    });
545

NEW
546
    this.deviceStream.stderr.on('data', (data) => {
×
NEW
547
      const msg = data.toString().trim();
×
NEW
548
      if (msg) console.error(`${modeName} error: ${msg}`);
×
549
    });
550

NEW
551
    this.deviceStream.on('close', (code) => {
×
NEW
552
      clearTimeout(timeout);
×
NEW
553
      console.log(`${modeName} exited with code ${code}`);
×
NEW
554
      this.active = false;
×
NEW
555
      if (!callbackCalled) {
×
NEW
556
        callbackCalled = true;
×
NEW
557
        callback(new Error(`${modeName} exited immediately (Code: ${code})`));
×
558
      }
559
    });
560
  }
561

562
  stopCamera(callback) {
563
    if (this.intervalObj) {
3!
564
      clearInterval(this.intervalObj);
3✔
565
      this.intervalObj = null;
3✔
566
    }
567

568
    if (this.deviceStream) {
3!
569
      this.deviceStream.kill('SIGTERM'); // Clean kill
3✔
570
      this.deviceStream = null;
3✔
571
    }
572

573
    this.active = false;
3✔
574
    this.settings.setValue('camera.active', false);
3✔
575
    if (callback) callback(null, false);
3!
576
  }
577

578
  async isUbuntu () {
579
    // Check if we are running Ubuntu
580
    let ret
581
    const data = await si.osInfo()
3✔
582
    if (data.distro.toString().includes('Ubuntu')) {
3!
583
      console.log('Video Running Ubuntu')
3✔
584
      ret = true
3✔
585
    } else {
586
      ret = false
×
587
    }
588
    return ret
3✔
589
  }
590

591
  getStreamingStatus () {
592
    // return the current streaming status
593
    if (this.active) {
×
594
      return 'Active - Streaming video'
×
595
    } else {
596
      return 'Not streaming'
×
597
    }
598
  }
599

600
  startHeartbeatInterval () {
601
    // start the 1-sec loop to send heartbeat events
UNCOV
602
    this.intervalObj = setInterval(() => {
×
603
      const mavType = minimal.MavType.CAMERA
×
604
      const autopilot = minimal.MavAutopilot.INVALID
×
605
      const component = minimal.MavComponent.CAMERA
×
606

607
      this.eventEmitter.emit('cameraheartbeat', mavType, autopilot, component)
×
608
    }, 1000)
609
  }
610

611
  captureStillPhoto(senderSysId, senderCompId, targetComponent, positionData = null) {
3✔
612
    console.log('Attempting captureStillPhoto. Internal state: active=', this.active, 'mode=', this.cameraMode, 'deviceStream exists=', !!this.deviceStream);
3✔
613

614
    // Capture a single still photo
615
    console.log('Capturing still photo')
3✔
616

617
    if (!this.active || !this.deviceStream) {
3!
NEW
618
      console.log('Cannot capture photo - camera not active')
×
NEW
619
      console.log('Internal check failed: Cannot capture photo - camera not active or no deviceStream.');
×
UNCOV
620
      return
×
621
    }
622

623
    // Write GPS data to a temporary file for Python to read
624
    if (positionData) {
3!
NEW
625
      try {
×
NEW
626
        const gpsPayload = JSON.stringify(positionData);
×
NEW
627
        fs.writeFileSync('/tmp/rpanion_gps.json', gpsPayload);
×
NEW
628
        console.log('Wrote GPS data for geotagging:', gpsPayload);
×
629
      } catch (e) {
NEW
630
        console.error('Failed to write GPS temp file:', e);
×
631
      }
632
    } else {
633
      // Clean up old file to prevent stale tags
634
      if (fs.existsSync('/tmp/rpanion_gps.json')) {
3!
NEW
635
        fs.unlinkSync('/tmp/rpanion_gps.json');
×
636
      }
637
    }
638

639
    // Signal the Python process to capture a photo
640
    this.deviceStream.kill('SIGUSR1')
3✔
641

642
    // Build MAVLink CAMERA_TRIGGER packet for geotagging/logging
643
    const msg = new common.CameraTrigger()
3✔
644
    // Date.now() returns time in milliseconds
645
    msg.timeUsec = BigInt(Date.now() * 1000)
3✔
646
    // Increment the photo counter
647
    msg.seq = this.photoSeq++
3✔
648

649
    this.eventEmitter.emit('digicamcontrol', senderSysId, senderCompId, targetComponent)
3✔
650
    this.eventEmitter.emit('cameratrigger', msg, senderCompId)
3✔
651
  }
652

653
  toggleVideoRecording() {
654
    if (!this.active || !this.deviceStream) {
3!
NEW
655
      console.log('Cannot toggle video - camera not active');
×
NEW
656
      return;
×
657
    }
658

659
    console.log('Toggling local video recording via SIGUSR1');
3✔
660
    // Signal the Python process to start or stop the file write
661
    this.deviceStream.kill('SIGUSR1');
3✔
662
  }
663

664
  // Helper to convert JS strings to the Array<string> format node-mavlink expects for char[]
665
  toMavChars(str, length) {
666
    const buf = new Uint8Array(length);
6✔
667
    if (!str) return buf;
6!
668

669
    const encoded = new TextEncoder().encode(str);
6✔
670
    buf.set(encoded.slice(0, length));
6✔
671
    return buf;
6✔
672
  }
673

674
  sendCameraInformation(senderSysId, senderCompId, targetComponent) {
675
    console.log('Sending MAVLink CameraInformation packet')
3✔
676

677
    const msg = new common.CameraInformation();
3✔
678

679
    // Get the camera model name, and handle cases where settings might be null
680
    let devicePath = "Unknown";
3✔
681
    if (this.cameraMode === 'photo' && this.stillSettings && this.stillSettings.device) {
3!
NEW
682
      devicePath = this.stillSettings.device;
×
683
    } else if (this.videoSettings && this.videoSettings.device) {
3!
684
      devicePath = this.videoSettings.device;
3✔
685
    }
686

687
    let extractedModel = "Unknown";
3✔
688
    if (devicePath !== "Unknown") {
3!
689
      if (devicePath.includes('rtspsource')) {
3!
NEW
690
        extractedModel = "RTSP Source";
×
691
      } else if (devicePath.includes('/')) {
3!
692
        // e.g. /base/soc/i2c0mux/i2c@1/imx219@10 -> imx219
NEW
693
        const parts = devicePath.split('/');
×
NEW
694
        const leaf = parts[parts.length - 1];
×
NEW
695
        extractedModel = leaf.split('@')[0];
×
696
      } else {
697
        extractedModel = devicePath;
3✔
698
      }
699
    }
700

701
    msg.vendorName = this.toMavChars("Rpanion", 32);
3✔
702
    msg.modelName = this.toMavChars(extractedModel, 32);
3✔
703
    msg.firmwareVersion = 0;
3✔
704
    msg.focalLength = 0;
3✔
705
    msg.sensorSizeH = 0;
3✔
706
    msg.sensorSizeV = 0;
3✔
707
    msg.lensId = 0;
3✔
708
    msg.camDefinitionVersion = 0;
3✔
709
    msg.camDefinitionUri = ""; // send zero-length string if not known
3✔
710
    msg.gimbalDeviceId = 0;
3✔
711

712
    // Mode-specific Configuration
713
    if (this.cameraMode === 'photo') {
3!
NEW
714
      msg.resolutionH = this.stillSettings?.width || 0;
×
NEW
715
      msg.resolutionV = this.stillSettings?.height || 0;
×
NEW
716
      msg.flags = 2; // CAMERA_CAP_FLAGS_CAPTURE_IMAGE
×
717
    }
718
    else if (this.cameraMode === 'video') {
3!
NEW
719
      msg.resolutionH = this.videoSettings?.width || 0;
×
NEW
720
      msg.resolutionV = this.videoSettings?.height || 0;
×
NEW
721
      msg.flags = 4; // CAMERA_CAP_FLAGS_CAPTURE_VIDEO
×
722
    }
723
    else {
724
      // Default: streaming
725
      msg.resolutionH = this.videoSettings?.width || 0;
3✔
726
      msg.resolutionV = this.videoSettings?.height || 0;
3✔
727
      msg.flags = 256; // CAMERA_CAP_FLAGS_HAS_VIDEO_STREAM
3✔
728
    }
729

730
    this.eventEmitter.emit('camerainfo', msg, senderSysId, senderCompId, targetComponent);
3✔
731
  }
732

733
  sendCameraSettings(senderSysId, senderCompId, targetComponent) {
NEW
734
    console.log('Sending MAVLink CameraSettings packet')
×
735

736
    // build a CAMERA_SETTINGS packet
NEW
737
    const msg = new common.CameraSettings()
×
738

NEW
739
    msg.timeBootMs = 0
×
740

741
    // Camera modes: 0 = IMAGE, 1 = VIDEO, 2 = IMAGE_SURVEY
NEW
742
    if (this.cameraMode === 'photo') {
×
NEW
743
      msg.modeId = 0
×
744
    } else {
NEW
745
      msg.modeId = 1
×
746
    }
747

NEW
748
    msg.zoomLevel = null
×
NEW
749
    msg.focusLevel = null
×
750

NEW
751
    this.eventEmitter.emit('camerasettings', msg, senderSysId, senderCompId, targetComponent)
×
752
  }
753

754
  sendVideoStreamInformation(senderSysId, senderCompId, targetComponent) {
755
    console.log('Responding to MAVLink request for VideoStreamInformation')
3✔
756

757
    // build a VIDEO_STREAM_INFORMATION packet
758
    const msg = new common.VideoStreamInformation()
3✔
759

760
    // rpanion only supports a single stream, so streamId and count will always be 1
761
    msg.streamId = 1
3✔
762
    msg.count = 1
3✔
763

764
    // msg.type and msg.uri need to be different depending on whether RTP or RTSP is selected
765
    if (this.videoSettings && this.videoSettings.useUDP) {
3!
766
      // msg.type = 0 = VIDEO_STREAM_TYPE_RTSP
767
      // msg.type = 1 = VIDEO_STREAM_TYPE_RTPUDP
NEW
768
      msg.type = 1
×
769
      // For RTP, just send the destination UDP port instead of a full URI
NEW
770
      msg.uri = this.videoSettings.useUDPPort.toString();
×
771
    } else {
772
      msg.type = 0
3✔
773
      msg.encoding = this.videoSettings.compression === 'H264' ? 1 : (this.videoSettings.compression === 'H265' ? 2 : 0);
3!
774

775
      // Find the address in the list that matches the selected MAVLink interface IP
776
      // This uses the array populated in populateAddresses() to ensure 1:1 consistency with Web UI
777
      const matchedAddress = this.deviceAddresses.find(addr =>
3✔
778
        addr.includes(this.videoSettings.mavStreamSelected)
3✔
779
      );
780

781
      msg.uri = matchedAddress || "";
3!
782

783
    }
784

785
    // 1 = VIDEO_STREAM_STATUS_FLAGS_RUNNING
786
    msg.flags = 1;
3✔
787
    msg.framerate = this.videoSettings.fps;
3✔
788
    msg.resolutionH = this.videoSettings.width;
3✔
789
    msg.resolutionV = this.videoSettings.height;
3✔
790
    msg.bitrate = this.videoSettings.bitrate;
3✔
791
    msg.rotation = this.videoSettings.rotation;
3✔
792
    // Rpanion doesn't collect field of view values, so set to zero
793
    msg.hfov = 0;
3✔
794
    // Set the stream name (usually the device path)
795
    msg.name = this.videoSettings.device;
3✔
796

797
    this.eventEmitter.emit('videostreaminfo', msg, senderSysId, senderCompId, targetComponent)
3✔
798
  }
799

800
  onMavPacket(packet, data) {
NEW
801
    if (packet.header.msgid === common.CommandLong.MSG_ID &&
×
802
      data.targetComponent === minimal.MavComponent.CAMERA) {
NEW
803
      if (data._param1 === common.CameraInformation.MSG_ID) {
×
NEW
804
        console.log('Responding to MAVLink request for CameraInformation')
×
NEW
805
        this.sendCameraInformation(packet.header.sysid, minimal.MavComponent.CAMERA, packet.header.compid);
×
806
      }
NEW
807
      else if (data._param1 === common.VideoStreamInformation.MSG_ID && this.cameraMode === "streaming") {
×
NEW
808
        console.log('Responding to MAVLink request for VideoStreamInformation')
×
NEW
809
        this.sendVideoStreamInformation(packet.header.sysid, minimal.MavComponent.CAMERA, packet.header.compid);
×
810
      }
NEW
811
      else if (data._param1 === common.CameraSettings.MSG_ID) {
×
NEW
812
        console.log('Responding to MAVLink request for CameraSettings')
×
NEW
813
        this.sendCameraSettings(packet.header.sysid, minimal.MavComponent.CAMERA, packet.header.compid)
×
814
      }
815
      // 203 = MAV_CMD_DO_DIGICAM_CONTROL
NEW
816
      else if (data.command === 203) {
×
NEW
817
        console.log('Received DoDigicamControl command')
×
NEW
818
        this.captureStillPhoto(packet.header.sysid, minimal.MavComponent.CAMERA, packet.header.compid)
×
819
      }
820
    }
821
  }
822
}
823

824
module.exports = videoStream
3✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc