homebridge-nest-accfactory 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,36 +8,34 @@
8
8
  import EventEmitter from 'node:events';
9
9
  import { Buffer } from 'node:buffer';
10
10
  import { setTimeout, clearTimeout } from 'node:timers';
11
- import process from 'node:process';
12
- import child_process from 'node:child_process';
13
- import net from 'node:net';
14
11
  import dgram from 'node:dgram';
15
12
  import fs from 'node:fs';
16
13
  import path from 'node:path';
17
- import { fileURLToPath } from 'node:url';
18
14
 
19
15
  // Define our modules
20
16
  import HomeKitDevice from '../HomeKitDevice.js';
17
+ import Streamer from '../streamer.js';
21
18
  import NexusTalk from '../nexustalk.js';
22
19
  import WebRTC from '../webrtc.js';
20
+ import FFmpeg from '../ffmpeg.js';
21
+ import { processCommonData, parseDurationToSeconds, scaleValue } from '../utils.js';
23
22
 
24
- const CAMERA_RESOURCE = {
25
- OFFLINE: 'Nest_camera_offline.jpg',
26
- OFF: 'Nest_camera_off.jpg',
27
- TRANSFER: 'Nest_camera_transfer.jpg',
28
- };
29
- const MP4BOX = 'mp4box'; // MP4 box fragement event for HKSV recording
30
- const SNAPSHOT_CACHE_TIMEOUT = 30000; // Timeout for retaining snapshot image (in milliseconds)
23
+ // Define constants
24
+ import { DATA_SOURCE, PROTOBUF_RESOURCES, __dirname, RESOURCE_PATH, RESOURCE_IMAGES, DEVICE_TYPE, TIMERS } from '../consts.js';
25
+
26
+ const MP4BOX = 'mp4box';
31
27
  const STREAMING_PROTOCOL = {
32
28
  WEBRTC: 'PROTOCOL_WEBRTC',
33
29
  NEXUSTALK: 'PROTOCOL_NEXUSTALK',
34
30
  };
35
- const RESOURCE_PATH = '../res';
36
- const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a defined for JS __dirname
37
31
 
