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.
- package/CHANGELOG.md +27 -0
- package/README.md +31 -25
- package/config.schema.json +46 -22
- package/dist/HomeKitDevice.js +495 -262
- package/dist/HomeKitHistory.js +357 -341
- package/dist/config.js +67 -85
- package/dist/consts.js +160 -0
- package/dist/devices.js +35 -48
- package/dist/ffmpeg.js +297 -0
- package/dist/index.js +3 -3
- package/dist/nexustalk.js +157 -124
- package/dist/plugins/camera.js +1153 -924
- package/dist/plugins/doorbell.js +26 -32
- package/dist/plugins/floodlight.js +11 -24
- package/dist/plugins/heatlink.js +411 -5
- package/dist/plugins/lock.js +309 -0
- package/dist/plugins/protect.js +239 -70
- package/dist/plugins/tempsensor.js +158 -34
- package/dist/plugins/thermostat.js +890 -454
- package/dist/plugins/weather.js +128 -33
- package/dist/protobuf/nest/services/apigateway.proto +1 -1
- package/dist/protobuf/nestlabs/gateway/v2.proto +1 -1
- package/dist/protobuf/root.proto +1 -0
- package/dist/rtpmuxer.js +186 -0
- package/dist/streamer.js +486 -244
- package/dist/system.js +1739 -2852
- package/dist/utils.js +327 -0
- package/dist/webrtc.js +354 -225
- package/package.json +19 -16
package/dist/plugins/camera.js
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
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
|
-
#
|
|
51
|
+
#liveSessions = new Map(); // Track active HomeKit live stream sessions (port, crypto, rtpSplitter)
|
|
55
52
|
#recordingConfig = {}; // HomeKit Secure Video recording configuration
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
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,
|
|
61
|
-
super(accessory, api, log,
|
|
57
|
+
constructor(accessory, api, log, deviceData) {
|
|
58
|
+
super(accessory, api, log, deviceData);
|
|
62
59
|
|
|
63
|
-
// Load
|
|
64
|
-
const
|
|
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.#
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
|
244
|
+
clearTimeout(this.#snapshotTimer);
|
|
239
245
|
this.motionTimer = undefined;
|
|
240
246
|
this.personTimer = undefined;
|
|
241
|
-
this
|
|
247
|
+
this.#snapshotTimer = undefined;
|
|
248
|
+
|
|
249
|
+
// Stop all streamer logic (buffering, output, etc)
|
|
250
|
+
this.streamer?.stopEverything?.();
|
|
242
251
|
|
|
243
|
-
|
|
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,
|
|
247
|
-
this.#
|
|
248
|
-
|
|
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.#
|
|
272
|
+
this.#liveSessions = undefined;
|
|
270
273
|
this.motionServices = undefined;
|
|
271
274
|
this.streamer = undefined;
|
|
272
275
|
this.controller = undefined;
|
|
273
276
|
}
|
|
274
277
|
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
'
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
'
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
427
|
-
|
|
307
|
+
this.streamer = new WebRTC(this.uuid, deviceData, {
|
|
308
|
+
log: this.log,
|
|
309
|
+
});
|
|
428
310
|
}
|
|
429
|
-
});
|
|
430
311
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
437
|
-
|
|
317
|
+
this.streamer = new NexusTalk(this.uuid, deviceData, {
|
|
318
|
+
log: this.log,
|
|
319
|
+
});
|
|
438
320
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
//
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
482
|
-
if (
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
488
|
-
|
|
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 (
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
this
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
//
|
|
530
|
-
this
|
|
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
|
-
|
|
535
|
-
this.
|
|
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
|
-
|
|
541
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
549
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
464
|
+
// Log motion started into history
|
|
465
|
+
this.history(this.motionServices[zoneID].service, {
|
|
466
|
+
status: 1,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
});
|
|
556
470
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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.#
|
|
844
|
+
this.#cameraImages?.off !== undefined
|
|
570
845
|
) {
|
|
571
846
|
// Return 'camera switched off' jpg to image buffer
|
|
572
|
-
imageBuffer = this.#
|
|
847
|
+
imageBuffer = this.#cameraImages.off;
|
|
573
848
|
}
|
|
574
849
|
|
|
575
|
-
if (this.deviceData.migrating === false && this.deviceData.online === false && this.#
|
|
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.#
|
|
852
|
+
imageBuffer = this.#cameraImages.offline;
|
|
578
853
|
}
|
|
579
854
|
|
|
580
|
-
if (this.deviceData.migrating === true && this.#
|
|
855
|
+
if (this.deviceData.migrating === true && this.#cameraImages?.transfer !== undefined) {
|
|
581
856
|
// Return 'camera transferring' jpg to image buffer
|
|
582
|
-
imageBuffer = this.#
|
|
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
|
|
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
|
-
|
|
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 =
|
|
598
|
-
server.
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
|
687
|
-
'
|
|
688
|
-
'-
|
|
689
|
-
'
|
|
690
|
-
'-
|
|
691
|
-
'
|
|
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
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
//
|
|
812
|
-
let
|
|
1080
|
+
// Two-way audio support if enabled and codecs available
|
|
1081
|
+
let ffmpegTalk = null;
|
|
813
1082
|
if (
|
|
814
|
-
((this.streamer
|
|
815
|
-
|
|
816
|
-
|
|
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
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
840
|
-
//
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
'-
|
|
848
|
-
'-protocol_whitelist
|
|
849
|
-
'
|
|
850
|
-
'-
|
|
851
|
-
'
|
|
852
|
-
'-
|
|
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
|
-
|
|
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
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
)
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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
|
-
|
|
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 ' + (
|
|
1151
|
+
'o=- 0 0 IN ' + (session.ipv6 ? 'IP6' : 'IP4') + ' ' + session.address,
|
|
905
1152
|
's=HomeKit Audio Talkback',
|
|
906
|
-
'c=IN ' + (
|
|
1153
|
+
'c=IN ' + (session.ipv6 ? 'IP6' : 'IP4') + ' ' + session.address,
|
|
907
1154
|
't=0 0',
|
|
908
|
-
'm=audio ' +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
);
|
|
945
|
-
|
|
946
|
-
//
|
|
947
|
-
|
|
948
|
-
|
|
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 &&
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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
|
-
|
|
1307
|
-
|
|
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
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
-
|
|
1318
|
-
|
|
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
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
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
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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
|
-
|
|
1343
|
-
|
|
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
|
-
|
|
1348
|
-
}
|
|
1577
|
+
return devices;
|
|
1349
1578
|
}
|