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/webrtc.js CHANGED
@@ -4,13 +4,14 @@
4
4
  // Handles connection and data from Google WebRTC systems
5
5
  // Currently a "work in progress"
6
6
  //
7
- // Code version 2025.06.10
7
+ // Code version 2025.07.23
8
8
  // Mark Hulskamp
9
9
  'use strict';
10
10
 
11
11
  // Define external library requirements
12
12
  import protobuf from 'protobufjs';
13
13
  import * as werift from 'werift';
14
+ import { Decoder } from '@evan/opus';
14
15
 
15
16
  // Define nodejs module requirements
16
17
  import EventEmitter from 'node:events';
@@ -20,38 +21,28 @@ import { setInterval, clearInterval, setTimeout, clearTimeout } from 'node:timer
20
21
  import fs from 'node:fs';
21
22
  import path from 'node:path';
22
23
  import crypto from 'node:crypto';
23
- import { fileURLToPath } from 'node:url';
24
24
 
25
25
  // Define our modules
26
26
  import Streamer from './streamer.js';
27
27
 
28
28
  // Define constants
29
- const EXTENDINTERVAL = 120000; // Send extend command to Google Home Foyer every this period for active streams
29
+ import { USER_AGENT, __dirname } from './consts.js';
30
+
31
+ const EXTEND_INTERVAL = 30000; // Send extend command to Google Home Foyer every this period for active streams
30
32
  const RTP_PACKET_HEADER_SIZE = 12;
31
33
  const RTP_VIDEO_PAYLOAD_TYPE = 102;
32
34
  const RTP_AUDIO_PAYLOAD_TYPE = 111;
33
35
  //const RTP_TALKBACK_PAYLOAD_TYPE = 110;
34
- const USERAGENT = 'Nest/5.78.0 (iOScom.nestlabs.jasper.release) os=18.0'; // User Agent string
35
- const GOOGLEHOMEFOYERPREFIX = 'google.internal.home.foyer.v1.';
36
- const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a defined for JS __dirname
37
-
38
- // Blank audio in AAC format, mono channel @48000
39
- const AACMONO48000BLANK = Buffer.from([
40
- 0xff, 0xf1, 0x4c, 0x40, 0x03, 0x9f, 0xfc, 0xde, 0x02, 0x00, 0x4c, 0x61, 0x76, 0x63, 0x35, 0x39, 0x2e, 0x31, 0x38, 0x2e, 0x31, 0x30, 0x30,
41
- 0x00, 0x02, 0x30, 0x40, 0x0e,
42
- ]);
36
+ const GOOGLE_HOME_FOYER_PREFIX = 'google.internal.home.foyer.v1.';
43
37
 
44
- // Blank audio in opus format, stero channel @48000
45
- //const OPUSSTEREO48000BLANK = Buffer.from([]);
38
+ // Blank audio in Opus format, mono channel @48000
39
+ const PCM_S16LE_48000_STEREO_BLANK = Buffer.alloc(1920 * 2 * 2); // 20ms stereo silence at 48kHz
46
40
 
47
41
  // WebRTC object