38
32
  export default class NestCamera extends HomeKitDevice {
39
33
  static TYPE = 'Camera';
40
- static VERSION = '2025.06.15';
34
+ static VERSION = '2025.08.04'; // Code version
35
+
36
+ // For messaging back to parent class (Doorbell/Floodlight)
37
+ static SET = HomeKitDevice.SET;
38
+ static GET = HomeKitDevice.GET;
41
39
 
42
40
  controller = undefined; // HomeKit Camera/Doorbell controller service
43
41
  streamer = undefined; // Streamer object for live/recording stream
@@ -46,22 +44,21 @@ export default class NestCamera extends HomeKitDevice {
46
44
  operatingModeService = undefined; // Link to camera/doorbell operating mode service
47
45
  personTimer = undefined; // Cooldown timer for person/face events
48
46
  motionTimer = undefined; // Cooldown timer for motion events
49
- snapshotTimer = undefined; // Timer for cached snapshot images
50
- lastSnapshotImage = undefined; // JPG image buffer for last camera snapshot
51
47
  snapshotEvent = undefined; // Event for which to get snapshot for
48
+ ffmpeg = undefined; // FFMpeg object class
52
49
 
53
50
  // Internal data only for this class
54
- #hkSessions = []; // Track live and recording active sessions
51
+ #liveSessions = new Map(); // Track active HomeKit live stream sessions (port, crypto, rtpSplitter)
55
52
  #recordingConfig = {}; // HomeKit Secure Video recording configuration
56
- #cameraOfflineImage = undefined; // JPG image buffer for camera offline
57
- #cameraVideoOffImage = undefined; // JPG image buffer for camera video off
58
- #cameraTransferringImage = undefined; // JPG image buffer for camera transferring between Nest/Google Home
53
+ #cameraImages = {}; // Snapshot resource images
54
+ #snapshotTimer = undefined; // Timer for cached snapshot images
55
+ #lastSnapshotImage = undefined; // JPG image buffer for last camera snapshot
59
56
 
60
- constructor(accessory, api, log, eventEmitter, deviceData) {
61
- super(accessory, api, log, eventEmitter, deviceData);
57
+ constructor(accessory, api, log, deviceData) {
58
+ super(accessory, api, log, deviceData);
62
59
 
63
- // Load supporrt image files as required
64
- const loadImageIfExists = (filename, label) => {
60
+ // Load support image files as required
61
+ const loadImageResource = (filename, label) => {
65
62
  let buffer = undefined;
66
63
  let file = path.resolve(__dirname, RESOURCE_PATH, filename);
67
64
  if (fs.existsSync(file) === true) {
@@ -72,25 +69,36 @@ export default class NestCamera extends HomeKitDevice {
72
69
  return buffer;
73
70
  };
74
71
 
75
- this.#cameraOfflineImage = loadImageIfExists(CAMERA_RESOURCE.OFFLINE, 'offline');
76
- this.#cameraVideoOffImage = loadImageIfExists(CAMERA_RESOURCE.OFF, 'video off');
77
- this.#cameraTransferringImage = loadImageIfExists(CAMERA_RESOURCE.TRANSFER, 'transferring');
72
+ this.#cameraImages = {
73
+ offline: loadImageResource(RESOURCE_IMAGES.CAMERA_OFFLINE, 'offline'),
74
+ off: loadImageResource(RESOURCE_IMAGES.CAMERA_OFF, 'video off'),
75
+ transfer: loadImageResource(RESOURCE_IMAGES.CAMERA_TRANSFER, 'transferring'),
76
+ };
77
+
78
+ // Create ffmpeg object if have been told valid binary
79
+ if (typeof this.deviceData?.ffmpeg?.binary === 'string' && this.deviceData?.ffmpeg?.valid === true) {
80
+ this.ffmpeg = new FFmpeg(this.deviceData?.ffmpeg?.binary, log);
81
+ }
78
82
  }
79
83
 
80
84
  // Class functions
81
- setupDevice(hapController = this.hap.CameraController) {
82
- // Setup motion services
85
+ onAdd() {
86
+ // Setup motion services. This needs to be done before we setup the HomeKit camera controller
83
87
  if (this.motionServices === undefined) {
84
88
  this.createCameraMotionServices();
85
89
  }
86
90
 
87
- // Setup HomeKit camera/doorbell controller
88
- if (this.controller === undefined && typeof hapController === 'function') {
89
- // Need to cleanup the CameraOperatingMode service. This is to allow seamless configuration
90
- // switching between enabling hksv or not
91
- // Thanks to @bcullman (Brad Ullman) for catching this
92
- this.accessory.removeService(this.accessory.getService(this.hap.Service.CameraOperatingMode));
93
- this.controller = new hapController(this.generateControllerOptions());
91
+ // Setup HomeKit camera controller
92
+ // Need to cleanup the CameraOperatingMode service. This is to allow seamless configuration
93
+ // switching between enabling hksv or not
94
+ // Thanks to @bcullman (Brad Ullman) for catching this
95
+ this.accessory.removeService(this.accessory.getService(this.hap.Service.CameraOperatingMode));
96
+ if (this.controller === undefined) {
97
+ // Establish the "camera" controller here
98
+ this.controller = new this.hap.CameraController(this.generateControllerOptions());
99
+ }
100
+ if (this.controller !== undefined) {
101
+ // Configure the controller thats been created
94
102
  this.accessory.configureController(this.controller);
95
103
  }
96
104
 
@@ -112,7 +120,7 @@ export default class NestCamera extends HomeKitDevice {
112
120
  (value === true && this.deviceData.statusled_brightness !== 0) ||
113
121
  (value === false && this.deviceData.statusled_brightness !== 1)
114
122
  ) {
115
- this.set({ uuid: this.deviceData.nest_google_uuid, statusled_brightness: value === true ? 0 : 1 });
123
+ this.message(HomeKitDevice.SET, { uuid: this.deviceData.nest_google_uuid, statusled_brightness: value === true ? 0 : 1 });
116
124
  this?.log?.info?.('Recording status LED on "%s" was turned', this.deviceData.description, value === true ? 'on' : 'off');
117
125
  }
118
126
  },
@@ -127,7 +135,10 @@ export default class NestCamera extends HomeKitDevice {
127
135
  onSet: (value) => {
128
136
  // only change IRLed status value if different than on-device
129
137
  if ((value === false && this.deviceData.irled_enabled === true) || (value === true && this.deviceData.irled_enabled === false)) {
130
- this.set({ uuid: this.deviceData.nest_google_uuid, irled_enabled: value === true ? 'auto_on' : 'always_off' });
138
+ this.message(HomeKitDevice.SET, {
139
+ uuid: this.deviceData.nest_google_uuid,
140
+ irled_enabled: value === true ? 'auto_on' : 'always_off',
141
+ });
131
142
 
132
143
  this?.log?.info?.('Night vision on "%s" was turned', this.deviceData.description, value === true ? 'on' : 'off');
133
144
  }
@@ -147,7 +158,7 @@ export default class NestCamera extends HomeKitDevice {
147
158
  (this.deviceData.streaming_enabled === true && value === true)
148
159
  ) {
149
160
  // Camera state does not reflect requested state, so fix
150
- this.set({ uuid: this.deviceData.nest_google_uuid, streaming_enabled: value === false ? true : false });
161
+ this.message(HomeKitDevice.SET, { uuid: this.deviceData.nest_google_uuid, streaming_enabled: value === false ? true : false });
151
162
  this?.log?.info?.('Camera on "%s" was turned', this.deviceData.description, value === false ? 'on' : 'off');
152
163
  }
153
164
  }
@@ -177,7 +188,7 @@ export default class NestCamera extends HomeKitDevice {
177
188
  (this.deviceData.audio_enabled === true && value === this.hap.Characteristic.RecordingAudioActive.DISABLE) ||
178
189
  (this.deviceData.audio_enabled === false && value === this.hap.Characteristic.RecordingAudioActive.ENABLE)
179
190
  ) {
180
- this.set({
191
+ this.message(HomeKitDevice.SET, {
181
192
  uuid: this.deviceData.nest_google_uuid,
182
193
  audio_enabled: value === this.hap.Characteristic.RecordingAudioActive.ENABLE ? true : false,
183
194
  });
@@ -214,46 +225,37 @@ export default class NestCamera extends HomeKitDevice {
214
225
  );
215
226
  }
216
227
 
217
- // Setup linkage to EveHome app if configured todo so
218
- if (
219
- this.deviceData?.eveHistory === true &&
220
- typeof this.motionServices?.[1]?.service === 'object' &&
221
- typeof this.historyService?.linkToEveHome === 'function'
222
- ) {
223
- this.historyService.linkToEveHome(this.motionServices[1].service, {
224
- description: this.deviceData.description,
225
- });
226
- }
227
-
228
228
  // Extra setup details for output
229
- this.deviceData.hksv === true &&
230
- this.postSetupDetail('HomeKit Secure Video support' + (this.streamer?.isBuffering() === true ? ' and recording buffer started' : ''));
231
- this.deviceData.localAccess === true && this.postSetupDetail('Local access');
229
+ this.deviceData.streaming_protocols.includes(STREAMING_PROTOCOL.WEBRTC) === true &&
230
+ WebRTC !== undefined &&
231
+ this.postSetupDetail('WebRTC streamer', 'debug');
232
+ this.deviceData.streaming_protocols.includes(STREAMING_PROTOCOL.NEXUSTALK) === true &&
233
+ NexusTalk !== undefined &&
234
+ this.postSetupDetail('NexusTalk streamer', 'debug');
235
+ this.deviceData.hksv === true && this.postSetupDetail('HomeKit Secure Video support');
236
+ this.deviceData.localAccess === true && this.postSetupDetail('Local network access');
237
+ this.deviceData.ffmpeg.hwaccel === true && this.postSetupDetail('Video hardware acceleration');
232
238
  }
233
239
 
234
- removeDevice() {
240
+ onRemove() {
235
241
  // Clean up our camera object since this device is being removed
236
242
  clearTimeout(this.motionTimer);
237
243
  clearTimeout(this.personTimer);
238
- clearTimeout(this.snapshotTimer);
244
+ clearTimeout(this.#snapshotTimer);
239
245
  this.motionTimer = undefined;
240
246
  this.personTimer = undefined;
241
- this.snapshotTimer = undefined;
247
+ this.#snapshotTimer = undefined;
248
+
249
+ // Stop all streamer logic (buffering, output, etc)
250
+ this.streamer?.stopEverything?.();
242
251
 
243
- this.streamer !== undefined && this.streamer.stopEverything();
252
+ // Terminate any remaining ffmpeg sessions for this camera/doorbell
253
+ this.ffmpeg?.killAllSessions?.(this.uuid);
244
254
 
245
255
  // Stop any on-going HomeKit sessions, either live or recording
246
- // We'll terminate any ffmpeg, rtpSpliter etc processes
247
- this.#hkSessions.forEach((session) => {
248
- if (typeof session.rtpSplitter?.close === 'function') {
249
- session.rtpSplitter.close();
250
- }
251
- session.ffmpeg.forEach((ffmpeg) => {
252
- ffmpeg.kill('SIGKILL');
253
- });
254
- if (session?.eventEmitter instanceof EventEmitter === true) {
255
- session.eventEmitter.removeAllListeners(MP4BOX);
256
- }
256
+ // We'll terminate any ffmpeg, rtpSplitter etc processes
257
+ this.#liveSessions?.forEach?.((session) => {
258
+ session?.rtpSplitter?.close?.();
257
259
  });
258
260
 
259
261
  // Remove any motion services we created
@@ -265,300 +267,573 @@ export default class NestCamera extends HomeKitDevice {
265
267
  // Remove the camera controller
266
268
  this.accessory.removeController(this.controller);
267
269
 
270
+ // Clear references
268
271
  this.operatingModeService = undefined;
269
- this.#hkSessions = undefined;
272
+ this.#liveSessions = undefined;
270
273
  this.motionServices = undefined;
271
274
  this.streamer = undefined;
272
275
  this.controller = undefined;
273
276
  }
274
277
 
275
- // Taken and adapted from:
276
- // https://github.com/hjdhjd/homebridge-unifi-protect/blob/eee6a4e379272b659baa6c19986d51f5bf2cbbbc/src/protect-ffmpeg-record.ts
277
- async *handleRecordingStreamRequest(sessionID) {
278
- if (this.deviceData?.ffmpeg?.binary === undefined) {
279
- this?.log?.warn?.(
280
- 'Received request to start recording for "%s" however we do not have an ffmpeg binary present',
281
- this.deviceData.description,
282
- );
283
- return;
284
- }
285
-
286
- if (
287
- this.motionServices?.[1]?.service !== undefined &&
288
- this.motionServices[1].service.getCharacteristic(this.hap.Characteristic.MotionDetected).value === false
289
- ) {
290
- // Should only be recording if motion detected.
291
- // Sometimes when starting up, HAP-nodeJS or HomeKit triggers this even when motion isn't occuring
292
- this?.log?.debug?.('Received request to commence recording for "%s" however we have not detected any motion');
293
- return;
294
- }
295
-
296
- if (this.streamer === undefined) {
297
- this?.log?.error?.(
298
- 'Received request to start recording for "%s" however we do not any associated streaming protocol support',
299
- this.deviceData.description,
300
- );
278
+ async onUpdate(deviceData) {
279
+ if (typeof deviceData !== 'object' || this.controller === undefined) {
301
280
  return;
302
281
  }
303
282
 
304
- // Build our ffmpeg command string for the liveview video/audio stream
305
- let commandLine = [
306
- '-hide_banner',
307
- '-nostats',
308
- '-use_wallclock_as_timestamps 1',
309
- '-fflags +discardcorrupt',
310
- '-max_delay 500000',
311
- '-flags low_delay',
312
- '-f h264',
313
- '-i pipe:0',
314
- ];
315
-
316
- let includeAudio = false;
317
- if (
318
- this.deviceData.audio_enabled === true &&
319
- this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.RecordingAudioActive)
320
- .value === this.hap.Characteristic.RecordingAudioActive.ENABLE &&
321
- this.streamer?.codecs?.audio === 'aac' &&
322
- this.deviceData?.ffmpeg?.libfdk_aac === true
323
- ) {
324
- // Audio data only on extra pipe created in spawn command
325
- commandLine.push('-f aac -i pipe:3');
326
- includeAudio = true;
327
- }
328
-
329
- if (
330
- this.deviceData.audio_enabled === true &&
331
- this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.RecordingAudioActive)
332
- .value === this.hap.Characteristic.RecordingAudioActive.ENABLE &&
333
- this.streamer?.codecs?.audio === 'opus' &&
334
- this.deviceData?.ffmpeg?.libopus === true
335
- ) {
336
- // Audio data only on extra pipe created in spawn command
337
- commandLine.push('-i pipe:3');
338
- includeAudio = true;
283
+ if (this.deviceData.migrating === false && deviceData.migrating === true) {
284
+ // Migration happening between Nest <-> Google Home apps. We'll stop any active streams, close the current streaming object
285
+ this?.log?.warn?.('Migration between Nest <-> Google Home apps has started for "%s"', deviceData.description);
286
+ this.streamer?.stopEverything?.();
287
+ this.streamer = undefined;
339
288
  }
340
289
 
341
- // Build our video command for ffmpeg
342
- commandLine.push(
343
- '-map 0:v:0',
344
- '-codec:v libx264',
345
- '-preset veryfast',
346
- '-profile:v ' +
347
- (this.#recordingConfig.videoCodec.parameters.profile === this.hap.H264Profile.HIGH
348
- ? 'high'
349
- : this.#recordingConfig.videoCodec.parameters.profile === this.hap.H264Profile.MAIN
350
- ? 'main'
351
- : 'baseline'),
352
- '-level:v ' +
353
- (this.#recordingConfig.videoCodec.parameters.level === this.hap.H264Level.LEVEL4_0
354
- ? '4.0'
355
- : this.#recordingConfig.videoCodec.parameters.level === this.hap.H264Level.LEVEL3_2
356
- ? '3.2'
357
- : '3.1'),
358
- '-noautoscale',
359
- '-bf 0',
360
- '-filter:v fps=fps=' + this.#recordingConfig.videoCodec.resolution[2],
361
- '-g:v ' + (this.#recordingConfig.videoCodec.resolution[2] * this.#recordingConfig.videoCodec.parameters.iFrameInterval) / 1000,
362
- '-b:v ' + this.#recordingConfig.videoCodec.parameters.bitRate + 'k',
363
- '-bufsize ' + 2 * this.#recordingConfig.videoCodec.parameters.bitRate + 'k',
364
- '-fps_mode passthrough',
365
- '-reset_timestamps 1',
366
- '-video_track_timescale 90000',
367
- '-movflags frag_keyframe+empty_moov+default_base_moof',
368
- );
369
-
370
- // We have seperate video and audio streams that need to be muxed together if audio enabled
371
- if (includeAudio === true) {
372
- let audioSampleRates = ['8', '16', '24', '32', '44.1', '48'];
373
-
374
- commandLine.push(
375
- '-map 1:a:0',
376
- '-codec:a libfdk_aac',
377
- '-profile:a aac_low',
378
- '-ar ' + audioSampleRates[this.#recordingConfig.audioCodec.samplerate] + 'k',
379
- '-b:a ' + this.#recordingConfig.audioCodec.bitrate + 'k',
380
- '-ac ' + this.#recordingConfig.audioCodec.audioChannels,
381
- );
290
+ if (this.deviceData.migrating === true && deviceData.migrating === false) {
291
+ // Migration has completed between Nest <-> Google Home apps
292
+ this?.log?.success?.('Migration between Nest <-> Google Home apps has completed for "%s"', deviceData.description);
382
293
  }
383
294
 
384
- commandLine.push('-f mp4 pipe:1'); // output to stdout in mp4
385
-
386
- // Start our ffmpeg recording process and stream from our streamer
387
- // video is pipe #1
388
- // audio is pipe #3 if including audio
389
- this?.log?.debug?.(
390
- 'ffmpeg process for recording stream from "%s" will be called using the following commandline',
391
- this.deviceData.description,
392
- commandLine.join(' ').toString(),
393
- );
394
- let ffmpegRecording = child_process.spawn(this.deviceData.ffmpeg.binary, commandLine.join(' ').split(' '), {
395
- env: process.env,
396
- stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
397
- });
398
-
399
- // Process FFmpeg output and parse out the fMP4 stream it's generating for HomeKit Secure Video.
400
- let mp4FragmentData = [];
401
- let mp4boxes = [];
402
- let eventEmitter = new EventEmitter();
403
-
404
- ffmpegRecording.stdout.on('data', (data) => {
405
- // Process the mp4 data from our socket connection and convert into mp4 fragment boxes we need
406
- mp4FragmentData = mp4FragmentData.length === 0 ? data : Buffer.concat([mp4FragmentData, data]);
407
- while (mp4FragmentData.length >= 8) {
408
- let boxSize = mp4FragmentData.slice(0, 4).readUInt32BE(0); // Includes header and data size
409
-
410
- if (mp4FragmentData.length < boxSize) {
411
- // We dont have enough data in the buffer yet to process the full mp4 box
412
- // so, exit loop and await more data
413
- break;
414
- }
415
-
416
- // Add it to our queue to be pushed out through the generator function.
417
- if (Array.isArray(mp4boxes) === true && eventEmitter !== undefined) {
418
- mp4boxes.push({
419
- header: mp4FragmentData.slice(0, 8),
420
- type: mp4FragmentData.slice(4, 8).toString(),
421
- data: mp4FragmentData.slice(8, boxSize),
422
- });
423
- eventEmitter.emit(MP4BOX);
295
+ // Handle case of changes in streaming protocols OR just finished migration between Nest <-> Google Home apps
296
+ if (this.streamer === undefined && deviceData.migrating === false) {
297
+ if (JSON.stringify(deviceData.streaming_protocols) !== JSON.stringify(this.deviceData.streaming_protocols)) {
298
+ this?.log?.warn?.('Available streaming protocols have changed for "%s"', deviceData.description);
299
+ this.streamer?.stopEverything?.();
300
+ this.streamer = undefined;
301
+ }
302
+ if (deviceData.streaming_protocols.includes(STREAMING_PROTOCOL.WEBRTC) === true && WebRTC !== undefined) {
303
+ if (this.deviceData.migrating === true && deviceData.migrating === false) {
304
+ this?.log?.debug?.('Using WebRTC streamer for "%s" after migration', deviceData.description);
424
305
  }
425
306
 
426
- // Remove the section of data we've just processed from our buffer
427
- mp4FragmentData = mp4FragmentData.slice(boxSize);
307
+ this.streamer = new WebRTC(this.uuid, deviceData, {
308
+ log: this.log,
309
+ });
428
310
  }
429
- });
430
311
 
431
- ffmpegRecording.on('exit', (code, signal) => {
432
- if (signal !== 'SIGKILL' || signal === null) {
433
- this?.log?.error?.('ffmpeg recording process for "%s" stopped unexpectedly. Exit code was "%s"', this.deviceData.description, code);
434
- }
312
+ if (deviceData.streaming_protocols.includes(STREAMING_PROTOCOL.NEXUSTALK) === true && NexusTalk !== undefined) {
313
+ if (this.deviceData.migrating === true && deviceData.migrating === false) {
314
+ this?.log?.debug?.('Using NexusTalk streamer for "%s" after migration', deviceData.description);
315
+ }
435
316
 
436
- if (this.#hkSessions?.[sessionID] !== undefined) {
437
- delete this.#hkSessions[sessionID];
317
+ this.streamer = new NexusTalk(this.uuid, deviceData, {
318
+ log: this.log,
319
+ });
438
320
  }
439
- });
440
-
441
- // eslint-disable-next-line no-unused-vars
442
- ffmpegRecording.on('error', (error) => {
443
- // Empty
444
- });
445
-
446
- // ffmpeg console output is via stderr
447
- ffmpegRecording.stderr.on('data', (data) => {
448
- if (data.toString().includes('frame=') === false && this.deviceData?.ffmpeg?.debug === true) {
449
- // Monitor ffmpeg output
450
- this?.log?.debug?.(data.toString());
321
+ if (
322
+ this?.streamer?.isBuffering() === false &&
323
+ deviceData?.hksv === true &&
324
+ this?.controller?.recordingManagement?.recordingManagementService !== undefined &&
325
+ this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.Active).value ===
326
+ this.hap.Characteristic.Active.ACTIVE
327
+ ) {
328
+ await this.message(Streamer.MESSAGE, Streamer.MESSAGE_TYPE.START_BUFFER);
451
329
  }
452
- });
330
+ }
453
331
 
454
- // Start the appropriate streamer
455
- this.streamer !== undefined &&
456
- this.streamer.startRecordStream(sessionID, ffmpegRecording.stdin, ffmpegRecording?.stdio?.[3] ? ffmpegRecording.stdio[3] : null);
332
+ // Check to see if any activity zones were added for both non-HKSV and HKSV enabled devices
333
+ if (
334
+ Array.isArray(deviceData.activity_zones) === true &&
335
+ JSON.stringify(deviceData.activity_zones) !== JSON.stringify(this.deviceData.activity_zones)
336
+ ) {
337
+ deviceData.activity_zones.forEach((zone) => {
338
+ if (this.deviceData.hksv === false || (this.deviceData.hksv === true && this.ffmpeg instanceof FFmpeg === true && zone.id === 1)) {
339
+ if (this.motionServices?.[zone.id]?.service === undefined) {
340
+ // Zone doesn't have an associated motion sensor, so add one
341
+ let zoneName = zone.id === 1 ? '' : zone.name;
342
+ let eveOptions = zone.id === 1 ? {} : undefined; // Only link EveHome for zone 1
457
343
 
458
- // Store our ffmpeg sessions
459
- this.#hkSessions[sessionID] = {};
460
- this.#hkSessions[sessionID].eventEmitter = eventEmitter;
461
- this.#hkSessions[sessionID].ffmpeg = ffmpegRecording; // Store ffmpeg process ID
344
+ let tempService = this.addHKService(this.hap.Service.MotionSensor, zoneName, zone.id, eveOptions);
462
345
 
463
- this?.log?.info?.('Started recording from "%s" %s', this.deviceData.description, includeAudio === false ? 'without audio' : '');
346
+ this.addHKCharacteristic(tempService, this.hap.Characteristic.Active);
347
+ tempService.updateCharacteristic(this.hap.Characteristic.Name, zoneName);
348
+ tempService.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); // No motion initially
464
349
 
465
- // Loop generating MOOF/MDAT box pairs for HomeKit Secure Video.
466
- // HAP-NodeJS cancels this async generator function when recording completes also
467
- let segment = [];
468
- for (;;) {
469
- if (this.#hkSessions?.[sessionID] === undefined || this.#hkSessions?.[sessionID]?.ffmpeg === undefined) {
470
- // Our session object is not present
471
- // ffmpeg recorder process is not present
472
- // so finish up the loop
473
- break;
474
- }
350
+ this.motionServices[zone.id] = { service: tempService, timer: undefined };
351
+ }
352
+ }
353
+ });
354
+ }
475
355
 
476
- if (mp4boxes?.length === 0 && eventEmitter !== undefined) {
477
- // since the ffmpeg recorder process hasn't notified us of any mp4 fragment boxes, wait until there are some
478
- await EventEmitter.once(eventEmitter, MP4BOX);
479
- }
356
+ // Check to see if any activity zones were removed for both non-HKSV and HKSV enabled devices
357
+ // We'll also update the online status of the camera in the motion service here
358
+ Object.entries(this.motionServices).forEach(([zoneID, service]) => {
359
+ // Set online status
360
+ service.service.updateCharacteristic(
361
+ this.hap.Characteristic.Active,
362
+ deviceData.online === true ? this.hap.Characteristic.Active.ACTIVE : this.hap.Characteristic.Active.INACTIVE,
363
+ );
480
364
 
481
- let mp4box = mp4boxes.shift();
482
- if (typeof mp4box !== 'object') {
483
- // Not an mp4 fragment box, so try again
484
- continue;
365
+ // Handle deleted zones (excluding zone ID 1 for HKSV)
366
+ if (
367
+ zoneID !== '1' &&
368
+ Array.isArray(deviceData.activity_zones) === true &&
369
+ deviceData.activity_zones.findIndex(({ id }) => id === Number(zoneID)) === -1
370
+ ) {
371
+ // Motion service we created doesn't appear in zone list anymore, so assume deleted
372
+ this.accessory.removeService(service.service);
373
+ delete this.motionServices[zoneID];
485
374
  }
375
+ });
486
376
 
487
- // Queue up this fragment mp4 segment
488
- segment.push(mp4box.header, mp4box.data);
377
+ if (this.operatingModeService !== undefined) {
378
+ // Update camera off/on status
379
+ this.operatingModeService.updateCharacteristic(
380
+ this.hap.Characteristic.ManuallyDisabled,
381
+ deviceData.streaming_enabled === false
382
+ ? this.hap.Characteristic.ManuallyDisabled.DISABLED
383
+ : this.hap.Characteristic.ManuallyDisabled.ENABLED,
384
+ );
489
385
 
490
- if (mp4box.type === 'moov' || mp4box.type === 'mdat') {
491
- yield { data: Buffer.concat(segment), isLast: false };
492
- segment = [];
386
+ if (deviceData?.has_statusled === true) {
387
+ // Set camera recording indicator. This cannot be turned off on Nest Cameras/Doorbells
388
+ // 0 = auto
389
+ // 1 = low
390
+ // 2 = high
391
+ this.operatingModeService.updateCharacteristic(
392
+ this.hap.Characteristic.CameraOperatingModeIndicator,
393
+ deviceData.statusled_brightness !== 1,
394
+ );
493
395
  }
494
- }
495
- }
496
396
 
497
- closeRecordingStream(sessionID, closeReason) {
498
- // Stop the associated recording stream
499
- this.streamer !== undefined && this.streamer.stopRecordStream(sessionID);
500
-
501
- if (typeof this.#hkSessions?.[sessionID] === 'object') {
502
- if (this.#hkSessions[sessionID]?.ffmpeg !== undefined) {
503
- // Kill the ffmpeg recorder process
504
- this.#hkSessions[sessionID].ffmpeg.kill('SIGKILL');
397
+ if (deviceData?.has_irled === true) {
398
+ // Set nightvision status in HomeKit
399
+ this.operatingModeService.updateCharacteristic(this.hap.Characteristic.NightVision, deviceData.irled_enabled);
505
400
  }
506
- if (this.#hkSessions[sessionID]?.eventEmitter !== undefined) {
507
- this.#hkSessions[sessionID].eventEmitter.emit(MP4BOX); // This will ensure we cleanly exit out from our segment generator
508
- this.#hkSessions[sessionID].eventEmitter.removeAllListeners(MP4BOX); // Tidy up our event listeners
401
+
402
+ if (deviceData?.has_video_flip === true) {
403
+ // Update image flip status
404
+ this.operatingModeService.updateCharacteristic(this.hap.Characteristic.ImageRotation, deviceData.video_flipped === true ? 180 : 0);
509
405
  }
510
- delete this.#hkSessions[sessionID];
511
406
  }
512
407
 
513
- // Log recording finished messages depending on reason
514
- if (closeReason === this.hap.HDSProtocolSpecificErrorReason.NORMAL) {
515
- this?.log?.info?.('Completed recording from "%s"', this.deviceData.description);
516
- } else {
517
- this?.log?.warn?.(
518
- 'Recording from "%s" completed with error. Reason was "%s"',
519
- this.deviceData.description,
520
- this.hap.HDSProtocolSpecificErrorReason[closeReason],
408
+ if (deviceData.hksv === true && this.controller?.recordingManagement?.recordingManagementService !== undefined) {
409
+ // Update recording audio status
410
+ this.controller.recordingManagement.recordingManagementService.updateCharacteristic(
411
+ this.hap.Characteristic.RecordingAudioActive,
412
+ deviceData.audio_enabled === true
413
+ ? this.hap.Characteristic.RecordingAudioActive.ENABLE
414
+ : this.hap.Characteristic.RecordingAudioActive.DISABLE,
521
415
  );
522
416
  }
523
- }
524
417
 
525
- updateRecordingActive(enableRecording) {
526
- if (enableRecording === true && this.streamer?.isBuffering() === false) {
527
- // Start a buffering stream for this camera/doorbell. Ensures motion captures all video on motion trigger
528
- // Required due to data delays by on prem Nest to cloud to HomeKit accessory to iCloud etc
529
- // Make sure have appropriate bandwidth!!!
530
- this?.log?.info?.('Recording was turned on for "%s"', this.deviceData.description);
531
- this.streamer.startBuffering();
418
+ if (this.controller?.microphoneService !== undefined) {
419
+ // Update microphone volume if specified
420
+ //this.controller.microphoneService.updateCharacteristic(this.hap.Characteristic.Volume, deviceData.xxx);
421
+
422
+ // if audio is disabled, we'll mute microphone
423
+ this.controller.setMicrophoneMuted(deviceData.audio_enabled === false ? true : false);
532
424
  }
425
+ if (this.controller?.speakerService !== undefined) {
426
+ // Update speaker volume if specified
427
+ //this.controller.speakerService.updateCharacteristic(this.hap.Characteristic.Volume, deviceData.xxx);
533
428
 
534
- if (enableRecording === false && this.streamer?.isBuffering() === true) {
535
- this.streamer.stopBuffering();
536
- this?.log?.warn?.('Recording was turned off for "%s"', this.deviceData.description);
429
+ // if audio is disabled, we'll mute speaker
430
+ this.controller.setSpeakerMuted(deviceData.audio_enabled === false ? true : false);
537
431
  }
538
- }
539
432
 
540
- updateRecordingConfiguration(recordingConfig) {
541
- this.#recordingConfig = recordingConfig; // Store the recording configuration HKSV has provided
542
- }
433
+ // Process alerts, the most recent alert is first
434
+ // For HKSV, we're interested motion events
435
+ // For non-HKSV, we're interested motion, face and person events (maybe sound and package later)
436
+ deviceData.alerts.forEach((event) => {
437
+ if (
438
+ this.operatingModeService === undefined ||
439
+ (this.operatingModeService !== undefined &&
440
+ this.operatingModeService.getCharacteristic(this.hap.Characteristic.HomeKitCameraActive).value ===
441
+ this.hap.Characteristic.HomeKitCameraActive.ON)
442
+ ) {
443
+ // We're configured to handle camera events
444
+ // https://github.com/Supereg/secure-video-specification?tab=readme-ov-file#33-homekitcameraactive
543
445
 
544
- async handleSnapshotRequest(snapshotRequestDetails, callback) {
545
- // snapshotRequestDetails.reason === ResourceRequestReason.PERIODIC
546
- // snapshotRequestDetails.reason === ResourceRequestReason.EVENT
446
+ // Handle motion event
447
+ // For a HKSV enabled camera, we will use this to trigger the starting of the HKSV recording if the camera is active
448
+ if (event.types.includes('motion') === true) {
449
+ if (
450
+ this.motionTimer === undefined &&
451
+ (this.deviceData.hksv === false || this.ffmpeg instanceof FFmpeg === false || this.streamer === undefined)
452
+ ) {
453
+ this?.log?.info?.('Motion detected at "%s"', deviceData.description);
454
+ }
547
455
 
548
- // Get current image from camera/doorbell
549
- let imageBuffer = undefined;
456
+ event.zone_ids.forEach((zoneID) => {
457
+ if (
458
+ typeof this.motionServices?.[zoneID]?.service === 'object' &&
459
+ this.motionServices[zoneID].service.getCharacteristic(this.hap.Characteristic.MotionDetected).value !== true
460
+ ) {
461
+ // Trigger motion for matching zone of not aleady active
462
+ this.motionServices[zoneID].service.updateCharacteristic(this.hap.Characteristic.MotionDetected, true);
550
463
 
551
- if (this.deviceData.migrating === false && this.deviceData.streaming_enabled === true && this.deviceData.online === true) {
552
- let response = await this.get({ uuid: this.deviceData.nest_google_uuid, camera_snapshot: '' });
553
- if (Buffer.isBuffer(response?.camera_snapshot) === true) {
554
- imageBuffer = response.camera_snapshot;
555
- this.lastSnapshotImage = response.camera_snapshot;
464
+ // Log motion started into history
465
+ this.history(this.motionServices[zoneID].service, {
466
+ status: 1,
467
+ });
468
+ }
469
+ });
556
470
 
557
- // Keep this snapshot image cached for a certain period
558
- clearTimeout(this.snapshotTimer);
559
- this.snapshotTimer = setTimeout(() => {
560
- this.lastSnapshotImage = undefined;
561
- }, SNAPSHOT_CACHE_TIMEOUT);
471
+ // Clear any motion active timer so we can extend if more motion detected
472
+ clearTimeout(this.motionTimer);
473
+ this.motionTimer = setTimeout(() => {
474
+ event.zone_ids.forEach((zoneID) => {
475
+ if (typeof this.motionServices?.[zoneID]?.service === 'object') {
476
+ // Mark associted motion services as motion not detected
477
+ this.motionServices[zoneID].service.updateCharacteristic(this.hap.Characteristic.MotionDetected, false);
478
+
479
+ // Log motion started into history
480
+ this.history(this.motionServices[zoneID].service, { status: 0 });
481
+ }
482
+ });
483
+
484
+ this.motionTimer = undefined; // No motion timer active
485
+ }, this.deviceData.motionCooldown * 1000);
486
+ }
487
+
488
+ // Handle person/face event
489
+ // We also treat a 'face' event the same as a person event ie: if you have a face, you have a person
490
+ if (event.types.includes('person') === true || event.types.includes('face') === true) {
491
+ if (this.personTimer === undefined) {
492
+ // We don't have a person cooldown timer running, so we can process the 'person'/'face' event
493
+ if (this.deviceData.hksv === false || this.ffmpeg instanceof FFmpeg === false || this.streamer === undefined) {
494
+ // We'll only log a person detected event if HKSV is disabled
495
+ this?.log?.info?.('Person detected at "%s"', deviceData.description);
496
+ }
497
+
498
+ // Cooldown for person being detected
499
+ // Start this before we process further
500
+ this.personTimer = setTimeout(() => {
501
+ this.personTimer = undefined; // No person timer active
502
+ }, this.deviceData.personCooldown * 1000);
503
+
504
+ if (event.types.includes('motion') === false) {
505
+ // If person/face events doesn't include a motion event, add in here
506
+ // This will handle all the motion triggering stuff
507
+ event.types.push('motion');
508
+ }
509
+ }
510
+ }
511
+ }
512
+ });
513
+ }
514
+
515
+ onShutdown() {
516
+ // Stop all streamer logic (buffering, output, etc)
517
+ this.streamer?.stopEverything?.();
518
+
519
+ // Terminate any remaining ffmpeg sessions for this camera/doorbell
520
+ this.ffmpeg?.killAllSessions?.(this.uuid);
521
+
522
+ // Stop any on-going HomeKit sessions, either live or recording
523
+ // We'll terminate any ffmpeg, rtpSplitter etc processes
524
+ this.#liveSessions?.forEach?.((session) => {
525
+ session?.rtpSplitter?.close?.();
526
+ });
527
+ }
528
+
529
+ // Taken and adapted from:
530
+ // https://github.com/hjdhjd/homebridge-unifi-protect/blob/eee6a4e379272b659baa6c19986d51f5bf2cbbbc/src/protect-ffmpeg-record.ts
531
+ async *handleRecordingStreamRequest(sessionID) {
532
+ if (this.ffmpeg instanceof FFmpeg === false) {
533
+ // No valid ffmpeg binary present, so cannot do recording!!
534
+ this?.log?.warn?.(
535
+ 'Received request to start recording for "%s" however we do not have an ffmpeg binary present',
536
+ this.deviceData.description,
537
+ );
538
+ return;
539
+ }
540
+
541
+ if (this.streamer === undefined) {
542
+ this?.log?.error?.(
543
+ 'Received request to start recording for "%s" however we do not have any associated streaming protocol support',
544
+ this.deviceData.description,
545
+ );
546
+ return;
547
+ }
548
+
549
+ if (
550
+ this.motionServices?.[1]?.service !== undefined &&
551
+ this.motionServices[1].service.getCharacteristic(this.hap.Characteristic.MotionDetected).value === false
552
+ ) {
553
+ // Should only be recording if motion detected.
554
+ // Sometimes when starting up, HAP-nodeJS or HomeKit triggers this even when motion isn't occurring
555
+ this?.log?.debug?.(
556
+ 'Received request to commence recording for "%s" however we have not detected any motion',
557
+ this.deviceData.description,
558
+ );
559
+ return;
560
+ }
561
+
562
+ let includeAudio =
563
+ this.deviceData.audio_enabled === true &&
564
+ this.controller?.recordingManagement?.recordingManagementService?.getCharacteristic(this.hap.Characteristic.RecordingAudioActive)
565
+ ?.value === this.hap.Characteristic.RecordingAudioActive.ENABLE;
566
+
567
+ let commandLine = [
568
+ '-hide_banner',
569
+ '-nostats',
570
+ '-fflags',
571
+ '+discardcorrupt+genpts',
572
+ '-avoid_negative_ts',
573
+ 'make_zero',
574
+ '-max_delay',
575
+ '500000',
576
+ '-flags',
577
+ 'low_delay',
578
+
579
+ // Video input
580
+ '-f',
581
+ 'h264',
582
+ '-i',
583
+ 'pipe:0',
584
+
585
+ // Audio input (optional)
586
+ ...(includeAudio === true
587
+ ? this.streamer.codecs.audio === Streamer.CODEC_TYPE.PCM
588
+ ? ['-thread_queue_size', '512', '-f', 's16le', '-ar', '48000', '-ac', '2', '-i', 'pipe:3']
589
+ : this.streamer.codecs.audio === Streamer.CODEC_TYPE.AAC
590
+ ? ['-thread_queue_size', '512', '-f', 'aac', '-i', 'pipe:3']
591
+ : []
592
+ : []),
593
+
594
+ // Video output including hardware acceleration if available
595
+ '-map',
596
+ '0:v:0',
597
+ '-codec:v',
598
+ this.deviceData?.ffmpeg?.hwaccel === true && this.ffmpeg?.hardwareH264Codec !== undefined ? this.ffmpeg.hardwareH264Codec : 'libx264',
599
+ ...(this.deviceData?.ffmpeg?.hwaccel !== true || ['h264_nvenc', 'h264_qsv'].includes(this.ffmpeg?.hardwareH264Codec || '') === true
600
+ ? [
601
+ '-preset',
602
+ 'veryfast',
603
+ '-profile:v',
604
+ this.#recordingConfig.videoCodec.parameters.profile === this.hap.H264Profile.HIGH
605
+ ? 'high'
606
+ : this.#recordingConfig.videoCodec.parameters.profile === this.hap.H264Profile.MAIN
607
+ ? 'main'
608
+ : 'baseline',
609
+ '-level:v',
610
+ this.#recordingConfig.videoCodec.parameters.level === this.hap.H264Level.LEVEL4_0
611
+ ? '4.0'
612
+ : this.#recordingConfig.videoCodec.parameters.level === this.hap.H264Level.LEVEL3_2
613
+ ? '3.2'
614
+ : '3.1',
615
+ '-bf',
616
+ '0',
617
+ ]
618
+ : []),
619
+
620
+ '-filter:v',
621
+ 'fps=fps=' + this.#recordingConfig.videoCodec.resolution[2] + ',format=yuv420p',
622
+ '-fps_mode',
623
+ 'cfr',
624
+ '-g:v',
625
+ Math.round(
626
+ (this.#recordingConfig.videoCodec.resolution[2] * this.#recordingConfig.videoCodec.parameters.iFrameInterval) / 1000,
627
+ ).toString(),
628
+ '-b:v',
629
+ this.#recordingConfig.videoCodec.parameters.bitRate + 'k',
630
+ '-bufsize',
631
+ 2 * this.#recordingConfig.videoCodec.parameters.bitRate + 'k',
632
+ '-video_track_timescale',
633
+ '90000',
634
+ '-movflags',
635
+ 'frag_keyframe+empty_moov+default_base_moof',
636
+
637
+ // Audio output
638
+ ...(includeAudio === true
639
+ ? ['-map', '1:a:0', '-codec:a', 'libfdk_aac', '-profile:a', 'aac_low', '-ar', '16000', '-b:a', '16k', '-ac', '1']
640
+ : []),
641
+
642
+ '-f',
643
+ 'mp4',
644
+ 'pipe:1',
645
+ ];
646
+
647
+ // Start our ffmpeg recording process and stream from our streamer
648
+ // video is pipe #1
649
+ // audio is pipe #3 if including audio
650
+ this?.log?.debug?.(
651
+ 'ffmpeg process for recording stream from "%s" will be called using the following commandline',
652
+ this.deviceData.description,
653
+ commandLine.join(' ').toString(),
654
+ );
655
+
656
+ let ffmpegStream = this.ffmpeg.createSession(
657
+ this.uuid,
658
+ sessionID,
659
+ commandLine,
660
+ 'record',
661
+ this.deviceData.ffmpeg.debug === true
662
+ ? (data) => {
663
+ if (data.toString().includes('frame=') === false) {
664
+ this?.log?.debug?.(data.toString());
665
+ }
666
+ }
667
+ : undefined,
668
+ 4, // 4 pipes required
669
+ );
670
+
671
+ if (ffmpegStream === undefined) {
672
+ return;
673
+ }
674
+
675
+ let buffer = Buffer.alloc(0);
676
+ let mp4boxes = [];
677
+
678
+ ffmpegStream?.stdout?.on?.('data', (chunk) => {
679
+ buffer = Buffer.concat([buffer, chunk]);
680
+
681
+ while (buffer.length >= 8) {
682
+ let boxSize = buffer.readUInt32BE(0);
683
+ if (boxSize < 8 || buffer.length < boxSize) {
684
+ // We dont have enough data in the buffer yet to process the full mp4 box
685
+ // so, exit loop and await more data
686
+ break;
687
+ }
688
+
689
+ let boxType = buffer.subarray(4, 8).toString();
690
+
691
+ // Add it to our queue to be pushed out through the generator function.
692
+ mp4boxes.push({
693
+ header: buffer.subarray(0, 8),
694
+ type: boxType,
695
+ data: buffer.subarray(8, boxSize),
696
+ });
697
+
698
+ buffer = buffer.subarray(boxSize);
699
+ this.emit(MP4BOX);
700
+ }
701
+ });
702
+
703
+ ffmpegStream?.on?.('exit', (code, signal) => {
704
+ if (signal !== 'SIGKILL' || signal === null) {
705
+ this?.log?.error?.('ffmpeg recording process for "%s" stopped unexpectedly. Exit code was "%s"', this.deviceData.description, code);
706
+ }
707
+
708
+ // Ensure generator wakes up and exits
709
+ this.emit(MP4BOX);
710
+ this.removeAllListeners(MP4BOX);
711
+ });
712
+
713
+ // Start the appropriate streamer
714
+ let { video, audio } = await this.message(Streamer.MESSAGE, Streamer.MESSAGE_TYPE.START_RECORD, {
715
+ sessionID: sessionID,
716
+ });
717
+
718
+ // Connect the ffmpeg process to the streamer input/output
719
+ video?.pipe?.(ffmpegStream?.stdin); // Streamer video → ffmpeg stdin (pipe:0)
720
+ audio?.pipe?.(ffmpegStream?.stdio?.[3]); // Streamer audio → ffmpeg pipe:3
721
+
722
+ this?.log?.info?.('Started recording from "%s" %s', this.deviceData.description, includeAudio === false ? 'without audio' : '');
723
+
724
+ // Loop generating MOOF/MDAT box pairs for HomeKit Secure Video.
725
+ // HAP-NodeJS cancels this async generator function when recording completes also
726
+ let segment = [];
727
+ for (;;) {
728
+ if (this.ffmpeg?.hasSession?.(this.uuid, sessionID, 'record') === false) {
729
+ break;
730
+ }
731
+
732
+ if (mp4boxes.length === 0) {
733
+ await EventEmitter.once(this, MP4BOX);
734
+
735
+ if (this.ffmpeg?.hasSession?.(this.uuid, sessionID, 'record') === false) {
736
+ break;
737
+ }
738
+
739
+ if (mp4boxes.length === 0) {
740
+ continue;
741
+ }
742
+ }
743
+
744
+ let box = mp4boxes.shift();
745
+ if (box === undefined) {
746
+ continue;
747
+ }
748
+
749
+ segment.push(box.header, box.data);
750
+
751
+ if (box.type === 'moov' || box.type === 'mdat') {
752
+ yield { data: Buffer.concat(segment), isLast: false };
753
+ segment = [];
754
+ }
755
+ }
756
+ }
757
+
758
+ async closeRecordingStream(sessionID, closeReason) {
759
+ // Stop recording stream from the streamer
760
+ await this.message(Streamer.MESSAGE, Streamer.MESSAGE_TYPE.STOP_RECORD, {
761
+ sessionID: sessionID,
762
+ });
763
+
764
+ // Terminate the ffmpeg recording process
765
+ this.ffmpeg?.killSession?.(this.uuid, sessionID, 'record', 'SIGKILL');
766
+
767
+ // Wake and clear HomeKit Secure Video generator
768
+ this.emit(MP4BOX);
769
+ this.removeAllListeners(MP4BOX);
770
+
771
+ // Log completion depending on reason
772
+ if (closeReason === this.hap.HDSProtocolSpecificErrorReason.NORMAL) {
773
+ this?.log?.info?.('Completed recording from "%s"', this.deviceData.description);
774
+ } else {
775
+ this?.log?.warn?.(
776
+ 'Recording from "%s" completed with error. Reason was "%s"',
777
+ this.deviceData.description,
778
+ this.hap.HDSProtocolSpecificErrorReason?.[closeReason] || 'code ' + closeReason,
779
+ );
780
+ }
781
+ }
782
+
783
+ async updateRecordingActive(enableRecording) {
784
+ if (this.streamer === undefined) {
785
+ return;
786
+ }
787
+
788
+ if (enableRecording === true && this.streamer.isBuffering() === false) {
789
+ // Start a buffering stream for this camera/doorbell. Ensures motion captures all video on motion trigger
790
+ // Required due to data delays by on prem Nest to cloud to HomeKit accessory to iCloud etc
791
+ // Make sure have appropriate bandwidth!!!
792
+ this?.log?.info?.('Recording was turned on for "%s"', this.deviceData.description);
793
+ await this.message(Streamer.MESSAGE, Streamer.MESSAGE_TYPE.START_BUFFER);
794
+ }
795
+
796
+ if (enableRecording === false && this.streamer.isBuffering() === true) {
797
+ // Stop buffering stream for this camera/doorbell
798
+ await this.message(Streamer.MESSAGE, Streamer.MESSAGE_TYPE.STOP_BUFFER);
799
+ this?.log?.warn?.('Recording was turned off for "%s"', this.deviceData.description);
800
+ }
801
+ }
802
+
803
+ updateRecordingConfiguration(recordingConfig) {
804
+ this.#recordingConfig = recordingConfig; // Store the recording configuration HKSV has provided
805
+ }
806
+
807
+ async handleSnapshotRequest(snapshotRequestDetails, callback) {
808
+ // snapshotRequestDetails.reason === ResourceRequestReason.PERIODIC
809
+ // snapshotRequestDetails.reason === ResourceRequestReason.EVENT
810
+
811
+ // eslint-disable-next-line no-unused-vars
812
+ const isLikelyBlackImage = (buffer) => {
813
+ // TODO <- Placeholder for actual black image detection logic
814
+ return false;
815
+ };
816
+
817
+ // Get current image from camera/doorbell
818
+ let imageBuffer = undefined;
819
+
820
+ if (this.deviceData.migrating === false && this.deviceData.streaming_enabled === true && this.deviceData.online === true) {
821
+ // Call the camera/doorbell to get a snapshot image.
822
+ // Prefer onGet() result if implemented; fallback to static handler
823
+ let response = await this.get({ uuid: this.deviceData.nest_google_uuid, camera_snapshot: Buffer.alloc(0) });
824
+ if (
825
+ Buffer.isBuffer(response?.camera_snapshot) === true &&
826
+ response.camera_snapshot.length > 0 &&
827
+ isLikelyBlackImage(response.camera_snapshot) === false
828
+ ) {
829
+ imageBuffer = response.camera_snapshot;
830
+ this.#lastSnapshotImage = response.camera_snapshot;
831
+
832
+ // Keep this snapshot image cached for a certain period
833
+ clearTimeout(this.#snapshotTimer);
834
+ this.#snapshotTimer = setTimeout(() => {
835
+ this.#lastSnapshotImage = undefined;
836
+ }, TIMERS.SNAPSHOT);
562
837
  }
563
838
  }
564
839
 
@@ -566,43 +841,42 @@ export default class NestCamera extends HomeKitDevice {
566
841
  this.deviceData.migrating === false &&
567
842
  this.deviceData.streaming_enabled === false &&
568
843
  this.deviceData.online === true &&
569
- this.#cameraVideoOffImage !== undefined
844
+ this.#cameraImages?.off !== undefined
570
845
  ) {
571
846
  // Return 'camera switched off' jpg to image buffer
572
- imageBuffer = this.#cameraVideoOffImage;
847
+ imageBuffer = this.#cameraImages.off;
573
848
  }
574
849
 
575
- if (this.deviceData.migrating === false && this.deviceData.online === false && this.#cameraOfflineImage !== undefined) {
850
+ if (this.deviceData.migrating === false && this.deviceData.online === false && this.#cameraImages?.offline !== undefined) {
576
851
  // Return 'camera offline' jpg to image buffer
577
- imageBuffer = this.#cameraOfflineImage;
852
+ imageBuffer = this.#cameraImages.offline;
578
853
  }
579
854
 
580
- if (this.deviceData.migrating === true && this.#cameraTransferringImage !== undefined) {
855
+ if (this.deviceData.migrating === true && this.#cameraImages?.transfer !== undefined) {
581
856
  // Return 'camera transferring' jpg to image buffer
582
- imageBuffer = this.#cameraTransferringImage;
857
+ imageBuffer = this.#cameraImages.transfer;
583
858
  }
584
859
 
585
860
  if (imageBuffer === undefined) {
586
861
  // If we get here, we have no snapshot image
587
862
  // We'll use the last success snapshop as long as its within a certain time period
588
- imageBuffer = this.lastSnapshotImage;
863
+ imageBuffer = this.#lastSnapshotImage;
589
864
  }
590
865
 
591
866
  callback(imageBuffer?.length === 0 ? 'Unable to obtain Camera/Doorbell snapshot' : null, imageBuffer);
592
867
  }
593
868
 
594
869
  async prepareStream(request, callback) {
595
- const getPort = async (options) => {
870
+ // HomeKit has asked us to prepare ports and encryption details for video/audio streaming
871
+
872
+ const getPort = async () => {
596
873
  return new Promise((resolve, reject) => {
597
- let server = net.createServer();
598
- server.unref();
599
- server.on('error', reject);
600
- server.listen(options, () => {
874
+ let server = dgram.createSocket('udp4');
875
+ server.bind({ port: 0, exclusive: true }, () => {
601
876
  let port = server.address().port;
602
- server.close(() => {
603
- resolve(port); // return port
604
- });
877
+ server.close(() => resolve(port));
605
878
  });
879
+ server.on('error', reject);
606
880
  });
607
881
  };
608
882
 
@@ -618,21 +892,15 @@ export default class NestCamera extends HomeKitDevice {
618
892
  audioPort: request.audio.port,
619
893
  localAudioPort: await getPort(),
620
894
  audioTalkbackPort: await getPort(),
621
- rptSplitterPort: await getPort(),
895
+ rtpSplitterPort: await getPort(),
622
896
  audioCryptoSuite: request.video.srtpCryptoSuite,
623
897
  audioSRTP: Buffer.concat([request.audio.srtp_key, request.audio.srtp_salt]),
624
898
  audioSSRC: this.hap.CameraController.generateSynchronisationSource(),
625
899
 
626
- rtpSplitter: null,
627
- ffmpeg: [], // Array of ffmpeg processes we create for streaming video/audio and audio talkback
628
- video: null,
629
- audio: null,
900
+ rtpSplitter: null, // setup later during stream start
630
901
  };
631
902
 
632
- // Build response back to HomeKit with the details filled out
633
-
634
- // Dropped ip module by using small snippet of code below
635
- // Converts ipv4 mapped into ipv6 address into pure ipv4
903
+ // Converts ipv4-mapped ipv6 into pure ipv4
636
904
  if (request.addressVersion === 'ipv4' && request.sourceAddress.startsWith('::ffff:') === true) {
637
905
  request.sourceAddress = request.sourceAddress.replace('::ffff:', '');
638
906
  }
@@ -646,272 +914,251 @@ export default class NestCamera extends HomeKitDevice {
646
914
  srtp_salt: request.video.srtp_salt,
647
915
  },
648
916
  audio: {
649
- port: sessionInfo.rptSplitterPort,
917
+ port: sessionInfo.rtpSplitterPort,
650
918
  ssrc: sessionInfo.audioSSRC,
651
919
  srtp_key: request.audio.srtp_key,
652
920
  srtp_salt: request.audio.srtp_salt,
653
921
  },
654
922
  };
655
- this.#hkSessions[request.sessionID] = sessionInfo; // Store the session information
923
+
924
+ this.#liveSessions.set(request.sessionID, sessionInfo); // Store the session information
656
925
  callback(undefined, response);
657
926
  }
658
927
 
659
928
  async handleStreamRequest(request, callback) {
660
929
  // called when HomeKit asks to start/stop/reconfigure a camera/doorbell live stream
661
- if (request.type === this.hap.StreamRequestTypes.START && this.streamer === undefined) {
662
- // We have no streamer object configured, so cannot do live streams!!
663
- this?.log?.error?.(
664
- 'Received request to start live video for "%s" however we do not any associated streaming protocol support',
665
- this.deviceData.description,
666
- );
667
- }
668
930
 
669
- if (request.type === this.hap.StreamRequestTypes.START && this.deviceData?.ffmpeg?.binary === undefined) {
670
- // No ffmpeg binary present, so cannot do live streams!!
671
- this?.log?.warn?.(
672
- 'Received request to start live video for "%s" however we do not have an ffmpeg binary present',
673
- this.deviceData.description,
674
- );
675
- }
931
+ if (request.type === this.hap.StreamRequestTypes.START) {
932
+ if (this.streamer === undefined) {
933
+ // We have no streamer object configured, so cannot do live streams!!
934
+ this?.log?.error?.(
935
+ 'Received request to start live video for "%s" however we do not have any associated streaming protocol support',
936
+ this.deviceData.description,
937
+ );
938
+ return callback?.();
939
+ }
940
+
941
+ if (this.ffmpeg instanceof FFmpeg === false) {
942
+ // No valid ffmpeg binary present, so cannot do live streams!!
943
+ this?.log?.warn?.(
944
+ 'Received request to start live video for "%s" however we do not have a valid ffmpeg binary',
945
+ this.deviceData.description,
946
+ );
947
+ return callback?.();
948
+ }
949
+
950
+ let session = this.#liveSessions.get(request.sessionID);
951
+ let includeAudio = this.deviceData.audio_enabled === true && this.streamer?.codecs?.audio !== undefined;
676
952
 
677
- if (
678
- request.type === this.hap.StreamRequestTypes.START &&
679
- this.streamer !== undefined &&
680
- this.deviceData?.ffmpeg?.binary !== undefined
681
- ) {
682
953
  // Build our ffmpeg command string for the liveview video/audio stream
683
954
  let commandLine = [
684
955
  '-hide_banner',
685
956
  '-nostats',
686
- '-use_wallclock_as_timestamps 1',
687
- '-fflags +discardcorrupt',
688
- '-max_delay 500000',
689
- '-flags low_delay',
690
- '-f h264',
691
- '-i pipe:0',
957
+ '-use_wallclock_as_timestamps',
958
+ '1',
959
+ '-fflags',
960
+ '+discardcorrupt',
961
+ '-max_delay',
962
+ '500000',
963
+ '-flags',
964
+ 'low_delay',
965
+
966
+ // Video input
967
+ '-f',
968
+ 'h264',
969
+ '-i',
970
+ 'pipe:0',
971
+
972
+ // Audio input (if enabled)
973
+ ...(includeAudio === true
974
+ ? this.streamer.codecs.audio === Streamer.CODEC_TYPE.PCM
975
+ ? ['-thread_queue_size', '512', '-f', 's16le', '-ar', '48000', '-ac', '2', '-i', 'pipe:3']
976
+ : this.streamer.codecs.audio === Streamer.CODEC_TYPE.AAC
977
+ ? ['-thread_queue_size', '512', '-f', 'aac', '-i', 'pipe:3']
978
+ : []
979
+ : []),
980
+
981
+ // Video output
982
+ '-map',
983
+ '0:v:0',
984
+ '-codec:v',
985
+ 'copy',
986
+ // Below is comment out as we don't use hardware acceleration for live streaming
987
+ // ...(this.deviceData.ffmpeg.hwaccel === true && this.ffmpeg.hardwareH264Codec !== undefined
988
+ // ? ['-codec:v', this.ffmpeg.hardwareH264Codec]
989
+ // : ['-codec:v', 'copy']),
990
+ '-fps_mode',
991
+ 'passthrough',
992
+ '-reset_timestamps',
993
+ '1',
994
+ '-video_track_timescale',
995
+ '90000',
996
+ '-payload_type',
997
+ request.video.pt,
998
+ '-ssrc',
999
+ session.videoSSRC,
1000
+ '-f',
1001
+ 'rtp',
1002
+ '-srtp_out_suite',
1003
+ this.hap.SRTPCryptoSuites[session.videoCryptoSuite],
1004
+ '-srtp_out_params',
1005
+ session.videoSRTP.toString('base64'),
1006
+ 'srtp://' + session.address + ':' + session.videoPort + '?rtcpport=' + session.videoPort + '&pkt_size=' + request.video.mtu,
1007
+
1008
+ // Audio output (if enabled)
1009
+ ...(includeAudio === true
1010
+ ? request.audio.codec === this.hap.AudioStreamingCodecType.AAC_ELD
1011
+ ? ['-map', '1:a:0', '-codec:a', 'libfdk_aac', '-profile:a', 'aac_eld']
1012
+ : request.audio.codec === this.hap.AudioStreamingCodecType.OPUS
1013
+ ? [
1014
+ '-map',
1015
+ '1:a:0',
1016
+ '-codec:a',
1017
+ 'libopus',
1018
+ '-application',
1019
+ 'lowdelay',
1020
+ '-frame_duration',
1021
+ request.audio.packet_time.toString(),
1022
+ ]
1023
+ : []
1024
+ : []),
1025
+
1026
+ // Shared audio output params
1027
+ ...(includeAudio === true
1028
+ ? [
1029
+ '-flags',
1030
+ '+global_header',
1031
+ '-ar',
1032
+ request.audio.sample_rate.toString() + 'k',
1033
+ '-b:a',
1034
+ request.audio.max_bit_rate + 'k',
1035
+ '-ac',
1036
+ request.audio.channel.toString(),
1037
+ '-payload_type',
1038
+ request.audio.pt,
1039
+ '-ssrc',
1040
+ session.audioSSRC,
1041
+ '-f',
1042
+ 'rtp',
1043
+ '-srtp_out_suite',
1044
+ this.hap.SRTPCryptoSuites[session.audioCryptoSuite],
1045
+ '-srtp_out_params',
1046
+ session.audioSRTP.toString('base64'),
1047
+ 'srtp://' +
1048
+ session.address +
1049
+ ':' +
1050
+ session.audioPort +
1051
+ '?rtcpport=' +
1052
+ session.audioPort +
1053
+ '&localrtcpport=' +
1054
+ session.localAudioPort +
1055
+ '&pkt_size=188',
1056
+ ]
1057
+ : []),
692
1058
  ];
693
1059
 
694
- let includeAudio = false;
695
- if (
696
- this.deviceData.audio_enabled === true &&
697
- this.streamer?.codecs?.audio === 'aac' &&
698
- this.deviceData?.ffmpeg?.libfdk_aac === true
699
- ) {
700
- // Audio data only on extra pipe created in spawn command
701
- commandLine.push('-f aac -i pipe:3');
702
- includeAudio = true;
703
- }
704
-
705
- if (this.deviceData.audio_enabled === true && this.streamer?.codecs?.audio === 'opus' && this.deviceData?.ffmpeg?.libopus === true) {
706
- // Audio data only on extra pipe created in spawn command
707
- commandLine.push('-i pipe:3');
708
- includeAudio = true;
709
- }
710
-
711
- // Build our video command for ffmpeg
712
- commandLine.push(
713
- '-map 0:v:0',
714
- '-codec:v copy',
715
- '-fps_mode passthrough',
716
- '-reset_timestamps 1',
717
- '-video_track_timescale 90000',
718
- '-payload_type ' + request.video.pt,
719
- '-ssrc ' + this.#hkSessions[request.sessionID].videoSSRC,
720
- '-f rtp',
721
- '-srtp_out_suite ' + this.hap.SRTPCryptoSuites[this.#hkSessions[request.sessionID].videoCryptoSuite],
722
- '-srtp_out_params ' +
723
- this.#hkSessions[request.sessionID].videoSRTP.toString('base64') +
724
- ' srtp://' +
725
- this.#hkSessions[request.sessionID].address +
726
- ':' +
727
- this.#hkSessions[request.sessionID].videoPort +
728
- '?rtcpport=' +
729
- this.#hkSessions[request.sessionID].videoPort +
730
- '&pkt_size=' +
731
- request.video.mtu,
732
- );
733
-
734
- // We have seperate video and audio streams that need to be muxed together if audio enabled
735
- if (includeAudio === true) {
736
- if (request.audio.codec === this.hap.AudioStreamingCodecType.AAC_ELD) {
737
- commandLine.push('-map 1:a:0', '-codec:a libfdk_aac', '-profile:a aac_eld');
738
- }
739
-
740
- if (request.audio.codec === this.hap.AudioStreamingCodecType.OPUS) {
741
- commandLine.push(
742
- '-map 1:a:0',
743
- '-codec:a libopus',
744
- '-application lowdelay',
745
- '-frame_duration ' + request.audio.packet_time.toString(),
746
- );
747
- }
748
-
749
- commandLine.push(
750
- '-flags +global_header',
751
- '-ar ' + request.audio.sample_rate + 'k',
752
- '-b:a ' + request.audio.max_bit_rate + 'k',
753
- '-ac ' + request.audio.channel,
754
- '-payload_type ' + request.audio.pt,
755
- '-ssrc ' + this.#hkSessions[request.sessionID].audioSSRC,
756
- '-f rtp',
757
- '-srtp_out_suite ' + this.hap.SRTPCryptoSuites[this.#hkSessions[request.sessionID].audioCryptoSuite],
758
- '-srtp_out_params ' +
759
- this.#hkSessions[request.sessionID].audioSRTP.toString('base64') +
760
- ' srtp://' +
761
- this.#hkSessions[request.sessionID].address +
762
- ':' +
763
- this.#hkSessions[request.sessionID].audioPort +
764
- '?rtcpport=' +
765
- this.#hkSessions[request.sessionID].audioPort +
766
- '&localrtcpport=' +
767
- this.#hkSessions[request.sessionID].localAudioPort +
768
- '&pkt_size=188',
769
- );
770
- }
771
-
772
- // Start our ffmpeg streaming process and stream from our streamer
773
- // video is pipe #1
774
- // audio is pipe #3 if including audio
775
1060
  this?.log?.debug?.(
776
1061
  'ffmpeg process for live streaming from "%s" will be called using the following commandline',
777
1062
  this.deviceData.description,
778
1063
  commandLine.join(' ').toString(),
779
1064
  );
780
- let ffmpegStreaming = child_process.spawn(this.deviceData.ffmpeg.binary, commandLine.join(' ').split(' '), {
781
- env: process.env,
782
- stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
783
- });
784
-
785
- ffmpegStreaming.on('exit', (code, signal) => {
786
- if (signal !== 'SIGKILL' || signal === null) {
787
- this?.log?.error?.(
788
- 'ffmpeg video/audio live streaming process for "%s" stopped unexpectedly. Exit code was "%s"',
789
- this.deviceData.description,
790
- code,
791
- );
792
-
793
- // Clean up or streaming request, but calling it again with a 'STOP' reques
794
- this.handleStreamRequest({ type: this.hap.StreamRequestTypes.STOP, sessionID: request.sessionID }, null);
795
- }
796
- });
797
1065
 
798
- // ffmpeg console output is via stderr
799
- ffmpegStreaming.stderr.on('data', (data) => {
800
- if (data.toString().includes('frame=') === false && this.deviceData?.ffmpeg?.debug === true) {
801
- // Monitor ffmpeg output
802
- this?.log?.debug?.(data.toString());
803
- }
804
- });
805
-
806
- // eslint-disable-next-line no-unused-vars
807
- ffmpegStreaming.on('error', (error) => {
808
- // Empty
809
- });
1066
+ // Launch the ffmpeg process for streaming and connect it to streamer input/output
1067
+ let ffmpegStream = this.ffmpeg.createSession(
1068
+ this.uuid,
1069
+ request.sessionID,
1070
+ commandLine,
1071
+ 'live',
1072
+ (data) => {
1073
+ if (data.toString().includes('frame=') === false && this.deviceData.ffmpeg.debug === true) {
1074
+ this?.log?.debug?.(data.toString());
1075
+ }
1076
+ },
1077
+ 4, // 4 pipes required
1078
+ );
810
1079
 
811
- // We only enable two/way audio on camera/doorbell if we have the required libraries in ffmpeg AND two-way/audio is enabled
812
- let ffmpegAudioTalkback = null; // No ffmpeg process for return audio yet
1080
+ // Two-way audio support if enabled and codecs available
1081
+ let ffmpegTalk = null;
813
1082
  if (
814
- ((this.streamer.codecs.talk === 'speex' && this.deviceData?.ffmpeg?.libspeex === true) ||
815
- (this.streamer.codecs.talk === 'opus' && this.deviceData?.ffmpeg?.libopus === true)) &&
816
- this.deviceData?.ffmpeg?.libfdk_aac === true &&
1083
+ ((this.streamer?.codecs?.talkback === Streamer.CODEC_TYPE.SPEEX &&
1084
+ this.ffmpeg?.features?.encoders?.includes('libspeex') === true) ||
1085
+ (this.streamer?.codecs?.talkback === Streamer.CODEC_TYPE.OPUS &&
1086
+ this.ffmpeg?.features?.encoders?.includes('libopus') === true)) &&
1087
+ this.ffmpeg?.features?.encoders?.includes('libfdk_aac') === true &&
817
1088
  this.deviceData.audio_enabled === true &&
818
1089
  this.deviceData.has_speaker === true &&
819
1090
  this.deviceData.has_microphone === true
820
1091
  ) {
821
- // Setup RTP splitter for two/away audio
822
- this.#hkSessions[request.sessionID].rtpSplitter = dgram.createSocket('udp4');
823
- this.#hkSessions[request.sessionID].rtpSplitter.bind(this.#hkSessions[request.sessionID].rptSplitterPort);
824
-
825
- this.#hkSessions[request.sessionID].rtpSplitter.on('error', () => {
826
- this.#hkSessions[request.sessionID].rtpSplitter.close();
827
- });
828
-
829
- this.#hkSessions[request.sessionID].rtpSplitter.on('message', (message) => {
830
- let payloadType = message.readUInt8(1) & 0x7f;
831
- if (payloadType === request.audio.pt) {
832
- // Audio payload type from HomeKit should match our payload type for audio
833
- if (message.length > 50) {
834
- // Only send on audio data if we have a longer audio packet.
835
- // (not sure it makes any difference, as under iOS 15 packets are roughly same length)
836
- this.#hkSessions[request.sessionID].rtpSplitter.send(message, this.#hkSessions[request.sessionID].audioTalkbackPort);
837
- }
1092
+ // Setup RTP splitter for two-way audio
1093
+ session.rtpSplitter = dgram.createSocket('udp4');
1094
+ session.rtpSplitter.bind(session.rtpSplitterPort);
1095
+ session.rtpSplitter.on('error', () => session.rtpSplitter.close());
1096
+ session.rtpSplitter.on('message', (message) => {
1097
+ let pt = message.readUInt8(1) & 0x7f;
1098
+ if (pt === request.audio.pt && message.length > 50) {
1099
+ session.rtpSplitter.send(message, session.audioTalkbackPort);
838
1100
  } else {
839
- this.#hkSessions[request.sessionID].rtpSplitter.send(message, this.#hkSessions[request.sessionID].localAudioPort);
840
- // Send RTCP to return audio as a heartbeat
841
- this.#hkSessions[request.sessionID].rtpSplitter.send(message, this.#hkSessions[request.sessionID].audioTalkbackPort);
1101
+ session.rtpSplitter.send(message, session.localAudioPort);
1102
+ session.rtpSplitter.send(message, session.audioTalkbackPort); // RTCP keepalive
842
1103
  }
843
1104
  });
844
1105
 
845
- // Build ffmpeg command
846
- let commandLine = [
847
- '-hide_banner -nostats',
848
- '-protocol_whitelist pipe,udp,rtp',
849
- '-f sdp',
850
- '-codec:a libfdk_aac',
851
- '-i pipe:0',
852
- '-map 0:a',
1106
+ let talkbackCommandLine = [
1107
+ '-hide_banner',
1108
+ '-nostats',
1109
+ '-protocol_whitelist',
1110
+ 'pipe,udp,rtp',
1111
+ '-f',
1112
+ 'sdp',
1113
+ '-codec:a',
1114
+ 'libfdk_aac',
1115
+ '-i',
1116
+ 'pipe:0',
1117
+ '-map',
1118
+ '0:a',
1119
+ ...(this.streamer?.codecs?.talkback === Streamer.CODEC_TYPE.SPEEX
1120
+ ? ['-codec:a', 'libspeex', '-frames_per_packet', '4', '-vad', '1', '-ac', '1', '-ar', '16k']
1121
+ : []),
1122
+ ...(this.streamer?.codecs?.talkback === Streamer.CODEC_TYPE.OPUS
1123
+ ? ['-codec:a', 'libopus', '-application', 'lowdelay', '-ac', '2', '-ar', '48k']
1124
+ : []),
1125
+ '-f',
1126
+ 'data',
1127
+ 'pipe:1',
853
1128
  ];
854
1129
 
855
- if (this.streamer.codecs.talk === 'speex') {
856
- commandLine.push('-codec:a libspeex', '-frames_per_packet 4', '-vad 1', '-ac 1', '-ar 16k');
857
- }
858
-
859
- if (this.streamer.codecs.talk === 'opus') {
860
- commandLine.push('-codec:a libopus', '-application lowdelay', '-ac 2', '-ar 48k');
861
- }
862
-
863
- commandLine.push('-f data pipe:1');
864
-
865
1130
  this?.log?.debug?.(
866
1131
  'ffmpeg process for talkback on "%s" will be called using the following commandline',
867
1132
  this.deviceData.description,
868
- commandLine.join(' ').toString(),
1133
+ talkbackCommandLine.join(' '),
869
1134
  );
870
- ffmpegAudioTalkback = child_process.spawn(this.deviceData.ffmpeg.binary, commandLine.join(' ').split(' '), {
871
- env: process.env,
872
- });
873
1135
 
874
- ffmpegAudioTalkback.on('exit', (code, signal) => {
875
- if (signal !== 'SIGKILL' || signal === null) {
876
- this?.log?.error?.(
877
- 'ffmpeg audio talkback streaming process for "%s" stopped unexpectedly. Exit code was "%s"',
878
- this.deviceData.description,
879
- code,
880
- );
881
-
882
- // Clean up or streaming request, but calling it again with a 'STOP' request
883
- this.handleStreamRequest({ type: this.hap.StreamRequestTypes.STOP, sessionID: request.sessionID }, null);
884
- }
885
- });
886
-
887
- // eslint-disable-next-line no-unused-vars
888
- ffmpegAudioTalkback.on('error', (error) => {
889
- // Empty
890
- });
891
-
892
- // ffmpeg console output is via stderr
893
- ffmpegAudioTalkback.stderr.on('data', (data) => {
894
- if (data.toString().includes('frame=') === false && this.deviceData?.ffmpeg?.debug === true) {
895
- // Monitor ffmpeg output
896
- this?.log?.debug?.(data.toString());
897
- }
898
- });
1136
+ ffmpegTalk = this.ffmpeg.createSession(
1137
+ this.uuid,
1138
+ request.sessionID,
1139
+ talkbackCommandLine,
1140
+ 'talk',
1141
+ (data) => {
1142
+ if (data.toString().includes('frame=') === false && this.deviceData.ffmpeg.debug === true) {
1143
+ this?.log?.debug?.(data.toString());
1144
+ }
1145
+ },
1146
+ 3, // 3 pipes required
1147
+ );
899
1148
 
900
- // Write out SDP configuration
901
- // Tried to align the SDP configuration to what HomeKit has sent us in its audio request details
902
- let sdpResponse = [
1149
+ let sdp = [
903
1150
  'v=0',
904
- 'o=- 0 0 IN ' + (this.#hkSessions[request.sessionID].ipv6 ? 'IP6' : 'IP4') + ' ' + this.#hkSessions[request.sessionID].address,
1151
+ 'o=- 0 0 IN ' + (session.ipv6 ? 'IP6' : 'IP4') + ' ' + session.address,
905
1152
  's=HomeKit Audio Talkback',
906
- 'c=IN ' + (this.#hkSessions[request.sessionID].ipv6 ? 'IP6' : 'IP4') + ' ' + this.#hkSessions[request.sessionID].address,
1153
+ 'c=IN ' + (session.ipv6 ? 'IP6' : 'IP4') + ' ' + session.address,
907
1154
  't=0 0',
908
- 'm=audio ' + this.#hkSessions[request.sessionID].audioTalkbackPort + ' RTP/AVP ' + request.audio.pt,
1155
+ 'm=audio ' + session.audioTalkbackPort + ' RTP/AVP ' + request.audio.pt,
909
1156
  'b=AS:' + request.audio.max_bit_rate,
910
1157
  'a=ptime:' + request.audio.packet_time,
911
1158
  ];
912
1159
 
913
1160
  if (request.audio.codec === this.hap.AudioStreamingCodecType.AAC_ELD) {
914
- sdpResponse.push(
1161
+ sdp.push(
915
1162
  'a=rtpmap:' + request.audio.pt + ' MPEG4-GENERIC/' + request.audio.sample_rate * 1000 + '/' + request.audio.channel,
916
1163
  'a=fmtp:' +
917
1164
  request.audio.pt +
@@ -920,309 +1167,46 @@ export default class NestCamera extends HomeKitDevice {
920
1167
  }
921
1168
 
922
1169
  if (request.audio.codec === this.hap.AudioStreamingCodecType.OPUS) {
923
- sdpResponse.push(
1170
+ sdp.push(
924
1171
  'a=rtpmap:' + request.audio.pt + ' opus/' + request.audio.sample_rate * 1000 + '/' + request.audio.channel,
925
- 'a=fmtp:' + request.audio.pt + ' minptime=10;useinbandfec=1"',
1172
+ 'a=fmtp:' + request.audio.pt + ' minptime=10;useinbandfec=1',
926
1173
  );
927
1174
  }
928
1175
 
929
- sdpResponse.push(
930
- 'a=crypto:1 ' +
931
- this.hap.SRTPCryptoSuites[this.#hkSessions[request.sessionID].audioCryptoSuite] +
932
- ' inline:' +
933
- this.#hkSessions[request.sessionID].audioSRTP.toString('base64'),
934
- );
935
-
936
- ffmpegAudioTalkback.stdin.write(sdpResponse.join('\r\n'));
937
- ffmpegAudioTalkback.stdin.end();
1176
+ sdp.push('a=crypto:1 ' + this.hap.SRTPCryptoSuites[session.audioCryptoSuite] + ' inline:' + session.audioSRTP.toString('base64'));
1177
+ ffmpegTalk?.stdin?.write?.(sdp.join('\r\n'));
1178
+ ffmpegTalk?.stdin?.end?.();
938
1179
  }
939
1180
 
940
- this?.log?.info?.(
941
- 'Live stream started on "%s" %s',
942
- this.deviceData.description,
943
- ffmpegAudioTalkback?.stdout ? 'with two-way audio' : '',
944
- );
945
-
946
- // Start the appropriate streamer
947
- this.streamer !== undefined &&
948
- this.streamer.startLiveStream(
949
- request.sessionID,
950
- ffmpegStreaming.stdin,
951
- ffmpegStreaming?.stdio?.[3] ? ffmpegStreaming.stdio[3] : null,
952
- ffmpegAudioTalkback?.stdout ? ffmpegAudioTalkback.stdout : null,
953
- );
954
-
955
- // Store our ffmpeg sessions
956
- ffmpegStreaming && this.#hkSessions[request.sessionID].ffmpeg.push(ffmpegStreaming); // Store ffmpeg process ID
957
- ffmpegAudioTalkback && this.#hkSessions[request.sessionID].ffmpeg.push(ffmpegAudioTalkback); // Store ffmpeg audio return process ID
958
- this.#hkSessions[request.sessionID].video = request.video; // Cache the video request details
959
- this.#hkSessions[request.sessionID].audio = request.audio; // Cache the audio request details
1181
+ // Start the actual streamer process
1182
+ this?.log?.info?.('Live stream started on "%s"%s', this.deviceData.description, ffmpegTalk ? ' (two-way audio enabled)' : '');
1183
+ let { video, audio, talkback } = await this.message(Streamer.MESSAGE, Streamer.MESSAGE_TYPE.START_LIVE, {
1184
+ sessionID: request.sessionID,
1185
+ });
1186
+ // Connect the ffmpeg process to the streamer input/output
1187
+ video?.pipe?.(ffmpegStream?.stdin); // Streamer video ffmpeg stdin (pipe:0)
1188
+ audio?.pipe?.(ffmpegStream?.stdio?.[3]); // Streamer audio → ffmpeg pipe:3
1189
+ ffmpegTalk?.stdout?.pipe?.(talkback); // ffmpeg talkback stdout → Streamer talkback pipe:1
960
1190
  }
961
1191
 
962
- if (request.type === this.hap.StreamRequestTypes.STOP && typeof this.#hkSessions[request.sessionID] === 'object') {
963
- this.streamer !== undefined && this.streamer.stopLiveStream(request.sessionID);
964
-
965
- // Close HomeKit session
966
- this.controller.forceStopStreamingSession(request.sessionID);
967
-
968
- // Close off any running ffmpeg and/or splitter processes we created
969
- if (typeof this.#hkSessions[request.sessionID]?.rtpSplitter?.close === 'function') {
970
- this.#hkSessions[request.sessionID].rtpSplitter.close();
971
- }
972
- this.#hkSessions[request.sessionID].ffmpeg.forEach((ffmpeg) => {
973
- ffmpeg.kill('SIGKILL');
1192
+ if (request.type === this.hap.StreamRequestTypes.STOP && this.#liveSessions.has(request.sessionID)) {
1193
+ // Stop the HomeKit stream and cleanup any associated ffmpeg or RTP splitter sessions
1194
+ await this.message(Streamer.MESSAGE, Streamer.MESSAGE_TYPE.STOP_LIVE, {
1195
+ sessionID: request.sessionID,
974
1196
  });
975
-
976
- delete this.#hkSessions[request.sessionID];
977
-
1197
+ this.controller.forceStopStreamingSession(request.sessionID);
1198
+ this.#liveSessions.get(request.sessionID)?.rtpSplitter?.close?.();
1199
+ this.ffmpeg?.killSession?.(this.uuid, request.sessionID, 'live', 'SIGKILL');
1200
+ this.ffmpeg?.killSession?.(this.uuid, request.sessionID, 'talk', 'SIGKILL');
1201
+ this.#liveSessions.delete(request.sessionID);
978
1202
  this?.log?.info?.('Live stream stopped from "%s"', this.deviceData.description);
979
1203
  }
980
1204
 
981
- if (request.type === this.hap.StreamRequestTypes.RECONFIGURE && typeof this.#hkSessions[request.sessionID] === 'object') {
1205
+ if (request.type === this.hap.StreamRequestTypes.RECONFIGURE && this.#liveSessions.has(request.sessionID)) {
982
1206
  this?.log?.debug?.('Unsupported reconfiguration request for live stream on "%s"', this.deviceData.description);
983
1207
  }
984
1208
 
985
- if (typeof callback === 'function') {
986
- callback(); // do callback if defined
987
- }
988
- }
989
-
990
- updateDevice(deviceData) {
991
- if (typeof deviceData !== 'object' || this.controller === undefined) {
992
- return;
993
- }
994
-
995
- if (this.deviceData.migrating === false && deviceData.migrating === true) {
996
- // Migration happening between Nest <-> Google Home apps. We'll stop any active streams, close the current streaming object
997
- this?.log?.warn?.('Migration between Nest <-> Google Home apps has started for "%s"', deviceData.description);
998
- this.streamer !== undefined && this.streamer.stopEverything();
999
- this.streamer = undefined;
1000
- }
1001
-
1002
- if (this.deviceData.migrating === true && deviceData.migrating === false) {
1003
- // Migration has completed between Nest <-> Google Home apps
1004
- this?.log?.success?.('Migration between Nest <-> Google Home apps has completed for "%s"', deviceData.description);
1005
- }
1006
-
1007
- // Handle case of changes in streaming protocols OR just finished migration between Nest <-> Google Home apps
1008
- if (this.streamer === undefined && deviceData.migrating === false) {
1009
- if (JSON.stringify(deviceData.streaming_protocols) !== JSON.stringify(this.deviceData.streaming_protocols)) {
1010
- this?.log?.warn?.('Available streaming protocols have changed for "%s"', deviceData.description);
1011
- this.streamer !== undefined && this.streamer.stopEverything();
1012
- this.streamer = undefined;
1013
- }
1014
- if (deviceData.streaming_protocols.includes(STREAMING_PROTOCOL.WEBRTC) === true && WebRTC !== undefined) {
1015
- this?.log?.debug?.('Using WebRTC streamer for "%s"', deviceData.description);
1016
- this.streamer = new WebRTC(deviceData, {
1017
- log: this.log,
1018
- buffer:
1019
- deviceData.hksv === true &&
1020
- this?.controller?.recordingManagement?.recordingManagementService !== undefined &&
1021
- this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.Active).value ===
1022
- this.hap.Characteristic.Active.ACTIVE,
1023
- });
1024
- }
1025
-
1026
- if (deviceData.streaming_protocols.includes(STREAMING_PROTOCOL.NEXUSTALK) === true && NexusTalk !== undefined) {
1027
- this?.log?.debug?.('Using NexusTalk streamer for "%s"', deviceData.description);
1028
- this.streamer = new NexusTalk(deviceData, {
1029
- log: this.log,
1030
- buffer:
1031
- deviceData.hksv === true &&
1032
- this?.controller?.recordingManagement?.recordingManagementService !== undefined &&
1033
- this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.Active).value ===
1034
- this.hap.Characteristic.Active.ACTIVE,
1035
- });
1036
- }
1037
- }
1038
-
1039
- // Check to see if any activity zones were added for both non-HKSV and HKSV enabled devices
1040
- if (
1041
- Array.isArray(deviceData.activity_zones) === true &&
1042
- JSON.stringify(deviceData.activity_zones) !== JSON.stringify(this.deviceData.activity_zones)
1043
- ) {
1044
- deviceData.activity_zones.forEach((zone) => {
1045
- if (this.deviceData.hksv === false || (this.deviceData.hksv === true && zone.id === 1)) {
1046
- if (this.motionServices?.[zone.id]?.service === undefined) {
1047
- // Zone doesn't have an associated motion sensor, so add one
1048
- let zoneName = zone.id === 1 ? '' : zone.name;
1049
- let tempService = this.addHKService(this.hap.Service.MotionSensor, zoneName, zone.id);
1050
-
1051
- this.addHKCharacteristic(tempService, this.hap.Characteristic.Active);
1052
- tempService.updateCharacteristic(this.hap.Characteristic.Name, zoneName);
1053
- tempService.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); // No motion initially
1054
-
1055
- this.motionServices[zone.id] = { service: tempService, timer: undefined };
1056
- }
1057
- }
1058
- });
1059
- }
1060
-
1061
- // Check to see if any activity zones were removed for both non-HKSV and HKSV enabled devices
1062
- // We'll also update the online status of the camera in the motion service here
1063
- Object.entries(this.motionServices).forEach(([zoneID, service]) => {
1064
- // Set online status
1065
- service.service.updateCharacteristic(
1066
- this.hap.Characteristic.Active,
1067
- deviceData.online === true ? this.hap.Characteristic.Active.ACTIVE : this.hap.Characteristic.Active.INACTIVE,
1068
- );
1069
-
1070
- // Handle deleted zones (excluding zone ID 1 for HKSV)
1071
- if (
1072
- zoneID !== '1' &&
1073
- Array.isArray(deviceData.activity_zones) === true &&
1074
- deviceData.activity_zones.findIndex(({ id }) => id === Number(zoneID)) === -1
1075
- ) {
1076
- // Motion service we created doesn't appear in zone list anymore, so assume deleted
1077
- this.accessory.removeService(service.service);
1078
- delete this.motionServices[zoneID];
1079
- }
1080
- });
1081
-
1082
- if (this.operatingModeService !== undefined) {
1083
- // Update camera off/on status
1084
- this.operatingModeService.updateCharacteristic(
1085
- this.hap.Characteristic.ManuallyDisabled,
1086
- deviceData.streaming_enabled === false
1087
- ? this.hap.Characteristic.ManuallyDisabled.DISABLED
1088
- : this.hap.Characteristic.ManuallyDisabled.ENABLED,
1089
- );
1090
-
1091
- if (deviceData?.has_statusled === true) {
1092
- // Set camera recording indicator. This cannot be turned off on Nest Cameras/Doorbells
1093
- // 0 = auto
1094
- // 1 = low
1095
- // 2 = high
1096
- this.operatingModeService.updateCharacteristic(
1097
- this.hap.Characteristic.CameraOperatingModeIndicator,
1098
- deviceData.statusled_brightness !== 1,
1099
- );
1100
- }
1101
-
1102
- if (deviceData?.has_irled === true) {
1103
- // Set nightvision status in HomeKit
1104
- this.operatingModeService.updateCharacteristic(this.hap.Characteristic.NightVision, deviceData.irled_enabled);
1105
- }
1106
-
1107
- if (deviceData?.has_video_flip === true) {
1108
- // Update image flip status
1109
- this.operatingModeService.updateCharacteristic(this.hap.Characteristic.ImageRotation, deviceData.video_flipped === true ? 180 : 0);
1110
- }
1111
- }
1112
-
1113
- if (deviceData.hksv === true && this.controller?.recordingManagement?.recordingManagementService !== undefined) {
1114
- // Update recording audio status
1115
- this.controller.recordingManagement.recordingManagementService.updateCharacteristic(
1116
- this.hap.Characteristic.RecordingAudioActive,
1117
- deviceData.audio_enabled === true
1118
- ? this.hap.Characteristic.RecordingAudioActive.ENABLE
1119
- : this.hap.Characteristic.RecordingAudioActive.DISABLE,
1120
- );
1121
- }
1122
-
1123
- if (this.controller?.microphoneService !== undefined) {
1124
- // Update microphone volume if specified
1125
- //this.controller.microphoneService.updateCharacteristic(this.hap.Characteristic.Volume, deviceData.xxx);
1126
-
1127
- // if audio is disabled, we'll mute microphone
1128
- this.controller.setMicrophoneMuted(deviceData.audio_enabled === false ? true : false);
1129
- }
1130
- if (this.controller?.speakerService !== undefined) {
1131
- // Update speaker volume if specified
1132
- //this.controller.speakerService.updateCharacteristic(this.hap.Characteristic.Volume, deviceData.xxx);
1133
-
1134
- // if audio is disabled, we'll mute speaker
1135
- this.controller.setSpeakerMuted(deviceData.audio_enabled === false ? true : false);
1136
- }
1137
-
1138
- // Notify our associated streamers about any data changes
1139
- this.streamer !== undefined && this.streamer.update(deviceData);
1140
-
1141
- // Process alerts, the most recent alert is first
1142
- // For HKSV, we're interested motion events
1143
- // For non-HKSV, we're interested motion, face and person events (maybe sound and package later)
1144
- deviceData.alerts.forEach((event) => {
1145
- if (
1146
- this.operatingModeService === undefined ||
1147
- (this.operatingModeService !== undefined &&
1148
- this.operatingModeService.getCharacteristic(this.hap.Characteristic.HomeKitCameraActive).value ===
1149
- this.hap.Characteristic.HomeKitCameraActive.ON)
1150
- ) {
1151
- // We're configured to handle camera events
1152
- // https://github.com/Supereg/secure-video-specification?tab=readme-ov-file#33-homekitcameraactive
1153
-
1154
- // Handle motion event
1155
- // For a HKSV enabled camera, we will use this to trigger the starting of the HKSV recording if the camera is active
1156
- if (event.types.includes('motion') === true) {
1157
- if (this.motionTimer === undefined && (this.deviceData.hksv === false || this.streamer === undefined)) {
1158
- this?.log?.info?.('Motion detected at "%s"', deviceData.description);
1159
- }
1160
-
1161
- event.zone_ids.forEach((zoneID) => {
1162
- if (
1163
- typeof this.motionServices?.[zoneID]?.service === 'object' &&
1164
- this.motionServices[zoneID].service.getCharacteristic(this.hap.Characteristic.MotionDetected).value !== true
1165
- ) {
1166
- // Trigger motion for matching zone of not aleady active
1167
- this.motionServices[zoneID].service.updateCharacteristic(this.hap.Characteristic.MotionDetected, true);
1168
-
1169
- // Log motion started into history
1170
- if (typeof this.historyService?.addHistory === 'function') {
1171
- this.historyService.addHistory(this.motionServices[zoneID].service, {
1172
- time: Math.floor(Date.now() / 1000),
1173
- status: 1,
1174
- });
1175
- }
1176
- }
1177
- });
1178
-
1179
- // Clear any motion active timer so we can extend if more motion detected
1180
- clearTimeout(this.motionTimer);
1181
- this.motionTimer = setTimeout(() => {
1182
- event.zone_ids.forEach((zoneID) => {
1183
- if (typeof this.motionServices?.[zoneID]?.service === 'object') {
1184
- // Mark associted motion services as motion not detected
1185
- this.motionServices[zoneID].service.updateCharacteristic(this.hap.Characteristic.MotionDetected, false);
1186
-
1187
- // Log motion started into history
1188
- if (typeof this.historyService?.addHistory === 'function') {
1189
- this.historyService.addHistory(this.motionServices[zoneID].service, {
1190
- time: Math.floor(Date.now() / 1000),
1191
- status: 0,
1192
- });
1193
- }
1194
- }
1195
- });
1196
-
1197
- this.motionTimer = undefined; // No motion timer active
1198
- }, this.deviceData.motionCooldown * 1000);
1199
- }
1200
-
1201
- // Handle person/face event
1202
- // We also treat a 'face' event the same as a person event ie: if you have a face, you have a person
1203
- if (event.types.includes('person') === true || event.types.includes('face') === true) {
1204
- if (this.personTimer === undefined) {
1205
- // We don't have a person cooldown timer running, so we can process the 'person'/'face' event
1206
- if (this.deviceData.hksv === false || this.streamer === undefined) {
1207
- // We'll only log a person detected event if HKSV is disabled
1208
- this?.log?.info?.('Person detected at "%s"', deviceData.description);
1209
- }
1210
-
1211
- // Cooldown for person being detected
1212
- // Start this before we process further
1213
- this.personTimer = setTimeout(() => {
1214
- this.personTimer = undefined; // No person timer active
1215
- }, this.deviceData.personCooldown * 1000);
1216
-
1217
- if (event.types.includes('motion') === false) {
1218
- // If person/face events doesn't include a motion event, add in here
1219
- // This will handle all the motion triggering stuff
1220
- event.types.push('motion');
1221
- }
1222
- }
1223
- }
1224
- }
1225
- });
1209
+ callback?.(); // do callback if defined
1226
1210
  }
1227
1211
 
1228
1212
  createCameraMotionServices() {
@@ -1241,12 +1225,14 @@ export default class NestCamera extends HomeKitDevice {
1241
1225
  // If we have HKSV video enabled, we'll only create a single motion sensor
1242
1226
  // A zone with the ID of 1 is treated as the main motion sensor
1243
1227
  for (let zone of zones) {
1244
- if (this.deviceData.hksv === true && zone.id !== 1) {
1228
+ if (this.deviceData.hksv === true && this.ffmpeg instanceof FFmpeg === true && zone.id !== 1) {
1245
1229
  continue;
1246
1230
  }
1247
1231
 
1248
1232
  let zoneName = zone.id === 1 ? '' : zone.name;
1249
- let service = this.addHKService(this.hap.Service.MotionSensor, zoneName, zone.id);
1233
+ let eveOptions = zone.id === 1 ? {} : undefined; // Only link EveHome for zone 1
1234
+
1235
+ let service = this.addHKService(this.hap.Service.MotionSensor, zoneName, zone.id, eveOptions);
1250
1236
  this.addHKCharacteristic(service, this.hap.Characteristic.Active);
1251
1237
  service.updateCharacteristic(this.hap.Characteristic.Name, zoneName);
1252
1238
  service.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); // No motion initially
@@ -1258,92 +1244,335 @@ export default class NestCamera extends HomeKitDevice {
1258
1244
 
1259
1245
  generateControllerOptions() {
1260
1246
  // Setup HomeKit controller camera/doorbell options
1247
+
1248
+ let resolutions = [
1249
+ [3840, 2160, 30], // 4K
1250
+ [1920, 1080, 30], // 1080p
1251
+ [1600, 1200, 30], // Native res of Nest Hello
1252
+ [1280, 960, 30],
1253
+ [1280, 720, 30], // 720p
1254
+ [1024, 768, 30],
1255
+ [640, 480, 30],
1256
+ [640, 360, 30],
1257
+ [480, 360, 30],
1258
+ [480, 270, 30],
1259
+ [320, 240, 30],
1260
+ [320, 240, 15], // Apple Watch requires this (plus OPUS @16K)
1261
+ [320, 180, 30],
1262
+ [320, 180, 15],
1263
+ ];
1264
+
1265
+ let profiles = [this.hap.H264Profile.MAIN];
1266
+ let levels = [this.hap.H264Level.LEVEL3_1, this.hap.H264Level.LEVEL3_2, this.hap.H264Level.LEVEL4_0];
1267
+ let videoType = this.hap.VideoCodecType.H264;
1268
+
1261
1269
  let controllerOptions = {
1262
1270
  cameraStreamCount: this.deviceData.maxStreams,
1263
1271
  delegate: this,
1264
- streamingOptions: {
1265
- supportedCryptoSuites: [this.hap.SRTPCryptoSuites.NONE, this.hap.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80],
1266
- video: {
1267
- resolutions: [
1268
- // width, height, framerate
1269
- // <--- Need to auto generate this list
1270
- [3840, 2160, 30], // 4K
1271
- [1920, 1080, 30], // 1080p
1272
- [1600, 1200, 30], // Native res of Nest Hello
1273
- [1280, 960, 30],
1274
- [1280, 720, 30], // 720p
1275
- [1024, 768, 30],
1276
- [640, 480, 30],
1277
- [640, 360, 30],
1278
- [480, 360, 30],
1279
- [480, 270, 30],
1280
- [320, 240, 30],
1281
- [320, 240, 15], // Apple Watch requires this configuration (Apple Watch also seems to required OPUS @16K)
1282
- [320, 180, 30],
1283
- [320, 180, 15],
1284
- ],
1285
- codec: {
1286
- type: this.hap.VideoCodecType.H264,
1287
- profiles: [this.hap.H264Profile.MAIN],
1288
- levels: [this.hap.H264Level.LEVEL3_1, this.hap.H264Level.LEVEL3_2, this.hap.H264Level.LEVEL4_0],
1289
- },
1290
- },
1291
- audio: {
1292
- twoWayAudio:
1293
- this.deviceData?.ffmpeg?.libfdk_aac === true &&
1294
- (this.deviceData?.ffmpeg?.libspeex === true || this.deviceData?.ffmpeg?.libopus === true) &&
1295
- this.deviceData.has_speaker === true &&
1296
- this.deviceData.has_microphone === true,
1297
- codecs: [
1298
- {
1299
- type: this.hap.AudioStreamingCodecType.AAC_ELD,
1300
- samplerate: this.hap.AudioStreamingSamplerate.KHZ_16,
1301
- audioChannel: 1,
1272
+ streamingOptions:
1273
+ this.ffmpeg instanceof FFmpeg === true
1274
+ ? {
1275
+ supportedCryptoSuites: [this.hap.SRTPCryptoSuites.NONE, this.hap.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80],
1276
+ video: {
1277
+ resolutions,
1278
+ codec: {
1279
+ type: videoType,
1280
+ profiles,
1281
+ levels,
1282
+ },
1283
+ },
1284
+ audio: {
1285
+ twoWayAudio:
1286
+ this.ffmpeg?.features?.encoders?.includes('libfdk_aac') === true &&
1287
+ (this.ffmpeg?.features?.encoders?.includes('libspeex') === true ||
1288
+ this.ffmpeg?.features?.encoders?.includes('libopus') === true) &&
1289
+ this.deviceData.has_speaker === true &&
1290
+ this.deviceData.has_microphone === true,
1291
+ codecs: [
1292
+ {
1293
+ type: this.hap.AudioStreamingCodecType.AAC_ELD,
1294
+ samplerate: this.hap.AudioStreamingSamplerate.KHZ_16,
1295
+ audioChannel: 1,
1296
+ },
1297
+ ],
1298
+ },
1299
+ }
1300
+ : {
1301
+ supportedCryptoSuites: [this.hap.SRTPCryptoSuites.NONE],
1302
+ video: {
1303
+ resolutions: [],
1304
+ codec: {
1305
+ type: videoType,
1306
+ profiles: [],
1307
+ levels: [],
1308
+ },
1309
+ },
1310
+ audio: {
1311
+ twoWayAudio: false,
1312
+ codecs: [],
1313
+ },
1302
1314
  },
1303
- ],
1304
- },
1305
- },
1306
- recording: undefined,
1307
- sensors: undefined,
1315
+ recording:
1316
+ this.deviceData.hksv === true && this.ffmpeg instanceof FFmpeg === true
1317
+ ? {
1318
+ delegate: this,
1319
+ options: {
1320
+ overrideEventTriggerOptions: [this.hap.EventTriggerOption.MOTION],
1321
+ mediaContainerConfiguration: [
1322
+ {
1323
+ fragmentLength: 4000,
1324
+ type: this.hap.MediaContainerType.FRAGMENTED_MP4,
1325
+ },
1326
+ ],
1327
+ prebufferLength: 4000, // Seems to always be 4000???
1328
+ video: {
1329
+ resolutions,
1330
+ parameters: {
1331
+ profiles,
1332
+ levels,
1333
+ },
1334
+ type: videoType,
1335
+ },
1336
+ audio: {
1337
+ codecs: [
1338
+ {
1339
+ type: this.hap.AudioRecordingCodecType.AAC_LC,
1340
+ samplerate: this.hap.AudioRecordingSamplerate.KHZ_16,
1341
+ audioChannel: 1,
1342
+ },
1343
+ ],
1344
+ },
1345
+ },
1346
+ }
1347
+ : undefined,
1348
+ sensors:
1349
+ this.deviceData.hksv === true && this.ffmpeg instanceof FFmpeg === true
1350
+ ? {
1351
+ motion: typeof this.motionServices?.[1]?.service === 'object' ? this.motionServices[1].service : false,
1352
+ }
1353
+ : undefined,
1308
1354
  };
1355
+ return controllerOptions;
1356
+ }
1357
+ }
1358
+
1359
+ // Function to process our RAW Nest or Google for this device type
1360
+ export function processRawData(log, rawData, config, deviceType = undefined) {
1361
+ if (
1362
+ rawData === null ||
1363
+ typeof rawData !== 'object' ||
1364
+ rawData?.constructor !== Object ||
1365
+ typeof config !== 'object' ||
1366
+ config?.constructor !== Object
1367
+ ) {
1368
+ return;
1369
+ }
1309
1370
 
1310
- if (this.deviceData.hksv === true) {
1311
- controllerOptions.recording = {
1312
- delegate: this,
1313
- options: {
1314
- overrideEventTriggerOptions: [this.hap.EventTriggerOption.MOTION],
1315
- mediaContainerConfiguration: [
1371
+ let devices = {};
1372
+
1373
+ // Process data for any camera/doorbell(s) we have in the raw data
1374
+ Object.entries(rawData)
1375
+ .filter(
1376
+ ([key, value]) =>
1377
+ key.startsWith('quartz.') === true ||
1378
+ (key.startsWith('DEVICE_') === true && PROTOBUF_RESOURCES.CAMERA.includes(value.value?.device_info?.typeName) === true),
1379
+ )
1380
+ .forEach(([object_key, value]) => {
1381
+ let tempDevice = {};
1382
+ try {
1383
+ if (
1384
+ value?.source === DATA_SOURCE.GOOGLE &&
1385
+ rawData?.[value.value?.device_info?.pairerId?.resourceId] !== undefined &&
1386
+ Array.isArray(value.value?.streaming_protocol?.supportedProtocols) === true &&
1387
+ value.value.streaming_protocol.supportedProtocols.includes('PROTOCOL_WEBRTC') === true &&
1388
+ (value.value?.configuration_done?.deviceReady === true ||
1389
+ value.value?.camera_migration_status?.state?.where === 'MIGRATED_TO_GOOGLE_HOME')
1390
+ ) {
1391
+ tempDevice = processCommonData(
1392
+ object_key,
1316
1393
  {
1317
- fragmentLength: 4000,
1318
- type: this.hap.MediaContainerType.FRAGMENTED_MP4,
1394
+ type: DEVICE_TYPE.CAMERA,
1395
+ model:
1396
+ value.value.device_info.typeName === 'google.resource.NeonQuartzResource' &&
1397
+ value.value?.floodlight_settings === undefined &&
1398
+ value.value?.floodlight_state === undefined
1399
+ ? 'Cam (battery)'
1400
+ : value.value.device_info.typeName === 'google.resource.GreenQuartzResource'
1401
+ ? 'Doorbell (2nd gen, battery)'
1402
+ : value.value.device_info.typeName === 'google.resource.SpencerResource'
1403
+ ? 'Cam (wired)'
1404
+ : value.value.device_info.typeName === 'google.resource.VenusResource'
1405
+ ? 'Doorbell (2nd gen, wired)'
1406
+ : value.value.device_info.typeName === 'nest.resource.NestCamOutdoorResource'
1407
+ ? 'Cam Outdoor (1st gen)'
1408
+ : value.value.device_info.typeName === 'nest.resource.NestCamIndoorResource'
1409
+ ? 'Cam Indoor (1st gen)'
1410
+ : value.value.device_info.typeName === 'nest.resource.NestCamIQResource'
1411
+ ? 'Cam IQ'
1412
+ : value.value.device_info.typeName === 'nest.resource.NestCamIQOutdoorResource'
1413
+ ? 'Cam Outdoor (1st gen)'
1414
+ : value.value.device_info.typeName === 'nest.resource.NestHelloResource'
1415
+ ? 'Doorbell (1st gen, wired)'
1416
+ : value.value.device_info.typeName === 'google.resource.NeonQuartzResource' &&
1417
+ value.value?.floodlight_settings !== undefined &&
1418
+ value.value?.floodlight_state !== undefined
1419
+ ? 'Cam with Floodlight'
1420
+ : 'Camera (unknown)',
1421
+ softwareVersion: value.value.device_identity.softwareVersion,
1422
+ serialNumber: value.value.device_identity.serialNumber,
1423
+ description: String(value.value?.label?.label ?? ''),
1424
+ location: String(
1425
+ [
1426
+ ...Object.values(
1427
+ rawData?.[value.value?.device_info?.pairerId?.resourceId]?.value?.located_annotations?.predefinedWheres || {},
1428
+ ),
1429
+ ...Object.values(
1430
+ rawData?.[value.value?.device_info?.pairerId?.resourceId]?.value?.located_annotations?.customWheres || {},
1431
+ ),
1432
+ ].find((where) => where?.whereId?.resourceId === value.value?.device_located_settings?.whereAnnotationRid?.resourceId)
1433
+ ?.label?.literal ?? '',
1434
+ ),
1435
+ online: value.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE',
1436
+ audio_enabled: value.value?.microphone_settings?.enableMicrophone === true,
1437
+ has_indoor_chime:
1438
+ value.value?.doorbell_indoor_chime_settings?.chimeType === 'CHIME_TYPE_MECHANICAL' ||
1439
+ value.value?.doorbell_indoor_chime_settings?.chimeType === 'CHIME_TYPE_ELECTRONIC',
1440
+ indoor_chime_enabled: value.value?.doorbell_indoor_chime_settings?.chimeEnabled === true,
1441
+ streaming_enabled: value.value?.recording_toggle?.currentCameraState === 'CAMERA_ON',
1442
+ has_microphone: value.value?.microphone_settings?.enableMicrophone === true,
1443
+ has_speaker: value.value?.speaker_volume?.volume !== undefined,
1444
+ has_motion_detection: value.value?.observation_trigger_capabilities?.videoEventTypes?.motion?.value === true,
1445
+ activity_zones: Array.isArray(value.value?.activity_zone_settings?.activityZones)
1446
+ ? value.value.activity_zone_settings.activityZones.map((zone) => ({
1447
+ id: zone.zoneProperties?.zoneId !== undefined ? zone.zoneProperties.zoneId : zone.zoneProperties.internalIndex,
1448
+ name: HomeKitDevice.makeValidHKName(zone.zoneProperties?.name !== undefined ? zone.zoneProperties.name : ''),
1449
+ hidden: false,
1450
+ uri: '',
1451
+ }))
1452
+ : [],
1453
+ alerts: typeof value.value?.alerts === 'object' ? value.value.alerts : [],
1454
+ quiet_time_enabled:
1455
+ isNaN(value.value?.quiet_time_settings?.quietTimeEnds?.seconds) === false &&
1456
+ Number(value.value.quiet_time_settings.quietTimeEnds.seconds) !== 0 &&
1457
+ Math.floor(Date.now() / 1000) < Number(value.value.quiet_time_settings.quietTimeEnds.seconds),
1458
+ camera_type: value.value.device_identity.vendorProductId,
1459
+ streaming_protocols:
1460
+ value.value?.streaming_protocol?.supportedProtocols !== undefined ? value.value.streaming_protocol.supportedProtocols : [],
1461
+ streaming_host:
1462
+ typeof value.value?.streaming_protocol?.directHost?.value === 'string'
1463
+ ? value.value.streaming_protocol.directHost.value
1464
+ : '',
1465
+ has_light: value.value?.floodlight_settings !== undefined && value.value?.floodlight_state !== undefined,
1466
+ light_enabled: value.value?.floodlight_state?.currentState === 'LIGHT_STATE_ON',
1467
+ light_brightness:
1468
+ isNaN(value.value?.floodlight_settings?.brightness) === false
1469
+ ? scaleValue(Number(value.value.floodlight_settings.brightness), 0, 10, 0, 100)
1470
+ : 0,
1471
+ migrating:
1472
+ value.value?.camera_migration_status?.state?.progress !== undefined &&
1473
+ value.value.camera_migration_status.state.progress !== 'PROGRESS_COMPLETE' &&
1474
+ value.value.camera_migration_status.state.progress !== 'PROGRESS_NONE',
1319
1475
  },
1320
- ],
1321
- prebufferLength: 4000, // Seems to always be 4000???
1322
- video: {
1323
- resolutions: controllerOptions.streamingOptions.video.resolutions,
1324
- parameters: {
1325
- profiles: controllerOptions.streamingOptions.video.codec.profiles,
1326
- levels: controllerOptions.streamingOptions.video.codec.levels,
1476
+ config,
1477
+ );
1478
+ if (tempDevice.model.toUpperCase().includes('DOORBELL') === true) {
1479
+ tempDevice.type = DEVICE_TYPE.DOORBELL;
1480
+ }
1481
+ if (tempDevice.model.toUpperCase().includes('FLOODLIGHT') === true) {
1482
+ tempDevice.type = DEVICE_TYPE.FLOODLIGHT;
1483
+ }
1484
+ }
1485
+
1486
+ if (
1487
+ value?.source === DATA_SOURCE.NEST &&
1488
+ rawData?.['where.' + value.value.structure_id] !== undefined &&
1489
+ value.value?.nexus_api_http_server_url !== undefined &&
1490
+ (value.value?.properties?.['cc2migration.overview_state'] === 'NORMAL' ||
1491
+ value.value?.properties?.['cc2migration.overview_state'] === 'REVERSE_MIGRATION_IN_PROGRESS')
1492
+ ) {
1493
+ // We'll only use the Nest API data for Camera's which have NOT been migrated to Google Home
1494
+ tempDevice = processCommonData(
1495
+ object_key,
1496
+ {
1497
+ type: DEVICE_TYPE.CAMERA,
1498
+ serialNumber: value.value.serial_number,
1499
+ softwareVersion: value.value.software_version,
1500
+ model: value.value.model.replace(/nest\s*/gi, ''), // Use camera/doorbell model that Nest supplies
1501
+ description: typeof value.value?.description === 'string' ? value.value.description : '',
1502
+ location:
1503
+ rawData?.['where.' + value.value.structure_id]?.value?.wheres?.find((where) => where?.where_id === value.value.where_id)
1504
+ ?.name ?? '',
1505
+ streaming_enabled: value.value.streaming_state.includes('enabled') === true,
1506
+ nexus_api_http_server_url: value.value.nexus_api_http_server_url,
1507
+ online: value.value.streaming_state.includes('offline') === false,
1508
+ audio_enabled: value.value.audio_input_enabled === true,
1509
+ has_indoor_chime: value.value?.capabilities.includes('indoor_chime') === true,
1510
+ indoor_chime_enabled: value.value?.properties['doorbell.indoor_chime.enabled'] === true,
1511
+ has_irled: value.value?.capabilities.includes('irled') === true,
1512
+ irled_enabled: value.value?.properties['irled.state'] !== 'always_off',
1513
+ has_statusled: value.value?.capabilities.includes('statusled') === true,
1514
+ has_video_flip: value.value?.capabilities.includes('video.flip') === true,
1515
+ video_flipped: value.value?.properties['video.flipped'] === true,
1516
+ statusled_brightness:
1517
+ isNaN(value.value?.properties?.['statusled.brightness']) === false
1518
+ ? Number(value.value.properties['statusled.brightness'])
1519
+ : 0,
1520
+ has_microphone: value.value?.capabilities.includes('audio.microphone') === true,
1521
+ has_speaker: value.value?.capabilities.includes('audio.speaker') === true,
1522
+ has_motion_detection: value.value?.capabilities.includes('detectors.on_camera') === true,
1523
+ activity_zones: value.value.activity_zones,
1524
+ alerts: typeof value.value?.alerts === 'object' ? value.value.alerts : [],
1525
+ streaming_protocols: ['PROTOCOL_NEXUSTALK'],
1526
+ streaming_host: value.value.direct_nexustalk_host,
1527
+ quiet_time_enabled: false,
1528
+ camera_type: value.value.camera_type,
1529
+ migrating:
1530
+ value.value?.properties?.['cc2migration.overview_state'] !== undefined &&
1531
+ value.value.properties['cc2migration.overview_state'] !== 'NORMAL',
1327
1532
  },
1328
- type: controllerOptions.streamingOptions.video.codec.type,
1329
- },
1330
- audio: {
1331
- codecs: [
1332
- {
1333
- type: this.hap.AudioRecordingCodecType.AAC_LC,
1334
- samplerate: this.hap.AudioRecordingSamplerate.KHZ_16,
1335
- audioChannel: 1,
1336
- },
1337
- ],
1338
- },
1339
- },
1340
- };
1533
+ config,
1534
+ );
1535
+ if (tempDevice.model.toUpperCase().includes('DOORBELL') === true) {
1536
+ tempDevice.type = DEVICE_TYPE.DOORBELL;
1537
+ }
1538
+ if (tempDevice.model.toUpperCase().includes('FLOODLIGHT') === true) {
1539
+ tempDevice.type = DEVICE_TYPE.FLOODLIGHT;
1540
+ }
1541
+ }
1542
+ // eslint-disable-next-line no-unused-vars
1543
+ } catch (error) {
1544
+ log?.debug?.('Error processing camera data for "%s"', object_key);
1545
+ }
1341
1546
 
1342
- controllerOptions.sensors = {
1343
- motion: typeof this.motionServices?.[1]?.service === 'object' ? this.motionServices[1].service : false,
1344
- };
1345
- }
1547
+ if (
1548
+ Object.entries(tempDevice).length !== 0 &&
1549
+ typeof devices[tempDevice.serialNumber] === 'undefined' &&
1550
+ (deviceType === undefined || (typeof deviceType === 'string' && deviceType !== '' && tempDevice.type === deviceType))
1551
+ ) {
1552
+ let deviceOptions = config?.devices?.find(
1553
+ (device) => device?.serialNumber?.toUpperCase?.() === tempDevice?.serialNumber?.toUpperCase?.(),
1554
+ );
1555
+ // Insert any extra options we've read in from configuration file for this device
1556
+ tempDevice.eveHistory = config.options.eveHistory === true || deviceOptions?.eveHistory === true;
1557
+ tempDevice.hksv = (config.options?.hksv === true || deviceOptions?.hksv === true) && config.options?.ffmpeg?.valid === true;
1558
+ tempDevice.doorbellCooldown = parseDurationToSeconds(deviceOptions?.doorbellCooldown, { defaultValue: 60, min: 0, max: 300 });
1559
+ tempDevice.motionCooldown = parseDurationToSeconds(deviceOptions?.motionCooldown, { defaultValue: 60, min: 0, max: 300 });
1560
+ tempDevice.personCooldown = parseDurationToSeconds(deviceOptions?.personCooldown, { defaultValue: 120, min: 0, max: 300 });
1561
+ tempDevice.chimeSwitch = deviceOptions?.chimeSwitch === true; // Control 'indoor' chime by switch
1562
+ tempDevice.localAccess = deviceOptions?.localAccess === true; // Local network video streaming rather than from cloud from camera/doorbells
1563
+ tempDevice.ffmpeg = {
1564
+ binary: config.options.ffmpeg.binary,
1565
+ valid: config.options.ffmpeg.valid === true,
1566
+ debug: deviceOptions?.ffmpegDebug === true || config.options?.ffmpeg.debug === true,
1567
+ hwaccel:
1568
+ (deviceOptions?.ffmpegHWaccel === true || config.options?.ffmpegHWaccel === true) &&
1569
+ config.options.ffmpeg.valid === true &&
1570
+ config.options.ffmpeg.hwaccel === true,
1571
+ };
1572
+ tempDevice.maxStreams = config.options.hksv === true || deviceOptions?.hksv === true ? 1 : 2;
1573
+ devices[tempDevice.serialNumber] = tempDevice; // Store processed device
1574
+ }
1575
+ });
1346
1576
 
1347
- return controllerOptions;
1348
- }
1577
+ return devices;
1349
1578
  }