homebridge-nest-accfactory 0.0.4-a

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.
Files changed (88) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +176 -0
  3. package/README.md +121 -0
  4. package/config.schema.json +107 -0
  5. package/dist/HomeKitDevice.js +441 -0
  6. package/dist/HomeKitHistory.js +2835 -0
  7. package/dist/camera.js +1276 -0
  8. package/dist/doorbell.js +122 -0
  9. package/dist/index.js +35 -0
  10. package/dist/nexustalk.js +741 -0
  11. package/dist/protect.js +240 -0
  12. package/dist/protobuf/google/rpc/status.proto +91 -0
  13. package/dist/protobuf/google/rpc/stream_body.proto +26 -0
  14. package/dist/protobuf/google/trait/product/camera.proto +53 -0
  15. package/dist/protobuf/googlehome/foyer.proto +208 -0
  16. package/dist/protobuf/nest/messages.proto +8 -0
  17. package/dist/protobuf/nest/services/apigateway.proto +107 -0
  18. package/dist/protobuf/nest/trait/audio.proto +7 -0
  19. package/dist/protobuf/nest/trait/cellular.proto +313 -0
  20. package/dist/protobuf/nest/trait/debug.proto +37 -0
  21. package/dist/protobuf/nest/trait/detector.proto +41 -0
  22. package/dist/protobuf/nest/trait/diagnostics.proto +87 -0
  23. package/dist/protobuf/nest/trait/firmware.proto +221 -0
  24. package/dist/protobuf/nest/trait/guest.proto +105 -0
  25. package/dist/protobuf/nest/trait/history.proto +345 -0
  26. package/dist/protobuf/nest/trait/humanlibrary.proto +19 -0
  27. package/dist/protobuf/nest/trait/hvac.proto +1353 -0
  28. package/dist/protobuf/nest/trait/input.proto +29 -0
  29. package/dist/protobuf/nest/trait/lighting.proto +61 -0
  30. package/dist/protobuf/nest/trait/located.proto +193 -0
  31. package/dist/protobuf/nest/trait/media.proto +68 -0
  32. package/dist/protobuf/nest/trait/network.proto +352 -0
  33. package/dist/protobuf/nest/trait/occupancy.proto +373 -0
  34. package/dist/protobuf/nest/trait/olive.proto +15 -0
  35. package/dist/protobuf/nest/trait/pairing.proto +85 -0
  36. package/dist/protobuf/nest/trait/product/camera.proto +283 -0
  37. package/dist/protobuf/nest/trait/product/detect.proto +67 -0
  38. package/dist/protobuf/nest/trait/product/doorbell.proto +18 -0
  39. package/dist/protobuf/nest/trait/product/guard.proto +59 -0
  40. package/dist/protobuf/nest/trait/product/protect.proto +344 -0
  41. package/dist/protobuf/nest/trait/promonitoring.proto +14 -0
  42. package/dist/protobuf/nest/trait/resourcedirectory.proto +32 -0
  43. package/dist/protobuf/nest/trait/safety.proto +119 -0
  44. package/dist/protobuf/nest/trait/security.proto +516 -0
  45. package/dist/protobuf/nest/trait/selftest.proto +78 -0
  46. package/dist/protobuf/nest/trait/sensor.proto +291 -0
  47. package/dist/protobuf/nest/trait/service.proto +46 -0
  48. package/dist/protobuf/nest/trait/structure.proto +85 -0
  49. package/dist/protobuf/nest/trait/system.proto +51 -0
  50. package/dist/protobuf/nest/trait/test.proto +15 -0
  51. package/dist/protobuf/nest/trait/ui.proto +65 -0
  52. package/dist/protobuf/nest/trait/user.proto +98 -0
  53. package/dist/protobuf/nest/trait/voiceassistant.proto +30 -0
  54. package/dist/protobuf/nestlabs/eventingapi/v1.proto +83 -0
  55. package/dist/protobuf/nestlabs/gateway/v1.proto +273 -0
  56. package/dist/protobuf/nestlabs/gateway/v2.proto +96 -0
  57. package/dist/protobuf/nestlabs/history/v1.proto +73 -0
  58. package/dist/protobuf/root.proto +64 -0
  59. package/dist/protobuf/wdl-event-importance.proto +11 -0
  60. package/dist/protobuf/wdl.proto +450 -0
  61. package/dist/protobuf/weave/common.proto +144 -0
  62. package/dist/protobuf/weave/trait/audio.proto +12 -0
  63. package/dist/protobuf/weave/trait/auth.proto +22 -0
  64. package/dist/protobuf/weave/trait/description.proto +32 -0
  65. package/dist/protobuf/weave/trait/heartbeat.proto +38 -0
  66. package/dist/protobuf/weave/trait/locale.proto +20 -0
  67. package/dist/protobuf/weave/trait/network.proto +24 -0
  68. package/dist/protobuf/weave/trait/pairing.proto +8 -0
  69. package/dist/protobuf/weave/trait/peerdevices.proto +18 -0
  70. package/dist/protobuf/weave/trait/power.proto +86 -0
  71. package/dist/protobuf/weave/trait/schedule.proto +76 -0
  72. package/dist/protobuf/weave/trait/security.proto +343 -0
  73. package/dist/protobuf/weave/trait/telemetry/tunnel.proto +37 -0
  74. package/dist/protobuf/weave/trait/time.proto +16 -0
  75. package/dist/res/Nest_camera_connecting.h264 +0 -0
  76. package/dist/res/Nest_camera_connecting.jpg +0 -0
  77. package/dist/res/Nest_camera_off.h264 +0 -0
  78. package/dist/res/Nest_camera_off.jpg +0 -0
  79. package/dist/res/Nest_camera_offline.h264 +0 -0
  80. package/dist/res/Nest_camera_offline.jpg +0 -0
  81. package/dist/res/Nest_camera_transfer.jpg +0 -0
  82. package/dist/streamer.js +344 -0
  83. package/dist/system.js +3112 -0
  84. package/dist/tempsensor.js +99 -0
  85. package/dist/thermostat.js +1026 -0
  86. package/dist/weather.js +205 -0
  87. package/dist/webrtc.js +55 -0
  88. package/package.json +66 -0