48
42
  export default class WebRTC extends Streamer {
49
43
  token = undefined; // oauth2 token
50
44
  localAccess = false; // Do we try direct local access to the camera or via Google Home first
51
- extendTimer = undefined; // Stream extend timer
52
- stalledTimer = undefined; // Timer object for no received data
53
- pingTimer = undefined; // Google Hopme Foyer periodic ping
54
- blankAudio = AACMONO48000BLANK;
45
+ blankAudio = PCM_S16LE_48000_STEREO_BLANK;
55
46
  video = {}; // Video stream details once connected
56
47
  audio = {}; // Audio stream details once connected
57
48
 
@@ -64,9 +55,22 @@ export default class WebRTC extends Streamer {
64
55
  #peerConnection = undefined;
65
56
  #videoTransceiver = undefined;
66
57
  #audioTransceiver = undefined;
58
+ #opusDecoder = new Decoder({ channels: 2, sample_rate: 48000 });
59
+ #extendTimer = undefined; // Stream extend timer
60
+ #stalledTimer = undefined; // Timer object for no received data
61
+ #pingTimer = undefined; // Google Hopme Foyer periodic ping
62
+
63
+ // Codecs being used for video, audio and talking
64
+ get codecs() {
65
+ return {
66
+ video: Streamer.CODEC_TYPE.H264, // Video is H264
67
+ audio: Streamer.CODEC_TYPE.PCM, // Audio is PCM (we decode Opus to PCM output)
68
+ talkback: Streamer.CODEC_TYPE.OPUS, // Talking is also Opus
69
+ };
70
+ }
67
71
 
68
- constructor(deviceData, options) {
69
- super(deviceData, options);
72
+ constructor(uuid, deviceData, options) {
73
+ super(uuid, deviceData, options);
70
74
 
71
75
  // Load the protobuf for Google Home Foyer. Needed to communicate with camera devices using webrtc
72
76
  if (fs.existsSync(path.resolve(__dirname + '/protobuf/googlehome/foyer.proto')) === true) {
@@ -83,26 +87,14 @@ export default class WebRTC extends Streamer {
83
87
  if (deviceData?.apiAccess?.fieldTest === true) {
84
88
  this.#googleHomeFoyerAPIHost = 'https://preprod-googlehomefoyer-pa.sandbox.googleapis.com';
85
89
  }
86
-
87
- // Set our streamer codec types
88
- this.codecs = {
89
- video: 'h264',
90
- audio: 'opus',
91
- talk: 'opus',
92
- };
93
-
94
- // If specified option to start buffering, kick off
95
- if (options?.buffer === true) {
96
- this.startBuffering();
97
- }
98
90
  }
99
91
 
100
92
  // Class functions
101
93
  async connect() {
102
- clearInterval(this.extendTimer);
103
- clearTimeout(this.stalledTimer);
104
- this.extendTimer = undefined;
105
- this.stalledTimer = undefined;
94
+ clearInterval(this.#extendTimer);
95
+ clearTimeout(this.#stalledTimer);
96
+ this.#extendTimer = undefined;
97
+ this.#stalledTimer = undefined;
106
98
  this.#id = undefined;
107
99
 
108
100
  if (this.online === true && this.videoEnabled === true) {
@@ -121,7 +113,7 @@ export default class WebRTC extends Streamer {
121
113
  // Test to see if our uuid matches here
122
114
  let currentGoogleUuid = device?.id?.googleUuid;
123
115
  Object.values(device.otherIds.otherThirdPartyId).forEach((other) => {
124
- if (other?.id === this.uuid) {
116
+ if (other?.id === this.nest_google_uuid) {
125
117
  this.#googleHomeDeviceUUID = currentGoogleUuid;
126
118
  }
127
119
  });
@@ -145,7 +137,7 @@ export default class WebRTC extends Streamer {
145
137
 
146
138
  if (homeFoyerResponse.status !== 0) {
147
139
  this.connected = undefined;
148
- this?.log?.debug?.('Request to start camera viewing was not accepted for uuid "%s"', this.uuid);
140
+ this?.log?.debug?.('Request to start camera viewing was not accepted for uuid "%s"', this.nest_google_uuid);
149
141
  }
150
142
 
151
143
  if (homeFoyerResponse.status === 0) {
@@ -200,11 +192,11 @@ export default class WebRTC extends Streamer {
200
192
  let webRTCOffer = await this.#peerConnection.createOffer();
201
193
  await this.#peerConnection.setLocalDescription(webRTCOffer);
202
194
 
203
- this?.log?.debug?.('Sending WebRTC offer for uuid "%s"', this.uuid);
195
+ this?.log?.debug?.('Sending WebRTC offer for uuid "%s"', this.nest_google_uuid);
204
196
 
205
197
  homeFoyerResponse = await this.#googleHomeFoyerCommand('CameraService', 'JoinStream', {
206
198
  command: 'offer',
207
- deviceId: this.uuid,
199
+ deviceId: this.nest_google_uuid,
208
200
  local: this.localAccess,
209
201
  streamContext: 'STREAM_CONTEXT_DEFAULT',
210
202
  requestedVideoResolution: 'VIDEO_RESOLUTION_FULL_HIGH',
@@ -213,7 +205,7 @@ export default class WebRTC extends Streamer {
213
205
 
214
206
  if (homeFoyerResponse.status !== 0) {
215
207
  this.connected = undefined;
216
- this?.log?.debug?.('WebRTC offer was not agreed with remote for uuid "%s"', this.uuid);
208
+ this?.log?.debug?.('WebRTC offer was not agreed with remote for uuid "%s"', this.nest_google_uuid);
217
209
  }
218
210
 
219
211
  if (
@@ -221,58 +213,53 @@ export default class WebRTC extends Streamer {
221
213
  homeFoyerResponse.data?.[0]?.responseType === 'answer' &&
222
214
  homeFoyerResponse.data?.[0]?.streamId !== undefined
223
215
  ) {
224
- this?.log?.debug?.('WebRTC offer agreed with remote for uuid "%s"', this.uuid);
216
+ this?.log?.debug?.('WebRTC offer agreed with remote for uuid "%s"', this.nest_google_uuid);
225
217
 
226
- this.#audioTransceiver?.onTrack &&
227
- this.#audioTransceiver.onTrack.subscribe((track) => {
228
- this.#handlePlaybackBegin(track);
218
+ this.#audioTransceiver?.onTrack?.subscribe?.((track) => {
219
+ this.#handlePlaybackBegin(track);
229
220
 
230
- track.onReceiveRtp.subscribe((rtp) => {
231
- this.#handlePlaybackPacket(rtp);
232
- });
221
+ track.onReceiveRtp.subscribe((rtp) => {
222
+ this.#handlePlaybackPacket(rtp);
233
223
  });
224
+ });
234
225
 
235
- this.#videoTransceiver?.onTrack &&
236
- this.#videoTransceiver.onTrack.subscribe((track) => {
237
- this.#handlePlaybackBegin(track);
226
+ this.#videoTransceiver?.onTrack?.subscribe?.((track) => {
227
+ this.#handlePlaybackBegin(track);
238
228
 
239
- track.onReceiveRtp.subscribe((rtp) => {
240
- this.#handlePlaybackPacket(rtp);
241
- });
242
- track.onReceiveRtcp.once(() => {
243
- setInterval(() => {
244
- if (this.#videoTransceiver?.receiver !== undefined) {
245
- this.#videoTransceiver.receiver.sendRtcpPLI(track.ssrc);
246
- }
247
- }, 2000);
248
- });
229
+ track.onReceiveRtp.subscribe((rtp) => {
230
+ this.#handlePlaybackPacket(rtp);
231
+ });
232
+ track.onReceiveRtcp.once(() => {
233
+ setInterval(() => {
234
+ this.#videoTransceiver?.receiver?.sendRtcpPLI?.(track.ssrc);
235
+ }, 2000);
249
236
  });
237
+ });
250
238
 
251
239
  this.#id = homeFoyerResponse.data[0].streamId;
252
- this.#peerConnection &&
253
- (await this.#peerConnection.setRemoteDescription({
254
- type: 'answer',
255
- sdp: homeFoyerResponse.data[0].sdp,
256
- }));
257
240
 
258
- this?.log?.debug?.('Playback started from WebRTC for uuid "%s" with session ID "%s"', this.uuid, this.#id);
241
+ await this.#peerConnection?.setRemoteDescription?.({
242
+ type: 'answer',
243
+ sdp: homeFoyerResponse.data[0].sdp,
244
+ });
245
+
246
+ this?.log?.debug?.('Playback started from WebRTC for uuid "%s" with session ID "%s"', this.nest_google_uuid, this.#id);
259
247
  this.connected = true;
260
248
 
261
249
  // Monitor connection status. If closed and there are still output streams, re-connect
262
250
  // Never seem to get a 'connected' status. Could use that for something?
263
- this.#peerConnection &&
264
- this.#peerConnection.connectionStateChange.subscribe((state) => {
265
- if (state !== 'connected' && state !== 'connecting') {
266
- this?.log?.debug?.('Connection closed to WebRTC for uuid "%s"', this.uuid);
267
- this.connected = undefined;
268
- if (this.haveOutputs() === true) {
269
- this.connect();
270
- }
251
+ this.#peerConnection?.iceConnectionStateChange?.subscribe?.((state) => {
252
+ if (state !== 'connected' && state !== 'connecting') {
253
+ this?.log?.debug?.('Connection closed to WebRTC for uuid "%s"', this.nest_google_uuid);
254
+ this.connected = undefined;
255
+ if (this.isStreaming() === true || this.isBuffering() === true) {
256
+ this.connect();
271
257
  }
272
- });
258
+ }
259
+ });
273
260
 
274
261
  // Create a timer to extend the active stream every period as defined
275
- this.extendTimer = setInterval(async () => {
262
+ this.#extendTimer = setInterval(async () => {
276
263
  if (
277
264
  this.#googleHomeFoyer !== undefined &&
278
265
  this.connected === true &&
@@ -281,19 +268,17 @@ export default class WebRTC extends Streamer {
281
268
  ) {
282
269
  let homeFoyerResponse = await this.#googleHomeFoyerCommand('CameraService', 'JoinStream', {
283
270
  command: 'extend',
284
- deviceId: this.uuid,
271
+ deviceId: this.nest_google_uuid,
285
272
  streamId: this.#id,
286
273
  });
287
274
 
288
275
  if (homeFoyerResponse?.data?.[0]?.streamExtensionStatus !== 'STATUS_STREAM_EXTENDED') {
289
- this?.log?.debug?.('Error occurred while requested stream extension for uuid "%s"', this.uuid);
276
+ this?.log?.debug?.('Error occurred while requested stream extension for uuid "%s"', this.nest_google_uuid);
290
277
 
291
- if (typeof this.#peerConnection?.close === 'function') {
292
- await this.#peerConnection.close();
293
- }
278
+ await this.#peerConnection?.close?.();
294
279
  }
295
280
  }
296
- }, EXTENDINTERVAL);
281
+ }, EXTEND_INTERVAL);
297
282
  }
298
283
  }
299
284
  }
@@ -313,27 +298,23 @@ export default class WebRTC extends Streamer {
313
298
  });
314
299
  }
