homebridge-nest-accfactory 0.2.9 → 0.3.0

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +14 -7
  3. package/config.schema.json +126 -1
  4. package/dist/HomeKitDevice.js +194 -77
  5. package/dist/HomeKitHistory.js +1 -1
  6. package/dist/config.js +207 -0
  7. package/dist/devices.js +113 -0
  8. package/dist/index.js +2 -1
  9. package/dist/nexustalk.js +19 -21
  10. package/dist/{camera.js → plugins/camera.js} +212 -239
  11. package/dist/{doorbell.js → plugins/doorbell.js} +32 -30
  12. package/dist/plugins/floodlight.js +91 -0
  13. package/dist/plugins/heatlink.js +17 -0
  14. package/dist/{protect.js → plugins/protect.js} +24 -41
  15. package/dist/{tempsensor.js → plugins/tempsensor.js} +13 -17
  16. package/dist/{thermostat.js → plugins/thermostat.js} +424 -381
  17. package/dist/{weather.js → plugins/weather.js} +26 -60
  18. package/dist/protobuf/google/trait/product/camera.proto +1 -1
  19. package/dist/protobuf/googlehome/foyer.proto +0 -46
  20. package/dist/protobuf/nest/services/apigateway.proto +31 -1
  21. package/dist/protobuf/nest/trait/firmware.proto +207 -89
  22. package/dist/protobuf/nest/trait/hvac.proto +1052 -312
  23. package/dist/protobuf/nest/trait/located.proto +51 -8
  24. package/dist/protobuf/nest/trait/network.proto +366 -36
  25. package/dist/protobuf/nest/trait/occupancy.proto +145 -17
  26. package/dist/protobuf/nest/trait/product/protect.proto +57 -43
  27. package/dist/protobuf/nest/trait/resourcedirectory.proto +8 -0
  28. package/dist/protobuf/nest/trait/sensor.proto +7 -1
  29. package/dist/protobuf/nest/trait/service.proto +3 -1
  30. package/dist/protobuf/nest/trait/structure.proto +60 -14
  31. package/dist/protobuf/nest/trait/ui.proto +41 -1
  32. package/dist/protobuf/nest/trait/user.proto +6 -1
  33. package/dist/protobuf/nest/trait/voiceassistant.proto +2 -1
  34. package/dist/protobuf/nestlabs/eventingapi/v1.proto +20 -1
  35. package/dist/protobuf/root.proto +1 -0
  36. package/dist/protobuf/wdl.proto +18 -2
  37. package/dist/protobuf/weave/common.proto +2 -1
  38. package/dist/protobuf/weave/trait/heartbeat.proto +41 -1
  39. package/dist/protobuf/weave/trait/power.proto +1 -0
  40. package/dist/protobuf/weave/trait/security.proto +10 -1
  41. package/dist/streamer.js +80 -80
  42. package/dist/system.js +1208 -1245
  43. package/dist/webrtc.js +28 -23
  44. package/package.json +12 -12
  45. package/dist/floodlight.js +0 -97
package/dist/system.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Nest System communications
2
2
  // Part of homebridge-nest-accfactory
3
3
  //
4
- // Code version 2025/03/20
4
+ // Code version 2025.06.13
5
5
  // Mark Hulskamp
6
6
  'use strict';
7
7
 
@@ -15,332 +15,144 @@ import { setInterval, clearInterval, setTimeout, clearTimeout } from 'node:timer
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import crypto from 'node:crypto';
18
- import process from 'node:process';
19
- import child_process from 'node:child_process';
20
18
  import { fileURLToPath } from 'node:url';
21
19
 
22
20
  // Import our modules
23
21
  import HomeKitDevice from './HomeKitDevice.js';
24
- import NestCamera from './camera.js';
25
- import NestDoorbell from './doorbell.js';
26
- import NestFloodlight from './floodlight.js';
27
- import NestProtect from './protect.js';
28
- import NestTemperatureSensor from './tempsensor.js';
29
- import NestWeather from './weather.js';
30
- import NestThermostat from './thermostat.js';
22
+ import { DeviceType, loadDeviceModules, getDeviceHKCategory } from './devices.js';
23
+ import { AccountType, processConfig, buildConnections } from './config.js';
31
24
 
25
+ // Define constants
32
26
  const CAMERAALERTPOLLING = 2000; // Camera alerts polling timer
33
27
  const CAMERAZONEPOLLING = 30000; // Camera zones changes polling timer
34
28
  const WEATHERPOLLING = 300000; // Weather data polling timer
35
29
  const NESTAPITIMEOUT = 10000; // Nest API timeout
36
30
  const USERAGENT = 'Nest/5.78.0 (iOScom.nestlabs.jasper.release) os=18.0'; // User Agent string
37
- const FFMPEGVERSION = '6.0'; // Minimum version of ffmpeg we require
38
-
39
31
  const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a defined for JS __dirname
32
+ const DATASOURCE = {
33
+ NestAPI: 'Nest', // From the Nest API
34
+ ProtobufAPI: 'Protobuf', // From the Protobuf API
35
+ };
40
36
 
41
37
  // We handle the connections to Nest/Google
42
38
  // Perform device management (additions/removals/updates)