package/dist/camera.js ADDED
@@ -0,0 +1,1276 @@
1
+ // Nest Cameras
2
+ // Part of homebridge-nest-accfactory
3
+ //
4
+ // Code version 7/9/2024
5
+ // Mark Hulskamp
6
+ 'use strict';
7
+
8
+ // Define nodejs module requirements
9
+ import EventEmitter from 'node:events';
10
+ import { Buffer } from 'node:buffer';
11
+ import { setTimeout, clearTimeout } from 'node:timers';
12
+ import process from 'node:process';
13
+ import child_process from 'node:child_process';
14
+ import net from 'node:net';
15
+ import dgram from 'node:dgram';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ // Define our modules
21
+ import HomeKitDevice from './HomeKitDevice.js';
22
+ import NexusTalk from './nexustalk.js';
23
+ //import WebRTC from './webrtc.js';
24
+ let WebRTC = undefined;
25
+
26
+ const CAMERAOFFLINEJPGFILE = 'Nest_camera_offline.jpg'; // Camera offline jpg image file
27
+ const CAMERAOFFJPGFILE = 'Nest_camera_off.jpg'; // Camera video off jpg image file
28
+ const MP4BOX = 'mp4box'; // MP4 box fragement event for HKSV recording
29
+ const SNAPSHOTCACHETIMEOUT = 30000; // Timeout for retaining snapshot image (in milliseconds)
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a defined for JS __dirname
31
+
32
+ export default class NestCamera extends HomeKitDevice {
33
+ controller = undefined; // HomeKit Camera/Doorbell controller service
34
+ streamer = undefined; // Streamer object for live/recording stream
35
+ motionServices = undefined; // Object of Camera/Doorbell motion sensor(s)
36
+ operatingModeService = undefined; // Link to camera/doorbell operating mode service
37
+ personTimer = undefined; // Cooldown timer for person/face events
38
+ motionTimer = undefined; // Cooldown timer for motion events
39
+ snapshotTimer = undefined; // Timer for cached snapshot images
40
+ cameraOfflineImage = undefined; // JPG image buffer for camera offline
41
+ cameraVideoOffImage = undefined; // JPG image buffer for camera video off
42
+ lastSnapshotImage = undefined; // JPG image buffer for last camera snapshot
43
+ snapshotEvent = undefined; // Event for which to get snapshot for
44
+
45
+ // Internal data only for this class
46
+ #hkSessions = []; // Track live and recording active sessions
47
+ #recordingConfig = {}; // HomeKit Secure Video recording configuration
48
+
49
+ constructor(accessory, api, log, eventEmitter, deviceData) {
50
+ super(accessory, api, log, eventEmitter, deviceData);
51
+
52
+ // buffer for camera offline jpg image
53
+ let imageFile = path.resolve(__dirname + '/res/' + CAMERAOFFLINEJPGFILE);
54
+ if (fs.existsSync(imageFile) === true) {
55
+ this.cameraOfflineImage = fs.readFileSync(imageFile);
56
+ }
57
+
58
+ // buffer for camera stream off jpg image
59
+ imageFile = path.resolve(__dirname + '/res/' + CAMERAOFFJPGFILE);
60
+ if (fs.existsSync(imageFile) === true) {
61
+ this.cameraVideoOffImage = fs.readFileSync(imageFile);
62
+ }
63
+
64
+ this.set({ 'watermark.enabled': false }); // 'Try' to turn off Nest watermark in video stream
65
+ }
66
+
67
+ // Class functions
68
+ addServices() {
69
+ // Setup motion services
70
+ if (this.motionServices === undefined) {
71
+ this.createCameraMotionServices();
72
+ }
73
+
74
+ // Setup HomeKit camera controller
75
+ if (this.controller === undefined) {
76
+ this.controller = new this.hap.CameraController(this.generateControllerOptions());
77
+ this.accessory.configureController(this.controller);
78
+ }
79
+
80
+ // Setup additional services/characteristics after we have a controller created
81
+ this.createCameraServices();
82
+
83
+ // Depending on the streaming profiles that the camera supports, this will be either nexustalk or webrtc
84
+ // We'll also start pre-buffering if required for HKSV
85
+ if (this.deviceData.streaming_protocols.includes('PROTOCOL_WEBRTC') === true && this.streamer === undefined && WebRTC !== undefined) {
86
+ this.streamer = new WebRTC(this.deviceData, {
87
+ log: this.log,
88
+ buffer:
89
+ this.deviceData.hksv === true &&
90
+ this?.controller?.recordingManagement?.recordingManagementService !== undefined &&
91
+ this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.Active).value ===
92
+ this.hap.Characteristic.Active.ACTIVE,
93
+ });
94
+ }
95
+
96
+ if (
97
+ this.deviceData.streaming_protocols.includes('PROTOCOL_NEXUSTALK') === true &&
98
+ this.streamer === undefined &&
99
+ NexusTalk !== undefined
100
+ ) {
101
+ this.streamer = new NexusTalk(this.deviceData, {
102
+ log: this.log,
103
+ buffer:
104
+ this.deviceData.hksv === true &&
105
+ this?.controller?.recordingManagement?.recordingManagementService !== undefined &&
106
+ this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.Active).value ===
107
+ this.hap.Characteristic.Active.ACTIVE,
108
+ });
109
+ }
110
+
111
+ if (this.streamer === undefined) {
112
+ this?.log?.error &&
113
+ this.log.error(
114
+ 'No suitable streaming protocol is present for "%s". Streaming and recording will be unavailable',
115
+ this.deviceData.description,
116
+ );
117
+ }
118
+
119
+ // Setup linkage to EveHome app if configured todo so
120
+ if (
121
+ this.deviceData?.eveHistory === true &&
122
+ typeof this.motionServices?.[1]?.service === 'object' &&
123
+ typeof this.historyService?.linkToEveHome === 'function'
124
+ ) {
125
+ this.historyService.linkToEveHome(this.motionServices[1].service, {
126
+ description: this.deviceData.description,
127
+ });
128
+ }
129
+
130
+ // Create extra details for output
131
+ let postSetupDetails = [];
132
+ this.deviceData.hksv === true &&
133
+ postSetupDetails.push(
134
+ 'HomeKit Secure Video support' + (this.streamer?.isBuffering() === true ? ' and recording buffer started' : ''),
135
+ );
136
+ return postSetupDetails;
137
+ }
138
+
139
+ removeServices() {
140
+ // Clean up our camera object since this device is being removed
141
+ this.motionTimer = clearTimeout(this.motionTimer);
142
+ this.personTimer = clearTimeout(this.personTimer);
143
+ this.snapshotTimer = clearTimeout(this.snapshotTimer);
144
+
145
+ this.streamer?.isBuffering() === true && this.streamer.stopBuffering();
146
+
147
+ // Stop any on-going HomeKit sessions, either live or recording
148
+ // We'll terminate any ffmpeg, rtpSpliter etc processes
149
+ this.#hkSessions.forEach((session) => {
150
+ if (typeof session.rtpSplitter?.close === 'function') {
151
+ session.rtpSplitter.close();
152
+ }
153
+ session.ffmpeg.forEach((ffmpeg) => {
154
+ ffmpeg.kill('SIGKILL');
155
+ });
156
+ if (session?.eventEmitter instanceof EventEmitter === true) {
157
+ session.eventEmitter.removeAllListeners(MP4BOX);
158
+ }
159
+ });
160
+
161
+ // Remove any motion services we created
162
+ Object.values(this.motionServices).forEach((service) => {
163
+ service.updateCharacteristic(this.hap.Characteristic.MotionDetected, false);
164
+ this.accessory.removeService(service);
165
+ });
166
+
167
+ // Remove the camera controller
168
+ this.accessory.removeController(this.controller);
169
+
170
+ this.operatingModeService = undefined;
171
+ this.#hkSessions = undefined;
172
+ this.motionServices = undefined;
173
+ this.streamer = undefined;
174
+ this.controller = undefined;
175
+ }
176
+
177
+ // Taken and adapted from:
178
+ // https://github.com/hjdhjd/homebridge-unifi-protect/blob/eee6a4e379272b659baa6c19986d51f5bf2cbbbc/src/protect-ffmpeg-record.ts
179
+ async *handleRecordingStreamRequest(sessionID) {
180
+ if (this.deviceData?.ffmpeg?.path === undefined) {
181
+ this?.log?.warn &&
182
+ this.log.warn(
183
+ 'Received request to start recording for "%s" however we do not have an ffmpeg binary present',
184
+ this.deviceData.description,
185
+ );
186
+ return;
187
+ }
188
+
189
+ if (
190
+ this.motionServices?.[1]?.service !== undefined &&
191
+ this.motionServices[1].service.getCharacteristic(this.hap.Characteristic.MotionDetected).value === false
192
+ ) {
193
+ // Should only be recording if motion detected.
194
+ // Sometimes when starting up, HAP-nodeJS or HomeKit triggers this even when motion isn't occuring
195
+ this?.log?.debug && this.log.debug('Received request to commence recording for "%s" however we have not detected any motion');
196
+ return;
197
+ }
198
+
199
+ if (this.streamer === undefined) {
200
+ this?.log?.error &&
201
+ this.log.error(
202
+ 'Received request to start recording for "%s" however we do not any associated streaming protocol supported',
203
+ this.deviceData.description,
204
+ );
205
+ return;
206
+ }
207
+
208
+ // Build our ffmpeg command string for recording the video/audio stream
209
+ let commandLine =
210
+ '-hide_banner -nostats' +
211
+ ' -fflags +discardcorrupt' +
212
+ ' -max_delay 500000' +
213
+ ' -flags low_delay' +
214
+ ' -f h264 -i pipe:0' + // Video data only on stdin
215
+ (this.deviceData.audio_enabled === true &&
216
+ this.deviceData?.ffmpeg?.libfdk_aac === true &&
217
+ this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.RecordingAudioActive)
218
+ .value === this.hap.Characteristic.RecordingAudioActive.ENABLE
219
+ ? ' -f aac -i pipe:3'
220
+ : ''); // Audio data only on extra pipe created in spawn command
221
+
222
+ // Build our video command for ffmpeg
223
+ commandLine =
224
+ commandLine +
225
+ ' -map 0:v' + // stdin, the first input is video data
226
+ ' -codec:v libx264' +
227
+ ' -preset veryfast' +
228
+ ' -profile:v ' +
229
+ (this.#recordingConfig.videoCodec.parameters.profile === this.hap.H264Profile.HIGH
230
+ ? 'high'
231
+ : this.#recordingConfig.videoCodec.parameters.profile === this.hap.H264Profile.MAIN
232
+ ? 'main'
233
+ : 'baseline') +
234
+ ' -level:v ' +
235
+ (this.#recordingConfig.videoCodec.parameters.level === this.hap.H264Level.LEVEL4_0
236
+ ? '4.0'
237
+ : this.#recordingConfig.videoCodec.parameters.level === this.hap.H264Level.LEVEL3_2
238
+ ? '3.2'
239
+ : '3.1') +
240
+ ' -noautoscale' +
241
+ ' -bf 0' +
242
+ ' -filter:v fps=fps=' +
243
+ this.#recordingConfig.videoCodec.resolution[2] + // convert to framerate HomeKit has requested
244
+ ' -g:v ' +
245
+ (this.#recordingConfig.videoCodec.resolution[2] * this.#recordingConfig.videoCodec.parameters.iFrameInterval) / 1000 +
246
+ ' -b:v ' +
247
+ this.#recordingConfig.videoCodec.parameters.bitRate +
248
+ 'k' +
249
+ ' -fps_mode passthrough' +
250
+ ' -movflags frag_keyframe+empty_moov+default_base_moof' +
251
+ ' -reset_timestamps 1' +
252
+ ' -video_track_timescale 90000' +
253
+ ' -bufsize ' +
254
+ 2 * this.#recordingConfig.videoCodec.parameters.bitRate +
255
+ 'k';
256
+
257
+ // We have seperate video and audio streams that need to be muxed together if audio enabled
258
+ if (
259
+ this.deviceData.audio_enabled === true &&
260
+ this.deviceData?.ffmpeg?.libfdk_aac === true &&
261
+ this.controller.recordingManagement.recordingManagementService.getCharacteristic(this.hap.Characteristic.RecordingAudioActive)
262
+ .value === this.hap.Characteristic.RecordingAudioActive.ENABLE
263
+ ) {
264
+ let audioSampleRates = ['8', '16', '24', '32', '44.1', '48'];
265
+
266
+ commandLine =
267
+ commandLine +
268
+ ' -map 1:a' + // pipe:3, the second input is audio data
269
+ ' -codec:a libfdk_aac' +
270
+ ' -profile:a aac_low' + // HAP.AudioRecordingCodecType.AAC_LC
271
+ ' -ar ' +
272
+ audioSampleRates[this.#recordingConfig.audioCodec.samplerate] +
273
+ 'k' +
274
+ ' -b:a ' +
275
+ this.#recordingConfig.audioCodec.bitrate +
276
+ 'k' +
277
+ ' -ac ' +
278
+ this.#recordingConfig.audioCodec.audioChannels;
279
+ }
280
+
281
+ commandLine = commandLine + ' -f mp4 pipe:1'; // output to stdout in mp4
282
+
283
+ this.#hkSessions[sessionID] = {};
284
+ this.#hkSessions[sessionID].ffmpeg = child_process.spawn(
285
+ path.resolve(this.deviceData.ffmpeg.path + '/ffmpeg'),
286
+ commandLine.split(' '),
287
+ {
288
+ env: process.env,
289
+ stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
290
+ },
291
+ ); // Extra pipe, #3 for audio data
292
+
293
+ this.#hkSessions[sessionID].video = this.#hkSessions[sessionID].ffmpeg.stdin; // Video data on stdio pipe for ffmpeg
294
+ this.#hkSessions[sessionID].audio = this.#hkSessions[sessionID]?.ffmpeg?.stdio?.[3]
295
+ ? this.#hkSessions[sessionID].ffmpeg.stdio[3]
296
+ : null; // Audio data on extra pipe for ffmpeg or null if audio recording disabled
297
+
298
+ // Process FFmpeg output and parse out the fMP4 stream it's generating for HomeKit Secure Video.
299
+ let mp4FragmentData = [];
300
+ this.#hkSessions[sessionID].mp4boxes = [];
301
+ this.#hkSessions[sessionID].eventEmitter = new EventEmitter();
302
+
303
+ this.#hkSessions[sessionID].ffmpeg.stdout.on('data', (data) => {
304
+ // Process the mp4 data from our socket connection and convert into mp4 fragment boxes we need
305
+ mp4FragmentData = mp4FragmentData.length === 0 ? data : Buffer.concat([mp4FragmentData, data]);
306
+ while (mp4FragmentData.length >= 8) {
307
+ let boxSize = mp4FragmentData.slice(0, 4).readUInt32BE(0); // Includes header and data size
308
+
309
+ if (mp4FragmentData.length < boxSize) {
310
+ // We dont have enough data in the buffer yet to process the full mp4 box
311
+ // so, exit loop and await more data
312
+ break;
313
+ }
314
+
315
+ // Add it to our queue to be pushed out through the generator function.
316
+ if (typeof this.#hkSessions?.[sessionID]?.mp4boxes === 'object' && this.#hkSessions?.[sessionID]?.eventEmitter !== undefined) {
317
+ this.#hkSessions[sessionID].mp4boxes.push({
318
+ header: mp4FragmentData.slice(0, 8),
319
+ type: mp4FragmentData.slice(4, 8).toString(),
320
+ data: mp4FragmentData.slice(8, boxSize),
321
+ });
322
+ this.#hkSessions[sessionID].eventEmitter.emit(MP4BOX);
323
+ }
324
+
325
+ // Remove the section of data we've just processed from our buffer
326
+ mp4FragmentData = mp4FragmentData.slice(boxSize);
327
+ }
328
+ });
329
+
330
+ this.#hkSessions[sessionID].ffmpeg.on('exit', (code, signal) => {
331
+ if (signal !== 'SIGKILL' || signal === null) {
332
+ this?.log?.error &&
333
+ this.log.error('ffmpeg recording process for "%s" stopped unexpectedly. Exit code was "%s"', this.deviceData.description, code);
334
+ }
335
+ if (typeof this.#hkSessions[sessionID]?.audio?.end === 'function') {
336
+ // Tidy up our created extra pipe
337
+ this.#hkSessions[sessionID].audio.end();
338
+ }
339
+ });
340
+
341
+ // eslint-disable-next-line no-unused-vars
342
+ this.#hkSessions[sessionID].ffmpeg.on('error', (error) => {
343
+ // Empty
344
+ });
345
+
346
+ // ffmpeg outputs to stderr
347
+ this.#hkSessions[sessionID].ffmpeg.stderr.on('data', (data) => {
348
+ if (data.toString().includes('frame=') === false) {
349
+ // Monitor ffmpeg output while testing. Use 'ffmpeg as a debug option'
350
+ this?.log?.debug && this.log.debug(data.toString());
351
+ }
352
+ });
353
+
354
+ this.streamer !== undefined &&
355
+ this.streamer.startRecordStream(
356
+ sessionID,
357
+ this.#hkSessions[sessionID].ffmpeg.stdin,
358
+ this.#hkSessions[sessionID]?.ffmpeg?.stdio?.[3] ? this.#hkSessions[sessionID].ffmpeg.stdio[3] : null,
359
+ );
360
+
361
+ this?.log?.info &&
362
+ this.log.info(
363
+ 'Started recording from "%s" %s',
364
+ this.deviceData.description,
365
+ this.#hkSessions[sessionID]?.ffmpeg?.stdio?.[3] ? '' : 'without audio',
366
+ );
367
+
368
+ // Loop generating MOOF/MDAT box pairs for HomeKit Secure Video.
369
+ // HAP-NodeJS cancels this async generator function when recording completes also
370
+ let segment = [];
371
+ for (;;) {
372
+ if (
373
+ this.#hkSessions?.[sessionID] === undefined ||
374
+ this.#hkSessions?.[sessionID]?.ffmpeg === undefined ||
375
+ this.#hkSessions?.[sessionID]?.mp4boxes === undefined ||
376
+ this.#hkSessions?.[sessionID]?.eventEmitter === undefined
377
+ ) {
378
+ // Our session object is not present
379
+ // ffmpeg recorder process is not present
380
+ // the mp4box array is not present
381
+ // eventEmitter is not present
382
+ // so finish up the loop
383
+ break;
384
+ }
385
+
386
+ if (this.#hkSessions?.[sessionID]?.mp4boxes?.length === 0 && this.#hkSessions?.[sessionID]?.eventEmitter !== undefined) {
387
+ // since the ffmpeg recorder process hasn't notified us of any mp4 fragment boxes, wait until there are some
388
+ await EventEmitter.once(this.#hkSessions[sessionID].eventEmitter, MP4BOX);
389
+ }
390
+
391
+ let mp4box = this.#hkSessions?.[sessionID]?.mp4boxes.shift();
392
+ if (typeof mp4box !== 'object') {
393
+ // Not an mp4 fragment box, so try again
394
+ continue;
395
+ }
396
+
397
+ // Queue up this fragment mp4 segment
398
+ segment.push(mp4box.header, mp4box.data);
399
+
400
+ if (mp4box.type === 'moov' || mp4box.type === 'mdat') {
401
+ yield { data: Buffer.concat(segment), isLast: false };
402
+ segment = [];
403
+ }
404
+ }
405
+ }
406
+
407
+ closeRecordingStream(sessionID, closeReason) {
408
+ // Stop the associated recording stream
409
+ this.streamer !== undefined && this.streamer.stopRecordStream(sessionID);
410
+
411
+ if (typeof this.#hkSessions?.[sessionID] === 'object') {
412
+ if (this.#hkSessions[sessionID]?.ffmpeg !== undefined) {
413
+ // Kill the ffmpeg recorder process
414
+ this.#hkSessions[sessionID].ffmpeg.kill('SIGKILL');
415
+ }
416
+ if (this.#hkSessions[sessionID]?.eventEmitter !== undefined) {
417
+ this.#hkSessions[sessionID].eventEmitter.emit(MP4BOX); // This will ensure we cleanly exit out from our segment generator
418
+ this.#hkSessions[sessionID].eventEmitter.removeAllListeners(MP4BOX); // Tidy up our event listeners
419
+ }
420
+ delete this.#hkSessions[sessionID];
421
+ }
422
+
423
+ // Log recording finished messages depending on reason
424
+ if (closeReason === this.hap.HDSProtocolSpecificErrorReason.NORMAL) {
425
+ this?.log?.info && this.log.info('Completed recording from "%s"', this.deviceData.description);
426
+ } else {
427
+ this?.log?.warn &&
428
+ this.log.warn(
429
+ 'Recording from "%s" completed with error. Reason was "%s"',
430
+ this.deviceData.description,
431
+ this.hap.HDSProtocolSpecificErrorReason[closeReason],
432
+ );
433
+ }
434
+ }
435
+
436
+ updateRecordingActive(enableRecording) {
437
+ if (enableRecording === true && this.streamer?.isBuffering() === false) {
438
+ // Start a buffering stream for this camera/doorbell. Ensures motion captures all video on motion trigger
439
+ // Required due to data delays by on prem Nest to cloud to HomeKit accessory to iCloud etc
440
+ // Make sure have appropriate bandwidth!!!
441
+ this?.log?.info && this.log.info('Recording was turned on for "%s"', this.deviceData.description);
442
+ this.streamer.startBuffering();
443
+ }
444
+
445
+ if (enableRecording === false && this.streamer?.isBuffering() === true) {
446
+ this.streamer.stopBuffering();
447
+ this?.log?.warn && this.log.warn('Recording was turned off for "%s"', this.deviceData.description);
448
+ }
449
+ }
450
+
451
+ updateRecordingConfiguration(recordingConfig) {
452
+ this.#recordingConfig = recordingConfig; // Store the recording configuration HKSV has provided
453
+ }
454
+
455
+ async handleSnapshotRequest(snapshotRequestDetails, callback) {
456
+ // snapshotRequestDetails.reason === ResourceRequestReason.PERIODIC
457
+ // snapshotRequestDetails.reason === ResourceRequestReason.EVENT
458
+
459
+ // Get current image from camera/doorbell
460
+ let imageBuffer = undefined;
461
+
462
+ if (this.deviceData.streaming_enabled === true && this.deviceData.online === true) {
463
+ let response = await this.get({ camera_snapshot: '' });
464
+ if (Buffer.isBuffer(response?.camera_snapshot) === true) {
465
+ imageBuffer = response.camera_snapshot;
466
+ this.lastSnapshotImage = response.camera_snapshot;
467
+
468
+ // Keep this snapshot image cached for a certain period
469
+ this.snapshotTimer = clearTimeout(this.snapshotTimer);
470
+ this.snapshotTimer = setTimeout(() => {
471
+ this.lastSnapshotImage = undefined;
472
+ }, SNAPSHOTCACHETIMEOUT);
473
+ }
474
+ }
475
+
476
+ if (this.deviceData.streaming_enabled === false && this.deviceData.online === true && this.cameraVideoOffImage !== undefined) {
477
+ // Return 'camera switched off' jpg to image buffer
478
+ imageBuffer = this.cameraVideoOffImage;
479
+ }
480
+
481
+ if (this.deviceData.online === false && this.cameraOfflineImage !== undefined) {
482
+ // Return 'camera offline' jpg to image buffer
483
+ imageBuffer = this.cameraOfflineImage;
484
+ }
485
+
486
+ if (imageBuffer === undefined) {
487
+ // If we get here, we have no snapshot image
488
+ // We'll use the last success snapshop as long as its within a certain time period
489
+ imageBuffer = this.lastSnapshotImage;
490
+ }
491
+
492
+ callback(imageBuffer?.length === 0 ? 'Unabled to obtain Camera/Doorbell snapshot' : null, imageBuffer);
493
+ }
494
+
495
+ async prepareStream(request, callback) {
496
+ const getPort = async (options) => {
497
+ return new Promise((resolve, reject) => {
498
+ let server = net.createServer();
499
+ server.unref();
500
+ server.on('error', reject);
501
+ server.listen(options, () => {
502
+ let port = server.address().port;
503
+ server.close(() => {
504
+ resolve(port); // return port
505
+ });
506
+ });
507
+ });
508
+ };
509
+
510
+ // Generate streaming session information
511
+ let sessionInfo = {
512
+ address: request.targetAddress,
513
+ videoPort: request.video.port,
514
+ localVideoPort: await getPort(),
515
+ videoCryptoSuite: request.video.srtpCryptoSuite,
516
+ videoSRTP: Buffer.concat([request.video.srtp_key, request.video.srtp_salt]),
517
+ videoSSRC: this.hap.CameraController.generateSynchronisationSource(),
518
+
519
+ audioPort: request.audio.port,
520
+ localAudioPort: await getPort(),
521
+ audioTalkbackPort: await getPort(),
522
+ rptSplitterPort: await getPort(),
523
+ audioCryptoSuite: request.video.srtpCryptoSuite,
524
+ audioSRTP: Buffer.concat([request.audio.srtp_key, request.audio.srtp_salt]),
525
+ audioSSRC: this.hap.CameraController.generateSynchronisationSource(),
526
+
527
+ rtpSplitter: null,
528
+ ffmpeg: [], // Array of ffmpeg processes we create for streaming video/audio and audio talkback
529
+ video: null,
530
+ audio: null,
531
+ };
532
+
533
+ // Build response back to HomeKit with the details filled out
534
+
535
+ // Drop ip module by using small snippet of code below
536
+ // Convert ipv4 mapped into ipv6 address into pure ipv4
537
+ if (request.addressVersion === 'ipv4' && request.sourceAddress.startsWith('::ffff:') === true) {
538
+ request.sourceAddress = request.sourceAddress.replace('::ffff:', '');
539
+ }
540
+
541
+ let response = {
542
+ address: request.sourceAddress, // IP Address version must match
543
+ video: {
544
+ port: sessionInfo.localVideoPort,
545
+ ssrc: sessionInfo.videoSSRC,
546
+ srtp_key: request.video.srtp_key,
547
+ srtp_salt: request.video.srtp_salt,
548
+ },
549
+ audio: {
550
+ port: sessionInfo.rptSplitterPort,
551
+ ssrc: sessionInfo.audioSSRC,
552
+ srtp_key: request.audio.srtp_key,
553
+ srtp_salt: request.audio.srtp_salt,
554
+ },
555
+ };
556
+ this.#hkSessions[request.sessionID] = sessionInfo; // Store the session information
557
+ callback(undefined, response);
558
+ }
559
+
560
+ async handleStreamRequest(request, callback) {
561
+ // called when HomeKit asks to start/stop/reconfigure a camera/doorbell stream
562
+ if (this.streamer === undefined) {
563
+ this?.log?.error &&
564
+ this.log.error(
565
+ 'Received request to start live video for "%s" however we do not any associated streaming protocol supported',
566
+ this.deviceData.description,
567
+ );
568
+
569
+ if (typeof callback === 'function') {
570
+ callback(); // do callback if defined
571
+ }
572
+ return;
573
+ }
574
+
575
+ if (this.deviceData?.ffmpeg?.path === undefined && request.type === this.hap.StreamRequestTypes.START) {
576
+ this?.log?.warn &&
577
+ this.log.warn(
578
+ 'Received request to start live video for "%s" however we do not have an ffmpeg binary present',
579
+ this.deviceData.description,
580
+ );
581
+
582
+ if (typeof callback === 'function') {
583
+ callback(); // do callback if defined
584
+ }
585
+ return;
586
+ }
587
+
588
+ if (request.type === this.hap.StreamRequestTypes.START) {
589
+ // Build our ffmpeg command string for the liveview video/audio stream
590
+ let commandLine =
591
+ '-hide_banner -nostats' +
592
+ ' -use_wallclock_as_timestamps 1' +
593
+ ' -fflags +discardcorrupt' +
594
+ ' -max_delay 500000' +
595
+ ' -flags low_delay' +
596
+ ' -f h264 -i pipe:0' + // Video data only on stdin
597
+ (this.deviceData.audio_enabled === true && this.deviceData?.ffmpeg?.libfdk_aac === true ? ' -f aac -i pipe:3' : ''); // Audio data only on extra pipe created in spawn command
598
+
599
+ // Build our video command for ffmpeg
600
+ commandLine =
601
+ commandLine +
602
+ ' -map 0:v' + // stdin, the first input is video data
603
+ ' -codec:v copy' +
604
+ ' -fps_mode passthrough' +
605
+ ' -reset_timestamps 1' +
606
+ ' -video_track_timescale 90000' +
607
+ ' -payload_type ' +
608
+ request.video.pt +
609
+ ' -ssrc ' +
610
+ this.#hkSessions[request.sessionID].videoSSRC +
611
+ ' -f rtp' +
612
+ ' -srtp_out_suite ' +
613
+ this.hap.SRTPCryptoSuites[this.#hkSessions[request.sessionID].videoCryptoSuite] +
614
+ ' -srtp_out_params ' +
615
+ this.#hkSessions[request.sessionID].videoSRTP.toString('base64') +
616
+ ' srtp://' +
617
+ this.#hkSessions[request.sessionID].address +
618
+ ':' +
619
+ this.#hkSessions[request.sessionID].videoPort +
620
+ '?rtcpport=' +
621
+ this.#hkSessions[request.sessionID].videoPort +
622
+ '&pkt_size=' +
623
+ request.video.mtu;
624
+
625
+ // We have seperate video and audio streams that need to be muxed together if audio enabled
626
+ if (this.deviceData.audio_enabled === true && this.deviceData?.ffmpeg?.libfdk_aac === true) {
627
+ commandLine =
628
+ commandLine +
629
+ ' -map 1:a' + // pipe:3, the second input is audio data
630
+ ' -codec:a libfdk_aac' +
631
+ ' -profile:a aac_eld' + //+ this.hap.AudioStreamingCodecType.AAC_ELD
632
+ ' -flags +global_header' +
633
+ ' -ar ' +
634
+ request.audio.sample_rate +
635
+ 'k' +
636
+ ' -b:a ' +
637
+ request.audio.max_bit_rate +
638
+ 'k' +
639
+ ' -ac ' +
640
+ request.audio.channel +
641
+ ' -payload_type ' +
642
+ request.audio.pt +
643
+ ' -ssrc ' +
644
+ this.#hkSessions[request.sessionID].audioSSRC +
645
+ ' -f rtp' +
646
+ ' -srtp_out_suite ' +
647
+ this.hap.SRTPCryptoSuites[this.#hkSessions[request.sessionID].audioCryptoSuite] +
648
+ ' -srtp_out_params ' +
649
+ this.#hkSessions[request.sessionID].audioSRTP.toString('base64') +
650
+ ' srtp://' +
651
+ this.#hkSessions[request.sessionID].address +
652
+ ':' +
653
+ this.#hkSessions[request.sessionID].audioPort +
654
+ '?rtcpport=' +
655
+ this.#hkSessions[request.sessionID].audioPort +
656
+ '&localrtcpport=' +
657
+ this.#hkSessions[request.sessionID].localAudioPort +
658
+ '&pkt_size=188';
659
+ }
660
+
661
+ // Start our ffmpeg streaming process and stream from our streamer
662
+ let ffmpegStreaming = child_process.spawn(path.resolve(this.deviceData.ffmpeg.path + '/ffmpeg'), commandLine.split(' '), {
663
+ env: process.env,
664
+ stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
665
+ }); // Extra pipe, #3 for audio data
666
+
667
+ // ffmpeg console output is via stderr
668
+ ffmpegStreaming.stderr.on('data', (data) => {
669
+ if (data.toString().includes('frame=') === false) {
670
+ // Monitor ffmpeg output while testing. Use 'ffmpeg as a debug option'
671
+ this?.log?.debug && this.log.debug(data.toString());
672
+ }
673
+ });
674
+
675
+ ffmpegStreaming.on('exit', (code, signal) => {
676
+ if (signal !== 'SIGKILL' || signal === null) {
677
+ this?.log?.error &&
678
+ this.log.error(
679
+ 'ffmpeg video/audio live streaming process for "%s" stopped unexpectedly. Exit code was "%s"',
680
+ this.deviceData.description,
681
+ code,
682
+ );
683
+ this.controller.forceStopStreamingSession(request.sessionID);
684
+ }
685
+ });
686
+
687
+ // eslint-disable-next-line no-unused-vars
688
+ ffmpegStreaming.on('error', (error) => {
689
+ // Empty
690
+ });
691
+
692
+ // We only enable two/way audio on camera/doorbell if we have the required libraries in ffmpeg AND two-way/audio is enabled
693
+ let ffmpegAudioTalkback = null; // No ffmpeg process for return audio yet
694
+ if (
695
+ this.deviceData?.ffmpeg?.libspeex === true &&
696
+ this.deviceData?.ffmpeg?.libfdk_aac === true &&
697
+ this.deviceData.audio_enabled === true &&
698
+ this.deviceData.has_speaker === true &&
699
+ this.deviceData.has_microphone === true
700
+ ) {
701
+ // Setup RTP splitter for two/away audio
702
+ this.#hkSessions[request.sessionID].rtpSplitter = dgram.createSocket('udp4');
703
+ this.#hkSessions[request.sessionID].rtpSplitter.bind(this.#hkSessions[request.sessionID].rptSplitterPort);
704
+
705
+ this.#hkSessions[request.sessionID].rtpSplitter.on('error', () => {
706
+ this.#hkSessions[request.sessionID].rtpSplitter.close();
707
+ });
708
+
709
+ this.#hkSessions[request.sessionID].rtpSplitter.on('message', (message) => {
710
+ let payloadType = message.readUInt8(1) & 0x7f;
711
+ if (payloadType === request.audio.pt) {
712
+ // Audio payload type from HomeKit should match our payload type for audio
713
+ if (message.length > 50) {
714
+ // Only send on audio data if we have a longer audio packet.
715
+ // (not sure it makes any difference, as under iOS 15 packets are roughly same length)
716
+ this.#hkSessions[request.sessionID].rtpSplitter.send(message, this.#hkSessions[request.sessionID].audioTalkbackPort);
717
+ }
718
+ } else {
719
+ this.#hkSessions[request.sessionID].rtpSplitter.send(message, this.#hkSessions[request.sessionID].localAudioPort);
720
+ // Send RTCP to return audio as a heartbeat
721
+ this.#hkSessions[request.sessionID].rtpSplitter.send(message, this.#hkSessions[request.sessionID].audioTalkbackPort);
722
+ }
723
+ });
724
+
725
+ // Build ffmpeg command
726
+ let commandLine =
727
+ '-hide_banner -nostats' +
728
+ ' -protocol_whitelist pipe,udp,rtp' +
729
+ ' -f sdp' +
730
+ ' -codec:a libfdk_aac' +
731
+ ' -i pipe:0' +
732
+ ' -map 0:a' +
733
+ ' -codec:a libspeex' +
734
+ ' -frames_per_packet 4' +
735
+ ' -vad 1' + // testing to filter background noise?
736
+ ' -ac 1' +
737
+ ' -ar 16k' +
738
+ ' -f data pipe:1';
739
+
740
+ ffmpegAudioTalkback = child_process.spawn(path.resolve(this.deviceData.ffmpeg.path + '/ffmpeg'), commandLine.split(' '), {
741
+ env: process.env,
742
+ });
743
+
744
+ ffmpegAudioTalkback.on('exit', (code, signal) => {
745
+ if (signal !== 'SIGKILL' || signal === null) {
746
+ this?.log?.error &&
747
+ this.log.error(
748
+ 'ffmpeg audio talkback streaming process for "%s" stopped unexpectedly. Exit code was "%s"',
749
+ this.deviceData.description,
750
+ code,
751
+ );
752
+ this.controller.forceStopStreamingSession(request.sessionID);
753
+ }
754
+ });
755
+
756
+ // eslint-disable-next-line no-unused-vars
757
+ ffmpegAudioTalkback.on('error', (error) => {
758
+ // Empty
759
+ });
760
+
761
+ // ffmpeg console output is via stderr
762
+ ffmpegAudioTalkback.stderr.on('data', (data) => {
763
+ this?.log?.debug && this.log.debug(data.toString());
764
+ });
765
+
766
+ // Write out SDP configuration
767
+ // Tried to align the SDP configuration to what HomeKit has sent us in its audio request details
768
+ ffmpegAudioTalkback.stdin.write(
769
+ 'v=0\n' +
770
+ 'o=- 0 0 IN ' +
771
+ (this.#hkSessions[request.sessionID].ipv6 ? 'IP6' : 'IP4') +
772
+ ' ' +
773
+ this.#hkSessions[request.sessionID].address +
774
+ '\n' +
775
+ 's=Nest Audio Talkback\n' +
776
+ 'c=IN ' +
777
+ (this.#hkSessions[request.sessionID].ipv6 ? 'IP6' : 'IP4') +
778
+ ' ' +
779
+ this.#hkSessions[request.sessionID].address +
780
+ '\n' +
781
+ 't=0 0\n' +
782
+ 'm=audio ' +
783
+ this.#hkSessions[request.sessionID].audioTalkbackPort +
784
+ ' RTP/AVP ' +
785
+ request.audio.pt +
786
+ '\n' +
787
+ 'b=AS:' +
788
+ request.audio.max_bit_rate +
789
+ '\n' +
790
+ 'a=ptime:' +
791
+ request.audio.packet_time +
792
+ '\n' +
793
+ 'a=rtpmap:' +
794
+ request.audio.pt +
795
+ ' MPEG4-GENERIC/' +
796
+ request.audio.sample_rate * 1000 +
797
+ '/1\n' +
798
+ 'a=fmtp:' +
799
+ request.audio.pt +
800
+ ' profile-level-id=1;mode=AAC-hbr;sizelength=13;indexlength=3;indexdeltalength=3;config=F8F0212C00BC00\n' +
801
+ 'a=crypto:1 ' +
802
+ this.hap.SRTPCryptoSuites[this.#hkSessions[request.sessionID].audioCryptoSuite] +
803
+ ' inline:' +
804
+ this.#hkSessions[request.sessionID].audioSRTP.toString('base64'),
805
+ );
806
+ ffmpegAudioTalkback.stdin.end();
807
+ }
808
+
809
+ this?.log?.info &&
810
+ this.log.info(
811
+ 'Live stream started on "%s" %s',
812
+ this.deviceData.description,
813
+ ffmpegAudioTalkback?.stdout ? 'with two-way audio' : '',
814
+ );
815
+
816
+ // Start the appropirate streamer
817
+ this.streamer !== undefined &&
818
+ this.streamer.startLiveStream(
819
+ request.sessionID,
820
+ ffmpegStreaming.stdin,
821
+ ffmpegStreaming?.stdio?.[3] ? ffmpegStreaming.stdio[3] : null,
822
+ ffmpegAudioTalkback?.stdout ? ffmpegAudioTalkback.stdout : null,
823
+ );
824
+
825
+ // Store our ffmpeg sessions
826
+ ffmpegStreaming && this.#hkSessions[request.sessionID].ffmpeg.push(ffmpegStreaming); // Store ffmpeg process ID
827
+ ffmpegAudioTalkback && this.#hkSessions[request.sessionID].ffmpeg.push(ffmpegAudioTalkback); // Store ffmpeg audio return process ID
828
+ this.#hkSessions[request.sessionID].video = request.video; // Cache the video request details
829
+ this.#hkSessions[request.sessionID].audio = request.audio; // Cache the audio request details
830
+ }
831
+
832
+ if (request.type === this.hap.StreamRequestTypes.STOP && typeof this.#hkSessions[request.sessionID] === 'object') {
833
+ this.streamer !== undefined && this.streamer.stopLiveStream(request.sessionID);
834
+
835
+ // Close off any running ffmpeg and/or splitter processes we created
836
+ if (typeof this.#hkSessions[request.sessionID]?.rtpSplitter?.close === 'function') {
837
+ this.#hkSessions[request.sessionID].rtpSplitter.close();
838
+ }
839
+ this.#hkSessions[request.sessionID].ffmpeg.forEach((ffmpeg) => {
840
+ ffmpeg.kill('SIGKILL');
841
+ });
842
+
843
+ delete this.#hkSessions[request.sessionID];
844
+
845
+ this?.log?.info && this.log.info('Live stream stopped from "%s"', this.deviceData.description);
846
+ }
847
+
848
+ if (request.type === this.hap.StreamRequestTypes.RECONFIGURE && typeof this.#hkSessions[request.sessionID] === 'object') {
849
+ this?.log?.debug && this.log.debug('Unsupported reconfiguration request for live stream on "%s"', this.deviceData.description);
850
+ }
851
+
852
+ if (typeof callback === 'function') {
853
+ callback(); // do callback if defined
854
+ }
855
+ }
856
+
857
+ updateServices(deviceData) {
858
+ if (typeof deviceData !== 'object' || this.controller === undefined) {
859
+ return;
860
+ }
861
+
862
+ // For non-HKSV enabled devices, we will process any activity zone changes to add or remove any motion services
863
+ if (deviceData.hksv === false && JSON.stringify(deviceData.activity_zones) !== JSON.stringify(this.deviceData.activity_zones)) {
864
+ // Check to see if any activity zones were added
865
+ deviceData.activity_zones.forEach((zone) => {
866
+ if (typeof this.motionServices[zone.id]?.service === 'undefined') {
867
+ // Zone doesn't have an associated motion sensor, so add one
868
+ let tempService = this.accessory.addService(this.hap.Service.MotionSensor, zone.id === 1 ? '' : zone.name, zone.id);
869
+ tempService.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); // No motion initially
870
+ this.motionServices[zone.id] = { service: tempService };
871
+ }
872
+ });
873
+
874
+ // Check to see if any activity zones were removed
875
+ Object.entries(this.motionServices).forEach(([zoneID, service]) => {
876
+ if (deviceData.activity_zones.findIndex(({ id }) => id === zoneID) === -1) {
877
+ // Motion service we created doesn't appear in zone list anymore, so assume deleted
878
+ this.accessory.removeService(service.service);
879
+ delete this.motionServices[zoneID];
880
+ }
881
+ });
882
+ }
883
+
884
+ if (this.operatingModeService !== undefined) {
885
+ // Update camera off/on status
886
+ // 0 = Enabled
887
+ // 1 = Disabled
888
+ this.operatingModeService.updateCharacteristic(
889
+ this.hap.Characteristic.ManuallyDisabled,
890
+ deviceData.streaming_enabled === true ? 0 : 1,
891
+ );
892
+
893
+ if (deviceData.has_statusled === true && typeof deviceData.statusled_brightness === 'number') {
894
+ // Set camera recording indicator. This cannot be turned off on Nest Cameras/Doorbells
895
+ // 0 = auto
896
+ // 1 = low
897
+ // 2 = high
898
+ this.operatingModeService.updateCharacteristic(
899
+ this.hap.Characteristic.CameraOperatingModeIndicator,
900
+ deviceData.statusled_brightness !== 1,
901
+ );
902
+ }
903
+
904
+ if (deviceData.has_irled === true) {
905
+ // Set nightvision status in HomeKit
906
+ this.operatingModeService.updateCharacteristic(this.hap.Characteristic.NightVision, deviceData.irled_enabled);
907
+ }
908
+
909
+ if (deviceData.has_video_flip === true) {
910
+ // Update image flip status
911
+ this.operatingModeService.updateCharacteristic(this.hap.Characteristic.ImageRotation, deviceData.video_flipped === true ? 180 : 0);
912
+ }
913
+ }
914
+
915
+ if (deviceData.hksv === true && this.controller?.recordingManagement?.recordingManagementService !== undefined) {
916
+ // Update recording audio status
917
+ this.controller.recordingManagement.recordingManagementService.updateCharacteristic(
918
+ this.hap.Characteristic.RecordingAudioActive,
919
+ deviceData.audio_enabled === true
920
+ ? this.hap.Characteristic.RecordingAudioActive.ENABLE
921
+ : this.hap.Characteristic.RecordingAudioActive.DISABLE,
922
+ );
923
+ }
924
+
925
+ if (this.controller?.microphoneService !== undefined) {
926
+ // Update microphone volume if specified
927
+ //this.controller.microphoneService.updateCharacteristic(this.hap.Characteristic.Volume, deviceData.xxx);
928
+
929
+ // if audio is disabled, we'll mute microphone
930
+ this.controller.setMicrophoneMuted(deviceData.audio_enabled === false ? true : false);
931
+ }
932
+ if (this.controller?.speakerService !== undefined) {
933
+ // Update speaker volume if specified
934
+ //this.controller.speakerService.updateCharacteristic(this.hap.Characteristic.Volume, deviceData.xxx);
935
+
936
+ // if audio is disabled, we'll mute speaker
937
+ this.controller.setSpeakerMuted(deviceData.audio_enabled === false ? true : false);
938
+ }
939
+
940
+ // Notify our associated streamers about any data changes
941
+ this.streamer !== undefined && this.streamer.update(deviceData);
942
+
943
+ // Process alerts, the most recent alert is first
944
+ // For HKSV, we're interested motion events
945
+ // For non-HKSV, we're interested motion, face and person events (maybe sound and package later)
946
+ deviceData.alerts.forEach((event) => {
947
+ // Handle motion event
948
+ // For a HKSV enabled camera, we will use this to trigger the starting of the HKSV recording if the camera is active
949
+ if (event.types.includes('motion') === true) {
950
+ if (this.motionTimer === undefined && (this.deviceData.hksv === false || this.streamer === undefined)) {
951
+ this?.log?.info && this.log.info('Motion detected at "%s"', this.deviceData.description);
952
+ }
953
+
954
+ event.zone_ids.forEach((zoneID) => {
955
+ if (
956
+ typeof this.motionServices?.[zoneID]?.service === 'object' &&
957
+ this.motionServices[zoneID].service.getCharacteristic(this.hap.Characteristic.MotionDetected).value !== true
958
+ ) {
959
+ // Trigger motion for matching zone of not aleady active
960
+ this.motionServices[zoneID].service.updateCharacteristic(this.hap.Characteristic.MotionDetected, true);
961
+
962
+ // Log motion started into history
963
+ if (typeof this.historyService?.addHistory === 'function') {
964
+ this.historyService.addHistory(this.motionServices[zoneID].service, {
965
+ time: Math.floor(Date.now() / 1000),
966
+ status: 1,
967
+ });
968
+ }
969
+ }
970
+ });
971
+
972
+ // Clear any motion active timer so we can extend if more motion detected
973
+ clearTimeout(this.motionTimer);
974
+ this.motionTimer = setTimeout(() => {
975
+ event.zone_ids.forEach((zoneID) => {
976
+ if (typeof this.motionServices?.[zoneID]?.service === 'object') {
977
+ // Mark associted motion services as motion not detected
978
+ this.motionServices[zoneID].service.updateCharacteristic(this.hap.Characteristic.MotionDetected, false);
979
+
980
+ // Log motion started into history
981
+ if (typeof this.historyService?.addHistory === 'function') {
982
+ this.historyService.addHistory(this.motionServices[zoneID].service, {
983
+ time: Math.floor(Date.now() / 1000),
984
+ status: 0,
985
+ });
986
+ }
987
+ }
988
+ });
989
+
990
+ this.motionTimer = undefined; // No motion timer active
991
+ }, this.deviceData.motionCooldown * 1000);
992
+ }
993
+
994
+ // Handle person/face event
995
+ // We also treat a 'face' event the same as a person event ie: if you have a face, you have a person
996
+ if (event.types.includes('person') === true || event.types.includes('face') === true) {
997
+ if (this.personTimer === undefined) {
998
+ // We don't have a person cooldown timer running, so we can process the 'person'/'face' event
999
+ if (this?.log?.info && (this.deviceData.hksv === false || this.streamer === undefined)) {
1000
+ // We'll only log a person detected event if HKSV is disabled
1001
+ this.log.info('Person detected at "%s"', this.deviceData.description);
1002
+ }
1003
+
1004
+ // Cooldown for person being detected
1005
+ // Start this before we process further
1006
+ this.personTimer = setTimeout(() => {
1007
+ this.personTimer = undefined; // No person timer active
1008
+ }, this.deviceData.personCooldown * 1000);
1009
+
1010
+ if (event.types.includes('motion') === false) {
1011
+ // If person/face events doesn't include a motion event, add in here
1012
+ // This will handle all the motion triggering stuff
1013
+ event.types.push('motion');
1014
+ }
1015
+ }
1016
+ }
1017
+ });
1018
+ }
1019
+
1020
+ createCameraMotionServices() {
1021
+ // First up, remove any motion services present in the accessory
1022
+ // This will help with any 'restored' service Homebridge has done
1023
+ // And allow for zone changes on the camera/doorbell
1024
+ this.motionServices = {};
1025
+ this.accessory.services.forEach((service) => {
1026
+ if (service.UUID === this.hap.Service.MotionSensor.UUID) {
1027
+ this.accessory.removeService(service);
1028
+ }
1029
+ });
1030
+
1031
+ if (this.deviceData.has_motion_detection === true && typeof this.deviceData.activity_zones === 'object') {
1032
+ // We have the capability of motion sensing on device, so setup motion sensor(s)
1033
+ // If we have HKSV video enabled, we'll only create a single motion sensor
1034
+ // A zone with the ID of 1 is treated as the main motion sensor
1035
+ this.deviceData.activity_zones.forEach((zone) => {
1036
+ if (this.deviceData.hksv === false || (this.deviceData.hksv === true && zone.id === 1)) {
1037
+ let tempService = this.accessory.addService(this.hap.Service.MotionSensor, zone.id === 1 ? '' : zone.name, zone.id);
1038
+ tempService.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); // No motion initially
1039
+ this.motionServices[zone.id] = {
1040
+ service: tempService,
1041
+ };
1042
+ }
1043
+ });
1044
+ }
1045
+ }
1046
+
1047
+ createCameraServices() {
1048
+ if (this.controller === undefined) {
1049
+ return;
1050
+ }
1051
+
1052
+ this.operatingModeService = this.controller?.recordingManagement?.operatingModeService;
1053
+ if (this.operatingModeService === undefined) {
1054
+ // Add in operating mode service for a non-hksv camera/doorbell
1055
+ // Allow us to change things such as night vision, camera indicator etc within HomeKit for those also:-)
1056
+ this.operatingModeService = this.accessory.getService(this.hap.Service.CameraOperatingMode);
1057
+ if (this.operatingModeService === undefined) {
1058
+ this.operatingModeService = this.accessory.addService(this.hap.Service.CameraOperatingMode, '', 1);
1059
+ }
1060
+ }
1061
+
1062
+ // Setup set callbacks for characteristics
1063
+ if (this.deviceData.has_statusled === true && this.operatingModeService !== undefined) {
1064
+ if (this.operatingModeService.testCharacteristic(this.hap.Characteristic.CameraOperatingModeIndicator) === false) {
1065
+ this.operatingModeService.addOptionalCharacteristic(this.hap.Characteristic.CameraOperatingModeIndicator);
1066
+ }
1067
+ this.operatingModeService.getCharacteristic(this.hap.Characteristic.CameraOperatingModeIndicator).onSet((value) => {
1068
+ // 0 = auto, 1 = low, 2 = high
1069
+ // We'll use auto mode for led on and low for led off
1070
+ if (
1071
+ (value === true && this.deviceData.statusled_brightness !== 0) ||
1072
+ (value === false && this.deviceData.statusled_brightness !== 1)
1073
+ ) {
1074
+ this.set({ 'statusled.brightness': value === true ? 0 : 1 });
1075
+ if (this?.log?.info) {
1076
+ this.log.info('Recording status LED on "%s" was turned', this.deviceData.description, value === true ? 'on' : 'off');
1077
+ }
1078
+ }
1079
+ });
1080
+
1081
+ this.operatingModeService.getCharacteristic(this.hap.Characteristic.CameraOperatingModeIndicator).onGet(() => {
1082
+ return this.deviceData.statusled_brightness !== 1;
1083
+ });
1084
+ }
1085
+
1086
+ if (this.deviceData.has_irled === true && this.operatingModeService !== undefined) {
1087
+ if (this.operatingModeService.testCharacteristic(this.hap.Characteristic.NightVision) === false) {
1088
+ this.operatingModeService.addOptionalCharacteristic(this.hap.Characteristic.NightVision);
1089
+ }
1090
+
1091
+ this.operatingModeService.getCharacteristic(this.hap.Characteristic.NightVision).onSet((value) => {
1092
+ // only change IRLed status value if different than on-device
1093
+ if ((value === false && this.deviceData.irled_enabled === true) || (value === true && this.deviceData.irled_enabled === false)) {
1094
+ this.set({ 'irled.state': value === true ? 'auto_on' : 'always_off' });
1095
+
1096
+ if (this?.log?.info) {
1097
+ this.log.info('Night vision on "%s" was turned', this.deviceData.description, value === true ? 'on' : 'off');
1098
+ }
1099
+ }
1100
+ });
1101
+
1102
+ this.operatingModeService.getCharacteristic(this.hap.Characteristic.NightVision).onGet(() => {
1103
+ return this.deviceData.irled_enabled;
1104
+ });
1105
+ }
1106
+
1107
+ if (this.operatingModeService !== undefined) {
1108
+ this.operatingModeService.getCharacteristic(this.hap.Characteristic.HomeKitCameraActive).onSet((value) => {
1109
+ if (value !== this.operatingModeService.getCharacteristic(this.hap.Characteristic.HomeKitCameraActive).value) {
1110
+ // Make sure only updating status if HomeKit value *actually changes*
1111
+ if (
1112
+ (this.deviceData.streaming_enabled === false && value === this.hap.Characteristic.HomeKitCameraActive.ON) ||
1113
+ (this.deviceData.streaming_enabled === true && value === this.hap.Characteristic.HomeKitCameraActive.OFF)
1114
+ ) {
1115
+ // Camera state does not reflect requested state, so fix
1116
+ this.set({ 'streaming.enabled': value === this.hap.Characteristic.HomeKitCameraActive.ON ? true : false });
1117
+ if (this.log.info) {
1118
+ this.log.info(
1119
+ 'Camera on "%s" was turned',
1120
+ this.deviceData.description,
1121
+ value === this.hap.Characteristic.HomeKitCameraActive.ON ? 'on' : 'off',
1122
+ );
1123
+ }
1124
+ }
1125
+ }
1126
+ });
1127
+
1128
+ this.operatingModeService.getCharacteristic(this.hap.Characteristic.HomeKitCameraActive).onGet(() => {
1129
+ return this.deviceData.streaming_enabled === true
1130
+ ? this.hap.Characteristic.HomeKitCameraActive.ON
1131
+ : this.hap.Characteristic.HomeKitCameraActive.OFF;
1132
+ });
1133
+ }
1134
+
1135
+ if (this.deviceData.has_video_flip === true && this.operatingModeService !== undefined) {
1136
+ if (this.operatingModeService.testCharacteristic(this.hap.Characteristic.ImageRotation) === false) {
1137
+ this.operatingModeService.addOptionalCharacteristic(this.hap.Characteristic.ImageRotation);
1138
+ }
1139
+
1140
+ this.operatingModeService.getCharacteristic(this.hap.Characteristic.ImageRotation).onGet(() => {
1141
+ return this.deviceData.video_flipped === true ? 180 : 0;
1142
+ });
1143
+ }
1144
+
1145
+ if (this.deviceData.has_irled === true && this.operatingModeService !== undefined) {
1146
+ if (this.operatingModeService.testCharacteristic(this.hap.Characteristic.ManuallyDisabled) === false) {
1147
+ this.operatingModeService.addOptionalCharacteristic(this.hap.Characteristic.ManuallyDisabled);
1148
+ }
1149
+
1150
+ this.operatingModeService.getCharacteristic(this.hap.Characteristic.ManuallyDisabled).onGet(() => {
1151
+ return this.deviceData.streaming_enabled === true ? 0 : 1;
1152
+ });
1153
+ }
1154
+
1155
+ if (this.deviceData.has_microphone === true && this.controller?.recordingManagement?.recordingManagementService !== undefined) {
1156
+ this.controller.recordingManagement.recordingManagementService
1157
+ .getCharacteristic(this.hap.Characteristic.RecordingAudioActive)
1158
+ .onSet((value) => {
1159
+ if (
1160
+ (this.deviceData.audio_enabled === true && value === this.hap.Characteristic.RecordingAudioActive.DISABLE) ||
1161
+ (this.deviceData.audio_enabled === false && value === this.hap.Characteristic.RecordingAudioActive.ENABLE)
1162
+ ) {
1163
+ this.set({ 'audio.enabled': value === this.hap.Characteristic.RecordingAudioActive.ENABLE ? true : false });
1164
+ if (this?.log?.info) {
1165
+ this.log.info(
1166
+ 'Audio recording on "%s" was turned',
1167
+ this.deviceData.description,
1168
+ value === this.hap.Characteristic.RecordingAudioActive.ENABLE ? 'on' : 'off',
1169
+ );
1170
+ }
1171
+ }
1172
+ });
1173
+
1174
+ this.controller.recordingManagement.recordingManagementService
1175
+ .getCharacteristic(this.hap.Characteristic.RecordingAudioActive)
1176
+ .onGet(() => {
1177
+ return this.deviceData.audio_enabled === true
1178
+ ? this.hap.Characteristic.RecordingAudioActive.ENABLE
1179
+ : this.hap.Characteristic.RecordingAudioActive.DISABLE;
1180
+ });
1181
+ }
1182
+ }
1183
+
1184
+ generateControllerOptions() {
1185
+ // Setup HomeKit controller camera/doorbell options
1186
+ let controllerOptions = {
1187
+ cameraStreamCount: this.deviceData.maxStreams,
1188
+ delegate: this,
1189
+ streamingOptions: {
1190
+ supportedCryptoSuites: [this.hap.SRTPCryptoSuites.NONE, this.hap.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80],
1191
+ video: {
1192
+ resolutions: [
1193
+ // width, height, framerate
1194
+ // <--- Need to auto generate this list
1195
+ [3840, 2160, 30], // 4K
1196
+ [1920, 1080, 30], // 1080p
1197
+ [1600, 1200, 30], // Native res of Nest Hello
1198
+ [1280, 960, 30],
1199
+ [1280, 720, 30], // 720p
1200
+ [1024, 768, 30],
1201
+ [640, 480, 30],
1202
+ [640, 360, 30],
1203
+ [480, 360, 30],
1204
+ [480, 270, 30],
1205
+ [320, 240, 30],
1206
+ [320, 240, 15], // Apple Watch requires this configuration (Apple Watch also seems to required OPUS @16K)
1207
+ [320, 180, 30],
1208
+ [320, 180, 15],
1209
+ ],
1210
+ codec: {
1211
+ type: this.hap.VideoCodecType.H264,
1212
+ profiles: [this.hap.H264Profile.MAIN],
1213
+ levels: [this.hap.H264Level.LEVEL3_1, this.hap.H264Level.LEVEL3_2, this.hap.H264Level.LEVEL4_0],
1214
+ },
1215
+ },
1216
+ audio: undefined,
1217
+ },
1218
+ recording: undefined,
1219
+ sensors: undefined,
1220
+ };
1221
+
1222
+ if (this.deviceData?.ffmpeg?.libfdk_aac === true) {
1223
+ // Enabling audio for streaming if we have the appropriate codec in ffmpeg binary present
1224
+ controllerOptions.streamingOptions.audio = {
1225
+ twoWayAudio:
1226
+ this.deviceData?.ffmpeg?.libspeex === true && this.deviceData.has_speaker === true && this.deviceData.has_microphone === true,
1227
+ codecs: [
1228
+ {
1229
+ type: this.hap.AudioStreamingCodecType.AAC_ELD,
1230
+ samplerate: this.hap.AudioStreamingSamplerate.KHZ_16,
1231
+ audioChannel: 1,
1232
+ },
1233
+ ],
1234
+ };
1235
+ }
1236
+
1237
+ if (this.deviceData.hksv === true) {
1238
+ controllerOptions.recording = {
1239
+ delegate: this,
1240
+ options: {
1241
+ overrideEventTriggerOptions: [this.hap.EventTriggerOption.MOTION],
1242
+ mediaContainerConfiguration: [
1243
+ {
1244
+ fragmentLength: 4000,
1245
+ type: this.hap.MediaContainerType.FRAGMENTED_MP4,
1246
+ },
1247
+ ],
1248
+ prebufferLength: 4000, // Seems to always be 4000???
1249
+ video: {
1250
+ resolutions: controllerOptions.streamingOptions.video.resolutions,
1251
+ parameters: {
1252
+ profiles: controllerOptions.streamingOptions.video.codec.profiles,
1253
+ levels: controllerOptions.streamingOptions.video.codec.levels,
1254
+ },
1255
+ type: controllerOptions.streamingOptions.video.codec.type,
1256
+ },
1257
+ audio: {
1258
+ codecs: [
1259
+ {
1260
+ type: this.hap.AudioRecordingCodecType.AAC_LC,
1261
+ samplerate: this.hap.AudioRecordingSamplerate.KHZ_16,
1262
+ audioChannel: 1,
1263
+ },
1264
+ ],
1265
+ },
1266
+ },
1267
+ };
1268
+
1269
+ controllerOptions.sensors = {
1270
+ motion: typeof this.motionServices?.[1]?.service === 'object' ? this.motionServices[1].service : false,
1271
+ };
1272
+ }
1273
+
1274
+ return controllerOptions;
1275
+ }
1276
+ }