homebridge-nest-accfactory 0.3.0 → 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 +31 -0
- package/README.md +31 -25
- package/config.schema.json +46 -22
- package/dist/HomeKitDevice.js +523 -281
- package/dist/HomeKitHistory.js +357 -341
- package/dist/config.js +69 -87
- package/dist/consts.js +160 -0
- package/dist/devices.js +40 -48
- package/dist/ffmpeg.js +297 -0
- package/dist/index.js +3 -3
- package/dist/nexustalk.js +182 -149
- package/dist/plugins/camera.js +1164 -933
- 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 +240 -71
- package/dist/plugins/tempsensor.js +159 -35
- package/dist/plugins/thermostat.js +891 -455
- 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 +490 -248
- package/dist/system.js +1741 -2868
- package/dist/utils.js +327 -0
- package/dist/webrtc.js +358 -229
- package/package.json +19 -16
package/dist/streamer.js
CHANGED
|
@@ -6,372 +6,614 @@
|
|
|
6
6
|
// Buffers a single audio/video stream which allows multiple HomeKit devices to connect to the single stream
|
|
7
7
|
// for live viewing and/or recording
|
|
8
8
|
//
|
|
9
|
-
// The following functions should be
|
|
9
|
+
// The following functions should be defined in your class which extends this
|
|
10
10
|
//
|
|
11
11
|
// streamer.connect()
|
|
12
12
|
// streamer.close()
|
|
13
|
-
// streamer.
|
|
14
|
-
// streamer.
|
|
13
|
+
// streamer.sendTalkback(talkingBuffer)
|
|
14
|
+
// streamer.onUpdate(deviceData)
|
|
15
|
+
// streamer.codecs() <- return codecs beeing used in
|
|
15
16
|
//
|
|
16
17
|
// The following defines should be overriden in your class which extends this
|
|
17
18
|
//
|
|
18
19
|
// blankAudio - Buffer containing a blank audio segment for the type of audio being used
|
|
19
20
|
//
|
|
20
|
-
// Code version 2025.
|
|
21
|
+
// Code version 2025.07.26
|
|
21
22
|
// Mark Hulskamp
|
|
22
23
|
'use strict';
|
|
23
24
|
|
|
24
25
|
// Define nodejs module requirements
|
|
25
26
|
import { Buffer } from 'node:buffer';
|
|
26
|
-
import { setInterval, setTimeout, clearTimeout } from 'node:timers';
|
|
27
|
+
import { setInterval, clearInterval, setTimeout, clearTimeout } from 'node:timers';
|
|
27
28
|
import fs from 'fs';
|
|
28
29
|
import path from 'node:path';
|
|
29
|
-
import {
|
|
30
|
+
import { PassThrough } from 'stream';
|
|
31
|
+
|
|
32
|
+
// Define our modules
|
|
33
|
+
import HomeKitDevice from './HomeKitDevice.js';
|
|
30
34
|
|
|
31
35
|
// Define constants
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
};
|
|
37
|
-
const TALKBACKAUDIOTIMEOUT = 1000;
|
|
38
|
-
const H264NALSTARTCODE = Buffer.from([0x00, 0x00, 0x00, 0x01]);
|
|
39
|
-
const MAXBUFFERAGE = 5000; // Keep last 5s of media in buffer
|
|
40
|
-
const STREAMFRAMEINTERVAL = 1000 / 30; // 30fps approx
|
|
41
|
-
const RESOURCEPATH = './res';
|
|
42
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a defined for JS __dirname
|
|
43
|
-
const LOGLEVELS = {
|
|
44
|
-
info: 'info',
|
|
45
|
-
success: 'success',
|
|
46
|
-
warn: 'warn',
|
|
47
|
-
error: 'error',
|
|
48
|
-
debug: 'debug',
|
|
49
|
-
};
|
|
36
|
+
import { TIMERS, RESOURCE_FRAMES, RESOURCE_PATH, LOG_LEVELS, __dirname } from './consts.js';
|
|
37
|
+
|
|
38
|
+
const MAX_BUFFER_AGE = 5000; // Keep last 5s of media in buffer
|
|
39
|
+
const STREAM_FRAME_INTERVAL = 1000 / 30; // 30fps approx
|
|
50
40
|
|
|
51
41
|
// Streamer object
|
|
52
42
|
export default class Streamer {
|
|
43
|
+
static H264NALUS = {
|
|
44
|
+
START_CODE: Buffer.from([0x00, 0x00, 0x00, 0x01]),
|
|
45
|
+
TYPES: {
|
|
46
|
+
SLICE_NON_IDR: 1,
|
|
47
|
+
SLICE_PART_A: 2,
|
|
48
|
+
SLICE_PART_B: 3,
|
|
49
|
+
SLICE_PART_C: 4,
|
|
50
|
+
IDR: 5, // Instantaneous Decoder Refresh
|
|
51
|
+
SEI: 6,
|
|
52
|
+
SPS: 7,
|
|
53
|
+
PPS: 8,
|
|
54
|
+
AUD: 9,
|
|
55
|
+
END_SEQUENCE: 10,
|
|
56
|
+
END_STREAM: 11,
|
|
57
|
+
STAP_A: 24,
|
|
58
|
+
FU_A: 28,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
static STREAM_TYPE = {
|
|
63
|
+
LIVE: 'live',
|
|
64
|
+
RECORD: 'record',
|
|
65
|
+
BUFFER: 'buffer',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
static PACKET_TYPE = {
|
|
69
|
+
VIDEO: 'video',
|
|
70
|
+
AUDIO: 'audio',
|
|
71
|
+
TALK: 'talk',
|
|
72
|
+
METADATA: 'meta',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
static CODEC_TYPE = {
|
|
76
|
+
H264: 'h264',
|
|
77
|
+
AAC: 'aac',
|
|
78
|
+
OPUS: 'opus',
|
|
79
|
+
PCM: 'pcm',
|
|
80
|
+
SPEEX: 'speex',
|
|
81
|
+
META: 'meta',
|
|
82
|
+
UNKNOWN: 'undefined',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
static MESSAGE = 'Streamer.onMessage'; // Message type for HomeKitDevice to listen for
|
|
86
|
+
|
|
87
|
+
static MESSAGE_TYPE = {
|
|
88
|
+
START_LIVE: 'start-live',
|
|
89
|
+
STOP_LIVE: 'stop-live',
|
|
90
|
+
START_RECORD: 'start-record',
|
|
91
|
+
STOP_RECORD: 'stop-record',
|
|
92
|
+
START_BUFFER: 'start-buffer',
|
|
93
|
+
STOP_BUFFER: 'stop-buffer',
|
|
94
|
+
};
|
|
95
|
+
|
|
53
96
|
log = undefined; // Logging function object
|
|
54
97
|
videoEnabled = undefined; // Video stream on camera enabled or not
|
|
55
98
|
audioEnabled = undefined; // Audio from camera enabled or not
|
|
56
99
|
online = undefined; // Camera online or not
|
|
57
|
-
|
|
100
|
+
nest_google_uuid = undefined; // Nest/Google UUID of the device connecting
|
|
58
101
|
connected = undefined; // Stream endpoint connection: undefined = not connected , false = connecting , true = connected and streaming
|
|
59
102
|
blankAudio = undefined; // Blank audio 'frame'
|
|
60
|
-
codecs = {
|
|
61
|
-
video: undefined, // Video codec being used
|
|
62
|
-
audio: undefined, // Audio codec being used
|
|
63
|
-
talk: undefined, // Talking codec being used
|
|
64
|
-
};
|
|
65
103
|
|
|
66
104
|
// Internal data only for this class
|
|
67
105
|
#outputTimer = undefined; // Timer for non-blocking loop to stream output data
|
|
68
106
|
#outputs = {}; // Output streams ie: buffer, live, record
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
107
|
+
#cameraFrames = {}; // H264 resource frames for offline, video off, transferring
|
|
108
|
+
#sequenceCounters = {}; // Sequence counters for packet types
|
|
109
|
+
#h264Video = {}; // H264 video state for SPS/PPS and IDR frames
|
|
110
|
+
|
|
111
|
+
// Codecs being used for video, audio and talking
|
|
112
|
+
get codecs() {
|
|
113
|
+
return {
|
|
114
|
+
video: undefined,
|
|
115
|
+
audio: undefined,
|
|
116
|
+
talkback: undefined,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
72
119
|
|
|
73
|
-
constructor(deviceData, options) {
|
|
120
|
+
constructor(uuid, deviceData, options) {
|
|
74
121
|
// Setup logger object if passed as option
|
|
75
|
-
if (Object.
|
|
122
|
+
if (Object.values(LOG_LEVELS).every((fn) => typeof options?.log?.[fn] === 'function')) {
|
|
76
123
|
this.log = options.log;
|
|
77
124
|
}
|
|
78
125
|
|
|
126
|
+
// Setup HomeKitDevicee message type handler back to HomeKitDevice classes
|
|
127
|
+
HomeKitDevice.message(uuid, Streamer.MESSAGE, this);
|
|
128
|
+
HomeKitDevice.message(uuid, HomeKitDevice.UPDATE, this); // Register for 'update' message for this uuid also
|
|
129
|
+
HomeKitDevice.message(uuid, HomeKitDevice.SHUTDOWN, this); // Register for 'shutdown' message for this uuid also
|
|
130
|
+
|
|
79
131
|
// Store data we need from the device data passed it
|
|
80
132
|
this.migrating = deviceData?.migrating === true;
|
|
81
133
|
this.online = deviceData?.online === true;
|
|
82
134
|
this.videoEnabled = deviceData?.streaming_enabled === true;
|
|
83
135
|
this.audioEnabled = deviceData?.audio_enabled === true;
|
|
84
|
-
this.
|
|
136
|
+
this.nest_google_uuid = deviceData?.nest_google_uuid;
|
|
85
137
|
|
|
86
138
|
// Load support video frame files as required
|
|
87
|
-
const
|
|
139
|
+
const loadFrameResource = (filename, label) => {
|
|
88
140
|
let buffer = undefined;
|
|
89
|
-
let file = path.resolve(__dirname,
|
|
141
|
+
let file = path.resolve(__dirname, RESOURCE_PATH, filename);
|
|
142
|
+
|
|
90
143
|
if (fs.existsSync(file) === true) {
|
|
91
144
|
buffer = fs.readFileSync(file);
|
|
145
|
+
if (buffer.indexOf(Streamer.H264NALUS.START_CODE) === 0) {
|
|
146
|
+
buffer = buffer.subarray(Streamer.H264NALUS.START_CODE.length);
|
|
147
|
+
}
|
|
92
148
|
} else {
|
|
93
149
|
this.log?.warn?.('Failed to load %s video resource for "%s"', label, deviceData.description);
|
|
94
150
|
}
|
|
151
|
+
|
|
95
152
|
return buffer;
|
|
96
153
|
};
|
|
97
154
|
|
|
98
|
-
this.#
|
|
99
|
-
|
|
100
|
-
|
|
155
|
+
this.#cameraFrames = {
|
|
156
|
+
offline: loadFrameResource(RESOURCE_FRAMES.CAMERA_OFFLINE, 'offline'),
|
|
157
|
+
off: loadFrameResource(RESOURCE_FRAMES.CAMERA_OFF, 'video off'),
|
|
158
|
+
transfer: loadFrameResource(RESOURCE_FRAMES.CAMERA_TRANSFER, 'transferring'),
|
|
159
|
+
};
|
|
101
160
|
|
|
102
|
-
// Start
|
|
103
|
-
|
|
104
|
-
// Record streams will always start from the beginning of the buffer (tail)
|
|
105
|
-
// Live streams will always start from the end of the buffer (head)
|
|
106
|
-
let lastVideoFrameTime = Date.now();
|
|
107
|
-
this.#outputTimer = setInterval(() => {
|
|
108
|
-
let dateNow = Date.now();
|
|
109
|
-
let outputVideoFrame = dateNow - lastVideoFrameTime >= STREAMFRAMEINTERVAL;
|
|
110
|
-
Object.values(this.#outputs).forEach((output) => {
|
|
111
|
-
// Monitor for camera going offline, video enabled/disabled or being transferred between Nest/Google Home
|
|
112
|
-
// We'll insert the appropriate video frame into the stream
|
|
113
|
-
if (this.online === false && this.#cameraOfflineFrame !== undefined && outputVideoFrame === true) {
|
|
114
|
-
// Camera is offline so feed in our custom h264 frame and AAC silence
|
|
115
|
-
output.buffer.push({ time: dateNow, type: 'video', data: this.#cameraOfflineFrame });
|
|
116
|
-
output.buffer.push({ time: dateNow, type: 'audio', data: this.blankAudio });
|
|
117
|
-
lastVideoFrameTime = dateNow;
|
|
118
|
-
}
|
|
119
|
-
if (this.online === true && this.videoEnabled === false && this.#cameraVideoOffFrame !== undefined && outputVideoFrame === true) {
|
|
120
|
-
// Camera video is turned off so feed in our custom h264 frame and AAC silence
|
|
121
|
-
output.buffer.push({ time: dateNow, type: 'video', data: this.#cameraVideoOffFrame });
|
|
122
|
-
output.buffer.push({ time: dateNow, type: 'audio', data: this.blankAudio });
|
|
123
|
-
lastVideoFrameTime = dateNow;
|
|
124
|
-
}
|
|
125
|
-
if (this.migrating === true && this.#cameraTransferringFrame !== undefined && outputVideoFrame === true) {
|
|
126
|
-
// Camera video is turned off so feed in our custom h264 frame and AAC silence
|
|
127
|
-
output.buffer.push({ time: dateNow, type: 'video', data: this.#cameraTransferringFrame });
|
|
128
|
-
output.buffer.push({ time: dateNow, type: 'audio', data: this.blankAudio });
|
|
129
|
-
lastVideoFrameTime = dateNow;
|
|
130
|
-
}
|
|
161
|
+
this.#outputLoop(); // Start the output loop to process media packets
|
|
162
|
+
}
|
|
131
163
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
output.buffer.shift();
|
|
138
|
-
}
|
|
139
|
-
}
|
|
164
|
+
// Class functions
|
|
165
|
+
async onUpdate(deviceData) {
|
|
166
|
+
if (typeof deviceData !== 'object') {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
140
169
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
170
|
+
if (deviceData?.migrating !== undefined && this.migrating !== deviceData?.migrating) {
|
|
171
|
+
// Migration status has changed
|
|
172
|
+
this.migrating = deviceData.migrating;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (deviceData?.nest_google_uuid !== undefined && this.nest_google_uuid !== deviceData?.nest_google_uuid) {
|
|
176
|
+
this.nest_google_uuid = deviceData?.nest_google_uuid;
|
|
177
|
+
|
|
178
|
+
if (this.isStreaming() === true || this.isBuffering() === true) {
|
|
179
|
+
// Since the Nest/Google device uuid has changed if there any any active outputs, close and connect again
|
|
180
|
+
// This may occur if a device has migrated between Nest and Google APIs
|
|
181
|
+
this.#doClose();
|
|
182
|
+
this.#doConnect();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (
|
|
187
|
+
(deviceData?.online !== undefined && this.online !== deviceData.online) ||
|
|
188
|
+
(deviceData?.streaming_enabled !== undefined && this.videoEnabled !== deviceData.streaming_enabled) ||
|
|
189
|
+
(deviceData?.audio_enabled !== undefined && this.audioEnabled !== deviceData.audio_enabled)
|
|
190
|
+
) {
|
|
191
|
+
// Online status or streaming status has changed
|
|
192
|
+
if (deviceData?.online !== undefined) {
|
|
193
|
+
this.online = deviceData.online === true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (deviceData?.streaming_enabled !== undefined) {
|
|
197
|
+
this.videoEnabled = deviceData.streaming_enabled === true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (deviceData?.audio_enabled !== undefined) {
|
|
201
|
+
this.audioEnabled = deviceData.audio_enabled === true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (this.isStreaming() === true || this.isBuffering() === true) {
|
|
205
|
+
// Since online, video, audio enabled status has changed, if there any any active outputs, close and connect again
|
|
206
|
+
if (this.online === false || this.videoEnabled === false || this.audioEnabled === false) {
|
|
207
|
+
this.#doClose(); // as offline or streaming not enabled, close streamer
|
|
151
208
|
}
|
|
152
|
-
|
|
153
|
-
|
|
209
|
+
this.#doConnect();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
154
212
|
}
|
|
155
213
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
214
|
+
async onMessage(type, message) {
|
|
215
|
+
if (typeof type !== 'string' || type === '') {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Ensure a message with sessionID is always a string
|
|
220
|
+
let sessionID = message?.sessionID !== undefined ? String(message.sessionID) : undefined;
|
|
221
|
+
|
|
222
|
+
if (type === Streamer.MESSAGE_TYPE.START_BUFFER) {
|
|
223
|
+
// Start buffering media packets
|
|
224
|
+
await this.#startBuffering();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (type === Streamer.MESSAGE_TYPE.STOP_BUFFER) {
|
|
228
|
+
// Stop buffering media packets
|
|
229
|
+
await this.#stopBuffering();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (type === Streamer.MESSAGE_TYPE.START_LIVE) {
|
|
233
|
+
// Start live HomeKit stream
|
|
234
|
+
return await this.#startLiveStream(sessionID);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (type === Streamer.MESSAGE_TYPE.STOP_LIVE) {
|
|
238
|
+
// Stop live stream
|
|
239
|
+
await this.#stopLiveStream(sessionID);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (type === Streamer.MESSAGE_TYPE.START_RECORD) {
|
|
243
|
+
// Start recording HomeKit stream
|
|
244
|
+
return await this.#startRecording(sessionID);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (type === Streamer.MESSAGE_TYPE.STOP_RECORD) {
|
|
248
|
+
// Stop recording stream
|
|
249
|
+
await this.#stopRecording(sessionID);
|
|
250
|
+
}
|
|
159
251
|
}
|
|
160
252
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
this?.log?.debug?.('Started buffering for uuid "%s"', this.uuid);
|
|
166
|
-
this.connect();
|
|
167
|
-
}
|
|
253
|
+
onShutdown() {
|
|
254
|
+
clearInterval(this.#outputTimer);
|
|
255
|
+
this.stopEverything();
|
|
256
|
+
}
|
|
168
257
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
};
|
|
258
|
+
stopEverything() {
|
|
259
|
+
if (this.isStreaming() === true || this.isBuffering() === true) {
|
|
260
|
+
this?.log?.debug?.('Stopped buffering, live and recording from device uuid "%s"', this.nest_google_uuid);
|
|
261
|
+
this.#outputs = {}; // Remove all outputs (live, record, buffer)
|
|
262
|
+
this.#sequenceCounters = {}; // Reset sequence tracking
|
|
263
|
+
this.#h264Video = {}; // Reset cached SPS/PPS and keyframe flag
|
|
264
|
+
this.#doClose(); // Trigger subclass-defined stream close logic
|
|
173
265
|
}
|
|
174
266
|
}
|
|
175
267
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
videoStream.on('error', () => {
|
|
180
|
-
// EPIPE errors??
|
|
181
|
-
});
|
|
268
|
+
add(packetType, data, timestamp = Date.now(), sequence = undefined) {
|
|
269
|
+
if (typeof packetType !== 'string' || packetType === '' || Buffer.isBuffer(data) !== true) {
|
|
270
|
+
return;
|
|
182
271
|
}
|
|
183
272
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
});
|
|
273
|
+
packetType = packetType.toLowerCase();
|
|
274
|
+
if (Streamer.PACKET_TYPE?.[packetType.toUpperCase()] === undefined) {
|
|
275
|
+
return;
|
|
188
276
|
}
|
|
189
277
|
|
|
190
|
-
if (
|
|
191
|
-
|
|
278
|
+
if (this.#sequenceCounters?.[packetType] === undefined) {
|
|
279
|
+
this.#sequenceCounters[packetType] = 0;
|
|
280
|
+
}
|
|
192
281
|
|
|
193
|
-
|
|
194
|
-
// EPIPE errors??
|
|
195
|
-
});
|
|
282
|
+
let seq = typeof sequence === 'number' ? sequence : this.#sequenceCounters[packetType]++;
|
|
196
283
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
284
|
+
// H264-specific NALU validation
|
|
285
|
+
if (packetType === Streamer.PACKET_TYPE.VIDEO && this.codecs?.video === Streamer.CODEC_TYPE.H264) {
|
|
286
|
+
// Strip start code if present (0x00 00 00 01)
|
|
287
|
+
if (data.indexOf(Streamer.H264NALUS.START_CODE) === 0) {
|
|
288
|
+
data = data.subarray(Streamer.H264NALUS.START_CODE.length);
|
|
289
|
+
}
|
|
201
290
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
291
|
+
if (data.length < 1) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let naluType = data[0] & 0x1f;
|
|
296
|
+
if (naluType === 0 || naluType > 31) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const insertSortedByTime = (buffer, packet) => {
|
|
302
|
+
let low = 0;
|
|
303
|
+
let high = buffer.length;
|
|
304
|
+
while (low < high) {
|
|
305
|
+
let mid = (low + high) >>> 1;
|
|
306
|
+
if (packet.time < buffer[mid].time) {
|
|
307
|
+
high = mid;
|
|
308
|
+
} else {
|
|
309
|
+
low = mid + 1;
|
|
207
310
|
}
|
|
311
|
+
}
|
|
312
|
+
buffer.splice(low, 0, packet);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// Add new packet to the shared buffer first (__BUFFER)
|
|
316
|
+
if (Array.isArray(this.#outputs?.__BUFFER?.buffer) === true) {
|
|
317
|
+
insertSortedByTime(this.#outputs?.__BUFFER?.buffer, {
|
|
318
|
+
type: packetType,
|
|
319
|
+
data: data,
|
|
320
|
+
time: timestamp,
|
|
321
|
+
sequence: seq,
|
|
208
322
|
});
|
|
209
323
|
}
|
|
210
324
|
|
|
211
|
-
//
|
|
212
|
-
this.#
|
|
325
|
+
// Distribute packet to other outputs
|
|
326
|
+
for (const session of Object.values(this.#outputs)) {
|
|
327
|
+
if (session.type === Streamer.STREAM_TYPE.BUFFER) {
|
|
328
|
+
continue; // already handled __BUFFER above
|
|
329
|
+
}
|
|
213
330
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
talk: talkbackStream,
|
|
220
|
-
buffer: [],
|
|
221
|
-
};
|
|
331
|
+
// Lazily initialize RECORD stream buffer from shared __BUFFER
|
|
332
|
+
if (session.buffer === undefined && session.type === Streamer.STREAM_TYPE.RECORD) {
|
|
333
|
+
session.buffer = Array.isArray(this.#outputs?.__BUFFER?.buffer) === true ? structuredClone(this.#outputs.__BUFFER.buffer) : [];
|
|
334
|
+
continue; // buffer already includes current packet via clone
|
|
335
|
+
}
|
|
222
336
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
this.uuid,
|
|
227
|
-
talkbackStream !== null && typeof talkbackStream === 'object' ? 'with two-way audio and session id of' : 'and session id of',
|
|
228
|
-
sessionID,
|
|
229
|
-
);
|
|
230
|
-
}
|
|
337
|
+
if (Array.isArray(session.buffer) === false) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
231
340
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
341
|
+
// Insert new packet maintaining order
|
|
342
|
+
insertSortedByTime(session.buffer, {
|
|
343
|
+
type: packetType,
|
|
344
|
+
data: data,
|
|
345
|
+
time: timestamp,
|
|
346
|
+
sequence: seq,
|
|
237
347
|
});
|
|
348
|
+
|
|
349
|
+
// Trim old packets based on age
|
|
350
|
+
let cutoff = Date.now() - MAX_BUFFER_AGE;
|
|
351
|
+
while (session.buffer.length > 0 && session.buffer[0].time < cutoff) {
|
|
352
|
+
session.buffer.shift();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Cap total size
|
|
356
|
+
if (session.buffer.length > 200) {
|
|
357
|
+
session.buffer.splice(0, session.buffer.length - 200);
|
|
358
|
+
}
|
|
238
359
|
}
|
|
360
|
+
}
|
|
239
361
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
362
|
+
#startBuffering() {
|
|
363
|
+
if (this.#outputs?.__BUFFER === undefined) {
|
|
364
|
+
this.#outputs.__BUFFER = {
|
|
365
|
+
type: Streamer.STREAM_TYPE.BUFFER,
|
|
366
|
+
buffer: [],
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
this.log?.debug?.('Started buffering from device uuid "%s"', this.nest_google_uuid);
|
|
370
|
+
|
|
371
|
+
if (this.connected !== true) {
|
|
372
|
+
this.#doConnect();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#stopBuffering() {
|
|
378
|
+
if (this.#outputs?.__BUFFER !== undefined) {
|
|
379
|
+
delete this.#outputs.__BUFFER;
|
|
380
|
+
this.log?.debug?.('Stopped buffering from device uuid "%s"', this.nest_google_uuid);
|
|
381
|
+
|
|
382
|
+
// If we have no more output streams active, we'll close the connection
|
|
383
|
+
if (this.isStreaming() === false) {
|
|
384
|
+
this.#doClose();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#startLiveStream(sessionID) {
|
|
390
|
+
if (typeof sessionID !== 'string' || sessionID === '') {
|
|
391
|
+
return;
|
|
244
392
|
}
|
|
245
393
|
|
|
246
|
-
|
|
394
|
+
if (this.#outputs?.[sessionID] !== undefined) {
|
|
395
|
+
this?.log?.warn?.('Live stream already exists for uuid "%s" and session id "%s"', this.nest_google_uuid, sessionID);
|
|
396
|
+
return {
|
|
397
|
+
video: this.#outputs[sessionID].video,
|
|
398
|
+
audio: this.#outputs[sessionID].audio,
|
|
399
|
+
talkback: this.#outputs[sessionID].talkback,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let videoOut = new PassThrough(); // Streamer writes video here
|
|
404
|
+
let audioOut = new PassThrough(); // Streamer writes audio here
|
|
405
|
+
let talkbackIn = new PassThrough({ highWaterMark: 1024 * 16 }); // ffmpeg writes talkback here
|
|
406
|
+
|
|
407
|
+
// eslint-disable-next-line no-unused-vars
|
|
408
|
+
videoOut?.on?.('error', (error) => {});
|
|
409
|
+
// eslint-disable-next-line no-unused-vars
|
|
410
|
+
audioOut?.on?.('error', (error) => {});
|
|
411
|
+
// eslint-disable-next-line no-unused-vars
|
|
412
|
+
talkbackIn?.on?.('error', (error) => {});
|
|
413
|
+
|
|
414
|
+
// Setup talkback handler
|
|
415
|
+
talkbackIn?.on?.('data', (data) => {
|
|
416
|
+
// Received audio data to send onto camera/doorbell for output
|
|
417
|
+
if (typeof this?.sendTalkback === 'function') {
|
|
418
|
+
this.sendTalkback(data);
|
|
419
|
+
|
|
420
|
+
clearTimeout(this.#outputs?.[sessionID]?.talkbackTimeout);
|
|
421
|
+
this.#outputs[sessionID].talkbackTimeout = setTimeout(() => {
|
|
422
|
+
// no audio received in 1000ms, so mark end of stream
|
|
423
|
+
this.sendTalkback(Buffer.alloc(0));
|
|
424
|
+
}, TIMERS.TALKBACK_AUDIO);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
talkbackIn?.on?.('close', () => {
|
|
428
|
+
clearTimeout(this.#outputs?.[sessionID]?.talkbackTimeout);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// Ensure upstream connection is active
|
|
247
432
|
this.#doConnect();
|
|
248
433
|
|
|
249
|
-
// Add video/audio streams for our output loop to handle outputting
|
|
250
434
|
this.#outputs[sessionID] = {
|
|
251
|
-
type:
|
|
252
|
-
video:
|
|
253
|
-
audio:
|
|
254
|
-
|
|
255
|
-
|
|
435
|
+
type: Streamer.STREAM_TYPE.LIVE,
|
|
436
|
+
video: videoOut,
|
|
437
|
+
audio: audioOut,
|
|
438
|
+
talkback: talkbackIn,
|
|
439
|
+
talkbackTimeout: undefined,
|
|
440
|
+
buffer: [],
|
|
256
441
|
};
|
|
257
442
|
|
|
258
|
-
|
|
259
|
-
|
|
443
|
+
this?.log?.debug?.('Started live stream from device uuid "%s" and session id "%s"', this.nest_google_uuid, sessionID);
|
|
444
|
+
|
|
445
|
+
return { video: videoOut, audio: audioOut, talkback: talkbackIn };
|
|
260
446
|
}
|
|
261
447
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
if (
|
|
265
|
-
this?.log?.debug?.('Stopped
|
|
448
|
+
#stopLiveStream(sessionID) {
|
|
449
|
+
let output = this.#outputs?.[sessionID];
|
|
450
|
+
if (output !== undefined) {
|
|
451
|
+
this?.log?.debug?.('Stopped live stream from device uuid "%s" and session id "%s"', this.nest_google_uuid, sessionID);
|
|
452
|
+
|
|
453
|
+
// Gracefully end output streams
|
|
454
|
+
output.video?.end?.(); // Video output stream
|
|
455
|
+
output.audio?.end?.(); // Audio output stream
|
|
456
|
+
output.talkback?.end?.(); // Talkback input stream
|
|
457
|
+
clearTimeout(output?.talkbackTimeout);
|
|
266
458
|
delete this.#outputs[sessionID];
|
|
267
459
|
}
|
|
268
460
|
|
|
269
|
-
// If
|
|
270
|
-
if (this.
|
|
271
|
-
this
|
|
461
|
+
// If no more active streams, close the upstream connection
|
|
462
|
+
if (this.isStreaming() === false && this.isBuffering() === false) {
|
|
463
|
+
this.#doClose();
|
|
272
464
|
}
|
|
273
465
|
}
|
|
274
466
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
this?.log?.debug?.('Stopped live stream from uuid "%s"', this.uuid);
|
|
279
|
-
delete this.#outputs[sessionID];
|
|
467
|
+
#startRecording(sessionID) {
|
|
468
|
+
if (typeof sessionID !== 'string' || sessionID === '') {
|
|
469
|
+
return;
|
|
280
470
|
}
|
|
281
471
|
|
|
282
|
-
//
|
|
283
|
-
if (this
|
|
284
|
-
this.
|
|
472
|
+
// Prevent duplicate recording sessions
|
|
473
|
+
if (this.#outputs?.[sessionID] !== undefined) {
|
|
474
|
+
this?.log?.warn?.('Recording stream already exists for uuid "%s" and session id "%s"', this.nest_google_uuid, sessionID);
|
|
475
|
+
return {
|
|
476
|
+
video: this.#outputs[sessionID].video,
|
|
477
|
+
audio: this.#outputs[sessionID].audio,
|
|
478
|
+
};
|
|
285
479
|
}
|
|
480
|
+
|
|
481
|
+
// Create stream outputs for ffmpeg to consume
|
|
482
|
+
let videoOut = new PassThrough(); // Streamer writes video here
|
|
483
|
+
let audioOut = new PassThrough(); // Streamer writes audio here
|
|
484
|
+
|
|
485
|
+
// eslint-disable-next-line no-unused-vars
|
|
486
|
+
videoOut?.on?.('error', (error) => {});
|
|
487
|
+
// eslint-disable-next-line no-unused-vars
|
|
488
|
+
audioOut?.on?.('error', (error) => {});
|
|
489
|
+
|
|
490
|
+
// Ensure upstream connection is active
|
|
491
|
+
this.#doConnect();
|
|
492
|
+
|
|
493
|
+
// Register recording session
|
|
494
|
+
this.#outputs[sessionID] = {
|
|
495
|
+
type: Streamer.STREAM_TYPE.RECORD,
|
|
496
|
+
video: videoOut,
|
|
497
|
+
audio: audioOut,
|
|
498
|
+
buffer: undefined,
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
this?.log?.debug?.('Started recording stream from device uuid "%s" with session id of "%s"', this.nest_google_uuid, sessionID);
|
|
502
|
+
|
|
503
|
+
// Return stream objects for ffmpeg to consume
|
|
504
|
+
return { video: videoOut, audio: audioOut };
|
|
286
505
|
}
|
|
287
506
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
507
|
+
#stopRecording(sessionID) {
|
|
508
|
+
let output = this.#outputs?.[sessionID];
|
|
509
|
+
if (output !== undefined) {
|
|
510
|
+
this?.log?.debug?.('Stopped recording stream from device uuid "%s"', this.nest_google_uuid);
|
|
511
|
+
|
|
512
|
+
// Gracefully end output streams
|
|
513
|
+
output.video?.end?.(); // Video output stream
|
|
514
|
+
output.audio?.end?.(); // Audio output stream
|
|
515
|
+
delete this.#outputs[sessionID];
|
|
292
516
|
}
|
|
293
517
|
|
|
294
518
|
// If we have no more output streams active, we'll close the connection
|
|
295
|
-
if (this.
|
|
296
|
-
this
|
|
519
|
+
if (this.isStreaming() === false && this.isBuffering() === false) {
|
|
520
|
+
this.#doClose();
|
|
297
521
|
}
|
|
298
522
|
}
|
|
299
523
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
this?.log?.debug?.('Stopped buffering, live and recording from uuid "%s"', this.uuid);
|
|
303
|
-
this.#outputs = {}; // No more outputs
|
|
304
|
-
if (typeof this.close === 'function') {
|
|
305
|
-
this.close();
|
|
306
|
-
}
|
|
307
|
-
}
|
|
524
|
+
isBuffering() {
|
|
525
|
+
return this.#outputs?.__BUFFER !== undefined;
|
|
308
526
|
}
|
|
309
527
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
}
|
|
528
|
+
isStreaming() {
|
|
529
|
+
return Object.values(this.#outputs).some((x) => x?.type === Streamer.STREAM_TYPE.LIVE || x?.type === Streamer.STREAM_TYPE.RECORD);
|
|
530
|
+
}
|
|
314
531
|
|
|
315
|
-
|
|
532
|
+
isRecording() {
|
|
533
|
+
return Object.values(this.#outputs).some((x) => x?.type === Streamer.STREAM_TYPE.RECORD);
|
|
534
|
+
}
|
|
316
535
|
|
|
317
|
-
|
|
318
|
-
|
|
536
|
+
isLiveStreaming() {
|
|
537
|
+
return Object.values(this.#outputs).some((x) => x?.type === Streamer.STREAM_TYPE.LIVE);
|
|
538
|
+
}
|
|
319
539
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
this.close();
|
|
324
|
-
}
|
|
325
|
-
this.#doConnect();
|
|
326
|
-
}
|
|
540
|
+
async #doConnect() {
|
|
541
|
+
if (this.online === true && this.videoEnabled === true && this.connected === undefined) {
|
|
542
|
+
await this?.connect?.();
|
|
327
543
|
}
|
|
544
|
+
}
|
|
328
545
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
this.videoEnabled !== deviceData.streaming_enabled ||
|
|
332
|
-
this.audioEnabled !== deviceData?.audio_enabled
|
|
333
|
-
) {
|
|
334
|
-
// Online status or streaming status has changed
|
|
335
|
-
this.online = deviceData?.online === true;
|
|
336
|
-
this.videoEnabled = deviceData?.streaming_enabled === true;
|
|
337
|
-
this.audioEnabled = deviceData?.audio_enabled === true;
|
|
338
|
-
|
|
339
|
-
if (this.haveOutputs() === true) {
|
|
340
|
-
// Since online, video, audio enabled status has changed, if there any any active outputs, close and connect again
|
|
341
|
-
if ((this.online === false || this.videoEnabled === false || this.audioEnabled === false) && typeof this.close === 'function') {
|
|
342
|
-
this.close(); // as offline or streaming not enabled, close streamer
|
|
343
|
-
}
|
|
344
|
-
this.#doConnect();
|
|
345
|
-
}
|
|
346
|
-
}
|
|
546
|
+
async #doClose() {
|
|
547
|
+
await this?.close?.();
|
|
347
548
|
}
|
|
348
549
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
550
|
+
#outputLoop() {
|
|
551
|
+
// Start a non-blocking loop for output to the various streams which connect to our streamer object
|
|
552
|
+
// This process will also handle the rolling-buffer size we require
|
|
553
|
+
let lastFallbackFrameTime = Date.now();
|
|
554
|
+
this.#outputTimer = setInterval(() => {
|
|
555
|
+
let dateNow = Date.now();
|
|
353
556
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
557
|
+
for (const session of Object.values(this.#outputs)) {
|
|
558
|
+
// Keep our 'main' rolling buffer under a certain size
|
|
559
|
+
// Live/record buffers will always reduce in length in the next section
|
|
560
|
+
if (session.type === Streamer.STREAM_TYPE.BUFFER) {
|
|
561
|
+
let cutoffTime = dateNow - MAX_BUFFER_AGE;
|
|
562
|
+
while (session.buffer.length > 0 && session.buffer[0].time < cutoffTime) {
|
|
563
|
+
session.buffer.shift();
|
|
564
|
+
}
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
358
567
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
568
|
+
// Output fallback frame directly for offline, video disabled, or migrating
|
|
569
|
+
// This is required as the streamer may be disconnected or has no incoming packets
|
|
570
|
+
// We will pace this at ~30fps (every STREAM_FRAME_INTERVAL)
|
|
571
|
+
if (dateNow - lastFallbackFrameTime >= STREAM_FRAME_INTERVAL) {
|
|
572
|
+
let fallbackFrame =
|
|
573
|
+
this.online === false && this.#cameraFrames?.offline
|
|
574
|
+
? this.#cameraFrames.offline
|
|
575
|
+
: this.online === true && this.videoEnabled === false && this.#cameraFrames?.off
|
|
576
|
+
? this.#cameraFrames.off
|
|
577
|
+
: this.migrating === true && this.#cameraFrames?.transfer
|
|
578
|
+
? this.#cameraFrames.transfer
|
|
579
|
+
: undefined;
|
|
580
|
+
|
|
581
|
+
if (Buffer.isBuffer(fallbackFrame) === true) {
|
|
582
|
+
if (this.codecs?.video === Streamer.CODEC_TYPE.H264) {
|
|
583
|
+
session?.video?.write?.(Streamer.H264NALUS.START_CODE);
|
|
584
|
+
}
|
|
585
|
+
session?.video?.write?.(fallbackFrame);
|
|
586
|
+
session?.audio?.write?.(this.blankAudio);
|
|
587
|
+
lastFallbackFrameTime = dateNow;
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
367
591
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
592
|
+
// Normal buffered video output using actual packet timestamps
|
|
593
|
+
if (this.connected === true && (session.type === Streamer.STREAM_TYPE.LIVE || session.type === Streamer.STREAM_TYPE.RECORD)) {
|
|
594
|
+
while (
|
|
595
|
+
session?.buffer?.length > 0 &&
|
|
596
|
+
session.buffer[0].type === Streamer.PACKET_TYPE.VIDEO &&
|
|
597
|
+
session.buffer[0].time <= dateNow
|
|
598
|
+
) {
|
|
599
|
+
let packet = session.buffer.shift();
|
|
600
|
+
if (this.codecs?.video === Streamer.CODEC_TYPE.H264) {
|
|
601
|
+
session?.video?.write?.(Streamer.H264NALUS.START_CODE);
|
|
602
|
+
}
|
|
603
|
+
session?.video?.write?.(packet.data);
|
|
604
|
+
}
|
|
371
605
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
606
|
+
// Output any available audio packets immediately (based on timestamp)
|
|
607
|
+
while (
|
|
608
|
+
session?.buffer?.length > 0 &&
|
|
609
|
+
session.buffer[0].type === Streamer.PACKET_TYPE.AUDIO &&
|
|
610
|
+
session.buffer[0].time <= dateNow
|
|
611
|
+
) {
|
|
612
|
+
let packet = session.buffer.shift();
|
|
613
|
+
session?.audio?.write?.(packet.data);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}, 5); // Every 5ms
|
|
376
618
|
}
|
|
377
619
|
}
|