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/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 overriden in your class which extends this
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.talkingAudio(talkingData)
14
- // streamer.update(deviceData) <- call super after
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.06.12
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 { fileURLToPath } from 'node:url';
30
+ import { PassThrough } from 'stream';
31
+
32
+ // Define our modules
33
+ import HomeKitDevice from './HomeKitDevice.js';
30
34
 
31
35
  // Define constants
32
- const CAMERARESOURCE = {
33
- offline: 'Nest_camera_offline.h264',
34
- off: 'Nest_camera_off.h264',
35
- transfer: 'Nest_camera_transfer.h264',
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
- uuid = undefined; // UUID of the device connecting
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
- #cameraOfflineFrame = undefined; // Camera offline video frame
70
- #cameraVideoOffFrame = undefined; // Video turned off on camera video frame
71
- #cameraTransferringFrame = undefined; // Camera transferring between Nest/Google Home video frame
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.keys(LOGLEVELS).every((fn) => typeof options?.log?.[fn] === 'function')) {
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.uuid = deviceData?.nest_google_uuid;
136
+ this.nest_google_uuid = deviceData?.nest_google_uuid;
85
137
 
86
138
  // Load support video frame files as required
87
- const loadFrameIfExists = (filename, label) => {
139
+ const loadFrameResource = (filename, label) => {
88
140
  let buffer = undefined;
89
- let file = path.resolve(__dirname, RESOURCEPATH, filename);
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.#cameraOfflineFrame = loadFrameIfExists(CAMERARESOURCE.offline, 'offline');
99
- this.#cameraVideoOffFrame = loadFrameIfExists(CAMERARESOURCE.off, 'video off');
100
- this.#cameraTransferringFrame = loadFrameIfExists(CAMERARESOURCE.transfer, 'transferring');
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 a non-blocking loop for output to the various streams which connect to our streamer object
103
- // This process will also handle the rolling-buffer size we require
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
- // Keep our 'main' rolling buffer under a certain size
133
- // Live/record buffers will always reduce in length in the next section
134
- if (output.type === 'buffer') {
135
- let cutoffTime = dateNow - MAXBUFFERAGE;
136
- while (output.buffer.length > 0 && output.buffer[0].time < cutoffTime) {
137
- output.buffer.shift();
138
- }
139
- }
164
+ // Class functions
165
+ async onUpdate(deviceData) {
166
+ if (typeof deviceData !== 'object') {
167
+ return;
168
+ }
140
169
 
141
- // Output the packet data to any 'live' or 'recording' streams
142
- if (output.type === 'live' || output.type === 'record') {
143
- let packet = output.buffer.shift();
144
- if (packet?.type === 'video' && typeof output?.video?.write === 'function') {
145
- packet.data = Buffer.concat([H264NALSTARTCODE, packet.data]);
146
- output.video.write(packet.data);
147
- }
148
- if (packet?.type === 'audio' && typeof output?.audio?.write === 'function') {
149
- output.audio.write(packet.data);
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
- }, 10); // Every 10ms, rather than "next tick"
209
+ this.#doConnect();
210
+ }
211
+ }
154
212
  }
155
213
 
156
- // Class functions
157
- isBuffering() {
158
- return this.#outputs?.buffer !== undefined;
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
- startBuffering() {
162
- if (this.#outputs?.buffer === undefined) {
163
- // No active buffer session, start connection to streamer
164
- if (this.online === true && this.videoEnabled === true && this.connected === undefined && typeof this.connect === 'function') {
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
- this.#outputs.buffer = {
170
- type: 'buffer',
171
- buffer: [],
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
- startLiveStream(sessionID, videoStream, audioStream, talkbackStream) {
177
- // Setup error catching for video/audio/talkback streams
178
- if (videoStream !== null && typeof videoStream === 'object') {
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
- if (audioStream !== null && typeof audioStream === 'object') {
185
- audioStream.on('error', () => {
186
- // EPIPE errors??
187
- });
273
+ packetType = packetType.toLowerCase();
274
+ if (Streamer.PACKET_TYPE?.[packetType.toUpperCase()] === undefined) {
275
+ return;
188
276
  }
189
277
 
190
- if (talkbackStream !== null && typeof talkbackStream === 'object') {
191
- let talkbackTimeout = undefined;
278
+ if (this.#sequenceCounters?.[packetType] === undefined) {
279
+ this.#sequenceCounters[packetType] = 0;
280
+ }
192
281
 
193
- talkbackStream.on('error', () => {
194
- // EPIPE errors??
195
- });
282
+ let seq = typeof sequence === 'number' ? sequence : this.#sequenceCounters[packetType]++;
196
283
 
197
- talkbackStream.on('data', (data) => {
198
- // Received audio data to send onto camera/doorbell for output
199
- if (typeof this.talkingAudio === 'function') {
200
- this.talkingAudio(data);
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
- clearTimeout(talkbackTimeout);
203
- talkbackTimeout = setTimeout(() => {
204
- // no audio received in 1000ms, so mark end of stream
205
- this.talkingAudio(Buffer.alloc(0));
206
- }, TALKBACKAUDIOTIMEOUT);
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
- // We do not have an active connection, so startup connection
212
- this.#doConnect();
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
- // Add video/audio streams for our output loop to handle outputting
215
- this.#outputs[sessionID] = {
216
- type: 'live',
217
- video: videoStream,
218
- audio: audioStream,
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
- // finally, we've started live stream
224
- this?.log?.debug?.(
225
- 'Started live stream from uuid "%s" %s "%s"',
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
- startRecordStream(sessionID, videoStream, audioStream) {
233
- // Setup error catching for video/audio streams
234
- if (videoStream !== null && typeof videoStream === 'object') {
235
- videoStream.on('error', () => {
236
- // EPIPE errors??
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
- if (audioStream !== null && typeof audioStream === 'object') {
241
- audioStream.on('error', () => {
242
- // EPIPE errors??
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
- // We do not have an active connection, so startup connection
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: 'record',
252
- video: videoStream,
253
- audio: audioStream,
254
- // eslint-disable-next-line no-undef
255
- buffer: this.#outputs?.buffer?.buffer !== undefined ? structuredClone(this.#outputs.buffer.buffer) : [],
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
- // Finally we've started the recording stream
259
- this?.log?.debug?.('Started recording stream from uuid "%s" with session id of "%s"', this.uuid, sessionID);
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
- stopRecordStream(sessionID) {
263
- // Request to stop a recording stream
264
- if (this.#outputs?.[sessionID] !== undefined) {
265
- this?.log?.debug?.('Stopped recording stream from uuid "%s"', this.uuid);
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 we have no more output streams active, we'll close the connection
270
- if (this.haveOutputs() === false && typeof this.close === 'function') {
271
- this.close();
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
- stopLiveStream(sessionID) {
276
- // Request to stop an active live stream
277
- if (this.#outputs?.[sessionID] !== undefined) {
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
- // If we have no more output streams active, we'll close the connection
283
- if (this.haveOutputs() === false && typeof this.close === 'function') {
284
- this.close();
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
- stopBuffering() {
289
- if (this.#outputs?.buffer !== undefined) {
290
- this?.log?.debug?.('Stopped buffering from uuid "%s"', this.uuid);
291
- delete this.#outputs.buffer;
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.haveOutputs() === false && typeof this.close === 'function') {
296
- this.close();
519
+ if (this.isStreaming() === false && this.isBuffering() === false) {
520
+ this.#doClose();
297
521
  }
298
522
  }
299
523
 
300
- stopEverything() {
301
- if (this.haveOutputs() === true) {
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
- update(deviceData) {
311
- if (typeof deviceData !== 'object') {
312
- return;
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
- this.migrating = deviceData.migrating;
532
+ isRecording() {
533
+ return Object.values(this.#outputs).some((x) => x?.type === Streamer.STREAM_TYPE.RECORD);
534
+ }
316
535
 
317
- if (this.uuid !== deviceData?.nest_google_uuid) {
318
- this.uuid = deviceData?.nest_google_uuid;
536
+ isLiveStreaming() {
537
+ return Object.values(this.#outputs).some((x) => x?.type === Streamer.STREAM_TYPE.LIVE);
538
+ }
319
539
 
320
- if (this.haveOutputs() === true) {
321
- // Since the uuid has change and a streamer may use this, if there any any active outputs, close and connect again
322
- if (typeof this.close === 'function') {
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
- if (
330
- this.online !== deviceData.online ||
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
- addToOutput(type, data) {
350
- if (typeof type !== 'string' || type === '' || Buffer.isBuffer(data) === false) {
351
- return;
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
- if (data.indexOf(H264NALSTARTCODE) === 0) {
355
- // Strip H264 start code from input buffer. We'll handle this later
356
- data = data.subarray(H264NALSTARTCODE.length);
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
- Object.values(this.#outputs).forEach((output) => {
360
- output.buffer.push({
361
- time: Date.now(), // Timestamp of when this was added to buffer
362
- type: type,
363
- data: data,
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
- haveOutputs() {
369
- return Object.keys(this.#outputs).length > 0;
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
- #doConnect() {
373
- if (this.online === true && this.videoEnabled === true && this.connected === undefined && typeof this.connect === 'function') {
374
- this.connect();
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
  }