43
39
  export default class NestAccfactory {
44
- static DeviceType = {
45
- THERMOSTAT: 'thermostat',
46
- TEMPSENSOR: 'temperature',
47
- SMOKESENSOR: 'protect',
48
- CAMERA: 'camera',
49
- DOORBELL: 'doorbell',
50
- FLOODLIGHT: 'floodlight',
51
- WEATHER: 'weather',
52
- LOCK: 'lock', // yet to implement
53
- ALARM: 'alarm', // yet to implement
54
- };
55
-
56
- static DataSource = {
57
- REST: 'REST', // From the REST API
58
- PROTOBUF: 'Protobuf', // From the Protobuf API
59
- };
60
-
61
- static GoogleAccount = 'google'; // Google account connection
62
- static NestAccount = 'nest'; // Nest account connection
63
-
64
40
  cachedAccessories = []; // Track restored cached accessories
65
41
 
66
42
  // Internal data only for this class
67
- #connections = {}; // Object of confirmed connections
68
- #rawData = {}; // Cached copy of data from both Rest and Protobuf APIs
43
+ #connections = undefined; // Object of confirmed connections
44
+ #rawData = {}; // Cached copy of data from both Nest and Protobuf APIs
69
45
  #eventEmitter = new EventEmitter(); // Used for object messaging from this platform
70
- #connectionTimer = undefined;
71
46
  #protobufRoot = null; // Protobuf loaded protos
72
47
  #trackedDevices = {}; // Object of devices we've created. used to track data source type, comms uuid. key'd by serial #
48
+ #deviceModules = undefined; // No loaded device support modules to start
49
+
50
+ constructor(log, config, api, eventEmitter) {
51
+ // If no explicit event emitter was passed, and the api is an EventEmitter (e.g., in Homebridge),
52
+ // we'll treat it as the source for lifecycle messages like didFinishLaunching/shutdown in this constructor
53
+ if (api instanceof EventEmitter && eventEmitter === undefined) {
54
+ eventEmitter = api;
55
+ }
73
56
 
74
- constructor(log, config, api) {
75
- this.config = config;
76
57
  this.log = log;
77
58
  this.api = api;
78
59
 
79
60
  // Perform validation on the configuration passed into us and set defaults if not present
61
+ this.config = processConfig(config, this.log);
62
+ this.#connections = buildConnections(this.config);
80
63
 
81
- // Build our accounts connection object. Allows us to have multiple diffent account connections under the one accessory
82
- Object.keys(this.config).forEach((key) => {
83
- if (this.config[key]?.access_token !== undefined && this.config[key].access_token !== '') {
84
- // Nest account connection, assign a random UUID for each connection
85
- this.#connections[crypto.randomUUID()] = {
86
- type: NestAccfactory.NestAccount,
87
- authorised: false,
88
- access_token: this.config[key].access_token,
89
- fieldTest: this.config[key]?.fieldTest === true,
90
- referer: this.config[key]?.fieldTest === true ? 'home.ft.nest.com' : 'home.nest.com',
91
- restAPIHost: this.config[key]?.fieldTest === true ? 'home.ft.nest.com' : 'home.nest.com',
92
- cameraAPIHost: this.config[key]?.fieldTest === true ? 'camera.home.ft.nest.com' : 'camera.home.nest.com',
93
- protobufAPIHost: this.config[key]?.fieldTest === true ? 'grpc-web.ft.nest.com' : 'grpc-web.production.nest.com',
94
- };
95
- }
96
- if (
97
- this.config[key]?.issuetoken !== undefined &&
98
- this.config[key].issuetoken !== '' &&
99
- this.config[key]?.cookie !== undefined &&
100
- this.config[key].cookie !== ''
101
- ) {
102
- // Google account connection, assign a random UUID for each connection
103
- this.#connections[crypto.randomUUID()] = {
104
- type: NestAccfactory.GoogleAccount,
105
- authorised: false,
106
- issuetoken: this.config[key].issuetoken,
107
- cookie: this.config[key].cookie,
108
- fieldTest: this.config[key]?.fieldTest === true,
109
- referer: this.config[key]?.fieldTest === true ? 'home.ft.nest.com' : 'home.nest.com',
110
- restAPIHost: this.config[key]?.fieldTest === true ? 'home.ft.nest.com' : 'home.nest.com',
111
- cameraAPIHost: this.config[key]?.fieldTest === true ? 'camera.home.ft.nest.com' : 'camera.home.nest.com',
112
- protobufAPIHost: this.config[key]?.fieldTest === true ? 'grpc-web.ft.nest.com' : 'grpc-web.production.nest.com',
113
- };
114
- }
115
- });
116
-
117
- // If we don't have either a Nest access_token and/or a Google issuetoken/cookie, return back.
64
+ // Check for valid connections, either a Nest and/or Google one specified. Otherwise, return back.
118
65
  if (Object.keys(this.#connections).length === 0) {
119
- this?.log?.error && this.log.error('No connections have been specified in the JSON configuration. Please review');
66
+ this?.log?.error?.('No connections have been specified in the JSON configuration. Please review');
120
67
  return;
121
68
  }
122
69
 
123
- if (typeof this.config?.options !== 'object') {
124
- this.config.options = {};
125
- }
70
+ eventEmitter?.on?.('didFinishLaunching', async () => {
71
+ // We got notified that Homebridge (or Docker) has finished loading
126
72
 
127
- this.config.options.eveHistory = this.config.options?.eveHistory === true;
128
- this.config.options.elevation = isNaN(this.config.options?.elevation) === false ? Number(this.config.options.elevation) : 0;
129
- this.config.options.weather = this.config.options?.weather === true;
130
- this.config.options.hksv = this.config.options?.hksv === true;
131
-
132
- // Controls what APIs we use, default is to use both REST and protobuf APIs
133
- this.config.options.restAPI = this.config.options?.restAPI === true || this.config.options?.restAPI === undefined;
134
- this.config.options.protobufAPI = this.config.options?.protobufAPI === true || this.config.options?.protobufAPI === undefined;
135
-
136
- // Get configuration for max number of concurrent 'live view' streams. For HomeKit Secure Video, this will always be 1
137
- this.config.options.maxStreams =
138
- isNaN(this.config.options?.maxStreams) === false && this.deviceData?.hksv === false
139
- ? Number(this.config.options.maxStreams)
140
- : this.deviceData?.hksv === true
141
- ? 1
142
- : 2;
143
-
144
- // Check if a ffmpeg binary exist via a specific path in configuration OR /usr/local/bin
145
- this.config.options.ffmpeg = {};
146
- this.config.options.ffmpeg.debug = this.config.options?.ffmpegDebug === true;
147
- this.config.options.ffmpeg.binary = path.resolve(
148
- typeof this.config.options?.ffmpegPath === 'string' && this.config.options.ffmpegPath !== ''
149
- ? this.config.options.ffmpegPath
150
- : '/usr/local/bin',
151
- );
73
+ // Load device support modules from the plugins folder if not already done
74
+ this.#deviceModules = await loadDeviceModules(this.log, 'plugins');
152
75
 
153
- // If the path doesn't include 'ffmpeg' on the end, we'll add it here
154
- if (this.config.options.ffmpeg.binary.endsWith('/ffmpeg') === false) {
155
- this.config.options.ffmpeg.binary = this.config.options.ffmpeg.binary + '/ffmpeg';
156
- }
76
+ // Start reconnect loop per connection with backoff for failed tries
77
+ // This also initiates both Nest and Protobuf subscribes
78
+ for (const uuid of Object.keys(this.#connections)) {
79
+ let reconnectDelay = 15000;
157
80
 
158
- this.config.options.ffmpeg.version = undefined;
159
- this.config.options.ffmpeg.libspeex = false;
160
- this.config.options.ffmpeg.libopus = false;
161
- this.config.options.ffmpeg.libx264 = false;
162
- this.config.options.ffmpeg.libfdk_aac = false;
163
-
164
- if (fs.existsSync(this.config.options.ffmpeg.binary) === false) {
165
- if (this?.log?.warn) {
166
- this.log.warn('Specified ffmpeg binary "%s" was not found', this.config.options.ffmpeg.binary);
167
- this.log.warn('Stream video/recording from camera/doorbells will be unavailable');
168
- }
169
-
170
- // If we flag ffmpegPath as undefined, no video streaming/record support enabled for camers/doorbells
171
- this.config.options.ffmpeg.binary = undefined;
172
- }
173
-
174
- // Process ffmpeg binary to see if we can use it
175
- if (fs.existsSync(this.config.options.ffmpeg.binary) === true) {
176
- let ffmpegProcess = child_process.spawnSync(this.config.options.ffmpeg.binary, ['-version'], {
177
- env: process.env,
178
- });
179
- if (ffmpegProcess.stdout !== null) {
180
- // Determine ffmpeg version
181
- this.config.options.ffmpeg.version = ffmpegProcess.stdout
182
- .toString()
183
- .match(/(?:ffmpeg version:(\d+)\.)?(?:(\d+)\.)?(?:(\d+)\.\d+)(.*?)/gim)[0];
184
-
185
- // Determine what libraries ffmpeg is compiled with
186
- this.config.options.ffmpeg.libspeex = ffmpegProcess.stdout.toString().includes('--enable-libspeex') === true;
187
- this.config.options.ffmpeg.libopus = ffmpegProcess.stdout.toString().includes('--enable-libopus') === true;
188
- this.config.options.ffmpeg.libx264 = ffmpegProcess.stdout.toString().includes('--enable-libx264') === true;
189
- this.config.options.ffmpeg.libfdk_aac = ffmpegProcess.stdout.toString().includes('--enable-libfdk-aac') === true;
190
- if (
191
- this.config.options.ffmpeg.version.localeCompare(FFMPEGVERSION, undefined, {
192
- numeric: true,
193
- sensitivity: 'case',
194
- caseFirst: 'upper',
195
- }) === -1 ||
196
- this.config.options.ffmpeg.libspeex === false ||
197
- this.config.options.ffmpeg.libopus === false ||
198
- this.config.options.ffmpeg.libx264 === false ||
199
- this.config.options.ffmpeg.libfdk_aac === false
200
- ) {
201
- this?.log?.warn &&
202
- this.log.warn('ffmpeg binary "%s" does not meet the minimum support requirements', this.config.options.ffmpeg.binary);
203
- if (
204
- this.config.options.ffmpeg.version.localeCompare(FFMPEGVERSION, undefined, {
205
- numeric: true,
206
- sensitivity: 'case',
207
- caseFirst: 'upper',
208
- }) === -1
209
- ) {
210
- this?.log?.warn &&
211
- this.log.warn(
212
- 'Minimum binary version is "%s", however the installed version is "%s"',
213
- FFMPEGVERSION,
214
- this.config.options.ffmpeg.version,
215
- );
216
- this?.log?.warn && this.log.warn('Stream video/recording from camera/doorbells will be unavailable');
81
+ const reconnectLoop = async () => {
82
+ if (this.#connections?.[uuid]?.authorised === false) {
83
+ try {
84
+ await this.#connect(uuid);
85
+ this.#subscribeNest(uuid, true);
86
+ this.#subscribeProtobuf(uuid, true);
87
+ // eslint-disable-next-line no-unused-vars
88
+ } catch (error) {
89
+ // Empty
90
+ }
217
91
 
218
- this.config.options.ffmpeg.binary = undefined; // No ffmpeg since below min version
219
- }
220
- if (
221
- this.config.options.ffmpeg.libspeex === false &&
222
- this.config.options.ffmpeg.libx264 === true &&
223
- this.config.options.ffmpeg.libfdk_aac === true
224
- ) {
225
- this?.log?.warn && this.log.warn('Missing libspeex in ffmpeg binary, talkback on certain camera/doorbells will be unavailable');
226
- }
227
- if (
228
- this.config.options.ffmpeg.libx264 === true &&
229
- this.config.options.ffmpeg.libfdk_aac === false &&
230
- this.config.options.ffmpeg.libopus === false
231
- ) {
232
- this?.log?.warn &&
233
- this.log.warn('Missing libfdk_aac and libopus in ffmpeg binary, audio from camera/doorbells will be unavailable');
234
- }
235
- if (this.config.options.ffmpeg.libx264 === true && this.config.options.ffmpeg.libfdk_aac === false) {
236
- this?.log?.warn && this.log.warn('Missing libfdk_aac in ffmpeg binary, audio from camera/doorbells will be unavailable');
237
- }
238
- if (
239
- this.config.options.ffmpeg.libx264 === true &&
240
- this.config.options.ffmpeg.libfdk_aac === true &&
241
- this.config.options.ffmpeg.libopus === false
242
- ) {
243
- this?.log?.warn &&
244
- this.log.warn(
245
- 'Missing libopus in ffmpeg binary, audio (including talkback) from certain camera/doorbells will be unavailable',
246
- );
92
+ reconnectDelay = this.#connections?.[uuid]?.authorised === true ? 15000 : Math.min(reconnectDelay * 2, 60000);
93
+ } else {
94
+ reconnectDelay = 15000;
247
95
  }
248
- if (this.config.options.ffmpeg.libx264 === false) {
249
- this?.log?.warn &&
250
- this.log.warn('Missing libx264 in ffmpeg binary, stream video/recording from camera/doorbells will be unavailable');
251
96
 
252
- this.config.options.ffmpeg.binary = undefined; // No ffmpeg since we do not have all the required libraries
253
- }
254
- }
255
- }
256
- }
97
+ setTimeout(reconnectLoop, reconnectDelay);
98
+ };
257
99
 
258
- if (this.config.options.ffmpeg.binary !== undefined) {
259
- this?.log?.success && this.log.success('Found valid ffmpeg binary in %s', this.config.options.ffmpeg.binary);
260
- }
100
+ reconnectLoop();
101
+ }
102
+ });
261
103
 
262
- if (this.api instanceof EventEmitter === true) {
263
- this.api.on('didFinishLaunching', async () => {
264
- // We got notified that Homebridge has finished loading, so we are ready to process
265
- this.discoverDevices();
104
+ eventEmitter?.on?.('shutdown', async () => {
105
+ // We got notified that Homebridge is shutting down
106
+ // Perform cleanup of internal state
107
+ this.#eventEmitter?.removeAllListeners();
266
108
 
267
- // We'll check connection status every 15 seconds. We'll also handle token expiry/refresh this way
268
- clearInterval(this.#connectionTimer);
269
- this.#connectionTimer = setInterval(this.discoverDevices.bind(this), 15000);
109
+ Object.values(this.#trackedDevices).forEach((device) => {
110
+ Object.values(device?.timers || {}).forEach((timer) => clearInterval(timer));
270
111
  });
271
112
 
272
- this.api.on('shutdown', async () => {
273
- // We got notified that Homebridge is shutting down
274
- // Perform cleanup some internal cleaning up
275
- this.#eventEmitter.removeAllListeners();
276
- Object.values(this.#trackedDevices).forEach((value) => {
277
- if (value?.timers !== undefined) {
278
- Object.values(value?.timers).forEach((timers) => {
279
- clearInterval(timers);
280
- });
281
- }
282
- });
283
- this.#trackedDevices = {};
284
- clearInterval(this.#connectionTimer);
285
- this.#connectionTimer = undefined;
286
- this.#rawData = {};
287
- this.#protobufRoot = null;
288
- this.#eventEmitter = undefined;
289
- });
290
- }
113
+ this.#trackedDevices = {};
114
+ this.#rawData = {};
115
+ this.#protobufRoot = null;
116
+ this.#eventEmitter = undefined;
117
+ });
291
118
 
292
119
  // Setup event listeners for set/get calls from devices if not already done so
293
120
  this.#eventEmitter.addListener(HomeKitDevice.SET, (uuid, values) => {
294
- this.#set(uuid, values);
121
+ this.#set(values);
295
122
  });
123
+
296
124
  this.#eventEmitter.addListener(HomeKitDevice.GET, async (uuid, values) => {
297
- let results = await this.#get(uuid, values);
125
+ let results = await this.#get(values);
298
126
  // Send the results back to the device via a special event (only if still active)
299
- if (this.#eventEmitter !== undefined) {
300
- this.#eventEmitter.emit(HomeKitDevice.GET + '->' + uuid, results);
301
- }
127
+ this.#eventEmitter?.emit?.(HomeKitDevice.GET + '->' + uuid, results);
302
128
  });
303
129
  }
304
130
 
305
131
  configureAccessory(accessory) {
306
132
  // This gets called from Homebridge each time it restores an accessory from its cache
307
- this?.log?.info && this.log.info('Loading accessory from cache:', accessory.displayName);
133
+ this?.log?.info?.('Loading accessory from cache:', accessory.displayName);
308
134
 
309
135
  // add the restored accessory to the accessories cache, so we can track if it has already been registered
310
136
  this.cachedAccessories.push(accessory);
311
137
  }
312
138
 
313
- async discoverDevices() {
314
- Object.keys(this.#connections).forEach((uuid) => {
315
- if (this.#connections[uuid].authorised === false) {
316
- this.#connect(uuid).then(() => {
317
- if (this.#connections[uuid].authorised === true && this.config.options?.restAPI === true) {
318
- this.#subscribeREST(uuid, true);
319
- }
320
- if (this.#connections[uuid].authorised === true && this.config.options?.protobufAPI === true) {
321
- this.#subscribeProtobuf(uuid, true);
322
- }
323
- });
324
- }
325
- });
326
- }
327
-
328
- async #connect(connectionUUID) {
329
- if (typeof this.#connections?.[connectionUUID] === 'object') {
330
- if (this.#connections[connectionUUID].type === NestAccfactory.GoogleAccount) {
139
+ async #connect(uuid) {
140
+ if (typeof this.#connections?.[uuid] === 'object') {
141
+ this?.log?.info?.(
142
+ 'Performing authorisation for connection "%s" %s',
143
+ this.#connections[uuid].name,
144
+ this.#connections[uuid].fieldTest === true ? 'using field test endpoints' : '',
145
+ );
146
+ if (this.#connections[uuid].type === AccountType.Google) {
331
147
  // Google cookie method as refresh token method no longer supported by Google since October 2022
332
148
  // Instructions from homebridge_nest or homebridge_nest_cam to obtain this
333
- this?.log?.info &&
334
- this.log.info(
335
- 'Performing Google account authorisation ' +
336
- (this.#connections[connectionUUID].fieldTest === true ? 'using field test endpoints' : ''),
337
- );
149
+ this?.log?.debug?.('Performing authorisation using Google account for connection uuid "%s"', uuid);
338
150
 
339
- await fetchWrapper('get', this.#connections[connectionUUID].issuetoken, {
151
+ await fetchWrapper('get', this.#connections[uuid].issuetoken, {
340
152
  headers: {
341
153
  referer: 'https://accounts.google.com/o/oauth2/iframe',
342
154
  'User-Agent': USERAGENT,
343
- cookie: this.#connections[connectionUUID].cookie,
155
+ cookie: this.#connections[uuid].cookie,
344
156
  'Sec-Fetch-Mode': 'cors',
345
157
  'X-Requested-With': 'XmlHttpRequest',
346
158
  },
@@ -354,7 +166,7 @@ export default class NestAccfactory {
354
166
  'https://nestauthproxyservice-pa.googleapis.com/v1/issue_jwt',
355
167
  {
356
168
  headers: {
357
- referer: 'https://' + this.#connections[connectionUUID].referer,
169
+ referer: 'https://' + this.#connections[uuid].referer,
358
170
  'User-Agent': USERAGENT,
359
171
  Authorization: data.token_type + ' ' + data.access_token,
360
172
  'Content-Type': 'application/x-www-form-urlencoded',
@@ -369,9 +181,9 @@ export default class NestAccfactory {
369
181
  let googleToken = data.jwt;
370
182
  let tokenExpire = Math.floor(new Date(data.claims.expirationTime).valueOf() / 1000); // Token expiry, should be 1hr
371
183
 
372
- await fetchWrapper('get', 'https://' + this.#connections[connectionUUID].restAPIHost + '/session', {
184
+ await fetchWrapper('get', 'https://' + this.#connections[uuid].restAPIHost + '/session', {
373
185
  headers: {
374
- referer: 'https://' + this.#connections[connectionUUID].referer,
186
+ referer: 'https://' + this.#connections[uuid].referer,
375
187
  'User-Agent': USERAGENT,
376
188
  Authorization: 'Basic ' + googleToken,
377
189
  },
@@ -379,65 +191,58 @@ export default class NestAccfactory {
379
191
  .then((response) => response.json())
380
192
  .then((data) => {
381
193
  // Store successful connection details
382
- this.#connections[connectionUUID].authorised = true;
383
- this.#connections[connectionUUID].userID = data.userid;
384
- this.#connections[connectionUUID].transport_url = data.urls.transport_url;
385
- this.#connections[connectionUUID].weather_url = data.urls.weather_url;
386
- this.#connections[connectionUUID].token = googleToken;
387
- this.#connections[connectionUUID].cameraAPI = {
194
+ this.#connections[uuid].authorised = true;
195
+ this.#connections[uuid].userID = data.userid;
196
+ this.#connections[uuid].transport_url = data.urls.transport_url;
197
+ this.#connections[uuid].weather_url = data.urls.weather_url;
198
+ this.#connections[uuid].token = googleToken;
199
+ this.#connections[uuid].cameraAPI = {
388
200
  key: 'Authorization',
389
201
  value: 'Basic ', // NOTE: extra space required
390
202
  token: googleToken,
391
203
  oauth2: googleOAuth2Token,
204
+ fieldTest: this.#connections[uuid]?.fieldTest === true,
392
205
  };
393
206
 
394
207
  // Set timeout for token expiry refresh
395
- clearTimeout(this.#connections[connectionUUID].timer);
396
- this.#connections[connectionUUID].timer = setTimeout(
208
+ clearTimeout(this.#connections[uuid].timer);
209
+ this.#connections[uuid].timer = setTimeout(
397
210
  () => {
398
- this?.log?.info && this.log.info('Performing periodic token refresh for Google account');
399
- this.#connect(connectionUUID);
211
+ this?.log?.info?.('Performing periodic token refresh for connection "%s"', this.#connections[uuid].name);
212
+ this.#connect(uuid);
400
213
  },
401
214
  (tokenExpire - Math.floor(Date.now() / 1000) - 60) * 1000,
402
215
  ); // Refresh just before token expiry
403
216
 
404
- this?.log?.success && this.log.success('Successfully authorised using Google account');
217
+ this?.log?.success?.('Successfully authorised connection "%s"', this.#connections[uuid].name);
405
218
  });
406
219
  });
407
220
  })
408
221
  // eslint-disable-next-line no-unused-vars
409
222
  .catch((error) => {
410
223
  // The token we used to obtained a Nest session failed, so overall authorisation failed
411
- this.#connections[connectionUUID].authorised = false;
412
- this?.log?.debug &&
413
- this.log.debug(
414
- 'Failed to connect to gateway using credential details for connection uuid "%s". A periodic retry event will be triggered',
415
- connectionUUID,
416
- );
417
- this?.log?.error && this.log.error('Authorisation failed using Google account');
224
+ this.#connections[uuid].authorised = false;
225
+ this?.log?.debug?.('Failed to connect using credential details for connection uuid "%s"', uuid);
226
+ this?.log?.error?.('Authorisation failed on connection "%s"', this.#connections[uuid].name);
418
227
  });
419
228
  }
420
229
 
421
- if (this.#connections[connectionUUID].type === NestAccfactory.NestAccount) {
230
+ if (this.#connections[uuid].type === AccountType.Nest) {
422
231
  // Nest access token method. Get WEBSITE2 cookie for use with camera API calls if needed later
423
- this?.log?.info &&
424
- this.log.info(
425
- 'Performing Nest account authorisation ' +
426
- (this.#connections[connectionUUID].fieldTest === true ? 'using field test endpoints' : ''),
427
- );
232
+ this?.log?.debug?.('Performing authorisation using Nest account for connection uuid "%s"', uuid);
428
233
 
429
234
  await fetchWrapper(
430
235
  'post',
431
- 'https://webapi.' + this.#connections[connectionUUID].cameraAPIHost + '/api/v1/login.login_nest',
236
+ 'https://webapi.' + this.#connections[uuid].cameraAPIHost + '/api/v1/login.login_nest',
432
237
  {
433
238
  withCredentials: true,
434
239
  headers: {
435
- referer: 'https://' + this.#connections[connectionUUID].referer,
240
+ referer: 'https://' + this.#connections[uuid].referer,
436
241
  'User-Agent': USERAGENT,
437
242
  'Content-Type': 'application/x-www-form-urlencoded',
438
243
  },
439
244
  },
440
- Buffer.from('access_token=' + this.#connections[connectionUUID].access_token, 'utf8'),
245
+ Buffer.from('access_token=' + this.#connections[uuid].access_token, 'utf8'),
441
246
  )
442
247
  .then((response) => response.json())
443
248
  .then(async (data) => {
@@ -447,53 +252,58 @@ export default class NestAccfactory {
447
252
 
448
253
  let nestToken = data.items[0].session_token;
449
254
 
450
- await fetchWrapper('get', 'https://' + this.#connections[connectionUUID].restAPIHost + '/session', {
255
+ await fetchWrapper('get', 'https://' + this.#connections[uuid].restAPIHost + '/session', {
451
256
  headers: {
452
- referer: 'https://' + this.#connections[connectionUUID].referer,
257
+ referer: 'https://' + this.#connections[uuid].referer,
453
258
  'User-Agent': USERAGENT,
454
- Authorization: 'Basic ' + this.#connections[connectionUUID].access_token,
259
+ Authorization: 'Basic ' + this.#connections[uuid].access_token,
455
260
  },
456
261
  })
457
262
  .then((response) => response.json())
458
263
  .then((data) => {
459
264
  // Store successful connection details
460
- this.#connections[connectionUUID].authorised = true;
461
- this.#connections[connectionUUID].userID = data.userid;
462
- this.#connections[connectionUUID].transport_url = data.urls.transport_url;
463
- this.#connections[connectionUUID].weather_url = data.urls.weather_url;
464
- this.#connections[connectionUUID].token = this.#connections[connectionUUID].access_token;
465
- this.#connections[connectionUUID].cameraAPI = {
265
+ this.#connections[uuid].authorised = true;
266
+ this.#connections[uuid].userID = data.userid;
267
+ this.#connections[uuid].transport_url = data.urls.transport_url;
268
+ this.#connections[uuid].weather_url = data.urls.weather_url;
269
+ this.#connections[uuid].token = this.#connections[uuid].access_token;
270
+ this.#connections[uuid].cameraAPI = {
466
271
  key: 'cookie',
467
- value: this.#connections[connectionUUID].fieldTest === true ? 'website_ft=' : 'website_2=',
272
+ value: this.#connections[uuid].fieldTest === true ? 'website_ft=' : 'website_2=',
468
273
  token: nestToken,
274
+ fieldTest: this.#connections[uuid]?.fieldTest === true,
469
275
  };
470
276
 
471
277
  // Set timeout for token expiry refresh
472
- clearTimeout(this.#connections[connectionUUID].timer);
473
- this.#connections[connectionUUID].timer = setTimeout(
278
+ clearTimeout(this.#connections[uuid].timer);
279
+ this.#connections[uuid].timer = setTimeout(
474
280
  () => {
475
- this?.log?.info && this.log.info('Performing periodic token refresh for Nest account');
476
- this.#connect(connectionUUID);
281
+ this?.log?.info?.('Performing periodic token refresh for connection "%s"', this.#connections[uuid].name);
282
+ this.#connect(uuid);
477
283
  },
478
284
  1000 * 3600 * 24,
479
285
  ); // Refresh token every 24hrs
480
286
 
481
- this?.log?.success && this.log.success('Successfully authorised using Nest account');
287
+ this?.log?.success?.('Successfully authorised connection "%s"', this.#connections[uuid].name);
482
288
  });
483
289
  })
484
290
  // eslint-disable-next-line no-unused-vars
485
291
  .catch((error) => {
486
292
  // The token we used to obtained a Nest session failed, so overall authorisation failed
487
- this.#connections[connectionUUID].authorised = false;
488
- this?.log?.debug && this.log.debug('Failed to connect using credential details for connection uuid "%s"', connectionUUID);
489
- this?.log?.error && this.log.error('Authorisation failed using Nest account');
293
+ this.#connections[uuid].authorised = false;
294
+ this?.log?.debug?.('Failed to connect using credential details for connection uuid "%s"', uuid);
295
+ this?.log?.error?.('Authorisation failed on connection "%s"', this.#connections[uuid].name);
490
296
  });
491
297
  }
492
298
  }
493
299
  }
494
300
 
495
- async #subscribeREST(connectionUUID, fullRefresh) {
496
- if (typeof this.#connections?.[connectionUUID] !== 'object' || this.#connections?.[connectionUUID]?.authorised !== true) {
301
+ async #subscribeNest(uuid, fullRefresh) {
302
+ if (
303
+ typeof this.#connections?.[uuid] !== 'object' ||
304
+ this.#connections?.[uuid]?.authorised === false ||
305
+ this.config?.options?.useNestAPI === false
306
+ ) {
497
307
  // Not a valid connection object and/or we're not authorised
498
308
  return;
499
309
  }
@@ -513,25 +323,20 @@ export default class NestAccfactory {
513
323
  'topaz',
514
324
  'widget_track',
515
325
  'quartz',
326
+ 'occupancy',
516
327
  ];
517
328
 
518
- // By default, setup for a full data read from the REST API
519
- let subscribeURL =
520
- 'https://' +
521
- this.#connections[connectionUUID].restAPIHost +
522
- '/api/0.1/user/' +
523
- this.#connections[connectionUUID].userID +
524
- '/app_launch';
329
+ // By default, setup for a full data read from the Nest API
330
+ let subscribeURL = 'https://' + this.#connections[uuid].restAPIHost + '/api/0.1/user/' + this.#connections[uuid].userID + '/app_launch';
525
331
  let subscribeJSONData = { known_bucket_types: REQUIREDBUCKETS, known_bucket_versions: [] };
526
332
 
527
333
  if (fullRefresh === false) {
528
- // We have data stored from this REST API, so setup read using known object
529
- subscribeURL = this.#connections[connectionUUID].transport_url + '/v6/subscribe';
334
+ // We have data stored from this Nest API, so setup read using known object
335
+ subscribeURL = this.#connections[uuid].transport_url + '/v6/subscribe';
530
336
  subscribeJSONData = { objects: [] };
531
-
532
337
  Object.entries(this.#rawData)
533
338
  // eslint-disable-next-line no-unused-vars
534
- .filter(([object_key, object]) => object.source === NestAccfactory.DataSource.REST && object.connection === connectionUUID)
339
+ .filter(([object_key, object]) => object.source === DATASOURCE.NestAPI && object.connection === uuid)
535
340
  .forEach(([object_key, object]) => {
536
341
  subscribeJSONData.objects.push({
537
342
  object_key: object_key,
@@ -542,7 +347,7 @@ export default class NestAccfactory {
542
347
  }
543
348
 
544
349
  if (fullRefresh === true) {
545
- this?.log?.debug && this.log.debug('Starting REST API subscribe for connection uuid "%s"', connectionUUID);
350
+ this?.log?.debug?.('Starting Nest API subscribe for connection uuid "%s"', uuid);
546
351
  }
547
352
 
548
353
  fetchWrapper(
@@ -550,9 +355,9 @@ export default class NestAccfactory {
550
355
  subscribeURL,
551
356
  {
552
357
  headers: {
553
- referer: 'https://' + this.#connections[connectionUUID].referer,
358
+ referer: 'https://' + this.#connections[uuid].referer,
554
359
  'User-Agent': USERAGENT,
555
- Authorization: 'Basic ' + this.#connections[connectionUUID].token,
360
+ Authorization: 'Basic ' + this.#connections[uuid].token,
556
361
  },
557
362
  keepalive: true,
558
363
  //timeout: (5 * 60000),
@@ -585,12 +390,7 @@ export default class NestAccfactory {
585
390
  ) {
586
391
  value.value.weather = this.#rawData[value.object_key].value.weather;
587
392
  }
588
- value.value.weather = await this.#getWeatherData(
589
- connectionUUID,
590
- value.object_key,
591
- value.value.latitude,
592
- value.value.longitude,
593
- );
393
+ value.value.weather = await this.#getWeather(uuid, value.object_key, value.value.latitude, value.value.longitude);
594
394
 
595
395
  // Check for changes in the swarm property. This seems indicate changes in devices
596
396
  if (typeof this.#rawData[value.object_key] === 'object') {
@@ -611,78 +411,70 @@ export default class NestAccfactory {
611
411
  ? this.#rawData[value.object_key].value.properties
612
412
  : [];
613
413
 
614
- await fetchWrapper(
615
- 'get',
616
- 'https://webapi.' +
617
- this.#connections[connectionUUID].cameraAPIHost +
618
- '/api/cameras.get_with_properties?uuid=' +
619
- value.object_key.split('.')[1],
620
- {
621
- headers: {
622
- referer: 'https://' + this.#connections[connectionUUID].referer,
623
- 'User-Agent': USERAGENT,
624
- [this.#connections[connectionUUID].cameraAPI.key]:
625
- this.#connections[connectionUUID].cameraAPI.value + this.#connections[connectionUUID].cameraAPI.token,
414
+ try {
415
+ let response = await fetchWrapper(
416
+ 'get',
417
+ 'https://webapi.' +
418
+ this.#connections[uuid].cameraAPIHost +
419
+ '/api/cameras.get_with_properties?uuid=' +
420
+ value.object_key.split('.')[1],
421
+ {
422
+ headers: {
423
+ referer: 'https://' + this.#connections[uuid].referer,
424
+ 'User-Agent': USERAGENT,
425
+ [this.#connections[uuid].cameraAPI.key]:
426
+ this.#connections[uuid].cameraAPI.value + this.#connections[uuid].cameraAPI.token,
427
+ },
428
+ timeout: NESTAPITIMEOUT,
626
429
  },
627
- timeout: NESTAPITIMEOUT,
628
- },
629
- )
630
- .then((response) => response.json())
631
- .then((data) => {
632
- value.value.properties = data.items[0].properties;
633
- })
634
- .catch((error) => {
635
- if (
636
- error?.cause !== undefined &&
637
- JSON.stringify(error.cause).toUpperCase().includes('TIMEOUT') === false &&
638
- this?.log?.debug
639
- ) {
640
- this.log.debug('REST API had error retrieving camera/doorbell details during subscribe. Error was "%s"', error?.code);
641
- }
642
- });
430
+ );
431
+ let data = await response.json();
432
+ value.value.properties = data.items[0].properties;
433
+ } catch (error) {
434
+ if (error?.cause !== undefined && String(error.cause).toUpperCase().includes('TIMEOUT') === false) {
435
+ this?.log?.debug?.(
436
+ 'Nest API had error retrieving camera/doorbell details during subscribe. Error was "%s"',
437
+ error?.code ?? String(error),
438
+ );
439
+ }
440
+ }
643
441
 
644
442
  value.value.activity_zones =
645
443
  typeof this.#rawData[value.object_key]?.value?.activity_zones === 'object'
646
444
  ? this.#rawData[value.object_key].value.activity_zones
647
445
  : [];
648
446
 
649
- await fetchWrapper('get', value.value.nexus_api_http_server_url + '/cuepoint_category/' + value.object_key.split('.')[1], {
650
- headers: {
651
- referer: 'https://' + this.#connections[connectionUUID].referer,
652
- 'User-Agent': USERAGENT,
653
- [this.#connections[connectionUUID].cameraAPI.key]:
654
- this.#connections[connectionUUID].cameraAPI.value + this.#connections[connectionUUID].cameraAPI.token,
655
- },
656
- timeout: NESTAPITIMEOUT,
657
- })
658
- .then((response) => response.json())
659
- .then((data) => {
660
- let zones = [];
661
- data.forEach((zone) => {
662
- if (zone.type.toUpperCase() === 'ACTIVITY' || zone.type.toUpperCase() === 'REGION') {
663
- zones.push({
664
- id: zone.id === 0 ? 1 : zone.id,
665
- name: makeHomeKitName(zone.label),
666
- hidden: zone.hidden === true,
667
- uri: zone.nexusapi_image_uri,
668
- });
669
- }
670
- });
671
-
672
- value.value.activity_zones = zones;
673
- })
674
- .catch((error) => {
675
- if (
676
- error?.cause !== undefined &&
677
- JSON.stringify(error.cause).toUpperCase().includes('TIMEOUT') === false &&
678
- this?.log?.debug
679
- ) {
680
- this.log.debug(
681
- 'REST API had error retrieving camera/doorbell activity zones during subscribe. Error was "%s"',
682
- error?.code,
683
- );
684
- }
685
- });
447
+ try {
448
+ let response = await fetchWrapper(
449
+ 'get',
450
+ value.value.nexus_api_http_server_url + '/cuepoint_category/' + value.object_key.split('.')[1],
451
+ {
452
+ headers: {
453
+ referer: 'https://' + this.#connections[uuid].referer,
454
+ 'User-Agent': USERAGENT,
455
+ [this.#connections[uuid].cameraAPI.key]:
456
+ this.#connections[uuid].cameraAPI.value + this.#connections[uuid].cameraAPI.token,
457
+ },
458
+ timeout: NESTAPITIMEOUT,
459
+ },
460
+ );
461
+ let data = await response.json();
462
+ value.value.activity_zones = data
463
+ .filter((zone) => zone?.type?.toUpperCase() === 'ACTIVITY' || zone?.type?.toUpperCase() === 'REGION')
464
+ .map((zone) => ({
465
+ id: zone.id === 0 ? 1 : zone.id,
466
+ name: makeHomeKitName(zone.label),
467
+ hidden: zone.hidden === true,
468
+ uri: zone.nexusapi_image_uri,
469
+ }));
470
+ } catch (error) {
471
+ if (error?.cause !== undefined && String(error.cause).toUpperCase().includes('TIMEOUT') === false) {
472
+ this?.log?.debug?.(
473
+ 'Nest API had error retrieving camera/doorbell activity zones during subscribe. Error was "%s"',
474
+ error?.code ?? String(error),
475
+ );
476
+ }
477
+ }
686
478
  }
687
479
 
688
480
  if (value.object_key.startsWith('buckets.') === true) {
@@ -693,7 +485,7 @@ export default class NestAccfactory {
693
485
  // Check for added objects
694
486
  value.value.buckets.map((object_key) => {
695
487
  if (this.#rawData[value.object_key].value.buckets.includes(object_key) === false) {
696
- // Since this is an added object to the raw REST API structure, we need to do a full read of the data
488
+ // Since this is an added object to the raw Nest API structure, we need to do a full read of the data
697
489
  fullRefresh = true;
698
490
  }
699
491
  });
@@ -719,13 +511,11 @@ export default class NestAccfactory {
719
511
  });
720
512
 
721
513
  // Send removed notice onto HomeKit device for it to process
722
- if (this.#eventEmitter !== undefined) {
723
- this.#eventEmitter.emit(
724
- this.#trackedDevices[this.#rawData[object_key].value.serial_number].uuid,
725
- HomeKitDevice.REMOVE,
726
- {},
727
- );
728
- }
514
+ this.#eventEmitter?.emit?.(
515
+ this.#trackedDevices[this.#rawData[object_key].value.serial_number].uuid,
516
+ HomeKitDevice.REMOVE,
517
+ {},
518
+ );
729
519
 
730
520
  // Finally, remove from tracked devices
731
521
  delete this.#trackedDevices[this.#rawData[object_key].value.serial_number];
@@ -737,17 +527,17 @@ export default class NestAccfactory {
737
527
  }
738
528
  }
739
529
 
740
- // Store or update the date in our internally saved raw REST API data
530
+ // Store or update the date in our internally saved raw Nest API data
741
531
  if (typeof this.#rawData[value.object_key] === 'undefined') {
742
532
  this.#rawData[value.object_key] = {};
743
533
  this.#rawData[value.object_key].object_revision = value.object_revision;
744
534
  this.#rawData[value.object_key].object_timestamp = value.object_timestamp;
745
- this.#rawData[value.object_key].connection = connectionUUID;
746
- this.#rawData[value.object_key].source = NestAccfactory.DataSource.REST;
535
+ this.#rawData[value.object_key].connection = uuid;
536
+ this.#rawData[value.object_key].source = DATASOURCE.NestAPI;
747
537
  this.#rawData[value.object_key].value = {};
748
538
  }
749
539
 
750
- // Finally, update our internal raw REST API data with the new values
540
+ // Finally, update our internal raw Nest API data with the new values
751
541
  this.#rawData[value.object_key].object_revision = value.object_revision; // Used for future subscribes
752
542
  this.#rawData[value.object_key].object_timestamp = value.object_timestamp; // Used for future subscribes
753
543
  for (const [fieldKey, fieldValue] of Object.entries(value.value)) {
@@ -759,55 +549,57 @@ export default class NestAccfactory {
759
549
  await this.#processPostSubscribe();
760
550
  })
761
551
  .catch((error) => {
762
- if (error?.cause !== undefined && JSON.stringify(error.cause).toUpperCase().includes('TIMEOUT') === false && this?.log?.debug) {
763
- this.log.debug('REST API had an error performing subscription with connection uuid "%s"', connectionUUID);
764
- this.log.debug('Error was "%s"', error);
552
+ if (
553
+ error?.cause === undefined ||
554
+ (typeof error.cause === 'object' && String(error.cause).toUpperCase().includes('TIMEOUT') === false)
555
+ ) {
556
+ this?.log?.debug?.(
557
+ 'Nest API had an error performing subscription with connection uuid "%s"',
558
+ uuid,
559
+ error?.message ?? String(error),
560
+ );
561
+ this?.log?.debug?.('Restarting Nest API subscription for connection uuid "%s"', uuid);
765
562
  }
766
563
  })
767
564
  .finally(() => {
768
- this?.log?.debug && this.log.debug('Restarting REST API subscribe for connection uuid "%s"', connectionUUID);
769
- setTimeout(this.#subscribeREST.bind(this, connectionUUID, fullRefresh), 1000);
565
+ setTimeout(() => this.#subscribeNest(uuid, fullRefresh), 1000);
770
566
  });
771
567
  }
772
568
 
773
- async #subscribeProtobuf(connectionUUID, firstRun) {
774
- if (typeof this.#connections?.[connectionUUID] !== 'object' || this.#connections?.[connectionUUID]?.authorised !== true) {
569
+ async #subscribeProtobuf(uuid, firstRun) {
570
+ if (
571
+ typeof this.#connections?.[uuid] !== 'object' ||
572
+ this.#connections?.[uuid]?.authorised === false ||
573
+ this.config?.options?.useGoogleAPI === false
574
+ ) {
775
575
  // Not a valid connection object and/or we're not authorised
776
576
  return;
777
577
  }
778
578
 
779
579
  const calculate_message_size = (inputBuffer) => {
780
- // First byte in the is a tag type??
781
- // Following is a varint type
782
- // After varint size, is the buffer content
783
580
  let varint = 0;
784
- let bufferPos = 0;
785
- let currentByte;
786
-
787
- for (;;) {
788
- currentByte = inputBuffer[bufferPos + 1]; // Offset in buffer + 1 to skip over starting tag
789
- varint |= (currentByte & 0x7f) << (bufferPos * 7);
790
- bufferPos += 1;
791
- if (bufferPos > 5) {
792
- throw new Error('VarInt exceeds allowed bounds.');
793
- }
794
- if ((currentByte & 0x80) !== 0x80) {
795
- break;
581
+ let shift = 0;
582
+
583
+ for (let i = 1; i <= 5; i++) {
584
+ // Start at index 1 (skip tag byte)
585
+ let byte = inputBuffer[i];
586
+ varint |= (byte & 0x7f) << shift;
587
+ if ((byte & 0x80) === 0) {
588
+ return varint + i + 1; // +1 to include initial tag byte
796
589
  }
590
+ shift += 7;
797
591
  }
798
592
 
799
- // Return length of message in buffer
800
- return varint + bufferPos + 1;
593
+ throw new Error('VarInt exceeds allowed bounds.');
801
594
  };
802
595
 
803
- const traverseTypes = (currentTrait, callback) => {
804
- if (currentTrait instanceof protobuf.Type) {
805
- callback(currentTrait);
596
+ const traverseTypes = (trait, callback) => {
597
+ if (trait instanceof protobuf.Type === true) {
598
+ callback(trait);
806
599
  }
807
- if (currentTrait.nestedArray) {
808
- currentTrait.nestedArray.map((trait) => {
809
- traverseTypes(trait, callback);
810
- });
600
+
601
+ for (const nested of trait && trait.nestedArray ? trait.nestedArray : []) {
602
+ traverseTypes(nested, callback);
811
603
  }
812
604
  };
813
605
 
@@ -817,13 +609,12 @@ export default class NestAccfactory {
817
609
  protobuf.configure();
818
610
  this.#protobufRoot = protobuf.loadSync(path.resolve(__dirname + '/protobuf/root.proto'));
819
611
  if (this.#protobufRoot !== null) {
820
- this?.log?.debug && this.log.debug('Loaded protobuf support files for Protobuf API');
612
+ this?.log?.debug?.('Loaded protobuf support files for Protobuf API');
821
613
  }
822
614
  }
823
615
 
824
616
  if (this.#protobufRoot === null) {
825
- this?.log?.warn &&
826
- this.log.warn('Failed to loaded Protobuf API support files. This will cause certain Nest/Google devices to be un-supported');
617
+ this?.log?.warn?.('Failed to loaded Protobuf API support files. This will cause certain Nest/Google devices to be un-supported');
827
618
  return;
828
619
  }
829
620
 
@@ -834,15 +625,15 @@ export default class NestAccfactory {
834
625
  let observeRequest = this.#protobufRoot.lookup('nestlabs.gateway.v2.ObserveRequest');
835
626
  if (traitTypeObserveParam !== null && observeRequest !== null) {
836
627
  traverseTypes(this.#protobufRoot, (type) => {
837
- // We only want to have certain trait'families' in our observe reponse we are building
628
+ // We only want to have certain trait 'families' in our observe reponse we are building
838
629
  // This also depends on the account type we connected with
839
630
  // Nest accounts cannot observe camera/doorbell product traits
840
631
  if (
841
- (this.#connections[connectionUUID].type === NestAccfactory.NestAccount &&
632
+ (this.#connections[uuid].type === AccountType.Nest &&
842
633
  type.fullName.startsWith('.nest.trait.product.camera') === false &&
843
634
  type.fullName.startsWith('.nest.trait.product.doorbell') === false &&
844
635
  (type.fullName.startsWith('.nest.trait') === true || type.fullName.startsWith('.weave.') === true)) ||
845
- (this.#connections[connectionUUID].type === NestAccfactory.GoogleAccount &&
636
+ (this.#connections[uuid].type === AccountType.Google &&
846
637
  (type.fullName.startsWith('.nest.trait') === true ||
847
638
  type.fullName.startsWith('.weave.') === true ||
848
639
  type.fullName.startsWith('.google.trait.product.camera') === true))
@@ -854,17 +645,17 @@ export default class NestAccfactory {
854
645
  }
855
646
 
856
647
  if (firstRun === true) {
857
- this?.log?.debug && this.log.debug('Starting Protobuf API trait observe for connection uuid "%s"', connectionUUID);
648
+ this?.log?.debug?.('Starting Protobuf API trait observe for connection uuid "%s"', uuid);
858
649
  }
859
650
 
860
651
  fetchWrapper(
861
652
  'post',
862
- 'https://' + this.#connections[connectionUUID].protobufAPIHost + '/nestlabs.gateway.v2.GatewayService/Observe',
653
+ 'https://' + this.#connections[uuid].protobufAPIHost + '/nestlabs.gateway.v2.GatewayService/Observe',
863
654
  {
864
655
  headers: {
865
- referer: 'https://' + this.#connections[connectionUUID].referer,
656
+ referer: 'https://' + this.#connections[uuid].referer,
866
657
  'User-Agent': USERAGENT,
867
- Authorization: 'Basic ' + this.#connections[connectionUUID].token,
658
+ Authorization: 'Basic ' + this.#connections[uuid].token,
868
659
  'Content-Type': 'application/x-protobuf',
869
660
  'X-Accept-Content-Transfer-Encoding': 'binary',
870
661
  'X-Accept-Response-Streaming': 'true',
@@ -929,13 +720,11 @@ export default class NestAccfactory {
929
720
  }
930
721
 
931
722
  // Send removed notice onto HomeKit device for it to process
932
- if (this.#eventEmitter !== undefined) {
933
- this.#eventEmitter.emit(
934
- this.#trackedDevices[this.#rawData[resource.resourceId].value.device_identity.serialNumber].uuid,
935
- HomeKitDevice.REMOVE,
936
- {},
937
- );
938
- }
723
+ this.#eventEmitter?.emit?.(
724
+ this.#trackedDevices[this.#rawData[resource.resourceId].value.device_identity.serialNumber].uuid,
725
+ HomeKitDevice.REMOVE,
726
+ {},
727
+ );
939
728
 
940
729
  // Finally, remove from tracked devices
941
730
  delete this.#trackedDevices[this.#rawData[resource.resourceId].value.device_identity.serialNumber];
@@ -955,8 +744,8 @@ export default class NestAccfactory {
955
744
  decodedMessage.observeResponse[0].traitStates.map(async (trait) => {
956
745
  if (typeof this.#rawData[trait.traitId.resourceId] === 'undefined') {
957
746
  this.#rawData[trait.traitId.resourceId] = {};
958
- this.#rawData[trait.traitId.resourceId].connection = connectionUUID;
959
- this.#rawData[trait.traitId.resourceId].source = NestAccfactory.DataSource.PROTOBUF;
747
+ this.#rawData[trait.traitId.resourceId].connection = uuid;
748
+ this.#rawData[trait.traitId.resourceId].source = DATASOURCE.ProtobufAPI;
960
749
  this.#rawData[trait.traitId.resourceId].value = {};
961
750
  }
962
751
  this.#rawData[trait.traitId.resourceId]['value'][trait.traitId.traitLabel] =
@@ -966,15 +755,15 @@ export default class NestAccfactory {
966
755
  delete this.#rawData[trait.traitId.resourceId]['value'][trait.traitId.traitLabel]['@type'];
967
756
 
968
757
  // If we have structure location details and associated geo-location details, get the weather data for the location
969
- // We'll store this in the object key/value as per REST API
758
+ // We'll store this in the object key/value as per Nest API
970
759
  if (
971
760
  trait.traitId.resourceId.startsWith('STRUCTURE_') === true &&
972
761
  trait.traitId.traitLabel === 'structure_location' &&
973
762
  isNaN(trait.patch.values?.geoCoordinate?.latitude) === false &&
974
763
  isNaN(trait.patch.values?.geoCoordinate?.longitude) === false
975
764
  ) {
976
- this.#rawData[trait.traitId.resourceId].value.weather = await this.#getWeatherData(
977
- connectionUUID,
765
+ this.#rawData[trait.traitId.resourceId].value.weather = await this.#getWeather(
766
+ uuid,
978
767
  trait.traitId.resourceId,
979
768
  Number(trait.patch.values.geoCoordinate.latitude),
980
769
  Number(trait.patch.values.geoCoordinate.longitude),
@@ -989,15 +778,20 @@ export default class NestAccfactory {
989
778
  }
990
779
  })
991
780
  .catch((error) => {
992
- if (error?.cause !== undefined && JSON.stringify(error.cause).toUpperCase().includes('TIMEOUT') === false && this?.log?.debug) {
993
- this.log.debug('Protobuf API had an error performing trait observe with connection uuid "%s"', connectionUUID);
994
- this.log.debug('Error was "%s"', error);
781
+ if (
782
+ error?.cause === undefined ||
783
+ (typeof error.cause === 'object' && String(error.cause).toUpperCase().includes('TIMEOUT') === false)
784
+ ) {
785
+ this?.log?.debug?.(
786
+ 'Protobuf API had an error performing trait observe with connection uuid "%s". Error: "%s"',
787
+ uuid,
788
+ error?.message ?? String(error),
789
+ );
790
+ this?.log?.debug?.('Restarting Protobuf API trait observe for connection uuid "%s"', uuid);
995
791
  }
996
792
  })
997
-
998
793
  .finally(() => {
999
- this?.log?.debug && this.log.debug('Restarting Protobuf API trait observe for connection uuid "%s"', connectionUUID);
1000
- setTimeout(this.#subscribeProtobuf.bind(this, connectionUUID, false), 1000);
794
+ setTimeout(() => this.#subscribeProtobuf(uuid, false), 1000);
1001
795
  });
1002
796
  }
1003
797
 
@@ -1005,79 +799,44 @@ export default class NestAccfactory {
1005
799
  Object.values(this.#processData('')).forEach((deviceData) => {
1006
800
  if (this.#trackedDevices?.[deviceData?.serialNumber] === undefined && deviceData?.excluded === true) {
1007
801
  // We haven't tracked this device before (ie: should be a new one) and but its excluded
1008
- this?.log?.warn && this.log.warn('Device "%s" is ignored due to it being marked as excluded', deviceData.description);
802
+ this?.log?.warn?.('Device "%s" is ignored due to it being marked as excluded', deviceData.description);
1009
803
 
1010
804
  // Track this device even though its excluded
1011
805
  this.#trackedDevices[deviceData.serialNumber] = {
1012
- uuid: undefined,
806
+ uuid: HomeKitDevice.generateUUID(HomeKitDevice.PLUGIN_NAME, this.api, deviceData.serialNumber),
1013
807
  rawDataUuid: deviceData.nest_google_uuid,
1014
808
  source: undefined,
809
+ timers: undefined,
1015
810
  exclude: true,
1016
811
  };
812
+
813
+ // If we're running under Homebridge, and the device is now marked as excluded and present in accessory cache
814
+ // Then we'll unregister it from the Homebridge platform
815
+ if (typeof this?.api?.unregisterPlatformAccessories === 'function') {
816
+ let accessory = this.cachedAccessories.find(
817
+ (accessory) => accessory?.UUID === this.#trackedDevices[deviceData.serialNumber].uuid,
818
+ );
819
+ if (accessory !== undefined && typeof accessory === 'object') {
820
+ this.api.unregisterPlatformAccessories(HomeKitDevice.PLUGIN_NAME, HomeKitDevice.PLATFORM_NAME, [accessory]);
821
+ }
822
+ }
1017
823
  }
1018
824
 
1019
825
  if (this.#trackedDevices?.[deviceData?.serialNumber] === undefined && deviceData?.excluded === false) {
1020
826
  // We haven't tracked this device before (ie: should be a new one) and its not excluded
1021
827
  // so create the required HomeKit accessories based upon the device data
1022
- if (deviceData.device_type === NestAccfactory.DeviceType.THERMOSTAT && typeof NestThermostat === 'function') {
1023
- // Nest Thermostat(s) - Categories.THERMOSTAT = 9
1024
- let tempDevice = new NestThermostat(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
1025
- tempDevice.add('Nest Thermostat', 9, true);
1026
- // Track this device once created
1027
- this.#trackedDevices[deviceData.serialNumber] = {
1028
- uuid: tempDevice.uuid,
1029
- rawDataUuid: deviceData.nest_google_uuid,
1030
- source: undefined,
1031
- };
1032
- }
1033
-
1034
- if (deviceData.device_type === NestAccfactory.DeviceType.TEMPSENSOR && typeof NestTemperatureSensor === 'function') {
1035
- // Nest Temperature Sensor - Categories.SENSOR = 10;
1036
- let tempDevice = new NestTemperatureSensor(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
1037
- tempDevice.add('Nest Temperature Sensor', 10, true);
1038
- // Track this device once created
1039
- this.#trackedDevices[deviceData.serialNumber] = {
1040
- uuid: tempDevice.uuid,
1041
- rawDataUuid: deviceData.nest_google_uuid,
1042
- source: undefined,
1043
- };
1044
- }
1045
-
1046
- if (deviceData.device_type === NestAccfactory.DeviceType.SMOKESENSOR && typeof NestProtect === 'function') {
1047
- // Nest Protect(s) - Categories.SENSOR = 10
1048
- let tempDevice = new NestProtect(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
1049
- tempDevice.add('Nest Protect', 10, true);
1050
- // Track this device once created
1051
- this.#trackedDevices[deviceData.serialNumber] = {
1052
- uuid: tempDevice.uuid,
1053
- rawDataUuid: deviceData.nest_google_uuid,
1054
- source: undefined,
1055
- };
1056
- }
1057
-
1058
- if (
1059
- (deviceData.device_type === NestAccfactory.DeviceType.CAMERA ||
1060
- deviceData.device_type === NestAccfactory.DeviceType.DOORBELL ||
1061
- deviceData.device_type === NestAccfactory.DeviceType.FLOODLIGHT) &&
1062
- (typeof NestCamera === 'function' || typeof NestDoorbell === 'function' || typeof NestFloodlight === 'function')
1063
- ) {
1064
- let accessoryName = 'Nest ' + deviceData.model.replace(/\s*(?:\([^()]*\))/gi, '');
1065
- let tempDevice = undefined;
1066
- if (deviceData.device_type === NestAccfactory.DeviceType.CAMERA) {
1067
- // Nest Camera(s) - Categories.IP_CAMERA = 17
1068
- tempDevice = new NestCamera(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
1069
- tempDevice.add(accessoryName, 17, true);
1070
- }
1071
- if (deviceData.device_type === NestAccfactory.DeviceType.DOORBELL) {
1072
- // Nest Doorbell(s) - Categories.VIDEO_DOORBELL = 18
1073
- tempDevice = new NestDoorbell(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
1074
- tempDevice.add(accessoryName, 18, true);
1075
- }
1076
- if (deviceData.device_type === NestAccfactory.DeviceType.FLOODLIGHT) {
1077
- // Nest Camera(s) with Floodlight - Categories.IP_CAMERA = 17
1078
- tempDevice = new NestFloodlight(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
1079
- tempDevice.add(accessoryName, 17, true);
1080
- }
828
+ let deviceClass = this.#deviceModules.get(deviceData.device_type);
829
+ if (deviceClass !== undefined) {
830
+ // We have found a device class for this device type, so we can create the device
831
+ let accessoryName =
832
+ (deviceData.manufacturer?.trim() || 'Nest') +
833
+ ' ' +
834
+ deviceClass.TYPE.replace(/([a-z])([A-Z])/g, '$1 $2')
835
+ .replace(/[^a-zA-Z0-9 ]+/g, ' ')
836
+ .toLowerCase()
837
+ .replace(/\b\w/g, (character) => character.toUpperCase());
838
+ let tempDevice = new deviceClass(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
839
+ tempDevice.add(accessoryName, getDeviceHKCategory(deviceClass.TYPE), true);
1081
840
 
1082
841
  // Track this device once created
1083
842
  this.#trackedDevices[deviceData.serialNumber] = {
@@ -1085,254 +844,241 @@ export default class NestAccfactory {
1085
844
  rawDataUuid: deviceData.nest_google_uuid,
1086
845
  source: undefined,
1087
846
  timers: {},
847
+ exclude: false,
1088
848
  };
1089
849
 
1090
- // Setup polling loop for camera/doorbell zone data
1091
- // This is only required for REST API data sources as these details are present in Protobuf API
1092
- this.#trackedDevices[deviceData.serialNumber].timers.zones = setInterval(async () => {
1093
- let nest_google_uuid = this.#trackedDevices?.[deviceData?.serialNumber]?.rawDataUuid;
1094
- if (
1095
- this.#rawData?.[nest_google_uuid]?.value !== undefined &&
1096
- this.#trackedDevices?.[deviceData?.serialNumber]?.source === NestAccfactory.DataSource.REST
1097
- ) {
1098
- await fetchWrapper(
1099
- 'get',
1100
- this.#rawData[nest_google_uuid].value.nexus_api_http_server_url + '/cuepoint_category/' + nest_google_uuid.split('.')[1],
1101
- {
1102
- headers: {
1103
- referer: 'https://' + this.#connections[this.#rawData[nest_google_uuid].connection].referer,
1104
- 'User-Agent': USERAGENT,
1105
- [this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.key]:
1106
- this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.value +
1107
- this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.token,
1108
- },
1109
- timeout: CAMERAZONEPOLLING,
1110
- },
1111
- )
1112
- .then((response) => response.json())
1113
- .then((data) => {
1114
- let zones = [];
1115
- data.forEach((zone) => {
1116
- if (zone.type.toUpperCase() === 'ACTIVITY' || zone.type.toUpperCase() === 'REGION') {
1117
- zones.push({
1118
- id: zone.id === 0 ? 1 : zone.id,
1119
- name: makeHomeKitName(zone.label),
1120
- hidden: zone.hidden === true,
1121
- uri: zone.nexusapi_image_uri,
1122
- });
1123
- }
1124
- });
850
+ // Optional things for each device type
851
+ if (
852
+ deviceClass.TYPE === DeviceType.CAMERA ||
853
+ deviceClass.TYPE === DeviceType.DOORBELL ||
854
+ deviceClass.TYPE === DeviceType.FLOODLIGHT
855
+ ) {
856
+ // Setup polling loop for camera/doorbell zone data
857
+ // This is only required for Nest API data sources as these details are present in Protobuf API
858
+ clearInterval(this.#trackedDevices?.[deviceData.serialNumber]?.timers?.zones);
859
+ this.#trackedDevices[deviceData.serialNumber].timers.zones = setInterval(async () => {
860
+ let nest_google_uuid = this.#trackedDevices?.[deviceData?.serialNumber]?.rawDataUuid;
861
+ if (
862
+ this.#rawData?.[nest_google_uuid]?.value !== undefined &&
863
+ this.#trackedDevices?.[deviceData?.serialNumber]?.source === DATASOURCE.NestAPI
864
+ ) {
865
+ try {
866
+ let response = await fetchWrapper(
867
+ 'get',
868
+ this.#rawData[nest_google_uuid].value.nexus_api_http_server_url +
869
+ '/cuepoint_category/' +
870
+ nest_google_uuid.split('.')[1],
871
+ {
872
+ headers: {
873
+ referer: 'https://' + this.#connections[this.#rawData[nest_google_uuid].connection].referer,
874
+ 'User-Agent': USERAGENT,
875
+ [this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.key]:
876
+ this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.value +
877
+ this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.token,
878
+ },
879
+ timeout: CAMERAZONEPOLLING,
880
+ },
881
+ );
882
+ let data = await response.json();
883
+
884
+ // Transform activity zones if present
885
+ let zones =
886
+ Array.isArray(data) === true
887
+ ? data
888
+ .filter((zone) => zone.type.toUpperCase() === 'ACTIVITY' || zone.type.toUpperCase() === 'REGION')
889
+ .map((zone) => ({
890
+ id: zone.id === 0 ? 1 : zone.id,
891
+ name: makeHomeKitName(zone.label),
892
+ hidden: zone.hidden === true,
893
+ uri: zone.nexusapi_image_uri,
894
+ }))
895
+ : [];
1125
896
 
1126
897
  // Update internal structure with new zone details.
1127
- // We do a test to see if its still present not interval loop not finished or device removed
898
+ // We do a test to see if it's still present, not interval loop not finished or device removed
1128
899
  if (this.#rawData?.[nest_google_uuid]?.value !== undefined) {
1129
900
  this.#rawData[nest_google_uuid].value.activity_zones = zones;
1130
901
 
1131
902
  // Send updated data onto HomeKit device for it to process
1132
- if (this.#eventEmitter !== undefined && this.#trackedDevices?.[deviceData?.serialNumber]?.uuid !== undefined) {
1133
- this.#eventEmitter.emit(this.#trackedDevices[deviceData.serialNumber].uuid, HomeKitDevice.UPDATE, {
903
+ this.#trackedDevices?.[deviceData?.serialNumber]?.uuid &&
904
+ this.#eventEmitter?.emit?.(this.#trackedDevices[deviceData.serialNumber].uuid, HomeKitDevice.UPDATE, {
1134
905
  activity_zones: zones,
1135
906
  });
1136
- }
1137
907
  }
1138
- })
1139
- .catch((error) => {
1140
- // Log debug message if wasn't a timeout
1141
- if (
1142
- error?.cause !== undefined &&
1143
- JSON.stringify(error.cause).toUpperCase().includes('TIMEOUT') === false &&
1144
- this?.log?.debug
1145
- ) {
1146
- this.log.debug(
1147
- 'REST API had error retrieving camera/doorbell activity zones for "%s". Error was "%s"',
908
+ } catch (error) {
909
+ // Log debug message if it wasn't a timeout
910
+ if (error?.cause !== undefined && String(error.cause).toUpperCase().includes('TIMEOUT') === false) {
911
+ this?.log?.debug?.(
912
+ 'Nest API had error retrieving camera/doorbell activity zones for "%s". Error was "%s"',
1148
913
  deviceData.description,
1149
914
  error?.code,
1150
915
  );
1151
916
  }
1152
- });
1153
- }
1154
- }, CAMERAZONEPOLLING);
917
+ }
918
+ }
919
+ }, CAMERAZONEPOLLING);
1155
920
 
1156
- // Setup polling loop for camera/doorbell alert data
1157
- this.#trackedDevices[deviceData.serialNumber].timers.alerts = setInterval(async () => {
1158
- let alerts = []; // No alerts to processed yet
1159
- let nest_google_uuid = this.#trackedDevices?.[deviceData?.serialNumber]?.rawDataUuid;
1160
- if (
1161
- this.#rawData?.[nest_google_uuid]?.value !== undefined &&
1162
- this.#trackedDevices?.[deviceData?.serialNumber]?.source === NestAccfactory.DataSource.PROTOBUF
1163
- ) {
1164
- let commandResponse = await this.#protobufCommand(this.#rawData[nest_google_uuid].connection, 'ResourceApi', 'SendCommand', {
1165
- resourceRequest: {
1166
- resourceId: nest_google_uuid,
1167
- requestId: crypto.randomUUID(),
1168
- },
1169
- resourceCommands: [
921
+ // Setup polling loop for camera/doorbell alert data, clearing any existing polling loop
922
+ clearInterval(this.#trackedDevices?.[deviceData.serialNumber]?.timers?.alerts);
923
+ this.#trackedDevices[deviceData.serialNumber].timers.alerts = setInterval(async () => {
924
+ let alerts = []; // No alerts to processed yet
925
+ let nest_google_uuid = this.#trackedDevices?.[deviceData?.serialNumber]?.rawDataUuid;
926
+ if (
927
+ this.#rawData?.[nest_google_uuid]?.value !== undefined &&
928
+ this.#trackedDevices?.[deviceData?.serialNumber]?.source === DATASOURCE.ProtobufAPI
929
+ ) {
930
+ let commandResponse = await this.#protobufCommand(
931
+ this.#rawData[nest_google_uuid].connection,
932
+ 'ResourceApi',
933
+ 'SendCommand',
1170
934
  {
1171
- traitLabel: 'camera_observation_history',
1172
- command: {
1173
- type_url: 'type.nestlabs.com/nest.trait.history.CameraObservationHistoryTrait.CameraObservationHistoryRequest',
1174
- value: {
1175
- // We want camera history from now for upto 30secs from now
1176
- queryStartTime: { seconds: Math.floor(Date.now() / 1000), nanos: (Math.round(Date.now()) % 1000) * 1e6 },
1177
- queryEndTime: {
1178
- seconds: Math.floor((Date.now() + 30000) / 1000),
1179
- nanos: (Math.round(Date.now() + 30000) % 1000) * 1e6,
935
+ resourceRequest: {
936
+ resourceId: nest_google_uuid,
937
+ requestId: crypto.randomUUID(),
938
+ },
939
+ resourceCommands: [
940
+ {
941
+ traitLabel: 'camera_observation_history',
942
+ command: {
943
+ type_url: 'type.nestlabs.com/nest.trait.history.CameraObservationHistoryTrait.CameraObservationHistoryRequest',
944
+ value: {
945
+ // We want camera history from now for upto 30secs from now
946
+ queryStartTime: { seconds: Math.floor(Date.now() / 1000), nanos: (Math.round(Date.now()) % 1000) * 1e6 },
947
+ queryEndTime: {
948
+ seconds: Math.floor((Date.now() + 30000) / 1000),
949
+ nanos: (Math.round(Date.now() + 30000) % 1000) * 1e6,
950
+ },
951
+ },
1180
952
  },
1181
953
  },
1182
- },
954
+ ],
1183
955
  },
1184
- ],
1185
- });
1186
-
1187
- if (
1188
- typeof commandResponse?.sendCommandResponse?.[0]?.traitOperations?.[0]?.event?.event?.cameraEventWindow?.cameraEvent ===
1189
- 'object'
1190
- ) {
1191
- commandResponse.sendCommandResponse[0].traitOperations[0].event.event.cameraEventWindow.cameraEvent.forEach((event) => {
1192
- alerts.push({
1193
- playback_time: parseInt(event.startTime.seconds) * 1000 + parseInt(event.startTime.nanos) / 1000000,
1194
- start_time: parseInt(event.startTime.seconds) * 1000 + parseInt(event.startTime.nanos) / 1000000,
1195
- end_time: parseInt(event.endTime.seconds) * 1000 + parseInt(event.endTime.nanos) / 1000000,
1196
- id: event.eventId,
1197
- zone_ids:
1198
- typeof event.activityZone === 'object'
1199
- ? event.activityZone.map((zone) => (zone?.zoneIndex !== undefined ? zone.zoneIndex : zone.internalIndex))
1200
- : [],
1201
- types: event.eventType
1202
- .map((event) => (event.startsWith('EVENT_') === true ? event.split('EVENT_')[1].toLowerCase() : ''))
1203
- .filter((event) => event),
1204
- });
1205
-
1206
- // Fix up event types to match REST API
1207
- // 'EVENT_UNFAMILIAR_FACE' = 'unfamiliar-face'
1208
- // 'EVENT_PERSON_TALKING' = 'personHeard'
1209
- // 'EVENT_DOG_BARKING' = 'dogBarking'
1210
- // <---- TODO (as the ones we use match from Protobuf)
1211
- });
956
+ );
1212
957
 
1213
- // Sort alerts to be most recent first
1214
- alerts = alerts.sort((a, b) => {
1215
- if (a.start_time > b.start_time) {
1216
- return -1;
1217
- }
1218
- });
958
+ if (
959
+ Array.isArray(
960
+ commandResponse?.sendCommandResponse?.[0]?.traitOperations?.[0]?.event?.event?.cameraEventWindow?.cameraEvent,
961
+ ) === true
962
+ ) {
963
+ alerts = commandResponse.sendCommandResponse[0].traitOperations[0].event.event.cameraEventWindow.cameraEvent
964
+ .map((event) => ({
965
+ playback_time: parseInt(event.startTime.seconds) * 1000 + parseInt(event.startTime.nanos) / 1000000,
966
+ start_time: parseInt(event.startTime.seconds) * 1000 + parseInt(event.startTime.nanos) / 1000000,
967
+ end_time: parseInt(event.endTime.seconds) * 1000 + parseInt(event.endTime.nanos) / 1000000,
968
+ id: event.eventId,
969
+ zone_ids:
970
+ typeof event.activityZone === 'object'
971
+ ? event.activityZone.map((zone) => (zone?.zoneIndex !== undefined ? zone.zoneIndex : zone.internalIndex))
972
+ : [],
973
+ types: event.eventType
974
+ .map((event) => {
975
+ if (event === 'EVENT_UNFAMILIAR_FACE') {
976
+ return 'unfamiliar-face';
977
+ }
978
+ if (event === 'EVENT_PERSON_TALKING') {
979
+ return 'personHeard';
980
+ }
981
+ if (event === 'EVENT_DOG_BARKING') {
982
+ return 'dogBarking';
983
+ }
984
+ return event.startsWith('EVENT_') ? event.split('EVENT_')[1].toLowerCase() : '';
985
+ })
986
+ .filter((event) => event),
987
+ }))
988
+ .sort((a, b) => b.start_time - a.start_time);
989
+ }
1219
990
  }
1220
- }
1221
991
 
1222
- if (
1223
- this.#rawData?.[nest_google_uuid]?.value !== undefined &&
1224
- this.#trackedDevices?.[deviceData?.serialNumber]?.source === NestAccfactory.DataSource.REST
1225
- ) {
1226
- await fetchWrapper(
1227
- 'get',
1228
- this.#rawData[nest_google_uuid].value.nexus_api_http_server_url +
1229
- '/cuepoint/' +
1230
- nest_google_uuid.split('.')[1] +
1231
- '/2?start_time=' +
1232
- Math.floor(Date.now() / 1000 - 30),
1233
- {
1234
- headers: {
1235
- referer: 'https://' + this.#connections[this.#rawData[nest_google_uuid].connection].referer,
1236
- 'User-Agent': USERAGENT,
1237
- [this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.key]:
1238
- this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.value +
1239
- this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.token,
1240
- },
1241
- timeout: CAMERAALERTPOLLING,
1242
- retry: 3,
1243
- },
1244
- )
1245
- .then((response) => response.json())
1246
- .then((data) => {
1247
- data.forEach((alert) => {
1248
- // Fix up alert zone IDs. If there is an ID of 0, we'll transform to 1. ie: main zone
1249
- // If there are NO zone IDs, we'll put a 1 in there ie: main zone
1250
- alert.zone_ids = alert.zone_ids.map((id) => (id !== 0 ? id : 1));
1251
- if (alert.zone_ids.length === 0) {
1252
- alert.zone_ids.push(1);
1253
- }
1254
- alerts.push({
1255
- playback_time: alert.playback_time,
1256
- start_time: alert.start_time,
1257
- end_time: alert.end_time,
1258
- id: alert.id,
1259
- zone_ids: alert.zone_ids,
1260
- types: alert.types,
1261
- });
1262
- });
992
+ if (
993
+ this.#rawData?.[nest_google_uuid]?.value !== undefined &&
994
+ this.#trackedDevices?.[deviceData?.serialNumber]?.source === DATASOURCE.NestAPI
995
+ ) {
996
+ try {
997
+ let response = await fetchWrapper(
998
+ 'get',
999
+ this.#rawData[nest_google_uuid].value.nexus_api_http_server_url +
1000
+ '/cuepoint/' +
1001
+ nest_google_uuid.split('.')[1] +
1002
+ '/2?start_time=' +
1003
+ Math.floor(Date.now() / 1000 - 30),
1004
+ {
1005
+ headers: {
1006
+ referer: 'https://' + this.#connections[this.#rawData[nest_google_uuid].connection].referer,
1007
+ 'User-Agent': USERAGENT,
1008
+ [this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.key]:
1009
+ this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.value +
1010
+ this.#connections[this.#rawData[nest_google_uuid].connection].cameraAPI.token,
1011
+ },
1012
+ timeout: CAMERAALERTPOLLING,
1013
+ retry: 3,
1014
+ },
1015
+ );
1263
1016
 
1264
- // Sort alerts to be most recent first
1265
- alerts = alerts.sort((a, b) => {
1266
- if (a.start_time > b.start_time) {
1267
- return -1;
1268
- }
1269
- });
1270
- })
1271
- .catch((error) => {
1272
- // Log debug message if wasn't a timeout
1273
- if (
1274
- error?.cause !== undefined &&
1275
- JSON.stringify(error.cause).toUpperCase().includes('TIMEOUT') === false &&
1276
- this?.log?.debug
1277
- ) {
1278
- this.log.debug(
1279
- 'REST API had error retrieving camera/doorbell activity notifications for "%s". Error was "%s"',
1017
+ let data = await response.json();
1018
+
1019
+ alerts =
1020
+ Array.isArray(data) === true
1021
+ ? data
1022
+ .map((alert) => {
1023
+ alert.zone_ids = alert.zone_ids.map((id) => (id !== 0 ? id : 1));
1024
+ if (alert.zone_ids.length === 0) {
1025
+ alert.zone_ids.push(1);
1026
+ }
1027
+ return {
1028
+ playback_time: alert.playback_time,
1029
+ start_time: alert.start_time,
1030
+ end_time: alert.end_time,
1031
+ id: alert.id,
1032
+ zone_ids: alert.zone_ids,
1033
+ types: alert.types,
1034
+ };
1035
+ })
1036
+ .sort((a, b) => b.start_time - a.start_time)
1037
+ : [];
1038
+ } catch (error) {
1039
+ if (error?.cause !== undefined && String(error.cause).toUpperCase().includes('TIMEOUT') === false) {
1040
+ this?.log?.debug?.(
1041
+ 'Nest API had error retrieving camera/doorbell activity notifications for "%s". Error was "%s"',
1280
1042
  deviceData.description,
1281
1043
  error?.code,
1282
1044
  );
1283
1045
  }
1284
- });
1285
- }
1286
-
1287
- // Update internal structure with new alerts.
1288
- // We do a test to see if its still present not interval loop not finished or device removed
1289
- if (this.#rawData?.[nest_google_uuid]?.value !== undefined) {
1290
- this.#rawData[nest_google_uuid].value.alerts = alerts;
1291
-
1292
- // Send updated alerts onto HomeKit device for it to process
1293
- if (this.#eventEmitter !== undefined && this.#trackedDevices?.[deviceData?.serialNumber]?.uuid !== undefined) {
1294
- this.#eventEmitter.emit(this.#trackedDevices[deviceData.serialNumber].uuid, HomeKitDevice.UPDATE, {
1295
- alerts: alerts,
1296
- });
1046
+ }
1297
1047
  }
1298
- }
1299
- }, CAMERAALERTPOLLING);
1300
- }
1301
-
1302
- if (deviceData.device_type === NestAccfactory.DeviceType.WEATHER && typeof NestWeather === 'function') {
1303
- // Nest 'Virtual' weather station - Categories.SENSOR = 10
1304
- let tempDevice = new NestWeather(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
1305
- tempDevice.add('Nest Weather', 10, true);
1306
1048
 
1307
- // Track this device once created
1308
- this.#trackedDevices[deviceData.serialNumber] = {
1309
- uuid: tempDevice.uuid,
1310
- rawDataUuid: deviceData.nest_google_uuid,
1311
- source: undefined,
1312
- timers: {},
1313
- };
1049
+ // Update internal structure with new alerts.
1050
+ // We do a test to see if its still present not interval loop not finished or device removed
1051
+ if (this.#rawData?.[nest_google_uuid]?.value !== undefined) {
1052
+ this.#rawData[nest_google_uuid].value.alerts = alerts;
1314
1053
 
1315
- // Setup polling loop for weather data
1316
- this.#trackedDevices[deviceData.serialNumber].timers.weather = setInterval(async () => {
1317
- let nest_google_uuid = this.#trackedDevices?.[deviceData?.serialNumber]?.rawDataUuid;
1318
- if (this.#rawData?.[nest_google_uuid] !== undefined) {
1319
- this.#rawData[nest_google_uuid].value.weather = await this.#getWeatherData(
1320
- this.#rawData[nest_google_uuid].connection,
1321
- nest_google_uuid,
1322
- this.#rawData[nest_google_uuid].value.weather.latitude,
1323
- this.#rawData[nest_google_uuid].value.weather.longitude,
1324
- );
1054
+ // Send updated alerts onto HomeKit device for it to process
1055
+ this.#trackedDevices?.[deviceData?.serialNumber]?.uuid &&
1056
+ this.#eventEmitter?.emit?.(this.#trackedDevices[deviceData.serialNumber].uuid, HomeKitDevice.UPDATE, { alerts: alerts });
1057
+ }
1058
+ }, CAMERAALERTPOLLING);
1059
+ }
1325
1060
 
1326
- // Send updated weather data onto HomeKit device for it to process
1327
- if (this.#eventEmitter !== undefined && this.#trackedDevices?.[deviceData?.serialNumber]?.uuid !== undefined) {
1328
- this.#eventEmitter.emit(
1329
- this.#trackedDevices[deviceData.serialNumber].uuid,
1330
- HomeKitDevice.UPDATE,
1331
- this.#processData(nest_google_uuid)[deviceData.serialNumber],
1061
+ if (deviceClass.TYPE === DeviceType.WEATHER) {
1062
+ // Setup polling loop for weather data, clearing any existing polling loop
1063
+ clearInterval(this.#trackedDevices?.[deviceData.serialNumber]?.timers?.weather);
1064
+ this.#trackedDevices[deviceData.serialNumber].timers.weather = setInterval(async () => {
1065
+ if (this.#rawData?.[this.#trackedDevices?.[deviceData.serialNumber]?.rawDataUuid] !== undefined) {
1066
+ this.#rawData[this.#trackedDevices[deviceData.serialNumber].rawDataUuid].value.weather = await this.#getWeather(
1067
+ this.#rawData[this.#trackedDevices[deviceData.serialNumber].rawDataUuid].connection,
1068
+ this.#trackedDevices[deviceData.serialNumber].rawDataUuid,
1069
+ this.#rawData[this.#trackedDevices[deviceData.serialNumber].rawDataUuid].value.weather.latitude,
1070
+ this.#rawData[this.#trackedDevices[deviceData.serialNumber].rawDataUuid].value.weather.longitude,
1332
1071
  );
1072
+
1073
+ this.#trackedDevices?.[deviceData.serialNumber]?.uuid &&
1074
+ this.#eventEmitter?.emit?.(
1075
+ this.#trackedDevices[deviceData.serialNumber].uuid,
1076
+ HomeKitDevice.UPDATE,
1077
+ this.#processData(this.#trackedDevices[deviceData.serialNumber].rawDataUuid)?.[deviceData.serialNumber],
1078
+ );
1333
1079
  }
1334
- }
1335
- }, WEATHERPOLLING);
1080
+ }, WEATHERPOLLING);
1081
+ }
1336
1082
  }
1337
1083
  }
1338
1084
 
@@ -1343,21 +1089,19 @@ export default class NestAccfactory {
1343
1089
  this.#rawData[deviceData.nest_google_uuid].source !== this.#trackedDevices[deviceData.serialNumber].source
1344
1090
  ) {
1345
1091
  // Data source for this device has been updated
1346
- this?.log?.debug &&
1347
- this.log.debug(
1348
- 'Using %s API as data source for "%s" from connection uuid "%s"',
1349
- this.#rawData[deviceData.nest_google_uuid]?.source,
1350
- deviceData.description,
1351
- this.#rawData[deviceData.nest_google_uuid].connection,
1352
- );
1092
+ this?.log?.debug?.(
1093
+ 'Using %s API as data source for "%s" from connection uuid "%s"',
1094
+ this.#rawData[deviceData.nest_google_uuid]?.source,
1095
+ deviceData.description,
1096
+ this.#rawData[deviceData.nest_google_uuid].connection,
1097
+ );
1353
1098
 
1354
1099
  this.#trackedDevices[deviceData.serialNumber].source = this.#rawData[deviceData.nest_google_uuid].source;
1355
1100
  this.#trackedDevices[deviceData.serialNumber].rawDataUuid = deviceData.nest_google_uuid;
1356
1101
  }
1357
1102
 
1358
- if (this.#eventEmitter !== undefined) {
1359
- this.#eventEmitter.emit(this.#trackedDevices[deviceData.serialNumber].uuid, HomeKitDevice.UPDATE, deviceData);
1360
- }
1103
+ this.#trackedDevices?.[deviceData?.serialNumber]?.uuid &&
1104
+ this.#eventEmitter?.emit?.(this.#trackedDevices[deviceData.serialNumber].uuid, HomeKitDevice.UPDATE, deviceData);
1361
1105
  }
1362
1106
  });
1363
1107
  }
@@ -1368,37 +1112,69 @@ export default class NestAccfactory {
1368
1112
  }
1369
1113
  let devices = {};
1370
1114
 
1371
- // Get the device(s) location from stucture
1372
- // We'll test in both REST and Protobuf API data
1115
+ // Get the device(s) location from structure
1116
+ // We'll test in both Nest and Protobuf API data
1373
1117
  const get_location_name = (structure_id, where_id) => {
1374
1118
  let location = '';
1375
1119
 
1376
- // Check REST data
1377
- if (typeof this.#rawData?.['where.' + structure_id]?.value === 'object') {
1378
- this.#rawData['where.' + structure_id].value.wheres.forEach((value) => {
1379
- if (where_id === value.where_id) {
1380
- location = value.name;
1120
+ if (typeof structure_id === 'string' && typeof where_id === 'string') {
1121
+ // Check Nest data
1122
+ if (typeof this.#rawData?.['where.' + structure_id]?.value === 'object') {
1123
+ this.#rawData['where.' + structure_id].value.wheres.forEach((value) => {
1124
+ if (where_id === value.where_id) {
1125
+ location = value.name;
1126
+ }
1127
+ });
1128
+ }
1129
+
1130
+ // Check Protobuf data (combined predefined and custom)
1131
+ let protobufWheres = [
1132
+ ...Object.values(this.#rawData?.[structure_id]?.value?.located_annotations?.predefinedWheres || {}),
1133
+ ...Object.values(this.#rawData?.[structure_id]?.value?.located_annotations?.customWheres || {}),
1134
+ ];
1135
+
1136
+ protobufWheres.forEach((value) => {
1137
+ if (value?.whereId?.resourceId === where_id) {
1138
+ location = value.label?.literal;
1381
1139
  }
1382
1140
  });
1383
1141
  }
1142
+ return location;
1143
+ };
1384
1144
 
1385
- // Check Protobuf data
1386
- if (typeof this.#rawData[structure_id]?.value?.located_annotations?.predefinedWheres === 'object') {
1387
- Object.values(this.#rawData[structure_id].value.located_annotations.predefinedWheres).forEach((value) => {
1388
- if (value.whereId.resourceId === where_id) {
1389
- location = value.label.literal;
1145
+ // Process software version strings and return as x.x.x
1146
+ // handles things like:
1147
+ // 1.0a17 -> 1.0.17
1148
+ // 3.6rc8 -> 3.6.8
1149
+ // rquartz-user 1 OPENMASTER 507800056 test-keys stable-channel stable-channel -> 507800056
1150
+ // nq-user 1.73 OPENMASTER 422270 release-keys stable-channel stable-channel -> 422270
1151
+ const process_software_version = (versionString) => {
1152
+ let version = '0.0.0';
1153
+ if (typeof versionString === 'string') {
1154
+ let normalised = versionString.replace(/[-_]/g, '.');
1155
+ let tokens = normalised.split(/\s+/);
1156
+ let candidate = tokens[3] || normalised;
1157
+ let match = candidate.match(/\d+(?:\.\d+)*[a-zA-Z]*\d*/) || normalised.match(/\d+(?:\.\d+)*[a-zA-Z]*\d*/);
1158
+
1159
+ if (Array.isArray(match)) {
1160
+ let raw = match[0];
1161
+ if (raw.includes('.') === false) {
1162
+ return raw; // Return single-number version like "422270" as-is
1390
1163
  }
1391
- });
1392
- }
1393
- if (typeof this.#rawData[structure_id]?.value?.located_annotations?.customWheres === 'object') {
1394
- Object.values(this.#rawData[structure_id].value.located_annotations.customWheres).forEach((value) => {
1395
- if (value.whereId.resourceId === where_id) {
1396
- location = value.label.literal;
1164
+
1165
+ let parts = raw.split('.').flatMap((part) => {
1166
+ let [, n1, , n2] = part.match(/^(\d+)([a-zA-Z]+)?(\d+)?$/) || [];
1167
+ return [n1, n2].filter(Boolean).map(Number);
1168
+ });
1169
+
1170
+ while (parts.length < 3) {
1171
+ parts.push(0);
1397
1172
  }
1398
- });
1173
+ version = parts.slice(0, 3).join('.');
1174
+ }
1399
1175
  }
1400
1176
 
1401
- return location;
1177
+ return version;
1402
1178
  };
1403
1179
 
1404
1180
  // Process common data for all devices
@@ -1406,38 +1182,43 @@ export default class NestAccfactory {
1406
1182
  let processed = {};
1407
1183
  try {
1408
1184
  // Fix up data we need to
1185
+ let deviceOptions = this.config?.devices?.find(
1186
+ (device) => device?.serialNumber?.toUpperCase?.() === data?.serialNumber?.toUpperCase?.(),
1187
+ );
1409
1188
  data.nest_google_uuid = object_key;
1410
1189
  data.serialNumber = data.serialNumber.toUpperCase(); // ensure serial numbers are in upper case
1411
- data.excluded = this.config?.devices?.[data?.serialNumber]?.exclude === true; // Mark device as excluded or not
1412
- data.manufacturer = typeof data?.manufacturer === 'string' ? data.manufacturer : 'Nest';
1413
- data.softwareVersion =
1414
- typeof data?.softwareVersion === 'string' ? data.softwareVersion.replace(/([a-zA-Z]+)/g, '.').replace(/-/g, '.') : '0.0.0';
1190
+ data.excluded = this?.config?.options?.exclude === true ? deviceOptions?.exclude !== false : deviceOptions?.exclude === true;
1191
+ data.manufacturer = typeof data?.manufacturer === 'string' && data.manufacturer !== '' ? data.manufacturer : 'Nest';
1192
+ data.softwareVersion = process_software_version(data.softwareVersion);
1415
1193
  let description = typeof data?.description === 'string' ? data.description : '';
1416
1194
  let location = typeof data?.location === 'string' ? data.location : '';
1417
- if (description === '') {
1195
+ if (description === '' && location !== '') {
1418
1196
  description = location;
1419
1197
  location = '';
1420
1198
  }
1199
+ if (description === '' && location === '') {
1200
+ description = 'unknown description';
1201
+ }
1421
1202
  data.description = makeHomeKitName(location === '' ? description : description + ' - ' + location);
1422
1203
  delete data.location;
1423
1204
 
1424
1205
  // Insert HomeKit pairing code for when using HAP-NodeJS library rather than Homebridge
1425
1206
  // Validate the pairing code is in the format of "xxx-xx-xxx" or "xxxx-xxxx"
1426
1207
  if (
1427
- new RegExp(/^([0-9]{3}-[0-9]{2}-[0-9]{3})$/).test(this.config?.options?.hkPairingCode) === true ||
1428
- new RegExp(/^([0-9]{4}-[0-9]{4})$/).test(this.config?.options?.hkPairingCode) === true
1208
+ typeof this.config?.options?.hkPairingCode === 'string' &&
1209
+ (HomeKitDevice.HK_PIN_3_2_3.test(this.config.options.hkPairingCode) ||
1210
+ HomeKitDevice.HK_PIN_4_4.test(this.config.options.hkPairingCode))
1429
1211
  ) {
1430
1212
  data.hkPairingCode = this.config.options.hkPairingCode;
1431
- }
1432
- if (
1433
- new RegExp(/^([0-9]{3}-[0-9]{2}-[0-9]{3})$/).test(this.config?.devices?.[data.serialNumber]?.hkPairingCode) === true ||
1434
- new RegExp(/^([0-9]{4}-[0-9]{4})$/).test(this.config?.devices?.[data.serialNumber]?.hkPairingCode) === true
1213
+ } else if (
1214
+ typeof deviceOptions?.hkPairingCode === 'string' &&
1215
+ (HomeKitDevice.HK_PIN_3_2_3.test(deviceOptions.hkPairingCode) || HomeKitDevice.HK_PIN_4_4.test(deviceOptions.hkPairingCode))
1435
1216
  ) {
1436
- data.hkPairingCode = this.config.devices[data.serialNumber].hkPairingCode;
1217
+ data.hkPairingCode = deviceOptions.hkPairingCode;
1437
1218
  }
1438
1219
 
1439
1220
  // If we have a hkPairingCode defined, we need to generate a hkUsername also
1440
- if (data.hkPairingCode !== undefined) {
1221
+ if (data?.hkPairingCode !== undefined) {
1441
1222
  // Use a Nest Labs prefix for first 6 digits, followed by a CRC24 based off serial number for last 6 digits.
1442
1223
  data.hkUsername = ('18B430' + crc24(data.serialNumber.toUpperCase()))
1443
1224
  .toString('hex')
@@ -1459,14 +1240,19 @@ export default class NestAccfactory {
1459
1240
  let processed = {};
1460
1241
  try {
1461
1242
  // Fix up data we need to
1243
+ data.device_type = DeviceType.THERMOSTAT; // Nest Thermostat
1244
+
1245
+ // If we have hot water control, it should be a 'UK/EU' model, so add that after the 'gen' tag in the model name
1246
+ data.model = data.has_hot_water_control === true ? data.model.replace(/\bgen\)/, 'gen, EU)') : data.model;
1247
+
1462
1248
  data = process_common_data(object_key, data);
1463
- data.device_type = NestAccfactory.DeviceType.THERMOSTAT; // Nest Thermostat
1464
1249
  data.target_temperature_high = adjustTemperature(data.target_temperature_high, 'C', 'C', true);
1465
1250
  data.target_temperature_low = adjustTemperature(data.target_temperature_low, 'C', 'C', true);
1466
1251
  data.target_temperature = adjustTemperature(data.target_temperature, 'C', 'C', true);
1467
1252
  data.backplate_temperature = adjustTemperature(data.backplate_temperature, 'C', 'C', true);
1468
1253
  data.current_temperature = adjustTemperature(data.current_temperature, 'C', 'C', true);
1469
1254
  data.battery_level = scaleValue(data.battery_level, 3.6, 3.9, 0, 100);
1255
+
1470
1256
  processed = data;
1471
1257
  // eslint-disable-next-line no-unused-vars
1472
1258
  } catch (error) {
@@ -1476,6 +1262,10 @@ export default class NestAccfactory {
1476
1262
  };
1477
1263
 
1478
1264
  const PROTOBUF_THERMOSTAT_RESOURCES = [
1265
+ 'nest.resource.NestAmber1DisplayResource',
1266
+ 'nest.resource.NestAmber2DisplayResource',
1267
+ 'nest.resource.NestLearningThermostat1Resource',
1268
+ 'nest.resource.NestLearningThermostat2Resource',
1479
1269
  'nest.resource.NestLearningThermostat3Resource',
1480
1270
  'nest.resource.NestAgateDisplayResource',
1481
1271
  'nest.resource.NestOnyxResource',
@@ -1493,50 +1283,55 @@ export default class NestAccfactory {
1493
1283
  let tempDevice = {};
1494
1284
  try {
1495
1285
  if (
1496
- value?.source === NestAccfactory.DataSource.PROTOBUF &&
1497
- this.config.options?.protobufAPI === true &&
1286
+ value?.source === DATASOURCE.ProtobufAPI &&
1287
+ this.config.options?.useGoogleAPI === true &&
1498
1288
  value.value?.configuration_done?.deviceReady === true
1499
1289
  ) {
1500
1290
  let RESTTypeData = {};
1501
1291
  RESTTypeData.serialNumber = value.value.device_identity.serialNumber;
1502
- RESTTypeData.softwareVersion =
1503
- value.value.device_identity.softwareVersion.split(/\s+/)?.[3] !== undefined
1504
- ? value.value.device_identity.softwareVersion.split(/\s+/)?.[3]
1505
- : value.value.device_identity.softwareVersion;
1506
- RESTTypeData.model = 'Thermostat';
1507
- if (value.value.device_info.typeName === 'nest.resource.NestLearningThermostat3Resource') {
1292
+ RESTTypeData.softwareVersion = value.value.device_identity.softwareVersion;
1293
+ RESTTypeData.model = 'Thermostat (unknown)';
1294
+ if (value.value.device_info.typeName === 'nest.resource.NestLearningThermostat1Resource') {
1295
+ RESTTypeData.model = 'Learning Thermostat (1st gen)';
1296
+ }
1297
+ if (value.value.device_info.typeName === 'nest.resource.NestLearningThermostat2Resource') {
1298
+ RESTTypeData.model = 'Learning Thermostat (2nd gen)';
1299
+ }
1300
+ if (
1301
+ value.value.device_info.typeName === 'nest.resource.NestLearningThermostat3Resource' ||
1302
+ value.value.device_info.typeName === 'nest.resource.NestAmber2DisplayResource'
1303
+ ) {
1508
1304
  RESTTypeData.model = 'Learning Thermostat (3rd gen)';
1509
1305
  }
1510
1306
  if (value.value.device_info.typeName === 'google.resource.GoogleBismuth1Resource') {
1511
1307
  RESTTypeData.model = 'Learning Thermostat (4th gen)';
1512
1308
  }
1513
- if (value.value.device_info.typeName === 'nest.resource.NestAgateDisplayResource') {
1514
- RESTTypeData.model = 'Thermostat E';
1515
- }
1516
- if (value.value.device_info.typeName === 'nest.resource.NestOnyxResource') {
1309
+ if (
1310
+ value.value.device_info.typeName === 'nest.resource.NestOnyxResource' ||
1311
+ value.value.device_info.typeName === 'nest.resource.NestAgateDisplayResource'
1312
+ ) {
1517
1313
  RESTTypeData.model = 'Thermostat E (1st gen)';
1518
1314
  }
1519
1315
  if (value.value.device_info.typeName === 'google.resource.GoogleZirconium1Resource') {
1520
- RESTTypeData.model = 'Thermostat (2020 Model)';
1316
+ RESTTypeData.model = 'Thermostat (2020)';
1521
1317
  }
1522
1318
  RESTTypeData.current_humidity =
1523
- isNaN(value.value.current_humidity.humidityValue.humidity.value) === false
1319
+ isNaN(value.value?.current_humidity?.humidityValue?.humidity?.value) === false
1524
1320
  ? Number(value.value.current_humidity.humidityValue.humidity.value)
1525
1321
  : 0.0;
1526
- RESTTypeData.temperature_scale = value.value.display_settings.temperatureScale === 'TEMPERATURE_SCALE_F' ? 'F' : 'C';
1527
- RESTTypeData.removed_from_base = value.value.display.thermostatState.includes('bpd') === true;
1322
+ RESTTypeData.temperature_scale = value.value?.display_settings?.temperatureScale === 'TEMPERATURE_SCALE_F' ? 'F' : 'C';
1323
+ RESTTypeData.removed_from_base =
1324
+ Array.isArray(value.value?.display?.thermostatState) === true && value.value?.display.thermostatState.includes('bpd');
1528
1325
  RESTTypeData.backplate_temperature = parseFloat(value.value.backplate_temperature.temperatureValue.temperature.value);
1529
1326
  RESTTypeData.current_temperature = parseFloat(value.value.current_temperature.temperatureValue.temperature.value);
1530
1327
  RESTTypeData.battery_level = parseFloat(value.value.battery_voltage.batteryValue.batteryVoltage.value);
1531
1328
  RESTTypeData.online = value.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE';
1532
1329
  RESTTypeData.leaf = value.value?.leaf?.active === true;
1533
- RESTTypeData.has_humidifier = value.value.hvac_equipment_capabilities.hasHumidifier === true;
1534
- RESTTypeData.has_dehumidifier = value.value.hvac_equipment_capabilities.hasDehumidifier === true;
1330
+ RESTTypeData.has_humidifier = value.value?.hvac_equipment_capabilities?.hasHumidifier === true;
1331
+ RESTTypeData.has_dehumidifier = value.value?.hvac_equipment_capabilities?.hasDehumidifier === true;
1535
1332
  RESTTypeData.has_fan =
1536
- typeof value.value.fan_control_capabilities.maxAvailableSpeed === 'string' &&
1537
- value.value.fan_control_capabilities.maxAvailableSpeed !== 'FAN_SPEED_SETTING_OFF'
1538
- ? true
1539
- : false;
1333
+ typeof value.value?.fan_control_capabilities?.maxAvailableSpeed === 'string' &&
1334
+ value.value.fan_control_capabilities.maxAvailableSpeed !== 'FAN_SPEED_SETTING_OFF';
1540
1335
  RESTTypeData.can_cool =
1541
1336
  value.value?.hvac_equipment_capabilities?.hasStage1Cool === true ||
1542
1337
  value.value?.hvac_equipment_capabilities?.hasStage2Cool === true ||
@@ -1641,7 +1436,9 @@ export default class NestAccfactory {
1641
1436
  }
1642
1437
 
1643
1438
  // Update fan status, on or off and max number of speeds supported
1644
- RESTTypeData.fan_state = parseInt(value.value.fan_control_settings.timerEnd?.seconds) > 0 ? true : false;
1439
+ RESTTypeData.fan_state =
1440
+ isNaN(value.value?.fan_control_settings?.timerEnd?.seconds) === false &&
1441
+ Number(value.value.fan_control_settings.timerEnd.seconds) > 0;
1645
1442
  RESTTypeData.fan_timer_speed =
1646
1443
  value.value.fan_control_settings.timerSpeed.includes('FAN_SPEED_SETTING_STAGE') === true &&
1647
1444
  isNaN(value.value.fan_control_settings.timerSpeed.split('FAN_SPEED_SETTING_STAGE')[1]) === false
@@ -1662,31 +1459,23 @@ export default class NestAccfactory {
1662
1459
  RESTTypeData.has_air_filter = value.value.hvac_equipment_capabilities.hasAirFilter === true;
1663
1460
  RESTTypeData.filter_replacement_needed = value.value.filter_reminder.filterReplacementNeeded.value === true;
1664
1461
 
1462
+ // Hotwater details
1463
+ RESTTypeData.has_hot_water_control = value.value?.hvac_equipment_capabilities?.hasHotWaterControl === true;
1464
+ RESTTypeData.hot_water_active = value.value?.hot_water?.boilerActive === true;
1465
+ RESTTypeData.hot_water_boost_active =
1466
+ isNaN(value.value?.hot_water_settings?.boostTimerEnd?.seconds) === false &&
1467
+ Number(value.value.hot_water_settings.boostTimerEnd.seconds) > 0;
1468
+
1665
1469
  // Process any temperature sensors associated with this thermostat
1666
1470
  RESTTypeData.active_rcs_sensor =
1667
- value.value.remote_comfort_sensing_settings?.activeRcsSelection?.activeRcsSensor !== undefined
1471
+ value.value?.remote_comfort_sensing_settings?.activeRcsSelection?.activeRcsSensor !== undefined
1668
1472
  ? value.value.remote_comfort_sensing_settings.activeRcsSelection.activeRcsSensor.resourceId
1669
1473
  : '';
1670
- RESTTypeData.linked_rcs_sensors = [];
1671
- if (value.value?.remote_comfort_sensing_settings?.associatedRcsSensors !== undefined) {
1672
- value.value.remote_comfort_sensing_settings.associatedRcsSensors.forEach((sensor) => {
1673
- if (this.#rawData?.[sensor.deviceId.resourceId]?.value !== undefined) {
1674
- this.#rawData[sensor.deviceId.resourceId].value.associated_thermostat = object_key; // Sensor is linked to this thermostat
1675
-
1676
- // Get sensor online/offline status
1677
- // 'liveness' property doesn't appear in Protobuf data for temp sensors, so we'll add that object here
1678
- this.#rawData[sensor.deviceId.resourceId].value.liveness = {};
1679
- this.#rawData[sensor.deviceId.resourceId].value.liveness.status = 'LIVENESS_DEVICE_STATUS_UNSPECIFIED';
1680
- if (value.value?.remote_comfort_sensing_state?.rcsSensorStatuses !== undefined) {
1681
- Object.values(value.value.remote_comfort_sensing_state.rcsSensorStatuses).forEach((sensorStatus) => {
1682
- if (
1683
- sensorStatus?.sensorId?.resourceId === sensor.deviceId.resourceId &&
1684
- sensorStatus?.dataRecency?.includes('OK') === true
1685
- ) {
1686
- this.#rawData[sensor.deviceId.resourceId].value.liveness.status = 'LIVENESS_DEVICE_STATUS_ONLINE';
1687
- }
1688
- });
1689
- }
1474
+ RESTTypeData.linked_rcs_sensors = [];
1475
+ if (Array.isArray(value.value?.remote_comfort_sensing_settings?.associatedRcsSensors) === true) {
1476
+ value.value.remote_comfort_sensing_settings.associatedRcsSensors.forEach((sensor) => {
1477
+ if (typeof this.#rawData?.[sensor?.deviceId?.resourceId]?.value === 'object') {
1478
+ this.#rawData[sensor.deviceId.resourceId].value.associated_thermostat = object_key; // Sensor is linked to this thermostat
1690
1479
  }
1691
1480
 
1692
1481
  RESTTypeData.linked_rcs_sensors.push(sensor.deviceId.resourceId);
@@ -1694,7 +1483,7 @@ export default class NestAccfactory {
1694
1483
  }
1695
1484
 
1696
1485
  RESTTypeData.schedule_mode =
1697
- value.value?.target_temperature_settings?.targetTemperature?.setpointType !== undefined &&
1486
+ typeof value.value?.target_temperature_settings?.targetTemperature?.setpointType === 'string' &&
1698
1487
  value.value.target_temperature_settings.targetTemperature.setpointType.split('SET_POINT_TYPE_')[1].toLowerCase() !== 'off'
1699
1488
  ? value.value.target_temperature_settings.targetTemperature.setpointType.split('SET_POINT_TYPE_')[1].toLowerCase()
1700
1489
  : '';
@@ -1705,7 +1494,7 @@ export default class NestAccfactory {
1705
1494
  'SET_POINT_SCHEDULE_TYPE_' + RESTTypeData.schedule_mode.toUpperCase()
1706
1495
  ) {
1707
1496
  Object.values(value.value[RESTTypeData.schedule_mode + '_schedule_settings'].setpoints).forEach((schedule) => {
1708
- // Create REST API schedule entries
1497
+ // Create Nest API schedule entries
1709
1498
  const DAYSOFWEEK = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'];
1710
1499
  let dayofWeekIndex = DAYSOFWEEK.indexOf(schedule.dayOfWeek.split('DAY_OF_WEEK_')[1]);
1711
1500
 
@@ -1716,7 +1505,7 @@ export default class NestAccfactory {
1716
1505
  RESTTypeData.schedules[dayofWeekIndex][Object.entries(RESTTypeData.schedules[dayofWeekIndex]).length] = {
1717
1506
  'temp-min': adjustTemperature(schedule.heatingTarget.value, 'C', 'C', true),
1718
1507
  'temp-max': adjustTemperature(schedule.coolingTarget.value, 'C', 'C', true),
1719
- time: isNaN(schedule.secondsInDay) === false ? Number(schedule.secondsInDay) : 0,
1508
+ time: isNaN(schedule?.secondsInDay) === false ? Number(schedule.secondsInDay) : 0,
1720
1509
  type: RESTTypeData.schedule_mode.toUpperCase(),
1721
1510
  entry_type: 'setpoint',
1722
1511
  };
@@ -1726,26 +1515,22 @@ export default class NestAccfactory {
1726
1515
  tempDevice = process_thermostat_data(object_key, RESTTypeData);
1727
1516
  }
1728
1517
 
1729
- if (
1730
- value?.source === NestAccfactory.DataSource.REST &&
1731
- this.config.options?.restAPI === true &&
1732
- value.value?.where_id !== undefined
1733
- ) {
1518
+ if (value?.source === DATASOURCE.NestAPI && this.config.options?.useNestAPI === true && value.value?.where_id !== undefined) {
1734
1519
  let RESTTypeData = {};
1735
1520
  RESTTypeData.serialNumber = value.value.serial_number;
1736
1521
  RESTTypeData.softwareVersion = value.value.current_version;
1737
- RESTTypeData.model = 'Thermostat';
1522
+ RESTTypeData.model = 'Thermostat (unknown)';
1738
1523
  if (value.value.serial_number.substring(0, 2) === '15') {
1739
1524
  RESTTypeData.model = 'Thermostat E (1st gen)'; // Nest Thermostat E
1740
1525
  }
1741
- if (value.value.serial_number.substring(0, 2) === '09') {
1742
- RESTTypeData.model = 'Thermostat (3rd gen)'; // Nest Thermostat 3rd Gen
1526
+ if (value.value.serial_number.substring(0, 2) === '09' || value.value.serial_number.substring(0, 2) === '10') {
1527
+ RESTTypeData.model = 'Learning Thermostat (3rd gen)'; // Nest Thermostat 3rd gen
1743
1528
  }
1744
1529
  if (value.value.serial_number.substring(0, 2) === '02') {
1745
- RESTTypeData.model = 'Thermostat (2nd gen)'; // Nest Thermostat 2nd Gen
1530
+ RESTTypeData.model = 'Learning Thermostat (2nd gen)'; // Nest Thermostat 2nd gen
1746
1531
  }
1747
1532
  if (value.value.serial_number.substring(0, 2) === '01') {
1748
- RESTTypeData.model = 'Thermostat (1st gen)'; // Nest Thermostat 1st Gen
1533
+ RESTTypeData.model = 'Learning Thermostat (1st gen)'; // Nest Thermostat 1st gen
1749
1534
  }
1750
1535
  RESTTypeData.current_humidity = value.value.current_humidity;
1751
1536
  RESTTypeData.temperature_scale = value.value.temperature_scale;
@@ -1872,7 +1657,7 @@ export default class NestAccfactory {
1872
1657
  }
1873
1658
 
1874
1659
  // Update fan status, on or off
1875
- RESTTypeData.fan_state = value.value.fan_timer_timeout > 0 ? true : false;
1660
+ RESTTypeData.fan_state = isNaN(value.value.fan_timer_timeout) === false && Number(value.value.fan_timer_timeout) > 0;
1876
1661
  RESTTypeData.fan_timer_speed =
1877
1662
  value.value.fan_timer_speed.includes('stage') === true && isNaN(value.value.fan_timer_speed.split('stage')[1]) === false
1878
1663
  ? Number(value.value.fan_timer_speed.split('stage')[1])
@@ -1891,6 +1676,12 @@ export default class NestAccfactory {
1891
1676
  RESTTypeData.has_air_filter = value.value.has_air_filter === true;
1892
1677
  RESTTypeData.filter_replacement_needed = value.value.filter_replacement_needed === true;
1893
1678
 
1679
+ // Hotwater details
1680
+ RESTTypeData.has_hot_water_control = value.value.has_hot_water_control === true;
1681
+ RESTTypeData.hot_water_active = value.value?.hot_water_active === true;
1682
+ RESTTypeData.hot_water_boost_active =
1683
+ isNaN(value.value?.hot_water_boost_time_to_end) === false && Number(value.value.hot_water_boost_time_to_end) > 0;
1684
+
1894
1685
  // Process any temperature sensors associated with this thermostat
1895
1686
  RESTTypeData.active_rcs_sensor = '';
1896
1687
  RESTTypeData.linked_rcs_sensors = [];
@@ -1936,30 +1727,31 @@ export default class NestAccfactory {
1936
1727
  }
1937
1728
  // eslint-disable-next-line no-unused-vars
1938
1729
  } catch (error) {
1939
- this?.log?.debug && this.log.debug('Error processing data for thermostat(s)');
1730
+ this?.log?.debug?.('Error processing data for thermostat(s)');
1940
1731
  }
1941
1732
 
1942
1733
  if (Object.entries(tempDevice).length !== 0 && typeof devices[tempDevice.serialNumber] === 'undefined') {
1734
+ let deviceOptions = this.config?.devices?.find(
1735
+ (device) => device?.serialNumber?.toUpperCase?.() === tempDevice?.serialNumber?.toUpperCase?.(),
1736
+ );
1943
1737
  // Insert any extra options we've read in from configuration file for this device
1944
- tempDevice.eveHistory =
1945
- this.config.options.eveHistory === true || this.config?.devices?.[tempDevice.serialNumber]?.eveHistory === true;
1946
- tempDevice.humiditySensor = this.config?.devices?.[tempDevice.serialNumber]?.humiditySensor === true;
1738
+ tempDevice.eveHistory = this.config.options.eveHistory === true || deviceOptions?.eveHistory === true;
1739
+ tempDevice.humiditySensor = deviceOptions?.humiditySensor === true;
1947
1740
  tempDevice.externalCool =
1948
- typeof this.config?.devices?.[tempDevice.serialNumber]?.externalCool === 'string'
1949
- ? this.config.devices[tempDevice.serialNumber].externalCool
1950
- : undefined; // Config option for external cooling source
1741
+ typeof deviceOptions?.externalCool === 'string' && deviceOptions.externalCool !== '' ? deviceOptions.externalCool : undefined; // Config option for external cooling source
1951
1742
  tempDevice.externalHeat =
1952
- typeof this.config?.devices?.[tempDevice.serialNumber]?.externalHeat === 'string'
1953
- ? this.config.devices[tempDevice.serialNumber].externalHeat
1954
- : undefined; // Config option for external heating source
1743
+ typeof deviceOptions?.externalHeat === 'string' && deviceOptions.externalHeat !== '' ? deviceOptions.externalHeat : undefined; // Config option for external heating source
1955
1744
  tempDevice.externalFan =
1956
- typeof this.config?.devices?.[tempDevice.serialNumber]?.externalFan === 'string'
1957
- ? this.config.devices[tempDevice.serialNumber].externalFan
1958
- : undefined; // Config option for external fan source
1745
+ typeof deviceOptions?.externalFan === 'string' && deviceOptions.externalFan !== '' ? deviceOptions.externalFan : undefined; // Config option for external fan source
1959
1746
  tempDevice.externalDehumidifier =
1960
- typeof this.config?.devices?.[tempDevice.serialNumber]?.externalDehumidifier === 'string'
1961
- ? this.config.devices[tempDevice.serialNumber].externalDehumidifier
1747
+ typeof deviceOptions?.externalDehumidifier === 'string' && deviceOptions.externalDehumidifier !== ''
1748
+ ? deviceOptions.externalDehumidifier
1962
1749
  : undefined; // Config option for external dehumidifier source
1750
+ tempDevice.hotWaterBoostTime = parseDurationToSeconds(deviceOptions?.hotWaterBoostTime, {
1751
+ defaultValue: 30 * 60, // 30mins
1752
+ min: 60, // 1min
1753
+ max: 7200, // 2hrs
1754
+ });
1963
1755
  devices[tempDevice.serialNumber] = tempDevice; // Store processed device
1964
1756
  }
1965
1757
  });
@@ -1971,9 +1763,10 @@ export default class NestAccfactory {
1971
1763
  let processed = {};
1972
1764
  try {
1973
1765
  // Fix up data we need to
1974
- data = process_common_data(object_key, data);
1975
- data.device_type = NestAccfactory.DeviceType.TEMPSENSOR; // Nest Temperature sensor
1766
+ data.device_type = DeviceType.TEMPSENSOR; // Nest Temperature sensor
1976
1767
  data.model = 'Temperature Sensor';
1768
+ data.softwareVersion = '1.0.0';
1769
+ data = process_common_data(object_key, data);
1977
1770
  data.current_temperature = adjustTemperature(data.current_temperature, 'C', 'C', true);
1978
1771
  processed = data;
1979
1772
  // eslint-disable-next-line no-unused-vars
@@ -1994,8 +1787,8 @@ export default class NestAccfactory {
1994
1787
  let tempDevice = {};
1995
1788
  try {
1996
1789
  if (
1997
- value?.source === NestAccfactory.DataSource.PROTOBUF &&
1998
- this.config.options?.protobufAPI === true &&
1790
+ value?.source === DATASOURCE.ProtobufAPI &&
1791
+ this.config.options?.useGoogleAPI === true &&
1999
1792
  value.value?.configuration_done?.deviceReady === true &&
2000
1793
  typeof value?.value?.associated_thermostat === 'string' &&
2001
1794
  value?.value?.associated_thermostat !== ''
@@ -2005,8 +1798,9 @@ export default class NestAccfactory {
2005
1798
  // Guessing battery minimum voltage is 2v??
2006
1799
  RESTTypeData.battery_level = scaleValue(Number(value.value.battery.assessedVoltage.value), 2.0, 3.0, 0, 100);
2007
1800
  RESTTypeData.current_temperature = value.value.current_temperature.temperatureValue.temperature.value;
2008
- // Online status we 'faked' when processing Thermostat Protobuf data
2009
- RESTTypeData.online = value.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE';
1801
+ RESTTypeData.online =
1802
+ isNaN(value.value?.last_updated_beacon?.lastBeaconTime?.seconds) === false &&
1803
+ Math.floor(Date.now() / 1000) - Number(value.value.last_updated_beacon.lastBeaconTime.seconds) < 3600 * 4;
2010
1804
  RESTTypeData.associated_thermostat = value.value.associated_thermostat;
2011
1805
  RESTTypeData.description = typeof value.value?.label?.label === 'string' ? value.value.label.label : '';
2012
1806
  RESTTypeData.location = get_location_name(
@@ -2019,8 +1813,8 @@ export default class NestAccfactory {
2019
1813
  tempDevice = process_kryptonite_data(object_key, RESTTypeData);
2020
1814
  }
2021
1815
  if (
2022
- value?.source === NestAccfactory.DataSource.REST &&
2023
- this.config.options?.restAPI === true &&
1816
+ value?.source === DATASOURCE.NestAPI &&
1817
+ this.config.options?.useNestAPI === true &&
2024
1818
  value.value?.where_id !== undefined &&
2025
1819
  value.value?.structure_id !== undefined &&
2026
1820
  typeof value?.value?.associated_thermostat === 'string' &&
@@ -2030,7 +1824,7 @@ export default class NestAccfactory {
2030
1824
  RESTTypeData.serialNumber = value.value.serial_number;
2031
1825
  RESTTypeData.battery_level = scaleValue(Number(value.value.battery_level), 0, 100, 0, 100);
2032
1826
  RESTTypeData.current_temperature = value.value.current_temperature;
2033
- RESTTypeData.online = Math.floor(Date.now() / 1000) - value.value.last_updated_at < 3600 * 4 ? true : false;
1827
+ RESTTypeData.online = Math.floor(Date.now() / 1000) - value.value.last_updated_at < 3600 * 4;
2034
1828
  RESTTypeData.associated_thermostat = value.value.associated_thermostat;
2035
1829
  RESTTypeData.description = value.value.description;
2036
1830
  RESTTypeData.location = get_location_name(value.value.structure_id, value.value.where_id);
@@ -2041,36 +1835,88 @@ export default class NestAccfactory {
2041
1835
  }
2042
1836
  // eslint-disable-next-line no-unused-vars
2043
1837
  } catch (error) {
2044
- this?.log?.debug && this.log.debug('Error processing data for temperature sensor(s)');
1838
+ this?.log?.debug?.('Error processing data for temperature sensor(s)');
2045
1839
  }
2046
1840
  if (Object.entries(tempDevice).length !== 0 && typeof devices[tempDevice.serialNumber] === 'undefined') {
1841
+ let deviceOptions = this.config?.devices?.find(
1842
+ (device) => device?.serialNumber?.toUpperCase?.() === tempDevice?.serialNumber?.toUpperCase?.(),
1843
+ );
2047
1844
  // Insert any extra options we've read in from configuration file for this device
2048
- tempDevice.eveHistory =
2049
- this.config.options.eveHistory === true || this.config?.devices?.[tempDevice.serialNumber]?.eveHistory === true;
1845
+ tempDevice.eveHistory = this.config.options.eveHistory === true || deviceOptions?.eveHistory === true;
2050
1846
  devices[tempDevice.serialNumber] = tempDevice; // Store processed device
2051
1847
  }
2052
1848
  });
2053
1849
 
2054
- // Process data for any smoke detectors we have in the raw data
2055
- const process_protect_data = (object_key, data) => {
1850
+ // Process data for any heatlink devices we have in the raw data
1851
+ const process_heatlink_data = (object_key, data) => {
2056
1852
  let processed = {};
2057
1853
  try {
2058
1854
  // Fix up data we need to
1855
+ data.device_type = DeviceType.HEATLINK;
2059
1856
  data = process_common_data(object_key, data);
2060
- data.device_type = NestAccfactory.DeviceType.SMOKESENSOR; // Nest Protect
2061
- data.model = 'Protect';
2062
- if (data.wired_or_battery === 0) {
2063
- data.model = data.model + ' (wired'; // Mains powered
2064
- }
2065
- if (data.wired_or_battery === 1) {
2066
- data.model = data.model + ' (battery'; // Battery powered
2067
- }
2068
- if (data.serialNumber.substring(0, 2) === '06') {
2069
- data.model = data.model + ', 2nd gen)'; // Nest Protect 2nd Gen
1857
+ data.current_temperature = adjustTemperature(data.current_temperature, 'C', 'C', true);
1858
+ processed = data;
1859
+ // eslint-disable-next-line no-unused-vars
1860
+ } catch (error) {
1861
+ // Empty
1862
+ }
1863
+ return processed;
1864
+ };
1865
+
1866
+ Object.entries(this.#rawData)
1867
+ .filter(
1868
+ ([key, value]) =>
1869
+ key.startsWith('DEVICE_') === true &&
1870
+ value.value?.device_info?.typeName === 'nest.resource.NestAgateHeatlinkResource' &&
1871
+ (deviceUUID === '' || deviceUUID === key),
1872
+ )
1873
+ .forEach(([object_key, value]) => {
1874
+ let tempDevice = {};
1875
+ try {
1876
+ if (
1877
+ value?.source === DATASOURCE.ProtobufAPI &&
1878
+ this.config.options?.useGoogleAPI === true &&
1879
+ value.value?.configuration_done?.deviceReady === true
1880
+ ) {
1881
+ let RESTTypeData = {};
1882
+ RESTTypeData.serialNumber = value.value.device_identity.serialNumber;
1883
+ RESTTypeData.softwareVersion = value.value.device_identity.softwareVersion;
1884
+ RESTTypeData.model = 'Heatlink (unknown)';
1885
+ if (value.value.device_info.typeName === 'nest.resource.NestAgateHeatlinkResource') {
1886
+ RESTTypeData.model = 'Heatlink';
1887
+ }
1888
+ RESTTypeData.battery_level = 100; // Not sure what it is
1889
+ RESTTypeData.current_temperature = value.value.temperature.temperatureValue.temperature.value;
1890
+ RESTTypeData.online = value.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE';
1891
+ RESTTypeData.description = typeof value.value?.label?.label === 'string' ? value.value.label.label : '';
1892
+ RESTTypeData.location = get_location_name(
1893
+ value.value?.device_info?.pairerId?.resourceId,
1894
+ value.value?.device_located_settings?.whereAnnotationRid?.resourceId,
1895
+ );
1896
+ RESTTypeData.active_sensor = true; // This should be active always?
1897
+ tempDevice = process_heatlink_data(object_key, RESTTypeData);
1898
+ }
1899
+ // eslint-disable-next-line no-unused-vars
1900
+ } catch (error) {
1901
+ this?.log?.debug?.('Error processing data for heatlink(s)');
2070
1902
  }
2071
- if (data.serialNumber.substring(0, 2) === '05') {
2072
- data.model = data.model + ', 1st gen)'; // Nest Protect 1st Gen
1903
+ if (Object.entries(tempDevice).length !== 0 && typeof devices[tempDevice.serialNumber] === 'undefined') {
1904
+ let deviceOptions = this.config?.devices?.find(
1905
+ (device) => device?.serialNumber?.toUpperCase?.() === tempDevice?.serialNumber?.toUpperCase?.(),
1906
+ );
1907
+ // Insert any extra options we've read in from configuration file for this device
1908
+ tempDevice.eveHistory = this.config.options.eveHistory === true || deviceOptions?.eveHistory === true;
1909
+ devices[tempDevice.serialNumber] = tempDevice; // Store processed device
2073
1910
  }
1911
+ });
1912
+
1913
+ // Process data for any smoke detectors we have in the raw data
1914
+ const process_protect_data = (object_key, data) => {
1915
+ let processed = {};
1916
+ try {
1917
+ // Fix up data we need to
1918
+ data.device_type = DeviceType.SMOKESENSOR; // Nest Protect
1919
+ data = process_common_data(object_key, data);
2074
1920
  processed = data;
2075
1921
  // eslint-disable-next-line no-unused-vars
2076
1922
  } catch (error) {
@@ -2079,27 +1925,44 @@ export default class NestAccfactory {
2079
1925
  return processed;
2080
1926
  };
2081
1927
 
1928
+ const PROTOBUF_PROTECT_RESOURCES = [
1929
+ 'nest.resource.NestProtect1LinePoweredResource',
1930
+ 'nest.resource.NestProtect1BatteryPoweredResource',
1931
+ 'nest.resource.NestProtect2LinePoweredResource',
1932
+ 'nest.resource.NestProtect2BatteryPoweredResource',
1933
+ 'NestProtect2Resource',
1934
+ ];
2082
1935
  Object.entries(this.#rawData)
2083
1936
  .filter(
2084
1937
  ([key, value]) =>
2085
1938
  (key.startsWith('topaz.') === true ||
2086
- (key.startsWith('DEVICE_') === true && value.value?.device_info?.className.startsWith('topaz') === true)) &&
1939
+ (key.startsWith('DEVICE_') === true && PROTOBUF_PROTECT_RESOURCES.includes(value.value?.device_info?.typeName) === true)) &&
2087
1940
  (deviceUUID === '' || deviceUUID === key),
2088
1941
  )
2089
1942
  .forEach(([object_key, value]) => {
2090
1943
  let tempDevice = {};
2091
1944
  try {
2092
1945
  if (
2093
- value?.source === NestAccfactory.DataSource.PROTOBUF &&
2094
- this.config.options?.protobufAPI === true &&
1946
+ value?.source === DATASOURCE.ProtobufAPI &&
1947
+ this.config.options?.useGoogleAPI === true &&
2095
1948
  value.value?.configuration_done?.deviceReady === true
2096
1949
  ) {
2097
1950
  let RESTTypeData = {};
2098
1951
  RESTTypeData.serialNumber = value.value.device_identity.serialNumber;
2099
- RESTTypeData.softwareVersion =
2100
- value.value.device_identity.softwareVersion.split(/\s+/)?.[3] !== undefined
2101
- ? value.value.device_identity.softwareVersion.split(/\s+/)?.[3]
2102
- : value.value.device_identity.softwareVersion;
1952
+ RESTTypeData.softwareVersion = value.value.device_identity.softwareVersion;
1953
+ RESTTypeData.model = 'Protect (unknown)';
1954
+ if (value.value.device_info.typeName === 'nest.resource.NestProtect1LinePoweredResource') {
1955
+ RESTTypeData.model = 'Protect (1st gen, wired)';
1956
+ }
1957
+ if (value.value.device_info.typeName === 'nest.resource.NestProtect1BatteryPoweredResource') {
1958
+ RESTTypeData.model = 'Protect (1st gen, battery)';
1959
+ }
1960
+ if (value.value.device_info.typeName === 'nest.resource.NestProtect2LinePoweredResource') {
1961
+ RESTTypeData.model = 'Protect (2nd gen, wired)';
1962
+ }
1963
+ if (value.value.device_info.typeName === 'nest.resource.NestProtect2BatteryPoweredResource') {
1964
+ RESTTypeData.model = 'Protect (2nd gen, battery)';
1965
+ }
2103
1966
  RESTTypeData.online = value.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE';
2104
1967
  RESTTypeData.line_power_present = value.value?.wall_power?.status === 'POWER_SOURCE_STATUS_ACTIVE';
2105
1968
  RESTTypeData.wired_or_battery = typeof value.value?.wall_power?.status === 'string' ? 0 : 1;
@@ -2114,7 +1977,7 @@ export default class NestAccfactory {
2114
1977
  : 1;
2115
1978
  RESTTypeData.smoke_status = value.value?.safety_alarm_smoke?.alarmState === 'ALARM_STATE_ALARM';
2116
1979
  RESTTypeData.co_status = value.value?.safety_alarm_co?.alarmState === 'ALARM_STATE_ALARM';
2117
- RESTTypeData.heat_status = false; // To find in protobuf
1980
+ RESTTypeData.heat_status = false; // TODO <- need to find in protobuf
2118
1981
  RESTTypeData.hushed_state =
2119
1982
  value.value?.safety_alarm_smoke?.silenceState === 'SILENCE_STATE_SILENCED' ||
2120
1983
  value.value?.safety_alarm_co?.silenceState === 'SILENCE_STATE_SILENCED';
@@ -2128,12 +1991,12 @@ export default class NestAccfactory {
2128
1991
  ? value.value.safety_summary?.warningDevices?.failures.includes('FAILURE_TYPE_TEMP') === false
2129
1992
  : true;
2130
1993
  RESTTypeData.latest_alarm_test =
2131
- isNaN(value.value.self_test?.lastMstEnd?.seconds) === false ? Number(value.value.self_test.lastMstEnd.seconds) : 0;
1994
+ isNaN(value.value?.self_test?.lastMstEnd?.seconds) === false ? Number(value.value.self_test.lastMstEnd.seconds) : 0;
2132
1995
  RESTTypeData.self_test_in_progress =
2133
1996
  value.value?.legacy_structure_self_test?.mstInProgress === true ||
2134
1997
  value.value?.legacy_structure_self_test?.astInProgress === true;
2135
1998
  RESTTypeData.replacement_date =
2136
- isNaN(value.value.legacy_protect_device_settings?.replaceByDate?.seconds) === false
1999
+ isNaN(value.value?.legacy_protect_device_settings?.replaceByDate?.seconds) === false
2137
2000
  ? Number(value.value.legacy_protect_device_settings.replaceByDate.seconds)
2138
2001
  : 0;
2139
2002
  RESTTypeData.topaz_hush_key =
@@ -2149,14 +2012,27 @@ export default class NestAccfactory {
2149
2012
  tempDevice = process_protect_data(object_key, RESTTypeData);
2150
2013
  }
2151
2014
  if (
2152
- value?.source === NestAccfactory.DataSource.REST &&
2153
- this.config.options?.restAPI === true &&
2015
+ value?.source === DATASOURCE.NestAPI &&
2016
+ this.config.options?.useNestAPI === true &&
2154
2017
  value.value?.where_id !== undefined &&
2155
2018
  value.value?.structure_id !== undefined
2156
2019
  ) {
2157
2020
  let RESTTypeData = {};
2158
2021
  RESTTypeData.serialNumber = value.value.serial_number;
2159
2022
  RESTTypeData.softwareVersion = value.value.software_version;
2023
+ RESTTypeData.model = 'Protect (unknown)';
2024
+ if (RESTTypeData.serialNumber.substring(0, 2) === '06') {
2025
+ RESTTypeData.model = 'Protect (2nd gen)'; // Nest Protect 2nd gen
2026
+ }
2027
+ if (RESTTypeData.serialNumber.substring(0, 2) === '05') {
2028
+ RESTTypeData.model = 'Protect (1st gen)'; // Nest Protect 1st gen
2029
+ }
2030
+ RESTTypeData.model =
2031
+ value.value.wired_or_battery === 1
2032
+ ? RESTTypeData.model.replace(/\bgen\)/, 'gen, battery)')
2033
+ : value.value.wired_or_battery === 0
2034
+ ? RESTTypeData.model.replace(/\bgen\)/, 'gen, wired)')
2035
+ : RESTTypeData.model;
2160
2036
  RESTTypeData.online =
2161
2037
  typeof value?.value?.thread_mac_address === 'string'
2162
2038
  ? this.#rawData?.['widget_track.' + value?.value?.thread_mac_address.toUpperCase()]?.value?.online === true
@@ -2187,13 +2063,15 @@ export default class NestAccfactory {
2187
2063
  }
2188
2064
  // eslint-disable-next-line no-unused-vars
2189
2065
  } catch (error) {
2190
- this?.log?.debug && this.log.debug('Error processing data for smoke sensor(s)');
2066
+ this?.log?.debug?.('Error processing data for smoke sensor(s)');
2191
2067
  }
2192
2068
 
2193
2069
  if (Object.entries(tempDevice).length !== 0 && typeof devices[tempDevice.serialNumber] === 'undefined') {
2070
+ let deviceOptions = this.config?.devices?.find(
2071
+ (device) => device?.serialNumber?.toUpperCase?.() === tempDevice?.serialNumber?.toUpperCase?.(),
2072
+ );
2194
2073
  // Insert any extra options we've read in from configuration file for this device
2195
- tempDevice.eveHistory =
2196
- this.config.options.eveHistory === true || this.config?.devices?.[tempDevice.serialNumber]?.eveHistory === true;
2074
+ tempDevice.eveHistory = this.config.options.eveHistory === true || deviceOptions?.eveHistory === true;
2197
2075
  devices[tempDevice.serialNumber] = tempDevice; // Store processed device
2198
2076
  }
2199
2077
  });
@@ -2203,14 +2081,14 @@ export default class NestAccfactory {
2203
2081
  let processed = {};
2204
2082
  try {
2205
2083
  // Fix up data we need to
2206
- data = process_common_data(object_key, data);
2207
- data.device_type = NestAccfactory.DeviceType.CAMERA;
2084
+ data.device_type = DeviceType.CAMERA;
2208
2085
  if (data.model.toUpperCase().includes('DOORBELL') === true) {
2209
- data.device_type = NestAccfactory.DeviceType.DOORBELL;
2086
+ data.device_type = DeviceType.DOORBELL;
2210
2087
  }
2211
2088
  if (data.model.toUpperCase().includes('FLOODLIGHT') === true) {
2212
- data.device_type = NestAccfactory.DeviceType.FLOODLIGHT;
2089
+ data.device_type = DeviceType.FLOODLIGHT;
2213
2090
  }
2091
+ data = process_common_data(object_key, data);
2214
2092
  processed = data;
2215
2093
  // eslint-disable-next-line no-unused-vars
2216
2094
  } catch (error) {
@@ -2242,8 +2120,8 @@ export default class NestAccfactory {
2242
2120
  let tempDevice = {};
2243
2121
  try {
2244
2122
  if (
2245
- value?.source === NestAccfactory.DataSource.PROTOBUF &&
2246
- this.config.options?.protobufAPI === true &&
2123
+ value?.source === DATASOURCE.ProtobufAPI &&
2124
+ this.config.options?.useGoogleAPI === true &&
2247
2125
  Array.isArray(value.value?.streaming_protocol?.supportedProtocols) === true &&
2248
2126
  value.value.streaming_protocol.supportedProtocols.includes('PROTOCOL_WEBRTC') === true &&
2249
2127
  (value.value?.configuration_done?.deviceReady === true ||
@@ -2251,15 +2129,8 @@ export default class NestAccfactory {
2251
2129
  ) {
2252
2130
  let RESTTypeData = {};
2253
2131
  RESTTypeData.serialNumber = value.value.device_identity.serialNumber;
2254
-
2255
- // Protobuf camera firmware versions appear in alonger string. Seems the '4th' word in the string is the actual number version
2256
- // ie: rquartz-user 1 OPENMASTER 507800056 test-keys stable-channel stable-channel
2257
- // ie: nq-user 1.73 OPENMASTER 422270 release-keys stable-channel stable-channel
2258
- RESTTypeData.softwareVersion =
2259
- value.value.device_identity.softwareVersion.split(/\s+/)?.[3] !== undefined
2260
- ? value.value.device_identity.softwareVersion.split(/\s+/)?.[3]
2261
- : value.value.device_identity.softwareVersion;
2262
- RESTTypeData.model = 'Camera';
2132
+ RESTTypeData.softwareVersion = value.value.device_identity.softwareVersion;
2133
+ RESTTypeData.model = 'Camera (unknown)';
2263
2134
  if (
2264
2135
  value.value.device_info.typeName === 'google.resource.NeonQuartzResource' &&
2265
2136
  value.value?.floodlight_settings === undefined &&
@@ -2268,13 +2139,13 @@ export default class NestAccfactory {
2268
2139
  RESTTypeData.model = 'Cam (battery)';
2269
2140
  }
2270
2141
  if (value.value.device_info.typeName === 'google.resource.GreenQuartzResource') {
2271
- RESTTypeData.model = 'Doorbell (battery)';
2142
+ RESTTypeData.model = 'Doorbell (2nd gen, battery)';
2272
2143
  }
2273
2144
  if (value.value.device_info.typeName === 'google.resource.SpencerResource') {
2274
2145
  RESTTypeData.model = 'Cam (wired)';
2275
2146
  }
2276
2147
  if (value.value.device_info.typeName === 'google.resource.VenusResource') {
2277
- RESTTypeData.model = 'Doorbell (wired, 2nd gen)';
2148
+ RESTTypeData.model = 'Doorbell (2nd gen, wired)';
2278
2149
  }
2279
2150
  if (value.value.device_info.typeName === 'nest.resource.NestCamIndoorResource') {
2280
2151
  RESTTypeData.model = 'Cam Indoor (1st gen)';
@@ -2286,14 +2157,14 @@ export default class NestAccfactory {
2286
2157
  RESTTypeData.model = 'Cam Outdoor (1st gen)';
2287
2158
  }
2288
2159
  if (value.value.device_info.typeName === 'nest.resource.NestHelloResource') {
2289
- RESTTypeData.model = 'Doorbell (wired, 1st gen)';
2160
+ RESTTypeData.model = 'Doorbell (1st gen, wired)';
2290
2161
  }
2291
2162
  if (
2292
2163
  value.value.device_info.typeName === 'google.resource.NeonQuartzResource' &&
2293
2164
  value.value?.floodlight_settings !== undefined &&
2294
2165
  value.value?.floodlight_state !== undefined
2295
2166
  ) {
2296
- RESTTypeData.model = 'Cam with Floodlight (wired)';
2167
+ RESTTypeData.model = 'Cam with Floodlight';
2297
2168
  }
2298
2169
 
2299
2170
  RESTTypeData.online = value.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE';
@@ -2308,6 +2179,7 @@ export default class NestAccfactory {
2308
2179
  value.value?.doorbell_indoor_chime_settings?.chimeType === 'CHIME_TYPE_ELECTRONIC';
2309
2180
  RESTTypeData.indoor_chime_enabled = value.value?.doorbell_indoor_chime_settings?.chimeEnabled === true;
2310
2181
  RESTTypeData.streaming_enabled = value.value?.recording_toggle?.currentCameraState === 'CAMERA_ON';
2182
+ // Still need to find below in protobuf
2311
2183
  //RESTTypeData.has_irled =
2312
2184
  //RESTTypeData.irled_enabled =
2313
2185
  //RESTTypeData.has_statusled =
@@ -2328,8 +2200,9 @@ export default class NestAccfactory {
2328
2200
  }
2329
2201
  RESTTypeData.alerts = typeof value.value?.alerts === 'object' ? value.value.alerts : [];
2330
2202
  RESTTypeData.quiet_time_enabled =
2331
- parseInt(value.value?.quiet_time_settings?.quietTimeEnds?.seconds) !== 0 &&
2332
- Math.floor(Date.now() / 1000) < parseInt(value.value?.quiet_time_settings?.quietTimeEnds?.second);
2203
+ isNaN(value.value?.quiet_time_settings?.quietTimeEnds?.seconds) === false &&
2204
+ Number(value.value.quiet_time_settings.quietTimeEnds.seconds) !== 0 &&
2205
+ Math.floor(Date.now() / 1000) < Number(value.value.quiet_time_settings.quietTimeEnds.second);
2333
2206
  RESTTypeData.camera_type = value.value.device_identity.vendorProductId;
2334
2207
  RESTTypeData.streaming_protocols =
2335
2208
  value.value?.streaming_protocol?.supportedProtocols !== undefined ? value.value.streaming_protocol.supportedProtocols : [];
@@ -2340,7 +2213,7 @@ export default class NestAccfactory {
2340
2213
  RESTTypeData.has_light = value.value?.floodlight_settings !== undefined && value.value?.floodlight_state !== undefined;
2341
2214
  RESTTypeData.light_enabled = value.value?.floodlight_state?.currentState === 'LIGHT_STATE_ON';
2342
2215
  RESTTypeData.light_brightness =
2343
- value.value?.floodlight_settings?.brightness !== undefined
2216
+ isNaN(value.value?.floodlight_settings?.brightness) === false
2344
2217
  ? scaleValue(Number(value.value.floodlight_settings.brightness), 0, 10, 0, 100)
2345
2218
  : 0;
2346
2219
 
@@ -2357,15 +2230,15 @@ export default class NestAccfactory {
2357
2230
  }
2358
2231
 
2359
2232
  if (
2360
- value?.source === NestAccfactory.DataSource.REST &&
2361
- this.config.options?.restAPI === true &&
2233
+ value?.source === DATASOURCE.NestAPI &&
2234
+ this.config.options?.useNestAPI === true &&
2362
2235
  value.value?.where_id !== undefined &&
2363
2236
  value.value?.structure_id !== undefined &&
2364
2237
  value.value?.nexus_api_http_server_url !== undefined &&
2365
2238
  (value.value?.properties?.['cc2migration.overview_state'] === 'NORMAL' ||
2366
2239
  value.value?.properties?.['cc2migration.overview_state'] === 'REVERSE_MIGRATION_IN_PROGRESS')
2367
2240
  ) {
2368
- // We'll only use the REST API data for Camera's which have NOT been migrated to Google Home
2241
+ // We'll only use the Nest API data for Camera's which have NOT been migrated to Google Home
2369
2242
  let RESTTypeData = {};
2370
2243
  RESTTypeData.serialNumber = value.value.serial_number;
2371
2244
  RESTTypeData.softwareVersion = value.value.software_version;
@@ -2385,7 +2258,7 @@ export default class NestAccfactory {
2385
2258
  RESTTypeData.video_flipped = value.value?.properties['video.flipped'] === true;
2386
2259
  RESTTypeData.statusled_brightness =
2387
2260
  isNaN(value.value?.properties?.['statusled.brightness']) === false
2388
- ? Number(value.value?.properties['statusled.brightness'])
2261
+ ? Number(value.value.properties['statusled.brightness'])
2389
2262
  : 0;
2390
2263
  RESTTypeData.has_microphone = value.value?.capabilities.includes('audio.microphone') === true;
2391
2264
  RESTTypeData.has_speaker = value.value?.capabilities.includes('audio.speaker') === true;
@@ -2409,55 +2282,43 @@ export default class NestAccfactory {
2409
2282
  }
2410
2283
  // eslint-disable-next-line no-unused-vars
2411
2284
  } catch (error) {
2412
- this?.log?.debug && this.log.debug('Error processing data for camera/doorbell(s)');
2285
+ this?.log?.debug?.('Error processing data for camera/doorbell(s)');
2413
2286
  }
2414
2287
 
2415
2288
  if (Object.entries(tempDevice).length !== 0 && typeof devices[tempDevice.serialNumber] === 'undefined') {
2289
+ let deviceOptions = this.config?.devices?.find(
2290
+ (device) => device?.serialNumber?.toUpperCase?.() === tempDevice?.serialNumber?.toUpperCase?.(),
2291
+ );
2416
2292
  // Insert any extra options we've read in from configuration file for this device
2417
- tempDevice.eveHistory =
2418
- this.config.options.eveHistory === true || this.config?.devices?.[tempDevice.serialNumber]?.eveHistory === true;
2419
- tempDevice.hksv = this.config.options.hksv === true || this.config?.devices?.[tempDevice.serialNumber]?.hksv === true;
2420
- tempDevice.doorbellCooldown =
2421
- isNaN(this.config?.devices?.[tempDevice.serialNumber]?.doorbellCooldown) === false
2422
- ? Number(this.config.devices[tempDevice.serialNumber].doorbellCooldown)
2423
- : 60;
2424
- tempDevice.motionCooldown =
2425
- isNaN(this.config?.devices?.[tempDevice.serialNumber]?.motionCooldown) === false
2426
- ? Number(this.config.devices[tempDevice.serialNumber].motionCooldown)
2427
- : 60;
2428
- tempDevice.personCooldown =
2429
- isNaN(this.config?.devices?.[tempDevice.serialNumber]?.personCooldown) === false
2430
- ? Number(this.config.devices[tempDevice.serialNumber].personCooldown)
2431
- : 120;
2432
- tempDevice.chimeSwitch = this.config?.devices?.[tempDevice.serialNumber]?.chimeSwitch === true; // Control 'indoor' chime by switch
2433
- tempDevice.localAccess = this.config?.devices?.[tempDevice.serialNumber]?.localAccess === true; // Local network video streaming rather than from cloud from camera/doorbells
2293
+ tempDevice.eveHistory = this.config.options.eveHistory === true || deviceOptions?.eveHistory === true;
2294
+ tempDevice.hksv = this.config.options.hksv === true || deviceOptions?.hksv === true;
2295
+ tempDevice.doorbellCooldown = parseDurationToSeconds(deviceOptions?.doorbellCooldown, { defaultValue: 60, min: 0, max: 300 });
2296
+ tempDevice.motionCooldown = parseDurationToSeconds(deviceOptions?.motionCooldown, { defaultValue: 60, min: 0, max: 300 });
2297
+ tempDevice.personCooldown = parseDurationToSeconds(deviceOptions?.personCooldown, { defaultValue: 120, min: 0, max: 300 });
2298
+ tempDevice.chimeSwitch = deviceOptions?.chimeSwitch === true; // Control 'indoor' chime by switch
2299
+ tempDevice.localAccess = deviceOptions?.localAccess === true; // Local network video streaming rather than from cloud from camera/doorbells
2434
2300
  // eslint-disable-next-line no-undef
2435
2301
  tempDevice.ffmpeg = structuredClone(this.config.options.ffmpeg); // ffmpeg details, path, libraries. No ffmpeg = undefined
2436
- if (this.config?.devices?.[tempDevice.serialNumber]?.ffmpegDebug !== undefined) {
2302
+ if (deviceOptions?.ffmpegDebug !== undefined) {
2437
2303
  // Device specific ffmpeg debugging
2438
- tempDevice.ffmpeg.debug = this.config?.devices?.[tempDevice.serialNumber]?.ffmpegDebug === true;
2304
+ tempDevice.ffmpeg.debug = deviceOptions?.ffmpegDebug === true;
2439
2305
  }
2440
- tempDevice.maxStreams =
2441
- isNaN(this.config.options?.maxStreams) === false
2442
- ? Number(this.config.options.maxStreams)
2443
- : this.deviceData.hksv === true
2444
- ? 1
2445
- : 2;
2446
-
2306
+ tempDevice.maxStreams = this.config.options.hksv === true || deviceOptions?.hksv === true ? 1 : 2;
2447
2307
  devices[tempDevice.serialNumber] = tempDevice; // Store processed device
2448
2308
  }
2449
2309
  });
2450
2310
 
2451
- // Process data for any structure(s) for both REST and Protobuf API data
2311
+ // Process data for any structure(s) for both Nest and Protobuf API data
2452
2312
  // We use this to created virtual weather station(s) for each structure that has location data
2453
2313
  const process_structure_data = (object_key, data) => {
2454
2314
  let processed = {};
2455
2315
  try {
2456
2316
  // Fix up data we need to
2457
- data = process_common_data(object_key, data);
2458
- data.device_type = NestAccfactory.DeviceType.WEATHER;
2317
+ data.device_type = DeviceType.WEATHER;
2459
2318
  data.model = 'Weather';
2460
- data.current_temperature = data.weather.current_temperature;
2319
+ data.softwareVersion = '1.0.0';
2320
+ data = process_common_data(object_key, data);
2321
+ data.current_temperature = adjustTemperature(data.weather.current_temperature, 'C', 'C', true);
2461
2322
  data.current_humidity = data.weather.current_humidity;
2462
2323
  data.condition = data.weather.condition;
2463
2324
  data.wind_direction = data.weather.wind_direction;
@@ -2466,12 +2327,6 @@ export default class NestAccfactory {
2466
2327
  data.sunset = data.weather.sunset;
2467
2328
  data.station = data.weather.station;
2468
2329
  data.forecast = data.weather.forecast;
2469
- data.elevation =
2470
- isNaN(this.config?.devices?.[data?.serial_number]?.elevation) === false
2471
- ? Number(this.config.devices[data.serial_number].elevation)
2472
- : isNaN(this.config?.options?.elevation) === false
2473
- ? Number(this.config.options.elevation)
2474
- : 0;
2475
2330
  processed = data;
2476
2331
  // eslint-disable-next-line no-unused-vars
2477
2332
  } catch (error) {
@@ -2491,8 +2346,8 @@ export default class NestAccfactory {
2491
2346
  let tempDevice = {};
2492
2347
  try {
2493
2348
  if (
2494
- value?.source === NestAccfactory.DataSource.PROTOBUF &&
2495
- this.config.options?.protobufAPI === true &&
2349
+ value?.source === DATASOURCE.ProtobufAPI &&
2350
+ this.config.options?.useGoogleAPI === true &&
2496
2351
  value.value?.structure_location?.geoCoordinate?.latitude !== undefined &&
2497
2352
  value.value?.structure_location?.geoCoordinate?.longitude !== undefined
2498
2353
  ) {
@@ -2510,14 +2365,14 @@ export default class NestAccfactory {
2510
2365
  : value.value.structure_info.name;
2511
2366
  RESTTypeData.weather = value.value.weather;
2512
2367
 
2513
- // Use the REST API structure ID from the Protobuf structure. This will ensure we generate the same serial number
2368
+ // Use the Nest API structure ID from the Protobuf structure. This will ensure we generate the same serial number
2514
2369
  // This should prevent two 'weather' objects being created
2515
2370
  tempDevice = process_structure_data(object_key, RESTTypeData);
2516
2371
  }
2517
2372
 
2518
2373
  if (
2519
- value?.source === NestAccfactory.DataSource.REST &&
2520
- this.config.options?.restAPI === true &&
2374
+ value?.source === DATASOURCE.NestAPI &&
2375
+ this.config.options?.useNestAPI === true &&
2521
2376
  value.value?.latitude !== undefined &&
2522
2377
  value.value?.longitude !== undefined
2523
2378
  ) {
@@ -2536,13 +2391,19 @@ export default class NestAccfactory {
2536
2391
  }
2537
2392
  // eslint-disable-next-line no-unused-vars
2538
2393
  } catch (error) {
2539
- this?.log?.debug && this.log.debug('Error processing data for weather');
2394
+ this?.log?.debug?.('Error processing data for weather');
2540
2395
  }
2541
2396
 
2542
2397
  if (Object.entries(tempDevice).length !== 0 && typeof devices[tempDevice.serialNumber] === 'undefined') {
2398
+ let deviceOptions = this.config?.devices?.find(
2399
+ (device) => device?.serialNumber?.toUpperCase?.() === tempDevice?.serialNumber?.toUpperCase?.(),
2400
+ );
2543
2401
  // Insert any extra options we've read in from configuration file for this device
2544
- tempDevice.eveHistory =
2545
- this.config.options.eveHistory === true || this.config?.devices?.[tempDevice.serialNumber]?.eveHistory === true;
2402
+ tempDevice.eveHistory = this.config.options.eveHistory === true || deviceOptions?.eveHistory === true;
2403
+ tempDevice.elevation =
2404
+ isNaN(deviceOptions?.elevation) === false && Number(deviceOptions?.elevation) >= 0 && Number(deviceOptions?.elevation) <= 8848
2405
+ ? Number(deviceOptions?.elevation)
2406
+ : this.config.options.elevation;
2546
2407
  devices[tempDevice.serialNumber] = tempDevice; // Store processed device
2547
2408
  }
2548
2409
  });
@@ -2550,10 +2411,8 @@ export default class NestAccfactory {
2550
2411
  return devices; // Return our processed data
2551
2412
  }
2552
2413
 
2553
- async #set(uuid, values) {
2414
+ async #set(values) {
2554
2415
  if (
2555
- typeof uuid !== 'string' ||
2556
- uuid === '' ||
2557
2416
  typeof values !== 'object' ||
2558
2417
  values?.uuid === undefined ||
2559
2418
  typeof this.#rawData?.[values?.uuid] !== 'object' ||
@@ -2563,9 +2422,9 @@ export default class NestAccfactory {
2563
2422
  }
2564
2423
 
2565
2424
  let nest_google_uuid = values.uuid; // Nest/Google structure uuid for this get request
2566
- let connectionUuid = this.#rawData[values.uuid].connection; // Associated connection uuid for the device
2425
+ let uuid = this.#rawData[values.uuid].connection; // Connection uuid for this device
2567
2426
 
2568
- if (this.#protobufRoot !== null && this.#rawData?.[nest_google_uuid]?.source === NestAccfactory.DataSource.PROTOBUF) {
2427
+ if (this.#protobufRoot !== null && this.#rawData?.[nest_google_uuid]?.source === DATASOURCE.ProtobufAPI) {
2569
2428
  let updatedTraits = [];
2570
2429
  let protobufElement = {
2571
2430
  traitRequest: {
@@ -2636,7 +2495,7 @@ export default class NestAccfactory {
2636
2495
  protobufElement.state.value.enabled = { value: false };
2637
2496
  }
2638
2497
 
2639
- // Tag 'who is doing the temperature/mode change. We are :-)
2498
+ // Tag 'who' is doing the temperature/mode change. We are :-)
2640
2499
  protobufElement.state.value.targetTemperature.currentActorInfo = {
2641
2500
  method: 'HVAC_ACTOR_METHOD_IOS',
2642
2501
  originator: this.#rawData[nest_google_uuid].value.target_temperature_settings.targetTemperature.currentActorInfo.originator,
@@ -2709,9 +2568,11 @@ export default class NestAccfactory {
2709
2568
  protobufElement.state.value.timerEnd =
2710
2569
  value === true
2711
2570
  ? {
2712
- seconds: Number(Math.floor(Date.now() / 1000 + Number(protobufElement.state.value.timerDuration.seconds))),
2713
- nanos: (Math.floor(Date.now() / 1000 + Number(protobufElement.state.value.timerDuration.seconds)) % 1000) * 1e6,
2714
- }
2571
+ seconds: Number(Math.floor(Date.now() / 1000) + Number(protobufElement.state.value.timerDuration.seconds)),
2572
+ nanos: Number(
2573
+ ((Math.floor(Date.now() / 1000) + Number(protobufElement.state.value.timerDuration.seconds)) % 1000) * 1e6,
2574
+ ),
2575
+ }
2715
2576
  : { seconds: 0, nanos: 0 };
2716
2577
  if (values?.fan_timer_speed !== undefined) {
2717
2578
  // We have a value to set fan speed also, so handle here as combined setting
@@ -2748,7 +2609,10 @@ export default class NestAccfactory {
2748
2609
  protobufElement.state.value = this.#rawData[nest_google_uuid].value.recording_toggle_settings;
2749
2610
  protobufElement.state.value.targetCameraState = value === true ? 'CAMERA_ON' : 'CAMERA_OFF';
2750
2611
  protobufElement.state.value.changeModeReason = 2;
2751
- protobufElement.state.value.settingsUpdated = { seconds: Math.floor(Date.now() / 1000), nanos: (Date.now() % 1000) * 1e6 };
2612
+ protobufElement.state.value.settingsUpdated = {
2613
+ seconds: Math.floor(Date.now() / 1000),
2614
+ nanos: (Date.now() % 1000) * 1e6,
2615
+ };
2752
2616
  }
2753
2617
 
2754
2618
  if (key === 'audio_enabled' && typeof value === 'boolean') {
@@ -2781,7 +2645,7 @@ export default class NestAccfactory {
2781
2645
  });
2782
2646
 
2783
2647
  if (serviceUUID !== undefined) {
2784
- let commandResponse = await this.#protobufCommand(connectionUuid, 'ResourceApi', 'SendCommand', {
2648
+ let commandResponse = await this.#protobufCommand(uuid, 'ResourceApi', 'SendCommand', {
2785
2649
  resourceRequest: {
2786
2650
  resourceId: serviceUUID,
2787
2651
  requestId: crypto.randomUUID(),
@@ -2800,7 +2664,7 @@ export default class NestAccfactory {
2800
2664
  });
2801
2665
 
2802
2666
  if (commandResponse.sendCommandResponse?.[0]?.traitOperations?.[0]?.progress !== 'COMPLETE') {
2803
- this?.log?.debug && this.log.debug('Protobuf API had error setting light status on uuid "%s"', nest_google_uuid);
2667
+ this?.log?.debug?.('Protobuf API had error setting light status on uuid "%s"', nest_google_uuid);
2804
2668
  }
2805
2669
  }
2806
2670
  }
@@ -2814,8 +2678,42 @@ export default class NestAccfactory {
2814
2678
  protobufElement.state.value.brightness = scaleValue(Number(value), 0, 100, 0, 10); // Scale to required level
2815
2679
  }
2816
2680
 
2681
+ if (
2682
+ key === 'active_sensor' &&
2683
+ typeof value === 'boolean' &&
2684
+ typeof this.#rawData?.[this.#rawData[nest_google_uuid]?.value?.associated_thermostat]?.value
2685
+ ?.remote_comfort_sensing_settings === 'object'
2686
+ ) {
2687
+ // Set active temperature sensor for associated thermostat
2688
+ protobufElement.traitRequest.resourceId = this.#rawData[nest_google_uuid].value.associated_thermostat;
2689
+ protobufElement.traitRequest.traitLabel = 'remote_comfort_sensing_settings';
2690
+ protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.hvac.RemoteComfortSensingSettingsTrait';
2691
+ protobufElement.state.value =
2692
+ this.#rawData[this.#rawData[nest_google_uuid].value.associated_thermostat].value.remote_comfort_sensing_settings;
2693
+ protobufElement.state.value.activeRcsSelection =
2694
+ value === true
2695
+ ? { rcsSourceType: 'RCS_SOURCE_TYPE_SINGLE_SENSOR', activeRcsSensor: { resourceId: nest_google_uuid } }
2696
+ : { rcsSourceType: 'RCS_SOURCE_TYPE_BACKPLATE' };
2697
+ }
2698
+
2699
+ if (key === 'hot_water_boost_active' && typeof value === 'object') {
2700
+ // Turn hotwater boost heating on/off
2701
+ protobufElement.traitRequest.traitLabel = 'hot_water_settings';
2702
+ protobufElement.state.type_url = 'type.nestlabs.com/nest.trait.hvac.HotWaterSettingsTrait';
2703
+ protobufElement.state.value = this.#rawData[nest_google_uuid].value.hot_water_settings;
2704
+ protobufElement.state.value.boostTimerEnd =
2705
+ value?.state === true
2706
+ ? {
2707
+ seconds: Number(Math.floor(Date.now() / 1000) + Number(isNaN(value?.time) === false ? value?.time : 30 * 60)),
2708
+ nanos: Number(
2709
+ (Math.floor(Date.now() / 1000) + (Number(isNaN(value?.time) === false ? value?.time : 30 * 60) % 1000)) * 1e6,
2710
+ ),
2711
+ }
2712
+ : { seconds: 0, nanos: 0 };
2713
+ }
2714
+
2817
2715
  if (protobufElement.traitRequest.traitLabel === '' || protobufElement.state.type_url === '') {
2818
- this?.log?.debug && this.log.debug('Unknown Protobuf set key "%s" for device uuid "%s"', key, nest_google_uuid);
2716
+ this?.log?.debug?.('Unknown Protobuf set key "%s" for device uuid "%s"', key, nest_google_uuid);
2819
2717
  }
2820
2718
 
2821
2719
  if (protobufElement.traitRequest.traitLabel !== '' && protobufElement.state.type_url !== '') {
@@ -2826,90 +2724,94 @@ export default class NestAccfactory {
2826
2724
  );
2827
2725
 
2828
2726
  if (updatedTraits.length !== 0) {
2829
- let commandResponse = await this.#protobufCommand(connectionUuid, 'TraitBatchApi', 'BatchUpdateState', {
2727
+ let commandResponse = await this.#protobufCommand(uuid, 'TraitBatchApi', 'BatchUpdateState', {
2830
2728
  batchUpdateStateRequest: updatedTraits,
2831
2729
  });
2832
2730
  if (
2833
2731
  commandResponse === undefined ||
2834
2732
  commandResponse?.batchUpdateStateResponse?.[0]?.traitOperations?.[0]?.progress !== 'COMPLETE'
2835
2733
  ) {
2836
- this?.log?.debug && this.log.debug('Protobuf API had error updating device traits for uuid "%s"', nest_google_uuid);
2734
+ this?.log?.debug?.('Protobuf API had error updating device traits for uuid "%s"', nest_google_uuid);
2837
2735
  }
2838
2736
  }
2839
2737
  }
2840
2738
 
2841
- if (this.#rawData?.[nest_google_uuid]?.source === NestAccfactory.DataSource.REST && nest_google_uuid.startsWith('quartz.') === true) {
2739
+ if (this.#rawData?.[nest_google_uuid]?.source === DATASOURCE.NestAPI && nest_google_uuid.startsWith('quartz.') === true) {
2842
2740
  // Set value on Nest Camera/Doorbell
2843
2741
  await Promise.all(
2844
2742
  Object.entries(values)
2845
2743
  .filter(([key]) => key !== 'uuid')
2846
2744
  .map(async ([key, value]) => {
2847
- const SETPROPERTIES = {
2848
- indoor_chime_enabled: 'doorbell.indoor_chime.enabled',
2849
- statusled_brightness: 'statusled.brightness',
2850
- irled_enabled: 'irled.state',
2851
- streaming_enabled: 'streaming.enabled',
2852
- audio_enabled: 'audio.enabled',
2853
- };
2854
-
2855
- // Transform key to correct set camera properties key
2856
- key = SETPROPERTIES[key] !== undefined ? SETPROPERTIES[key] : key;
2857
-
2858
- await fetchWrapper(
2859
- 'post',
2860
- 'https://webapi.' + this.#connections[connectionUuid].cameraAPIHost + '/api/dropcams.set_properties',
2745
+ let mappedKey =
2861
2746
  {
2862
- headers: {
2863
- referer: 'https://' + this.#connections[connectionUuid].referer,
2864
- 'User-Agent': USERAGENT,
2865
- 'Content-Type': 'application/x-www-form-urlencoded',
2866
- [this.#connections[connectionUuid].cameraAPI.key]:
2867
- this.#connections[connectionUuid].cameraAPI.value + this.#connections[connectionUuid].cameraAPI.token,
2747
+ indoor_chime_enabled: 'doorbell.indoor_chime.enabled',
2748
+ statusled_brightness: 'statusled.brightness',
2749
+ irled_enabled: 'irled.state',
2750
+ streaming_enabled: 'streaming.enabled',
2751
+ audio_enabled: 'audio.enabled',
2752
+ }[key] ?? key;
2753
+
2754
+ try {
2755
+ let response = await fetchWrapper(
2756
+ 'post',
2757
+ 'https://webapi.' + this.#connections[uuid].cameraAPIHost + '/api/dropcams.set_properties',
2758
+ {
2759
+ headers: {
2760
+ referer: 'https://' + this.#connections[uuid].referer,
2761
+ 'User-Agent': USERAGENT,
2762
+ 'Content-Type': 'application/x-www-form-urlencoded',
2763
+ [this.#connections[uuid].cameraAPI.key]:
2764
+ this.#connections[uuid].cameraAPI.value + this.#connections[uuid].cameraAPI.token,
2765
+ },
2766
+ timeout: NESTAPITIMEOUT,
2868
2767
  },
2869
- timeout: NESTAPITIMEOUT,
2870
- },
2871
- [key] + '=' + value + '&uuid=' + nest_google_uuid.split('.')[1],
2872
- )
2873
- .then((response) => response.json())
2874
- .then((data) => {
2875
- if (data?.status !== 0) {
2876
- throw new Error('REST API camera update for failed with error');
2877
- }
2878
- })
2879
- .catch((error) => {
2880
- if (
2881
- error?.cause !== undefined &&
2882
- JSON.stringify(error.cause).toUpperCase().includes('TIMEOUT') === false &&
2883
- this?.log?.debug
2884
- ) {
2885
- this.log.debug(
2886
- 'REST API camera update for failed with error for uuid "%s". Error was "%s"',
2887
- nest_google_uuid,
2888
- error?.code,
2889
- );
2890
- }
2891
- });
2768
+ mappedKey + '=' + value + '&uuid=' + nest_google_uuid.split('.')[1],
2769
+ );
2770
+
2771
+ let data = await response.json();
2772
+ if (data?.status !== 0) {
2773
+ throw new Error('Nest API camera update failed');
2774
+ }
2775
+ } catch (error) {
2776
+ if (error?.cause !== undefined && String(error.cause).toUpperCase().includes('TIMEOUT') === false) {
2777
+ this?.log?.debug?.('Nest API camera update failed for uuid "%s". Error was "%s"', nest_google_uuid, error?.code);
2778
+ }
2779
+ }
2892
2780
  }),
2893
2781
  );
2894
2782
  }
2895
2783
 
2896
- if (this.#rawData?.[nest_google_uuid]?.source === NestAccfactory.DataSource.REST && nest_google_uuid.startsWith('quartz.') === false) {
2784
+ if (this.#rawData?.[nest_google_uuid]?.source === DATASOURCE.NestAPI && nest_google_uuid.startsWith('quartz.') === false) {
2897
2785
  // set values on other Nest devices besides cameras/doorbells
2898
2786
  await Promise.all(
2899
2787
  Object.entries(values)
2900
2788
  .filter(([key]) => key !== 'uuid')
2901
2789
  .map(async ([key, value]) => {
2902
2790
  let subscribeJSONData = { objects: [] };
2791
+ let RESTStructureUUID = nest_google_uuid;
2903
2792
 
2904
- if (nest_google_uuid.startsWith('device.') === false) {
2905
- subscribeJSONData.objects.push({ object_key: nest_google_uuid, op: 'MERGE', value: { [key]: value } });
2793
+ if (nest_google_uuid.startsWith('kryptonite.') === true) {
2794
+ if (
2795
+ key === 'active_sensor' &&
2796
+ typeof value === 'boolean' &&
2797
+ typeof this.#rawData?.['rcs_settings.' + this.#rawData?.[nest_google_uuid]?.value?.associated_thermostat.split('.')[1]]
2798
+ ?.value?.active_rcs_sensors === 'object'
2799
+ ) {
2800
+ // Set active temperature sensor for associated thermostat
2801
+ RESTStructureUUID = 'rcs_settings.' + this.#rawData[nest_google_uuid].value.associated_thermostat.split('.')[1];
2802
+ subscribeJSONData.objects.push({
2803
+ object_key: RESTStructureUUID,
2804
+ op: 'MERGE',
2805
+ value:
2806
+ value === true
2807
+ ? { active_rcs_sensors: [nest_google_uuid], rcs_control_setting: 'OVERRIDE' }
2808
+ : { active_rcs_sensors: [], rcs_control_setting: 'OFF' },
2809
+ });
2810
+ }
2906
2811
  }
2907
2812
 
2908
- // Some elements when setting thermostat data are located in a different object location than with the device object
2909
- // Handle this scenario below
2910
2813
  if (nest_google_uuid.startsWith('device.') === true) {
2911
- let RESTStructureUUID = nest_google_uuid;
2912
-
2814
+ // Set thermostat settings. Some settings are located in a different ocject location, so we handle this below also
2913
2815
  if (
2914
2816
  (key === 'hvac_mode' &&
2915
2817
  typeof value === 'string' &&
@@ -2934,39 +2836,47 @@ export default class NestAccfactory {
2934
2836
  value = value !== 0 ? 'stage' + value : 'stage1';
2935
2837
  }
2936
2838
 
2839
+ if (key === 'hot_water_boost_active' && typeof value === 'object') {
2840
+ key = 'hot_water_boost_time_to_end';
2841
+ value =
2842
+ value?.state === true ? Number(isNaN(value?.time) === false ? value?.time : 30 * 60) + Math.floor(Date.now() / 1000) : 0;
2843
+ }
2844
+
2937
2845
  subscribeJSONData.objects.push({ object_key: RESTStructureUUID, op: 'MERGE', value: { [key]: value } });
2938
2846
  }
2939
2847
 
2848
+ if (nest_google_uuid.startsWith('device.') === false && nest_google_uuid.startsWith('kryptonite.') === false) {
2849
+ // Set other Nest object settings ie: not thermostat or temperature sensors
2850
+ subscribeJSONData.objects.push({ object_key: nest_google_uuid, op: 'MERGE', value: { [key]: value } });
2851
+ }
2852
+
2940
2853
  if (subscribeJSONData.objects.length !== 0) {
2941
- await fetchWrapper(
2942
- 'post',
2943
- this.#connections[connectionUuid].transport_url + '/v5/put',
2944
- {
2945
- referer: 'https://' + this.#connections[connectionUuid].referer,
2946
- headers: {
2947
- 'User-Agent': USERAGENT,
2948
- Authorization: 'Basic ' + this.#connections[connectionUuid].token,
2854
+ try {
2855
+ await fetchWrapper(
2856
+ 'post',
2857
+ this.#connections[uuid].transport_url + '/v5/put',
2858
+ {
2859
+ referer: 'https://' + this.#connections[uuid].referer,
2860
+ headers: {
2861
+ 'User-Agent': USERAGENT,
2862
+ Authorization: 'Basic ' + this.#connections[uuid].token,
2863
+ },
2949
2864
  },
2950
- },
2951
- JSON.stringify(subscribeJSONData),
2952
- ).catch((error) => {
2953
- this?.log?.debug &&
2954
- this.log.debug(
2955
- 'REST API property update for failed with error for uuid "%s". Error was "%s"',
2956
- nest_google_uuid,
2957
- error?.code,
2958
- );
2959
- });
2865
+ JSON.stringify(subscribeJSONData),
2866
+ );
2867
+ } catch (error) {
2868
+ if (error?.cause !== undefined && String(error.cause).toUpperCase().includes('TIMEOUT') === false) {
2869
+ this?.log?.debug?.('Nest API property update failed for uuid "%s". Error was "%s"', nest_google_uuid, error?.code);
2870
+ }
2871
+ }
2960
2872
  }
2961
2873
  }),
2962
2874
  );
2963
2875
  }
2964
2876
  }
2965
2877
 
2966
- async #get(uuid, values) {
2878
+ async #get(values) {
2967
2879
  if (
2968
- typeof uuid !== 'string' ||
2969
- uuid === '' ||
2970
2880
  typeof values !== 'object' ||
2971
2881
  values?.uuid === undefined ||
2972
2882
  typeof this.#rawData?.[values?.uuid] !== 'object' ||
@@ -2976,7 +2886,7 @@ export default class NestAccfactory {
2976
2886
  }
2977
2887
 
2978
2888
  let nest_google_uuid = values.uuid; // Nest/Google structure uuid for this get request
2979
- let connectionUuid = this.#rawData[values.uuid].connection; // Associated connection uuid for the uuid
2889
+ let uuid = this.#rawData[values.uuid].connection; // Connection uuid for this device
2980
2890
 
2981
2891
  await Promise.all(
2982
2892
  Object.entries(values)
@@ -2987,50 +2897,48 @@ export default class NestAccfactory {
2987
2897
  values[key] = undefined;
2988
2898
 
2989
2899
  if (
2990
- this.#rawData?.[nest_google_uuid]?.source === NestAccfactory.DataSource.REST &&
2900
+ this.#rawData?.[nest_google_uuid]?.source === DATASOURCE.NestAPI &&
2991
2901
  key === 'camera_snapshot' &&
2992
2902
  nest_google_uuid.startsWith('quartz.') === true &&
2993
2903
  typeof this.#rawData?.[nest_google_uuid]?.value?.nexus_api_http_server_url === 'string' &&
2994
2904
  this.#rawData[nest_google_uuid].value.nexus_api_http_server_url !== ''
2995
2905
  ) {
2996
- // Attempt to retrieve snapshot from camera via REST API
2997
- await fetchWrapper(
2998
- 'get',
2999
- this.#rawData[nest_google_uuid].value.nexus_api_http_server_url + '/get_image?uuid=' + nest_google_uuid.split('.')[1],
3000
- {
3001
- headers: {
3002
- referer: 'https://' + this.#connections[connectionUuid].referer,
3003
- 'User-Agent': USERAGENT,
3004
- [this.#connections[connectionUuid].cameraAPI.key]:
3005
- this.#connections[connectionUuid].cameraAPI.value + this.#connections[connectionUuid].cameraAPI.token,
2906
+ // Attempt to retrieve snapshot from camera via Nest API
2907
+ try {
2908
+ let response = await fetchWrapper(
2909
+ 'get',
2910
+ this.#rawData[nest_google_uuid].value.nexus_api_http_server_url + '/get_image?uuid=' + nest_google_uuid.split('.')[1],
2911
+ {
2912
+ headers: {
2913
+ referer: 'https://' + this.#connections[uuid].referer,
2914
+ 'User-Agent': USERAGENT,
2915
+ [this.#connections[uuid].cameraAPI.key]:
2916
+ this.#connections[uuid].cameraAPI.value + this.#connections[uuid].cameraAPI.token,
2917
+ },
2918
+ timeout: 3000,
3006
2919
  },
3007
- timeout: 3000,
3008
- },
3009
- )
3010
- .then((response) => response.arrayBuffer())
3011
- .then((data) => {
3012
- values[key] = Buffer.from(data);
3013
- })
3014
- .catch((error) => {
3015
- if (
3016
- error?.cause !== undefined &&
3017
- JSON.stringify(error.cause).toUpperCase().includes('TIMEOUT') === false &&
3018
- this?.log?.debug
3019
- ) {
3020
- this.log.debug('REST API camera snapshot failed with error for uuid "%s". Error was "%s"', nest_google_uuid, error?.code);
3021
- }
3022
- });
2920
+ );
2921
+ values[key] = Buffer.from(await response.arrayBuffer());
2922
+ } catch (error) {
2923
+ if (error?.cause !== undefined && String(error.cause).toUpperCase().includes('TIMEOUT') === false) {
2924
+ this?.log?.debug?.(
2925
+ 'Nest API camera snapshot failed with error for uuid "%s". Error was "%s"',
2926
+ nest_google_uuid,
2927
+ error?.code,
2928
+ );
2929
+ }
2930
+ }
3023
2931
  }
3024
2932
 
3025
2933
  if (
3026
- this.#rawData?.[nest_google_uuid]?.source === NestAccfactory.DataSource.PROTOBUF &&
2934
+ this.#rawData?.[nest_google_uuid]?.source === DATASOURCE.ProtobufAPI &&
3027
2935
  this.#protobufRoot !== null &&
3028
2936
  this.#rawData[nest_google_uuid]?.value?.device_identity?.vendorProductId !== undefined &&
3029
2937
  key === 'camera_snapshot'
3030
2938
  ) {
3031
2939
  // Attempt to retrieve snapshot from camera via Protobuf API
3032
2940
  // First, request to get snapshot url image updated
3033
- let commandResponse = await this.#protobufCommand(connectionUuid, 'ResourceApi', 'SendCommand', {
2941
+ let commandResponse = await this.#protobufCommand(uuid, 'ResourceApi', 'SendCommand', {
3034
2942
  resourceRequest: {
3035
2943
  resourceId: nest_google_uuid,
3036
2944
  requestId: crypto.randomUUID(),
@@ -3051,32 +2959,26 @@ export default class NestAccfactory {
3051
2959
  typeof this.#rawData?.[nest_google_uuid]?.value?.upload_live_image?.liveImageUrl === 'string' &&
3052
2960
  this.#rawData[nest_google_uuid].value.upload_live_image.liveImageUrl !== ''
3053
2961
  ) {
3054
- // Snapshot url image has beeen updated, so no retrieve it
3055
- await fetchWrapper('get', this.#rawData[nest_google_uuid].value.upload_live_image.liveImageUrl, {
3056
- referer: 'https://' + this.#connections[connectionUuid].referer,
3057
- headers: {
3058
- 'User-Agent': USERAGENT,
3059
- Authorization: 'Basic ' + this.#connections[connectionUuid].token,
3060
- },
3061
- timeout: 3000,
3062
- })
3063
- .then((response) => response.arrayBuffer())
3064
- .then((data) => {
3065
- values[key] = Buffer.from(data);
3066
- })
3067
- .catch((error) => {
3068
- if (
3069
- error?.cause !== undefined &&
3070
- JSON.stringify(error.cause).toUpperCase().includes('TIMEOUT') === false &&
3071
- this?.log?.debug
3072
- ) {
3073
- this.log.debug(
3074
- 'Protobuf API camera snapshot failed with error for uuid "%s". Error was "%s"',
3075
- nest_google_uuid,
3076
- error?.code,
3077
- );
3078
- }
2962
+ // Snapshot url image has been updated, so now retrieve it
2963
+ try {
2964
+ let response = await fetchWrapper('get', this.#rawData[nest_google_uuid].value.upload_live_image.liveImageUrl, {
2965
+ referer: 'https://' + this.#connections[uuid].referer,
2966
+ headers: {
2967
+ 'User-Agent': USERAGENT,
2968
+ Authorization: 'Basic ' + this.#connections[uuid].token,
2969
+ },
2970
+ timeout: 3000,
3079
2971
  });
2972
+ values[key] = Buffer.from(await response.arrayBuffer());
2973
+ } catch (error) {
2974
+ if (error?.cause !== undefined && String(error.cause).toUpperCase().includes('TIMEOUT') === false) {
2975
+ this?.log?.debug?.(
2976
+ 'Protobuf API camera snapshot failed with error for uuid "%s". Error was "%s"',
2977
+ nest_google_uuid,
2978
+ error?.code,
2979
+ );
2980
+ }
2981
+ }
3080
2982
  }
3081
2983
  }
3082
2984
  }),
@@ -3085,79 +2987,83 @@ export default class NestAccfactory {
3085
2987
  return values;
3086
2988
  }
3087
2989
 
3088
- async #getWeatherData(connectionUUID, deviceUUID, latitude, longitude) {
3089
- let weatherData = {};
3090
- if (typeof this.#rawData[deviceUUID]?.value?.weather === 'object') {
3091
- weatherData = this.#rawData?.[deviceUUID]?.value.weather;
3092
- }
2990
+ async #getWeather(uuid, deviceUUID, latitude, longitude) {
2991
+ let weather = typeof this.#rawData?.[deviceUUID]?.value?.weather === 'object' ? this.#rawData[deviceUUID].value.weather : {};
3093
2992
 
3094
- if (typeof this.#connections?.[connectionUUID].weather_url === 'string' && this.#connections[connectionUUID].weather_url !== '') {
3095
- await fetchWrapper('get', this.#connections[connectionUUID].weather_url + latitude + ',' + longitude, {
3096
- referer: 'https://' + this.#connections[connectionUUID].referer,
3097
- headers: {
3098
- 'User-Agent': USERAGENT,
3099
- },
3100
- timeout: NESTAPITIMEOUT,
3101
- })
3102
- .then((response) => response.json())
3103
- .then((data) => {
3104
- // Store the lat/long details in the weather data object
3105
- weatherData.latitude = latitude;
3106
- weatherData.longitude = longitude;
3107
-
3108
- // Update weather data
3109
- if (data?.[latitude + ',' + longitude]?.current !== undefined) {
3110
- weatherData.current_temperature = adjustTemperature(data[latitude + ',' + longitude].current.temp_c, 'C', 'C', false);
3111
- weatherData.current_humidity = data[latitude + ',' + longitude].current.humidity;
3112
- weatherData.condition = data[latitude + ',' + longitude].current.condition;
3113
- weatherData.wind_direction = data[latitude + ',' + longitude].current.wind_dir;
3114
- weatherData.wind_speed = data[latitude + ',' + longitude].current.wind_mph * 1.609344; // convert to km/h
3115
- weatherData.sunrise = data[latitude + ',' + longitude].current.sunrise;
3116
- weatherData.sunset = data[latitude + ',' + longitude].current.sunset;
3117
- }
3118
- weatherData.station =
3119
- data[latitude + ',' + longitude]?.location?.short_name !== undefined
3120
- ? data[latitude + ',' + longitude].location.short_name
3121
- : '';
3122
- weatherData.forecast =
3123
- data[latitude + ',' + longitude]?.forecast?.daily?.[0]?.condition !== undefined
3124
- ? data[latitude + ',' + longitude].forecast.daily[0].condition
3125
- : '';
3126
- })
3127
- .catch((error) => {
3128
- if (error?.cause !== undefined && JSON.stringify(error.cause).toUpperCase().includes('TIMEOUT') === false && this?.log?.debug) {
3129
- this.log.debug('REST API failed to retrieve weather details for uuid "%s". Error was "%s"', deviceUUID, error?.code);
3130
- }
2993
+ let location = latitude + ',' + longitude;
2994
+
2995
+ if (typeof this.#connections?.[uuid]?.weather_url === 'string' && this.#connections[uuid].weather_url !== '') {
2996
+ try {
2997
+ let response = await fetchWrapper('get', this.#connections[uuid].weather_url + location, {
2998
+ referer: 'https://' + this.#connections[uuid].referer,
2999
+ headers: {
3000
+ 'User-Agent': USERAGENT,
3001
+ },
3002
+ timeout: NESTAPITIMEOUT,
3131
3003
  });
3004
+
3005
+ let data = await response.json();
3006
+ let locationData = data?.[location];
3007
+
3008
+ // Store the lat/long details in the weather data object
3009
+ weather.latitude = latitude;
3010
+ weather.longitude = longitude;
3011
+
3012
+ // Update weather data
3013
+ if (locationData?.current !== undefined) {
3014
+ weather.current_temperature = adjustTemperature(locationData.current.temp_c, 'C', 'C', false);
3015
+ weather.current_humidity = locationData.current.humidity;
3016
+ weather.condition = locationData.current.condition;
3017
+ weather.wind_direction = locationData.current.wind_dir;
3018
+ weather.wind_speed = locationData.current.wind_mph * 1.609344; // convert to km/h
3019
+ weather.sunrise = locationData.current.sunrise;
3020
+ weather.sunset = locationData.current.sunset;
3021
+ }
3022
+
3023
+ weather.station = typeof locationData?.location?.short_name === 'string' ? locationData.location.short_name : '';
3024
+ weather.forecast = locationData?.forecast?.daily?.[0]?.condition !== undefined ? locationData.forecast.daily[0].condition : '';
3025
+ } catch (error) {
3026
+ if (error?.cause !== undefined && String(error.cause).toUpperCase().includes('TIMEOUT') === false) {
3027
+ this?.log?.debug?.('Nest API failed to retrieve weather details for uuid "%s". Error was "%s"', deviceUUID, error?.code);
3028
+ }
3029
+ }
3132
3030
  }
3133
3031
 
3134
- return weatherData;
3032
+ return weather;
3135
3033
  }
3136
3034
 
3137
- async #protobufCommand(connectionUUID, service, command, values) {
3035
+ async #protobufCommand(uuid, service, command, values) {
3138
3036
  if (
3139
3037
  this.#protobufRoot === null ||
3038
+ typeof uuid !== 'string' ||
3039
+ !uuid ||
3140
3040
  typeof service !== 'string' ||
3141
- service === '' ||
3041
+ !service ||
3142
3042
  typeof command !== 'string' ||
3143
- command === '' ||
3144
- typeof values !== 'object'
3043
+ !command ||
3044
+ typeof values !== 'object' ||
3045
+ values === null ||
3046
+ this.#connections?.[uuid] === undefined ||
3047
+ typeof this.#connections?.[uuid].protobufAPIHost !== 'string' ||
3048
+ typeof this.#connections?.[uuid].referer !== 'string' ||
3049
+ typeof this.#connections?.[uuid].token !== 'string'
3145
3050
  ) {
3146
3051
  return;
3147
3052
  }
3148
3053
 
3149
3054
  const encodeValues = (object) => {
3150
3055
  if (typeof object === 'object' && object !== null) {
3151
- if ('type_url' in object && 'value' in object) {
3152
- // We have a type_url and value object at this same level, we'll treat this a trait requiring encoding
3153
- let TraitMap = this.#protobufRoot.lookup(object.type_url.split('/')[1]);
3056
+ // We have a type_url and value object at this same level, we'll treat this a trait requiring encoding
3057
+ if (typeof object.type_url === 'string' && object.value !== undefined) {
3058
+ let typeName = object.type_url.split('/')[1];
3059
+ let TraitMap = this.#protobufRoot.lookup(typeName);
3154
3060
  if (TraitMap !== null) {
3155
3061
  object.value = TraitMap.encode(TraitMap.fromObject(object.value)).finish();
3156
3062
  }
3157
3063
  }
3158
3064
 
3159
- for (let key in object) {
3160
- if (object?.[key] !== undefined) {
3065
+ for (const key in object) {
3066
+ if (object[key] !== undefined) {
3161
3067
  encodeValues(object[key]);
3162
3068
  }
3163
3069
  }
@@ -3174,28 +3080,27 @@ export default class NestAccfactory {
3174
3080
  encodeValues(values);
3175
3081
 
3176
3082
  let encodedData = TraitMapRequest.encode(TraitMapRequest.fromObject(values)).finish();
3177
- await fetchWrapper(
3178
- 'post',
3179
- 'https://' + this.#connections[connectionUUID].protobufAPIHost + '/nestlabs.gateway.v1.' + service + '/' + command,
3180
- {
3181
- headers: {
3182
- referer: 'https://' + this.#connections[connectionUUID].referer,
3183
- 'User-Agent': USERAGENT,
3184
- Authorization: 'Basic ' + this.#connections[connectionUUID].token,
3185
- 'Content-Type': 'application/x-protobuf',
3186
- 'X-Accept-Content-Transfer-Encoding': 'binary',
3187
- 'X-Accept-Response-Streaming': 'true',
3083
+ try {
3084
+ let response = await fetchWrapper(
3085
+ 'post',
3086
+ 'https://' + this.#connections[uuid].protobufAPIHost + '/nestlabs.gateway.v1.' + service + '/' + command,
3087
+ {
3088
+ headers: {
3089
+ referer: 'https://' + this.#connections[uuid].referer,
3090
+ 'User-Agent': USERAGENT,
3091
+ Authorization: 'Basic ' + this.#connections[uuid].token,
3092
+ 'Content-Type': 'application/x-protobuf',
3093
+ 'X-Accept-Content-Transfer-Encoding': 'binary',
3094
+ 'X-Accept-Response-Streaming': 'true',
3095
+ },
3188
3096
  },
3189
- },
3190
- encodedData,
3191
- )
3192
- .then((response) => response.arrayBuffer())
3193
- .then((data) => {
3194
- commandResponse = TraitMapResponse.decode(Buffer.from(data)).toJSON();
3195
- })
3196
- .catch((error) => {
3197
- this?.log?.debug && this.log.debug('Protobuf gateway service command failed with error. Error was "%s"', error?.code);
3198
- });
3097
+ encodedData,
3098
+ );
3099
+ let buffer = await response.arrayBuffer();
3100
+ commandResponse = TraitMapResponse.decode(Buffer.from(buffer)).toJSON();
3101
+ } catch (error) {
3102
+ this?.log?.debug?.('Protobuf gateway service command failed with error. Error was "%s"', error?.code);
3103
+ }
3199
3104
  }
3200
3105
  return commandResponse;
3201
3106
  }
@@ -3203,28 +3108,22 @@ export default class NestAccfactory {
3203
3108
 
3204
3109
  // General helper functions which don't need to be part of an object class
3205
3110
  function adjustTemperature(temperature, currentTemperatureUnit, targetTemperatureUnit, round) {
3206
- // Converts temperatures between C/F and vice-versa
3207
- // Also rounds temperatures to 0.5 increments for C and 1.0 for F
3208
- if (targetTemperatureUnit.toUpperCase() === 'C') {
3209
- if (currentTemperatureUnit.toUpperCase() === 'F') {
3210
- // convert from F to C
3211
- temperature = ((temperature - 32) * 5) / 9;
3212
- }
3213
- if (round === true) {
3214
- // round to nearest 0.5C
3215
- temperature = Math.round(temperature * 2) * 0.5;
3216
- }
3217
- }
3111
+ currentTemperatureUnit = currentTemperatureUnit?.toUpperCase?.();
3112
+ targetTemperatureUnit = targetTemperatureUnit?.toUpperCase?.();
3218
3113
 
3219
- if (targetTemperatureUnit.toUpperCase() === 'F') {
3220
- if (currentTemperatureUnit.toUpperCase() === 'C') {
3221
- // convert from C to F
3222
- temperature = (temperature * 9) / 5 + 32;
3114
+ if (currentTemperatureUnit === 'F' && targetTemperatureUnit === 'C') {
3115
+ temperature = ((temperature - 32) * 5) / 9;
3116
+ if (round === true) {
3117
+ temperature = Math.round(temperature * 2) / 2; // round to nearest 0.5°C
3223
3118
  }
3119
+ } else if (currentTemperatureUnit === 'C' && targetTemperatureUnit === 'F') {
3120
+ temperature = (temperature * 9) / 5 + 32;
3224
3121
  if (round === true) {
3225
- // round to nearest 1F
3226
- temperature = Math.round(temperature);
3122
+ temperature = Math.round(temperature); // round to nearest 1°F
3227
3123
  }
3124
+ } else if (round === true) {
3125
+ // No conversion, just rounding
3126
+ temperature = targetTemperatureUnit === 'C' ? Math.round(temperature * 2) / 2 : Math.round(temperature);
3228
3127
  }
3229
3128
 
3230
3129
  return temperature;
@@ -3234,12 +3133,14 @@ function makeHomeKitName(nameToMakeValid) {
3234
3133
  // Strip invalid characters to meet HomeKit naming requirements
3235
3134
  // Ensure only letters or numbers are at the beginning AND/OR end of string
3236
3135
  // Matches against uni-code characters
3237
- return typeof nameToMakeValid === 'string'
3238
- ? nameToMakeValid
3239
- .replace(/[^\p{L}\p{N}\p{Z}\u2019 '.,-]/gu, '')
3240
- .replace(/^[^\p{L}\p{N}]*/gu, '')
3241
- .replace(/[^\p{L}\p{N}]+$/gu, '')
3242
- : nameToMakeValid;
3136
+ let validHomeKitName = nameToMakeValid;
3137
+ if (typeof nameToMakeValid === 'string') {
3138
+ validHomeKitName = nameToMakeValid
3139
+ .replace(/[^\p{L}\p{N}\p{Z}\u2019 '.,-]/gu, '') // Remove disallowed characters
3140
+ .replace(/^[^\p{L}\p{N}]*/gu, '') // Trim invalid prefix
3141
+ .replace(/[^\p{L}\p{N}]+$/gu, ''); // Trim invalid suffix
3142
+ }
3143
+ return validHomeKitName;
3243
3144
  }
3244
3145
 
3245
3146
  function crc24(valueToHash) {
@@ -3266,65 +3167,127 @@ function crc24(valueToHash) {
3266
3167
  0x7c0401, 0x42fa2f, 0xc4b6d4, 0xc82f22, 0x4e63d9, 0xd11cce, 0x575035, 0x5bc9c3, 0xdd8538,
3267
3168
  ];
3268
3169
 
3269
- let crc24 = 0xb704ce; // init crc24 hash;
3270
- valueToHash = Buffer.from(valueToHash); // convert value into buffer for processing
3271
- for (let index = 0; index < valueToHash.length; index++) {
3272
- crc24 = (crc24HashTable[((crc24 >> 16) ^ valueToHash[index]) & 0xff] ^ (crc24 << 8)) & 0xffffff;
3170
+ let crc = 0xb704ce;
3171
+
3172
+ const buffer = Buffer.from(valueToHash);
3173
+
3174
+ for (let i = 0; i < buffer.length; i++) {
3175
+ const index = ((crc >> 16) ^ buffer[i]) & 0xff;
3176
+ crc = (crc24HashTable[index] ^ (crc << 8)) & 0xffffff;
3273
3177
  }
3274
- return crc24.toString(16); // return crc24 as hex string
3178
+
3179
+ return crc.toString(16).padStart(6, '0'); // ensures 6-digit hex
3275
3180
  }
3276
3181
 
3277
- function scaleValue(value, sourceRangeMin, sourceRangeMax, targetRangeMin, targetRangeMax) {
3278
- if (value < sourceRangeMin) {
3279
- value = sourceRangeMin;
3182
+ function scaleValue(value, sourceMin, sourceMax, targetMin, targetMax) {
3183
+ if (sourceMax === sourceMin) {
3184
+ return targetMin;
3280
3185
  }
3281
- if (value > sourceRangeMax) {
3282
- value = sourceRangeMax;
3283
- }
3284
- return ((value - sourceRangeMin) * (targetRangeMax - targetRangeMin)) / (sourceRangeMax - sourceRangeMin) + targetRangeMin;
3186
+
3187
+ value = Math.max(sourceMin, Math.min(sourceMax, value));
3188
+
3189
+ return ((value - sourceMin) * (targetMax - targetMin)) / (sourceMax - sourceMin) + targetMin;
3285
3190
  }
3286
3191
 
3287
- async function fetchWrapper(method, url, options, data, response) {
3192
+ async function fetchWrapper(method, url, options, data) {
3288
3193
  if ((method !== 'get' && method !== 'post') || typeof url !== 'string' || url === '' || typeof options !== 'object') {
3289
3194
  return;
3290
3195
  }
3291
3196
 
3292
- if (isNaN(options?.timeout) === false && Number(options?.timeout) > 0) {
3293
- // If a timeout is specified in the options, setup here
3197
+ if (isNaN(options?.timeout) === false && Number(options.timeout) > 0) {
3294
3198
  // eslint-disable-next-line no-undef
3295
3199
  options.signal = AbortSignal.timeout(Number(options.timeout));
3296
3200
  }
3297
3201
 
3298
- if (options?.retry === undefined) {
3299
- // If not retry option specifed , we'll do just once
3202
+ if (isNaN(options.retry) === true || options.retry < 1) {
3300
3203
  options.retry = 1;
3301
3204
  }
3302
3205
 
3303
- options.method = method; // Set the HTTP method to use
3206
+ if (isNaN(options._retryCount) === true) {
3207
+ options._retryCount = 0;
3208
+ }
3209
+
3210
+ options.method = method;
3304
3211
 
3305
- if (method === 'post' && typeof data !== undefined) {
3306
- // Doing a HTTP post, so include the data in the body
3212
+ if (method === 'post' && data !== undefined) {
3307
3213
  options.body = data;
3308
3214
  }
3309
3215
 
3310
- if (options.retry > 0) {
3216
+ let response;
3217
+ try {
3311
3218
  // eslint-disable-next-line no-undef
3312
3219
  response = await fetch(url, options);
3313
- if (response.ok === false && options.retry > 1) {
3314
- options.retry--; // One less retry to go
3315
-
3316
- // Try again after short delay (500ms)
3317
- // We pass back in this response also for when we reach zero retries and still not successful
3318
- await new Promise((resolve) => setTimeout(resolve, 500));
3319
- // eslint-disable-next-line no-undef
3320
- response = await fetchWrapper(method, url, options, data, structuredClone(response));
3220
+ } catch (error) {
3221
+ if (options.retry > 1) {
3222
+ options.retry--;
3223
+ options._retryCount++;
3224
+
3225
+ const delay = 500 * 2 ** (options._retryCount - 1);
3226
+ await new Promise((resolve) => setTimeout(resolve, delay));
3227
+
3228
+ return fetchWrapper(method, url, options, data);
3321
3229
  }
3322
- if (response.ok === false && options.retry === 0) {
3323
- let error = new Error(response.statusText);
3324
- error.code = response.status;
3325
- throw error;
3230
+
3231
+ error.message = `Fetch failed for ${method.toUpperCase()} ${url} after ${options._retryCount + 1} attempt(s): ${error.message}`;
3232
+ throw error;
3233
+ }
3234
+
3235
+ if (response?.ok === false) {
3236
+ if (options.retry > 1) {
3237
+ options.retry--;
3238
+ options._retryCount++;
3239
+
3240
+ let delay = 500 * 2 ** (options._retryCount - 1);
3241
+ await new Promise((resolve) => setTimeout(resolve, delay));
3242
+
3243
+ return fetchWrapper(method, url, options, data);
3326
3244
  }
3245
+
3246
+ let error = new Error(`HTTP ${response.status} on ${method.toUpperCase()} ${url}: ${response.statusText || 'Unknown error'}`);
3247
+ error.code = response.status;
3248
+ throw error;
3327
3249
  }
3328
3250
 
3329
3251
  return response;
3330
3252
  }
3253
+
3254
+ function parseDurationToSeconds(inputDuration, { defaultValue = null, min = 0, max = Infinity } = {}) {
3255
+ let normalisedSeconds = defaultValue;
3256
+
3257
+ if (inputDuration !== undefined && inputDuration !== null && inputDuration !== '') {
3258
+ inputDuration = String(inputDuration).trim().toLowerCase();
3259
+
3260
+ // Case: plain numeric seconds (e.g. "30")
3261
+ if (/^\d+$/.test(inputDuration) === true) {
3262
+ normalisedSeconds = Number(inputDuration);
3263
+ } else {
3264
+ // Process input into normalised units. We'll convert in standard h (hours), m (minutes), s (seconds)
3265
+ inputDuration = inputDuration
3266
+ .replace(/hrs?|hours?/g, 'h')
3267
+ .replace(/mins?|minutes?/g, 'm')
3268
+ .replace(/secs?|s\b/g, 's')
3269
+ .replace(/ +/g, '');
3270
+
3271
+ // Match duration format like "1h30m15s"
3272
+ let match = inputDuration.match(/^((\d+)h)?((\d+)m)?((\d+)s?)?$/);
3273
+
3274
+ if (Array.isArray(match) === true) {
3275
+ let total = Number(match[2] || 0) * 3600 + Number(match[4] || 0) * 60 + Number(match[6] || 0);
3276
+ normalisedSeconds = Math.floor(total / 3600) * 3600 + Math.floor((total % 3600) / 60) * 60 + (total % 60);
3277
+ }
3278
+ }
3279
+
3280
+ if (normalisedSeconds === null || isNaN(normalisedSeconds) === true) {
3281
+ normalisedSeconds = defaultValue;
3282
+ }
3283
+
3284
+ if (isNaN(min) === false && normalisedSeconds < min) {
3285
+ normalisedSeconds = min;
3286
+ }
3287
+ if (isNaN(max) === false && normalisedSeconds > max) {
3288
+ normalisedSeconds = max;
3289
+ }
3290
+ }
3291
+
3292
+ return normalisedSeconds;
3293
+ }