homebridge-nest-accfactory 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/nexustalk.js CHANGED
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // Credit to https://github.com/Brandawg93/homebridge-nest-cam for the work on the Nest Camera comms code on which this is based
7
7
  //
8
- // Code version 2025.06.15
8
+ // Code version 2025.07.30
9
9
  // Mark Hulskamp
10
10
  'use strict';
11
11
 
@@ -19,15 +19,14 @@ import fs from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import tls from 'tls';
21
21
  import crypto from 'crypto';
22
- import { fileURLToPath } from 'node:url';
23
22
 
24
23
  // Define our modules
25
24
  import Streamer from './streamer.js';
26
25
 
27
26
  // Define constants
27
+ import { USER_AGENT, __dirname } from './consts.js';
28
+
28
29
  const PING_INTERVAL = 15000; // Ping interval to nexus server while stream active
29
- const USER_AGENT = 'Nest/5.78.0 (iOScom.nestlabs.jasper.release) os=18.0'; // User Agent string
30
- const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a defined for JS __dirname
31
30
 
32
31
  const PACKET_TYPE = {
33
32
  PING: 1,
@@ -63,11 +62,9 @@ const AAC_MONO_48000_BLANK = Buffer.from([
63
62
 
64
63
  // nexusTalk object
65
64
  export default class NexusTalk extends Streamer {
65
+ streaming_host = undefined; // Main nexustalk streaming host
66
66
  token = undefined;
67
- tokenType = undefined;
68
- pingTimer = undefined; // Timer object for ping interval
69
- stalledTimer = undefined; // Timer object for no received data
70
- host = ''; // Host to connect to or connected too
67
+ useGoogleAuth = false; // Nest vs google auth
71
68
  blankAudio = AAC_MONO_48000_BLANK;
72
69
  video = {}; // Video stream details once connected
73
70
  audio = {}; // Audio stream details once connected
@@ -79,11 +76,23 @@ export default class NexusTalk extends Streamer {
79
76
  #messages = []; // Incoming messages
80
77
  #authorised = false; // Have we been authorised
81
78
  #id = undefined; // Session ID
79
+ #host = undefined; // Current host connected to
80
+ #pingTimer = undefined; // Timer object for ping interval
81
+ #stalledTimer = undefined; // Timer object for no received data
82
+
83
+ // Codecs being used for video, audio and talking
84
+ get codecs() {
85
+ return {
86
+ video: Streamer.CODEC_TYPE.H264, // Video codec
87
+ audio: Streamer.CODEC_TYPE.AAC, // Audio codec
88
+ talkback: Streamer.CODEC_TYPE.SPEEX, // Talkback codec
89
+ };
90
+ }
82
91
 
83
- constructor(deviceData, options) {
84
- super(deviceData, options);
92
+ constructor(uuid, deviceData, options) {
93
+ super(uuid, deviceData, options);
85
94
 
86
- if (fs.existsSync(path.resolve(__dirname + '/protobuf/nest/nexustalk.proto')) === true) {
95
+ if (fs.existsSync(path.join(__dirname, 'protobuf/nest/nexustalk.proto')) === true) {
87
96
  protobuf.util.Long = null;
88
97
  protobuf.configure();
89
98
  this.#protobufNexusTalk = protobuf.loadSync(path.resolve(__dirname + '/protobuf/nest/nexustalk.proto'));
@@ -91,85 +100,86 @@ export default class NexusTalk extends Streamer {
91
100
 
92
101
  // Store data we need from the device data passed it
93
102
  this.token = deviceData?.apiAccess?.token;
94
- this.tokenType = deviceData?.apiAccess?.oauth2 !== undefined ? 'google' : 'nest';
95
- this.host = deviceData?.streaming_host; // Host we'll connect to
96
-
97
- // Set our streamer codec types
98
- this.codecs = {
99
- video: 'h264',
100
- audio: 'aac',
101
- talk: 'speex',
102
- };
103
-
104
- // If specified option to start buffering, kick off
105
- if (options?.buffer === true) {
106
- this.startBuffering();
107
- }
103
+ this.streaming_host = deviceData?.streaming_host; // Host we'll connect to
104
+ this.useGoogleAuth = typeof deviceData?.apiAccess?.oauth2 === 'string' && deviceData?.apiAccess?.oauth2 !== '';
108
105
  }
109
106
 
110
107
  // Class functions
111
- connect(host) {
108
+ async connect(host) {
112
109
  // Clear any timers we have running
113
- clearInterval(this.pingTimer);
114
- clearTimeout(this.stalledTimer);
115
- this.pingTimer = undefined;
116
- this.stalledTimer = undefined;
110
+ clearInterval(this.#pingTimer);
111
+ clearTimeout(this.#stalledTimer);
112
+ this.#pingTimer = undefined;
113
+ this.#stalledTimer = undefined;
117
114
  this.#id = undefined; // No session ID yet
118
115
 
119
116
  if (this.online === true && this.videoEnabled === true) {
120
117
  if (typeof host === 'undefined' || host === null) {
121
118
  // No host parameter passed in, so we'll set this to our internally stored host
122
- host = this.host;
119
+ host = this.streaming_host;
123
120
  }
124
121
 
125
122
  this.connected = false; // Starting connection
126
123
  this?.log?.debug?.('Connection started to "%s"', host);
124
+ this.#host = host; // Update internal host name since we’re about to connect
125
+
126
+ // Wrap tls.connect() in a Promise so we can await the TLS handshake
127
+ try {
128
+ await new Promise((resolve, reject) => {
129
+ this.#socket = tls.connect({ host: host, port: 1443 }, () => {
130
+ // Opened connection to Nexus server, so now need to authenticate ourselves
131
+ this?.log?.debug?.('Connection established to "%s"', host);
132
+
133
+ this.#socket.setKeepAlive(true); // Keep socket connection alive
134
+ this.connected = true;
135
+ this.#Authenticate(false); // Send authentication request
136
+ resolve(); // Allow await connect() to continue
137
+ });
127
138
 
128
- this.#socket = tls.connect({ host: host, port: 1443 }, () => {
129
- // Opened connection to Nexus server, so now need to authenticate ourselves
130
- this?.log?.debug?.('Connection established to "%s"', host);
131
-
132
- this.#socket.setKeepAlive(true); // Keep socket connection alive
133
- this.host = host; // update internal host name since we've connected
134
- this.connected = true;
135
- this.#Authenticate(false);
136
- });
137
-
138
- this.#socket.on('error', () => {});
139
+ this.#socket.on('error', (err) => {
140
+ // TLS error (could be refused, timeout, etc.)
141
+ this?.log?.warn?.('TLS error on connect to "%s": %s', host, err?.message || err);
142
+ this.connected = undefined;
143
+ reject(err);
144
+ });
139
145
 
140
- this.#socket.on('end', () => {});
146
+ this.#socket.on('end', () => {});
141
147
 
142
- this.#socket.on('data', (data) => {
143
- this.#handleNexusData(data);
144
- });
148
+ this.#socket.on('data', (data) => {
149
+ this.#handleNexusData(data);
150
+ });
145
151
 
146
- this.#socket.on('close', (hadError) => {
147
- this?.log?.debug?.('Connection closed to "%s"', host);
148
-
149
- clearInterval(this.pingTimer);
150
- clearTimeout(this.stalledTimer);
151
- this.pingTimer = undefined;
152
- this.stalledTimer = undefined;
153
- this.#authorised = false; // Since connection close, we can't be authorised anymore
154
- this.#socket = undefined; // Clear socket object
155
- this.connected = undefined;
156
- this.#id = undefined; // Not an active session anymore
157
-
158
- if (hadError === true && this.haveOutputs() === true) {
159
- // We still have either active buffering occuring or output streams running
160
- // so attempt to restart connection to existing host
161
- this.connect(host);
162
- }
163
- });
152
+ this.#socket.on('close', (hadError) => {
153
+ this?.log?.debug?.('Connection closed to "%s"', host);
154
+
155
+ clearInterval(this.#pingTimer);
156
+ clearTimeout(this.#stalledTimer);
157
+ this.#pingTimer = undefined;
158
+ this.#stalledTimer = undefined;
159
+ this.#authorised = false; // Since connection closed, we can't be authorised anymore
160
+ this.#socket = undefined; // Clear socket object
161
+ this.connected = undefined;
162
+ this.#id = undefined; // Not an active session anymore
163
+
164
+ if (hadError === true && (this.isStreaming() === true || this.isBuffering() === true)) {
165
+ // We still have either active buffering occurring or output streams running
166
+ // so attempt to restart connection to existing host
167
+ this.connect(host);
168
+ }
169
+ });
170
+ });
171
+ } catch (error) {
172
+ this?.log?.error?.('Failed to connect to "%s": %s', host, String(error));
173
+ }
164
174
  }
165
175
  }
