homebridge-nest-accfactory 0.0.4-a → 0.0.6

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/system.js CHANGED
@@ -1,12 +1,11 @@
1
1
  // Nest System communications
2
2
  // Part of homebridge-nest-accfactory
3
3
  //
4
- // Code version 7/9/2024
4
+ // Code version 13/9/2024
5
5
  // Mark Hulskamp
6
6
  'use strict';
7
7
 
8
8
  // Define external module requirements
9
- import axios from 'axios';
10
9
  import protobuf from 'protobufjs';
11
10
 
12
11
  // Define nodejs module requirements
@@ -24,6 +23,7 @@ import { fileURLToPath } from 'node:url';
24
23
  import HomeKitDevice from './HomeKitDevice.js';
25
24
  import NestCamera from './camera.js';
26
25
  import NestDoorbell from './doorbell.js';
26
+ import NestFloodlight from './floodlight.js';
27
27
  import NestProtect from './protect.js';
28
28
  import NestTemperatureSensor from './tempsensor.js';
29
29
  import NestWeather from './weather.js';
@@ -47,6 +47,7 @@ export default class NestAccfactory {
47
47
  SMOKESENSOR: 'protect',
48
48
  CAMERA: 'camera',
49
49
  DOORBELL: 'doorbell',
50
+ FLOODLIGHT: 'floodlight',
50
51
  WEATHER: 'weather',
51
52
  LOCK: 'lock', // yet to implement
52
53
  ALARM: 'alarm', // yet to implement
@@ -59,13 +60,11 @@ export default class NestAccfactory {
59
60
 
60
61
  static GoogleConnection = 'google'; // Google account connection
61
62
  static NestConnection = 'nest'; // Nest account connection
62
- static SDMConnection = 'sdm'; // NOT coded, but here for future reference
63
- static HomeFoyerConnection = 'foyer'; // Google Home foyer connection
64
63
 
65
64
  cachedAccessories = []; // Track restored cached accessories
66
65
 
67
66
  // Internal data only for this class
68
- #connections = {}; // Array of confirmed connections, indexed by type
67
+ #connections = {}; // Object of confirmed connections
69
68
  #rawData = {}; // Cached copy of data from both Rest and Protobuf APIs
70
69
  #eventEmitter = new EventEmitter(); // Used for object messaging from this platform
71
70
 
@@ -75,18 +74,48 @@ export default class NestAccfactory {
75
74
  this.api = api;
76
75
 
77
76
  // Perform validation on the configuration passed into us and set defaults if not present
78
- if (typeof this.config?.nest !== 'object') {
79
- this.config.nest = {};
80
- }
81
- this.config.nest.access_token = typeof this.config.nest?.access_token === 'string' ? this.config.nest.access_token : '';
82
- this.config.nest.fieldTest = typeof this.config.nest?.fieldTest === 'boolean' ? this.config.nest.fieldTest : false;
83
77
 
84
- if (typeof this.config?.google !== 'object') {
85
- this.config.google = {};
78
+ // Build our accounts connection object. Allows us to have multiple diffent accont connections under the one accessory
79
+ Object.keys(this.config).forEach((key) => {
80
+ if (this.config[key]?.access_token !== undefined && this.config[key].access_token !== '') {
81
+ // Nest account connection, assign a random UUID for each connection
82
+ this.#connections[crypto.randomUUID()] = {
83
+ type: NestAccfactory.NestConnection,
84
+ authorised: false,
85
+ access_token: this.config[key].access_token,
86
+ fieldTest: this.config[key]?.fieldTest === true,
87
+ referer: this.config[key]?.fieldTest === true ? 'home.ft.nest.com' : 'home.nest.com',
88
+ restAPIHost: this.config[key]?.fieldTest === true ? 'home.ft.nest.com' : 'home.nest.com',
89
+ cameraAPIHost: this.config[key]?.fieldTest === true ? 'camera.home.ft.nest.com' : 'camera.home.nest.com',
90
+ protobufAPIHost: this.config[key]?.fieldTest === true ? 'grpc-web.ft.nest.com' : 'grpc-web.production.nest.com',
91
+ };
92
+ }
93
+ if (
94
+ this.config[key]?.issuetoken !== undefined &&
95
+ this.config[key].issuetoken !== '' &&
96
+ this.config[key]?.cookie !== undefined &&
97
+ this.config[key].cookie !== ''
98
+ ) {
99
+ // Google account connection, assign a random UUID for each connection
100
+ this.#connections[crypto.randomUUID()] = {
101
+ type: NestAccfactory.GoogleConnection,
102
+ authorised: false,
103
+ issuetoken: this.config[key].issuetoken,
104
+ cookie: this.config[key].cookie,
105
+ fieldTest: typeof this.config[key]?.fieldTest === 'boolean' ? this.config[key].fieldTest : false,
106
+ referer: this.config[key]?.fieldTest === true ? 'home.ft.nest.com' : 'home.nest.com',
107
+ restAPIHost: this.config[key]?.fieldTest === true ? 'home.ft.nest.com' : 'home.nest.com',
108
+ cameraAPIHost: this.config[key]?.fieldTest === true ? 'camera.home.ft.nest.com' : 'camera.home.nest.com',
109
+ protobufAPIHost: this.config[key]?.fieldTest === true ? 'grpc-web.ft.nest.com' : 'grpc-web.production.nest.com',
110
+ };
111
+ }
112
+ });
113
+
114
+ // If we don't have either a Nest access_token and/or a Google issuetoken/cookie, return back.
115
+ if (Object.keys(this.#connections).length === 0) {
116
+ this?.log?.error && this.log.error('No connections have been specified in the JSON configuration. Please review');
117
+ return;
86
118
  }
87
- this.config.google.issuetoken = typeof this.config.google?.issuetoken === 'string' ? this.config.google.issuetoken : '';
88
- this.config.google.cookie = typeof this.config.google?.cookie === 'string' ? this.config.google.cookie : '';
89
- this.config.google.fieldTest = typeof this.config.google?.fieldTest === 'boolean' ? this.config.google.fieldTest : false;
90
119
 
91
120
  if (typeof this.config?.options !== 'object') {
92
121
  this.config.options = {};
@@ -183,12 +212,6 @@ export default class NestAccfactory {
183
212
  }
184
213
  }
185
214
 
186
- // If we don't have either a Nest access_token and/or a Google issuetoken/cookie, return back.
187
- if (this.config.nest.access_token === '' && (this.config.google.issuetoken === '' || this.config.google.cookie === '')) {
188
- this?.log?.error && this.log.error('JSON plugin configuration is invalid. Please review');
189
- return;
190
- }
191
-
192
215
  if (this.api instanceof EventEmitter === true) {
193
216
  this.api.on('didFinishLaunching', async () => {
194
217
  // We got notified that Homebridge has finished loading, so we are ready to process
@@ -212,249 +235,188 @@ export default class NestAccfactory {
212
235
  }
213
236
 
214
237
  async discoverDevices() {
215
- await this.#connect();
216
- if (this.#connections?.nest !== undefined) {
217
- // We have a 'Nest' connected account, so process accordingly
218
- this.#subscribeREST(NestAccfactory.NestConnection, false);
219
- this.#subscribeProtobuf(NestAccfactory.NestConnection);
220
- }
221
-
222
- if (this.#connections?.google !== undefined) {
223
- // We have a 'Google' connected account, so process accordingly
224
- this.#subscribeREST(NestAccfactory.GoogleConnection, false);
225
- this.#subscribeProtobuf(NestAccfactory.GoogleConnection);
226
- }
227
-
228
238
  // Setup event listeners for set/get calls from devices
229
239
  this.#eventEmitter.addListener(HomeKitDevice.SET, (deviceUUID, values) => this.#set(deviceUUID, values));
230
240
  this.#eventEmitter.addListener(HomeKitDevice.GET, (deviceUUID, values) => this.#get(deviceUUID, values));
241
+
242
+ Object.keys(this.#connections).forEach((uuid) => {
243
+ this.#connect(uuid).then(() => {
244
+ if (this.#connections[uuid].authorised === true) {
245
+ this.#subscribeREST(uuid, true);
246
+ this.#subscribeProtobuf(uuid);
247
+ }
248
+ });
249
+ });
231
250
  }
232
251
 
233
- async #connect() {
234
- if (
235
- typeof this.config?.google === 'object' &&
236
- typeof this.config?.google?.issuetoken === 'string' &&
237
- this.config?.google?.issuetoken !== '' &&
238
- typeof this.config?.google?.cookie === 'string' &&
239
- this.config?.google?.cookie !== ''
240
- ) {
241
- let referer = 'home.nest.com'; // Which host is 'actually' doing the request
242
- let restAPIHost = 'home.nest.com'; // Root URL for Nest system REST API
243
- let cameraAPIHost = 'camera.home.nest.com'; // Root URL for Camera system API
244
- let protobufAPIHost = 'grpc-web.production.nest.com'; // Root URL for Protobuf API
245
-
246
- if (this.config?.google.fieldTest === true) {
247
- // FieldTest mode support enabled in configuration, so update default endpoints
248
- // This is all 'untested'
249
- this?.log?.info && this.log.info('Using FieldTest API endpoints for Google account');
250
-
251
- referer = 'home.ft.nest.com'; // Which host is 'actually' doing the request
252
- restAPIHost = 'home.ft.nest.com'; // Root FT URL for Nest system REST API
253
- cameraAPIHost = 'camera.home.ft.nest.com'; // Root FT URL for Camera system API
254
- protobufAPIHost = 'grpc-web.ft.nest.com'; // Root FT URL for Protobuf API
255
- }
252
+ async #connect(connectionUUID) {
253
+ if (typeof this.#connections?.[connectionUUID] === 'object') {
254
+ this.#connections[connectionUUID].authorised === false; // Mark connection as no-longer authorised
255
+ if (this.#connections[connectionUUID].type === NestAccfactory.GoogleConnection) {
256
+ // Google cookie method as refresh token method no longer supported by Google since October 2022
257
+ // Instructions from homebridge_nest or homebridge_nest_cam to obtain this
258
+ this?.log?.info &&
259
+ this.log.info(
260
+ 'Performing Google account authorisation ' +
261
+ (this.#connections[connectionUUID].fieldTest === true ? 'using field test endpoints' : ''),
262
+ );
263
+
264
+ await fetchWrapper('get', this.#connections[connectionUUID].issuetoken, {
265
+ headers: {
266
+ referer: 'https://accounts.google.com/o/oauth2/iframe',
267
+ 'User-Agent': USERAGENT,
268
+ cookie: this.#connections[connectionUUID].cookie,
269
+ 'Sec-Fetch-Mode': 'cors',
270
+ 'X-Requested-With': 'XmlHttpRequest',
271
+ },
272
+ })
273
+ .then((response) => response.json())
274
+ .then(async (data) => {
275
+ let googleOAuth2Token = data.access_token;
276
+
277
+ await fetchWrapper(
278
+ 'post',
279
+ 'https://nestauthproxyservice-pa.googleapis.com/v1/issue_jwt',
280
+ {
281
+ headers: {
282
+ referer: 'https://' + this.#connections[connectionUUID].referer,
283
+ 'User-Agent': USERAGENT,
284
+ Authorization: data.token_type + ' ' + data.access_token,
285
+ 'Content-Type': 'application/x-www-form-urlencoded',
286
+ },
287
+ },
288
+ 'embed_google_oauth_access_token=true&expire_after=3600s&google_oauth_access_token=' +
289
+ data.access_token +
290
+ '&policy_id=authproxy-oauth-policy',
291
+ )
292
+ .then((response) => response.json())
293
+ .then(async (data) => {
294
+ let googleToken = data.jwt;
295
+ let tokenExpire = Math.floor(new Date(data.claims.expirationTime).valueOf() / 1000); // Token expiry, should be 1hr
296
+
297
+ await fetchWrapper('get', 'https://' + this.#connections[connectionUUID].restAPIHost + '/session', {
298
+ headers: {
299
+ referer: 'https://' + this.#connections[connectionUUID].referer,
300
+ 'User-Agent': USERAGENT,
301
+ Authorization: 'Basic ' + googleToken,
302
+ },
303
+ })
304
+ .then((response) => response.json())
305
+ .then((data) => {
306
+ // Store successful connection details
307
+ this.#connections[connectionUUID].authorised = true;
308
+ this.#connections[connectionUUID].userID = data.userid;
309
+ this.#connections[connectionUUID].transport_url = data.urls.transport_url;
310
+ this.#connections[connectionUUID].weather_url = data.urls.weather_url;
311
+ this.#connections[connectionUUID].protobufRoot = null;
312
+ this.#connections[connectionUUID].token = googleToken;
313
+ this.#connections[connectionUUID].cameraAPI = {
314
+ key: 'Authorization',
315
+ value: 'Basic ',
316
+ token: googleToken,
317
+ oauth2: googleOAuth2Token,
318
+ };
256
319
 
257
- // Google cookie method as refresh token method no longer supported by Google since October 2022
258
- // Instructions from homebridge_nest or homebridge_nest_cam to obtain this
259
- this?.log?.info && this.log.info('Performing Google account authorisation');
320
+ // Set timeout for token expiry refresh
321
+ this.#connections[connectionUUID].timer = clearInterval(this.#connections[connectionUUID].timer);
322
+ this.#connections[connectionUUID].timer = setTimeout(
323
+ () => {
324
+ this?.log?.info && this.log.info('Performing periodic token refresh for Google account');
325
+ this.#connect(connectionUUID);
326
+ },
327
+ (tokenExpire - Math.floor(Date.now() / 1000) - 60) * 1000,
328
+ ); // Refresh just before token expiry
260
329
 
261
- let request = {
262
- method: 'get',
263
- url: this.config.google.issuetoken,
264
- headers: {
265
- referer: 'https://accounts.google.com/o/oauth2/iframe',
266
- 'User-Agent': USERAGENT,
267
- cookie: this.config.google.cookie,
268
- 'Sec-Fetch-Mode': 'cors',
269
- 'X-Requested-With': 'XmlHttpRequest',
270
- },
271
- };
272
- await axios(request)
273
- .then(async (response) => {
274
- if (typeof response.status !== 'number' || response.status !== 200) {
275
- throw new Error('Google API Authorisation failed with error');
276
- }
277
- this.special = response.data.access_token;
330
+ this?.log?.success && this.log.success('Successfully authorised using Google account');
331
+ });
332
+ });
333
+ })
334
+ // eslint-disable-next-line no-unused-vars
335
+ .catch((error) => {
336
+ // The token we used to obtained a Nest session failed, so overall authorisation failed
337
+ this?.log?.error && this.log.error('Authorisation failed using Google account');
338
+ });
339
+ }
278
340
 
279
- let request = {
280
- method: 'post',
281
- url: 'https://nestauthproxyservice-pa.googleapis.com/v1/issue_jwt',
341
+ if (this.#connections[connectionUUID].type === NestAccfactory.NestConnection) {
342
+ // Nest access token method. Get WEBSITE2 cookie for use with camera API calls if needed later
343
+ this?.log?.info &&
344
+ this.log.info(
345
+ 'Performing Nest account authorisation ' +
346
+ (this.#connections[connectionUUID].fieldTest === true ? 'using field test endpoints' : ''),
347
+ );
348
+
349
+ await fetchWrapper(
350
+ 'post',
351
+ 'https://webapi.' + this.#connections[connectionUUID].cameraAPIHost + '/api/v1/login.login_nest',
352
+ {
353
+ withCredentials: true,
282
354
  headers: {
283
- referer: 'https://' + referer,
355
+ referer: 'https://' + this.#connections[connectionUUID].referer,
284
356
  'User-Agent': USERAGENT,
285
- Authorization: 'Bearer ' + response.data.access_token,
357
+ 'Content-Type': 'application/x-www-form-urlencoded',
286
358
  },
287
- data:
288
- 'embed_google_oauth_access_token=true&expire_after=3600s&google_oauth_access_token=' +
289
- response.data.access_token +
290
- '&policy_id=authproxy-oauth-policy',
291
- };
292
-
293
- await axios(request).then(async (response) => {
294
- if (typeof response.status !== 'number' || response.status !== 200) {
295
- throw new Error('Google Camera API Token get failed with error');
359
+ },
360
+ Buffer.from('access_token=' + this.#connections[connectionUUID].access_token, 'utf8'),
361
+ )
362
+ .then((response) => response.json())
363
+ .then(async (data) => {
364
+ if (data?.items?.[0]?.session_token === undefined) {
365
+ throw new Error('No Nest session token was obtained');
296
366
  }
297
367
 
298
- let googleToken = response.data.jwt;
299
- let tokenExpire = Math.floor(new Date(response.data.claims.expirationTime).valueOf() / 1000); // Token expiry, should be 1hr
368
+ let nestToken = data.items[0].session_token;
300
369
 
301
- let request = {
302
- method: 'get',
303
- url: 'https://' + restAPIHost + '/session',
370
+ await fetchWrapper('get', 'https://' + this.#connections[connectionUUID].restAPIHost + '/session', {
304
371
  headers: {
305
- referer: 'https://' + referer,
372
+ referer: 'https://' + this.#connections[connectionUUID].referer,
306
373
  'User-Agent': USERAGENT,
307
- Authorization: 'Basic ' + googleToken,
374
+ Authorization: 'Basic ' + this.#connections[connectionUUID].access_token,
308
375
  },
309
- };
310
-
311
- await axios(request).then(async (response) => {
312
- if (typeof response.status !== 'number' || response.status !== 200) {
313
- throw new Error('Nest Session API get failed with error');
314
- }
376
+ })
377
+ .then((response) => response.json())
378
+ .then((data) => {
379
+ // Store successful connection details
380
+ this.#connections[connectionUUID].authorised = true;
381
+ this.#connections[connectionUUID].userID = data.userid;
382
+ this.#connections[connectionUUID].transport_url = data.urls.transport_url;
383
+ this.#connections[connectionUUID].weather_url = data.urls.weather_url;
384
+ this.#connections[connectionUUID].protobufRoot = null;
385
+ this.#connections[connectionUUID].token = this.#connections[connectionUUID].access_token;
386
+ this.#connections[connectionUUID].cameraAPI = {
387
+ key: 'cookie',
388
+ value: this.#connections[connectionUUID].fieldTest === true ? 'website_ft=' : 'website_2=',
389
+ token: nestToken,
390
+ };
315
391
 
316
- this?.log?.success && this.log.success('Successfully authorised using Google account');
317
-
318
- // Store successful connection details
319
- this.#connections['google'] = {
320
- type: 'google',
321
- referer: referer,
322
- restAPIHost: restAPIHost,
323
- cameraAPIHost: cameraAPIHost,
324
- protobufAPIHost: protobufAPIHost,
325
- userID: response.data.userid,
326
- transport_url: response.data.urls.transport_url,
327
- weather_url: response.data.urls.weather_url,
328
- timer: null,
329
- protobufRoot: null,
330
- token: googleToken,
331
- cameraAPI: {
332
- key: 'Authorization',
333
- value: 'Basic ',
334
- token: googleToken,
335
- },
336
- };
337
-
338
- // Set timeout for token expiry refresh
339
- clearInterval(this.#connections['google'].timer);
340
- this.#connections['google'].timer = setTimeout(
341
- () => {
342
- this?.log?.info && this.log.info('Performing periodic token refresh for Google account');
343
- this.#connect();
344
- },
345
- (tokenExpire - Math.floor(Date.now() / 1000) - 60) * 1000,
346
- ); // Refresh just before token expiry
347
- });
392
+ // Set timeout for token expiry refresh
393
+ this.#connections[connectionUUID].timer = clearInterval(this.#connections[connectionUUID].timer);
394
+ this.#connections[connectionUUID].timer = setTimeout(
395
+ () => {
396
+ this?.log?.info && this.log.info('Performing periodic token refresh for Nest account');
397
+ this.#connect(connectionUUID);
398
+ },
399
+ 1000 * 3600 * 24,
400
+ ); // Refresh token every 24hrs
401
+
402
+ this?.log?.success && this.log.success('Successfully authorised using Nest account');
403
+ });
404
+ })
405
+ // eslint-disable-next-line no-unused-vars
406
+ .catch((error) => {
407
+ // The token we used to obtained a Nest session failed, so overall authorisation failed
408
+ this?.log?.error && this.log.error('Authorisation failed using Nest account');
348
409
  });
349
- })
350
- // eslint-disable-next-line no-unused-vars
351
- .catch((error) => {
352
- // The token we used to obtained a Nest session failed, so overall authorisation failed
353
- this?.log?.error && this.log.error('Authorisation failed using Google account');
354
- });
355
- }
356
-
357
- if (typeof this.config?.nest?.access_token === 'string' && this.config?.nest?.access_token !== '') {
358
- let referer = 'home.nest.com'; // Which host is 'actually' doing the request
359
- let restAPIHost = 'home.nest.com'; // Root URL for Nest system REST API
360
- let cameraAPIHost = 'camera.home.nest.com'; // Root URL for Camera system API
361
- let protobufAPIHost = 'grpc-web.production.nest.com'; // Root URL for Protobuf API
362
-
363
- if (this.config?.nest.fieldTest === true) {
364
- // FieldTest mode support enabled in configuration, so update default endpoints
365
- // This is all 'untested'
366
- this?.log?.info && this.log.info('Using FieldTest API endpoints for Nest account');
367
-
368
- referer = 'home.ft.nest.com'; // Which host is 'actually' doing the request
369
- restAPIHost = 'home.ft.nest.com'; // Root FT URL for Nest system REST API
370
- cameraAPIHost = 'camera.home.ft.nest.com'; // Root FT URL for Camera system API
371
- protobufAPIHost = 'grpc-web.ft.nest.com'; // Root FT URL for Protobuf API
372
410
  }
373
-
374
- // Nest access token method. Get WEBSITE2 cookie for use with camera API calls if needed later
375
- this?.log?.info && this.log.info('Performing Nest account authorisation');
376
-
377
- let request = {
378
- method: 'post',
379
- url: 'https://webapi.' + cameraAPIHost + '/api/v1/login.login_nest',
380
- withCredentials: true,
381
- headers: {
382
- referer: 'https://' + referer,
383
- 'Content-Type': 'application/x-www-form-urlencoded',
384
- 'User-Agent': USERAGENT,
385
- },
386
- data: Buffer.from('access_token=' + this.config.nest.access_token, 'utf8'),
387
- };
388
- await axios(request)
389
- .then(async (response) => {
390
- if (
391
- typeof response.status !== 'number' ||
392
- response.status !== 200 ||
393
- typeof response.data.status !== 'number' ||
394
- response.data.status !== 0
395
- ) {
396
- throw new Error('Nest API Authorisation failed with error');
397
- }
398
-
399
- let nestToken = response.data.items[0].session_token;
400
-
401
- let request = {
402
- method: 'get',
403
- url: 'https://' + restAPIHost + '/session',
404
- headers: {
405
- referer: 'https://' + referer,
406
- 'User-Agent': USERAGENT,
407
- Authorization: 'Basic ' + this.config.nest.access_token,
408
- },
409
- };
410
-
411
- await axios(request).then((response) => {
412
- if (typeof response.status !== 'number' || response.status !== 200) {
413
- throw new Error('Nest Session API get failed with error');
414
- }
415
-
416
- this?.log?.success && this.log.success('Successfully authorised using Nest account');
417
-
418
- // Store successful connection details
419
- this.#connections['nest'] = {
420
- type: 'nest',
421
- referer: referer,
422
- restAPIHost: restAPIHost,
423
- cameraAPIHost: cameraAPIHost,
424
- protobufAPIHost: protobufAPIHost,
425
- userID: response.data.userid,
426
- transport_url: response.data.urls.transport_url,
427
- weather_url: response.data.urls.weather_url,
428
- timer: null,
429
- protobufRoot: null,
430
- token: this.config.nest.access_token,
431
- cameraAPI: {
432
- key: 'cookie',
433
- value: this.config.fieldTest === true ? 'website_ft=' : 'website_2=',
434
- token: nestToken,
435
- },
436
- };
437
-
438
- // Set timeout for token expiry refresh
439
- clearInterval(this.#connections['nest'].timer);
440
- this.#connections['nest'].timer = setTimeout(
441
- () => {
442
- this?.log?.info && this.log.info('Performing periodic token refresh for Nest account');
443
- this.#connect();
444
- },
445
- 1000 * 3600 * 24,
446
- ); // Refresh token every 24hrs
447
- });
448
- })
449
- // eslint-disable-next-line no-unused-vars
450
- .catch((error) => {
451
- // The token we used to obtained a Nest session failed, so overall authorisation failed
452
- this?.log?.error && this.log.error('Authorisation failed using Nest account');
453
- });
454
411
  }
455
412
  }
456
413
 
457
- async #subscribeREST(connectionType, fullRefresh) {
414
+ async #subscribeREST(connectionUUID, fullRefresh) {
415
+ if (typeof this.#connections?.[connectionUUID] !== 'object' || this.#connections?.[connectionUUID]?.authorised !== true) {
416
+ // Not a valid connection object and/or we're not authorised
417
+ return;
418
+ }
419
+
458
420
  const REQUIREDBUCKETS = [
459
421
  'buckets',
460
422
  'structure',
@@ -479,64 +441,56 @@ export default class NestAccfactory {
479
441
  quartz: ['where_id', 'structure_id', 'nexus_api_http_server_url'],
480
442
  };
481
443
 
482
- let restAPIURL = '';
483
- let restAPIJSONData = {};
484
- if (Object.keys(this.#rawData).length === 0 || (typeof fullRefresh === 'boolean' && fullRefresh === true)) {
485
- // Setup for a full data read from REST API
486
- restAPIURL =
487
- 'https://' +
488
- this.#connections[connectionType].restAPIHost +
489
- '/api/0.1/user/' +
490
- this.#connections[connectionType].userID +
491
- '/app_launch';
492
- restAPIJSONData = { known_bucket_types: REQUIREDBUCKETS, known_bucket_versions: [] };
493
- }
494
- if (Object.keys(this.#rawData).length !== 0 && typeof fullRefresh === 'boolean' && fullRefresh === false) {
495
- // Setup to subscribe to object changes we know about from REST API
496
- restAPIURL = this.#connections[connectionType].transport_url + '/v6/subscribe';
497
- restAPIJSONData = { objects: [] };
498
-
499
- Object.entries(this.#rawData).forEach(([object_key]) => {
500
- if (
501
- this.#rawData[object_key]?.source === NestAccfactory.DataSource.REST &&
502
- this.#rawData[object_key]?.connection === connectionType &&
503
- typeof this.#rawData[object_key]?.object_revision === 'number' &&
504
- typeof this.#rawData[object_key]?.object_timestamp === 'number'
505
- ) {
506
- restAPIJSONData.objects.push({
444
+ // By default, setup for a full data read from the REST API
445
+ let subscribeURL =
446
+ 'https://' +
447
+ this.#connections[connectionUUID].restAPIHost +
448
+ '/api/0.1/user/' +
449
+ this.#connections[connectionUUID].userID +
450
+ '/app_launch';
451
+ let subscribeJSONData = { known_bucket_types: REQUIREDBUCKETS, known_bucket_versions: [] };
452
+
453
+ if (fullRefresh === false) {
454
+ // We have data stored from ths REST API, so setup read using known objects
455
+ subscribeURL = this.#connections[connectionUUID].transport_url + '/v6/subscribe';
456
+ subscribeJSONData = { objects: [] };
457
+
458
+ Object.entries(this.#rawData)
459
+ // eslint-disable-next-line no-unused-vars
460
+ .filter(([object_key, object]) => object.source === NestAccfactory.DataSource.REST && object.connection === connectionUUID)
461
+ .forEach(([object_key, object]) => {
462
+ subscribeJSONData.objects.push({
507
463
  object_key: object_key,
508
- object_revision: this.#rawData[object_key].object_revision,
509
- object_timestamp: this.#rawData[object_key].object_timestamp,
464
+ object_revision: object.object_revision,
465
+ object_timestamp: object.object_timestamp,
510
466
  });
511
- }
512
- });
467
+ });
513
468
  }
514
469
 
515
- let request = {
516
- method: 'post',
517
- url: restAPIURL,
518
- responseType: 'json',
519
- headers: {
520
- 'User-Agent': USERAGENT,
521
- Authorization: 'Basic ' + this.#connections[connectionType].token,
470
+ fetchWrapper(
471
+ 'post',
472
+ subscribeURL,
473
+ {
474
+ headers: {
475
+ referer: 'https://' + this.#connections[connectionUUID].referer,
476
+ 'User-Agent': USERAGENT,
477
+ Authorization: 'Basic ' + this.#connections[connectionUUID].token,
478
+ },
479
+ keepalive: true,
480
+ timeout: 300 * 60,
522
481
  },
523
- data: JSON.stringify(restAPIJSONData),
524
- };
525
- axios(request)
526
- .then(async (response) => {
527
- if (typeof response.status !== 'number' || response.status !== 200) {
528
- throw new Error('REST API subscription failed with error');
529
- }
530
-
531
- let data = {};
482
+ JSON.stringify(subscribeJSONData),
483
+ )
484
+ .then((response) => response.json())
485
+ .then(async (data) => {
532
486
  let deviceChanges = []; // No REST API devices changes to start with
533
- if (typeof response.data?.updated_buckets === 'object') {
487
+ if (typeof data?.updated_buckets === 'object') {
534
488
  // This response is full data read
535
- data = response.data.updated_buckets;
489
+ data = data.updated_buckets;
536
490
  }
537
- if (typeof response.data?.objects === 'object') {
491
+ if (typeof data?.objects === 'object') {
538
492
  // This response contains subscribed data updates
539
- data = response.data.objects;
493
+ data = data.objects;
540
494
  }
541
495
 
542
496
  // Process the data we received
@@ -555,7 +509,7 @@ export default class NestAccfactory {
555
509
  value.value.weather = this.#rawData[value.object_key].value.weather;
556
510
  }
557
511
  value.value.weather = await this.#getWeatherData(
558
- connectionType,
512
+ connectionUUID,
559
513
  value.object_key,
560
514
  value.value.latitude,
561
515
  value.value.longitude,
@@ -580,34 +534,30 @@ export default class NestAccfactory {
580
534
  ? this.#rawData[value.object_key].value.properties
581
535
  : [];
582
536
 
583
- let request = {
584
- method: 'get',
585
- url:
586
- 'https://webapi.' +
587
- this.#connections[connectionType].cameraAPIHost +
537
+ await fetchWrapper(
538
+ 'get',
539
+ 'https://webapi.' +
540
+ this.#connections[connectionUUID].cameraAPIHost +
588
541
  '/api/cameras.get_with_properties?uuid=' +
589
542
  value.object_key.split('.')[1],
590
- headers: {
591
- referer: 'https://' + this.#connections[connectionType].referer,
592
- 'User-Agent': USERAGENT,
593
- [this.#connections[connectionType].cameraAPI.key]:
594
- this.#connections[connectionType].cameraAPI.value + this.#connections[connectionType].cameraAPI.token,
543
+ {
544
+ headers: {
545
+ referer: 'https://' + this.#connections[connectionUUID].referer,
546
+ 'User-Agent': USERAGENT,
547
+ [this.#connections[connectionUUID].cameraAPI.key]:
548
+ this.#connections[connectionUUID].cameraAPI.value + this.#connections[connectionUUID].cameraAPI.token,
549
+ },
550
+ timeout: NESTAPITIMEOUT,
595
551
  },
596
- responseType: 'json',
597
- timeout: NESTAPITIMEOUT,
598
- };
599
- await axios(request)
600
- .then((response) => {
601
- if (typeof response.status !== 'number' || response.status !== 200) {
602
- throw new Error('REST API had error retrieving camera/doorbell details');
603
- }
604
-
605
- value.value.properties = response.data.items[0].properties;
552
+ )
553
+ .then((response) => response.json())
554
+ .then((data) => {
555
+ value.value.properties = data.items[0].properties;
606
556
  })
607
557
  .catch((error) => {
608
- this?.log?.debug &&
609
- this?.log?.debug &&
610
- this.log.debug('REST API had error retrieving camera/doorbell details. Error was "%s"', error?.code);
558
+ if (error?.name !== 'TimeoutError' && this?.log?.debug) {
559
+ this.log.debug('REST API had error retrieving camera/doorbell details during subscribe. Error was "%s"', error?.code);
560
+ }
611
561
  });
612
562
 
613
563
  value.value.activity_zones =
@@ -615,26 +565,19 @@ export default class NestAccfactory {
615
565
  ? this.#rawData[value.object_key].value.activity_zones
616
566
  : [];
617
567
 
618
- request = {
619
- method: 'get',
620
- url: value.value.nexus_api_http_server_url + '/cuepoint_category/' + value.object_key.split('.')[1],
568
+ await fetchWrapper('get', value.value.nexus_api_http_server_url + '/cuepoint_category/' + value.object_key.split('.')[1], {
621
569
  headers: {
622
- referer: 'https://' + this.#connections[connectionType].referer,
570
+ referer: 'https://' + this.#connections[connectionUUID].referer,
623
571
  'User-Agent': USERAGENT,
624
- [this.#connections[connectionType].cameraAPI.key]:
625
- this.#connections[connectionType].cameraAPI.value + this.#connections[connectionType].cameraAPI.token,
572
+ [this.#connections[connectionUUID].cameraAPI.key]:
573
+ this.#connections[connectionUUID].cameraAPI.value + this.#connections[connectionUUID].cameraAPI.token,
626
574
  },
627
- responseType: 'json',
628
575
  timeout: NESTAPITIMEOUT,
629
- };
630
- await axios(request)
631
- .then((response) => {
632
- if (typeof response.status !== 'number' || response.status !== 200) {
633
- throw new Error('REST API had error retrieving camera/doorbell activity zones');
634
- }
635
-
576
+ })
577
+ .then((response) => response.json())
578
+ .then((data) => {
636
579
  let zones = [];
637
- response.data.forEach((zone) => {
580
+ data.forEach((zone) => {
638
581
  if (zone.type.toUpperCase() === 'ACTIVITY' || zone.type.toUpperCase() === 'REGION') {
639
582
  zones.push({
640
583
  id: zone.id === 0 ? 1 : zone.id,
@@ -648,9 +591,12 @@ export default class NestAccfactory {
648
591
  value.value.activity_zones = zones;
649
592
  })
650
593
  .catch((error) => {
651
- this?.log?.debug &&
652
- this?.log?.debug &&
653
- this.log.debug('REST API had error retrieving camera/doorbell activity zones. Error was "%s"', error?.code);
594
+ if (error?.name !== 'TimeoutError' && this?.log?.debug) {
595
+ this.log.debug(
596
+ 'REST API had error retrieving camera/doorbell activity zones during subscribe. Error was "%s"',
597
+ error?.code,
598
+ );
599
+ }
654
600
  });
655
601
  }
656
602
 
@@ -687,7 +633,7 @@ export default class NestAccfactory {
687
633
  this.#rawData[value.object_key] = {};
688
634
  this.#rawData[value.object_key].object_revision = value.object_revision;
689
635
  this.#rawData[value.object_key].object_timestamp = value.object_timestamp;
690
- this.#rawData[value.object_key].connection = connectionType;
636
+ this.#rawData[value.object_key].connection = connectionUUID;
691
637
  this.#rawData[value.object_key].source = NestAccfactory.DataSource.REST;
692
638
  this.#rawData[value.object_key].timers = {}; // No timers running for this object
693
639
  this.#rawData[value.object_key].value = {};
@@ -716,16 +662,21 @@ export default class NestAccfactory {
716
662
  await this.#processPostSubscribe(deviceChanges);
717
663
  })
718
664
  .catch((error) => {
719
- if (error?.code !== 'ECONNRESET') {
720
- this?.log?.error && this.log.error('REST API subscription failed with error "%s"', error?.code);
665
+ if (error?.name !== 'TimeoutError' && this?.log?.debug) {
666
+ this.log.debug('REST API had an error performing subscription. Will restart subscription');
721
667
  }
722
668
  })
723
669
  .finally(() => {
724
- setTimeout(this.#subscribeREST.bind(this, connectionType, fullRefresh), 1000);
670
+ setTimeout(this.#subscribeREST.bind(this, connectionUUID, fullRefresh), 1000);
725
671
  });
726
672
  }
727
673
 
728
- async #subscribeProtobuf(connectionType) {
674
+ async #subscribeProtobuf(connectionUUID) {
675
+ if (typeof this.#connections?.[connectionUUID] !== 'object' || this.#connections?.[connectionUUID]?.authorised !== true) {
676
+ // Not a valid connection object and/or we're not authorised
677
+ return;
678
+ }
679
+
729
680
  const calculate_message_size = (inputBuffer) => {
730
681
  // First byte in the is a tag type??
731
682
  // Following is a varint type
@@ -761,177 +712,182 @@ export default class NestAccfactory {
761
712
  }
762
713
  };
763
714
 
764
- let observeTraits = null;
765
- if (fs.existsSync(path.resolve(__dirname + '/protobuf/root.proto')) === true) {
715
+ // Attempt to load in protobuf files if not already done so for this connection
716
+ if (
717
+ this.#connections[connectionUUID].protobufRoot === null &&
718
+ fs.existsSync(path.resolve(__dirname + '/protobuf/root.proto')) === true
719
+ ) {
766
720
  protobuf.util.Long = null;
767
721
  protobuf.configure();
768
- this.#connections[connectionType].protobufRoot = protobuf.loadSync(path.resolve(__dirname + '/protobuf/root.proto'));
769
- if (this.#connections[connectionType].protobufRoot !== null) {
770
- // Loaded in the protobuf files, so now dynamically build the 'observe' post body data based on what we have loaded
771
- let observeTraitsList = [];
772
- let traitTypeObserveParam = this.#connections[connectionType].protobufRoot.lookup('nestlabs.gateway.v2.TraitTypeObserveParams');
773
- let observeRequest = this.#connections[connectionType].protobufRoot.lookup('nestlabs.gateway.v2.ObserveRequest');
774
- if (traitTypeObserveParam !== null && observeRequest !== null) {
775
- traverseTypes(this.#connections[connectionType].protobufRoot, (type) => {
776
- // We only want to have certain trait main 'families' in our observe reponse we are building
777
- // This also depends on the account type we connected with. Nest accounts cannot observe camera/doorbell product traits
778
- if (
779
- (connectionType === NestAccfactory.NestConnection &&
780
- type.fullName.startsWith('.nest.trait.product.camera') === false &&
781
- type.fullName.startsWith('.nest.trait.product.doorbell') === false &&
782
- (type.fullName.startsWith('.nest.trait') === true || type.fullName.startsWith('.weave.') === true)) ||
783
- (connectionType === NestAccfactory.GoogleConnection &&
784
- (type.fullName.startsWith('.nest.trait') === true ||
785
- type.fullName.startsWith('.weave.') === true ||
786
- type.fullName.startsWith('.google.trait.product.camera') === true))
787
- ) {
788
- observeTraitsList.push(traitTypeObserveParam.create({ traitType: type.fullName.replace(/^\.*|\.*$/g, '') }));
789
- }
790
- });
791
- observeTraits = observeRequest.encode(observeRequest.create({ stateTypes: [1, 2], traitTypeParams: observeTraitsList })).finish();
792
- }
793
- }
722
+ this.#connections[connectionUUID].protobufRoot = protobuf.loadSync(path.resolve(__dirname + '/protobuf/root.proto'));
794
723
  }
795
724
 
796
- let request = {
797
- method: 'post',
798
- url: 'https://' + this.#connections[connectionType].protobufAPIHost + '/nestlabs.gateway.v2.GatewayService/Observe',
799
- headers: {
800
- 'User-Agent': USERAGENT,
801
- Authorization: 'Basic ' + this.#connections[connectionType].token,
802
- 'Content-Type': 'application/x-protobuf',
803
- 'X-Accept-Content-Transfer-Encoding': 'binary',
804
- 'X-Accept-Response-Streaming': 'true',
805
- },
806
- responseType: 'stream',
807
- data: observeTraits,
808
- };
809
- axios(request)
810
- .then(async (response) => {
811
- if (typeof response.status !== 'number' || response.status !== 200) {
812
- throw new Error('protobuf API had error perform trait observe');
813
- }
814
-
815
- let deviceChanges = []; // No protobuf API devices changes to start with
816
- let buffer = Buffer.alloc(0);
817
- for await (const chunk of response.data) {
818
- buffer = Buffer.concat([buffer, Buffer.from(chunk)]);
819
- let messageSize = calculate_message_size(buffer);
820
- if (buffer.length >= messageSize) {
821
- let decodedMessage = {};
822
- try {
823
- // Attempt to decode the protobuf message(s) we extracted from the stream and get a JSON object representation
824
- decodedMessage = this.#connections[connectionType].protobufRoot
825
- .lookup('nest.rpc.StreamBody')
826
- .decode(buffer.subarray(0, messageSize))
827
- .toJSON();
828
- if (typeof decodedMessage?.message !== 'object') {
829
- decodedMessage.message = [];
830
- }
831
- if (typeof decodedMessage?.message[0]?.get !== 'object') {
832
- decodedMessage.message[0].get = [];
833
- }
834
- if (typeof decodedMessage?.message[0]?.resourceMetas !== 'object') {
835
- decodedMessage.message[0].resourceMetas = [];
836
- }
725
+ if (this.#connections[connectionUUID].protobufRoot !== null) {
726
+ // Loaded in the Protobuf files, so now dynamically build the 'observe' post body data based on what we have loaded
727
+ let observeTraitsList = [];
728
+ let observeBody = Buffer.alloc(0);
729
+ let traitTypeObserveParam = this.#connections[connectionUUID].protobufRoot.lookup('nestlabs.gateway.v2.TraitTypeObserveParams');
730
+ let observeRequest = this.#connections[connectionUUID].protobufRoot.lookup('nestlabs.gateway.v2.ObserveRequest');
731
+ if (traitTypeObserveParam !== null && observeRequest !== null) {
732
+ traverseTypes(this.#connections[connectionUUID].protobufRoot, (type) => {
733
+ // We only want to have certain trait'families' in our observe reponse we are building
734
+ // This also depends on the account type we connected with
735
+ // Nest accounts cannot observe camera/doorbell product traits
736
+ if (
737
+ (this.#connections[connectionUUID].type === NestAccfactory.NestConnection &&
738
+ type.fullName.startsWith('.nest.trait.product.camera') === false &&
739
+ type.fullName.startsWith('.nest.trait.product.doorbell') === false &&
740
+ (type.fullName.startsWith('.nest.trait') === true || type.fullName.startsWith('.weave.') === true)) ||
741
+ (this.#connections[connectionUUID].type === NestAccfactory.GoogleConnection &&
742
+ (type.fullName.startsWith('.nest.trait') === true ||
743
+ type.fullName.startsWith('.weave.') === true ||
744
+ type.fullName.startsWith('.google.trait.product.camera') === true))
745
+ ) {
746
+ observeTraitsList.push(traitTypeObserveParam.create({ traitType: type.fullName.replace(/^\.*|\.*$/g, '') }));
747
+ }
748
+ });
749
+ observeBody = observeRequest.encode(observeRequest.create({ stateTypes: [1, 2], traitTypeParams: observeTraitsList })).finish();
750
+ }
837
751
 
838
- // Tidy up our received messages. This ensures we only have one status for the trait in the data we process
839
- // We'll favour a trait with accepted status over the same with confirmed status
840
- let notAcceptedStatus = decodedMessage.message[0].get.filter((trait) => trait.stateTypes.includes('ACCEPTED') === false);
841
- let acceptedStatus = decodedMessage.message[0].get.filter((trait) => trait.stateTypes.includes('ACCEPTED') === true);
842
- let difference = acceptedStatus.map((trait) => trait.traitId.resourceId + '/' + trait.traitId.traitLabel);
843
- decodedMessage.message[0].get =
844
- ((notAcceptedStatus = notAcceptedStatus.filter(
845
- (trait) => difference.includes(trait.traitId.resourceId + '/' + trait.traitId.traitLabel) === false,
846
- )),
847
- [...notAcceptedStatus, ...acceptedStatus]);
848
-
849
- // We'll use the resource status message to look for structure and/or device removals
850
- // We could also check for structure and/or device additions here, but we'll want to be flagged
851
- // that a device is 'ready' for use before we add in. This data is populated in the trait data
852
- decodedMessage.message[0].resourceMetas.map(async (resource) => {
853
- if (
854
- resource.status === 'REMOVED' &&
855
- (resource.resourceId.startsWith('STRUCTURE_') || resource.resourceId.startsWith('DEVICE_'))
856
- ) {
857
- // We have the removal of a 'home' and/ device
858
- deviceChanges.push({ object_key: resource.resourceId, change: 'removed' });
752
+ fetchWrapper(
753
+ 'post',
754
+ 'https://' + this.#connections[connectionUUID].protobufAPIHost + '/nestlabs.gateway.v2.GatewayService/Observe',
755
+ {
756
+ headers: {
757
+ referer: 'https://' + this.#connections[connectionUUID].referer,
758
+ 'User-Agent': USERAGENT,
759
+ Authorization: 'Basic ' + this.#connections[connectionUUID].token,
760
+ 'Content-Type': 'application/x-protobuf',
761
+ 'X-Accept-Content-Transfer-Encoding': 'binary',
762
+ 'X-Accept-Response-Streaming': 'true',
763
+ },
764
+ keepalive: true,
765
+ timeout: 300 * 60,
766
+ },
767
+ observeBody,
768
+ )
769
+ .then((response) => response.body)
770
+ .then(async (data) => {
771
+ let deviceChanges = []; // No Protobuf API devices changes to start with
772
+ let buffer = Buffer.alloc(0);
773
+ for await (const chunk of data) {
774
+ buffer = Buffer.concat([buffer, Buffer.from(chunk)]);
775
+ let messageSize = calculate_message_size(buffer);
776
+ if (buffer.length >= messageSize) {
777
+ let decodedMessage = {};
778
+ try {
779
+ // Attempt to decode the Protobuf message(s) we extracted from the stream and get a JSON object representation
780
+ decodedMessage = this.#connections[connectionUUID].protobufRoot
781
+ .lookup('nestlabs.gateway.v2.ObserveResponse')
782
+ .decode(buffer.subarray(0, messageSize))
783
+ .toJSON();
784
+
785
+ // Tidy up our received messages. This ensures we only have one status for the trait in the data we process
786
+ // We'll favour a trait with accepted status over the same with confirmed status
787
+ if (decodedMessage?.observeResponse?.[0]?.traitStates !== undefined) {
788
+ let notAcceptedStatus = decodedMessage.observeResponse[0].traitStates.filter(
789
+ (trait) => trait.stateTypes.includes('ACCEPTED') === false,
790
+ );
791
+ let acceptedStatus = decodedMessage.observeResponse[0].traitStates.filter(
792
+ (trait) => trait.stateTypes.includes('ACCEPTED') === true,
793
+ );
794
+ let difference = acceptedStatus.map((trait) => trait.traitId.resourceId + '/' + trait.traitId.traitLabel);
795
+ decodedMessage.observeResponse[0].traitStates =
796
+ ((notAcceptedStatus = notAcceptedStatus.filter(
797
+ (trait) => difference.includes(trait.traitId.resourceId + '/' + trait.traitId.traitLabel) === false,
798
+ )),
799
+ [...notAcceptedStatus, ...acceptedStatus]);
859
800
  }
860
- });
861
- // eslint-disable-next-line no-unused-vars
862
- } catch (error) {
863
- // Empty
864
- }
865
- buffer = buffer.subarray(messageSize); // Remove the message from the beginning of the buffer
866
-
867
- if (typeof decodedMessage?.message[0]?.get === 'object') {
868
- await Promise.all(
869
- decodedMessage.message[0].get.map(async (trait) => {
870
- if (trait.traitId.traitLabel === 'configuration_done') {
801
+ // We'll use the resource status message to look for structure and/or device removals
802
+ // We could also check for structure and/or device additions here, but we'll want to be flagged
803
+ // that a device is 'ready' for use before we add in. This data is populated in the trait data
804
+ if (decodedMessage?.observeResponse?.[0]?.resourceMetas !== undefined) {
805
+ decodedMessage.observeResponse[0].resourceMetas.map(async (resource) => {
871
806
  if (
872
- this.#rawData[trait.traitId.resourceId]?.value?.configuration_done?.deviceReady !== true &&
873
- trait.patch.values?.deviceReady === true
807
+ resource.status === 'REMOVED' &&
808
+ (resource.resourceId.startsWith('STRUCTURE_') || resource.resourceId.startsWith('DEVICE_'))
874
809
  ) {
875
- deviceChanges.push({ object_key: trait.traitId.resourceId, change: 'add' });
810
+ // We have the removal of a 'home' and/ device
811
+ deviceChanges.push({ object_key: resource.resourceId, change: 'removed' });
876
812
  }
877
- }
878
- if (trait.traitId.traitLabel === 'camera_migration_status') {
879
- // Handle case of camera/doorbell(s) which have been migrated from Nest to Google Home
880
- if (
881
- this.#rawData[trait.traitId.resourceId]?.value?.camera_migration_status?.state?.where !== 'MIGRATED_TO_GOOGLE_HOME' &&
882
- trait.patch.values?.state?.where === 'MIGRATED_TO_GOOGLE_HOME' &&
883
- this.#rawData[trait.traitId.resourceId]?.value?.camera_migration_status?.state?.progress !== 'PROGRESS_COMPLETE' &&
884
- trait.patch.values?.state?.progress === 'PROGRESS_COMPLETE'
885
- ) {
886
- deviceChanges.push({ object_key: trait.traitId.resourceId, change: 'add' });
813
+ });
814
+ }
815
+ // eslint-disable-next-line no-unused-vars
816
+ } catch (error) {
817
+ // Empty
818
+ }
819
+ buffer = buffer.subarray(messageSize); // Remove the message from the beginning of the buffer
820
+
821
+ if (typeof decodedMessage?.observeResponse?.[0]?.traitStates === 'object') {
822
+ await Promise.all(
823
+ decodedMessage.observeResponse[0].traitStates.map(async (trait) => {
824
+ if (trait.traitId.traitLabel === 'configuration_done') {
825
+ if (
826
+ this.#rawData[trait.traitId.resourceId]?.value?.configuration_done?.deviceReady !== true &&
827
+ trait.patch.values?.deviceReady === true
828
+ ) {
829
+ deviceChanges.push({ object_key: trait.traitId.resourceId, change: 'add' });
830
+ }
831
+ }
832
+ if (trait.traitId.traitLabel === 'camera_migration_status') {
833
+ // Handle case of camera/doorbell(s) which have been migrated from Nest to Google Home
834
+ if (
835
+ this.#rawData[trait.traitId.resourceId]?.value?.camera_migration_status?.state?.where !==
836
+ 'MIGRATED_TO_GOOGLE_HOME' &&
837
+ trait.patch.values?.state?.where === 'MIGRATED_TO_GOOGLE_HOME' &&
838
+ this.#rawData[trait.traitId.resourceId]?.value?.camera_migration_status?.state?.progress !== 'PROGRESS_COMPLETE' &&
839
+ trait.patch.values?.state?.progress === 'PROGRESS_COMPLETE'
840
+ ) {
841
+ deviceChanges.push({ object_key: trait.traitId.resourceId, change: 'add' });
842
+ }
887
843
  }
888
- }
889
844
 
890
- if (typeof this.#rawData[trait.traitId.resourceId] === 'undefined') {
891
- this.#rawData[trait.traitId.resourceId] = {};
892
- this.#rawData[trait.traitId.resourceId].connection = connectionType;
893
- this.#rawData[trait.traitId.resourceId].source = NestAccfactory.DataSource.PROTOBUF;
894
- this.#rawData[trait.traitId.resourceId].timers = {}; // No timers running for this object
895
- this.#rawData[trait.traitId.resourceId].value = {};
896
- }
897
- this.#rawData[trait.traitId.resourceId]['value'][trait.traitId.traitLabel] =
898
- typeof trait.patch.values !== 'undefined' ? trait.patch.values : {};
845
+ if (typeof this.#rawData[trait.traitId.resourceId] === 'undefined') {
846
+ this.#rawData[trait.traitId.resourceId] = {};
847
+ this.#rawData[trait.traitId.resourceId].connection = connectionUUID;
848
+ this.#rawData[trait.traitId.resourceId].source = NestAccfactory.DataSource.PROTOBUF;
849
+ this.#rawData[trait.traitId.resourceId].timers = {}; // No timers running for this object
850
+ this.#rawData[trait.traitId.resourceId].value = {};
851
+ }
852
+ this.#rawData[trait.traitId.resourceId]['value'][trait.traitId.traitLabel] =
853
+ typeof trait.patch.values !== 'undefined' ? trait.patch.values : {};
899
854
 
900
- // We don't need to store the trait type, so remove it
901
- delete this.#rawData[trait.traitId.resourceId]['value'][trait.traitId.traitLabel]['@type'];
855
+ // We don't need to store the trait type, so remove it
856
+ delete this.#rawData[trait.traitId.resourceId]['value'][trait.traitId.traitLabel]['@type'];
902
857
 
903
- // If we have structure location details and associated geo-location details, get the weather data for the location
904
- // We'll store this in the object key/value as per REST API
905
- if (
906
- trait.traitId.resourceId.startsWith('STRUCTURE_') === true &&
907
- trait.traitId.traitLabel === 'structure_location' &&
908
- typeof trait.patch.values?.geoCoordinate?.latitude === 'number' &&
909
- typeof trait.patch.values?.geoCoordinate?.longitude === 'number'
910
- ) {
911
- this.#rawData[trait.traitId.resourceId].value.weather = await this.#getWeatherData(
912
- connectionType,
913
- trait.traitId.resourceId,
914
- trait.patch.values.geoCoordinate.latitude,
915
- trait.patch.values.geoCoordinate.longitude,
916
- );
917
- }
918
- }),
919
- );
858
+ // If we have structure location details and associated geo-location details, get the weather data for the location
859
+ // We'll store this in the object key/value as per REST API
860
+ if (
861
+ trait.traitId.resourceId.startsWith('STRUCTURE_') === true &&
862
+ trait.traitId.traitLabel === 'structure_location' &&
863
+ typeof trait.patch.values?.geoCoordinate?.latitude === 'number' &&
864
+ typeof trait.patch.values?.geoCoordinate?.longitude === 'number'
865
+ ) {
866
+ this.#rawData[trait.traitId.resourceId].value.weather = await this.#getWeatherData(
867
+ connectionUUID,
868
+ trait.traitId.resourceId,
869
+ trait.patch.values.geoCoordinate.latitude,
870
+ trait.patch.values.geoCoordinate.longitude,
871
+ );
872
+ }
873
+ }),
874
+ );
920
875
 
921
- await this.#processPostSubscribe(deviceChanges);
922
- deviceChanges = []; // No more device changes now
876
+ await this.#processPostSubscribe(deviceChanges);
877
+ deviceChanges = []; // No more device changes now
878
+ }
923
879
  }
924
880
  }
925
- }
926
- })
927
- .catch((error) => {
928
- if (error?.code !== 'ECONNRESET') {
929
- this?.log?.error && this.log.error('protobuf API had error perform trait observe. Error was "%s"', error?.code);
930
- }
931
- })
932
- .finally(() => {
933
- setTimeout(this.#subscribeProtobuf.bind(this, connectionType), 1000);
934
- });
881
+ })
882
+ .catch((error) => {
883
+ if (error?.name !== 'TimeoutError' && this?.log?.debug) {
884
+ this.log.debug('Protobuf API had an error performing trait observe. Will restart observe');
885
+ }
886
+ })
887
+ .finally(() => {
888
+ setTimeout(this.#subscribeProtobuf.bind(this, connectionUUID), 1000);
889
+ });
890
+ }
935
891
  }
936
892
 
937
893
  async #processPostSubscribe(deviceChanges) {
@@ -966,27 +922,37 @@ export default class NestAccfactory {
966
922
  // Device isn't marked as excluded, so create the required HomeKit accessories based upon the device data
967
923
  if (deviceData.device_type === NestAccfactory.DeviceType.THERMOSTAT && typeof NestThermostat === 'function') {
968
924
  // Nest Thermostat(s) - Categories.THERMOSTAT = 9
925
+ this?.log?.debug &&
926
+ this.log.debug('Using "%s" data source for "%s"', this.#rawData[object.object_key]?.source, deviceData.description);
969
927
  let tempDevice = new NestThermostat(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
970
928
  tempDevice.add('Nest Thermostat', 9, true);
971
929
  }
972
930
 
973
931
  if (deviceData.device_type === NestAccfactory.DeviceType.TEMPSENSOR && typeof NestTemperatureSensor === 'function') {
974
932
  // Nest Temperature Sensor - Categories.SENSOR = 10
933
+ this?.log?.debug &&
934
+ this.log.debug('Using "%s" data source for "%s"', this.#rawData[object.object_key]?.source, deviceData.description);
975
935
  let tempDevice = new NestTemperatureSensor(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
976
936
  tempDevice.add('Nest Temperature Sensor', 10, true);
977
937
  }
978
938
 
979
939
  if (deviceData.device_type === NestAccfactory.DeviceType.SMOKESENSOR && typeof NestProtect === 'function') {
980
940
  // Nest Protect(s) - Categories.SENSOR = 10
941
+ this?.log?.debug &&
942
+ this.log.debug('Using "%s" data source for "%s"', this.#rawData[object.object_key]?.source, deviceData.description);
981
943
  let tempDevice = new NestProtect(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
982
944
  tempDevice.add('Nest Protect', 10, true);
983
945
  }
984
946
 
985
947
  if (
986
948
  (deviceData.device_type === NestAccfactory.DeviceType.CAMERA ||
987
- deviceData.device_type === NestAccfactory.DeviceType.DOORBELL) &&
988
- (typeof NestCamera === 'function' || typeof NestDoorbell === 'function')
949
+ deviceData.device_type === NestAccfactory.DeviceType.DOORBELL ||
950
+ deviceData.device_type === NestAccfactory.DeviceType.FLOODLIGHT) &&
951
+ (typeof NestCamera === 'function' || typeof NestDoorbell === 'function' || typeof NestFloodlight === 'function')
989
952
  ) {
953
+ this?.log?.debug &&
954
+ this.log.debug('Using "%s" data source for "%s"', this.#rawData[object.object_key]?.source, deviceData.description);
955
+
990
956
  let accessoryName = 'Nest ' + deviceData.model.replace(/\s*(?:\([^()]*\))/gi, '');
991
957
  if (deviceData.device_type === NestAccfactory.DeviceType.CAMERA) {
992
958
  // Nest Camera(s) - Categories.IP_CAMERA = 17
@@ -998,39 +964,41 @@ export default class NestAccfactory {
998
964
  let tempDevice = new NestDoorbell(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
999
965
  tempDevice.add(accessoryName, 18, true);
1000
966
  }
967
+ if (deviceData.device_type === NestAccfactory.DeviceType.FLOODLIGHT) {
968
+ // Nest Camera(s) with Floodlight - Categories.IP_CAMERA = 17
969
+ let tempDevice = new NestFloodlight(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
970
+ tempDevice.add(accessoryName, 17, true);
971
+ }
1001
972
 
1002
973
  // Setup polling loop for camera/doorbell zone data if not already created.
1003
- // This is only required for REST API data sources as these details are present in protobuf API
974
+ // This is only required for REST API data sources as these details are present in Protobuf API
1004
975
  if (
1005
- typeof this.#rawData[object.object_key]?.timers?.zones === 'undefined' &&
976
+ this.#rawData?.[object.object_key] !== undefined &&
977
+ this.#rawData[object.object_key]?.timers?.zones === undefined &&
1006
978
  this.#rawData[object.object_key].source === NestAccfactory.DataSource.REST
1007
979
  ) {
1008
980
  this.#rawData[object.object_key].timers.zones = setInterval(async () => {
1009
- if (typeof this.#rawData[object.object_key]?.value === 'object') {
1010
- let request = {
1011
- method: 'get',
1012
- url:
1013
- this.#rawData[object.object_key].value.nexus_api_http_server_url +
981
+ if (this.#rawData?.[object.object_key]?.value?.nexus_api_http_server_url !== undefined) {
982
+ await fetchWrapper(
983
+ 'get',
984
+ this.#rawData[object.object_key].value.nexus_api_http_server_url +
1014
985
  '/cuepoint_category/' +
1015
986
  object.object_key.split('.')[1],
1016
- headers: {
1017
- referer: 'https://' + this.#connections[this.#rawData[object.object_key].connection].referer,
1018
- 'User-Agent': USERAGENT,
1019
- [this.#connections[this.#rawData[object.object_key].connection].cameraAPI.key]:
1020
- this.#connections[this.#rawData[object.object_key].connection].cameraAPI.value +
1021
- this.#connections[this.#rawData[object.object_key].connection].cameraAPI.token,
987
+ {
988
+ headers: {
989
+ referer: 'https://' + this.#connections[this.#rawData[object.object_key].connection].referer,
990
+ 'User-Agent': USERAGENT,
991
+ [this.#connections[this.#rawData[object.object_key].connection].cameraAPI.key]:
992
+ this.#connections[this.#rawData[object.object_key].connection].cameraAPI.value +
993
+ this.#connections[this.#rawData[object.object_key].connection].cameraAPI.token,
994
+ },
995
+ timeout: CAMERAZONEPOLLING,
1022
996
  },
1023
- responseType: 'json',
1024
- timeout: CAMERAZONEPOLLING,
1025
- };
1026
- await axios(request)
1027
- .then((response) => {
1028
- if (typeof response.status !== 'number' || response.status !== 200) {
1029
- throw new Error('REST API had error retrieving camera/doorbell activity zones');
1030
- }
1031
-
997
+ )
998
+ .then((response) => response.json())
999
+ .then((data) => {
1032
1000
  let zones = [];
1033
- response.data.forEach((zone) => {
1001
+ data.forEach((zone) => {
1034
1002
  if (zone.type.toUpperCase() === 'ACTIVITY' || zone.type.toUpperCase() === 'REGION') {
1035
1003
  zones.push({
1036
1004
  id: zone.id === 0 ? 1 : zone.id,
@@ -1050,13 +1018,12 @@ export default class NestAccfactory {
1050
1018
  })
1051
1019
  .catch((error) => {
1052
1020
  // Log debug message if wasn't a timeout
1053
- if (error?.code !== 'ECONNABORTED') {
1054
- this?.log?.debug &&
1055
- this.log.debug(
1056
- 'REST API had error retrieving camera/doorbell activity zones for uuid "%s". Error was "%s"',
1057
- object.object_key,
1058
- error?.code,
1059
- );
1021
+ if (error?.name !== 'TimeoutError' && this?.log?.debug) {
1022
+ this.log.debug(
1023
+ 'REST API had error retrieving camera/doorbell activity zones for uuid "%s". Error was "%s"',
1024
+ object.object_key,
1025
+ error?.code,
1026
+ );
1060
1027
  }
1061
1028
  });
1062
1029
  }
@@ -1064,7 +1031,7 @@ export default class NestAccfactory {
1064
1031
  }
1065
1032
 
1066
1033
  // Setup polling loop for camera/doorbell alert data if not already created
1067
- if (typeof this.#rawData[object.object_key]?.timers?.alerts === 'undefined') {
1034
+ if (this.#rawData?.[object.object_key] !== undefined && this.#rawData?.[object.object_key]?.timers?.alerts === undefined) {
1068
1035
  this.#rawData[object.object_key].timers.alerts = setInterval(async () => {
1069
1036
  if (
1070
1037
  typeof this.#rawData[object.object_key]?.value === 'object' &&
@@ -1072,28 +1039,40 @@ export default class NestAccfactory {
1072
1039
  ) {
1073
1040
  let alerts = []; // No alerts yet
1074
1041
 
1075
- let commandResponse = await this.#protobufCommand(object.object_key, [
1042
+ let commandResponse = await this.#protobufCommand(
1043
+ this.#rawData[object.object_key].connection,
1044
+ 'ResourceApi',
1045
+ 'SendCommand',
1076
1046
  {
1077
- traitLabel: 'camera_observation_history',
1078
- command: {
1079
- type_url: 'type.nestlabs.com/nest.trait.history.CameraObservationHistoryTrait.CameraObservationHistoryRequest',
1080
- value: {
1081
- // We want camera history from now for upto 30secs from now
1082
- queryStartTime: { seconds: Math.floor(Date.now() / 1000), nanos: (Math.round(Date.now()) % 1000) * 1e6 },
1083
- queryEndTime: {
1084
- seconds: Math.floor((Date.now() + 30000) / 1000),
1085
- nanos: (Math.round(Date.now() + 30000) % 1000) * 1e6,
1047
+ resourceRequest: {
1048
+ resourceId: object.object_key,
1049
+ requestId: crypto.randomUUID(),
1050
+ },
1051
+ resourceCommands: [
1052
+ {
1053
+ traitLabel: 'camera_observation_history',
1054
+ command: {
1055
+ type_url:
1056
+ 'type.nestlabs.com/nest.trait.history.CameraObservationHistoryTrait.CameraObservationHistoryRequest',
1057
+ value: {
1058
+ // We want camera history from now for upto 30secs from now
1059
+ queryStartTime: { seconds: Math.floor(Date.now() / 1000), nanos: (Math.round(Date.now()) % 1000) * 1e6 },
1060
+ queryEndTime: {
1061
+ seconds: Math.floor((Date.now() + 30000) / 1000),
1062
+ nanos: (Math.round(Date.now() + 30000) % 1000) * 1e6,
1063
+ },
1064
+ },
1086
1065
  },
1087
1066
  },
1088
- },
1067
+ ],
1089
1068
  },
1090
- ]);
1069
+ );
1091
1070
 
1092
1071
  if (
1093
- typeof commandResponse?.resourceCommandResponse?.[0]?.traitOperations?.[0]?.event?.event?.cameraEventWindow
1072
+ typeof commandResponse?.sendCommandResponse?.[0]?.traitOperations?.[0]?.event?.event?.cameraEventWindow
1094
1073
  ?.cameraEvent === 'object'
1095
1074
  ) {
1096
- commandResponse.resourceCommandResponse[0].traitOperations[0].event.event.cameraEventWindow.cameraEvent.forEach(
1075
+ commandResponse.sendCommandResponse[0].traitOperations[0].event.event.cameraEventWindow.cameraEvent.forEach(
1097
1076
  (event) => {
1098
1077
  alerts.push({
1099
1078
  playback_time: parseInt(event.startTime.seconds) * 1000 + parseInt(event.startTime.nanos) / 1000000,
@@ -1115,7 +1094,7 @@ export default class NestAccfactory {
1115
1094
  // 'EVENT_UNFAMILIAR_FACE' = 'unfamiliar-face'
1116
1095
  // 'EVENT_PERSON_TALKING' = 'personHeard'
1117
1096
  // 'EVENT_DOG_BARKING' = 'dogBarking'
1118
- // <---- TODO (as the ones we use match from protobuf)
1097
+ // <---- TODO (as the ones we use match from Protobuf)
1119
1098
  },
1120
1099
  );
1121
1100
 
@@ -1140,31 +1119,27 @@ export default class NestAccfactory {
1140
1119
  this.#rawData[object.object_key]?.source === NestAccfactory.DataSource.REST
1141
1120
  ) {
1142
1121
  let alerts = []; // No alerts yet
1143
- let request = {
1144
- method: 'get',
1145
- url:
1146
- this.#rawData[object.object_key].value.nexus_api_http_server_url +
1122
+ await fetchWrapper(
1123
+ 'get',
1124
+ this.#rawData[object.object_key].value.nexus_api_http_server_url +
1147
1125
  '/cuepoint/' +
1148
1126
  object.object_key.split('.')[1] +
1149
1127
  '/2?start_time=' +
1150
1128
  Math.floor(Date.now() / 1000 - 30),
1151
- headers: {
1152
- referer: 'https://' + this.#connections[this.#rawData[object.object_key].connection].referer,
1153
- 'User-Agent': USERAGENT,
1154
- [this.#connections[this.#rawData[object.object_key].connection].cameraAPI.key]:
1155
- this.#connections[this.#rawData[object.object_key].connection].cameraAPI.value +
1156
- this.#connections[this.#rawData[object.object_key].connection].cameraAPI.token,
1129
+ {
1130
+ headers: {
1131
+ referer: 'https://' + this.#connections[this.#rawData[object.object_key].connection].referer,
1132
+ 'User-Agent': USERAGENT,
1133
+ [this.#connections[this.#rawData[object.object_key].connection].cameraAPI.key]:
1134
+ this.#connections[this.#rawData[object.object_key].connection].cameraAPI.value +
1135
+ this.#connections[this.#rawData[object.object_key].connection].cameraAPI.token,
1136
+ },
1137
+ timeout: CAMERAALERTPOLLING,
1157
1138
  },
1158
- responseType: 'json',
1159
- timeout: CAMERAALERTPOLLING,
1160
- };
1161
- await axios(request)
1162
- .then((response) => {
1163
- if (typeof response.status !== 'number' || response.status !== 200) {
1164
- throw new Error('REST API had error retrieving camera/doorbell activity notifications');
1165
- }
1166
-
1167
- response.data.forEach((alert) => {
1139
+ )
1140
+ .then((response) => response.json())
1141
+ .then((data) => {
1142
+ data.forEach((alert) => {
1168
1143
  // Fix up alert zone IDs. If there is an ID of 0, we'll transform to 1. ie: main zone
1169
1144
  // If there are NO zone IDs, we'll put a 1 in there ie: main zone
1170
1145
  alert.zone_ids = alert.zone_ids.map((id) => (id !== 0 ? id : 1));
@@ -1190,13 +1165,12 @@ export default class NestAccfactory {
1190
1165
  })
1191
1166
  .catch((error) => {
1192
1167
  // Log debug message if wasn't a timeout
1193
- if (error?.code !== 'ECONNABORTED') {
1194
- this?.log?.debug &&
1195
- this.log.debug(
1196
- 'REST API had error retrieving camera/doorbell activity notifications for uuid "%s". Error was "%s"',
1197
- object.object_key,
1198
- error?.code,
1199
- );
1168
+ if (error?.name !== 'TimeoutError' && this?.log?.debug) {
1169
+ this.log.debug(
1170
+ 'REST API had error retrieving camera/doorbell activity notifications for uuid "%s". Error was "%s"',
1171
+ object.object_key,
1172
+ error?.code,
1173
+ );
1200
1174
  }
1201
1175
  });
1202
1176
 
@@ -1212,6 +1186,8 @@ export default class NestAccfactory {
1212
1186
  }
1213
1187
  if (deviceData.device_type === NestAccfactory.DeviceType.WEATHER && typeof NestWeather === 'function') {
1214
1188
  // Nest 'Virtual' weather station - Categories.SENSOR = 10
1189
+ this?.log?.debug &&
1190
+ this.log.debug('Using "%s" data source for "%s"', this.#rawData[object.object_key]?.source, deviceData.description);
1215
1191
  let tempDevice = new NestWeather(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
1216
1192
  tempDevice.add('Nest Weather', 10, true);
1217
1193
 
@@ -1251,12 +1227,12 @@ export default class NestAccfactory {
1251
1227
  let devices = {};
1252
1228
 
1253
1229
  // Get the device(s) location from stucture
1254
- // We'll test in both REST and protobuf API data
1230
+ // We'll test in both REST and Protobuf API data
1255
1231
  const get_location_name = (structure_id, where_id) => {
1256
1232
  let location = '';
1257
1233
 
1258
1234
  // Check REST data
1259
- if (typeof this.#rawData['where.' + structure_id]?.value === 'object') {
1235
+ if (typeof this.#rawData?.['where.' + structure_id]?.value === 'object') {
1260
1236
  this.#rawData['where.' + structure_id].value.wheres.forEach((value) => {
1261
1237
  if (where_id === value.where_id) {
1262
1238
  location = value.name;
@@ -1264,7 +1240,7 @@ export default class NestAccfactory {
1264
1240
  });
1265
1241
  }
1266
1242
 
1267
- // Check protobuf data
1243
+ // Check Protobuf data
1268
1244
  if (typeof this.#rawData[structure_id]?.value?.located_annotations?.predefinedWheres === 'object') {
1269
1245
  Object.values(this.#rawData[structure_id].value.located_annotations.predefinedWheres).forEach((value) => {
1270
1246
  if (value.whereId.resourceId === where_id) {
@@ -1358,7 +1334,7 @@ export default class NestAccfactory {
1358
1334
  .forEach(([object_key, value]) => {
1359
1335
  let tempDevice = {};
1360
1336
  try {
1361
- if (value.source === NestAccfactory.DataSource.PROTOBUF) {
1337
+ if (value?.source === NestAccfactory.DataSource.PROTOBUF) {
1362
1338
  let RESTTypeData = {};
1363
1339
  RESTTypeData.mac_address = Buffer.from(value.value.wifi_interface.macAddress, 'base64');
1364
1340
  RESTTypeData.serial_number = value.value.device_identity.serialNumber;
@@ -1398,59 +1374,57 @@ export default class NestAccfactory {
1398
1374
  ? true
1399
1375
  : false;
1400
1376
  RESTTypeData.can_cool =
1401
- value.value.hvac_equipment_capabilities.hasStage1Cool === true ||
1402
- value.value.hvac_equipment_capabilities.hasStage2Cool === true ||
1403
- value.value.hvac_equipment_capabilities.hasStage3Cool === true;
1377
+ value.value?.hvac_equipment_capabilities?.hasStage1Cool === true ||
1378
+ value.value?.hvac_equipment_capabilities?.hasStage2Cool === true ||
1379
+ value.value?.hvac_equipment_capabilities?.hasStage3Cool === true;
1404
1380
  RESTTypeData.can_heat =
1405
- value.value.hvac_equipment_capabilities.hasStage1Heat === true ||
1406
- value.value.hvac_equipment_capabilities.hasStage2Heat === true ||
1407
- value.value.hvac_equipment_capabilities.hasStage3Heat === true;
1408
- RESTTypeData.temperature_lock = value.value.temperature_lock_settings.enabled === true;
1381
+ value.value?.hvac_equipment_capabilities?.hasStage1Heat === true ||
1382
+ value.value?.hvac_equipment_capabilities?.hasStage2Heat === true ||
1383
+ value.value?.hvac_equipment_capabilities?.hasStage3Heat === true;
1384
+ RESTTypeData.temperature_lock = value.value?.temperature_lock_settings?.enabled === true;
1409
1385
  RESTTypeData.temperature_lock_pin_hash =
1410
- typeof value.value.temperature_lock_settings.pinHash === 'string' && value.value.temperature_lock_settings.enabled === true
1411
- ? value.value.temperature_lock_settings.pinHash
1412
- : '';
1413
- RESTTypeData.away = value.value.structure_mode.structureMode === 'STRUCTURE_MODE_AWAY';
1414
- RESTTypeData.occupancy = value.value.structure_mode.structureMode === 'STRUCTURE_MODE_HOME';
1386
+ value.value?.temperature_lock_settings?.enabled === true ? value.value.temperature_lock_settings.pinHash : '';
1387
+ RESTTypeData.away = value.value?.structure_mode?.structureMode === 'STRUCTURE_MODE_AWAY';
1388
+ RESTTypeData.occupancy = value.value?.structure_mode?.structureMode === 'STRUCTURE_MODE_HOME';
1415
1389
  //RESTTypeData.occupancy = (value.value.structure_mode.occupancy.activity === 'ACTIVITY_ACTIVE');
1416
- RESTTypeData.vacation_mode = value.value.structure_mode.structureMode === 'STRUCTURE_MODE_VACATION';
1417
- RESTTypeData.description = typeof value.value.label?.label === 'string' ? value.value.label.label : '';
1390
+ RESTTypeData.vacation_mode = value.value?.structure_mode?.structureMode === 'STRUCTURE_MODE_VACATION';
1391
+ RESTTypeData.description = value.value.label?.label !== undefined ? value.value.label.label : '';
1418
1392
  RESTTypeData.location = get_location_name(
1419
- value.value.device_info.pairerId.resourceId,
1420
- value.value.device_located_settings.whereAnnotationRid.resourceId,
1393
+ value.value?.device_info?.pairerId?.resourceId,
1394
+ value.value?.device_located_settings?.whereAnnotationRid?.resourceId,
1421
1395
  );
1422
1396
 
1423
1397
  // Work out current mode. ie: off, cool, heat, range and get temperature low/high and target
1424
1398
  RESTTypeData.hvac_mode =
1425
- value.value.target_temperature_settings.enabled.value === true
1399
+ value.value?.target_temperature_settings?.enabled?.value === true &&
1400
+ value.value?.target_temperature_settings?.targetTemperature?.setpointType !== undefined
1426
1401
  ? value.value.target_temperature_settings.targetTemperature.setpointType.split('SET_POINT_TYPE_')[1].toLowerCase()
1427
1402
  : 'off';
1428
1403
  RESTTypeData.target_temperature_low =
1429
- typeof value.value.target_temperature_settings.targetTemperature.heatingTarget.value === 'number'
1404
+ typeof value.value?.target_temperature_settings?.targetTemperature?.heatingTarget?.value === 'number'
1430
1405
  ? value.value.target_temperature_settings.targetTemperature.heatingTarget.value
1431
1406
  : 0.0;
1432
1407
  RESTTypeData.target_temperature_high =
1433
- typeof value.value.target_temperature_settings.targetTemperature.coolingTarget.value === 'number'
1408
+ typeof value.value?.target_temperature_settings?.targetTemperature?.coolingTarget?.value === 'number'
1434
1409
  ? value.value.target_temperature_settings.targetTemperature.coolingTarget.value
1435
1410
  : 0.0;
1436
- if (value.value.target_temperature_settings.targetTemperature.setpointType === 'SET_POINT_TYPE_COOL') {
1437
- // Target temperature is the cooling point
1438
- RESTTypeData.target_temperature = value.value.target_temperature_settings.targetTemperature.coolingTarget.value;
1439
- }
1440
- if (value.value.target_temperature_settings.targetTemperature.setpointType === 'SET_POINT_TYPE_HEAT') {
1441
- // Target temperature is the heating point
1442
- RESTTypeData.target_temperature = value.value.target_temperature_settings.targetTemperature.heatingTarget.value;
1443
- }
1444
- if (value.value.target_temperature_settings.targetTemperature.setpointType === 'SET_POINT_TYPE_RANGE') {
1445
- // Target temperature is in between the heating and cooling point
1446
- RESTTypeData.target_temperature =
1447
- (value.value.target_temperature_settings.targetTemperature.coolingTarget.value +
1448
- value.value.target_temperature_settings.targetTemperature.heatingTarget.value) *
1449
- 0.5;
1450
- }
1411
+ RESTTypeData.target_temperature =
1412
+ value.value?.target_temperature_settings?.targetTemperature?.setpointType === 'SET_POINT_TYPE_COOL' &&
1413
+ typeof value.value?.target_temperature_settings?.targetTemperature?.coolingTarget?.value === 'number'
1414
+ ? value.value.target_temperature_settings.targetTemperature.coolingTarget.value
1415
+ : value.value?.target_temperature_settings?.targetTemperature?.setpointType === 'SET_POINT_TYPE_HEAT' &&
1416
+ typeof value.value?.target_temperature_settings?.targetTemperature?.heatingTarget?.value === 'number'
1417
+ ? value.value.target_temperature_settings.targetTemperature.heatingTarget.value
1418
+ : value.value?.target_temperature_settings?.targetTemperature?.setpointType === 'SET_POINT_TYPE_RANGE' &&
1419
+ typeof value.value?.target_temperature_settings?.targetTemperature?.coolingTarget?.value === 'number' &&
1420
+ typeof value.value?.target_temperature_settings?.targetTemperature?.heatingTarget?.value === 'number'
1421
+ ? (value.value.target_temperature_settings.targetTemperature.coolingTarget.value +
1422
+ value.value.target_temperature_settings.targetTemperature.heatingTarget.value) *
1423
+ 0.5
1424
+ : 0.0;
1451
1425
 
1452
1426
  // Work out if eco mode is active and adjust temperature low/high and target
1453
- if (value.value.eco_mode_state.ecoMode !== 'ECO_MODE_INACTIVE') {
1427
+ if (value.value?.eco_mode_state?.ecoMode !== 'ECO_MODE_INACTIVE') {
1454
1428
  RESTTypeData.target_temperature_low = value.value.eco_mode_settings.ecoTemperatureHeat.value.value;
1455
1429
  RESTTypeData.target_temperature_high = value.value.eco_mode_settings.ecoTemperatureCool.value.value;
1456
1430
  if (
@@ -1482,21 +1456,21 @@ export default class NestAccfactory {
1482
1456
  // Work out current state ie: heating, cooling etc
1483
1457
  RESTTypeData.hvac_state = 'off'; // By default, we're not heating or cooling
1484
1458
  if (
1485
- value.value.hvac_control.hvacState.coolStage1Active === true ||
1486
- value.value.hvac_control.hvacState.coolStage2Active === true ||
1487
- value.value.hvac_control.hvacState.coolStage2Active === true
1459
+ value.value?.hvac_control?.hvacState?.coolStage1Active === true ||
1460
+ value.value?.hvac_control?.hvacState?.coolStage2Active === true ||
1461
+ value.value?.hvac_control?.hvacState?.coolStage2Active === true
1488
1462
  ) {
1489
1463
  // A cooling source is on, so we're in cooling mode
1490
1464
  RESTTypeData.hvac_state = 'cooling';
1491
1465
  }
1492
1466
  if (
1493
- value.value.hvac_control.hvacState.heatStage1Active === true ||
1494
- value.value.hvac_control.hvacState.heatStage2Active === true ||
1495
- value.value.hvac_control.hvacState.heatStage3Active === true ||
1496
- value.value.hvac_control.hvacState.alternateHeatStage1Active === true ||
1497
- value.value.hvac_control.hvacState.alternateHeatStage2Active === true ||
1498
- value.value.hvac_control.hvacState.auxiliaryHeatActive === true ||
1499
- value.value.hvac_control.hvacState.emergencyHeatActive === true
1467
+ value.value?.hvac_control?.hvacState?.heatStage1Active === true ||
1468
+ value.value?.hvac_control?.hvacState?.heatStage2Active === true ||
1469
+ value.value?.hvac_control?.hvacState?.heatStage3Active === true ||
1470
+ value.value?.hvac_control?.hvacState?.alternateHeatStage1Active === true ||
1471
+ value.value?.hvac_control?.hvacState?.alternateHeatStage2Active === true ||
1472
+ value.value?.hvac_control?.hvacState?.auxiliaryHeatActive === true ||
1473
+ value.value?.hvac_control?.hvacState?.emergencyHeatActive === true
1500
1474
  ) {
1501
1475
  // A heating source is on, so we're in heating mode
1502
1476
  RESTTypeData.hvac_state = 'heating';
@@ -1524,20 +1498,20 @@ export default class NestAccfactory {
1524
1498
 
1525
1499
  // Process any temperature sensors associated with this thermostat
1526
1500
  RESTTypeData.active_rcs_sensor =
1527
- typeof value.value.remote_comfort_sensing_settings?.activeRcsSelection?.activeRcsSensor === 'string'
1501
+ value.value.remote_comfort_sensing_settings?.activeRcsSelection?.activeRcsSensor !== undefined
1528
1502
  ? value.value.remote_comfort_sensing_settings.activeRcsSelection.activeRcsSensor.resourceId
1529
1503
  : '';
1530
1504
  RESTTypeData.linked_rcs_sensors = [];
1531
- if (typeof value.value?.remote_comfort_sensing_settings?.associatedRcsSensors === 'object') {
1505
+ if (value.value?.remote_comfort_sensing_settings?.associatedRcsSensors !== undefined) {
1532
1506
  value.value.remote_comfort_sensing_settings.associatedRcsSensors.forEach((sensor) => {
1533
- if (typeof this.#rawData?.[sensor.deviceId.resourceId]?.value === 'object') {
1507
+ if (this.#rawData?.[sensor.deviceId.resourceId]?.value !== undefined) {
1534
1508
  this.#rawData[sensor.deviceId.resourceId].value.associated_thermostat = object_key; // Sensor is linked to this thermostat
1535
1509
 
1536
1510
  // Get sensor online/offline status
1537
- // 'liveness' property doesn't appear in protobuf data for temp sensors, so we'll add that object here
1511
+ // 'liveness' property doesn't appear in Protobuf data for temp sensors, so we'll add that object here
1538
1512
  this.#rawData[sensor.deviceId.resourceId].value.liveness = {};
1539
1513
  this.#rawData[sensor.deviceId.resourceId].value.liveness.status = 'LIVENESS_DEVICE_STATUS_UNSPECIFIED';
1540
- if (typeof value.value?.remote_comfort_sensing_state?.rcsSensorStatuses === 'object') {
1514
+ if (value.value?.remote_comfort_sensing_state?.rcsSensorStatuses !== undefined) {
1541
1515
  Object.values(value.value.remote_comfort_sensing_state.rcsSensorStatuses).forEach((sensorStatus) => {
1542
1516
  if (
1543
1517
  sensorStatus?.sensorId?.resourceId === sensor.deviceId.resourceId &&
@@ -1554,14 +1528,14 @@ export default class NestAccfactory {
1554
1528
  }
1555
1529
 
1556
1530
  RESTTypeData.schedule_mode =
1531
+ value.value?.target_temperature_settings?.targetTemperature?.setpointType !== undefined &&
1557
1532
  value.value.target_temperature_settings.targetTemperature.setpointType.split('SET_POINT_TYPE_')[1].toLowerCase() !== 'off'
1558
1533
  ? value.value.target_temperature_settings.targetTemperature.setpointType.split('SET_POINT_TYPE_')[1].toLowerCase()
1559
1534
  : '';
1560
1535
  RESTTypeData.schedules = {};
1561
-
1562
1536
  if (
1563
- typeof value.value[RESTTypeData.schedule_mode + '_schedule_settings'].setpoints === 'object' &&
1564
- value.value[RESTTypeData.schedule_mode + '_schedule_settings'].type ===
1537
+ value.value[RESTTypeData.schedule_mode + '_schedule_settings']?.setpoints !== undefined &&
1538
+ value.value[RESTTypeData.schedule_mode + '_schedule_settings']?.type ===
1565
1539
  'SET_POINT_SCHEDULE_TYPE_' + RESTTypeData.schedule_mode.toUpperCase()
1566
1540
  ) {
1567
1541
  Object.values(value.value[RESTTypeData.schedule_mode + '_schedule_settings'].setpoints).forEach((schedule) => {
@@ -1586,7 +1560,7 @@ export default class NestAccfactory {
1586
1560
  tempDevice = process_thermostat_data(object_key, RESTTypeData);
1587
1561
  }
1588
1562
 
1589
- if (value.source === NestAccfactory.DataSource.REST) {
1563
+ if (value?.source === NestAccfactory.DataSource.REST) {
1590
1564
  let RESTTypeData = {};
1591
1565
  RESTTypeData.mac_address = value.value.mac_address;
1592
1566
  RESTTypeData.serial_number = value.value.serial_number;
@@ -1610,68 +1584,54 @@ export default class NestAccfactory {
1610
1584
  RESTTypeData.backplate_temperature = value.value.backplate_temperature;
1611
1585
  RESTTypeData.current_temperature = value.value.backplate_temperature;
1612
1586
  RESTTypeData.battery_level = value.value.battery_level;
1613
- RESTTypeData.online = this.#rawData['track.' + value.value.serial_number].value.online === true;
1587
+ RESTTypeData.online = this.#rawData?.['track.' + value.value.serial_number]?.value?.online === true;
1614
1588
  RESTTypeData.leaf = value.value.leaf === true;
1615
1589
  RESTTypeData.has_humidifier = value.value.has_humidifier === true;
1616
1590
  RESTTypeData.has_dehumidifier = value.value.has_dehumidifier === true;
1617
1591
  RESTTypeData.has_fan = value.value.has_fan === true;
1618
- RESTTypeData.can_cool = this.#rawData['shared.' + value.value.serial_number].value.can_cool === true;
1619
- RESTTypeData.can_heat = this.#rawData['shared.' + value.value.serial_number].value.can_heat === true;
1592
+ RESTTypeData.can_cool = this.#rawData?.['shared.' + value.value.serial_number]?.value?.can_cool === true;
1593
+ RESTTypeData.can_heat = this.#rawData?.['shared.' + value.value.serial_number]?.value?.can_heat === true;
1620
1594
  RESTTypeData.temperature_lock = value.value.temperature_lock === true;
1621
1595
  RESTTypeData.temperature_lock_pin_hash = value.value.temperature_lock_pin_hash;
1622
- RESTTypeData.away = false;
1623
- if (
1624
- typeof this.#rawData['structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value
1625
- ?.away === 'boolean'
1626
- ) {
1627
- RESTTypeData.away =
1628
- this.#rawData['structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]].value.away;
1629
- }
1630
- if (
1631
- this.#rawData['structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value
1632
- ?.structure_mode?.structureMode === 'STRUCTURE_MODE_AWAY'
1633
- ) {
1634
- RESTTypeData.away = true;
1635
- }
1596
+
1597
+ // Look in two possible locations for away status
1598
+ RESTTypeData.away =
1599
+ this.#rawData?.['structure.' + this.#rawData?.['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value
1600
+ ?.away === true ||
1601
+ this.#rawData?.['structure.' + this.#rawData?.['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value
1602
+ ?.structure_mode?.structureMode === 'STRUCTURE_MODE_AWAY';
1603
+
1636
1604
  RESTTypeData.occupancy = RESTTypeData.away === false; // Occupancy is opposite of away status ie: away is false, then occupied
1637
- RESTTypeData.vacation_mode = false;
1638
- if (
1639
- typeof this.#rawData['structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value
1640
- ?.vacation_mode === 'boolean'
1641
- ) {
1642
- RESTTypeData.vacation_mode =
1643
- this.#rawData[
1644
- 'structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]
1645
- ].value.vacation_mode; // vacation mode
1646
- }
1647
- if (
1648
- this.#rawData['structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value
1649
- ?.structure_mode?.structureMode === 'STRUCTURE_MODE_VACATION'
1650
- ) {
1651
- RESTTypeData.vacation_mode = true;
1652
- }
1605
+
1606
+ // Look in two possible locations for vacation status
1607
+ RESTTypeData.vacation_mode =
1608
+ this.#rawData['structure.' + this.#rawData?.['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value
1609
+ ?.vacation_mode === true ||
1610
+ this.#rawData?.['structure.' + this.#rawData?.['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value
1611
+ ?.structure_mode?.structureMode === 'STRUCTURE_MODE_VACATION';
1612
+
1653
1613
  RESTTypeData.description =
1654
- typeof this.#rawData['shared.' + value.value.serial_number]?.value?.name === 'string'
1614
+ this.#rawData?.['shared.' + value.value.serial_number]?.value?.name !== undefined
1655
1615
  ? makeHomeKitName(this.#rawData['shared.' + value.value.serial_number].value.name)
1656
1616
  : '';
1657
1617
  RESTTypeData.location = get_location_name(
1658
- this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1],
1618
+ this.#rawData?.['link.' + value.value.serial_number].value.structure.split('.')[1],
1659
1619
  value.value.where_id,
1660
1620
  );
1661
1621
 
1662
1622
  // Work out current mode. ie: off, cool, heat, range and get temperature low (heat) and high (cool)
1663
- RESTTypeData.hvac_mode = this.#rawData['shared.' + value.value.serial_number].value.target_temperature_type;
1664
- RESTTypeData.target_temperature_low = this.#rawData['shared.' + value.value.serial_number].value.target_temperature_low;
1665
- RESTTypeData.target_temperature_high = this.#rawData['shared.' + value.value.serial_number].value.target_temperature_high;
1666
- if (this.#rawData['shared.' + value.value.serial_number].value.target_temperature_type.toUpperCase() === 'COOL') {
1623
+ RESTTypeData.hvac_mode = this.#rawData?.['shared.' + value.value.serial_number].value.target_temperature_type;
1624
+ RESTTypeData.target_temperature_low = this.#rawData?.['shared.' + value.value.serial_number].value.target_temperature_low;
1625
+ RESTTypeData.target_temperature_high = this.#rawData?.['shared.' + value.value.serial_number].value.target_temperature_high;
1626
+ if (this.#rawData?.['shared.' + value.value.serial_number]?.value?.target_temperature_type.toUpperCase() === 'COOL') {
1667
1627
  // Target temperature is the cooling point
1668
1628
  RESTTypeData.target_temperature = this.#rawData['shared.' + value.value.serial_number].value.target_temperature_high;
1669
1629
  }
1670
- if (this.#rawData['shared.' + value.value.serial_number].value.target_temperature_type.toUpperCase() === 'HEAT') {
1630
+ if (this.#rawData?.['shared.' + value.value.serial_number]?.value?.target_temperature_type.toUpperCase() === 'HEAT') {
1671
1631
  // Target temperature is the heating point
1672
1632
  RESTTypeData.target_temperature = this.#rawData['shared.' + value.value.serial_number].value.target_temperature_low;
1673
1633
  }
1674
- if (this.#rawData['shared.' + value.value.serial_number].value.target_temperature_type.toUpperCase() === 'RANGE') {
1634
+ if (this.#rawData?.['shared.' + value.value.serial_number]?.value?.target_temperature_type.toUpperCase() === 'RANGE') {
1675
1635
  // Target temperature is in between the heating and cooling point
1676
1636
  RESTTypeData.target_temperature =
1677
1637
  (this.#rawData['shared.' + value.value.serial_number].value.target_temperature_low +
@@ -1700,21 +1660,21 @@ export default class NestAccfactory {
1700
1660
  // Work out current state ie: heating, cooling etc
1701
1661
  RESTTypeData.hvac_state = 'off'; // By default, we're not heating or cooling
1702
1662
  if (
1703
- this.#rawData['shared.' + value.value.serial_number].value.hvac_heater_state === true ||
1704
- this.#rawData['shared.' + value.value.serial_number].value.hvac_heat_x2_state === true ||
1705
- this.#rawData['shared.' + value.value.serial_number].value.hvac_heat_x3_state === true ||
1706
- this.#rawData['shared.' + value.value.serial_number].value.hvac_aux_heater_state === true ||
1707
- this.#rawData['shared.' + value.value.serial_number].value.hvac_alt_heat_x2_state === true ||
1708
- this.#rawData['shared.' + value.value.serial_number].value.hvac_emer_heat_state === true ||
1709
- this.#rawData['shared.' + value.value.serial_number].value.hvac_alt_heat_state === true
1663
+ this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_heater_state === true ||
1664
+ this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_heat_x2_state === true ||
1665
+ this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_heat_x3_state === true ||
1666
+ this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_aux_heater_state === true ||
1667
+ this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_alt_heat_x2_state === true ||
1668
+ this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_emer_heat_state === true ||
1669
+ this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_alt_heat_state === true
1710
1670
  ) {
1711
1671
  // A heating source is on, so we're in heating mode
1712
1672
  RESTTypeData.hvac_state = 'heating';
1713
1673
  }
1714
1674
  if (
1715
- this.#rawData['shared.' + value.value.serial_number].value.hvac_ac_state === true ||
1716
- this.#rawData['shared.' + value.value.serial_number].value.hvac_cool_x2_state === true ||
1717
- this.#rawData['shared.' + value.value.serial_number].value.hvac_cool_x3_state === true
1675
+ this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_ac_state === true ||
1676
+ this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_cool_x2_state === true ||
1677
+ this.#rawData?.['shared.' + value.value.serial_number]?.value?.hvac_cool_x3_state === true
1718
1678
  ) {
1719
1679
  // A cooling source is on, so we're in cooling mode
1720
1680
  RESTTypeData.hvac_state = 'cooling';
@@ -1739,21 +1699,26 @@ export default class NestAccfactory {
1739
1699
  // Process any temperature sensors associated with this thermostat
1740
1700
  RESTTypeData.active_rcs_sensor = '';
1741
1701
  RESTTypeData.linked_rcs_sensors = [];
1742
- this.#rawData['rcs_settings.' + value.value.serial_number].value.associated_rcs_sensors.forEach((sensor) => {
1743
- if (typeof this.#rawData[sensor]?.value === 'object') {
1744
- this.#rawData[sensor].value.associated_thermostat = object_key; // Sensor is linked to this thermostat
1745
-
1746
- // Is this sensor the active one? If so, get some details about it
1747
- if (this.#rawData['rcs_settings.' + value.value.serial_number].value.active_rcs_sensors.includes(sensor)) {
1748
- RESTTypeData.active_rcs_sensor = this.#rawData[sensor].value.serial_number.toUpperCase();
1749
- RESTTypeData.current_temperature = this.#rawData[sensor].value.current_temperature;
1702
+ if (this.#rawData?.['rcs_settings.' + value.value.serial_number]?.value?.associated_rcs_sensors !== undefined) {
1703
+ this.#rawData?.['rcs_settings.' + value.value.serial_number].value.associated_rcs_sensors.forEach((sensor) => {
1704
+ if (typeof this.#rawData[sensor]?.value === 'object') {
1705
+ this.#rawData[sensor].value.associated_thermostat = object_key; // Sensor is linked to this thermostat
1706
+
1707
+ // Is this sensor the active one? If so, get some details about it
1708
+ if (
1709
+ this.#rawData?.['rcs_settings.' + value.value.serial_number]?.value?.active_rcs_sensors !== undefined &&
1710
+ this.#rawData?.['rcs_settings.' + value.value.serial_number]?.value?.active_rcs_sensors.includes(sensor)
1711
+ ) {
1712
+ RESTTypeData.active_rcs_sensor = this.#rawData[sensor].value.serial_number.toUpperCase();
1713
+ RESTTypeData.current_temperature = this.#rawData[sensor].value.current_temperature;
1714
+ }
1715
+ RESTTypeData.linked_rcs_sensors.push(this.#rawData[sensor].value.serial_number.toUpperCase());
1750
1716
  }
1751
- RESTTypeData.linked_rcs_sensors.push(this.#rawData[sensor].value.serial_number.toUpperCase());
1752
- }
1753
- });
1717
+ });
1718
+ }
1754
1719
 
1755
1720
  // Get associated schedules
1756
- if (typeof this.#rawData['schedule.' + value.value.serial_number] === 'object') {
1721
+ if (this.#rawData?.['schedule.' + value.value.serial_number] !== undefined) {
1757
1722
  Object.values(this.#rawData['schedule.' + value.value.serial_number].value.days).forEach((schedules) => {
1758
1723
  Object.values(schedules).forEach((schedule) => {
1759
1724
  // Fix up temperatures in the schedule
@@ -1872,7 +1837,7 @@ export default class NestAccfactory {
1872
1837
  let tempDevice = {};
1873
1838
  try {
1874
1839
  if (
1875
- value.source === NestAccfactory.DataSource.PROTOBUF &&
1840
+ value?.source === NestAccfactory.DataSource.PROTOBUF &&
1876
1841
  typeof value?.value?.associated_thermostat === 'string' &&
1877
1842
  value?.value?.associated_thermostat !== ''
1878
1843
  ) {
@@ -1881,21 +1846,21 @@ export default class NestAccfactory {
1881
1846
  // Guessing battery minimum voltage is 2v??
1882
1847
  RESTTypeData.battery_level = scaleValue(value.value.battery.assessedVoltage.value, 2.0, 3.0, 0, 100);
1883
1848
  RESTTypeData.current_temperature = value.value.current_temperature.temperatureValue.temperature.value;
1884
- // Online status we 'faked' when processing Thermostat protobuf data
1849
+ // Online status we 'faked' when processing Thermostat Protobuf data
1885
1850
  RESTTypeData.online = value.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE';
1886
1851
  RESTTypeData.associated_thermostat = value.value.associated_thermostat;
1887
1852
  RESTTypeData.description = typeof value.value?.label?.label === 'string' ? value.value.label.label : '';
1888
1853
  RESTTypeData.location = get_location_name(
1889
- value.value.device_info.pairerId.resourceId,
1890
- value.value.device_located_settings.whereAnnotationRid.resourceId,
1854
+ value.value?.device_info?.pairerId?.resourceId,
1855
+ value.value?.device_located_settings?.whereAnnotationRid?.resourceId,
1891
1856
  );
1892
1857
  RESTTypeData.active_sensor =
1893
- this.#rawData[value.value.associated_thermostat].value?.remote_comfort_sensing_settings?.activeRcsSelection?.activeRcsSensor
1894
- ?.resourceId === object_key;
1858
+ this.#rawData?.[value.value?.associated_thermostat].value?.remote_comfort_sensing_settings?.activeRcsSelection
1859
+ ?.activeRcsSensor?.resourceId === object_key;
1895
1860
  tempDevice = process_kryptonite_data(object_key, RESTTypeData);
1896
1861
  }
1897
1862
  if (
1898
- value.source === NestAccfactory.DataSource.REST &&
1863
+ value?.source === NestAccfactory.DataSource.REST &&
1899
1864
  typeof value?.value?.associated_thermostat === 'string' &&
1900
1865
  value?.value?.associated_thermostat !== ''
1901
1866
  ) {
@@ -1908,7 +1873,8 @@ export default class NestAccfactory {
1908
1873
  RESTTypeData.description = value.value.description;
1909
1874
  RESTTypeData.location = get_location_name(value.value.structure_id, value.value.where_id);
1910
1875
  RESTTypeData.active_sensor =
1911
- this.#rawData['rcs_settings.' + value.value.associated_thermostat].value.active_rcs_sensors.includes(object_key) === true;
1876
+ this.#rawData?.['rcs_settings.' + value.value?.associated_thermostat]?.value?.active_rcs_sensors.includes(object_key) ===
1877
+ true;
1912
1878
  tempDevice = process_kryptonite_data(object_key, RESTTypeData);
1913
1879
  }
1914
1880
  // eslint-disable-next-line no-unused-vars
@@ -1999,7 +1965,7 @@ export default class NestAccfactory {
1999
1965
  .forEach(([object_key, value]) => {
2000
1966
  let tempDevice = {};
2001
1967
  try {
2002
- if (value.source === NestAccfactory.DataSource.PROTOBUF) {
1968
+ if (value?.source === NestAccfactory.DataSource.PROTOBUF) {
2003
1969
  /*
2004
1970
  let RESTTypeData = {};
2005
1971
  RESTTypeData.mac_address = Buffer.from(value.value.wifi_interface.macAddress, 'base64');
@@ -2012,11 +1978,11 @@ export default class NestAccfactory {
2012
1978
  RESTTypeData.battery_health_state = value.value.battery_voltage_bank1.faultInformation;
2013
1979
  RESTTypeData.smoke_status = value.value.safety_alarm_smoke.alarmState === 'ALARM_STATE_ALARM' ? 2 : 0; // matches REST data
2014
1980
  RESTTypeData.co_status = value.value.safety_alarm_co.alarmState === 'ALARM_STATE_ALARM' ? 2 : 0; // matches REST data
2015
- RESTTypeData.heat_status =
1981
+ // RESTTypeData.heat_status =
2016
1982
  RESTTypeData.hushed_state =
2017
1983
  value.value.safety_alarm_smoke.silenceState === 'SILENCE_STATE_SILENCED' ||
2018
1984
  value.value.safety_alarm_co.silenceState === 'SILENCE_STATE_SILENCED';
2019
- RESTTypeData.ntp_green_led = value.value.night_time_promise_settings.greenLedEnabled === true;
1985
+ RESTTypeData.ntp_green_led_enable = value.value.night_time_promise_settings.greenLedEnabled === true;
2020
1986
  RESTTypeData.smoke_test_passed = value.value.safety_summary.warningDevices.failures.includes('FAILURE_TYPE_SMOKE') === false;
2021
1987
  RESTTypeData.heat_test_passed = value.value.safety_summary.warningDevices.failures.includes('FAILURE_TYPE_TEMP') === false;
2022
1988
  RESTTypeData.latest_alarm_test =
@@ -2029,7 +1995,7 @@ export default class NestAccfactory {
2029
1995
  ? parseInt(value.value.legacy_protect_device_settings.replaceByDate.seconds)
2030
1996
  : 0;
2031
1997
 
2032
- RESTTypeData.removed_from_base =
1998
+ // RESTTypeData.removed_from_base =
2033
1999
  RESTTypeData.topaz_hush_key =
2034
2000
  typeof value.value.safety_structure_settings.structureHushKey === 'string'
2035
2001
  ? value.value.safety_structure_settings.structureHushKey
@@ -2037,19 +2003,19 @@ export default class NestAccfactory {
2037
2003
  RESTTypeData.detected_motion = value.value.legacy_protect_device_info.autoAway === false;
2038
2004
  RESTTypeData.description = typeof value.value?.label?.label === 'string' ? value.value.label.label : '';
2039
2005
  RESTTypeData.location = get_location_name(
2040
- value.value.device_info.pairerId.resourceId,
2041
- value.value.device_located_settings.whereAnnotationRid.resourceId,
2006
+ value.value?.device_info?.pairerId?.resourceId,
2007
+ value.value?.device_located_settings?.whereAnnotationRid?.resourceId,
2042
2008
  );
2043
2009
  tempDevice = process_protect_data(object_key, RESTTypeData);
2044
2010
  */
2045
2011
  }
2046
2012
 
2047
- if (value.source === NestAccfactory.DataSource.REST) {
2013
+ if (value?.source === NestAccfactory.DataSource.REST) {
2048
2014
  let RESTTypeData = {};
2049
2015
  RESTTypeData.mac_address = value.value.wifi_mac_address;
2050
2016
  RESTTypeData.serial_number = value.value.serial_number;
2051
2017
  RESTTypeData.software_version = value.value.software_version;
2052
- RESTTypeData.online = this.#rawData['widget_track.' + value.value.thread_mac_address.toUpperCase()].value.online === true;
2018
+ RESTTypeData.online = this.#rawData?.['widget_track.' + value?.value?.thread_mac_address.toUpperCase()]?.value?.online === true;
2053
2019
  RESTTypeData.line_power_present = value.value.line_power_present === true;
2054
2020
  RESTTypeData.wired_or_battery = value.value.wired_or_battery;
2055
2021
  RESTTypeData.battery_level = value.value.battery_level;
@@ -2058,17 +2024,17 @@ export default class NestAccfactory {
2058
2024
  RESTTypeData.co_status = value.value.co_status;
2059
2025
  RESTTypeData.heat_status = value.value.heat_status;
2060
2026
  RESTTypeData.hushed_state = value.value.hushed_state === true;
2061
- RESTTypeData.ntp_green_led = value.value.ntp_green_led_enable === true;
2027
+ RESTTypeData.ntp_green_led_enable = value.value.ntp_green_led_enable === true;
2062
2028
  RESTTypeData.smoke_test_passed = value.value.component_smoke_test_passed === true;
2063
2029
  RESTTypeData.heat_test_passed = value.value.component_temp_test_passed === true;
2064
2030
  RESTTypeData.latest_alarm_test = value.value.latest_manual_test_end_utc_secs;
2065
2031
  RESTTypeData.self_test_in_progress =
2066
- this.#rawData['safety.' + value.value.structure_id].value.manual_self_test_in_progress === true;
2032
+ this.#rawData?.['safety.' + value.value.structure_id]?.value?.manual_self_test_in_progress === true;
2067
2033
  RESTTypeData.replacement_date = value.value.replace_by_date_utc_secs;
2068
2034
  RESTTypeData.removed_from_base = value.value.removed_from_base === true;
2069
2035
  RESTTypeData.topaz_hush_key =
2070
- typeof this.#rawData['structure.' + value.value.structure_id]?.value?.topaz_hush_key === 'string'
2071
- ? this.#rawData['structure.' + value.value.structure_id].value.topaz_hush_key
2036
+ typeof this.#rawData?.['structure.' + value.value.structure_id]?.value?.topaz_hush_key === 'string'
2037
+ ? this.#rawData?.['structure.' + value.value.structure_id]?.value?.topaz_hush_key
2072
2038
  : '';
2073
2039
  RESTTypeData.detected_motion = value.value.auto_away === false;
2074
2040
  RESTTypeData.description = value.value?.description;
@@ -2102,6 +2068,9 @@ export default class NestAccfactory {
2102
2068
  if (data.model.toUpperCase().includes('DOORBELL') === true) {
2103
2069
  data.device_type = NestAccfactory.DeviceType.DOORBELL;
2104
2070
  }
2071
+ if (data.model.toUpperCase().includes('FLOODLIGHT') === true) {
2072
+ data.device_type = NestAccfactory.DeviceType.FLOODLIGHT;
2073
+ }
2105
2074
  data.uuid = object_key; // Internal structure ID
2106
2075
  data.manufacturer = typeof data?.manufacturer === 'string' ? data.manufacturer : 'Nest';
2107
2076
  data.software_version = typeof data?.software_version === 'string' ? data.software_version.replace(/-/g, '.') : '0.0.0';
@@ -2152,7 +2121,6 @@ export default class NestAccfactory {
2152
2121
  'nest.resource.NestCamIQResource',
2153
2122
  'nest.resource.NestCamIQOutdoorResource',
2154
2123
  'nest.resource.NestHelloResource',
2155
- 'google.resource.AzizResource',
2156
2124
  'google.resource.GoogleNewmanResource',
2157
2125
  ];
2158
2126
  Object.entries(this.#rawData)
@@ -2166,15 +2134,24 @@ export default class NestAccfactory {
2166
2134
  .forEach(([object_key, value]) => {
2167
2135
  let tempDevice = {};
2168
2136
  try {
2169
- if (value.source === NestAccfactory.DataSource.PROTOBUF && value.value?.streaming_protocol !== undefined) {
2137
+ if (value?.source === NestAccfactory.DataSource.PROTOBUF && value.value?.streaming_protocol !== undefined) {
2170
2138
  let RESTTypeData = {};
2171
- //RESTTypeData.mac_address = value.value.wifi_interface.macAddress.toString('hex');
2172
- // Use a Nest Labs prefix for first 6 digits, followed by a CRC24 based off serial number for last 6 digits.
2173
- RESTTypeData.mac_address = '18B430' + crc24(value.value.device_identity.serialNumber.toUpperCase()).toUpperCase();
2139
+ // If we haven't found a macaddress, ase a Nest Labs prefix for first 6 digits followed by a CRC24 based off serial number for last 6 digits.
2140
+ RESTTypeData.mac_address =
2141
+ value.value?.wifi_interface?.macAddress !== undefined
2142
+ ? Buffer.from(value.value.wifi_interface.macAddress, 'base64')
2143
+ : '18B430' + crc24(value.value.device_identity.serialNumber.toUpperCase());
2174
2144
  RESTTypeData.serial_number = value.value.device_identity.serialNumber;
2175
- RESTTypeData.software_version = value.value.device_identity.softwareVersion.replace(/[^0-9.]/g, '');
2145
+ RESTTypeData.software_version =
2146
+ value.value?.floodlight_settings?.associatedFloodlightFirmwareVersion !== undefined
2147
+ ? value.value.floodlight_settings.associatedFloodlightFirmwareVersion
2148
+ : value.value.device_identity.softwareVersion.replace(/[^0-9.]/g, '');
2176
2149
  RESTTypeData.model = 'Camera';
2177
- if (value.value.device_info.typeName === 'google.resource.NeonQuartzResource') {
2150
+ if (
2151
+ value.value.device_info.typeName === 'google.resource.NeonQuartzResource' &&
2152
+ value.value?.floodlight_settings === undefined &&
2153
+ value.value?.floodlight_state === undefined
2154
+ ) {
2178
2155
  RESTTypeData.model = 'Cam (battery)';
2179
2156
  }
2180
2157
  if (value.value.device_info.typeName === 'google.resource.GreenQuartzResource') {
@@ -2198,17 +2175,19 @@ export default class NestAccfactory {
2198
2175
  if (value.value.device_info.typeName === 'nest.resource.NestHelloResource') {
2199
2176
  RESTTypeData.model = 'Doorbell (wired, 1st gen)';
2200
2177
  }
2201
- if (value.value.device_info.typeName === 'google.resource.AzizResource') {
2178
+ if (
2179
+ value.value.device_info.typeName === 'google.resource.NeonQuartzResource' &&
2180
+ value.value?.floodlight_settings !== undefined &&
2181
+ value.value?.floodlight_state !== undefined
2182
+ ) {
2202
2183
  RESTTypeData.model = 'Cam with Floodlight (wired)';
2203
2184
  }
2204
- if (value.value.device_info.typeName === 'google.resource.GoogleNewmanResource') {
2205
- RESTTypeData.model = 'Hub Max';
2206
- }
2185
+
2207
2186
  RESTTypeData.online = value.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE';
2208
- RESTTypeData.description = typeof value.value?.label?.label === 'string' ? value.value.label.label : '';
2187
+ RESTTypeData.description = value.value?.label?.label !== undefined ? value.value.label.label : '';
2209
2188
  RESTTypeData.location = get_location_name(
2210
- value.value.device_info.pairerId.resourceId,
2211
- value.value.device_located_settings.whereAnnotationRid.resourceId,
2189
+ value.value?.device_info?.pairerId?.resourceId,
2190
+ value.value?.device_located_settings?.whereAnnotationRid?.resourceId,
2212
2191
  );
2213
2192
  RESTTypeData.audio_enabled = value.value?.microphone_settings?.enableMicrophone === true;
2214
2193
  RESTTypeData.has_indoor_chime =
@@ -2228,7 +2207,7 @@ export default class NestAccfactory {
2228
2207
  value.value.activity_zone_settings.activityZones.forEach((zone) => {
2229
2208
  RESTTypeData.activity_zones.push({
2230
2209
  id: typeof zone.zoneProperties?.zoneId === 'number' ? zone.zoneProperties.zoneId : zone.zoneProperties.internalIndex,
2231
- name: makeHomeKitName(typeof zone.zoneProperties?.name === 'string' ? zone.zoneProperties.name : ''),
2210
+ name: makeHomeKitName(zone.zoneProperties?.name !== undefined ? zone.zoneProperties.name : ''),
2232
2211
  hidden: false,
2233
2212
  uri: '',
2234
2213
  });
@@ -2244,10 +2223,18 @@ export default class NestAccfactory {
2244
2223
  RESTTypeData.streaming_host =
2245
2224
  typeof value.value?.streaming_protocol?.directHost?.value === 'string' ? value.value.streaming_protocol.directHost.value : '';
2246
2225
 
2226
+ // Floodlight settings/status
2227
+ RESTTypeData.has_light = value.value?.floodlight_settings !== undefined && value.value?.floodlight_state !== undefined;
2228
+ RESTTypeData.light_enabled = value.value?.floodlight_state?.currentState === 'LIGHT_STATE_ON';
2229
+ RESTTypeData.light_brightness =
2230
+ value.value?.floodlight_settings?.brightness !== undefined
2231
+ ? scaleValue(value.value.floodlight_settings.brightness, 0, 10, 0, 100)
2232
+ : 0;
2233
+
2247
2234
  tempDevice = process_camera_doorbell_data(object_key, RESTTypeData);
2248
2235
  }
2249
2236
 
2250
- if (value.source === NestAccfactory.DataSource.REST && value.value.properties['cc2migration.overview_state'] === 'NORMAL') {
2237
+ if (value?.source === NestAccfactory.DataSource.REST && value.value?.properties?.['cc2migration.overview_state'] === 'NORMAL') {
2251
2238
  // We'll only use the REST API data for Camera's which have NOT been migrated to Google Home
2252
2239
  let RESTTypeData = {};
2253
2240
  RESTTypeData.mac_address = value.value.mac_address;
@@ -2260,17 +2247,17 @@ export default class NestAccfactory {
2260
2247
  RESTTypeData.nexus_api_http_server_url = value.value.nexus_api_http_server_url;
2261
2248
  RESTTypeData.online = value.value.streaming_state.includes('offline') === false;
2262
2249
  RESTTypeData.audio_enabled = value.value.audio_input_enabled === true;
2263
- RESTTypeData.has_indoor_chime = value.value.capabilities.includes('indoor_chime') === true;
2264
- RESTTypeData.indoor_chime_enabled = value.value.properties['doorbell.indoor_chime.enabled'] === true;
2265
- RESTTypeData.has_irled = value.value.capabilities.includes('irled') === true;
2266
- RESTTypeData.irled_enabled = value.value.properties['irled.state'] !== 'always_off';
2267
- RESTTypeData.has_statusled = value.value.capabilities.includes('statusled') === true;
2268
- RESTTypeData.has_video_flip = value.value.capabilities.includes('video.flip') === true;
2269
- RESTTypeData.video_flipped = value.value.properties['video.flipped'] === true;
2270
- RESTTypeData.statusled_brightness = value.value.properties['statusled.brightness'];
2271
- RESTTypeData.has_microphone = value.value.capabilities.includes('audio.microphone') === true;
2272
- RESTTypeData.has_speaker = value.value.capabilities.includes('audio.speaker') === true;
2273
- RESTTypeData.has_motion_detection = value.value.capabilities.includes('detectors.on_camera') === true;
2250
+ RESTTypeData.has_indoor_chime = value.value?.capabilities.includes('indoor_chime') === true;
2251
+ RESTTypeData.indoor_chime_enabled = value.value?.properties['doorbell.indoor_chime.enabled'] === true;
2252
+ RESTTypeData.has_irled = value.value?.capabilities.includes('irled') === true;
2253
+ RESTTypeData.irled_enabled = value.value?.properties['irled.state'] !== 'always_off';
2254
+ RESTTypeData.has_statusled = value.value?.capabilities.includes('statusled') === true;
2255
+ RESTTypeData.has_video_flip = value.value?.capabilities.includes('video.flip') === true;
2256
+ RESTTypeData.video_flipped = value.value?.properties['video.flipped'] === true;
2257
+ RESTTypeData.statusled_brightness = value.value?.properties['statusled.brightness'];
2258
+ RESTTypeData.has_microphone = value.value?.capabilities.includes('audio.microphone') === true;
2259
+ RESTTypeData.has_speaker = value.value?.capabilities.includes('audio.speaker') === true;
2260
+ RESTTypeData.has_motion_detection = value.value?.capabilities.includes('detectors.on_camera') === true;
2274
2261
  RESTTypeData.activity_zones = value.value.activity_zones; // structure elements we added
2275
2262
  RESTTypeData.alerts = typeof value.value?.alerts === 'object' ? value.value.alerts : [];
2276
2263
  RESTTypeData.streaming_protocols = ['PROTOCOL_NEXUSTALK'];
@@ -2316,7 +2303,7 @@ export default class NestAccfactory {
2316
2303
  }
2317
2304
  });
2318
2305
 
2319
- // Process data for any structure(s) for both REST and protobuf API data
2306
+ // Process data for any structure(s) for both REST and Protobuf API data
2320
2307
  // We use this to created virtual weather station(s) for each structure that has location data
2321
2308
  const process_structure_data = (object_key, data) => {
2322
2309
  let processed = {};
@@ -2392,13 +2379,12 @@ export default class NestAccfactory {
2392
2379
  .forEach(([object_key, value]) => {
2393
2380
  let tempDevice = {};
2394
2381
  try {
2395
- if (value.source === NestAccfactory.DataSource.PROTOBUF) {
2382
+ if (value?.source === NestAccfactory.DataSource.PROTOBUF) {
2396
2383
  let RESTTypeData = {};
2397
2384
  RESTTypeData.postal_code = value.value.structure_location.postalCode.value;
2398
2385
  RESTTypeData.country_code = value.value.structure_location.countryCode.value;
2399
- RESTTypeData.city = typeof value.value.structure_location?.city === 'string' ? value.value.structure_location.city.value : '';
2400
- RESTTypeData.state =
2401
- typeof value.value.structure_location?.state === 'string' ? value.value.structure_location.state.value : '';
2386
+ RESTTypeData.city = value.value?.structure_location?.city !== undefined ? value.value.structure_location.city.value : '';
2387
+ RESTTypeData.state = value.value?.structure_location?.state !== undefined ? value.value.structure_location.state.value : '';
2402
2388
  RESTTypeData.latitude = value.value.structure_location.geoCoordinate.latitude;
2403
2389
  RESTTypeData.longitude = value.value.structure_location.geoCoordinate.longitude;
2404
2390
  RESTTypeData.description =
@@ -2407,16 +2393,16 @@ export default class NestAccfactory {
2407
2393
  : value.value.structure_info.name;
2408
2394
  RESTTypeData.weather = value.value.weather;
2409
2395
 
2410
- // Use the REST API structure ID from the protobuf structure. This should prevent two 'weather' objects being created
2396
+ // Use the REST API structure ID from the Protobuf structure. This should prevent two 'weather' objects being created
2411
2397
  let tempDevice = process_structure_data(value.value.structure_info.rtsStructureId, RESTTypeData);
2412
- tempDevice.uuid = object_key; // Use the protobuf structure ID post processing
2398
+ tempDevice.uuid = object_key; // Use the Protobuf structure ID post processing
2413
2399
  }
2414
- if (value.source === NestAccfactory.DataSource.REST) {
2400
+ if (value?.source === NestAccfactory.DataSource.REST) {
2415
2401
  let RESTTypeData = {};
2416
2402
  RESTTypeData.postal_code = value.value.postal_code;
2417
2403
  RESTTypeData.country_code = value.value.country_code;
2418
- RESTTypeData.city = typeof value.value?.city === 'string' ? value.value.city : '';
2419
- RESTTypeData.state = typeof value.value?.state === 'string' ? value.value.state : '';
2404
+ RESTTypeData.city = value.value?.city !== undefined ? value.value.city : '';
2405
+ RESTTypeData.state = value.value?.state !== undefined ? value.value.state : '';
2420
2406
  RESTTypeData.latitude = value.value.latitude;
2421
2407
  RESTTypeData.longitude = value.value.longitude;
2422
2408
  RESTTypeData.description =
@@ -2443,25 +2429,25 @@ export default class NestAccfactory {
2443
2429
  async #set(deviceUUID, values) {
2444
2430
  if (
2445
2431
  typeof deviceUUID !== 'string' ||
2446
- typeof this.#rawData[deviceUUID] !== 'object' ||
2432
+ typeof this.#rawData?.[deviceUUID] !== 'object' ||
2447
2433
  typeof values !== 'object' ||
2448
- typeof this.#connections[this.#rawData[deviceUUID]?.connection] !== 'object'
2434
+ typeof this.#connections[this.#rawData?.[deviceUUID]?.connection] !== 'object'
2449
2435
  ) {
2450
2436
  return;
2451
2437
  }
2452
2438
 
2453
2439
  if (
2454
2440
  this.#connections[this.#rawData[deviceUUID].connection].protobufRoot !== null &&
2455
- this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.PROTOBUF
2441
+ this.#rawData?.[deviceUUID]?.source === NestAccfactory.DataSource.PROTOBUF
2456
2442
  ) {
2457
- let TraitMap = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot.lookup('nest.rpc.NestTraitSetRequest');
2458
- let setDataToEncode = [];
2443
+ let updatedTraits = [];
2459
2444
  let protobufElement = {
2460
- traitId: {
2445
+ traitRequest: {
2461
2446
  resourceId: deviceUUID,
2462
2447
  traitLabel: '',
2448
+ requestId: crypto.randomUUID(),
2463
2449
  },
2464
- property: {
2450
+ state: {
2465
2451
  type_url: '',
2466
2452
  value: {},
2467
2453
  },
@@ -2470,9 +2456,9 @@ export default class NestAccfactory {
2470
2456
  await Promise.all(
2471
2457
  Object.entries(values).map(async ([key, value]) => {
2472
2458
  // Reset elements at start of loop
2473
- protobufElement.traitId.traitLabel = '';
2474
- protobufElement.property.type_url = '';
2475
- protobufElement.property.value = {};
2459
+ protobufElement.traitRequest.traitLabel = '';
2460
+ protobufElement.state.type_url = '';
2461
+ protobufElement.state.value = {};
2476
2462
 
2477
2463
  if (
2478
2464
  (key === 'hvac_mode' &&
@@ -2482,45 +2468,48 @@ export default class NestAccfactory {
2482
2468
  value.toUpperCase() === 'HEAT' ||
2483
2469
  value.toUpperCase() === 'RANGE')) ||
2484
2470
  (key === 'target_temperature' &&
2485
- this.#rawData[deviceUUID].value.eco_mode_state.ecoMode === 'ECO_MODE_INACTIVE' &&
2471
+ this.#rawData?.[deviceUUID]?.value?.eco_mode_state?.ecoMode === 'ECO_MODE_INACTIVE' &&
2486
2472
  typeof value === 'number') ||
2487
2473
  (key === 'target_temperature_low' &&
2488
- this.#rawData[deviceUUID].value.eco_mode_state.ecoMode === 'ECO_MODE_INACTIVE' &&
2474
+ this.#rawData?.[deviceUUID]?.value?.eco_mode_state?.ecoMode === 'ECO_MODE_INACTIVE' &&
2489
2475
  typeof value === 'number') ||
2490
2476
  (key === 'target_temperature_high' &&
2491
- this.#rawData[deviceUUID].value.eco_mode_state.ecoMode === 'ECO_MODE_INACTIVE' &&
2477
+ this.#rawData?.[deviceUUID]?.value?.eco_mode_state?.ecoMode === 'ECO_MODE_INACTIVE' &&
2492
2478
  typeof value === 'number')
2493
2479
  ) {
2494
2480
  // Set either the 'mode' and/or non-eco temperatures on the target thermostat
2495
- let coolingTarget = this.#rawData[deviceUUID].value.target_temperature_settings.targetTemperature.coolingTarget.value;
2496
- let heatingTarget = this.#rawData[deviceUUID].value.target_temperature_settings.targetTemperature.heatingTarget.value;
2481
+ protobufElement.traitRequest.traitLabel = 'target_temperature_settings';
2482
+ protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.hvac.TargetTemperatureSettingsTrait';
2483
+ protobufElement.state.value = this.#rawData[deviceUUID].value.target_temperature_settings;
2497
2484
 
2498
2485
  if (
2499
- key === 'target_temperature_low' ||
2500
- (key === 'target_temperature' &&
2501
- this.#rawData[deviceUUID].value.target_temperature_settings.targetTemperature.setpointType === 'SET_POINT_TYPE_HEAT')
2486
+ (key === 'target_temperature_low' || key === 'target_temperature') &&
2487
+ (protobufElement.state.value.targetTemperature.setpointType === 'SET_POINT_TYPE_HEAT' ||
2488
+ protobufElement.state.value.targetTemperature.setpointType === 'SET_POINT_TYPE_RANGE')
2502
2489
  ) {
2503
- heatingTarget = value;
2490
+ // Changing heating target temperature
2491
+ protobufElement.state.value.targetTemperature.heatingTarget = { value: value };
2504
2492
  }
2505
2493
  if (
2506
- key === 'target_temperature_high' ||
2507
- (key === 'target_temperature' &&
2508
- this.#rawData[deviceUUID].value.target_temperature_settings.targetTemperature.setpointType === 'SET_POINT_TYPE_COOL')
2494
+ (key === 'target_temperature_high' || key === 'target_temperature') &&
2495
+ (protobufElement.state.value.targetTemperature.setpointType === 'SET_POINT_TYPE_COOL' ||
2496
+ protobufElement.state.value.targetTemperature.setpointType === 'SET_POINT_TYPE_RANGE')
2509
2497
  ) {
2510
- coolingTarget = value;
2498
+ // Changing cooling target temperature
2499
+ protobufElement.state.value.targetTemperature.coolingTarget = { value: value };
2511
2500
  }
2512
2501
 
2513
- protobufElement.traitId.traitLabel = 'target_temperature_settings';
2514
- protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.TargetTemperatureSettingsTrait';
2515
- // eslint-disable-next-line no-undef
2516
- protobufElement.property.value.targetTemperature = structuredClone(this.#rawData[deviceUUID].value.target_temperature_settings);
2517
- protobufElement.property.value.targetTemperature.setpointType =
2518
- key === 'hvac_mode' && value.toUpperCase() !== 'OFF'
2519
- ? 'SET_POINT_TYPE_' + value.toUpperCase()
2520
- : this.#rawData[deviceUUID].value.target_temperature_settings.targetTemperature.setpointType;
2521
- protobufElement.property.value.targetTemperature.heatingTarget = { value: heatingTarget };
2522
- protobufElement.property.value.targetTemperature.coolingTarget = { value: coolingTarget };
2523
- protobufElement.property.value.targetTemperature.currentActorInfo = {
2502
+ if (key === 'hvac_mode' && value.toUpperCase() !== 'OFF') {
2503
+ protobufElement.state.value.targetTemperature.setpointType = 'SET_POINT_TYPE_' + value.toUpperCase();
2504
+ protobufElement.state.value.enabled = { value: true };
2505
+ }
2506
+
2507
+ if (key === 'hvac_mode' && value.toUpperCase() === 'OFF') {
2508
+ protobufElement.state.value.enabled = { value: false };
2509
+ }
2510
+
2511
+ // Tage 'who is doing the temperature/mode change. We are :-)
2512
+ protobufElement.state.value.targetTemperature.currentActorInfo = {
2524
2513
  method: 'HVAC_ACTOR_METHOD_IOS',
2525
2514
  originator: {
2526
2515
  resourceId: Object.keys(this.#rawData)
@@ -2530,76 +2519,62 @@ export default class NestAccfactory {
2530
2519
  timeOfAction: { seconds: Math.floor(Date.now() / 1000), nanos: (Date.now() % 1000) * 1e6 },
2531
2520
  originatorRtsId: '',
2532
2521
  };
2533
- protobufElement.property.value.targetTemperature.originalActorInfo = {
2534
- method: 'HVAC_ACTOR_METHOD_UNSPECIFIED',
2535
- originator: null,
2536
- timeOfAction: null,
2537
- originatorRtsId: '',
2538
- };
2539
- protobufElement.property.value.enabled = {
2540
- value:
2541
- key === 'hvac_mode'
2542
- ? value.toUpperCase() !== 'OFF'
2543
- : this.#rawData[deviceUUID].value.target_temperature_settings.enabled.value,
2544
- };
2545
2522
  }
2546
2523
 
2547
2524
  if (
2548
2525
  (key === 'target_temperature' &&
2549
- this.#rawData[deviceUUID].value.eco_mode_state.ecoMode !== 'ECO_MODE_INACTIVE' &&
2526
+ this.#rawData?.[deviceUUID]?.value?.eco_mode_state?.ecoMode !== 'ECO_MODE_INACTIVE' &&
2550
2527
  typeof value === 'number') ||
2551
2528
  (key === 'target_temperature_low' &&
2552
- this.#rawData[deviceUUID].value.eco_mode_state.ecoMode !== 'ECO_MODE_INACTIVE' &&
2529
+ this.#rawData?.[deviceUUID]?.value?.eco_mode_state?.ecoMode !== 'ECO_MODE_INACTIVE' &&
2553
2530
  typeof value === 'number') ||
2554
2531
  (key === 'target_temperature_high' &&
2555
- this.#rawData[deviceUUID].value.eco_mode_state.ecoMode !== 'ECO_MODE_INACTIVE' &&
2532
+ this.#rawData?.[deviceUUID]?.value?.eco_mode_state?.ecoMode !== 'ECO_MODE_INACTIVE' &&
2556
2533
  typeof value === 'number')
2557
2534
  ) {
2558
2535
  // Set eco mode temperatures on the target thermostat
2559
- protobufElement.traitId.traitLabel = 'eco_mode_settings';
2560
- protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.EcoModeSettingsTrait';
2561
- // eslint-disable-next-line no-undef
2562
- protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.eco_mode_settings);
2563
- protobufElement.property.value.ecoTemperatureHeat.value.value =
2564
- protobufElement.property.value.ecoTemperatureHeat.enabled === true &&
2565
- protobufElement.property.value.ecoTemperatureCool.enabled === false
2536
+ protobufElement.traitRequest.traitLabel = 'eco_mode_settings';
2537
+ protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.hvac.EcoModeSettingsTrait';
2538
+ protobufElement.state.value = this.#rawData[deviceUUID].value.eco_mode_settings;
2539
+
2540
+ protobufElement.state.value.ecoTemperatureHeat.value.value =
2541
+ protobufElement.state.value.ecoTemperatureHeat.enabled === true &&
2542
+ protobufElement.state.value.ecoTemperatureCool.enabled === false
2566
2543
  ? value
2567
- : protobufElement.property.value.ecoTemperatureHeat.value.value;
2568
- protobufElement.property.value.ecoTemperatureCool.value.value =
2569
- protobufElement.property.value.ecoTemperatureHeat.enabled === false &&
2570
- protobufElement.property.value.ecoTemperatureCool.enabled === true
2544
+ : protobufElement.state.value.ecoTemperatureHeat.value.value;
2545
+ protobufElement.state.value.ecoTemperatureCool.value.value =
2546
+ protobufElement.state.value.ecoTemperatureHeat.enabled === false &&
2547
+ protobufElement.state.value.ecoTemperatureCool.enabled === true
2571
2548
  ? value
2572
- : protobufElement.property.value.ecoTemperatureCool.value.value;
2573
- protobufElement.property.value.ecoTemperatureHeat.value.value =
2574
- protobufElement.property.value.ecoTemperatureHeat.enabled === true &&
2575
- protobufElement.property.value.ecoTemperatureCool.enabled === true &&
2549
+ : protobufElement.state.value.ecoTemperatureCool.value.value;
2550
+ protobufElement.state.value.ecoTemperatureHeat.value.value =
2551
+ protobufElement.state.value.ecoTemperatureHeat.enabled === true &&
2552
+ protobufElement.state.value.ecoTemperatureCool.enabled === true &&
2576
2553
  key === 'target_temperature_low'
2577
2554
  ? value
2578
- : protobufElement.property.value.ecoTemperatureHeat.value.value;
2579
- protobufElement.property.value.ecoTemperatureCool.value.value =
2580
- protobufElement.property.value.ecoTemperatureHeat.enabled === true &&
2581
- protobufElement.property.value.ecoTemperatureCool.enabled === true &&
2555
+ : protobufElement.state.value.ecoTemperatureHeat.value.value;
2556
+ protobufElement.state.value.ecoTemperatureCool.value.value =
2557
+ protobufElement.state.value.ecoTemperatureHeat.enabled === true &&
2558
+ protobufElement.state.value.ecoTemperatureCool.enabled === true &&
2582
2559
  key === 'target_temperature_high'
2583
2560
  ? value
2584
- : protobufElement.property.value.ecoTemperatureCool.value.value;
2561
+ : protobufElement.state.value.ecoTemperatureCool.value.value;
2585
2562
  }
2586
2563
 
2587
2564
  if (key === 'temperature_scale' && typeof value === 'string' && (value.toUpperCase() === 'C' || value.toUpperCase() === 'F')) {
2588
2565
  // Set the temperature scale on the target thermostat
2589
- protobufElement.traitId.traitLabel = 'display_settings';
2590
- protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.DisplaySettingsTrait';
2591
- // eslint-disable-next-line no-undef
2592
- protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.display_settings);
2593
- protobufElement.property.value.temperatureScale = value.toUpperCase() === 'F' ? 'TEMPERATURE_SCALE_F' : 'TEMPERATURE_SCALE_C';
2566
+ protobufElement.traitRequest.traitLabel = 'display_settings';
2567
+ protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.hvac.DisplaySettingsTrait';
2568
+ protobufElement.state.value = this.#rawData[deviceUUID].value.display_settings;
2569
+ protobufElement.state.value.temperatureScale = value.toUpperCase() === 'F' ? 'TEMPERATURE_SCALE_F' : 'TEMPERATURE_SCALE_C';
2594
2570
  }
2595
2571
 
2596
2572
  if (key === 'temperature_lock' && typeof value === 'boolean') {
2597
2573
  // Set lock mode on the target thermostat
2598
- protobufElement.traitId.traitLabel = 'temperature_lock_settings';
2599
- protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.TemperatureLockSettingsTrait';
2600
- // eslint-disable-next-line no-undef
2601
- protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.temperature_lock_settings);
2602
- protobufElement.property.value.enabled = value === true;
2574
+ protobufElement.traitRequest.traitLabel = 'temperature_lock_settings';
2575
+ protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.hvac.TemperatureLockSettingsTrait';
2576
+ protobufElement.state.value = this.#rawData[deviceUUID].value.temperature_lock_settings;
2577
+ protobufElement.state.value.enabled = value === true;
2603
2578
  }
2604
2579
 
2605
2580
  if (key === 'fan_state' && typeof value === 'boolean') {
@@ -2609,11 +2584,10 @@ export default class NestAccfactory {
2609
2584
  ? Math.floor(Date.now() / 1000) + this.#rawData[deviceUUID].value.fan_control_settings.timerDuration.seconds
2610
2585
  : 0;
2611
2586
 
2612
- protobufElement.traitId.traitLabel = 'fan_control_settings';
2613
- protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.FanControlSettingsTrait';
2614
- // eslint-disable-next-line no-undef
2615
- protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.fan_control_settings);
2616
- protobufElement.property.value.timerEnd = { seconds: endTime, nanos: (endTime % 1000) * 1e6 };
2587
+ protobufElement.traitRequest.traitLabel = 'fan_control_settings';
2588
+ protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.hvac.FanControlSettingsTrait';
2589
+ protobufElement.state.value = this.#rawData[deviceUUID].value.fan_control_settings;
2590
+ protobufElement.state.value.timerEnd = { seconds: endTime, nanos: (endTime % 1000) * 1e6 };
2617
2591
  }
2618
2592
 
2619
2593
  //if (key === 'statusled.brightness'
@@ -2621,128 +2595,155 @@ export default class NestAccfactory {
2621
2595
 
2622
2596
  if (key === 'streaming.enabled' && typeof value === 'boolean') {
2623
2597
  // Turn camera video on/off
2624
- protobufElement.traitId.traitLabel = 'recording_toggle_settings';
2625
- protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.product.camera.RecordingToggleSettingsTrait';
2626
- // eslint-disable-next-line no-undef
2627
- protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.recording_toggle_settings);
2628
- protobufElement.property.value.targetCameraState = value === true ? 'CAMERA_ON' : 'CAMERA_OFF';
2629
- protobufElement.property.value.changeModeReason = 2;
2630
- protobufElement.property.value.settingsUpdated = { seconds: Math.floor(Date.now() / 1000), nanos: (Date.now() % 1000) * 1e6 };
2631
- }
2632
-
2633
- if (key === 'watermark.enabled' && typeof value === 'boolean') {
2634
- // Unsupported via protobuf?
2598
+ protobufElement.traitRequest.traitLabel = 'recording_toggle_settings';
2599
+ protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.product.camera.RecordingToggleSettingsTrait';
2600
+ protobufElement.state.value = this.#rawData[deviceUUID].value.recording_toggle_settings;
2601
+ protobufElement.state.value.targetCameraState = value === true ? 'CAMERA_ON' : 'CAMERA_OFF';
2602
+ protobufElement.state.value.changeModeReason = 2;
2603
+ protobufElement.state.value.settingsUpdated = { seconds: Math.floor(Date.now() / 1000), nanos: (Date.now() % 1000) * 1e6 };
2635
2604
  }
2636
2605
 
2637
2606
  if (key === 'audio.enabled' && typeof value === 'boolean') {
2638
2607
  // Enable/disable microphone on camera/doorbell
2639
- protobufElement.traitId.traitLabel = 'microphone_settings';
2640
- protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.audio.MicrophoneSettingsTrait';
2641
- // eslint-disable-next-line no-undef
2642
- protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.microphone_settings);
2643
- protobufElement.property.value.enableMicrophone = value;
2608
+ protobufElement.traitRequest.traitLabel = 'microphone_settings';
2609
+ protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.audio.MicrophoneSettingsTrait';
2610
+ protobufElement.state.value = this.#rawData[deviceUUID].value.microphone_settings;
2611
+ protobufElement.state.value.enableMicrophone = value;
2644
2612
  }
2645
2613
 
2646
- if (key === 'doorbell.indoor_chime.enabled' && typeof value === 'boolean') {
2614
+ if (key === 'indoor_chime_enabled' && typeof value === 'boolean') {
2647
2615
  // Enable/disable chime status on doorbell
2648
- protobufElement.traitId.traitLabel = 'doorbell_indoor_chime_settings';
2649
- protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.product.doorbell.DoorbellIndoorChimeSettingsTrait';
2650
- // eslint-disable-next-line no-undef
2651
- protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.doorbell_indoor_chime_settings);
2652
- protobufElement.property.value.chimeEnabled = value;
2616
+ protobufElement.traitRequest.traitLabel = 'doorbell_indoor_chime_settings';
2617
+ protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.product.doorbell.DoorbellIndoorChimeSettingsTrait';
2618
+ protobufElement.state.value = this.#rawData[deviceUUID].value.doorbell_indoor_chime_settings;
2619
+ protobufElement.state.value.chimeEnabled = value;
2620
+ }
2621
+
2622
+ if (key === 'light_enabled' && typeof value === 'boolean') {
2623
+ // Turn on/off light on supported camera devices. Need to find the related or SERVICE__ object for teh device
2624
+ let serviceUUID = undefined;
2625
+ if (this.#rawData[deviceUUID].value?.related_resources?.relatedResources !== undefined) {
2626
+ Object.values(this.#rawData[deviceUUID].value?.related_resources?.relatedResources).forEach((values) => {
2627
+ if (
2628
+ values?.resourceTypeName?.resourceName === 'google.resource.AzizResource' &&
2629
+ values?.resourceId?.resourceId.startsWith('SERVICE_') === true
2630
+ ) {
2631
+ serviceUUID = values.resourceId.resourceId;
2632
+ }
2633
+ });
2634
+
2635
+ if (serviceUUID !== undefined) {
2636
+ let commandResponse = await this.#protobufCommand(this.#rawData[deviceUUID].connection, 'ResourceApi', 'SendCommand', {
2637
+ resourceRequest: {
2638
+ resourceId: serviceUUID,
2639
+ requestId: crypto.randomUUID(),
2640
+ },
2641
+ resourceCommands: [
2642
+ {
2643
+ traitLabel: 'on_off',
2644
+ command: {
2645
+ type_url: 'type.nestlabs.com/weave.trait.actuator.OnOffTrait.SetStateRequest',
2646
+ value: {
2647
+ on: value,
2648
+ },
2649
+ },
2650
+ },
2651
+ ],
2652
+ });
2653
+
2654
+ if (commandResponse.sendCommandResponse?.[0]?.traitOperations?.[0]?.progress !== 'COMPLETE') {
2655
+ this?.log?.debug && this.log.debug('Protobuf API had error setting light status on uuid "%s"', deviceUUID);
2656
+ }
2657
+ }
2658
+ }
2653
2659
  }
2654
2660
 
2655
- if (protobufElement.traitId.traitLabel === '' || protobufElement.property.type_url === '') {
2656
- this?.log?.debug && this.log.debug('Unknown protobuf set key for device', deviceUUID, key, value);
2661
+ if (key === 'light_brightness' && typeof value === 'number') {
2662
+ // Set light brightness on supported camera devices
2663
+ protobufElement.traitRequest.traitLabel = 'floodlight_settings';
2664
+ protobufElement.state.type_url = 'type.nestlabs.com/google.trait.product.camera.FloodlightSettingsTrait';
2665
+ protobufElement.state.value = this.#rawData[deviceUUID].value.floodlight_settings;
2666
+ protobufElement.state.value.brightness = scaleValue(value, 0, 100, 0, 10); // Scale to required level
2657
2667
  }
2658
2668
 
2659
- if (protobufElement.traitId.traitLabel !== '' && protobufElement.property.type_url !== '') {
2660
- let trait = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot.lookup(
2661
- protobufElement.property.type_url.split('/')[1],
2662
- );
2663
- protobufElement.property.value = trait.encode(trait.fromObject(protobufElement.property.value)).finish();
2669
+ if (protobufElement.traitRequest.traitLabel === '' || protobufElement.state.type_url === '') {
2670
+ this?.log?.debug && this.log.debug('Unknown Protobuf set key "%s" for device uuid "%s"', key, deviceUUID);
2671
+ }
2672
+
2673
+ if (protobufElement.traitRequest.traitLabel !== '' && protobufElement.state.type_url !== '') {
2664
2674
  // eslint-disable-next-line no-undef
2665
- setDataToEncode.push(structuredClone(protobufElement));
2675
+ updatedTraits.push(structuredClone(protobufElement));
2666
2676
  }
2667
2677
  }),
2668
2678
  );
2669
2679
 
2670
- if (setDataToEncode.length !== 0 && TraitMap !== null) {
2671
- let encodedData = TraitMap.encode(TraitMap.fromObject({ set: setDataToEncode })).finish();
2672
- let request = {
2673
- method: 'post',
2674
- url:
2675
- 'https://' +
2676
- this.#connections[this.#rawData[deviceUUID].connection].protobufAPIHost +
2677
- '/nestlabs.gateway.v1.TraitBatchApi/BatchUpdateState',
2678
- headers: {
2679
- 'User-Agent': USERAGENT,
2680
- Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token,
2681
- 'Content-Type': 'application/x-protobuf',
2682
- 'X-Accept-Content-Transfer-Encoding': 'binary',
2683
- 'X-Accept-Response-Streaming': 'true',
2684
- },
2685
- data: encodedData,
2686
- };
2687
- axios(request)
2688
- .then((response) => {
2689
- if (typeof response.status !== 'number' || response.status !== 200) {
2690
- throw new Error('protobuf API had error updating device traits');
2691
- }
2692
- })
2693
- .catch((error) => {
2694
- this?.log?.debug &&
2695
- this.log.debug('protobuf API had error updating device traits for uuid "%s". Error was "%s"', deviceUUID, error?.code);
2696
- });
2680
+ if (updatedTraits.length !== 0) {
2681
+ let commandResponse = await this.#protobufCommand(this.#rawData[deviceUUID].connection, 'TraitBatchApi', 'BatchUpdateState', {
2682
+ batchUpdateStateRequest: updatedTraits,
2683
+ });
2684
+ if (
2685
+ commandResponse === undefined ||
2686
+ commandResponse?.batchUpdateStateResponse?.[0]?.traitOperations?.[0]?.progress !== 'COMPLETE'
2687
+ ) {
2688
+ this?.log?.debug && this.log.debug('Protobuf API had error updating device traits for uuid "%s"', deviceUUID);
2689
+ }
2697
2690
  }
2698
2691
  }
2699
2692
 
2700
- if (this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.REST && deviceUUID.startsWith('quartz.') === true) {
2693
+ if (this.#rawData?.[deviceUUID]?.source === NestAccfactory.DataSource.REST && deviceUUID.startsWith('quartz.') === true) {
2701
2694
  // Set value on Nest Camera/Doorbell
2702
2695
  await Promise.all(
2703
2696
  Object.entries(values).map(async ([key, value]) => {
2704
- let request = {
2705
- method: 'post',
2706
- url: 'https://webapi.' + this.#connections[this.#rawData[deviceUUID].connection].cameraAPIHost + '/api/dropcams.set_properties',
2707
- headers: {
2708
- referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer,
2709
- 'User-Agent': USERAGENT,
2710
- 'content-type': 'application/x-www-form-urlencoded',
2711
- [this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.key]:
2712
- this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.value +
2713
- this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.token,
2714
- },
2715
- responseType: 'json',
2716
- timeout: NESTAPITIMEOUT,
2717
- data: [key] + '=' + value + '&uuid=' + deviceUUID.split('.')[1],
2697
+ const SETPROPERTIES = {
2698
+ indoor_chime_enabled: 'doorbell.indoor_chime.enabled',
2699
+ statusled_brightness: 'statusled.brightness',
2700
+ irled_enabled: 'irled.state',
2701
+ streaming_enabled: 'streaming.enabled',
2702
+ audio_enabled: 'audio.enabled',
2718
2703
  };
2719
- await axios(request)
2720
- .then((response) => {
2721
- if (
2722
- typeof response.status !== 'number' ||
2723
- response.status !== 200 ||
2724
- typeof response.data.status !== 'number' ||
2725
- response.data.status !== 0
2726
- ) {
2704
+
2705
+ // Transform key to correct set camera properties key
2706
+ key = SETPROPERTIES[key] !== undefined ? SETPROPERTIES[key] : key;
2707
+
2708
+ await fetchWrapper(
2709
+ 'post',
2710
+ 'https://webapi.' + this.#connections[this.#rawData[deviceUUID].connection].cameraAPIHost + '/api/dropcams.set_properties',
2711
+ {
2712
+ headers: {
2713
+ referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer,
2714
+ 'User-Agent': USERAGENT,
2715
+ 'Content-Type': 'application/x-www-form-urlencoded',
2716
+ [this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.key]:
2717
+ this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.value +
2718
+ this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.token,
2719
+ },
2720
+ timeout: NESTAPITIMEOUT,
2721
+ },
2722
+ [key] + '=' + value + '&uuid=' + deviceUUID.split('.')[1],
2723
+ )
2724
+ .then((response) => response.json())
2725
+ .then((data) => {
2726
+ if (data?.status !== 0) {
2727
2727
  throw new Error('REST API camera update for failed with error');
2728
2728
  }
2729
2729
  })
2730
2730
  .catch((error) => {
2731
- this?.log?.debug &&
2731
+ if (error?.name !== 'TimeoutError' && this?.log?.debug) {
2732
2732
  this.log.debug('REST API camera update for failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code);
2733
+ }
2733
2734
  });
2734
2735
  }),
2735
2736
  );
2736
2737
  }
2737
2738
 
2738
- if (this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.REST && deviceUUID.startsWith('quartz.') === false) {
2739
+ if (this.#rawData?.[deviceUUID]?.source === NestAccfactory.DataSource.REST && deviceUUID.startsWith('quartz.') === false) {
2739
2740
  // set values on other Nest devices besides cameras/doorbells
2740
2741
  await Promise.all(
2741
2742
  Object.entries(values).map(async ([key, value]) => {
2742
- let restAPIJSONData = { objects: [] };
2743
+ let subscribeJSONData = { objects: [] };
2743
2744
 
2744
2745
  if (deviceUUID.startsWith('device.') === false) {
2745
- restAPIJSONData.objects.push({ object_key: deviceUUID, op: 'MERGE', value: { [key]: value } });
2746
+ subscribeJSONData.objects.push({ object_key: deviceUUID, op: 'MERGE', value: { [key]: value } });
2746
2747
  }
2747
2748
 
2748
2749
  // Some elements when setting thermostat data are located in a different object locations than with the device object
@@ -2763,30 +2764,25 @@ export default class NestAccfactory {
2763
2764
  ) {
2764
2765
  RESTStructureUUID = 'shared.' + deviceUUID.split('.')[1];
2765
2766
  }
2766
- restAPIJSONData.objects.push({ object_key: RESTStructureUUID, op: 'MERGE', value: { [key]: value } });
2767
+ subscribeJSONData.objects.push({ object_key: RESTStructureUUID, op: 'MERGE', value: { [key]: value } });
2767
2768
  }
2768
2769
 
2769
- if (restAPIJSONData.objects.length !== 0) {
2770
- let request = {
2771
- method: 'post',
2772
- url: this.#connections[this.#rawData[deviceUUID].connection].transport_url + '/v5/put',
2773
- responseType: 'json',
2774
- headers: {
2775
- 'User-Agent': USERAGENT,
2776
- Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token,
2770
+ if (subscribeJSONData.objects.length !== 0) {
2771
+ await fetchWrapper(
2772
+ 'post',
2773
+ this.#connections[this.#rawData[deviceUUID].connection].transport_url + '/v5/put',
2774
+ {
2775
+ referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer,
2776
+ headers: {
2777
+ 'User-Agent': USERAGENT,
2778
+ Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token,
2779
+ },
2777
2780
  },
2778
- data: JSON.stringify(restAPIJSONData),
2779
- };
2780
- await axios(request)
2781
- .then(async (response) => {
2782
- if (typeof response.status !== 'number' || response.status !== 200) {
2783
- throw new Error('REST API property update for failed with error');
2784
- }
2785
- })
2786
- .catch((error) => {
2787
- this?.log?.debug &&
2788
- this.log.debug('REST API property update for failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code);
2789
- });
2781
+ JSON.stringify(subscribeJSONData),
2782
+ ).catch((error) => {
2783
+ this?.log?.debug &&
2784
+ this.log.debug('REST API property update for failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code);
2785
+ });
2790
2786
  }
2791
2787
  }),
2792
2788
  );
@@ -2796,9 +2792,9 @@ export default class NestAccfactory {
2796
2792
  async #get(deviceUUID, values) {
2797
2793
  if (
2798
2794
  typeof deviceUUID !== 'string' ||
2799
- typeof this.#rawData[deviceUUID] !== 'object' ||
2795
+ typeof this.#rawData?.[deviceUUID] !== 'object' ||
2800
2796
  typeof values !== 'object' ||
2801
- typeof this.#connections[this.#rawData[deviceUUID]?.connection] !== 'object'
2797
+ typeof this.#connections[this.#rawData?.[deviceUUID]?.connection] !== 'object'
2802
2798
  ) {
2803
2799
  values = {};
2804
2800
  }
@@ -2810,94 +2806,84 @@ export default class NestAccfactory {
2810
2806
  values[key] = undefined;
2811
2807
 
2812
2808
  if (
2813
- this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.REST &&
2809
+ this.#rawData?.[deviceUUID]?.source === NestAccfactory.DataSource.REST &&
2814
2810
  key === 'camera_snapshot' &&
2815
- deviceUUID.startsWith('quartz.') === true
2811
+ deviceUUID.startsWith('quartz.') === true &&
2812
+ typeof this.#rawData?.[deviceUUID]?.value?.nexus_api_http_server_url === 'string' &&
2813
+ this.#rawData[deviceUUID].value.nexus_api_http_server_url !== ''
2816
2814
  ) {
2817
2815
  // Attempt to retrieve snapshot from camera via REST API
2818
- let request = {
2819
- method: 'get',
2820
- url: this.#rawData[deviceUUID].value.nexus_api_http_server_url + '/get_image?uuid=' + deviceUUID.split('.')[1],
2821
- headers: {
2822
- referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer,
2823
- 'User-Agent': USERAGENT,
2824
- accept: '*/*',
2825
- [this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.key]:
2826
- this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.value +
2827
- this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.token,
2816
+ await fetchWrapper(
2817
+ 'get',
2818
+ this.#rawData[deviceUUID].value.nexus_api_http_server_url + '/get_image?uuid=' + deviceUUID.split('.')[1],
2819
+ {
2820
+ headers: {
2821
+ referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer,
2822
+ 'User-Agent': USERAGENT,
2823
+ [this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.key]:
2824
+ this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.value +
2825
+ this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.token,
2826
+ },
2827
+ timeout: 3000,
2828
2828
  },
2829
- responseType: 'arraybuffer',
2830
- timeout: 3000,
2831
- };
2832
-
2833
- // if (typeof keyValue keyValue !== '')
2834
- /* (url =
2835
- this.#rawData[deviceUUID].value.nexus_api_http_server_url +
2836
- '/event_snapshot/' +
2837
- deviceUUID.split('.')[1] +
2838
- '/' +
2839
- id +
2840
- '?crop_type=timeline&cachebuster=' +
2841
- Math.floor(Date.now() / 1000)), */
2842
-
2843
- await axios(request)
2844
- .then((response) => {
2845
- if (typeof response.status !== 'number' || response.status !== 200) {
2846
- throw new Error('REST API camera snapshot failed with error');
2847
- }
2848
-
2849
- values[key] = response.data;
2829
+ )
2830
+ .then((response) => response.arrayBuffer())
2831
+ .then((data) => {
2832
+ values[key] = Buffer.from(data);
2850
2833
  })
2851
2834
  .catch((error) => {
2852
- this?.log?.debug &&
2835
+ if (error?.name !== 'TimeoutError' && this?.log?.debug) {
2853
2836
  this.log.debug('REST API camera snapshot failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code);
2837
+ }
2854
2838
  });
2855
2839
  }
2856
2840
 
2857
2841
  if (
2858
- this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.PROTOBUF &&
2842
+ this.#rawData?.[deviceUUID]?.source === NestAccfactory.DataSource.PROTOBUF &&
2859
2843
  this.#connections[this.#rawData[deviceUUID].connection].protobufRoot !== null &&
2860
2844
  this.#rawData[deviceUUID]?.value?.device_identity?.vendorProductId !== undefined &&
2861
2845
  key === 'camera_snapshot'
2862
2846
  ) {
2863
- // Attempt to retrieve snapshot from camera via protobuf API
2847
+ // Attempt to retrieve snapshot from camera via Protobuf API
2864
2848
  // First, request to get snapshot url image updated
2865
- let commandResponse = await this.#protobufCommand(deviceUUID, [
2866
- {
2867
- traitLabel: 'upload_live_image',
2868
- command: {
2869
- type_url: 'type.nestlabs.com/nest.trait.product.camera.UploadLiveImageTrait.UploadLiveImageRequest',
2870
- value: {},
2871
- },
2849
+ let commandResponse = await this.#protobufCommand(this.#rawData[deviceUUID].connection, 'ResourceApi', 'SendCommand', {
2850
+ resourceRequest: {
2851
+ resourceId: deviceUUID,
2852
+ requestId: crypto.randomUUID(),
2872
2853
  },
2873
- ]);
2854
+ resourceCommands: [
2855
+ {
2856
+ traitLabel: 'upload_live_image',
2857
+ command: {
2858
+ type_url: 'type.nestlabs.com/nest.trait.product.camera.UploadLiveImageTrait.UploadLiveImageRequest',
2859
+ value: {},
2860
+ },
2861
+ },
2862
+ ],
2863
+ });
2874
2864
 
2875
- if (commandResponse?.resourceCommandResponse?.[0]?.traitOperations?.[0]?.progress === 'COMPLETE') {
2865
+ if (
2866
+ commandResponse?.sendCommandResponse?.[0]?.traitOperations?.[0]?.progress === 'COMPLETE' &&
2867
+ typeof this.#rawData?.[deviceUUID]?.value?.upload_live_image?.liveImageUrl === 'string' &&
2868
+ this.#rawData[deviceUUID].value.upload_live_image.liveImageUrl !== ''
2869
+ ) {
2876
2870
  // Snapshot url image has beeen updated, so no retrieve it
2877
- let request = {
2878
- method: 'get',
2879
- url: this.#rawData[deviceUUID].value.upload_live_image.liveImageUrl,
2871
+ await fetchWrapper('get', this.#rawData[deviceUUID].value.upload_live_image.liveImageUrl, {
2872
+ referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer,
2880
2873
  headers: {
2881
2874
  'User-Agent': USERAGENT,
2882
- accept: '*/*',
2883
- [this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.key]:
2884
- this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.value +
2885
- this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.token,
2875
+ Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token,
2886
2876
  },
2887
- responseType: 'arraybuffer',
2888
2877
  timeout: 3000,
2889
- };
2890
- await axios(request)
2891
- .then((response) => {
2892
- if (typeof response.status !== 'number' || response.status !== 200) {
2893
- throw new Error('protobuf API camera snapshot failed with error');
2894
- }
2895
-
2896
- values[key] = response.data;
2878
+ })
2879
+ .then((response) => response.arrayBuffer())
2880
+ .then((data) => {
2881
+ values[key] = Buffer.from(data);
2897
2882
  })
2898
2883
  .catch((error) => {
2899
- this?.log?.debug &&
2900
- this.log.debug('protobuf API camera snapshot failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code);
2884
+ if (error?.name !== 'TimeoutError' && this?.log?.debug) {
2885
+ this.log.debug('Protobuf API camera snapshot failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code);
2886
+ }
2901
2887
  });
2902
2888
  }
2903
2889
  }
@@ -2908,124 +2894,118 @@ export default class NestAccfactory {
2908
2894
  this.#eventEmitter.emit(HomeKitDevice.GET + '->' + deviceUUID, values);
2909
2895
  }
2910
2896
 
2911
- async #getWeatherData(connectionType, deviceUUID, latitude, longitude) {
2897
+ async #getWeatherData(connectionUUID, deviceUUID, latitude, longitude) {
2912
2898
  let weatherData = {};
2913
2899
  if (typeof this.#rawData[deviceUUID]?.value?.weather === 'object') {
2914
- weatherData = this.#rawData[deviceUUID].value.weather;
2900
+ weatherData = this.#rawData?.[deviceUUID]?.value.weather;
2915
2901
  }
2916
2902
 
2917
- let request = {
2918
- method: 'get',
2919
- url: this.#connections[connectionType].weather_url + latitude + ',' + longitude,
2920
- headers: {
2921
- 'User-Agent': USERAGENT,
2922
- },
2923
- responseType: 'json',
2924
- timeout: NESTAPITIMEOUT,
2925
- };
2926
- await axios(request)
2927
- .then((response) => {
2928
- if (typeof response.status !== 'number' || response.status !== 200) {
2929
- throw new Error('REST API failed to retireve weather details');
2930
- }
2931
-
2932
- if (typeof response.data[latitude + ',' + longitude].current === 'object') {
2903
+ if (typeof this.#connections?.[connectionUUID].weather_url === 'string' && this.#connections[connectionUUID].weather_url !== '') {
2904
+ await fetchWrapper('get', this.#connections[connectionUUID].weather_url + latitude + ',' + longitude, {
2905
+ referer: 'https://' + this.#connections[connectionUUID].referer,
2906
+ headers: {
2907
+ 'User-Agent': USERAGENT,
2908
+ },
2909
+ timeout: NESTAPITIMEOUT,
2910
+ })
2911
+ .then((response) => response.json())
2912
+ .then((data) => {
2933
2913
  // Store the lat/long details in the weather data object
2934
2914
  weatherData.latitude = latitude;
2935
2915
  weatherData.longitude = longitude;
2936
2916
 
2937
- // Update weather data object
2938
- weatherData.current_temperature = adjustTemperature(response.data[latitude + ',' + longitude].current.temp_c, 'C', 'C', false);
2939
- weatherData.current_humidity = response.data[latitude + ',' + longitude].current.humidity;
2940
- weatherData.condition = response.data[latitude + ',' + longitude].current.condition;
2941
- weatherData.wind_direction = response.data[latitude + ',' + longitude].current.wind_dir;
2942
- weatherData.wind_speed = response.data[latitude + ',' + longitude].current.wind_mph * 1.609344; // convert to km/h
2943
- weatherData.sunrise = response.data[latitude + ',' + longitude].current.sunrise;
2944
- weatherData.sunset = response.data[latitude + ',' + longitude].current.sunset;
2945
- weatherData.station = response.data[latitude + ',' + longitude].location.short_name;
2946
- weatherData.forecast = response.data[latitude + ',' + longitude].forecast.daily[0].condition;
2947
- }
2948
- })
2949
- .catch((error) => {
2950
- this?.log?.debug &&
2951
- this.log.debug('REST API failed to retireve weather details for uuid "%s". Error was "%s"', deviceUUID, error?.code);
2952
- });
2917
+ // Update weather data
2918
+ if (data?.[latitude + ',' + longitude]?.current !== undefined) {
2919
+ weatherData.current_temperature = adjustTemperature(data[latitude + ',' + longitude].current.temp_c, 'C', 'C', false);
2920
+ weatherData.current_humidity = data[latitude + ',' + longitude].current.humidity;
2921
+ weatherData.condition = data[latitude + ',' + longitude].current.condition;
2922
+ weatherData.wind_direction = data[latitude + ',' + longitude].current.wind_dir;
2923
+ weatherData.wind_speed = data[latitude + ',' + longitude].current.wind_mph * 1.609344; // convert to km/h
2924
+ weatherData.sunrise = data[latitude + ',' + longitude].current.sunrise;
2925
+ weatherData.sunset = data[latitude + ',' + longitude].current.sunset;
2926
+ }
2927
+ weatherData.station =
2928
+ data[latitude + ',' + longitude]?.location?.short_name !== undefined
2929
+ ? data[latitude + ',' + longitude].location.short_name
2930
+ : '';
2931
+ weatherData.forecast =
2932
+ data[latitude + ',' + longitude]?.forecast?.daily?.[0]?.condition !== undefined
2933
+ ? data[latitude + ',' + longitude].forecast.daily[0].condition
2934
+ : '';
2935
+ })
2936
+ .catch((error) => {
2937
+ if (error?.name !== 'TimeoutError' && this?.log?.debug) {
2938
+ this.log.debug('REST API failed to retrieve weather details for uuid "%s". Error was "%s"', deviceUUID, error?.code);
2939
+ }
2940
+ });
2941
+ }
2942
+
2953
2943
  return weatherData;
2954
2944
  }
2955
2945
 
2956
- async #protobufCommand(deviceUUID, commands) {
2946
+ async #protobufCommand(connectionUUID, service, command, values) {
2957
2947
  if (
2958
- typeof deviceUUID !== 'string' ||
2959
- typeof this.#rawData?.[deviceUUID] !== 'object' ||
2960
- this.#rawData[deviceUUID]?.source !== NestAccfactory.DataSource.PROTOBUF ||
2961
- Array.isArray(commands === false) ||
2962
- typeof this.#connections[this.#rawData[deviceUUID]?.connection] !== 'object'
2948
+ this.#connections?.[connectionUUID]?.protobufRoot === null ||
2949
+ typeof service !== 'string' ||
2950
+ service === '' ||
2951
+ typeof command !== 'string' ||
2952
+ command === '' ||
2953
+ typeof values !== 'object'
2963
2954
  ) {
2964
2955
  return;
2965
2956
  }
2966
2957
 
2967
- let commandResponse = undefined;
2968
- let encodedData = undefined;
2958
+ const encodeValues = (object) => {
2959
+ if (typeof object === 'object' && object !== null) {
2960
+ if ('type_url' in object && 'value' in object) {
2961
+ // We have a type_url and value object at this same level, we'll treat this a trait requiring encoding
2962
+ let TraitMap = this.#connections[connectionUUID].protobufRoot.lookup(object.type_url.split('/')[1]);
2963
+ if (TraitMap !== null) {
2964
+ object.value = TraitMap.encode(TraitMap.fromObject(object.value)).finish();
2965
+ }
2966
+ }
2969
2967
 
2970
- // Build the protobuf command object for encoding
2971
- let protobufElement = {
2972
- resourceRequest: {
2973
- resourceId: deviceUUID,
2974
- requestId: crypto.randomUUID(),
2975
- },
2976
- resourceCommands: commands,
2968
+ for (let key in object) {
2969
+ if (object?.[key] !== undefined) {
2970
+ encodeValues(object[key]);
2971
+ }
2972
+ }
2973
+ }
2977
2974
  };
2978
2975
 
2979
- // End code each of the commands
2980
- protobufElement.resourceCommands.forEach((command) => {
2981
- let trait = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot.lookup(command.command.type_url.split('/')[1]);
2982
- if (trait !== null) {
2983
- command.command.value = trait.encode(trait.fromObject(command.command.value)).finish();
2984
- }
2985
- });
2976
+ // Attempt to retrieve both 'Request' and 'Reponse' traits for the associated service and command
2977
+ let TraitMapRequest = this.#connections[connectionUUID].protobufRoot.lookup('nestlabs.gateway.v1.' + command + 'Request');
2978
+ let TraitMapResponse = this.#connections[connectionUUID].protobufRoot.lookup('nestlabs.gateway.v1.' + command + 'Response');
2979
+ let commandResponse = undefined;
2986
2980
 
2987
- let TraitMap = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot.lookup(
2988
- 'nestlabs.gateway.v1.ResourceCommandRequest',
2989
- );
2990
- if (TraitMap !== null) {
2991
- encodedData = TraitMap.encode(TraitMap.fromObject(protobufElement)).finish();
2992
- }
2981
+ if (TraitMapRequest !== null && TraitMapResponse !== null) {
2982
+ // Encode any trait values in our passed in object
2983
+ encodeValues(values);
2993
2984
 
2994
- if (encodedData !== undefined) {
2995
- let request = {
2996
- method: 'post',
2997
- url:
2998
- 'https://' +
2999
- this.#connections[this.#rawData[deviceUUID].connection].protobufAPIHost +
3000
- '/nestlabs.gateway.v1.ResourceApi/SendCommand',
3001
- headers: {
3002
- 'User-Agent': USERAGENT,
3003
- Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token,
3004
- 'Content-Type': 'application/x-protobuf',
3005
- 'X-Accept-Content-Transfer-Encoding': 'binary',
3006
- 'X-Accept-Response-Streaming': 'true',
2985
+ let encodedData = TraitMapRequest.encode(TraitMapRequest.fromObject(values)).finish();
2986
+ await fetchWrapper(
2987
+ 'post',
2988
+ 'https://' + this.#connections[connectionUUID].protobufAPIHost + '/nestlabs.gateway.v1.' + service + '/' + command,
2989
+ {
2990
+ headers: {
2991
+ referer: 'https://' + this.#connections[connectionUUID].referer,
2992
+ 'User-Agent': USERAGENT,
2993
+ Authorization: 'Basic ' + this.#connections[connectionUUID].token,
2994
+ 'Content-Type': 'application/x-protobuf',
2995
+ 'X-Accept-Content-Transfer-Encoding': 'binary',
2996
+ 'X-Accept-Response-Streaming': 'true',
2997
+ },
3007
2998
  },
3008
- responseType: 'arraybuffer',
3009
- data: encodedData,
3010
- };
3011
-
3012
- await axios(request)
3013
- .then((response) => {
3014
- if (typeof response.status !== 'number' || response.status !== 200) {
3015
- throw new Error('protobuf command send failed with error');
3016
- }
3017
-
3018
- commandResponse = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot
3019
- .lookup('nestlabs.gateway.v1.ResourceCommandResponseFromAPI')
3020
- .decode(response.data)
3021
- .toJSON();
2999
+ encodedData,
3000
+ )
3001
+ .then((response) => response.bytes())
3002
+ .then((data) => {
3003
+ commandResponse = TraitMapResponse.decode(data).toJSON();
3022
3004
  })
3023
3005
  .catch((error) => {
3024
- this?.log?.debug &&
3025
- this.log.debug('protobuf command send failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code);
3006
+ this?.log?.debug && this.log.debug('Protobuf gateway service command failed with error. Error was "%s"', error?.code);
3026
3007
  });
3027
3008
  }
3028
-
3029
3009
  return commandResponse;
3030
3010
  }
3031
3011
  }
@@ -3063,10 +3043,12 @@ function makeHomeKitName(nameToMakeValid) {
3063
3043
  // Strip invalid characters to meet HomeKit naming requirements
3064
3044
  // Ensure only letters or numbers are at the beginning AND/OR end of string
3065
3045
  // Matches against uni-code characters
3066
- return nameToMakeValid
3067
- .replace(/[^\p{L}\p{N}\p{Z}\u2019.,-]/gu, '')
3068
- .replace(/^[^\p{L}\p{N}]*/gu, '')
3069
- .replace(/[^\p{L}\p{N}]+$/gu, '');
3046
+ return typeof nameToMakeValid === 'string'
3047
+ ? nameToMakeValid
3048
+ .replace(/[^\p{L}\p{N}\p{Z}\u2019.,-]/gu, '')
3049
+ .replace(/^[^\p{L}\p{N}]*/gu, '')
3050
+ .replace(/[^\p{L}\p{N}]+$/gu, '')
3051
+ : nameToMakeValid;
3070
3052
  }
3071
3053
 
3072
3054
  function crc24(valueToHash) {
@@ -3110,3 +3092,31 @@ function scaleValue(value, sourceRangeMin, sourceRangeMax, targetRangeMin, targe
3110
3092
  }
3111
3093
  return ((value - sourceRangeMin) * (targetRangeMax - targetRangeMin)) / (sourceRangeMax - sourceRangeMin) + targetRangeMin;
3112
3094
  }
3095
+
3096
+ async function fetchWrapper(method, url, options, data) {
3097
+ if ((method !== 'get' && method !== 'post') || typeof url !== 'string' || url === '' || typeof options !== 'object') {
3098
+ return;
3099
+ }
3100
+
3101
+ if (typeof options?.timeout === 'number' && options?.timeout > 0) {
3102
+ // If a timeout is specified in the options, setup here
3103
+ // eslint-disable-next-line no-undef
3104
+ options.signal = AbortSignal.timeout(options.timeout);
3105
+ }
3106
+
3107
+ options.method = method; // Set the HTTP method to use
3108
+
3109
+ if (method === 'post' && typeof data !== undefined) {
3110
+ // Doing a HTTP post, so include the data in the body
3111
+ options.body = data;
3112
+ }
3113
+
3114
+ // eslint-disable-next-line no-undef
3115
+ let response = await fetch(url, options);
3116
+ if (response.ok === false) {
3117
+ let error = new Error(response.statusText);
3118
+ error.code = response.status;
3119
+ throw error;
3120
+ }
3121
+ return response;
3122
+ }