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