166
176
 
167
- close(stopStreamFirst) {
177
+ async close(stopStreamFirst) {
168
178
  // Close an authenicated socket stream gracefully
169
179
  if (this.#socket !== undefined) {
170
180
  if (stopStreamFirst === true) {
171
181
  // Send a notifcation to nexus we're finished playback
172
- this.#stopNexusData();
182
+ await this.#stopNexusData();
173
183
  }
174
184
  this.#socket.destroy();
175
185
  }
@@ -181,15 +191,17 @@ export default class NexusTalk extends Streamer {
181
191
  this.#messages = [];
182
192
  this.video = {};
183
193
  this.audio = {};
194
+ this.#host = undefined; // No longer connected to this host
184
195
  }
185
196
 
186
- update(deviceData) {
197
+ async onUpdate(deviceData) {
187
198
  if (typeof deviceData !== 'object') {
188
199
  return;
189
200
  }
190
201
 
191
- if (deviceData.apiAccess.token !== this.token) {
202
+ if (deviceData?.apiAccess?.token !== undefined && deviceData.apiAccess.token !== this.token) {
192
203
  // access token has changed so re-authorise
204
+ this?.log?.debug?.('Access token has changed for uuid "%s". Updating token', this.nest_google_uuid);
193
205
  this.token = deviceData.apiAccess.token;
194
206
 
195
207
  if (this.#socket !== undefined) {
@@ -197,30 +209,38 @@ export default class NexusTalk extends Streamer {
197
209
  }
198
210
  }
199
211
 
200
- if (this.host !== deviceData.streaming_host) {
201
- this.host = deviceData.streaming_host;
202
- this?.log?.debug?.('New host has been requested for connection. Host requested is "%s"', this.host);
203
- }
212
+ if (deviceData?.streaming_host !== undefined && this.streaming_host !== deviceData.streaming_host) {
213
+ this.streaming_host = deviceData.streaming_host;
204
214
 
205
- // Let our parent handle the remaining updates
206
- super.update(deviceData);
215
+ if (this.isStreaming() === true || this.isBuffering() === true) {
216
+ this?.log?.debug?.('New host has been requested for connection. Host requested is "%s"', this.streaming_host);
217
+
218
+ // Setup listener for socket close event. Once socket is closed, we'll perform the redirect
219
+ this.#socket?.on?.('close', () => {
220
+ this.connect(this.streaming_host); // Connect to new host
221
+ });
222
+ this.close(true); // Close existing socket
223
+ }
224
+ }
207
225
  }
208
226
 
209
- talkingAudio(talkingData) {
227
+ sendTalkback(talkingBuffer) {
228
+ if (Buffer.isBuffer(talkingBuffer) === false || this.#protobufNexusTalk === undefined || this.#id === undefined) {
229
+ return;
230
+ }
231
+
210
232
  // Encode audio packet for sending to camera
211
- if (typeof talkingData === 'object' && this.#protobufNexusTalk !== undefined) {
212
- let TraitMap = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.AudioPayload');
213
- if (TraitMap !== null) {
214
- let encodedData = TraitMap.encode(
215
- TraitMap.fromObject({
216
- payload: talkingData,
217
- sessionId: this.#id,
218
- codec: this.codecs.talk.toUpperCase(),
219
- sampleRate: 16000,
220
- }),
221
- ).finish();
222
- this.#sendMessage(PACKET_TYPE.AUDIO_PAYLOAD, encodedData);
223
- }
233
+ let TraitMap = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.AudioPayload');
234
+ if (TraitMap !== null) {
235
+ let encodedData = TraitMap.encode(
236
+ TraitMap.fromObject({
237
+ payload: talkingBuffer,
238
+ sessionId: this.#id,
239
+ codec: Streamer.CODEC_TYPE.SPEEX,
240
+ sampleRate: 16000,
241
+ }),
242
+ ).finish();
243
+ this.#sendMessage(PACKET_TYPE.AUDIO_PAYLOAD, encodedData);
224
244
  }
225
245
  }
226
246
 
@@ -300,15 +320,13 @@ export default class NexusTalk extends Streamer {
300
320
  let TraitMap = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.AuthoriseRequest');
301
321
  if (TraitMap !== null) {
302
322
  authoriseRequest = TraitMap.encode(
303
- TraitMap.fromObject(
304
- this.tokenType === 'nest' ? { sessionToken: this.token } : this.tokenType === 'google' ? { oliveToken: this.token } : {},
305
- ),
323
+ TraitMap.fromObject(this.useGoogleAuth === true ? { oliveToken: this.token } : { sessionToken: this.token }),
306
324
  ).finish();
307
325
  }
308
326
 
309
327
  if (reauthorise === true && authoriseRequest !== null) {
310
328
  // Request to re-authorise only
311
- this?.log?.debug?.('Re-authentication requested to "%s"', this.host);
329
+ this?.log?.debug?.('Re-authentication requested to "%s"', this.#host);
312
330
  this.#sendMessage(PACKET_TYPE.AUTHORIZE_REQUEST, authoriseRequest);
313
331
  }
314
332
 
@@ -316,12 +334,12 @@ export default class NexusTalk extends Streamer {
316
334
  // This isn't a re-authorise request, so perform 'Hello' packet
317
335
  let TraitMap = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.Hello');
318
336
  if (TraitMap !== null) {
319
- this?.log?.debug?.('Performing authentication to "%s"', this.host);
337
+ this?.log?.debug?.('Performing authentication to "%s"', this.#host);
320
338
 
321
339
  let encodedData = TraitMap.encode(
322
340
  TraitMap.fromObject({
323
341
  protocolVersion: 'VERSION_3',
324
- uuid: this.uuid.split(/[._]+/)[1],
342
+ uuid: this.nest_google_uuid.split(/[._]+/)[1],
325
343
  requireConnectedCamera: false,
326
344
  USER_AGENT: USER_AGENT,
327
345
  deviceId: crypto.randomUUID(),
@@ -350,13 +368,12 @@ export default class NexusTalk extends Streamer {
350
368
  return;
351
369
  }
352
370
 
353
- this?.log?.debug?.('Redirect requested from "%s" to "%s"', this.host, redirectToHost);
371
+ this?.log?.debug?.('Redirect requested from "%s" to "%s"', this.#host, redirectToHost);
354
372
 
355
373
  // Setup listener for socket close event. Once socket is closed, we'll perform the redirect
356
- this.#socket &&
357
- this.#socket.on('close', () => {
358
- this.connect(redirectToHost); // Connect to new host
359
- });
374
+ this.#socket?.on?.('close', () => {
375
+ this.connect(redirectToHost); // Connect to new host
376
+ });
360
377
  this.close(true); // Close existing socket
361
378
  }
362
379
 
@@ -368,18 +385,17 @@ export default class NexusTalk extends Streamer {
368
385
  if (stream.codecType === this.codecs.video.toUpperCase()) {
369
386
  this.video = {
370
387
  id: stream.channelId,
371
- startTime: Date.now() + stream.startTime,
388
+ baseTimestamp: stream.startTimestamp,
389
+ baseTime: Date.now(),
372
390
  sampleRate: stream.sampleRate,
373
- timeStamp: 0,
374
391
  };
375
392
  }
376
393
  if (stream.codecType === this.codecs.audio.toUpperCase()) {
377
394
  this.audio = {
378
395
  id: stream.channelId,
379
- startTime: Date.now() + stream.startTime,
396
+ baseTimestamp: stream.startTimestamp,
397
+ baseTime: Date.now(),
380
398
  sampleRate: stream.sampleRate,
381
- timeStamp: 0,
382
- talking: false,
383
399
  };
384
400
  }
385
401
  });
@@ -389,11 +405,26 @@ export default class NexusTalk extends Streamer {
389
405
  this.#packets = [];
390
406
  this.#messages = [];
391
407
 
392
- this?.log?.debug?.('Playback started from "%s" with session ID "%s"', this.host, this.#id);
408
+ this?.log?.debug?.('Playback started from "%s" with session ID "%s"', this.#host, this.#id);
393
409
  }
394
410
  }
395
411
 
396
412
  #handlePlaybackPacket(payload) {
413
+ const calculateTimestamp = (delta, stream) => {
414
+ if (
415
+ typeof delta !== 'number' ||
416
+ typeof stream?.sampleRate !== 'number' ||
417
+ stream?.baseTime === undefined ||
418
+ stream?.baseTimestamp === undefined
419
+ ) {
420
+ return Date.now();
421
+ }
422
+
423
+ let deltaTicks = stream.baseTimestamp + delta - stream.baseTimestamp;
424
+ let deltaMs = (deltaTicks / stream.sampleRate) * 1000;
425
+ return stream.baseTime + deltaMs;
426
+ };
427
+
397
428
  // Decode playback packet
398
429
  if (typeof payload === 'object' && this.#protobufNexusTalk !== undefined) {
399
430
  let decodedMessage = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.PlaybackPacket').decode(payload).toJSON();
@@ -401,30 +432,33 @@ export default class NexusTalk extends Streamer {
401
432
  // Setup up a timeout to monitor for no packets recieved in a certain period
402
433
  // If its trigger, we'll attempt to restart the stream and/or connection
403
434
  // <-- testing to see how often this occurs first
404
- clearTimeout(this.stalledTimer);
405
- this.stalledTimer = setTimeout(() => {
435
+ clearTimeout(this.#stalledTimer);
436
+ this.#stalledTimer = setTimeout(() => {
406
437
  this?.log?.debug?.(
407
438
  'We have not received any data from nexus in the past "%s" seconds for uuid "%s". Attempting restart',
408
439
  10,
409
- this.uuid,
440
+ this.nest_google_uuid,
410
441
  );
411
442
 
412
443
  // Setup listener for socket close event. Once socket is closed, we'll perform the re-connection
413
- this.#socket &&
414
- this.#socket.on('close', () => {
415
- this.connect(); // try reconnection
416
- });
444
+ this.#socket?.on?.('close', () => {
445
+ this.connect(); // try reconnection
446
+ });
417
447
  this.close(false); // Close existing socket
418
448
  }, 10000);
419
449
 
450
+ // Timestamps are rolling — incremented from startTime using timestampDelta per packet
451
+
420
452
  // Handle video packet
421
453
  if (decodedMessage?.channelId !== undefined && decodedMessage.channelId === this.video?.id) {
422
- this.addToOutput('video', Buffer.from(decodedMessage.payload, 'base64'));
454
+ let ts = calculateTimestamp(decodedMessage.timestampDelta, this.video);
455
+ this.add(Streamer.PACKET_TYPE.VIDEO, Buffer.from(decodedMessage.payload, 'base64'), ts);
423
456
  }
424
457
 
425
458
  // Handle audio packet
426
459
  if (decodedMessage?.channelId !== undefined && decodedMessage.channelId === this.audio?.id) {
427
- this.addToOutput('audio', Buffer.from(decodedMessage.payload, 'base64'));
460
+ let ts = calculateTimestamp(decodedMessage.timestampDelta, this.audio);
461
+ this.add(Streamer.PACKET_TYPE.AUDIO, Buffer.from(decodedMessage.payload, 'base64'), ts);
428
462
  }
429
463
  }
430
464
  }
@@ -436,18 +470,17 @@ export default class NexusTalk extends Streamer {
436
470
 
437
471
  if (this.#id !== undefined && decodedMessage.reason === 'USER_ENDED_SESSION') {
438
472
  // Normal playback ended ie: when we stopped playback
439
- this?.log?.debug?.('Playback ended on "%s"', this.host);
473
+ this?.log?.debug?.('Playback ended on "%s"', this.#host);
440
474
  }
441
475
 
442
476
  if (decodedMessage.reason !== 'USER_ENDED_SESSION') {
443
477
  // Error during playback, so we'll attempt to restart by reconnection to host
444
- this?.log?.debug?.('Playback ended on "%s" with error "%s". Attempting reconnection', this.host, decodedMessage.reason);
478
+ this?.log?.debug?.('Playback ended on "%s" with error "%s". Attempting reconnection', this.#host, decodedMessage.reason);
445
479
 
446
480
  // Setup listener for socket close event. Once socket is closed, we'll perform the re-connection
447
- this.#socket &&
448
- this.#socket.on('close', () => {
449
- this.connect(); // try reconnection to existing host
450
- });
481
+ this.#socket?.on?.('close', () => {
482
+ this.connect(); // try reconnection to existing host
483
+ });
451
484
  this.close(false); // Close existing socket
452
485
  }
453
486
  }
@@ -472,7 +505,7 @@ export default class NexusTalk extends Streamer {
472
505
  if (typeof payload === 'object' && this.#protobufNexusTalk !== undefined) {
473
506
  //let decodedMessage = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.TalkbackBegin').decode(payload).toJSON();
474
507
  this.audio.talking = true;
475
- this?.log?.debug?.('Talking started on uuid "%s"', this.uuid);
508
+ this?.log?.debug?.('Talking started on uuid "%s"', this.nest_google_uuid);
476
509
  }
477
510
  }
478
511
 
@@ -481,7 +514,7 @@ export default class NexusTalk extends Streamer {
481
514
  if (typeof payload === 'object' && this.#protobufNexusTalk !== undefined) {
482
515
  //let decodedMessage = this.#protobufNexusTalk.lookup('nest.nexustalk.v1.TalkbackEnd').decode(payload).toJSON();
483
516
  this.audio.talking = false;
484
- this?.log?.debug?.('Talking ended on uuid "%s"', this.uuid);
517
+ this?.log?.debug?.('Talking ended on uuid "%s"', this.nest_google_uuid);
485
518
  }
486
519
  }
487
520
 
@@ -521,8 +554,8 @@ export default class NexusTalk extends Streamer {
521
554
  }
522
555
 
523
556
  // Periodically send PING message to keep stream alive
524
- clearInterval(this.pingTimer);
525
- this.pingTimer = setInterval(() => {
557
+ clearInterval(this.#pingTimer);
558
+ this.#pingTimer = setInterval(() => {
526
559
  this.#sendMessage(PACKET_TYPE.PING, Buffer.alloc(0));
527
560
  }, PING_INTERVAL);
528
561