315
300
 
316
- this?.log?.debug?.('Notifying remote about closing connection for uuid "%s"', this.uuid);
301
+ this?.log?.debug?.('Notifying remote about closing connection for uuid "%s"', this.nest_google_uuid);
317
302
  await this.#googleHomeFoyerCommand('CameraService', 'JoinStream', {
318
303
  command: 'end',
319
- deviceId: this.uuid,
304
+ deviceId: this.nest_google_uuid,
320
305
  streamId: this.#id,
321
306
  endStreamReason: 'REASON_USER_EXITED_SESSION',
322
307
  });
323
308
  }
324
309
 
325
- if (this.#googleHomeFoyer !== undefined) {
326
- this.#googleHomeFoyer.destroy();
327
- }
310
+ this.#googleHomeFoyer?.destroy?.();
328
311
 
329
- if (typeof this.#peerConnection?.close === 'function') {
330
- await this.#peerConnection.close();
331
- }
312
+ await this.#peerConnection?.close?.();
332
313
 
333
- clearInterval(this.extendTimer);
334
- clearInterval(this.stalledTimer);
335
- this.extendTimer = undefined;
336
- this.stalledTimer = undefined;
314
+ clearInterval(this.#extendTimer);
315
+ clearInterval(this.#stalledTimer);
316
+ this.#extendTimer = undefined;
317
+ this.#stalledTimer = undefined;
337
318
  this.#id = undefined;
338
319
  this.#googleHomeFoyer = undefined;
339
320
  this.#peerConnection = undefined;
@@ -344,23 +325,28 @@ export default class WebRTC extends Streamer {
344
325
  this.audio = {};
345
326
  }
346
327
 
347
- update(deviceData) {
328
+ async onUpdate(deviceData) {
348
329
  if (typeof deviceData !== 'object') {
349
330
  return;
350
331
  }
351
332
 
352
- if (deviceData.apiAccess.oauth2 !== this.token) {
333
+ if (
334
+ typeof deviceData?.apiAccess?.oauth2 === 'string' &&
335
+ deviceData.apiAccess.oauth2 !== '' &&
336
+ deviceData.apiAccess.oauth2 !== this.token
337
+ ) {
353
338
  // OAuth2 token has changed
354
339
  this.token = deviceData.apiAccess.oauth2;
340
+ if (this.isStreaming() === true || this.isBuffering() === true) {
341
+ this?.log?.debug?.('OAuth2 token has been updated for WebRTC on uuid "%s". Restarting connection.', this.nest_google_uuid);
342
+ await this.#peerConnection?.close?.();
343
+ }
355
344
  }
356
-
357
- // Let our parent handle the remaining updates
358
- super.update(deviceData);
359
345
  }
360
346
 
361
- async talkingAudio(talkingData) {
347
+ async sendTalkback(talkingBuffer) {
362
348
  if (
363
- Buffer.isBuffer(talkingData) === false ||
349
+ Buffer.isBuffer(talkingBuffer) === false ||
364
350
  this.#googleHomeDeviceUUID === undefined ||
365
351
  this.#id === undefined ||
366
352
  typeof this.#audioTransceiver?.sender?.sendRtp !== 'function'
@@ -368,7 +354,7 @@ export default class WebRTC extends Streamer {
368
354
  return;
369
355
  }
370
356
 
371
- if (talkingData.length !== 0) {
357
+ if (talkingBuffer.length !== 0) {
372
358
  if (this.audio?.talking === undefined) {
373
359
  this.audio.talking = false;
374
360
  let homeFoyerResponse = await this.#googleHomeFoyerCommand('CameraService', 'SendTalkback', {
@@ -381,11 +367,11 @@ export default class WebRTC extends Streamer {
381
367
 
382
368
  if (homeFoyerResponse?.status !== 0) {
383
369
  this.audio.talking = undefined;
384
- this?.log?.debug?.('Error occurred while requesting talkback to start for uuid "%s"', this.uuid);
370
+ this?.log?.debug?.('Error occurred while requesting talkback to start for uuid "%s"', this.nest_google_uuid);
385
371
  }
386
372
  if (homeFoyerResponse?.status === 0) {
387
373
  this.audio.talking = true;
388
- this?.log?.debug?.('Talking start on uuid "%s"', this.uuid);
374
+ this?.log?.debug?.('Talking start on uuid "%s"', this.nest_google_uuid);
389
375
  }
390
376
  }
391
377
 
@@ -396,14 +382,14 @@ export default class WebRTC extends Streamer {
396
382
  rtpHeader.marker = true;
397
383
  rtpHeader.payloadOffset = RTP_PACKET_HEADER_SIZE;
398
384
  rtpHeader.payloadType = this.audio.id; // As the camera is send/recv, we use the same payload type id as the incoming audio
399
- rtpHeader.timestamp = Date.now() & 0xffffffff; // Think the time stamp difference should be 960ms per audio packet?
385
+ rtpHeader.timestamp = Date.now() >>> 0; // Think the time stamp difference should be 960ms per audio packet?
400
386
  rtpHeader.sequenceNumber = this.audio.talkSquenceNumber++ & 0xffff;
401
- let rtpPacket = new werift.RtpPacket(rtpHeader, talkingData);
387
+ let rtpPacket = new werift.RtpPacket(rtpHeader, talkingBuffer);
402
388
  this.#audioTransceiver.sender.sendRtp(rtpPacket.serialize());
403
389
  }
404
390
  }
405
391
 
406
- if (talkingData.length === 0 && this.audio?.talking === true) {
392
+ if (talkingBuffer.length === 0 && this.audio?.talking === true) {
407
393
  // Buffer length of zero, ised to signal no more talking data for the moment
408
394
  let homeFoyerResponse = await this.#googleHomeFoyerCommand('CameraService', 'SendTalkback', {
409
395
  googleDeviceId: {
@@ -413,98 +399,214 @@ export default class WebRTC extends Streamer {
413
399
  command: 'COMMAND_STOP',
414
400
  });
415
401
  if (homeFoyerResponse?.status !== 0) {
416
- this?.log?.debug?.('Error occurred while requesting talkback to stop for uuid "%s"', this.uuid);
402
+ this?.log?.debug?.('Error occurred while requesting talkback to stop for uuid "%s"', this.nest_google_uuid);
417
403
  }
418
404
  if (homeFoyerResponse?.status === 0) {
419
- this?.log?.debug?.('Talking ended on uuid "%s"', this.uuid);
405
+ this?.log?.debug?.('Talking ended on uuid "%s"', this.nest_google_uuid);
420
406
  }
421
407
  this.audio.talking = undefined;
422
408
  }
423
409
  }
424
410
 
425
411
  #handlePlaybackBegin(weriftTrack) {
412
+ // Handle the beginning of RTP playback for an audio or video track
426
413
  if (weriftTrack === undefined || typeof weriftTrack !== 'object') {
427
414
  return;
428
415
  }
429
416
 
430
417
  if (weriftTrack?.kind === 'audio') {
431
- // Store details about the audio track
432
418
  this.audio = {
433
- id: weriftTrack.codec.payloadType, // Audio track payload type being used
434
- startTime: Date.now(),
419
+ id: weriftTrack.codec.payloadType, // RTP payload type for audio
420
+ baseTimestamp: undefined,
421
+ baseTime: undefined,
435
422
  sampleRate: 48000,
436
- opus: undefined, // Buffer for processing incoming Opus RTP packets
423
+ opus: undefined,
437
424
  talkSquenceNumber: weriftTrack?.sender?.sequenceNumber === undefined ? 0 : weriftTrack.sender.sequenceNumber,
438
- talking: undefined, // undefined = not connected, false = connecting, true = connected and talking
425
+ talking: undefined,
439
426
  };
440
427
  }
441
428
 
442
429
  if (weriftTrack?.kind === 'video') {
443
- // Store details about the video track
444
430
  this.video = {
445
- id: weriftTrack.codec.payloadType, // Video track payload type being used
446
- startTime: Date.now(),
431
+ id: weriftTrack.codec.payloadType, // RTP payload type for video
432
+ baseTimestamp: undefined,
433
+ baseTime: undefined,
447
434
  sampleRate: 90000,
448
- h264: undefined, // Buffer for processing incoming fragmented H264 RTP packets
435
+ h264: {
436
+ fuBuffer: undefined,
437
+ fuTimer: undefined,
438
+ fuSeqStart: undefined,
439
+ fuType: undefined,
440
+ fuTimestamp: undefined,
441
+ lastSPS: undefined,
442
+ lastPPS: undefined,
443
+ },
449
444
  };
450
445
  }
451
446
  }
452
447
 
453
- async #handlePlaybackPacket(weriftRtpPacket) {
454
- if (weriftRtpPacket === undefined || typeof weriftRtpPacket !== 'object') {
448
+ #handlePlaybackPacket(rtpPacket) {
449
+ if (typeof rtpPacket !== 'object' || rtpPacket === undefined) {
455
450
  return;
456
451
  }
457
452
 
458
- // Setup up a timeout to monitor for no packets recieved in a certain period
459
- // If its trigger, we'll attempt to restart the stream and/or connection
460
- clearTimeout(this.stalledTimer);
461
- this.stalledTimer = setTimeout(async () => {
462
- this?.log?.debug?.(
463
- 'We have not received any data from webrtc in the past "%s" seconds for uuid "%s". Attempting restart',
464
- 10,
465
- this.uuid,
466
- );
467
-
468
- if (typeof this.#peerConnection?.close === 'function') {
469
- await this.#peerConnection.close();
470
- }
453
+ // Create timer for stalled rtp output. Restart strem if so
454
+ clearTimeout(this.#stalledTimer);
455
+ this.#stalledTimer = setTimeout(async () => {
456
+ await this.#peerConnection?.close?.();
471
457
  }, 10000);
472
458
 
473
- if (weriftRtpPacket.header.payloadType !== undefined && weriftRtpPacket.header.payloadType === this.video?.id) {
474
- // Process video RTP packets. Need to re-assemble the H264 NALUs into a single H264 frame we can output
475
- if (weriftRtpPacket.header.padding === false) {
476
- this.video.h264 = werift.H264RtpPayload.deSerialize(weriftRtpPacket.payload, this.video.h264?.fragment);
477
- if (this.video.h264?.payload !== undefined) {
478
- this.addToOutput('video', this.video.h264.payload);
479
- this.video.h264 = undefined;
459
+ const calculateTimestamp = (packet, stream) => {
460
+ if (typeof packet?.header?.timestamp !== 'number' || typeof stream?.sampleRate !== 'number') {
461
+ return Date.now();
462
+ }
463
+ if (stream.baseTimestamp === undefined) {
464
+ stream.baseTimestamp = packet.header.timestamp;
465
+ stream.baseTime = Date.now();
466
+ }
467
+ let deltaTicks = (packet.header.timestamp - stream.baseTimestamp + 0x100000000) % 0x100000000;
468
+ let deltaMs = (deltaTicks / stream.sampleRate) * 1000;
469
+ return stream.baseTime + deltaMs;
470
+ };
471
+
472
+ // Video packet processing
473
+ if (rtpPacket.header.payloadType === this.video?.id) {
474
+ let seq = rtpPacket.header.sequenceNumber;
475
+ if (this.video.h264.lastSeq !== undefined) {
476
+ let gap = (seq - this.video.h264.lastSeq + 0x10000) % 0x10000;
477
+ if (gap !== 1) {
478
+ // sequence gap (ignored silently)
480
479
  }
481
480
  }
481
+ this.video.h264.lastSeq = seq;
482
482
  }
483
483
 
484
- if (weriftRtpPacket.header.payloadType !== undefined && weriftRtpPacket.header.payloadType === this.audio?.id) {
485
- // Process audio RTP packet
486
- this.audio.opus = werift.OpusRtpPayload.deSerialize(weriftRtpPacket.payload);
487
- if (this.audio.opus?.payload !== undefined) {
488
- // Until work out audio, send blank aac
489
- this.addToOutput('audio', AACMONO48000BLANK);
484
+ if (rtpPacket.header.payloadType === this.video?.id) {
485
+ let payload = rtpPacket.payload;
486
+ if (!Buffer.isBuffer(payload) || payload.length < 1) {
487
+ return;
488
+ }
489
+
490
+ let nalType = payload[0] & 0x1f;
490
491
 
491
- // Decode payload to opus??
492
- //this.addToOutput('audio', this.audio.opus.payload);
492
+ // STAP-A
493
+ if (nalType === Streamer.H264NALUS.TYPES.STAP_A) {
494
+ let offset = 1;
495
+ while (offset + 2 <= payload.length) {
496
+ let size = payload.readUInt16BE(offset);
497
+ offset += 2;
498
+ if (size < 1 || offset + size > payload.length) {
499
+ break;
500
+ }
501
+ let nalu = payload.subarray(offset, offset + size);
502
+ let type = nalu[0] & 0x1f;
503
+ if (type === Streamer.H264NALUS.TYPES.SPS) {
504
+ this.video.h264.sps = nalu;
505
+ } else if (type === Streamer.H264NALUS.TYPES.PPS) {
506
+ this.video.h264.pps = nalu;
507
+ }
508
+ offset += size;
509
+ }
510
+ return;
511
+ }
512
+
513
+ // FU-A
514
+ if (nalType === Streamer.H264NALUS.TYPES.FU_A) {
515
+ let indicator = payload[0];
516
+ let header = payload[1];
517
+ let start = (header & 0x80) !== 0;
518
+ let end = (header & 0x40) !== 0;
519
+ let type = header & 0x1f;
520
+ let nalHeader = (indicator & 0xe0) | type;
521
+
522
+ if (this.video.h264.fragmentTime && Date.now() - this.video.h264.fragmentTime > 2000) {
523
+ delete this.video.h264.fragment;
524
+ delete this.video.h264.fragmentTime;
525
+ }
526
+
527
+ if (start) {
528
+ if (Array.isArray(this.video.h264.fragment)) {
529
+ delete this.video.h264.fragment;
530
+ delete this.video.h264.fragmentTime;
531
+ }
532
+ this.video.h264.fragment = [Buffer.from([nalHeader]), payload.subarray(2)];
533
+ this.video.h264.fragmentTime = Date.now();
534
+ return;
535
+ }
536
+
537
+ if (Array.isArray(this.video.h264.fragment)) {
538
+ this.video.h264.fragment.push(payload.subarray(2));
539
+ if (end) {
540
+ let buffer = Buffer.concat(this.video.h264.fragment);
541
+ delete this.video.h264.fragment;
542
+ delete this.video.h264.fragmentTime;
543
+
544
+ let ts = calculateTimestamp(rtpPacket, this.video);
545
+ if (type === Streamer.H264NALUS.TYPES.IDR) {
546
+ if (this.video.h264.sps) {
547
+ this.add(Streamer.PACKET_TYPE.VIDEO, this.video.h264.sps, ts);
548
+ }
549
+ if (this.video.h264.pps) {
550
+ this.add(Streamer.PACKET_TYPE.VIDEO, this.video.h264.pps, ts);
551
+ }
552
+ }
553
+
554
+ this.add(Streamer.PACKET_TYPE.VIDEO, buffer, ts);
555
+ }
556
+ return;
557
+ }
558
+
559
+ return;
560
+ }
561
+
562
+ // Raw NAL
563
+ let type = nalType;
564
+ if (type === 0) {
565
+ return;
566
+ }
567
+
568
+ let ts = calculateTimestamp(rtpPacket, this.video);
569
+ if (type === Streamer.H264NALUS.TYPES.IDR) {
570
+ if (this.video.h264.sps) {
571
+ this.add(Streamer.PACKET_TYPE.VIDEO, this.video.h264.sps, ts);
572
+ }
573
+ if (this.video.h264.pps) {
574
+ this.add(Streamer.PACKET_TYPE.VIDEO, this.video.h264.pps, ts);
575
+ }
576
+ }
577
+
578
+ this.add(Streamer.PACKET_TYPE.VIDEO, payload, ts);
579
+ return;
580
+ }
581
+
582
+ // Audio packet processing
583
+ if (rtpPacket.header.payloadType === this.audio?.id) {
584
+ let opus = werift.OpusRtpPayload.deSerialize(rtpPacket.payload);
585
+ if (opus?.payload?.[0] === 0xfc) {
586
+ return;
587
+ }
588
+
589
+ let ts = calculateTimestamp(rtpPacket, this.audio);
590
+ try {
591
+ let pcm = this.#opusDecoder.decode(opus.payload);
592
+ this.add(Streamer.PACKET_TYPE.AUDIO, Buffer.from(pcm), ts);
593
+ } catch {
594
+ this.add(Streamer.PACKET_TYPE.AUDIO, this.blankAudio, ts);
493
595
  }
494
596
  }
495
597
  }
496
598
 
497
599
  // Need more work in here*
498
- // <--- error handling
499
- // <--- timeout?
600
+ // < error handling
601
+ // < timeout?
500
602
  async #googleHomeFoyerCommand(service, command, values) {
501
603
  if (typeof service !== 'string' || service === '' || typeof command !== 'string' || command === '' || typeof values !== 'object') {
502
604
  return;
503
605
  }
504
606
 
505
607
  // Attempt to retrieve both 'Request' and 'Reponse' traits for the associated service and command
506
- let TraitMapRequest = this.#protobufFoyer.lookup(GOOGLEHOMEFOYERPREFIX + command + 'Request');
507
- let TraitMapResponse = this.#protobufFoyer.lookup(GOOGLEHOMEFOYERPREFIX + command + 'Response');
608
+ let TraitMapRequest = this.#protobufFoyer.lookup(GOOGLE_HOME_FOYER_PREFIX + command + 'Request');
609
+ let TraitMapResponse = this.#protobufFoyer.lookup(GOOGLE_HOME_FOYER_PREFIX + command + 'Response');
508
610
  let buffer = Buffer.alloc(0);
509
611
  let commandResponse = {
510
612
  status: undefined,
@@ -513,99 +615,126 @@ export default class WebRTC extends Streamer {
513
615
  };
514
616
 
515
617
  if (TraitMapRequest !== null && TraitMapResponse !== null && this.token !== undefined) {
516
- if (this.#googleHomeFoyer === undefined || (this.#googleHomeFoyer?.connected === false && this.#googleHomeFoyer?.closed === true)) {
517
- // No current HTTP/2 connection or current session is closed
518
- this?.log?.debug?.('Connection started to Google Home Foyer "%s"', this.#googleHomeFoyerAPIHost);
519
- this.#googleHomeFoyer = http2.connect(this.#googleHomeFoyerAPIHost);
520
-
521
- this.#googleHomeFoyer.on('connect', () => {
522
- this?.log?.debug?.('Connection established to Google Home Foyer "%s"', this.#googleHomeFoyerAPIHost);
523
-
524
- clearInterval(this.pingTimer);
525
- this.pingTimer = setInterval(() => {
526
- if (this.#googleHomeFoyer !== undefined) {
527
- // eslint-disable-next-line no-unused-vars
528
- this.#googleHomeFoyer.ping((error, duration, payload) => {
529
- // Do we log error to debug?
530
- });
618
+ try {
619
+ if (this.#googleHomeFoyer === undefined || (this.#googleHomeFoyer?.connected === false && this.#googleHomeFoyer?.closed === true)) {
620
+ // No current HTTP/2 connection or current session is closed
621
+ this?.log?.debug?.('Connection started to Google Home Foyer "%s"', this.#googleHomeFoyerAPIHost);
622
+ this.#googleHomeFoyer = http2.connect(this.#googleHomeFoyerAPIHost);
623
+
624
+ this.#googleHomeFoyer.on('connect', () => {
625
+ this?.log?.debug?.('Connection established to Google Home Foyer "%s"', this.#googleHomeFoyerAPIHost);
626
+
627
+ clearInterval(this.#pingTimer);
628
+ this.#pingTimer = setInterval(() => {
629
+ if (this.#googleHomeFoyer !== undefined) {
630
+ // eslint-disable-next-line no-unused-vars
631
+ this.#googleHomeFoyer.ping((error, duration, payload) => {
632
+ // Do we log error to debug?
633
+ });
634
+ }
635
+ }, 60000); // Every minute?
636
+ });
637
+
638
+ // eslint-disable-next-line no-unused-vars
639
+ this.#googleHomeFoyer.on('goaway', (errorCode, lastStreamID, opaqueData) => {
640
+ //console.log('http2 goaway', errorCode);
641
+ });
642
+
643
+ this.#googleHomeFoyer.on('error', (error) => {
644
+ this?.log?.debug?.('Google Home Foyer connection error: %s', String(error));
645
+ try {
646
+ this.#googleHomeFoyer.destroy();
647
+ } catch {
648
+ // Empty
531
649
  }
532
- }, 60000); // Every minute?
533
- });
650
+ this.#googleHomeFoyer = undefined;
651
+ });
534
652
 
535
- // eslint-disable-next-line no-unused-vars
536
- this.#googleHomeFoyer.on('goaway', (errorCode, lastStreamID, opaqueData) => {
537
- //console.log('http2 goaway', errorCode);
538
- });
653
+ this.#googleHomeFoyer.on('close', () => {
654
+ clearInterval(this.#pingTimer);
655
+ this.#pingTimer = undefined;
656
+ this.#googleHomeFoyer = undefined;
657
+ this?.log?.debug?.('Connection closed to Google Home Foyer "%s"', this.#googleHomeFoyerAPIHost);
658
+ });
659
+ }
539
660
 
540
- // eslint-disable-next-line no-unused-vars
541
- this.#googleHomeFoyer.on('error', (error) => {
542
- //console.log('http2 error', error);
543
- // Close??
661
+ let request = this.#googleHomeFoyer.request({
662
+ ':method': 'post',
663
+ ':path': '/' + GOOGLE_HOME_FOYER_PREFIX + service + '/' + command,
664
+ authorization: 'Bearer ' + this.token,
665
+ 'content-type': 'application/grpc',
666
+ 'user-agent': USER_AGENT,
667
+ te: 'trailers',
668
+ 'request-id': crypto.randomUUID(),
669
+ 'grpc-timeout': '10S',
544
670
  });
545
671
 
546
- this.#googleHomeFoyer.on('close', () => {
547
- clearInterval(this.pingTimer);
548
- this.pingTimer = undefined;
549
- this.#googleHomeFoyer = undefined;
550
- this?.log?.debug?.('Connection closed to Google Home Foyer "%s"', this.#googleHomeFoyerAPIHost);
672
+ request.on('data', (data) => {
673
+ buffer = Buffer.concat([buffer, data]);
674
+ while (buffer.length >= 5) {
675
+ let headerSize = 5;
676
+ let dataSize = buffer.readUInt32BE(1);
677
+ if (buffer.length < headerSize + dataSize) {
678
+ // We don't have enough data in the buffer yet to process the data
679
+ // so, exit loop and await more data
680
+ break;
681
+ }
682
+
683
+ commandResponse.data.push(TraitMapResponse.decode(buffer.subarray(headerSize, headerSize + dataSize)).toJSON());
684
+ buffer = buffer.subarray(headerSize + dataSize);
685
+ }
551
686
  });
552
- }
553
687
 
554
- let request = this.#googleHomeFoyer.request({
555
- ':method': 'post',
556
- ':path': '/' + GOOGLEHOMEFOYERPREFIX + service + '/' + command,
557
- authorization: 'Bearer ' + this.token,
558
- 'content-type': 'application/grpc',
559
- 'user-agent': USERAGENT,
560
- te: 'trailers',
561
- 'request-id': crypto.randomUUID(),
562
- 'grpc-timeout': '10S',
563
- });
688
+ request.on('trailers', (headers) => {
689
+ if (isNaN(Number(headers?.['grpc-status'])) === false) {
690
+ commandResponse.status = Number(headers['grpc-status']);
691
+ }
692
+ if (headers?.['grpc-message'] !== undefined) {
693
+ commandResponse.message = headers['grpc-message'];
694
+ }
695
+ });
564
696
 
565
- request.on('data', (data) => {
566
- buffer = Buffer.concat([buffer, data]);
567
- while (buffer.length >= 5) {
568
- let headerSize = 5;
569
- let dataSize = buffer.readUInt32BE(1);
570
- if (buffer.length < headerSize + dataSize) {
571
- // We don't have enough data in the buffer yet to process the data
572
- // so, exit loop and await more data
573
- break;
697
+ request.on('error', (error) => {
698
+ commandResponse.status = error.code;
699
+ commandResponse.message = error.message;
700
+ commandResponse.data = [];
701
+ try {
702
+ request.close();
703
+ } catch {
704
+ // Empty
574
705
  }
706
+ });
575
707
 
576
- commandResponse.data.push(TraitMapResponse.decode(buffer.subarray(headerSize, headerSize + dataSize)).toJSON());
577
- buffer = buffer.subarray(headerSize + dataSize);
578
- }
579
- });
708
+ if (request !== undefined && request?.closed === false && request?.destroyed === false) {
709
+ // Encode our request values, prefix with header (size of data), then send
710
+ let encodedData = TraitMapRequest.encode(TraitMapRequest.fromObject(values)).finish();
711
+ let header = Buffer.alloc(5);
712
+ header.writeUInt32BE(encodedData.length, 1);
713
+ request.write(Buffer.concat([header, encodedData]));
714
+ request.end();
580
715
 
581
- request.on('trailers', (headers) => {
582
- if (isNaN(Number(headers?.['grpc-status'])) === false) {
583
- commandResponse.status = Number(headers['grpc-status']);
584
- }
585
- if (headers?.['grpc-message'] !== undefined) {
586
- commandResponse.message = headers['grpc-message'];
716
+ await EventEmitter.once(request, 'close');
587
717
  }
588
- });
589
718
 
590
- request.on('error', (error) => {
719
+ try {
720
+ // No longer need this request
721
+ request.destroy();
722
+ } catch {
723
+ // Empty
724
+ }
725
+ } catch (error) {
591
726
  commandResponse.status = error.code;
592
- commandResponse.message = error.message;
727
+ commandResponse.message = String(error.message || error);
593
728
  commandResponse.data = [];
594
- request.close();
595
- });
596
729
 
597
- if (request !== undefined && request?.closed === false && request?.destroyed === false) {
598
- // Encode our request values, prefix with header (size of data), then send
599
- let encodedData = TraitMapRequest.encode(TraitMapRequest.fromObject(values)).finish();
600
- let header = Buffer.alloc(5);
601
- header.writeUInt32BE(encodedData.length, 1);
602
- request.write(Buffer.concat([header, encodedData]));
603
- request.end();
604
-
605
- await EventEmitter.once(request, 'close');
730
+ this?.log?.debug?.('Google Home Foyer request failed: %s', commandResponse.message);
731
+ try {
732
+ this.#googleHomeFoyer?.destroy();
733
+ } catch {
734
+ // Empty
735
+ }
736
+ this.#googleHomeFoyer = undefined;
606
737
  }
607
-
608
- request.destroy(); // No longer need this request
609
738
  }
610
739
 
611
740
  return commandResponse;