homebridge-nest-accfactory 0.0.4-a

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 (88) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +176 -0
  3. package/README.md +121 -0
  4. package/config.schema.json +107 -0
  5. package/dist/HomeKitDevice.js +441 -0
  6. package/dist/HomeKitHistory.js +2835 -0
  7. package/dist/camera.js +1276 -0
  8. package/dist/doorbell.js +122 -0
  9. package/dist/index.js +35 -0
  10. package/dist/nexustalk.js +741 -0
  11. package/dist/protect.js +240 -0
  12. package/dist/protobuf/google/rpc/status.proto +91 -0
  13. package/dist/protobuf/google/rpc/stream_body.proto +26 -0
  14. package/dist/protobuf/google/trait/product/camera.proto +53 -0
  15. package/dist/protobuf/googlehome/foyer.proto +208 -0
  16. package/dist/protobuf/nest/messages.proto +8 -0
  17. package/dist/protobuf/nest/services/apigateway.proto +107 -0
  18. package/dist/protobuf/nest/trait/audio.proto +7 -0
  19. package/dist/protobuf/nest/trait/cellular.proto +313 -0
  20. package/dist/protobuf/nest/trait/debug.proto +37 -0
  21. package/dist/protobuf/nest/trait/detector.proto +41 -0
  22. package/dist/protobuf/nest/trait/diagnostics.proto +87 -0
  23. package/dist/protobuf/nest/trait/firmware.proto +221 -0
  24. package/dist/protobuf/nest/trait/guest.proto +105 -0
  25. package/dist/protobuf/nest/trait/history.proto +345 -0
  26. package/dist/protobuf/nest/trait/humanlibrary.proto +19 -0
  27. package/dist/protobuf/nest/trait/hvac.proto +1353 -0
  28. package/dist/protobuf/nest/trait/input.proto +29 -0
  29. package/dist/protobuf/nest/trait/lighting.proto +61 -0
  30. package/dist/protobuf/nest/trait/located.proto +193 -0
  31. package/dist/protobuf/nest/trait/media.proto +68 -0
  32. package/dist/protobuf/nest/trait/network.proto +352 -0
  33. package/dist/protobuf/nest/trait/occupancy.proto +373 -0
  34. package/dist/protobuf/nest/trait/olive.proto +15 -0
  35. package/dist/protobuf/nest/trait/pairing.proto +85 -0
  36. package/dist/protobuf/nest/trait/product/camera.proto +283 -0
  37. package/dist/protobuf/nest/trait/product/detect.proto +67 -0
  38. package/dist/protobuf/nest/trait/product/doorbell.proto +18 -0
  39. package/dist/protobuf/nest/trait/product/guard.proto +59 -0
  40. package/dist/protobuf/nest/trait/product/protect.proto +344 -0
  41. package/dist/protobuf/nest/trait/promonitoring.proto +14 -0
  42. package/dist/protobuf/nest/trait/resourcedirectory.proto +32 -0
  43. package/dist/protobuf/nest/trait/safety.proto +119 -0
  44. package/dist/protobuf/nest/trait/security.proto +516 -0
  45. package/dist/protobuf/nest/trait/selftest.proto +78 -0
  46. package/dist/protobuf/nest/trait/sensor.proto +291 -0
  47. package/dist/protobuf/nest/trait/service.proto +46 -0
  48. package/dist/protobuf/nest/trait/structure.proto +85 -0
  49. package/dist/protobuf/nest/trait/system.proto +51 -0
  50. package/dist/protobuf/nest/trait/test.proto +15 -0
  51. package/dist/protobuf/nest/trait/ui.proto +65 -0
  52. package/dist/protobuf/nest/trait/user.proto +98 -0
  53. package/dist/protobuf/nest/trait/voiceassistant.proto +30 -0
  54. package/dist/protobuf/nestlabs/eventingapi/v1.proto +83 -0
  55. package/dist/protobuf/nestlabs/gateway/v1.proto +273 -0
  56. package/dist/protobuf/nestlabs/gateway/v2.proto +96 -0
  57. package/dist/protobuf/nestlabs/history/v1.proto +73 -0
  58. package/dist/protobuf/root.proto +64 -0
  59. package/dist/protobuf/wdl-event-importance.proto +11 -0
  60. package/dist/protobuf/wdl.proto +450 -0
  61. package/dist/protobuf/weave/common.proto +144 -0
  62. package/dist/protobuf/weave/trait/audio.proto +12 -0
  63. package/dist/protobuf/weave/trait/auth.proto +22 -0
  64. package/dist/protobuf/weave/trait/description.proto +32 -0
  65. package/dist/protobuf/weave/trait/heartbeat.proto +38 -0
  66. package/dist/protobuf/weave/trait/locale.proto +20 -0
  67. package/dist/protobuf/weave/trait/network.proto +24 -0
  68. package/dist/protobuf/weave/trait/pairing.proto +8 -0
  69. package/dist/protobuf/weave/trait/peerdevices.proto +18 -0
  70. package/dist/protobuf/weave/trait/power.proto +86 -0
  71. package/dist/protobuf/weave/trait/schedule.proto +76 -0
  72. package/dist/protobuf/weave/trait/security.proto +343 -0
  73. package/dist/protobuf/weave/trait/telemetry/tunnel.proto +37 -0
  74. package/dist/protobuf/weave/trait/time.proto +16 -0
  75. package/dist/res/Nest_camera_connecting.h264 +0 -0
  76. package/dist/res/Nest_camera_connecting.jpg +0 -0
  77. package/dist/res/Nest_camera_off.h264 +0 -0
  78. package/dist/res/Nest_camera_off.jpg +0 -0
  79. package/dist/res/Nest_camera_offline.h264 +0 -0
  80. package/dist/res/Nest_camera_offline.jpg +0 -0
  81. package/dist/res/Nest_camera_transfer.jpg +0 -0
  82. package/dist/streamer.js +344 -0
  83. package/dist/system.js +3112 -0
  84. package/dist/tempsensor.js +99 -0
  85. package/dist/thermostat.js +1026 -0
  86. package/dist/weather.js +205 -0
  87. package/dist/webrtc.js +55 -0
  88. package/package.json +66 -0
package/dist/system.js ADDED
@@ -0,0 +1,3112 @@
1
+ // Nest System communications
2
+ // Part of homebridge-nest-accfactory
3
+ //
4
+ // Code version 7/9/2024
5
+ // Mark Hulskamp
6
+ 'use strict';
7
+
8
+ // Define external module requirements
9
+ import axios from 'axios';
10
+ import protobuf from 'protobufjs';
11
+
12
+ // Define nodejs module requirements
13
+ import EventEmitter from 'node:events';
14
+ import { Buffer } from 'node:buffer';
15
+ import { setInterval, clearInterval, setTimeout } from 'node:timers';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import crypto from 'node:crypto';
19
+ import process from 'node:process';
20
+ import child_process from 'node:child_process';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ // Import our modules
24
+ import HomeKitDevice from './HomeKitDevice.js';
25
+ import NestCamera from './camera.js';
26
+ import NestDoorbell from './doorbell.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';
31
+
32
+ const CAMERAALERTPOLLING = 2000; // Camera alerts polling timer
33
+ const CAMERAZONEPOLLING = 30000; // Camera zones changes polling timer
34
+ const WEATHERPOLLING = 300000; // Weather data polling timer
35
+ const NESTAPITIMEOUT = 10000; // Nest API timeout
36
+ 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
+ const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Make a defined for JS __dirname
40
+
41
+ // We handle the connections to Nest/Google
42
+ // Perform device management (additions/removals/updates)
43
+ export default class NestAccfactory {
44
+ static DeviceType = {
45
+ THERMOSTAT: 'thermostat',
46
+ TEMPSENSOR: 'temperature',
47
+ SMOKESENSOR: 'protect',
48
+ CAMERA: 'camera',
49
+ DOORBELL: 'doorbell',
50
+ WEATHER: 'weather',
51
+ LOCK: 'lock', // yet to implement
52
+ ALARM: 'alarm', // yet to implement
53
+ };
54
+
55
+ static DataSource = {
56
+ REST: 'REST', // From the REST API
57
+ PROTOBUF: 'PROTOBUF', // From the protobuf API
58
+ };
59
+
60
+ static GoogleConnection = 'google'; // Google account connection
61
+ static NestConnection = 'nest'; // Nest account connection
62
+ static SDMConnection = 'sdm'; // NOT coded, but here for future reference
63
+ static HomeFoyerConnection = 'foyer'; // Google Home foyer connection
64
+
65
+ cachedAccessories = []; // Track restored cached accessories
66
+
67
+ // Internal data only for this class
68
+ #connections = {}; // Array of confirmed connections, indexed by type
69
+ #rawData = {}; // Cached copy of data from both Rest and Protobuf APIs
70
+ #eventEmitter = new EventEmitter(); // Used for object messaging from this platform
71
+
72
+ constructor(log, config, api) {
73
+ this.config = config;
74
+ this.log = log;
75
+ this.api = api;
76
+
77
+ // Perform validation on the configuration passed into us and set defaults if not present
78
+ if (typeof this.config?.nest !== 'object') {
79
+ this.config.nest = {};
80
+ }
81
+ this.config.nest.access_token = typeof this.config.nest?.access_token === 'string' ? this.config.nest.access_token : '';
82
+ this.config.nest.fieldTest = typeof this.config.nest?.fieldTest === 'boolean' ? this.config.nest.fieldTest : false;
83
+
84
+ if (typeof this.config?.google !== 'object') {
85
+ this.config.google = {};
86
+ }
87
+ this.config.google.issuetoken = typeof this.config.google?.issuetoken === 'string' ? this.config.google.issuetoken : '';
88
+ this.config.google.cookie = typeof this.config.google?.cookie === 'string' ? this.config.google.cookie : '';
89
+ this.config.google.fieldTest = typeof this.config.google?.fieldTest === 'boolean' ? this.config.google.fieldTest : false;
90
+
91
+ if (typeof this.config?.options !== 'object') {
92
+ this.config.options = {};
93
+ }
94
+
95
+ this.config.options.eveHistory = typeof this.config.options?.eveHistory === 'boolean' ? this.config.options.eveHistory : false;
96
+ this.config.options.elevation = typeof this.config.options?.elevation === 'number' ? this.config.options.elevation : 0;
97
+ this.config.options.weather = typeof this.config.options?.weather === 'boolean' ? this.config.options.weather : false;
98
+ this.config.options.hksv = typeof this.config.options?.hksv === 'boolean' ? this.config.options.hksv : false;
99
+
100
+ // Get configuration for max number of concurrent 'live view' streams. For HomeKit Secure Video, this will always be 1
101
+ this.config.options.maxStreams =
102
+ typeof this.config.options?.maxStreams === 'number' && this.deviceData?.hksv === false
103
+ ? this.config.options.maxStreams
104
+ : this.deviceData?.hksv === true
105
+ ? 1
106
+ : 2;
107
+
108
+ // Check if a ffmpeg binary exists in current path OR the specific path via configuration
109
+ // If using HomeBridge, the default path will be where the Homebridge user folder is, otherwise the current directory
110
+ this.config.options.ffmpeg = {};
111
+ this.config.options.ffmpeg['path'] =
112
+ typeof this.config.options?.ffmpegPath === 'string' && this.config.options.ffmpegPath !== ''
113
+ ? this.config.options.ffmpegPath
114
+ : typeof api?.user?.storagePath === 'function'
115
+ ? api.user.storagePath()
116
+ : __dirname;
117
+
118
+ this.config.options.ffmpeg['version'] = undefined;
119
+ this.config.options.ffmpeg.libspeex = false;
120
+ this.config.options.ffmpeg.libx264 = false;
121
+ this.config.options.ffmpeg.libfdk_aac = false;
122
+
123
+ if (fs.existsSync(path.resolve(this.config.options.ffmpeg.path + '/ffmpeg')) === false) {
124
+ if (this?.log?.warn) {
125
+ this.log.warn('No ffmpeg binary found in "%s"', this.config.options.ffmpeg.path);
126
+ this.log.warn('Stream video/recording from camera/doorbells will be unavailable');
127
+ }
128
+
129
+ // If we flag ffmpegPath as undefined, no video streaming/record support enabled for camers/doorbells
130
+ this.config.options.ffmpeg.path = undefined;
131
+ }
132
+
133
+ if (fs.existsSync(path.resolve(this.config.options.ffmpeg.path + '/ffmpeg')) === true) {
134
+ let ffmpegProcess = child_process.spawnSync(path.resolve(this.config.options.ffmpeg.path + '/ffmpeg'), ['-version'], {
135
+ env: process.env,
136
+ });
137
+ if (ffmpegProcess.stdout !== null) {
138
+ // Determine ffmpeg version
139
+ this.config.options.ffmpeg.version = ffmpegProcess.stdout
140
+ .toString()
141
+ .match(/(?:ffmpeg version:(\d+)\.)?(?:(\d+)\.)?(?:(\d+)\.\d+)(.*?)/gim)[0];
142
+
143
+ // Determine what libraries ffmpeg is compiled with
144
+ this.config.options.ffmpeg.libspeex = ffmpegProcess.stdout.toString().includes('--enable-libspeex') === true;
145
+ this.config.options.ffmpeg.libx264 = ffmpegProcess.stdout.toString().includes('--enable-libx264') === true;
146
+ this.config.options.ffmpeg.libfdk_aac = ffmpegProcess.stdout.toString().includes('--enable-libfdk-aac') === true;
147
+
148
+ if (
149
+ this.config.options.ffmpeg.version.replace(/\./gi, '') < parseFloat(FFMPEGVERSION.toString().replace(/\./gi, '')) ||
150
+ this.config.options.ffmpeg.libspeex === false ||
151
+ this.config.options.ffmpeg.libx264 === false ||
152
+ this.config.options.ffmpeg.libfdk_aac === false
153
+ ) {
154
+ this?.log?.warn &&
155
+ this.log.warn('ffmpeg binary in "%s" does not meet the minimum support requirements', this.config.options.ffmpeg.path);
156
+ if (this.config.options.ffmpeg.version.replace(/\./gi, '') < parseFloat(FFMPEGVERSION.toString().replace(/\./gi, ''))) {
157
+ this?.log?.warn &&
158
+ this.log.warn(
159
+ 'Minimum binary version is "%s", however the installed version is "%s"',
160
+ FFMPEGVERSION,
161
+ this.config.options.ffmpeg.version,
162
+ );
163
+ this?.log?.warn && this.log.warn('Stream video/recording from camera/doorbells will be unavailable');
164
+
165
+ this.config.options.ffmpeg.path = undefined; // No ffmpeg since below min version
166
+ }
167
+ if (
168
+ this.config.options.ffmpeg.libspeex === false &&
169
+ (this.config.options.ffmpeg.libx264 === true && this.config.options.ffmpeg.libfdk_aac) === true
170
+ ) {
171
+ this?.log?.warn && this.log.warn('Missing libspeex in ffmpeg binary, two-way audio on camera/doorbells will be unavailable');
172
+ }
173
+ if (this.config.options.ffmpeg.libx264 === true && this.config.options.ffmpeg.libfdk_aac === false) {
174
+ this?.log?.warn && this.log.warn('Missing libfdk_aac in ffmpeg binary, audio from camera/doorbells will be unavailable');
175
+ }
176
+ if (this.config.options.ffmpeg.libx264 === false) {
177
+ this?.log?.warn &&
178
+ this.log.warn('Missing libx264 in ffmpeg binary, stream video/recording from camera/doorbells will be unavailable');
179
+
180
+ this.config.options.ffmpeg.path = undefined; // No ffmpeg since we do not have all the required libraries
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ // If we don't have either a Nest access_token and/or a Google issuetoken/cookie, return back.
187
+ if (this.config.nest.access_token === '' && (this.config.google.issuetoken === '' || this.config.google.cookie === '')) {
188
+ this?.log?.error && this.log.error('JSON plugin configuration is invalid. Please review');
189
+ return;
190
+ }
191
+
192
+ if (this.api instanceof EventEmitter === true) {
193
+ this.api.on('didFinishLaunching', async () => {
194
+ // We got notified that Homebridge has finished loading, so we are ready to process
195
+ this.discoverDevices();
196
+ });
197
+
198
+ this.api.on('shutdown', async () => {
199
+ // We got notified that Homebridge is shutting down. Perform cleanup??
200
+ this.#eventEmitter.removeAllListeners(HomeKitDevice.SET);
201
+ this.#eventEmitter.removeAllListeners(HomeKitDevice.GET);
202
+ });
203
+ }
204
+ }
205
+
206
+ configureAccessory(accessory) {
207
+ // This gets called from HomeBridge each time it restores an accessory from its cache
208
+ this?.log?.info && this.log.info('Loading accessory from cache:', accessory.displayName);
209
+
210
+ // add the restored accessory to the accessories cache, so we can track if it has already been registered
211
+ this.cachedAccessories.push(accessory);
212
+ }
213
+
214
+ async discoverDevices() {
215
+ await this.#connect();
216
+ if (this.#connections?.nest !== undefined) {
217
+ // We have a 'Nest' connected account, so process accordingly
218
+ this.#subscribeREST(NestAccfactory.NestConnection, false);
219
+ this.#subscribeProtobuf(NestAccfactory.NestConnection);
220
+ }
221
+
222
+ if (this.#connections?.google !== undefined) {
223
+ // We have a 'Google' connected account, so process accordingly
224
+ this.#subscribeREST(NestAccfactory.GoogleConnection, false);
225
+ this.#subscribeProtobuf(NestAccfactory.GoogleConnection);
226
+ }
227
+
228
+ // Setup event listeners for set/get calls from devices
229
+ this.#eventEmitter.addListener(HomeKitDevice.SET, (deviceUUID, values) => this.#set(deviceUUID, values));
230
+ this.#eventEmitter.addListener(HomeKitDevice.GET, (deviceUUID, values) => this.#get(deviceUUID, values));
231
+ }
232
+
233
+ async #connect() {
234
+ if (
235
+ typeof this.config?.google === 'object' &&
236
+ typeof this.config?.google?.issuetoken === 'string' &&
237
+ this.config?.google?.issuetoken !== '' &&
238
+ typeof this.config?.google?.cookie === 'string' &&
239
+ this.config?.google?.cookie !== ''
240
+ ) {
241
+ let referer = 'home.nest.com'; // Which host is 'actually' doing the request
242
+ let restAPIHost = 'home.nest.com'; // Root URL for Nest system REST API
243
+ let cameraAPIHost = 'camera.home.nest.com'; // Root URL for Camera system API
244
+ let protobufAPIHost = 'grpc-web.production.nest.com'; // Root URL for Protobuf API
245
+
246
+ if (this.config?.google.fieldTest === true) {
247
+ // FieldTest mode support enabled in configuration, so update default endpoints
248
+ // This is all 'untested'
249
+ this?.log?.info && this.log.info('Using FieldTest API endpoints for Google account');
250
+
251
+ referer = 'home.ft.nest.com'; // Which host is 'actually' doing the request
252
+ restAPIHost = 'home.ft.nest.com'; // Root FT URL for Nest system REST API
253
+ cameraAPIHost = 'camera.home.ft.nest.com'; // Root FT URL for Camera system API
254
+ protobufAPIHost = 'grpc-web.ft.nest.com'; // Root FT URL for Protobuf API
255
+ }
256
+
257
+ // Google cookie method as refresh token method no longer supported by Google since October 2022
258
+ // Instructions from homebridge_nest or homebridge_nest_cam to obtain this
259
+ this?.log?.info && this.log.info('Performing Google account authorisation');
260
+
261
+ let request = {
262
+ method: 'get',
263
+ url: this.config.google.issuetoken,
264
+ headers: {
265
+ referer: 'https://accounts.google.com/o/oauth2/iframe',
266
+ 'User-Agent': USERAGENT,
267
+ cookie: this.config.google.cookie,
268
+ 'Sec-Fetch-Mode': 'cors',
269
+ 'X-Requested-With': 'XmlHttpRequest',
270
+ },
271
+ };
272
+ await axios(request)
273
+ .then(async (response) => {
274
+ if (typeof response.status !== 'number' || response.status !== 200) {
275
+ throw new Error('Google API Authorisation failed with error');
276
+ }
277
+ this.special = response.data.access_token;
278
+
279
+ let request = {
280
+ method: 'post',
281
+ url: 'https://nestauthproxyservice-pa.googleapis.com/v1/issue_jwt',
282
+ headers: {
283
+ referer: 'https://' + referer,
284
+ 'User-Agent': USERAGENT,
285
+ Authorization: 'Bearer ' + response.data.access_token,
286
+ },
287
+ data:
288
+ 'embed_google_oauth_access_token=true&expire_after=3600s&google_oauth_access_token=' +
289
+ response.data.access_token +
290
+ '&policy_id=authproxy-oauth-policy',
291
+ };
292
+
293
+ await axios(request).then(async (response) => {
294
+ if (typeof response.status !== 'number' || response.status !== 200) {
295
+ throw new Error('Google Camera API Token get failed with error');
296
+ }
297
+
298
+ let googleToken = response.data.jwt;
299
+ let tokenExpire = Math.floor(new Date(response.data.claims.expirationTime).valueOf() / 1000); // Token expiry, should be 1hr
300
+
301
+ let request = {
302
+ method: 'get',
303
+ url: 'https://' + restAPIHost + '/session',
304
+ headers: {
305
+ referer: 'https://' + referer,
306
+ 'User-Agent': USERAGENT,
307
+ Authorization: 'Basic ' + googleToken,
308
+ },
309
+ };
310
+
311
+ await axios(request).then(async (response) => {
312
+ if (typeof response.status !== 'number' || response.status !== 200) {
313
+ throw new Error('Nest Session API get failed with error');
314
+ }
315
+
316
+ this?.log?.success && this.log.success('Successfully authorised using Google account');
317
+
318
+ // Store successful connection details
319
+ this.#connections['google'] = {
320
+ type: 'google',
321
+ referer: referer,
322
+ restAPIHost: restAPIHost,
323
+ cameraAPIHost: cameraAPIHost,
324
+ protobufAPIHost: protobufAPIHost,
325
+ userID: response.data.userid,
326
+ transport_url: response.data.urls.transport_url,
327
+ weather_url: response.data.urls.weather_url,
328
+ timer: null,
329
+ protobufRoot: null,
330
+ token: googleToken,
331
+ cameraAPI: {
332
+ key: 'Authorization',
333
+ value: 'Basic ',
334
+ token: googleToken,
335
+ },
336
+ };
337
+
338
+ // Set timeout for token expiry refresh
339
+ clearInterval(this.#connections['google'].timer);
340
+ this.#connections['google'].timer = setTimeout(
341
+ () => {
342
+ this?.log?.info && this.log.info('Performing periodic token refresh for Google account');
343
+ this.#connect();
344
+ },
345
+ (tokenExpire - Math.floor(Date.now() / 1000) - 60) * 1000,
346
+ ); // Refresh just before token expiry
347
+ });
348
+ });
349
+ })
350
+ // eslint-disable-next-line no-unused-vars
351
+ .catch((error) => {
352
+ // The token we used to obtained a Nest session failed, so overall authorisation failed
353
+ this?.log?.error && this.log.error('Authorisation failed using Google account');
354
+ });
355
+ }
356
+
357
+ if (typeof this.config?.nest?.access_token === 'string' && this.config?.nest?.access_token !== '') {
358
+ let referer = 'home.nest.com'; // Which host is 'actually' doing the request
359
+ let restAPIHost = 'home.nest.com'; // Root URL for Nest system REST API
360
+ let cameraAPIHost = 'camera.home.nest.com'; // Root URL for Camera system API
361
+ let protobufAPIHost = 'grpc-web.production.nest.com'; // Root URL for Protobuf API
362
+
363
+ if (this.config?.nest.fieldTest === true) {
364
+ // FieldTest mode support enabled in configuration, so update default endpoints
365
+ // This is all 'untested'
366
+ this?.log?.info && this.log.info('Using FieldTest API endpoints for Nest account');
367
+
368
+ referer = 'home.ft.nest.com'; // Which host is 'actually' doing the request
369
+ restAPIHost = 'home.ft.nest.com'; // Root FT URL for Nest system REST API
370
+ cameraAPIHost = 'camera.home.ft.nest.com'; // Root FT URL for Camera system API
371
+ protobufAPIHost = 'grpc-web.ft.nest.com'; // Root FT URL for Protobuf API
372
+ }
373
+
374
+ // Nest access token method. Get WEBSITE2 cookie for use with camera API calls if needed later
375
+ this?.log?.info && this.log.info('Performing Nest account authorisation');
376
+
377
+ let request = {
378
+ method: 'post',
379
+ url: 'https://webapi.' + cameraAPIHost + '/api/v1/login.login_nest',
380
+ withCredentials: true,
381
+ headers: {
382
+ referer: 'https://' + referer,
383
+ 'Content-Type': 'application/x-www-form-urlencoded',
384
+ 'User-Agent': USERAGENT,
385
+ },
386
+ data: Buffer.from('access_token=' + this.config.nest.access_token, 'utf8'),
387
+ };
388
+ await axios(request)
389
+ .then(async (response) => {
390
+ if (
391
+ typeof response.status !== 'number' ||
392
+ response.status !== 200 ||
393
+ typeof response.data.status !== 'number' ||
394
+ response.data.status !== 0
395
+ ) {
396
+ throw new Error('Nest API Authorisation failed with error');
397
+ }
398
+
399
+ let nestToken = response.data.items[0].session_token;
400
+
401
+ let request = {
402
+ method: 'get',
403
+ url: 'https://' + restAPIHost + '/session',
404
+ headers: {
405
+ referer: 'https://' + referer,
406
+ 'User-Agent': USERAGENT,
407
+ Authorization: 'Basic ' + this.config.nest.access_token,
408
+ },
409
+ };
410
+
411
+ await axios(request).then((response) => {
412
+ if (typeof response.status !== 'number' || response.status !== 200) {
413
+ throw new Error('Nest Session API get failed with error');
414
+ }
415
+
416
+ this?.log?.success && this.log.success('Successfully authorised using Nest account');
417
+
418
+ // Store successful connection details
419
+ this.#connections['nest'] = {
420
+ type: 'nest',
421
+ referer: referer,
422
+ restAPIHost: restAPIHost,
423
+ cameraAPIHost: cameraAPIHost,
424
+ protobufAPIHost: protobufAPIHost,
425
+ userID: response.data.userid,
426
+ transport_url: response.data.urls.transport_url,
427
+ weather_url: response.data.urls.weather_url,
428
+ timer: null,
429
+ protobufRoot: null,
430
+ token: this.config.nest.access_token,
431
+ cameraAPI: {
432
+ key: 'cookie',
433
+ value: this.config.fieldTest === true ? 'website_ft=' : 'website_2=',
434
+ token: nestToken,
435
+ },
436
+ };
437
+
438
+ // Set timeout for token expiry refresh
439
+ clearInterval(this.#connections['nest'].timer);
440
+ this.#connections['nest'].timer = setTimeout(
441
+ () => {
442
+ this?.log?.info && this.log.info('Performing periodic token refresh for Nest account');
443
+ this.#connect();
444
+ },
445
+ 1000 * 3600 * 24,
446
+ ); // Refresh token every 24hrs
447
+ });
448
+ })
449
+ // eslint-disable-next-line no-unused-vars
450
+ .catch((error) => {
451
+ // The token we used to obtained a Nest session failed, so overall authorisation failed
452
+ this?.log?.error && this.log.error('Authorisation failed using Nest account');
453
+ });
454
+ }
455
+ }
456
+
457
+ async #subscribeREST(connectionType, fullRefresh) {
458
+ const REQUIREDBUCKETS = [
459
+ 'buckets',
460
+ 'structure',
461
+ 'where',
462
+ 'safety',
463
+ 'device',
464
+ 'shared',
465
+ 'track',
466
+ 'link',
467
+ 'rcs_settings',
468
+ 'schedule',
469
+ 'kryptonite',
470
+ 'topaz',
471
+ 'widget_track',
472
+ 'quartz',
473
+ ];
474
+ const DEVICEBUCKETS = {
475
+ structure: ['latitude', 'longitude'],
476
+ device: ['where_id'],
477
+ kryptonite: ['where_id', 'structure_id'],
478
+ topaz: ['where_id', 'structure_id'],
479
+ quartz: ['where_id', 'structure_id', 'nexus_api_http_server_url'],
480
+ };
481
+
482
+ let restAPIURL = '';
483
+ let restAPIJSONData = {};
484
+ if (Object.keys(this.#rawData).length === 0 || (typeof fullRefresh === 'boolean' && fullRefresh === true)) {
485
+ // Setup for a full data read from REST API
486
+ restAPIURL =
487
+ 'https://' +
488
+ this.#connections[connectionType].restAPIHost +
489
+ '/api/0.1/user/' +
490
+ this.#connections[connectionType].userID +
491
+ '/app_launch';
492
+ restAPIJSONData = { known_bucket_types: REQUIREDBUCKETS, known_bucket_versions: [] };
493
+ }
494
+ if (Object.keys(this.#rawData).length !== 0 && typeof fullRefresh === 'boolean' && fullRefresh === false) {
495
+ // Setup to subscribe to object changes we know about from REST API
496
+ restAPIURL = this.#connections[connectionType].transport_url + '/v6/subscribe';
497
+ restAPIJSONData = { objects: [] };
498
+
499
+ Object.entries(this.#rawData).forEach(([object_key]) => {
500
+ if (
501
+ this.#rawData[object_key]?.source === NestAccfactory.DataSource.REST &&
502
+ this.#rawData[object_key]?.connection === connectionType &&
503
+ typeof this.#rawData[object_key]?.object_revision === 'number' &&
504
+ typeof this.#rawData[object_key]?.object_timestamp === 'number'
505
+ ) {
506
+ restAPIJSONData.objects.push({
507
+ object_key: object_key,
508
+ object_revision: this.#rawData[object_key].object_revision,
509
+ object_timestamp: this.#rawData[object_key].object_timestamp,
510
+ });
511
+ }
512
+ });
513
+ }
514
+
515
+ let request = {
516
+ method: 'post',
517
+ url: restAPIURL,
518
+ responseType: 'json',
519
+ headers: {
520
+ 'User-Agent': USERAGENT,
521
+ Authorization: 'Basic ' + this.#connections[connectionType].token,
522
+ },
523
+ data: JSON.stringify(restAPIJSONData),
524
+ };
525
+ axios(request)
526
+ .then(async (response) => {
527
+ if (typeof response.status !== 'number' || response.status !== 200) {
528
+ throw new Error('REST API subscription failed with error');
529
+ }
530
+
531
+ let data = {};
532
+ let deviceChanges = []; // No REST API devices changes to start with
533
+ if (typeof response.data?.updated_buckets === 'object') {
534
+ // This response is full data read
535
+ data = response.data.updated_buckets;
536
+ }
537
+ if (typeof response.data?.objects === 'object') {
538
+ // This response contains subscribed data updates
539
+ data = response.data.objects;
540
+ }
541
+
542
+ // Process the data we received
543
+ fullRefresh = false; // Not a full data refresh required when we start again
544
+ await Promise.all(
545
+ data.map(async (value) => {
546
+ if (value.object_key.startsWith('structure.') === true) {
547
+ // Since we have a structure key, need to add in weather data for the location using latitude and longitude details
548
+ if (typeof value.value?.weather !== 'object') {
549
+ value.value.weather = {};
550
+ }
551
+ if (
552
+ typeof this.#rawData[value.object_key] === 'object' &&
553
+ typeof this.#rawData[value.object_key].value?.weather === 'object'
554
+ ) {
555
+ value.value.weather = this.#rawData[value.object_key].value.weather;
556
+ }
557
+ value.value.weather = await this.#getWeatherData(
558
+ connectionType,
559
+ value.object_key,
560
+ value.value.latitude,
561
+ value.value.longitude,
562
+ );
563
+
564
+ // Check for changes in the swarm property. This seems indicate changes in devices
565
+ if (typeof this.#rawData[value.object_key] === 'object') {
566
+ this.#rawData[value.object_key].value.swarm.map((object_key) => {
567
+ if (value.value.swarm.includes(object_key) === false) {
568
+ // Object is present in the old swarm list, but not in the new swarm list, so we assume it has been removed
569
+ // We'll remove the associated object here for future subscribe
570
+ delete this.#rawData[object_key];
571
+ }
572
+ });
573
+ }
574
+ }
575
+
576
+ if (value.object_key.startsWith('quartz.') === true) {
577
+ // We have camera(s) and/or doorbell(s), so get extra details that are required
578
+ value.value.properties =
579
+ typeof this.#rawData[value.object_key]?.value?.properties === 'object'
580
+ ? this.#rawData[value.object_key].value.properties
581
+ : [];
582
+
583
+ let request = {
584
+ method: 'get',
585
+ url:
586
+ 'https://webapi.' +
587
+ this.#connections[connectionType].cameraAPIHost +
588
+ '/api/cameras.get_with_properties?uuid=' +
589
+ value.object_key.split('.')[1],
590
+ headers: {
591
+ referer: 'https://' + this.#connections[connectionType].referer,
592
+ 'User-Agent': USERAGENT,
593
+ [this.#connections[connectionType].cameraAPI.key]:
594
+ this.#connections[connectionType].cameraAPI.value + this.#connections[connectionType].cameraAPI.token,
595
+ },
596
+ responseType: 'json',
597
+ timeout: NESTAPITIMEOUT,
598
+ };
599
+ await axios(request)
600
+ .then((response) => {
601
+ if (typeof response.status !== 'number' || response.status !== 200) {
602
+ throw new Error('REST API had error retrieving camera/doorbell details');
603
+ }
604
+
605
+ value.value.properties = response.data.items[0].properties;
606
+ })
607
+ .catch((error) => {
608
+ this?.log?.debug &&
609
+ this?.log?.debug &&
610
+ this.log.debug('REST API had error retrieving camera/doorbell details. Error was "%s"', error?.code);
611
+ });
612
+
613
+ value.value.activity_zones =
614
+ typeof this.#rawData[value.object_key]?.value?.activity_zones === 'object'
615
+ ? this.#rawData[value.object_key].value.activity_zones
616
+ : [];
617
+
618
+ request = {
619
+ method: 'get',
620
+ url: value.value.nexus_api_http_server_url + '/cuepoint_category/' + value.object_key.split('.')[1],
621
+ headers: {
622
+ referer: 'https://' + this.#connections[connectionType].referer,
623
+ 'User-Agent': USERAGENT,
624
+ [this.#connections[connectionType].cameraAPI.key]:
625
+ this.#connections[connectionType].cameraAPI.value + this.#connections[connectionType].cameraAPI.token,
626
+ },
627
+ responseType: 'json',
628
+ timeout: NESTAPITIMEOUT,
629
+ };
630
+ await axios(request)
631
+ .then((response) => {
632
+ if (typeof response.status !== 'number' || response.status !== 200) {
633
+ throw new Error('REST API had error retrieving camera/doorbell activity zones');
634
+ }
635
+
636
+ let zones = [];
637
+ response.data.forEach((zone) => {
638
+ if (zone.type.toUpperCase() === 'ACTIVITY' || zone.type.toUpperCase() === 'REGION') {
639
+ zones.push({
640
+ id: zone.id === 0 ? 1 : zone.id,
641
+ name: makeHomeKitName(zone.label),
642
+ hidden: zone.hidden === true,
643
+ uri: zone.nexusapi_image_uri,
644
+ });
645
+ }
646
+ });
647
+
648
+ value.value.activity_zones = zones;
649
+ })
650
+ .catch((error) => {
651
+ this?.log?.debug &&
652
+ this?.log?.debug &&
653
+ this.log.debug('REST API had error retrieving camera/doorbell activity zones. Error was "%s"', error?.code);
654
+ });
655
+ }
656
+
657
+ if (value.object_key.startsWith('buckets.') === true) {
658
+ if (
659
+ typeof this.#rawData[value.object_key] === 'object' &&
660
+ typeof this.#rawData[value.object_key].value?.buckets === 'object'
661
+ ) {
662
+ // Check for added objects
663
+ value.value.buckets.map((object_key) => {
664
+ if (this.#rawData[value.object_key].value.buckets.includes(object_key) === false) {
665
+ // Since this is an added object to the raw REST API structure, we need to do a full read of the data
666
+ fullRefresh = true;
667
+ }
668
+ });
669
+
670
+ // Check for removed objects
671
+ this.#rawData[value.object_key].value.buckets.map((object_key) => {
672
+ if (value.value.buckets.includes(object_key) === false) {
673
+ // Object is present in the old buckets list, but not in the new buckets list
674
+ // so we assume it has been removed
675
+ // It also could mean device(s) have been removed from Nest
676
+ if (Object.keys(DEVICEBUCKETS).includes(object_key.split('.')[0]) === true) {
677
+ deviceChanges.push({ object_key: object_key, change: 'remove' });
678
+ }
679
+ delete this.#rawData[object_key];
680
+ }
681
+ });
682
+ }
683
+ }
684
+
685
+ // Store or update the date in our internally saved raw REST API data
686
+ if (typeof this.#rawData[value.object_key] === 'undefined') {
687
+ this.#rawData[value.object_key] = {};
688
+ this.#rawData[value.object_key].object_revision = value.object_revision;
689
+ this.#rawData[value.object_key].object_timestamp = value.object_timestamp;
690
+ this.#rawData[value.object_key].connection = connectionType;
691
+ this.#rawData[value.object_key].source = NestAccfactory.DataSource.REST;
692
+ this.#rawData[value.object_key].timers = {}; // No timers running for this object
693
+ this.#rawData[value.object_key].value = {};
694
+ }
695
+
696
+ // Need to check for a possible device addition to the raw REST API data.
697
+ // We expect the devices we want to add, have certain minimum properties present in the data
698
+ // We'll perform that check here
699
+ if (
700
+ Object.keys(DEVICEBUCKETS).includes(value.object_key.split('.')[0]) === true &&
701
+ DEVICEBUCKETS[value.object_key.split('.')[0]].every((key) => key in value.value) === true &&
702
+ DEVICEBUCKETS[value.object_key.split('.')[0]].every((key) => key in this.#rawData[value.object_key].value) === false
703
+ ) {
704
+ deviceChanges.push({ object_key: value.object_key, change: 'add' });
705
+ }
706
+
707
+ // Finally, update our internal raw REST API data with the new values
708
+ this.#rawData[value.object_key].object_revision = value.object_revision; // Used for future subscribes
709
+ this.#rawData[value.object_key].object_timestamp = value.object_timestamp; // Used for future subscribes
710
+ for (const [fieldKey, fieldValue] of Object.entries(value.value)) {
711
+ this.#rawData[value.object_key]['value'][fieldKey] = fieldValue;
712
+ }
713
+ }),
714
+ );
715
+
716
+ await this.#processPostSubscribe(deviceChanges);
717
+ })
718
+ .catch((error) => {
719
+ if (error?.code !== 'ECONNRESET') {
720
+ this?.log?.error && this.log.error('REST API subscription failed with error "%s"', error?.code);
721
+ }
722
+ })
723
+ .finally(() => {
724
+ setTimeout(this.#subscribeREST.bind(this, connectionType, fullRefresh), 1000);
725
+ });
726
+ }
727
+
728
+ async #subscribeProtobuf(connectionType) {
729
+ const calculate_message_size = (inputBuffer) => {
730
+ // First byte in the is a tag type??
731
+ // Following is a varint type
732
+ // After varint size, is the buffer content
733
+ let varint = 0;
734
+ let bufferPos = 0;
735
+ let currentByte;
736
+
737
+ for (;;) {
738
+ currentByte = inputBuffer[bufferPos + 1]; // Offset in buffer + 1 to skip over starting tag
739
+ varint |= (currentByte & 0x7f) << (bufferPos * 7);
740
+ bufferPos += 1;
741
+ if (bufferPos > 5) {
742
+ throw new Error('VarInt exceeds allowed bounds.');
743
+ }
744
+ if ((currentByte & 0x80) !== 0x80) {
745
+ break;
746
+ }
747
+ }
748
+
749
+ // Return length of message in buffer
750
+ return varint + bufferPos + 1;
751
+ };
752
+
753
+ const traverseTypes = (currentTrait, callback) => {
754
+ if (currentTrait instanceof protobuf.Type) {
755
+ callback(currentTrait);
756
+ }
757
+ if (currentTrait.nestedArray) {
758
+ currentTrait.nestedArray.map((trait) => {
759
+ traverseTypes(trait, callback);
760
+ });
761
+ }
762
+ };
763
+
764
+ let observeTraits = null;
765
+ if (fs.existsSync(path.resolve(__dirname + '/protobuf/root.proto')) === true) {
766
+ protobuf.util.Long = null;
767
+ protobuf.configure();
768
+ this.#connections[connectionType].protobufRoot = protobuf.loadSync(path.resolve(__dirname + '/protobuf/root.proto'));
769
+ if (this.#connections[connectionType].protobufRoot !== null) {
770
+ // Loaded in the protobuf files, so now dynamically build the 'observe' post body data based on what we have loaded
771
+ let observeTraitsList = [];
772
+ let traitTypeObserveParam = this.#connections[connectionType].protobufRoot.lookup('nestlabs.gateway.v2.TraitTypeObserveParams');
773
+ let observeRequest = this.#connections[connectionType].protobufRoot.lookup('nestlabs.gateway.v2.ObserveRequest');
774
+ if (traitTypeObserveParam !== null && observeRequest !== null) {
775
+ traverseTypes(this.#connections[connectionType].protobufRoot, (type) => {
776
+ // We only want to have certain trait main 'families' in our observe reponse we are building
777
+ // This also depends on the account type we connected with. Nest accounts cannot observe camera/doorbell product traits
778
+ if (
779
+ (connectionType === NestAccfactory.NestConnection &&
780
+ type.fullName.startsWith('.nest.trait.product.camera') === false &&
781
+ type.fullName.startsWith('.nest.trait.product.doorbell') === false &&
782
+ (type.fullName.startsWith('.nest.trait') === true || type.fullName.startsWith('.weave.') === true)) ||
783
+ (connectionType === NestAccfactory.GoogleConnection &&
784
+ (type.fullName.startsWith('.nest.trait') === true ||
785
+ type.fullName.startsWith('.weave.') === true ||
786
+ type.fullName.startsWith('.google.trait.product.camera') === true))
787
+ ) {
788
+ observeTraitsList.push(traitTypeObserveParam.create({ traitType: type.fullName.replace(/^\.*|\.*$/g, '') }));
789
+ }
790
+ });
791
+ observeTraits = observeRequest.encode(observeRequest.create({ stateTypes: [1, 2], traitTypeParams: observeTraitsList })).finish();
792
+ }
793
+ }
794
+ }
795
+
796
+ let request = {
797
+ method: 'post',
798
+ url: 'https://' + this.#connections[connectionType].protobufAPIHost + '/nestlabs.gateway.v2.GatewayService/Observe',
799
+ headers: {
800
+ 'User-Agent': USERAGENT,
801
+ Authorization: 'Basic ' + this.#connections[connectionType].token,
802
+ 'Content-Type': 'application/x-protobuf',
803
+ 'X-Accept-Content-Transfer-Encoding': 'binary',
804
+ 'X-Accept-Response-Streaming': 'true',
805
+ },
806
+ responseType: 'stream',
807
+ data: observeTraits,
808
+ };
809
+ axios(request)
810
+ .then(async (response) => {
811
+ if (typeof response.status !== 'number' || response.status !== 200) {
812
+ throw new Error('protobuf API had error perform trait observe');
813
+ }
814
+
815
+ let deviceChanges = []; // No protobuf API devices changes to start with
816
+ let buffer = Buffer.alloc(0);
817
+ for await (const chunk of response.data) {
818
+ buffer = Buffer.concat([buffer, Buffer.from(chunk)]);
819
+ let messageSize = calculate_message_size(buffer);
820
+ if (buffer.length >= messageSize) {
821
+ let decodedMessage = {};
822
+ try {
823
+ // Attempt to decode the protobuf message(s) we extracted from the stream and get a JSON object representation
824
+ decodedMessage = this.#connections[connectionType].protobufRoot
825
+ .lookup('nest.rpc.StreamBody')
826
+ .decode(buffer.subarray(0, messageSize))
827
+ .toJSON();
828
+ if (typeof decodedMessage?.message !== 'object') {
829
+ decodedMessage.message = [];
830
+ }
831
+ if (typeof decodedMessage?.message[0]?.get !== 'object') {
832
+ decodedMessage.message[0].get = [];
833
+ }
834
+ if (typeof decodedMessage?.message[0]?.resourceMetas !== 'object') {
835
+ decodedMessage.message[0].resourceMetas = [];
836
+ }
837
+
838
+ // Tidy up our received messages. This ensures we only have one status for the trait in the data we process
839
+ // We'll favour a trait with accepted status over the same with confirmed status
840
+ let notAcceptedStatus = decodedMessage.message[0].get.filter((trait) => trait.stateTypes.includes('ACCEPTED') === false);
841
+ let acceptedStatus = decodedMessage.message[0].get.filter((trait) => trait.stateTypes.includes('ACCEPTED') === true);
842
+ let difference = acceptedStatus.map((trait) => trait.traitId.resourceId + '/' + trait.traitId.traitLabel);
843
+ decodedMessage.message[0].get =
844
+ ((notAcceptedStatus = notAcceptedStatus.filter(
845
+ (trait) => difference.includes(trait.traitId.resourceId + '/' + trait.traitId.traitLabel) === false,
846
+ )),
847
+ [...notAcceptedStatus, ...acceptedStatus]);
848
+
849
+ // We'll use the resource status message to look for structure and/or device removals
850
+ // We could also check for structure and/or device additions here, but we'll want to be flagged
851
+ // that a device is 'ready' for use before we add in. This data is populated in the trait data
852
+ decodedMessage.message[0].resourceMetas.map(async (resource) => {
853
+ if (
854
+ resource.status === 'REMOVED' &&
855
+ (resource.resourceId.startsWith('STRUCTURE_') || resource.resourceId.startsWith('DEVICE_'))
856
+ ) {
857
+ // We have the removal of a 'home' and/ device
858
+ deviceChanges.push({ object_key: resource.resourceId, change: 'removed' });
859
+ }
860
+ });
861
+ // eslint-disable-next-line no-unused-vars
862
+ } catch (error) {
863
+ // Empty
864
+ }
865
+ buffer = buffer.subarray(messageSize); // Remove the message from the beginning of the buffer
866
+
867
+ if (typeof decodedMessage?.message[0]?.get === 'object') {
868
+ await Promise.all(
869
+ decodedMessage.message[0].get.map(async (trait) => {
870
+ if (trait.traitId.traitLabel === 'configuration_done') {
871
+ if (
872
+ this.#rawData[trait.traitId.resourceId]?.value?.configuration_done?.deviceReady !== true &&
873
+ trait.patch.values?.deviceReady === true
874
+ ) {
875
+ deviceChanges.push({ object_key: trait.traitId.resourceId, change: 'add' });
876
+ }
877
+ }
878
+ if (trait.traitId.traitLabel === 'camera_migration_status') {
879
+ // Handle case of camera/doorbell(s) which have been migrated from Nest to Google Home
880
+ if (
881
+ this.#rawData[trait.traitId.resourceId]?.value?.camera_migration_status?.state?.where !== 'MIGRATED_TO_GOOGLE_HOME' &&
882
+ trait.patch.values?.state?.where === 'MIGRATED_TO_GOOGLE_HOME' &&
883
+ this.#rawData[trait.traitId.resourceId]?.value?.camera_migration_status?.state?.progress !== 'PROGRESS_COMPLETE' &&
884
+ trait.patch.values?.state?.progress === 'PROGRESS_COMPLETE'
885
+ ) {
886
+ deviceChanges.push({ object_key: trait.traitId.resourceId, change: 'add' });
887
+ }
888
+ }
889
+
890
+ if (typeof this.#rawData[trait.traitId.resourceId] === 'undefined') {
891
+ this.#rawData[trait.traitId.resourceId] = {};
892
+ this.#rawData[trait.traitId.resourceId].connection = connectionType;
893
+ this.#rawData[trait.traitId.resourceId].source = NestAccfactory.DataSource.PROTOBUF;
894
+ this.#rawData[trait.traitId.resourceId].timers = {}; // No timers running for this object
895
+ this.#rawData[trait.traitId.resourceId].value = {};
896
+ }
897
+ this.#rawData[trait.traitId.resourceId]['value'][trait.traitId.traitLabel] =
898
+ typeof trait.patch.values !== 'undefined' ? trait.patch.values : {};
899
+
900
+ // We don't need to store the trait type, so remove it
901
+ delete this.#rawData[trait.traitId.resourceId]['value'][trait.traitId.traitLabel]['@type'];
902
+
903
+ // If we have structure location details and associated geo-location details, get the weather data for the location
904
+ // We'll store this in the object key/value as per REST API
905
+ if (
906
+ trait.traitId.resourceId.startsWith('STRUCTURE_') === true &&
907
+ trait.traitId.traitLabel === 'structure_location' &&
908
+ typeof trait.patch.values?.geoCoordinate?.latitude === 'number' &&
909
+ typeof trait.patch.values?.geoCoordinate?.longitude === 'number'
910
+ ) {
911
+ this.#rawData[trait.traitId.resourceId].value.weather = await this.#getWeatherData(
912
+ connectionType,
913
+ trait.traitId.resourceId,
914
+ trait.patch.values.geoCoordinate.latitude,
915
+ trait.patch.values.geoCoordinate.longitude,
916
+ );
917
+ }
918
+ }),
919
+ );
920
+
921
+ await this.#processPostSubscribe(deviceChanges);
922
+ deviceChanges = []; // No more device changes now
923
+ }
924
+ }
925
+ }
926
+ })
927
+ .catch((error) => {
928
+ if (error?.code !== 'ECONNRESET') {
929
+ this?.log?.error && this.log.error('protobuf API had error perform trait observe. Error was "%s"', error?.code);
930
+ }
931
+ })
932
+ .finally(() => {
933
+ setTimeout(this.#subscribeProtobuf.bind(this, connectionType), 1000);
934
+ });
935
+ }
936
+
937
+ async #processPostSubscribe(deviceChanges) {
938
+ // Process any device removals we have
939
+ Object.values(deviceChanges)
940
+ .filter((object) => object.change === 'remove')
941
+ .forEach((object) => {
942
+ if (typeof this.#rawData[object.object_key] === 'object') {
943
+ // Remove any timers that might have been associated with this device
944
+ Object.values(this.#rawData[object.object_key].timers).forEach((timerObject) => {
945
+ clearInterval(timerObject);
946
+ });
947
+
948
+ // Clean up structure
949
+ delete this.#rawData[object.object_key];
950
+ }
951
+
952
+ // Send removed notice onto HomeKit device for it to process
953
+ // This allows handling removal of a device without knowing its previous data
954
+ this.#eventEmitter.emit(object.object_key, HomeKitDevice.REMOVE, {});
955
+ });
956
+
957
+ Object.values(this.#processData('')).forEach((deviceData) => {
958
+ // Process any device additions we have
959
+ Object.values(deviceChanges)
960
+ .filter((object) => object.change === 'add')
961
+ .forEach((object) => {
962
+ if (object.object_key === deviceData.uuid && deviceData.excluded === true) {
963
+ this?.log?.warn && this.log.warn('Device "%s" ignored due to it being marked as excluded', deviceData.description);
964
+ }
965
+ if (object.object_key === deviceData.uuid && deviceData.excluded === false) {
966
+ // Device isn't marked as excluded, so create the required HomeKit accessories based upon the device data
967
+ if (deviceData.device_type === NestAccfactory.DeviceType.THERMOSTAT && typeof NestThermostat === 'function') {
968
+ // Nest Thermostat(s) - Categories.THERMOSTAT = 9
969
+ let tempDevice = new NestThermostat(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
970
+ tempDevice.add('Nest Thermostat', 9, true);
971
+ }
972
+
973
+ if (deviceData.device_type === NestAccfactory.DeviceType.TEMPSENSOR && typeof NestTemperatureSensor === 'function') {
974
+ // Nest Temperature Sensor - Categories.SENSOR = 10
975
+ let tempDevice = new NestTemperatureSensor(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
976
+ tempDevice.add('Nest Temperature Sensor', 10, true);
977
+ }
978
+
979
+ if (deviceData.device_type === NestAccfactory.DeviceType.SMOKESENSOR && typeof NestProtect === 'function') {
980
+ // Nest Protect(s) - Categories.SENSOR = 10
981
+ let tempDevice = new NestProtect(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
982
+ tempDevice.add('Nest Protect', 10, true);
983
+ }
984
+
985
+ if (
986
+ (deviceData.device_type === NestAccfactory.DeviceType.CAMERA ||
987
+ deviceData.device_type === NestAccfactory.DeviceType.DOORBELL) &&
988
+ (typeof NestCamera === 'function' || typeof NestDoorbell === 'function')
989
+ ) {
990
+ let accessoryName = 'Nest ' + deviceData.model.replace(/\s*(?:\([^()]*\))/gi, '');
991
+ if (deviceData.device_type === NestAccfactory.DeviceType.CAMERA) {
992
+ // Nest Camera(s) - Categories.IP_CAMERA = 17
993
+ let tempDevice = new NestCamera(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
994
+ tempDevice.add(accessoryName, 17, true);
995
+ }
996
+ if (deviceData.device_type === NestAccfactory.DeviceType.DOORBELL) {
997
+ // Nest Doorbell(s) - Categories.VIDEO_DOORBELL = 18
998
+ let tempDevice = new NestDoorbell(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
999
+ tempDevice.add(accessoryName, 18, true);
1000
+ }
1001
+
1002
+ // Setup polling loop for camera/doorbell zone data if not already created.
1003
+ // This is only required for REST API data sources as these details are present in protobuf API
1004
+ if (
1005
+ typeof this.#rawData[object.object_key]?.timers?.zones === 'undefined' &&
1006
+ this.#rawData[object.object_key].source === NestAccfactory.DataSource.REST
1007
+ ) {
1008
+ this.#rawData[object.object_key].timers.zones = setInterval(async () => {
1009
+ if (typeof this.#rawData[object.object_key]?.value === 'object') {
1010
+ let request = {
1011
+ method: 'get',
1012
+ url:
1013
+ this.#rawData[object.object_key].value.nexus_api_http_server_url +
1014
+ '/cuepoint_category/' +
1015
+ object.object_key.split('.')[1],
1016
+ headers: {
1017
+ referer: 'https://' + this.#connections[this.#rawData[object.object_key].connection].referer,
1018
+ 'User-Agent': USERAGENT,
1019
+ [this.#connections[this.#rawData[object.object_key].connection].cameraAPI.key]:
1020
+ this.#connections[this.#rawData[object.object_key].connection].cameraAPI.value +
1021
+ this.#connections[this.#rawData[object.object_key].connection].cameraAPI.token,
1022
+ },
1023
+ responseType: 'json',
1024
+ timeout: CAMERAZONEPOLLING,
1025
+ };
1026
+ await axios(request)
1027
+ .then((response) => {
1028
+ if (typeof response.status !== 'number' || response.status !== 200) {
1029
+ throw new Error('REST API had error retrieving camera/doorbell activity zones');
1030
+ }
1031
+
1032
+ let zones = [];
1033
+ response.data.forEach((zone) => {
1034
+ if (zone.type.toUpperCase() === 'ACTIVITY' || zone.type.toUpperCase() === 'REGION') {
1035
+ zones.push({
1036
+ id: zone.id === 0 ? 1 : zone.id,
1037
+ name: makeHomeKitName(zone.label),
1038
+ hidden: zone.hidden === true,
1039
+ uri: zone.nexusapi_image_uri,
1040
+ });
1041
+ }
1042
+ });
1043
+
1044
+ this.#rawData[object.object_key].value.activity_zones = zones;
1045
+
1046
+ // Send updated data onto HomeKit device for it to process
1047
+ this.#eventEmitter.emit(object.object_key, HomeKitDevice.UPDATE, {
1048
+ activity_zones: this.#rawData[object.object_key].value.activity_zones,
1049
+ });
1050
+ })
1051
+ .catch((error) => {
1052
+ // Log debug message if wasn't a timeout
1053
+ if (error?.code !== 'ECONNABORTED') {
1054
+ this?.log?.debug &&
1055
+ this.log.debug(
1056
+ 'REST API had error retrieving camera/doorbell activity zones for uuid "%s". Error was "%s"',
1057
+ object.object_key,
1058
+ error?.code,
1059
+ );
1060
+ }
1061
+ });
1062
+ }
1063
+ }, CAMERAZONEPOLLING);
1064
+ }
1065
+
1066
+ // Setup polling loop for camera/doorbell alert data if not already created
1067
+ if (typeof this.#rawData[object.object_key]?.timers?.alerts === 'undefined') {
1068
+ this.#rawData[object.object_key].timers.alerts = setInterval(async () => {
1069
+ if (
1070
+ typeof this.#rawData[object.object_key]?.value === 'object' &&
1071
+ this.#rawData[object.object_key]?.source === NestAccfactory.DataSource.PROTOBUF
1072
+ ) {
1073
+ let alerts = []; // No alerts yet
1074
+
1075
+ let commandResponse = await this.#protobufCommand(object.object_key, [
1076
+ {
1077
+ traitLabel: 'camera_observation_history',
1078
+ command: {
1079
+ type_url: 'type.nestlabs.com/nest.trait.history.CameraObservationHistoryTrait.CameraObservationHistoryRequest',
1080
+ value: {
1081
+ // We want camera history from now for upto 30secs from now
1082
+ queryStartTime: { seconds: Math.floor(Date.now() / 1000), nanos: (Math.round(Date.now()) % 1000) * 1e6 },
1083
+ queryEndTime: {
1084
+ seconds: Math.floor((Date.now() + 30000) / 1000),
1085
+ nanos: (Math.round(Date.now() + 30000) % 1000) * 1e6,
1086
+ },
1087
+ },
1088
+ },
1089
+ },
1090
+ ]);
1091
+
1092
+ if (
1093
+ typeof commandResponse?.resourceCommandResponse?.[0]?.traitOperations?.[0]?.event?.event?.cameraEventWindow
1094
+ ?.cameraEvent === 'object'
1095
+ ) {
1096
+ commandResponse.resourceCommandResponse[0].traitOperations[0].event.event.cameraEventWindow.cameraEvent.forEach(
1097
+ (event) => {
1098
+ alerts.push({
1099
+ playback_time: parseInt(event.startTime.seconds) * 1000 + parseInt(event.startTime.nanos) / 1000000,
1100
+ start_time: parseInt(event.startTime.seconds) * 1000 + parseInt(event.startTime.nanos) / 1000000,
1101
+ end_time: parseInt(event.endTime.seconds) * 1000 + parseInt(event.endTime.nanos) / 1000000,
1102
+ id: event.eventId,
1103
+ zone_ids:
1104
+ typeof event.activityZone === 'object'
1105
+ ? event.activityZone.map((zone) =>
1106
+ typeof zone?.zoneIndex === 'number' ? zone.zoneIndex : zone.internalIndex,
1107
+ )
1108
+ : [],
1109
+ types: event.eventType
1110
+ .map((event) => (event.startsWith('EVENT_') === true ? event.split('EVENT_')[1].toLowerCase() : ''))
1111
+ .filter((event) => event),
1112
+ });
1113
+
1114
+ // Fix up event types to match REST API
1115
+ // 'EVENT_UNFAMILIAR_FACE' = 'unfamiliar-face'
1116
+ // 'EVENT_PERSON_TALKING' = 'personHeard'
1117
+ // 'EVENT_DOG_BARKING' = 'dogBarking'
1118
+ // <---- TODO (as the ones we use match from protobuf)
1119
+ },
1120
+ );
1121
+
1122
+ // Sort alerts to be most recent first
1123
+ alerts = alerts.sort((a, b) => {
1124
+ if (a.start_time > b.start_time) {
1125
+ return -1;
1126
+ }
1127
+ });
1128
+ }
1129
+
1130
+ this.#rawData[object.object_key].value.alerts = alerts;
1131
+
1132
+ // Send updated data onto HomeKit device for it to process
1133
+ this.#eventEmitter.emit(object.object_key, HomeKitDevice.UPDATE, {
1134
+ alerts: this.#rawData[object.object_key].value.alerts,
1135
+ });
1136
+ }
1137
+
1138
+ if (
1139
+ typeof this.#rawData[object.object_key]?.value === 'object' &&
1140
+ this.#rawData[object.object_key]?.source === NestAccfactory.DataSource.REST
1141
+ ) {
1142
+ let alerts = []; // No alerts yet
1143
+ let request = {
1144
+ method: 'get',
1145
+ url:
1146
+ this.#rawData[object.object_key].value.nexus_api_http_server_url +
1147
+ '/cuepoint/' +
1148
+ object.object_key.split('.')[1] +
1149
+ '/2?start_time=' +
1150
+ Math.floor(Date.now() / 1000 - 30),
1151
+ headers: {
1152
+ referer: 'https://' + this.#connections[this.#rawData[object.object_key].connection].referer,
1153
+ 'User-Agent': USERAGENT,
1154
+ [this.#connections[this.#rawData[object.object_key].connection].cameraAPI.key]:
1155
+ this.#connections[this.#rawData[object.object_key].connection].cameraAPI.value +
1156
+ this.#connections[this.#rawData[object.object_key].connection].cameraAPI.token,
1157
+ },
1158
+ responseType: 'json',
1159
+ timeout: CAMERAALERTPOLLING,
1160
+ };
1161
+ await axios(request)
1162
+ .then((response) => {
1163
+ if (typeof response.status !== 'number' || response.status !== 200) {
1164
+ throw new Error('REST API had error retrieving camera/doorbell activity notifications');
1165
+ }
1166
+
1167
+ response.data.forEach((alert) => {
1168
+ // Fix up alert zone IDs. If there is an ID of 0, we'll transform to 1. ie: main zone
1169
+ // If there are NO zone IDs, we'll put a 1 in there ie: main zone
1170
+ alert.zone_ids = alert.zone_ids.map((id) => (id !== 0 ? id : 1));
1171
+ if (alert.zone_ids.length === 0) {
1172
+ alert.zone_ids.push(1);
1173
+ }
1174
+ alerts.push({
1175
+ playback_time: alert.playback_time,
1176
+ start_time: alert.start_time,
1177
+ end_time: alert.end_time,
1178
+ id: alert.id,
1179
+ zone_ids: alert.zone_ids,
1180
+ types: alert.types,
1181
+ });
1182
+ });
1183
+
1184
+ // Sort alerts to be most recent first
1185
+ alerts = alerts.sort((a, b) => {
1186
+ if (a.start_time > b.start_time) {
1187
+ return -1;
1188
+ }
1189
+ });
1190
+ })
1191
+ .catch((error) => {
1192
+ // Log debug message if wasn't a timeout
1193
+ if (error?.code !== 'ECONNABORTED') {
1194
+ this?.log?.debug &&
1195
+ this.log.debug(
1196
+ 'REST API had error retrieving camera/doorbell activity notifications for uuid "%s". Error was "%s"',
1197
+ object.object_key,
1198
+ error?.code,
1199
+ );
1200
+ }
1201
+ });
1202
+
1203
+ this.#rawData[object.object_key].value.alerts = alerts;
1204
+
1205
+ // Send updated data onto HomeKit device for it to process
1206
+ this.#eventEmitter.emit(object.object_key, HomeKitDevice.UPDATE, {
1207
+ alerts: this.#rawData[object.object_key].value.alerts,
1208
+ });
1209
+ }
1210
+ }, CAMERAALERTPOLLING);
1211
+ }
1212
+ }
1213
+ if (deviceData.device_type === NestAccfactory.DeviceType.WEATHER && typeof NestWeather === 'function') {
1214
+ // Nest 'Virtual' weather station - Categories.SENSOR = 10
1215
+ let tempDevice = new NestWeather(this.cachedAccessories, this.api, this.log, this.#eventEmitter, deviceData);
1216
+ tempDevice.add('Nest Weather', 10, true);
1217
+
1218
+ // Setup polling loop for weather data if not already created
1219
+ if (typeof this.#rawData[object.object_key]?.timers?.weather === 'undefined') {
1220
+ this.#rawData[object.object_key].timers.weather = setInterval(async () => {
1221
+ this.#rawData[object.object_key].value.weather = await this.#getWeatherData(
1222
+ this.#rawData[object.object_key].connection,
1223
+ object.object_key,
1224
+ this.#rawData[object.object_key].value.weather.latitude,
1225
+ this.#rawData[object.object_key].value.weather.longitude,
1226
+ );
1227
+
1228
+ // Send updated data onto HomeKit device for it to process
1229
+ this.#eventEmitter.emit(
1230
+ object.object_key,
1231
+ HomeKitDevice.UPDATE,
1232
+ this.#processData(object.object_key)[deviceData.serial_number],
1233
+ );
1234
+ }, WEATHERPOLLING);
1235
+ }
1236
+ }
1237
+ }
1238
+ });
1239
+
1240
+ // Finally, after processing device additions, if device is not excluded, send updated data to device for it to process
1241
+ if (deviceData.excluded === false) {
1242
+ this.#eventEmitter.emit(deviceData.uuid, HomeKitDevice.UPDATE, deviceData);
1243
+ }
1244
+ });
1245
+ }
1246
+
1247
+ #processData(deviceUUID) {
1248
+ if (typeof deviceUUID !== 'string') {
1249
+ deviceUUID = '';
1250
+ }
1251
+ let devices = {};
1252
+
1253
+ // Get the device(s) location from stucture
1254
+ // We'll test in both REST and protobuf API data
1255
+ const get_location_name = (structure_id, where_id) => {
1256
+ let location = '';
1257
+
1258
+ // Check REST data
1259
+ if (typeof this.#rawData['where.' + structure_id]?.value === 'object') {
1260
+ this.#rawData['where.' + structure_id].value.wheres.forEach((value) => {
1261
+ if (where_id === value.where_id) {
1262
+ location = value.name;
1263
+ }
1264
+ });
1265
+ }
1266
+
1267
+ // Check protobuf data
1268
+ if (typeof this.#rawData[structure_id]?.value?.located_annotations?.predefinedWheres === 'object') {
1269
+ Object.values(this.#rawData[structure_id].value.located_annotations.predefinedWheres).forEach((value) => {
1270
+ if (value.whereId.resourceId === where_id) {
1271
+ location = value.label.literal;
1272
+ }
1273
+ });
1274
+ }
1275
+ if (typeof this.#rawData[structure_id]?.value?.located_annotations?.customWheres === 'object') {
1276
+ Object.values(this.#rawData[structure_id].value.located_annotations.customWheres).forEach((value) => {
1277
+ if (value.whereId.resourceId === where_id) {
1278
+ location = value.label.literal;
1279
+ }
1280
+ });
1281
+ }
1282
+
1283
+ return location;
1284
+ };
1285
+
1286
+ // Process data for any thermostat(s) we have in the raw data
1287
+ const process_thermostat_data = (object_key, data) => {
1288
+ let processed = {};
1289
+ try {
1290
+ // Fix up data we need to
1291
+ data.serial_number = data.serial_number.toUpperCase(); // ensure serial numbers are in upper case
1292
+ data.excluded =
1293
+ typeof this.config?.devices?.[data.serial_number]?.exclude === 'boolean'
1294
+ ? this.config.devices[data.serial_number].exclude
1295
+ : false; // Mark device as excluded or not
1296
+ data.device_type = NestAccfactory.DeviceType.THERMOSTAT; // Nest Thermostat
1297
+ data.uuid = object_key; // Internal structure ID
1298
+ data.manufacturer = typeof data?.manufacturer === 'string' ? data.manufacturer : 'Nest';
1299
+ data.software_version = typeof data?.software_version === 'string' ? data.software_version.replace(/-/g, '.') : '0.0.0';
1300
+ data.target_temperature_high = adjustTemperature(data.target_temperature_high, 'C', 'C', true);
1301
+ data.target_temperature_low = adjustTemperature(data.target_temperature_low, 'C', 'C', true);
1302
+ data.target_temperature = adjustTemperature(data.target_temperature, 'C', 'C', true);
1303
+ data.backplate_temperature = adjustTemperature(data.backplate_temperature, 'C', 'C', true);
1304
+ data.current_temperature = adjustTemperature(data.current_temperature, 'C', 'C', true);
1305
+ data.battery_level = scaleValue(data.battery_level, 3.6, 3.9, 0, 100);
1306
+ let description = typeof data?.description === 'string' ? data.description : '';
1307
+ let location = typeof data?.location === 'string' ? data.location : '';
1308
+ if (description === '') {
1309
+ description = location;
1310
+ location = '';
1311
+ }
1312
+ data.description = makeHomeKitName(location === '' ? description : description + ' - ' + location);
1313
+ delete data.location;
1314
+
1315
+ // Insert details for when using HAP-NodeJS library rather than Homebridge
1316
+ if (typeof this.config?.options?.hkPairingCode === 'string' && this.config.options.hkPairingCode !== '') {
1317
+ data.hkPairingCode = this.config.options.hkPairingCode;
1318
+ }
1319
+ if (
1320
+ typeof this.config?.devices?.[data.serial_number]?.hkPairingCode === 'string' &&
1321
+ this.config.devices[data.serial_number].hkPairingCode !== ''
1322
+ ) {
1323
+ data.hkPairingCode = this.config.devices[data.serial_number].hkPairingCode;
1324
+ }
1325
+ if (data?.hkPairingCode !== undefined && data?.mac_address !== undefined) {
1326
+ // Create mac_address in format of xx:xx:xx:xx:xx:xx
1327
+ data.hkUsername = data.mac_address
1328
+ .toString('hex')
1329
+ .split(/(..)/)
1330
+ .filter((s) => s)
1331
+ .join(':')
1332
+ .toUpperCase();
1333
+ }
1334
+ delete data.mac_address;
1335
+
1336
+ processed = data;
1337
+ // eslint-disable-next-line no-unused-vars
1338
+ } catch (error) {
1339
+ // Empty
1340
+ }
1341
+ return processed;
1342
+ };
1343
+
1344
+ const PROTOBUF_THERMOSTAT_RESOURCES = [
1345
+ 'nest.resource.NestLearningThermostat3Resource',
1346
+ 'nest.resource.NestAgateDisplayResource',
1347
+ 'nest.resource.NestOnyxResource',
1348
+ 'google.resource.GoogleZirconium1Resource',
1349
+ 'google.resource.GoogleBismuth1Resource',
1350
+ ];
1351
+ Object.entries(this.#rawData)
1352
+ .filter(
1353
+ ([key, value]) =>
1354
+ (key.startsWith('device.') === true ||
1355
+ (key.startsWith('DEVICE_') === true && PROTOBUF_THERMOSTAT_RESOURCES.includes(value.value?.device_info?.typeName) === true)) &&
1356
+ (deviceUUID === '' || deviceUUID === key),
1357
+ )
1358
+ .forEach(([object_key, value]) => {
1359
+ let tempDevice = {};
1360
+ try {
1361
+ if (value.source === NestAccfactory.DataSource.PROTOBUF) {
1362
+ let RESTTypeData = {};
1363
+ RESTTypeData.mac_address = Buffer.from(value.value.wifi_interface.macAddress, 'base64');
1364
+ RESTTypeData.serial_number = value.value.device_identity.serialNumber;
1365
+ RESTTypeData.software_version = value.value.device_identity.softwareVersion;
1366
+ RESTTypeData.model = 'Thermostat';
1367
+ if (value.value.device_info.typeName === 'nest.resource.NestLearningThermostat3Resource') {
1368
+ RESTTypeData.model = 'Learning Thermostat (3rd gen)';
1369
+ }
1370
+ if (value.value.device_info.typeName === 'google.resource.GoogleBismuth1Resource') {
1371
+ RESTTypeData.model = 'Learning Thermostat (4th gen)';
1372
+ }
1373
+ if (value.value.device_info.typeName === 'nest.resource.NestAgateDisplayResource') {
1374
+ RESTTypeData.model = 'Thermostat E';
1375
+ }
1376
+ if (value.value.device_info.typeName === 'nest.resource.NestOnyxResource') {
1377
+ RESTTypeData.model = 'Thermostat E (1st gen)';
1378
+ }
1379
+ if (value.value.device_info.typeName === 'google.resource.GoogleZirconium1Resource') {
1380
+ RESTTypeData.model = 'Thermostat (2020 Model)';
1381
+ }
1382
+ RESTTypeData.current_humidity =
1383
+ typeof value.value.current_humidity.humidityValue.humidity.value === 'number'
1384
+ ? value.value.current_humidity.humidityValue.humidity.value
1385
+ : 0.0;
1386
+ RESTTypeData.temperature_scale = value.value.display_settings.temperatureScale === 'TEMPERATURE_SCALE_F' ? 'F' : 'C';
1387
+ RESTTypeData.removed_from_base = value.value.display.thermostatState.includes('bpd') === true;
1388
+ RESTTypeData.backplate_temperature = parseFloat(value.value.backplate_temperature.temperatureValue.temperature.value);
1389
+ RESTTypeData.current_temperature = parseFloat(value.value.current_temperature.temperatureValue.temperature.value);
1390
+ RESTTypeData.battery_level = parseFloat(value.value.battery_voltage.batteryValue.batteryVoltage.value);
1391
+ RESTTypeData.online = value.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE';
1392
+ RESTTypeData.leaf = value.value?.leaf?.active === true;
1393
+ RESTTypeData.has_humidifier = value.value.hvac_equipment_capabilities.hasHumidifier === true;
1394
+ RESTTypeData.has_dehumidifier = value.value.hvac_equipment_capabilities.hasDehumidifier === true;
1395
+ RESTTypeData.has_fan =
1396
+ typeof value.value.fan_control_capabilities.maxAvailableSpeed === 'string' &&
1397
+ value.value.fan_control_capabilities.maxAvailableSpeed !== 'FAN_SPEED_SETTING_OFF'
1398
+ ? true
1399
+ : false;
1400
+ RESTTypeData.can_cool =
1401
+ value.value.hvac_equipment_capabilities.hasStage1Cool === true ||
1402
+ value.value.hvac_equipment_capabilities.hasStage2Cool === true ||
1403
+ value.value.hvac_equipment_capabilities.hasStage3Cool === true;
1404
+ RESTTypeData.can_heat =
1405
+ value.value.hvac_equipment_capabilities.hasStage1Heat === true ||
1406
+ value.value.hvac_equipment_capabilities.hasStage2Heat === true ||
1407
+ value.value.hvac_equipment_capabilities.hasStage3Heat === true;
1408
+ RESTTypeData.temperature_lock = value.value.temperature_lock_settings.enabled === true;
1409
+ RESTTypeData.temperature_lock_pin_hash =
1410
+ typeof value.value.temperature_lock_settings.pinHash === 'string' && value.value.temperature_lock_settings.enabled === true
1411
+ ? value.value.temperature_lock_settings.pinHash
1412
+ : '';
1413
+ RESTTypeData.away = value.value.structure_mode.structureMode === 'STRUCTURE_MODE_AWAY';
1414
+ RESTTypeData.occupancy = value.value.structure_mode.structureMode === 'STRUCTURE_MODE_HOME';
1415
+ //RESTTypeData.occupancy = (value.value.structure_mode.occupancy.activity === 'ACTIVITY_ACTIVE');
1416
+ RESTTypeData.vacation_mode = value.value.structure_mode.structureMode === 'STRUCTURE_MODE_VACATION';
1417
+ RESTTypeData.description = typeof value.value.label?.label === 'string' ? value.value.label.label : '';
1418
+ RESTTypeData.location = get_location_name(
1419
+ value.value.device_info.pairerId.resourceId,
1420
+ value.value.device_located_settings.whereAnnotationRid.resourceId,
1421
+ );
1422
+
1423
+ // Work out current mode. ie: off, cool, heat, range and get temperature low/high and target
1424
+ RESTTypeData.hvac_mode =
1425
+ value.value.target_temperature_settings.enabled.value === true
1426
+ ? value.value.target_temperature_settings.targetTemperature.setpointType.split('SET_POINT_TYPE_')[1].toLowerCase()
1427
+ : 'off';
1428
+ RESTTypeData.target_temperature_low =
1429
+ typeof value.value.target_temperature_settings.targetTemperature.heatingTarget.value === 'number'
1430
+ ? value.value.target_temperature_settings.targetTemperature.heatingTarget.value
1431
+ : 0.0;
1432
+ RESTTypeData.target_temperature_high =
1433
+ typeof value.value.target_temperature_settings.targetTemperature.coolingTarget.value === 'number'
1434
+ ? value.value.target_temperature_settings.targetTemperature.coolingTarget.value
1435
+ : 0.0;
1436
+ if (value.value.target_temperature_settings.targetTemperature.setpointType === 'SET_POINT_TYPE_COOL') {
1437
+ // Target temperature is the cooling point
1438
+ RESTTypeData.target_temperature = value.value.target_temperature_settings.targetTemperature.coolingTarget.value;
1439
+ }
1440
+ if (value.value.target_temperature_settings.targetTemperature.setpointType === 'SET_POINT_TYPE_HEAT') {
1441
+ // Target temperature is the heating point
1442
+ RESTTypeData.target_temperature = value.value.target_temperature_settings.targetTemperature.heatingTarget.value;
1443
+ }
1444
+ if (value.value.target_temperature_settings.targetTemperature.setpointType === 'SET_POINT_TYPE_RANGE') {
1445
+ // Target temperature is in between the heating and cooling point
1446
+ RESTTypeData.target_temperature =
1447
+ (value.value.target_temperature_settings.targetTemperature.coolingTarget.value +
1448
+ value.value.target_temperature_settings.targetTemperature.heatingTarget.value) *
1449
+ 0.5;
1450
+ }
1451
+
1452
+ // Work out if eco mode is active and adjust temperature low/high and target
1453
+ if (value.value.eco_mode_state.ecoMode !== 'ECO_MODE_INACTIVE') {
1454
+ RESTTypeData.target_temperature_low = value.value.eco_mode_settings.ecoTemperatureHeat.value.value;
1455
+ RESTTypeData.target_temperature_high = value.value.eco_mode_settings.ecoTemperatureCool.value.value;
1456
+ if (
1457
+ value.value.eco_mode_settings.ecoTemperatureHeat.enabled === true &&
1458
+ value.value.eco_mode_settings.ecoTemperatureCool.enabled === false
1459
+ ) {
1460
+ RESTTypeData.target_temperature = value.value.eco_mode_settings.ecoTemperatureHeat.value.value;
1461
+ RESTTypeData.hvac_mode = 'ecoheat';
1462
+ }
1463
+ if (
1464
+ value.value.eco_mode_settings.ecoTemperatureHeat.enabled === false &&
1465
+ value.value.eco_mode_settings.ecoTemperatureCool.enabled === true
1466
+ ) {
1467
+ RESTTypeData.target_temperature = value.value.eco_mode_settings.ecoTemperatureCool.value.value;
1468
+ RESTTypeData.hvac_mode = 'ecocool';
1469
+ }
1470
+ if (
1471
+ value.value.eco_mode_settings.ecoTemperatureHeat.enabled === true &&
1472
+ value.value.eco_mode_settings.ecoTemperatureCool.enabled === true
1473
+ ) {
1474
+ RESTTypeData.target_temperature =
1475
+ (value.value.eco_mode_settings.ecoTemperatureCool.value.value +
1476
+ value.value.eco_mode_settings.ecoTemperatureHeat.value.value) *
1477
+ 0.5;
1478
+ RESTTypeData.hvac_mode = 'ecorange';
1479
+ }
1480
+ }
1481
+
1482
+ // Work out current state ie: heating, cooling etc
1483
+ RESTTypeData.hvac_state = 'off'; // By default, we're not heating or cooling
1484
+ if (
1485
+ value.value.hvac_control.hvacState.coolStage1Active === true ||
1486
+ value.value.hvac_control.hvacState.coolStage2Active === true ||
1487
+ value.value.hvac_control.hvacState.coolStage2Active === true
1488
+ ) {
1489
+ // A cooling source is on, so we're in cooling mode
1490
+ RESTTypeData.hvac_state = 'cooling';
1491
+ }
1492
+ if (
1493
+ value.value.hvac_control.hvacState.heatStage1Active === true ||
1494
+ value.value.hvac_control.hvacState.heatStage2Active === true ||
1495
+ value.value.hvac_control.hvacState.heatStage3Active === true ||
1496
+ value.value.hvac_control.hvacState.alternateHeatStage1Active === true ||
1497
+ value.value.hvac_control.hvacState.alternateHeatStage2Active === true ||
1498
+ value.value.hvac_control.hvacState.auxiliaryHeatActive === true ||
1499
+ value.value.hvac_control.hvacState.emergencyHeatActive === true
1500
+ ) {
1501
+ // A heating source is on, so we're in heating mode
1502
+ RESTTypeData.hvac_state = 'heating';
1503
+ }
1504
+
1505
+ // Update fan status, on or off and max number of speeds supported
1506
+ RESTTypeData.fan_state = parseInt(value.value.fan_control_settings.timerEnd?.seconds) > 0 ? true : false;
1507
+ RESTTypeData.fan_current_speed =
1508
+ value.value.fan_control_settings.timerSpeed.includes('FAN_SPEED_SETTING_STAGE') === true
1509
+ ? parseInt(value.value.fan_control_settings.timerSpeed.split('FAN_SPEED_SETTING_STAGE')[1])
1510
+ : 0;
1511
+ RESTTypeData.fan_max_speed =
1512
+ value.value.fan_control_capabilities.maxAvailableSpeed.includes('FAN_SPEED_SETTING_STAGE') === true
1513
+ ? parseInt(value.value.fan_control_capabilities.maxAvailableSpeed.split('FAN_SPEED_SETTING_STAGE')[1])
1514
+ : 0;
1515
+
1516
+ // Humidifier/dehumidifier details
1517
+ RESTTypeData.target_humidity = value.value.humidity_control_settings.targetHumidity.value;
1518
+ RESTTypeData.humidifier_state = value.value.hvac_control.hvacState.humidifierActive === true;
1519
+ RESTTypeData.dehumidifier_state = value.value.hvac_control.hvacState.dehumidifierActive === true;
1520
+
1521
+ // Air filter details
1522
+ RESTTypeData.has_air_filter = value.value.hvac_equipment_capabilities.hasAirFilter === true;
1523
+ RESTTypeData.filter_replacement_needed = value.value.filter_reminder.filterReplacementNeeded.value === true;
1524
+
1525
+ // Process any temperature sensors associated with this thermostat
1526
+ RESTTypeData.active_rcs_sensor =
1527
+ typeof value.value.remote_comfort_sensing_settings?.activeRcsSelection?.activeRcsSensor === 'string'
1528
+ ? value.value.remote_comfort_sensing_settings.activeRcsSelection.activeRcsSensor.resourceId
1529
+ : '';
1530
+ RESTTypeData.linked_rcs_sensors = [];
1531
+ if (typeof value.value?.remote_comfort_sensing_settings?.associatedRcsSensors === 'object') {
1532
+ value.value.remote_comfort_sensing_settings.associatedRcsSensors.forEach((sensor) => {
1533
+ if (typeof this.#rawData?.[sensor.deviceId.resourceId]?.value === 'object') {
1534
+ this.#rawData[sensor.deviceId.resourceId].value.associated_thermostat = object_key; // Sensor is linked to this thermostat
1535
+
1536
+ // Get sensor online/offline status
1537
+ // 'liveness' property doesn't appear in protobuf data for temp sensors, so we'll add that object here
1538
+ this.#rawData[sensor.deviceId.resourceId].value.liveness = {};
1539
+ this.#rawData[sensor.deviceId.resourceId].value.liveness.status = 'LIVENESS_DEVICE_STATUS_UNSPECIFIED';
1540
+ if (typeof value.value?.remote_comfort_sensing_state?.rcsSensorStatuses === 'object') {
1541
+ Object.values(value.value.remote_comfort_sensing_state.rcsSensorStatuses).forEach((sensorStatus) => {
1542
+ if (
1543
+ sensorStatus?.sensorId?.resourceId === sensor.deviceId.resourceId &&
1544
+ sensorStatus?.dataRecency?.includes('OK') === true
1545
+ ) {
1546
+ this.#rawData[sensor.deviceId.resourceId].value.liveness.status = 'LIVENESS_DEVICE_STATUS_ONLINE';
1547
+ }
1548
+ });
1549
+ }
1550
+ }
1551
+
1552
+ RESTTypeData.linked_rcs_sensors.push(sensor.deviceId.resourceId);
1553
+ });
1554
+ }
1555
+
1556
+ RESTTypeData.schedule_mode =
1557
+ value.value.target_temperature_settings.targetTemperature.setpointType.split('SET_POINT_TYPE_')[1].toLowerCase() !== 'off'
1558
+ ? value.value.target_temperature_settings.targetTemperature.setpointType.split('SET_POINT_TYPE_')[1].toLowerCase()
1559
+ : '';
1560
+ RESTTypeData.schedules = {};
1561
+
1562
+ if (
1563
+ typeof value.value[RESTTypeData.schedule_mode + '_schedule_settings'].setpoints === 'object' &&
1564
+ value.value[RESTTypeData.schedule_mode + '_schedule_settings'].type ===
1565
+ 'SET_POINT_SCHEDULE_TYPE_' + RESTTypeData.schedule_mode.toUpperCase()
1566
+ ) {
1567
+ Object.values(value.value[RESTTypeData.schedule_mode + '_schedule_settings'].setpoints).forEach((schedule) => {
1568
+ // Create REST API schedule entries
1569
+ const DAYSOFWEEK = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'];
1570
+ let dayofWeekIndex = DAYSOFWEEK.indexOf(schedule.dayOfWeek.split('DAY_OF_WEEK_')[1]);
1571
+
1572
+ if (typeof RESTTypeData.schedules[dayofWeekIndex] === 'undefined') {
1573
+ RESTTypeData.schedules[dayofWeekIndex] = {};
1574
+ }
1575
+
1576
+ RESTTypeData.schedules[dayofWeekIndex][Object.entries(RESTTypeData.schedules[dayofWeekIndex]).length] = {
1577
+ 'temp-min': adjustTemperature(schedule.heatingTarget.value, 'C', 'C', true),
1578
+ 'temp-max': adjustTemperature(schedule.coolingTarget.value, 'C', 'C', true),
1579
+ time: typeof schedule.secondsInDay === 'number' ? schedule.secondsInDay : 0,
1580
+ type: RESTTypeData.schedule_mode.toUpperCase(),
1581
+ entry_type: 'setpoint',
1582
+ };
1583
+ });
1584
+ }
1585
+
1586
+ tempDevice = process_thermostat_data(object_key, RESTTypeData);
1587
+ }
1588
+
1589
+ if (value.source === NestAccfactory.DataSource.REST) {
1590
+ let RESTTypeData = {};
1591
+ RESTTypeData.mac_address = value.value.mac_address;
1592
+ RESTTypeData.serial_number = value.value.serial_number;
1593
+ RESTTypeData.software_version = value.value.current_version;
1594
+ RESTTypeData.model = 'Thermostat';
1595
+ if (value.value.serial_number.serial_number.substring(0, 2) === '15') {
1596
+ RESTTypeData.model = 'Thermostat E (1st gen)'; // Nest Thermostat E
1597
+ }
1598
+ if (value.value.serial_number.serial_number.substring(0, 2) === '09') {
1599
+ RESTTypeData.model = 'Thermostat (3rd gen)'; // Nest Thermostat 3rd Gen
1600
+ }
1601
+ if (value.value.serial_number.serial_number.substring(0, 2) === '02') {
1602
+ RESTTypeData.model = 'Thermostat (2nd gen)'; // Nest Thermostat 2nd Gen
1603
+ }
1604
+ if (value.value.serial_number.serial_number.substring(0, 2) === '01') {
1605
+ RESTTypeData.model = 'Thermostat (1st gen)'; // Nest Thermostat 1st Gen
1606
+ }
1607
+ RESTTypeData.current_humidity = value.value.current_humidity;
1608
+ RESTTypeData.temperature_scale = value.value.temperature_scale;
1609
+ RESTTypeData.removed_from_base = value.value.nlclient_state.toUpperCase() === 'BPD';
1610
+ RESTTypeData.backplate_temperature = value.value.backplate_temperature;
1611
+ RESTTypeData.current_temperature = value.value.backplate_temperature;
1612
+ RESTTypeData.battery_level = value.value.battery_level;
1613
+ RESTTypeData.online = this.#rawData['track.' + value.value.serial_number].value.online === true;
1614
+ RESTTypeData.leaf = value.value.leaf === true;
1615
+ RESTTypeData.has_humidifier = value.value.has_humidifier === true;
1616
+ RESTTypeData.has_dehumidifier = value.value.has_dehumidifier === true;
1617
+ RESTTypeData.has_fan = value.value.has_fan === true;
1618
+ RESTTypeData.can_cool = this.#rawData['shared.' + value.value.serial_number].value.can_cool === true;
1619
+ RESTTypeData.can_heat = this.#rawData['shared.' + value.value.serial_number].value.can_heat === true;
1620
+ RESTTypeData.temperature_lock = value.value.temperature_lock === true;
1621
+ RESTTypeData.temperature_lock_pin_hash = value.value.temperature_lock_pin_hash;
1622
+ RESTTypeData.away = false;
1623
+ if (
1624
+ typeof this.#rawData['structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value
1625
+ ?.away === 'boolean'
1626
+ ) {
1627
+ RESTTypeData.away =
1628
+ this.#rawData['structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]].value.away;
1629
+ }
1630
+ if (
1631
+ this.#rawData['structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value
1632
+ ?.structure_mode?.structureMode === 'STRUCTURE_MODE_AWAY'
1633
+ ) {
1634
+ RESTTypeData.away = true;
1635
+ }
1636
+ RESTTypeData.occupancy = RESTTypeData.away === false; // Occupancy is opposite of away status ie: away is false, then occupied
1637
+ RESTTypeData.vacation_mode = false;
1638
+ if (
1639
+ typeof this.#rawData['structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value
1640
+ ?.vacation_mode === 'boolean'
1641
+ ) {
1642
+ RESTTypeData.vacation_mode =
1643
+ this.#rawData[
1644
+ 'structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]
1645
+ ].value.vacation_mode; // vacation mode
1646
+ }
1647
+ if (
1648
+ this.#rawData['structure.' + this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1]]?.value
1649
+ ?.structure_mode?.structureMode === 'STRUCTURE_MODE_VACATION'
1650
+ ) {
1651
+ RESTTypeData.vacation_mode = true;
1652
+ }
1653
+ RESTTypeData.description =
1654
+ typeof this.#rawData['shared.' + value.value.serial_number]?.value?.name === 'string'
1655
+ ? makeHomeKitName(this.#rawData['shared.' + value.value.serial_number].value.name)
1656
+ : '';
1657
+ RESTTypeData.location = get_location_name(
1658
+ this.#rawData['link.' + value.value.serial_number].value.structure.split('.')[1],
1659
+ value.value.where_id,
1660
+ );
1661
+
1662
+ // Work out current mode. ie: off, cool, heat, range and get temperature low (heat) and high (cool)
1663
+ RESTTypeData.hvac_mode = this.#rawData['shared.' + value.value.serial_number].value.target_temperature_type;
1664
+ RESTTypeData.target_temperature_low = this.#rawData['shared.' + value.value.serial_number].value.target_temperature_low;
1665
+ RESTTypeData.target_temperature_high = this.#rawData['shared.' + value.value.serial_number].value.target_temperature_high;
1666
+ if (this.#rawData['shared.' + value.value.serial_number].value.target_temperature_type.toUpperCase() === 'COOL') {
1667
+ // Target temperature is the cooling point
1668
+ RESTTypeData.target_temperature = this.#rawData['shared.' + value.value.serial_number].value.target_temperature_high;
1669
+ }
1670
+ if (this.#rawData['shared.' + value.value.serial_number].value.target_temperature_type.toUpperCase() === 'HEAT') {
1671
+ // Target temperature is the heating point
1672
+ RESTTypeData.target_temperature = this.#rawData['shared.' + value.value.serial_number].value.target_temperature_low;
1673
+ }
1674
+ if (this.#rawData['shared.' + value.value.serial_number].value.target_temperature_type.toUpperCase() === 'RANGE') {
1675
+ // Target temperature is in between the heating and cooling point
1676
+ RESTTypeData.target_temperature =
1677
+ (this.#rawData['shared.' + value.value.serial_number].value.target_temperature_low +
1678
+ this.#rawData['shared.' + value.value.serial_number].value.target_temperature_high) *
1679
+ 0.5;
1680
+ }
1681
+
1682
+ // Work out if eco mode is active and adjust temperature low/high and target
1683
+ if (value.value.eco.mode.toUpperCase() === 'AUTO-ECO' || value.value.eco.mode.toUpperCase() === 'MANUAL-ECO') {
1684
+ RESTTypeData.target_temperature_low = value.value.away_temperature_low;
1685
+ RESTTypeData.target_temperature_high = value.value.away_temperature_high;
1686
+ if (value.value.away_temperature_high_enabled === true && value.value.away_temperature_low_enabled === false) {
1687
+ RESTTypeData.target_temperature = value.value.away_temperature_low;
1688
+ RESTTypeData.hvac_mode = 'ecoheat';
1689
+ }
1690
+ if (value.value.away_temperature_high_enabled === true && value.value.away_temperature_low_enabled === false) {
1691
+ RESTTypeData.target_temperature = value.value.away_temperature_high;
1692
+ RESTTypeData.hvac_mode = 'ecocool';
1693
+ }
1694
+ if (value.value.away_temperature_high_enabled === true && value.value.away_temperature_low_enabled === true) {
1695
+ RESTTypeData.target_temperature = (value.value.away_temperature_low + value.value.away_temperature_high) * 0.5;
1696
+ RESTTypeData.hvac_mode = 'ecorange';
1697
+ }
1698
+ }
1699
+
1700
+ // Work out current state ie: heating, cooling etc
1701
+ RESTTypeData.hvac_state = 'off'; // By default, we're not heating or cooling
1702
+ if (
1703
+ this.#rawData['shared.' + value.value.serial_number].value.hvac_heater_state === true ||
1704
+ this.#rawData['shared.' + value.value.serial_number].value.hvac_heat_x2_state === true ||
1705
+ this.#rawData['shared.' + value.value.serial_number].value.hvac_heat_x3_state === true ||
1706
+ this.#rawData['shared.' + value.value.serial_number].value.hvac_aux_heater_state === true ||
1707
+ this.#rawData['shared.' + value.value.serial_number].value.hvac_alt_heat_x2_state === true ||
1708
+ this.#rawData['shared.' + value.value.serial_number].value.hvac_emer_heat_state === true ||
1709
+ this.#rawData['shared.' + value.value.serial_number].value.hvac_alt_heat_state === true
1710
+ ) {
1711
+ // A heating source is on, so we're in heating mode
1712
+ RESTTypeData.hvac_state = 'heating';
1713
+ }
1714
+ if (
1715
+ this.#rawData['shared.' + value.value.serial_number].value.hvac_ac_state === true ||
1716
+ this.#rawData['shared.' + value.value.serial_number].value.hvac_cool_x2_state === true ||
1717
+ this.#rawData['shared.' + value.value.serial_number].value.hvac_cool_x3_state === true
1718
+ ) {
1719
+ // A cooling source is on, so we're in cooling mode
1720
+ RESTTypeData.hvac_state = 'cooling';
1721
+ }
1722
+
1723
+ // Update fan status, on or off
1724
+ RESTTypeData.fan_state = value.value.fan_timer_timeout > 0 ? true : false;
1725
+ RESTTypeData.fan_current_speed =
1726
+ value.value.fan_timer_speed.includes('stage') === true ? parseInt(value.value.fan_timer_speed.split('stage')[1]) : 0;
1727
+ RESTTypeData.fan_max_speed =
1728
+ value.value.fan_capabilities.includes('stage') === true ? parseInt(value.value.fan_capabilities.split('stage')[1]) : 0;
1729
+
1730
+ // Humidifier/dehumidifier details
1731
+ RESTTypeData.target_humidity = typeof value.value.target_humidity === 'number' ? value.value.target_humidity : 0.0;
1732
+ RESTTypeData.humidifier_state = value.value.humidifier_state === true;
1733
+ RESTTypeData.dehumidifier_state = value.value.dehumidifier_state === true;
1734
+
1735
+ // Air filter details
1736
+ RESTTypeData.has_air_filter = value.value.has_air_filter === true;
1737
+ RESTTypeData.filter_replacement_needed = value.value.filter_replacement_needed === true;
1738
+
1739
+ // Process any temperature sensors associated with this thermostat
1740
+ RESTTypeData.active_rcs_sensor = '';
1741
+ RESTTypeData.linked_rcs_sensors = [];
1742
+ this.#rawData['rcs_settings.' + value.value.serial_number].value.associated_rcs_sensors.forEach((sensor) => {
1743
+ if (typeof this.#rawData[sensor]?.value === 'object') {
1744
+ this.#rawData[sensor].value.associated_thermostat = object_key; // Sensor is linked to this thermostat
1745
+
1746
+ // Is this sensor the active one? If so, get some details about it
1747
+ if (this.#rawData['rcs_settings.' + value.value.serial_number].value.active_rcs_sensors.includes(sensor)) {
1748
+ RESTTypeData.active_rcs_sensor = this.#rawData[sensor].value.serial_number.toUpperCase();
1749
+ RESTTypeData.current_temperature = this.#rawData[sensor].value.current_temperature;
1750
+ }
1751
+ RESTTypeData.linked_rcs_sensors.push(this.#rawData[sensor].value.serial_number.toUpperCase());
1752
+ }
1753
+ });
1754
+
1755
+ // Get associated schedules
1756
+ if (typeof this.#rawData['schedule.' + value.value.serial_number] === 'object') {
1757
+ Object.values(this.#rawData['schedule.' + value.value.serial_number].value.days).forEach((schedules) => {
1758
+ Object.values(schedules).forEach((schedule) => {
1759
+ // Fix up temperatures in the schedule
1760
+ if (typeof schedule['temp'] === 'number') {
1761
+ schedule.temp = adjustTemperature(schedule.temp, 'C', 'C', true);
1762
+ }
1763
+ if (typeof schedule['temp-min'] === 'number') {
1764
+ schedule['temp-min'] = adjustTemperature(schedule['temp-min'], 'C', 'C', true);
1765
+ }
1766
+ if (typeof schedule['temp-max'] === 'number') {
1767
+ schedule['temp-max'] = adjustTemperature(schedule['temp-max'], 'C', 'C', true);
1768
+ }
1769
+ });
1770
+ });
1771
+ RESTTypeData.schedules = this.#rawData['schedule.' + value.value.serial_number].value.days;
1772
+ RESTTypeData.schedule_mode = this.#rawData['schedule.' + value.value.serial_number].value.schedule_mode;
1773
+ }
1774
+
1775
+ tempDevice = process_thermostat_data(object_key, RESTTypeData);
1776
+ }
1777
+ // eslint-disable-next-line no-unused-vars
1778
+ } catch (error) {
1779
+ this?.log?.debug && this.log.debug('Error processing data for thermostat(s)');
1780
+ }
1781
+
1782
+ if (Object.entries(tempDevice).length !== 0 && typeof devices[tempDevice.serial_number] === 'undefined') {
1783
+ // Insert any extra options we've read in from configuration file for this device
1784
+ tempDevice.eveHistory =
1785
+ this.config.options.eveHistory === true || this.config?.devices?.[tempDevice.serial_number]?.eveHistory === true;
1786
+ tempDevice.humiditySensor = this.config?.devices?.[tempDevice.serial_number]?.humiditySensor === true;
1787
+ tempDevice.externalCool =
1788
+ typeof this.config?.devices?.[tempDevice.serial_number]?.externalCool === 'string'
1789
+ ? this.config.devices[tempDevice.serial_number].externalCool
1790
+ : undefined; // Config option for external cooling source
1791
+ tempDevice.externalHeat =
1792
+ typeof this.config?.devices?.[tempDevice.serial_number]?.externalHeat === 'string'
1793
+ ? this.config.devices[tempDevice.serial_number].externalHeat
1794
+ : undefined; // Config option for external heating source
1795
+ tempDevice.externalFan =
1796
+ typeof this.config?.devices?.[tempDevice.serial_number]?.externalFan === 'string'
1797
+ ? this.config.devices[tempDevice.serial_number].externalFan
1798
+ : undefined; // Config option for external fan source
1799
+ tempDevice.externalDehumidifier =
1800
+ typeof this.config?.devices?.[tempDevice.serial_number]?.externalDehumidifier === 'string'
1801
+ ? this.config.devices[tempDevice.serial_number].externalDehumidifier
1802
+ : undefined; // Config option for external dehumidifier source
1803
+ devices[tempDevice.serial_number] = tempDevice; // Store processed device
1804
+ }
1805
+ });
1806
+
1807
+ // Process data for any temperature sensors we have in the raw data
1808
+ // This is done AFTER where have processed thermostat(s) as we inserted some extra details in there
1809
+ // We only process if the sensor has been associated to a thermostat
1810
+ const process_kryptonite_data = (object_key, data) => {
1811
+ let processed = {};
1812
+ try {
1813
+ // Fix up data we need to
1814
+ data.serial_number = data.serial_number.toUpperCase();
1815
+ data.excluded =
1816
+ typeof this.config?.devices?.[data.serial_number]?.exclude === 'boolean'
1817
+ ? this.config.devices[data.serial_number].exclude
1818
+ : false; // Mark device as excluded or not
1819
+ data.device_type = NestAccfactory.DeviceType.TEMPSENSOR; // Nest Temperature sensor
1820
+ data.uuid = object_key; // Internal structure ID
1821
+ data.manufacturer = typeof data?.manufacturer === 'string' ? data.manufacturer : 'Nest';
1822
+ data.software_version = typeof data?.software_version === 'string' ? data.software_version.replace(/-/g, '.') : '0.0.0';
1823
+ data.model = 'Temperature Sensor';
1824
+ data.current_temperature = adjustTemperature(data.current_temperature, 'C', 'C', true);
1825
+ let description = typeof data?.description === 'string' ? data.description : '';
1826
+ let location = typeof data?.location === 'string' ? data.location : '';
1827
+ if (description === '') {
1828
+ description = location;
1829
+ location = '';
1830
+ }
1831
+ data.description = makeHomeKitName(location === '' ? description : description + ' - ' + location);
1832
+ delete data.location;
1833
+
1834
+ // Insert details for when using HAP-NodeJS library rather than Homebridge
1835
+ if (typeof this.config?.options?.hkPairingCode === 'string' && this.config.options.hkPairingCode !== '') {
1836
+ data.hkPairingCode = this.config.options.hkPairingCode;
1837
+ }
1838
+ if (
1839
+ typeof this.config?.devices?.[data.serial_number]?.hkPairingCode === 'string' &&
1840
+ this.config.devices[data.serial_number].hkPairingCode !== ''
1841
+ ) {
1842
+ data.hkPairingCode = this.config.devices[data.serial_number].hkPairingCode;
1843
+ }
1844
+ if (data?.hkPairingCode !== undefined) {
1845
+ // Use a Nest Labs prefix for first 6 digits, followed by a CRC24 based off serial number for last 6 digits.
1846
+ let tempMACAddress = '18B430' + crc24(data.serial_number).toUpperCase();
1847
+ data.hkUsername = tempMACAddress
1848
+ .toString('hex')
1849
+ .split(/(..)/)
1850
+ .filter((s) => s)
1851
+ .join(':')
1852
+ .toUpperCase(); // Create mac_address in format of xx:xx:xx:xx:xx:xx
1853
+ }
1854
+ delete data.mac_address;
1855
+
1856
+ processed = data;
1857
+ // eslint-disable-next-line no-unused-vars
1858
+ } catch (error) {
1859
+ // Empty
1860
+ }
1861
+ return processed;
1862
+ };
1863
+
1864
+ Object.entries(this.#rawData)
1865
+ .filter(
1866
+ ([key, value]) =>
1867
+ (key.startsWith('kryptonite.') === true ||
1868
+ (key.startsWith('DEVICE_') === true && value.value?.device_info?.typeName === 'nest.resource.NestKryptoniteResource')) &&
1869
+ (deviceUUID === '' || deviceUUID === key),
1870
+ )
1871
+ .forEach(([object_key, value]) => {
1872
+ let tempDevice = {};
1873
+ try {
1874
+ if (
1875
+ value.source === NestAccfactory.DataSource.PROTOBUF &&
1876
+ typeof value?.value?.associated_thermostat === 'string' &&
1877
+ value?.value?.associated_thermostat !== ''
1878
+ ) {
1879
+ let RESTTypeData = {};
1880
+ RESTTypeData.serial_number = value.value.device_identity.serialNumber;
1881
+ // Guessing battery minimum voltage is 2v??
1882
+ RESTTypeData.battery_level = scaleValue(value.value.battery.assessedVoltage.value, 2.0, 3.0, 0, 100);
1883
+ RESTTypeData.current_temperature = value.value.current_temperature.temperatureValue.temperature.value;
1884
+ // Online status we 'faked' when processing Thermostat protobuf data
1885
+ RESTTypeData.online = value.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE';
1886
+ RESTTypeData.associated_thermostat = value.value.associated_thermostat;
1887
+ RESTTypeData.description = typeof value.value?.label?.label === 'string' ? value.value.label.label : '';
1888
+ RESTTypeData.location = get_location_name(
1889
+ value.value.device_info.pairerId.resourceId,
1890
+ value.value.device_located_settings.whereAnnotationRid.resourceId,
1891
+ );
1892
+ RESTTypeData.active_sensor =
1893
+ this.#rawData[value.value.associated_thermostat].value?.remote_comfort_sensing_settings?.activeRcsSelection?.activeRcsSensor
1894
+ ?.resourceId === object_key;
1895
+ tempDevice = process_kryptonite_data(object_key, RESTTypeData);
1896
+ }
1897
+ if (
1898
+ value.source === NestAccfactory.DataSource.REST &&
1899
+ typeof value?.value?.associated_thermostat === 'string' &&
1900
+ value?.value?.associated_thermostat !== ''
1901
+ ) {
1902
+ let RESTTypeData = {};
1903
+ RESTTypeData.serial_number = value.value.sserial_number;
1904
+ RESTTypeData.battery_level = scaleValue(value.value.battery_level, 0, 100, 0, 100);
1905
+ RESTTypeData.current_temperature = value.value.current_temperature;
1906
+ RESTTypeData.online = Math.floor(Date.now() / 1000) - value.value.last_updated_at < 3600 * 4 ? true : false;
1907
+ RESTTypeData.associated_thermostat = value.value.associated_thermostat;
1908
+ RESTTypeData.description = value.value.description;
1909
+ RESTTypeData.location = get_location_name(value.value.structure_id, value.value.where_id);
1910
+ RESTTypeData.active_sensor =
1911
+ this.#rawData['rcs_settings.' + value.value.associated_thermostat].value.active_rcs_sensors.includes(object_key) === true;
1912
+ tempDevice = process_kryptonite_data(object_key, RESTTypeData);
1913
+ }
1914
+ // eslint-disable-next-line no-unused-vars
1915
+ } catch (error) {
1916
+ this?.log?.debug && this.log.debug('Error processing data for temperature sensor(s)');
1917
+ }
1918
+ if (Object.entries(tempDevice).length !== 0 && typeof devices[tempDevice.serial_number] === 'undefined') {
1919
+ // Insert any extra options we've read in from configuration file for this device
1920
+ tempDevice.eveHistory =
1921
+ this.config.options.eveHistory === true || this.config?.devices?.[tempDevice.serial_number]?.eveHistory === true;
1922
+ devices[tempDevice.serial_number] = tempDevice; // Store processed device
1923
+ }
1924
+ });
1925
+
1926
+ // Process data for any smoke detectors we have in the raw data
1927
+ const process_protect_data = (object_key, data) => {
1928
+ let processed = {};
1929
+ try {
1930
+ // Fix up data we need to
1931
+ data.serial_number = data.serial_number.toUpperCase(); // ensure serial numbers are in upper case
1932
+ data.excluded =
1933
+ typeof this.config?.devices?.[data.serial_number]?.exclude === 'boolean'
1934
+ ? this.config.devices[data.serial_number].exclude
1935
+ : false; // Mark device as excluded or not
1936
+ data.device_type = NestAccfactory.DeviceType.SMOKESENSOR; // Nest Protect
1937
+ data.uuid = object_key; // Internal structure ID
1938
+ data.manufacturer = typeof data?.manufacturer === 'string' ? data.manufacturer : 'Nest';
1939
+ data.software_version = typeof data?.software_version === 'string' ? data.software_version.replace(/-/g, '.') : '0.0.0';
1940
+ data.battery_level = scaleValue(data.battery_level, 0, 5400, 0, 100);
1941
+ data.model = 'Protect';
1942
+ if (data.wired_or_battery === 0) {
1943
+ data.model = data.model + ' (wired'; // Mains powered
1944
+ }
1945
+ if (data.wired_or_battery === 1) {
1946
+ data.model = data.model + ' (battery'; // Battery powered
1947
+ }
1948
+ if (data.serial_number.substring(0, 2) === '06') {
1949
+ data.model = data.model + ', 2nd gen)'; // Nest Protect 2nd Gen
1950
+ }
1951
+ if (data.serial_number.substring(0, 2) === '05') {
1952
+ data.model = data.model + ', 1st gen)'; // Nest Protect 1st Gen
1953
+ }
1954
+ let description = typeof data?.description === 'string' ? data.description : '';
1955
+ let location = typeof data?.location === 'string' ? data.location : '';
1956
+ if (description === '') {
1957
+ description = location;
1958
+ location = '';
1959
+ }
1960
+ data.description = makeHomeKitName(location === '' ? description : description + ' - ' + location);
1961
+ delete data.location;
1962
+
1963
+ // Insert details for when using HAP-NodeJS library rather than Homebridge
1964
+ if (typeof this.config?.options?.hkPairingCode === 'string' && this.config.options.hkPairingCode !== '') {
1965
+ data.hkPairingCode = this.config.options.hkPairingCode;
1966
+ }
1967
+ if (
1968
+ typeof this.config?.devices?.[data.serial_number]?.hkPairingCode === 'string' &&
1969
+ this.config.devices[data.serial_number].hkPairingCode !== ''
1970
+ ) {
1971
+ data.hkPairingCode = this.config.devices[data.serial_number].hkPairingCode;
1972
+ }
1973
+ if (data?.hkPairingCode !== undefined && data?.mac_address !== undefined) {
1974
+ // Create mac_address in format of xx:xx:xx:xx:xx:xx
1975
+ data.hkUsername = data.mac_address
1976
+ .toString('hex')
1977
+ .split(/(..)/)
1978
+ .filter((s) => s)
1979
+ .join(':')
1980
+ .toUpperCase();
1981
+ }
1982
+ delete data.mac_address;
1983
+
1984
+ processed = data;
1985
+ // eslint-disable-next-line no-unused-vars
1986
+ } catch (error) {
1987
+ // Empty
1988
+ }
1989
+ return processed;
1990
+ };
1991
+
1992
+ Object.entries(this.#rawData)
1993
+ .filter(
1994
+ ([key, value]) =>
1995
+ (key.startsWith('topaz.') === true ||
1996
+ (key.startsWith('DEVICE_') === true && value.value?.device_info?.className.startsWith('topaz') === true)) &&
1997
+ (deviceUUID === '' || deviceUUID === key),
1998
+ )
1999
+ .forEach(([object_key, value]) => {
2000
+ let tempDevice = {};
2001
+ try {
2002
+ if (value.source === NestAccfactory.DataSource.PROTOBUF) {
2003
+ /*
2004
+ let RESTTypeData = {};
2005
+ RESTTypeData.mac_address = Buffer.from(value.value.wifi_interface.macAddress, 'base64');
2006
+ RESTTypeData.serial_number = value.value.device_identity.serialNumber;
2007
+ RESTTypeData.software_version = value.value.device_identity.softwareVersion;
2008
+ RESTTypeData.online = value.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE';
2009
+ RESTTypeData.line_power_present = value.value?.wall_power?.status === 'POWER_SOURCE_STATUS_ACTIVE';
2010
+ RESTTypeData.wired_or_battery = typeof value.value?.wall_power === 'object' ? 0 : 1;
2011
+ RESTTypeData.battery_level = parseFloat(value.value.battery_voltage_bank1.batteryValue.batteryVoltage.value);
2012
+ RESTTypeData.battery_health_state = value.value.battery_voltage_bank1.faultInformation;
2013
+ RESTTypeData.smoke_status = value.value.safety_alarm_smoke.alarmState === 'ALARM_STATE_ALARM' ? 2 : 0; // matches REST data
2014
+ RESTTypeData.co_status = value.value.safety_alarm_co.alarmState === 'ALARM_STATE_ALARM' ? 2 : 0; // matches REST data
2015
+ RESTTypeData.heat_status =
2016
+ RESTTypeData.hushed_state =
2017
+ value.value.safety_alarm_smoke.silenceState === 'SILENCE_STATE_SILENCED' ||
2018
+ value.value.safety_alarm_co.silenceState === 'SILENCE_STATE_SILENCED';
2019
+ RESTTypeData.ntp_green_led = value.value.night_time_promise_settings.greenLedEnabled === true;
2020
+ RESTTypeData.smoke_test_passed = value.value.safety_summary.warningDevices.failures.includes('FAILURE_TYPE_SMOKE') === false;
2021
+ RESTTypeData.heat_test_passed = value.value.safety_summary.warningDevices.failures.includes('FAILURE_TYPE_TEMP') === false;
2022
+ RESTTypeData.latest_alarm_test =
2023
+ parseInt(value.value.self_test.lastMstEnd?.second) > 0 ? parseInt(value.value.self_test.lastMstEnd.seconds) : 0;
2024
+ RESTTypeData.self_test_in_progress =
2025
+ value.value.legacy_structure_self_test.mstInProgress === true ||
2026
+ value.value.legacy_structure_self_test.astInProgress === true;
2027
+ RESTTypeData.replacement_date =
2028
+ value.value.legacy_protect_device_settings.replaceByDate.hasOwnProperty('seconds') === true
2029
+ ? parseInt(value.value.legacy_protect_device_settings.replaceByDate.seconds)
2030
+ : 0;
2031
+
2032
+ RESTTypeData.removed_from_base =
2033
+ RESTTypeData.topaz_hush_key =
2034
+ typeof value.value.safety_structure_settings.structureHushKey === 'string'
2035
+ ? value.value.safety_structure_settings.structureHushKey
2036
+ : '';
2037
+ RESTTypeData.detected_motion = value.value.legacy_protect_device_info.autoAway === false;
2038
+ RESTTypeData.description = typeof value.value?.label?.label === 'string' ? value.value.label.label : '';
2039
+ RESTTypeData.location = get_location_name(
2040
+ value.value.device_info.pairerId.resourceId,
2041
+ value.value.device_located_settings.whereAnnotationRid.resourceId,
2042
+ );
2043
+ tempDevice = process_protect_data(object_key, RESTTypeData);
2044
+ */
2045
+ }
2046
+
2047
+ if (value.source === NestAccfactory.DataSource.REST) {
2048
+ let RESTTypeData = {};
2049
+ RESTTypeData.mac_address = value.value.wifi_mac_address;
2050
+ RESTTypeData.serial_number = value.value.serial_number;
2051
+ RESTTypeData.software_version = value.value.software_version;
2052
+ RESTTypeData.online = this.#rawData['widget_track.' + value.value.thread_mac_address.toUpperCase()].value.online === true;
2053
+ RESTTypeData.line_power_present = value.value.line_power_present === true;
2054
+ RESTTypeData.wired_or_battery = value.value.wired_or_battery;
2055
+ RESTTypeData.battery_level = value.value.battery_level;
2056
+ RESTTypeData.battery_health_state = value.value.battery_health_state;
2057
+ RESTTypeData.smoke_status = value.value.smoke_status;
2058
+ RESTTypeData.co_status = value.value.co_status;
2059
+ RESTTypeData.heat_status = value.value.heat_status;
2060
+ RESTTypeData.hushed_state = value.value.hushed_state === true;
2061
+ RESTTypeData.ntp_green_led = value.value.ntp_green_led_enable === true;
2062
+ RESTTypeData.smoke_test_passed = value.value.component_smoke_test_passed === true;
2063
+ RESTTypeData.heat_test_passed = value.value.component_temp_test_passed === true;
2064
+ RESTTypeData.latest_alarm_test = value.value.latest_manual_test_end_utc_secs;
2065
+ RESTTypeData.self_test_in_progress =
2066
+ this.#rawData['safety.' + value.value.structure_id].value.manual_self_test_in_progress === true;
2067
+ RESTTypeData.replacement_date = value.value.replace_by_date_utc_secs;
2068
+ RESTTypeData.removed_from_base = value.value.removed_from_base === true;
2069
+ RESTTypeData.topaz_hush_key =
2070
+ typeof this.#rawData['structure.' + value.value.structure_id]?.value?.topaz_hush_key === 'string'
2071
+ ? this.#rawData['structure.' + value.value.structure_id].value.topaz_hush_key
2072
+ : '';
2073
+ RESTTypeData.detected_motion = value.value.auto_away === false;
2074
+ RESTTypeData.description = value.value?.description;
2075
+ RESTTypeData.location = get_location_name(value.value.structure_id, value.value.where_id);
2076
+ tempDevice = process_protect_data(object_key, RESTTypeData);
2077
+ }
2078
+ // eslint-disable-next-line no-unused-vars
2079
+ } catch (error) {
2080
+ this?.log?.debug && this.log.debug('Error processing data for smoke sensor(s)');
2081
+ }
2082
+
2083
+ if (Object.entries(tempDevice).length !== 0 && typeof devices[tempDevice.serial_number] === 'undefined') {
2084
+ // Insert any extra options we've read in from configuration file for this device
2085
+ tempDevice.eveHistory =
2086
+ this.config.options.eveHistory === true || this.config?.devices?.[tempDevice.serial_number]?.eveHistory === true;
2087
+ devices[tempDevice.serial_number] = tempDevice; // Store processed device
2088
+ }
2089
+ });
2090
+
2091
+ // Process data for any camera/doorbell(s) we have in the raw data
2092
+ const process_camera_doorbell_data = (object_key, data) => {
2093
+ let processed = {};
2094
+ try {
2095
+ // Fix up data we need to
2096
+ data.serial_number = data.serial_number.toUpperCase(); // ensure serial numbers are in upper case
2097
+ data.excluded =
2098
+ typeof this.config?.devices?.[data.serial_number]?.exclude === 'boolean'
2099
+ ? this.config.devices[data.serial_number].exclude
2100
+ : false; // Mark device as excluded or not
2101
+ data.device_type = NestAccfactory.DeviceType.CAMERA;
2102
+ if (data.model.toUpperCase().includes('DOORBELL') === true) {
2103
+ data.device_type = NestAccfactory.DeviceType.DOORBELL;
2104
+ }
2105
+ data.uuid = object_key; // Internal structure ID
2106
+ data.manufacturer = typeof data?.manufacturer === 'string' ? data.manufacturer : 'Nest';
2107
+ data.software_version = typeof data?.software_version === 'string' ? data.software_version.replace(/-/g, '.') : '0.0.0';
2108
+ let description = typeof data?.description === 'string' ? data.description : '';
2109
+ let location = typeof data?.location === 'string' ? data.location : '';
2110
+ if (description === '') {
2111
+ description = location;
2112
+ location = '';
2113
+ }
2114
+ data.description = makeHomeKitName(location === '' ? description : description + ' - ' + location);
2115
+ delete data.location;
2116
+
2117
+ // Insert details for when using HAP-NodeJS library rather than Homebridge
2118
+ if (typeof this.config?.options?.hkPairingCode === 'string' && this.config.options.hkPairingCode !== '') {
2119
+ data.hkPairingCode = this.config.options.hkPairingCode;
2120
+ }
2121
+ if (
2122
+ typeof this.config?.devices?.[data.serial_number]?.hkPairingCode === 'string' &&
2123
+ this.config.devices[data.serial_number].hkPairingCode !== ''
2124
+ ) {
2125
+ data.hkPairingCode = this.config.devices[data.serial_number].hkPairingCode;
2126
+ }
2127
+ if (data?.hkPairingCode !== undefined && data?.mac_address !== undefined) {
2128
+ // Create mac_address in format of xx:xx:xx:xx:xx:xx
2129
+ data.hkUsername = data.mac_address
2130
+ .toString('hex')
2131
+ .split(/(..)/)
2132
+ .filter((s) => s)
2133
+ .join(':')
2134
+ .toUpperCase();
2135
+ }
2136
+ delete data.mac_address;
2137
+
2138
+ processed = data;
2139
+ // eslint-disable-next-line no-unused-vars
2140
+ } catch (error) {
2141
+ // Empty
2142
+ }
2143
+ return processed;
2144
+ };
2145
+
2146
+ const PROTOBUF_CAMERA_DOORBELL_RESOURCES = [
2147
+ 'google.resource.NeonQuartzResource',
2148
+ 'google.resource.GreenQuartzResource',
2149
+ 'google.resource.SpencerResource',
2150
+ 'google.resource.VenusResource',
2151
+ 'nest.resource.NestCamIndoorResource',
2152
+ 'nest.resource.NestCamIQResource',
2153
+ 'nest.resource.NestCamIQOutdoorResource',
2154
+ 'nest.resource.NestHelloResource',
2155
+ 'google.resource.AzizResource',
2156
+ 'google.resource.GoogleNewmanResource',
2157
+ ];
2158
+ Object.entries(this.#rawData)
2159
+ .filter(
2160
+ ([key, value]) =>
2161
+ (key.startsWith('quartz.') === true ||
2162
+ (key.startsWith('DEVICE_') === true &&
2163
+ PROTOBUF_CAMERA_DOORBELL_RESOURCES.includes(value.value?.device_info?.typeName) === true)) &&
2164
+ (deviceUUID === '' || deviceUUID === key),
2165
+ )
2166
+ .forEach(([object_key, value]) => {
2167
+ let tempDevice = {};
2168
+ try {
2169
+ if (value.source === NestAccfactory.DataSource.PROTOBUF && value.value?.streaming_protocol !== undefined) {
2170
+ let RESTTypeData = {};
2171
+ //RESTTypeData.mac_address = value.value.wifi_interface.macAddress.toString('hex');
2172
+ // Use a Nest Labs prefix for first 6 digits, followed by a CRC24 based off serial number for last 6 digits.
2173
+ RESTTypeData.mac_address = '18B430' + crc24(value.value.device_identity.serialNumber.toUpperCase()).toUpperCase();
2174
+ RESTTypeData.serial_number = value.value.device_identity.serialNumber;
2175
+ RESTTypeData.software_version = value.value.device_identity.softwareVersion.replace(/[^0-9.]/g, '');
2176
+ RESTTypeData.model = 'Camera';
2177
+ if (value.value.device_info.typeName === 'google.resource.NeonQuartzResource') {
2178
+ RESTTypeData.model = 'Cam (battery)';
2179
+ }
2180
+ if (value.value.device_info.typeName === 'google.resource.GreenQuartzResource') {
2181
+ RESTTypeData.model = 'Doorbell (battery)';
2182
+ }
2183
+ if (value.value.device_info.typeName === 'google.resource.SpencerResource') {
2184
+ RESTTypeData.model = 'Cam (wired)';
2185
+ }
2186
+ if (value.value.device_info.typeName === 'google.resource.VenusResource') {
2187
+ RESTTypeData.model = 'Doorbell (wired, 2nd gen)';
2188
+ }
2189
+ if (value.value.device_info.typeName === 'nest.resource.NestCamIndoorResource') {
2190
+ RESTTypeData.model = 'Cam Indoor (1st gen)';
2191
+ }
2192
+ if (value.value.device_info.typeName === 'nest.resource.NestCamIQResource') {
2193
+ RESTTypeData.model = 'Cam IQ';
2194
+ }
2195
+ if (value.value.device_info.typeName === 'nest.resource.NestCamIQOutdoorResource') {
2196
+ RESTTypeData.model = 'Cam Outdoor (1st gen)';
2197
+ }
2198
+ if (value.value.device_info.typeName === 'nest.resource.NestHelloResource') {
2199
+ RESTTypeData.model = 'Doorbell (wired, 1st gen)';
2200
+ }
2201
+ if (value.value.device_info.typeName === 'google.resource.AzizResource') {
2202
+ RESTTypeData.model = 'Cam with Floodlight (wired)';
2203
+ }
2204
+ if (value.value.device_info.typeName === 'google.resource.GoogleNewmanResource') {
2205
+ RESTTypeData.model = 'Hub Max';
2206
+ }
2207
+ RESTTypeData.online = value.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE';
2208
+ RESTTypeData.description = typeof value.value?.label?.label === 'string' ? value.value.label.label : '';
2209
+ RESTTypeData.location = get_location_name(
2210
+ value.value.device_info.pairerId.resourceId,
2211
+ value.value.device_located_settings.whereAnnotationRid.resourceId,
2212
+ );
2213
+ RESTTypeData.audio_enabled = value.value?.microphone_settings?.enableMicrophone === true;
2214
+ RESTTypeData.has_indoor_chime =
2215
+ value.value?.doorbell_indoor_chime_settings?.chimeType === 'CHIME_TYPE_MECHANICAL' ||
2216
+ value.value?.doorbell_indoor_chime_settings?.chimeType === 'CHIME_TYPE_ELECTRONIC';
2217
+ RESTTypeData.indoor_chime_enabled = value.value?.doorbell_indoor_chime_settings?.chimeEnabled === true;
2218
+ RESTTypeData.streaming_enabled = value.value?.recording_toggle?.currentCameraState === 'CAMERA_ON';
2219
+ //RESTTypeData.has_irled =
2220
+ //RESTTypeData.irled_enabled =
2221
+ //RESTTypeData.has_statusled =
2222
+ //RESTTypeData.statusled_brightness =
2223
+ RESTTypeData.has_microphone = value.value?.microphone_settings?.enableMicrophone === true;
2224
+ RESTTypeData.has_speaker = value.value?.speaker_volume?.volume === true;
2225
+ RESTTypeData.has_motion_detection = value.value?.observation_trigger_capabilities?.videoEventTypes?.motion?.value === true;
2226
+ RESTTypeData.activity_zones = [];
2227
+ if (value.value?.activity_zone_settings?.activityZones !== undefined) {
2228
+ value.value.activity_zone_settings.activityZones.forEach((zone) => {
2229
+ RESTTypeData.activity_zones.push({
2230
+ id: typeof zone.zoneProperties?.zoneId === 'number' ? zone.zoneProperties.zoneId : zone.zoneProperties.internalIndex,
2231
+ name: makeHomeKitName(typeof zone.zoneProperties?.name === 'string' ? zone.zoneProperties.name : ''),
2232
+ hidden: false,
2233
+ uri: '',
2234
+ });
2235
+ });
2236
+ }
2237
+ RESTTypeData.alerts = typeof value.value?.alerts === 'object' ? value.value.alerts : [];
2238
+ RESTTypeData.quiet_time_enabled =
2239
+ parseInt(value.value?.quiet_time_settings?.quietTimeEnds?.seconds) !== 0 &&
2240
+ Math.floor(Date.now() / 1000) < parseInt(value.value?.quiet_time_settings?.quietTimeEnds?.second);
2241
+ RESTTypeData.camera_type = value.value.device_identity.vendorProductId;
2242
+ RESTTypeData.streaming_protocols =
2243
+ value.value?.streaming_protocol?.supportedProtocols !== undefined ? value.value.streaming_protocol.supportedProtocols : [];
2244
+ RESTTypeData.streaming_host =
2245
+ typeof value.value?.streaming_protocol?.directHost?.value === 'string' ? value.value.streaming_protocol.directHost.value : '';
2246
+
2247
+ tempDevice = process_camera_doorbell_data(object_key, RESTTypeData);
2248
+ }
2249
+
2250
+ if (value.source === NestAccfactory.DataSource.REST && value.value.properties['cc2migration.overview_state'] === 'NORMAL') {
2251
+ // We'll only use the REST API data for Camera's which have NOT been migrated to Google Home
2252
+ let RESTTypeData = {};
2253
+ RESTTypeData.mac_address = value.value.mac_address;
2254
+ RESTTypeData.serial_number = value.value.serial_number;
2255
+ RESTTypeData.software_version = value.value.software_version;
2256
+ RESTTypeData.model = value.value.model.replace(/nest\s*/gi, ''); // Use camera/doorbell model that Nest supplies
2257
+ RESTTypeData.description = value.value?.description;
2258
+ RESTTypeData.location = get_location_name(value.value.structure_id, value.value.where_id);
2259
+ RESTTypeData.streaming_enabled = value.value.streaming_state.includes('enabled') === true;
2260
+ RESTTypeData.nexus_api_http_server_url = value.value.nexus_api_http_server_url;
2261
+ RESTTypeData.online = value.value.streaming_state.includes('offline') === false;
2262
+ RESTTypeData.audio_enabled = value.value.audio_input_enabled === true;
2263
+ RESTTypeData.has_indoor_chime = value.value.capabilities.includes('indoor_chime') === true;
2264
+ RESTTypeData.indoor_chime_enabled = value.value.properties['doorbell.indoor_chime.enabled'] === true;
2265
+ RESTTypeData.has_irled = value.value.capabilities.includes('irled') === true;
2266
+ RESTTypeData.irled_enabled = value.value.properties['irled.state'] !== 'always_off';
2267
+ RESTTypeData.has_statusled = value.value.capabilities.includes('statusled') === true;
2268
+ RESTTypeData.has_video_flip = value.value.capabilities.includes('video.flip') === true;
2269
+ RESTTypeData.video_flipped = value.value.properties['video.flipped'] === true;
2270
+ RESTTypeData.statusled_brightness = value.value.properties['statusled.brightness'];
2271
+ RESTTypeData.has_microphone = value.value.capabilities.includes('audio.microphone') === true;
2272
+ RESTTypeData.has_speaker = value.value.capabilities.includes('audio.speaker') === true;
2273
+ RESTTypeData.has_motion_detection = value.value.capabilities.includes('detectors.on_camera') === true;
2274
+ RESTTypeData.activity_zones = value.value.activity_zones; // structure elements we added
2275
+ RESTTypeData.alerts = typeof value.value?.alerts === 'object' ? value.value.alerts : [];
2276
+ RESTTypeData.streaming_protocols = ['PROTOCOL_NEXUSTALK'];
2277
+ RESTTypeData.streaming_host = value.value.direct_nexustalk_host;
2278
+ RESTTypeData.quiet_time_enabled = false;
2279
+ RESTTypeData.camera_type = value.value.camera_type;
2280
+
2281
+ tempDevice = process_camera_doorbell_data(object_key, RESTTypeData);
2282
+ }
2283
+ // eslint-disable-next-line no-unused-vars
2284
+ } catch (error) {
2285
+ this?.log?.debug && this.log.debug('Error processing data for camera/doorbell(s)');
2286
+ }
2287
+
2288
+ if (Object.entries(tempDevice).length !== 0 && typeof devices[tempDevice.serial_number] === 'undefined') {
2289
+ // Insert details to allow access to camera API calls for the device
2290
+ if (value.connection !== undefined && typeof this.#connections?.[value.connection]?.cameraAPI === 'object') {
2291
+ tempDevice.apiAccess = this.#connections[value.connection].cameraAPI;
2292
+ }
2293
+
2294
+ // Insert any extra options we've read in from configuration file for this device
2295
+ tempDevice.eveHistory =
2296
+ this.config.options.eveHistory === true || this.config?.devices?.[tempDevice.serial_number]?.eveHistory === true;
2297
+ tempDevice.hksv = this.config.options.hksv === true || this.config?.devices?.[tempDevice.serial_number]?.hksv === true;
2298
+ tempDevice.doorbellCooldown =
2299
+ typeof this.config?.devices?.[tempDevice.serial_number]?.doorbellCooldown === 'number'
2300
+ ? this.config.devices[tempDevice.serial_number].doorbellCooldown
2301
+ : 60;
2302
+ tempDevice.motionCooldown =
2303
+ typeof this.config?.devices?.[tempDevice.serial_number]?.motionCooldown === 'number'
2304
+ ? this.config.devices[tempDevice.serial_number].motionCooldown
2305
+ : 60;
2306
+ tempDevice.personCooldown =
2307
+ typeof this.config?.devices?.[tempDevice.serial_number]?.personCooldown === 'number'
2308
+ ? this.config.devices[tempDevice.serial_number].personCooldown
2309
+ : 120;
2310
+ tempDevice.chimeSwitch = this.config?.devices?.[tempDevice.serial_number]?.chimeSwitch === true; // Control 'indoor' chime by switch
2311
+ tempDevice.localAccess = this.config?.devices?.[tempDevice.serial_number]?.localAccess === true; // Local network video streaming rather than from cloud from camera/doorbells
2312
+ tempDevice.ffmpeg = this.config.options.ffmpeg; // ffmpeg details, path, libraries. No ffmpeg = undefined
2313
+ tempDevice.maxStreams =
2314
+ typeof this.config.options?.maxStreams === 'number' ? this.config.options.maxStreams : this.deviceData.hksv === true ? 1 : 2;
2315
+ devices[tempDevice.serial_number] = tempDevice; // Store processed device
2316
+ }
2317
+ });
2318
+
2319
+ // Process data for any structure(s) for both REST and protobuf API data
2320
+ // We use this to created virtual weather station(s) for each structure that has location data
2321
+ const process_structure_data = (object_key, data) => {
2322
+ let processed = {};
2323
+ try {
2324
+ // Fix up data we need to
2325
+
2326
+ // For the serial number, use a Nest Labs prefix for first 6 digits, followed by a CRC24 based off structure for last 6 digits.
2327
+ data.serial_number = '18B430' + crc24(object_key).toUpperCase();
2328
+ data.excluded = this.config?.options?.weather === false; // Mark device as excluded or not
2329
+ data.device_type = NestAccfactory.DeviceType.WEATHER;
2330
+ data.uuid = object_key; // Internal structure ID
2331
+ data.manufacturer = typeof data?.manufacturer === 'string' ? data.manufacturer : 'Nest';
2332
+ data.software_version = typeof data?.software_version === 'string' ? data.software_version.replace(/-/g, '.') : '0.0.0';
2333
+ data.description = typeof data?.description === 'string' ? makeHomeKitName(data.description) : '';
2334
+ data.model = 'Weather';
2335
+ data.current_temperature = data.weather.current_temperature;
2336
+ data.current_humidity = data.weather.current_humidity;
2337
+ data.condition = data.weather.condition;
2338
+ data.wind_direction = data.weather.wind_direction;
2339
+ data.wind_speed = data.weather.wind_speed;
2340
+ data.sunrise = data.weather.sunrise;
2341
+ data.sunset = data.weather.sunset;
2342
+ data.station = data.weather.station;
2343
+ data.forecast = data.weather.forecast;
2344
+ data.elevation = 0;
2345
+
2346
+ // Either use global elevation setting or one specific for device
2347
+ if (typeof this.config?.devices?.[data.serial_number]?.elevation === 'number') {
2348
+ data.elevation = this.config.devices[data.serial_number].elevation;
2349
+ }
2350
+
2351
+ if (data.elevation === 0 && typeof this.config?.options?.elevation === 'number') {
2352
+ // Elevation from configuration
2353
+ data.elevation = this.config.options.elevation;
2354
+ }
2355
+
2356
+ // Insert details for when using HAP-NodeJS library rather than Homebridge
2357
+ if (typeof this.config?.options?.hkPairingCode === 'string' && this.config.options.hkPairingCode !== '') {
2358
+ data.hkPairingCode = this.config.options.hkPairingCode;
2359
+ }
2360
+ if (
2361
+ typeof this.config?.devices?.[data.serial_number]?.hkPairingCode === 'string' &&
2362
+ this.config.devices[data.serial_number].hkPairingCode !== ''
2363
+ ) {
2364
+ data.hkPairingCode = this.config.devices[data.serial_number].hkPairingCode;
2365
+ }
2366
+ if (data?.hkPairingCode !== undefined) {
2367
+ // Use a Nest Labs prefix for first 6 digits, followed by a CRC24 based off serial number for last 6 digits.
2368
+ let tempMACAddress = '18B430' + crc24(object_key).toUpperCase();
2369
+ data.hkUsername = tempMACAddress
2370
+ .toString('hex')
2371
+ .split(/(..)/)
2372
+ .filter((s) => s)
2373
+ .join(':')
2374
+ .toUpperCase(); // Create mac_address in format of xx:xx:xx:xx:xx:xx
2375
+ }
2376
+
2377
+ delete data.weather; // Don't need the 'weather' object in our output
2378
+
2379
+ processed = data;
2380
+ // eslint-disable-next-line no-unused-vars
2381
+ } catch (error) {
2382
+ // Empty
2383
+ }
2384
+ return processed;
2385
+ };
2386
+
2387
+ Object.entries(this.#rawData)
2388
+ .filter(
2389
+ ([key]) =>
2390
+ (key.startsWith('structure.') === true || key.startsWith('STRUCTURE_') === true) && (deviceUUID === '' || deviceUUID === key),
2391
+ )
2392
+ .forEach(([object_key, value]) => {
2393
+ let tempDevice = {};
2394
+ try {
2395
+ if (value.source === NestAccfactory.DataSource.PROTOBUF) {
2396
+ let RESTTypeData = {};
2397
+ RESTTypeData.postal_code = value.value.structure_location.postalCode.value;
2398
+ RESTTypeData.country_code = value.value.structure_location.countryCode.value;
2399
+ RESTTypeData.city = typeof value.value.structure_location?.city === 'string' ? value.value.structure_location.city.value : '';
2400
+ RESTTypeData.state =
2401
+ typeof value.value.structure_location?.state === 'string' ? value.value.structure_location.state.value : '';
2402
+ RESTTypeData.latitude = value.value.structure_location.geoCoordinate.latitude;
2403
+ RESTTypeData.longitude = value.value.structure_location.geoCoordinate.longitude;
2404
+ RESTTypeData.description =
2405
+ RESTTypeData.city !== '' && RESTTypeData.state !== ''
2406
+ ? RESTTypeData.city + ' - ' + RESTTypeData.state
2407
+ : value.value.structure_info.name;
2408
+ RESTTypeData.weather = value.value.weather;
2409
+
2410
+ // Use the REST API structure ID from the protobuf structure. This should prevent two 'weather' objects being created
2411
+ let tempDevice = process_structure_data(value.value.structure_info.rtsStructureId, RESTTypeData);
2412
+ tempDevice.uuid = object_key; // Use the protobuf structure ID post processing
2413
+ }
2414
+ if (value.source === NestAccfactory.DataSource.REST) {
2415
+ let RESTTypeData = {};
2416
+ RESTTypeData.postal_code = value.value.postal_code;
2417
+ RESTTypeData.country_code = value.value.country_code;
2418
+ RESTTypeData.city = typeof value.value?.city === 'string' ? value.value.city : '';
2419
+ RESTTypeData.state = typeof value.value?.state === 'string' ? value.value.state : '';
2420
+ RESTTypeData.latitude = value.value.latitude;
2421
+ RESTTypeData.longitude = value.value.longitude;
2422
+ RESTTypeData.description =
2423
+ RESTTypeData.city !== '' && RESTTypeData.state !== '' ? RESTTypeData.city + ' - ' + RESTTypeData.state : value.value.name;
2424
+ RESTTypeData.weather = value.value.weather;
2425
+ tempDevice = process_structure_data(object_key, RESTTypeData);
2426
+ }
2427
+ // eslint-disable-next-line no-unused-vars
2428
+ } catch (error) {
2429
+ this?.log?.debug && this.log.debug('Error processing data for weather');
2430
+ }
2431
+
2432
+ if (Object.entries(tempDevice).length !== 0 && typeof devices[tempDevice.serial_number] === 'undefined') {
2433
+ // Insert any extra options we've read in from configuration file for this device
2434
+ tempDevice.eveHistory =
2435
+ this.config.options.eveHistory === true || this.config?.devices?.[tempDevice.serial_number]?.eveHistory === true;
2436
+ devices[tempDevice.serial_number] = tempDevice; // Store processed device
2437
+ }
2438
+ });
2439
+
2440
+ return devices; // Return our processed data
2441
+ }
2442
+
2443
+ async #set(deviceUUID, values) {
2444
+ if (
2445
+ typeof deviceUUID !== 'string' ||
2446
+ typeof this.#rawData[deviceUUID] !== 'object' ||
2447
+ typeof values !== 'object' ||
2448
+ typeof this.#connections[this.#rawData[deviceUUID]?.connection] !== 'object'
2449
+ ) {
2450
+ return;
2451
+ }
2452
+
2453
+ if (
2454
+ this.#connections[this.#rawData[deviceUUID].connection].protobufRoot !== null &&
2455
+ this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.PROTOBUF
2456
+ ) {
2457
+ let TraitMap = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot.lookup('nest.rpc.NestTraitSetRequest');
2458
+ let setDataToEncode = [];
2459
+ let protobufElement = {
2460
+ traitId: {
2461
+ resourceId: deviceUUID,
2462
+ traitLabel: '',
2463
+ },
2464
+ property: {
2465
+ type_url: '',
2466
+ value: {},
2467
+ },
2468
+ };
2469
+
2470
+ await Promise.all(
2471
+ Object.entries(values).map(async ([key, value]) => {
2472
+ // Reset elements at start of loop
2473
+ protobufElement.traitId.traitLabel = '';
2474
+ protobufElement.property.type_url = '';
2475
+ protobufElement.property.value = {};
2476
+
2477
+ if (
2478
+ (key === 'hvac_mode' &&
2479
+ typeof value === 'string' &&
2480
+ (value.toUpperCase() === 'OFF' ||
2481
+ value.toUpperCase() === 'COOL' ||
2482
+ value.toUpperCase() === 'HEAT' ||
2483
+ value.toUpperCase() === 'RANGE')) ||
2484
+ (key === 'target_temperature' &&
2485
+ this.#rawData[deviceUUID].value.eco_mode_state.ecoMode === 'ECO_MODE_INACTIVE' &&
2486
+ typeof value === 'number') ||
2487
+ (key === 'target_temperature_low' &&
2488
+ this.#rawData[deviceUUID].value.eco_mode_state.ecoMode === 'ECO_MODE_INACTIVE' &&
2489
+ typeof value === 'number') ||
2490
+ (key === 'target_temperature_high' &&
2491
+ this.#rawData[deviceUUID].value.eco_mode_state.ecoMode === 'ECO_MODE_INACTIVE' &&
2492
+ typeof value === 'number')
2493
+ ) {
2494
+ // Set either the 'mode' and/or non-eco temperatures on the target thermostat
2495
+ let coolingTarget = this.#rawData[deviceUUID].value.target_temperature_settings.targetTemperature.coolingTarget.value;
2496
+ let heatingTarget = this.#rawData[deviceUUID].value.target_temperature_settings.targetTemperature.heatingTarget.value;
2497
+
2498
+ if (
2499
+ key === 'target_temperature_low' ||
2500
+ (key === 'target_temperature' &&
2501
+ this.#rawData[deviceUUID].value.target_temperature_settings.targetTemperature.setpointType === 'SET_POINT_TYPE_HEAT')
2502
+ ) {
2503
+ heatingTarget = value;
2504
+ }
2505
+ if (
2506
+ key === 'target_temperature_high' ||
2507
+ (key === 'target_temperature' &&
2508
+ this.#rawData[deviceUUID].value.target_temperature_settings.targetTemperature.setpointType === 'SET_POINT_TYPE_COOL')
2509
+ ) {
2510
+ coolingTarget = value;
2511
+ }
2512
+
2513
+ protobufElement.traitId.traitLabel = 'target_temperature_settings';
2514
+ protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.TargetTemperatureSettingsTrait';
2515
+ // eslint-disable-next-line no-undef
2516
+ protobufElement.property.value.targetTemperature = structuredClone(this.#rawData[deviceUUID].value.target_temperature_settings);
2517
+ protobufElement.property.value.targetTemperature.setpointType =
2518
+ key === 'hvac_mode' && value.toUpperCase() !== 'OFF'
2519
+ ? 'SET_POINT_TYPE_' + value.toUpperCase()
2520
+ : this.#rawData[deviceUUID].value.target_temperature_settings.targetTemperature.setpointType;
2521
+ protobufElement.property.value.targetTemperature.heatingTarget = { value: heatingTarget };
2522
+ protobufElement.property.value.targetTemperature.coolingTarget = { value: coolingTarget };
2523
+ protobufElement.property.value.targetTemperature.currentActorInfo = {
2524
+ method: 'HVAC_ACTOR_METHOD_IOS',
2525
+ originator: {
2526
+ resourceId: Object.keys(this.#rawData)
2527
+ .filter((key) => key.includes('USER_'))
2528
+ .toString(),
2529
+ },
2530
+ timeOfAction: { seconds: Math.floor(Date.now() / 1000), nanos: (Date.now() % 1000) * 1e6 },
2531
+ originatorRtsId: '',
2532
+ };
2533
+ protobufElement.property.value.targetTemperature.originalActorInfo = {
2534
+ method: 'HVAC_ACTOR_METHOD_UNSPECIFIED',
2535
+ originator: null,
2536
+ timeOfAction: null,
2537
+ originatorRtsId: '',
2538
+ };
2539
+ protobufElement.property.value.enabled = {
2540
+ value:
2541
+ key === 'hvac_mode'
2542
+ ? value.toUpperCase() !== 'OFF'
2543
+ : this.#rawData[deviceUUID].value.target_temperature_settings.enabled.value,
2544
+ };
2545
+ }
2546
+
2547
+ if (
2548
+ (key === 'target_temperature' &&
2549
+ this.#rawData[deviceUUID].value.eco_mode_state.ecoMode !== 'ECO_MODE_INACTIVE' &&
2550
+ typeof value === 'number') ||
2551
+ (key === 'target_temperature_low' &&
2552
+ this.#rawData[deviceUUID].value.eco_mode_state.ecoMode !== 'ECO_MODE_INACTIVE' &&
2553
+ typeof value === 'number') ||
2554
+ (key === 'target_temperature_high' &&
2555
+ this.#rawData[deviceUUID].value.eco_mode_state.ecoMode !== 'ECO_MODE_INACTIVE' &&
2556
+ typeof value === 'number')
2557
+ ) {
2558
+ // Set eco mode temperatures on the target thermostat
2559
+ protobufElement.traitId.traitLabel = 'eco_mode_settings';
2560
+ protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.EcoModeSettingsTrait';
2561
+ // eslint-disable-next-line no-undef
2562
+ protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.eco_mode_settings);
2563
+ protobufElement.property.value.ecoTemperatureHeat.value.value =
2564
+ protobufElement.property.value.ecoTemperatureHeat.enabled === true &&
2565
+ protobufElement.property.value.ecoTemperatureCool.enabled === false
2566
+ ? value
2567
+ : protobufElement.property.value.ecoTemperatureHeat.value.value;
2568
+ protobufElement.property.value.ecoTemperatureCool.value.value =
2569
+ protobufElement.property.value.ecoTemperatureHeat.enabled === false &&
2570
+ protobufElement.property.value.ecoTemperatureCool.enabled === true
2571
+ ? value
2572
+ : protobufElement.property.value.ecoTemperatureCool.value.value;
2573
+ protobufElement.property.value.ecoTemperatureHeat.value.value =
2574
+ protobufElement.property.value.ecoTemperatureHeat.enabled === true &&
2575
+ protobufElement.property.value.ecoTemperatureCool.enabled === true &&
2576
+ key === 'target_temperature_low'
2577
+ ? value
2578
+ : protobufElement.property.value.ecoTemperatureHeat.value.value;
2579
+ protobufElement.property.value.ecoTemperatureCool.value.value =
2580
+ protobufElement.property.value.ecoTemperatureHeat.enabled === true &&
2581
+ protobufElement.property.value.ecoTemperatureCool.enabled === true &&
2582
+ key === 'target_temperature_high'
2583
+ ? value
2584
+ : protobufElement.property.value.ecoTemperatureCool.value.value;
2585
+ }
2586
+
2587
+ if (key === 'temperature_scale' && typeof value === 'string' && (value.toUpperCase() === 'C' || value.toUpperCase() === 'F')) {
2588
+ // Set the temperature scale on the target thermostat
2589
+ protobufElement.traitId.traitLabel = 'display_settings';
2590
+ protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.DisplaySettingsTrait';
2591
+ // eslint-disable-next-line no-undef
2592
+ protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.display_settings);
2593
+ protobufElement.property.value.temperatureScale = value.toUpperCase() === 'F' ? 'TEMPERATURE_SCALE_F' : 'TEMPERATURE_SCALE_C';
2594
+ }
2595
+
2596
+ if (key === 'temperature_lock' && typeof value === 'boolean') {
2597
+ // Set lock mode on the target thermostat
2598
+ protobufElement.traitId.traitLabel = 'temperature_lock_settings';
2599
+ protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.TemperatureLockSettingsTrait';
2600
+ // eslint-disable-next-line no-undef
2601
+ protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.temperature_lock_settings);
2602
+ protobufElement.property.value.enabled = value === true;
2603
+ }
2604
+
2605
+ if (key === 'fan_state' && typeof value === 'boolean') {
2606
+ // Set fan mode on the target thermostat
2607
+ let endTime =
2608
+ value === true
2609
+ ? Math.floor(Date.now() / 1000) + this.#rawData[deviceUUID].value.fan_control_settings.timerDuration.seconds
2610
+ : 0;
2611
+
2612
+ protobufElement.traitId.traitLabel = 'fan_control_settings';
2613
+ protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.hvac.FanControlSettingsTrait';
2614
+ // eslint-disable-next-line no-undef
2615
+ protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.fan_control_settings);
2616
+ protobufElement.property.value.timerEnd = { seconds: endTime, nanos: (endTime % 1000) * 1e6 };
2617
+ }
2618
+
2619
+ //if (key === 'statusled.brightness'
2620
+ //if (key === 'irled.state'
2621
+
2622
+ if (key === 'streaming.enabled' && typeof value === 'boolean') {
2623
+ // Turn camera video on/off
2624
+ protobufElement.traitId.traitLabel = 'recording_toggle_settings';
2625
+ protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.product.camera.RecordingToggleSettingsTrait';
2626
+ // eslint-disable-next-line no-undef
2627
+ protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.recording_toggle_settings);
2628
+ protobufElement.property.value.targetCameraState = value === true ? 'CAMERA_ON' : 'CAMERA_OFF';
2629
+ protobufElement.property.value.changeModeReason = 2;
2630
+ protobufElement.property.value.settingsUpdated = { seconds: Math.floor(Date.now() / 1000), nanos: (Date.now() % 1000) * 1e6 };
2631
+ }
2632
+
2633
+ if (key === 'watermark.enabled' && typeof value === 'boolean') {
2634
+ // Unsupported via protobuf?
2635
+ }
2636
+
2637
+ if (key === 'audio.enabled' && typeof value === 'boolean') {
2638
+ // Enable/disable microphone on camera/doorbell
2639
+ protobufElement.traitId.traitLabel = 'microphone_settings';
2640
+ protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.audio.MicrophoneSettingsTrait';
2641
+ // eslint-disable-next-line no-undef
2642
+ protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.microphone_settings);
2643
+ protobufElement.property.value.enableMicrophone = value;
2644
+ }
2645
+
2646
+ if (key === 'doorbell.indoor_chime.enabled' && typeof value === 'boolean') {
2647
+ // Enable/disable chime status on doorbell
2648
+ protobufElement.traitId.traitLabel = 'doorbell_indoor_chime_settings';
2649
+ protobufElement.property.type_url = 'type.nestlabs.com/nest.trait.product.doorbell.DoorbellIndoorChimeSettingsTrait';
2650
+ // eslint-disable-next-line no-undef
2651
+ protobufElement.property.value = structuredClone(this.#rawData[deviceUUID].value.doorbell_indoor_chime_settings);
2652
+ protobufElement.property.value.chimeEnabled = value;
2653
+ }
2654
+
2655
+ if (protobufElement.traitId.traitLabel === '' || protobufElement.property.type_url === '') {
2656
+ this?.log?.debug && this.log.debug('Unknown protobuf set key for device', deviceUUID, key, value);
2657
+ }
2658
+
2659
+ if (protobufElement.traitId.traitLabel !== '' && protobufElement.property.type_url !== '') {
2660
+ let trait = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot.lookup(
2661
+ protobufElement.property.type_url.split('/')[1],
2662
+ );
2663
+ protobufElement.property.value = trait.encode(trait.fromObject(protobufElement.property.value)).finish();
2664
+ // eslint-disable-next-line no-undef
2665
+ setDataToEncode.push(structuredClone(protobufElement));
2666
+ }
2667
+ }),
2668
+ );
2669
+
2670
+ if (setDataToEncode.length !== 0 && TraitMap !== null) {
2671
+ let encodedData = TraitMap.encode(TraitMap.fromObject({ set: setDataToEncode })).finish();
2672
+ let request = {
2673
+ method: 'post',
2674
+ url:
2675
+ 'https://' +
2676
+ this.#connections[this.#rawData[deviceUUID].connection].protobufAPIHost +
2677
+ '/nestlabs.gateway.v1.TraitBatchApi/BatchUpdateState',
2678
+ headers: {
2679
+ 'User-Agent': USERAGENT,
2680
+ Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token,
2681
+ 'Content-Type': 'application/x-protobuf',
2682
+ 'X-Accept-Content-Transfer-Encoding': 'binary',
2683
+ 'X-Accept-Response-Streaming': 'true',
2684
+ },
2685
+ data: encodedData,
2686
+ };
2687
+ axios(request)
2688
+ .then((response) => {
2689
+ if (typeof response.status !== 'number' || response.status !== 200) {
2690
+ throw new Error('protobuf API had error updating device traits');
2691
+ }
2692
+ })
2693
+ .catch((error) => {
2694
+ this?.log?.debug &&
2695
+ this.log.debug('protobuf API had error updating device traits for uuid "%s". Error was "%s"', deviceUUID, error?.code);
2696
+ });
2697
+ }
2698
+ }
2699
+
2700
+ if (this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.REST && deviceUUID.startsWith('quartz.') === true) {
2701
+ // Set value on Nest Camera/Doorbell
2702
+ await Promise.all(
2703
+ Object.entries(values).map(async ([key, value]) => {
2704
+ let request = {
2705
+ method: 'post',
2706
+ url: 'https://webapi.' + this.#connections[this.#rawData[deviceUUID].connection].cameraAPIHost + '/api/dropcams.set_properties',
2707
+ headers: {
2708
+ referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer,
2709
+ 'User-Agent': USERAGENT,
2710
+ 'content-type': 'application/x-www-form-urlencoded',
2711
+ [this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.key]:
2712
+ this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.value +
2713
+ this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.token,
2714
+ },
2715
+ responseType: 'json',
2716
+ timeout: NESTAPITIMEOUT,
2717
+ data: [key] + '=' + value + '&uuid=' + deviceUUID.split('.')[1],
2718
+ };
2719
+ await axios(request)
2720
+ .then((response) => {
2721
+ if (
2722
+ typeof response.status !== 'number' ||
2723
+ response.status !== 200 ||
2724
+ typeof response.data.status !== 'number' ||
2725
+ response.data.status !== 0
2726
+ ) {
2727
+ throw new Error('REST API camera update for failed with error');
2728
+ }
2729
+ })
2730
+ .catch((error) => {
2731
+ this?.log?.debug &&
2732
+ this.log.debug('REST API camera update for failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code);
2733
+ });
2734
+ }),
2735
+ );
2736
+ }
2737
+
2738
+ if (this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.REST && deviceUUID.startsWith('quartz.') === false) {
2739
+ // set values on other Nest devices besides cameras/doorbells
2740
+ await Promise.all(
2741
+ Object.entries(values).map(async ([key, value]) => {
2742
+ let restAPIJSONData = { objects: [] };
2743
+
2744
+ if (deviceUUID.startsWith('device.') === false) {
2745
+ restAPIJSONData.objects.push({ object_key: deviceUUID, op: 'MERGE', value: { [key]: value } });
2746
+ }
2747
+
2748
+ // Some elements when setting thermostat data are located in a different object locations than with the device object
2749
+ // Handle this scenario below
2750
+ if (deviceUUID.startsWith('device.') === true) {
2751
+ let RESTStructureUUID = deviceUUID;
2752
+
2753
+ if (
2754
+ (key === 'hvac_mode' &&
2755
+ typeof value === 'string' &&
2756
+ (value.toUpperCase() === 'OFF' ||
2757
+ value.toUpperCase() === 'COOL' ||
2758
+ value.toUpperCase() === 'HEAT' ||
2759
+ value.toUpperCase() === 'RANGE')) ||
2760
+ (key === 'target_temperature' && typeof value === 'number') ||
2761
+ (key === 'target_temperature_low' && typeof value === 'number') ||
2762
+ (key === 'target_temperature_high' && typeof value === 'number')
2763
+ ) {
2764
+ RESTStructureUUID = 'shared.' + deviceUUID.split('.')[1];
2765
+ }
2766
+ restAPIJSONData.objects.push({ object_key: RESTStructureUUID, op: 'MERGE', value: { [key]: value } });
2767
+ }
2768
+
2769
+ if (restAPIJSONData.objects.length !== 0) {
2770
+ let request = {
2771
+ method: 'post',
2772
+ url: this.#connections[this.#rawData[deviceUUID].connection].transport_url + '/v5/put',
2773
+ responseType: 'json',
2774
+ headers: {
2775
+ 'User-Agent': USERAGENT,
2776
+ Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token,
2777
+ },
2778
+ data: JSON.stringify(restAPIJSONData),
2779
+ };
2780
+ await axios(request)
2781
+ .then(async (response) => {
2782
+ if (typeof response.status !== 'number' || response.status !== 200) {
2783
+ throw new Error('REST API property update for failed with error');
2784
+ }
2785
+ })
2786
+ .catch((error) => {
2787
+ this?.log?.debug &&
2788
+ this.log.debug('REST API property update for failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code);
2789
+ });
2790
+ }
2791
+ }),
2792
+ );
2793
+ }
2794
+ }
2795
+
2796
+ async #get(deviceUUID, values) {
2797
+ if (
2798
+ typeof deviceUUID !== 'string' ||
2799
+ typeof this.#rawData[deviceUUID] !== 'object' ||
2800
+ typeof values !== 'object' ||
2801
+ typeof this.#connections[this.#rawData[deviceUUID]?.connection] !== 'object'
2802
+ ) {
2803
+ values = {};
2804
+ }
2805
+
2806
+ await Promise.all(
2807
+ Object.entries(values).map(async ([key]) => {
2808
+ // We'll return the data under the original key value
2809
+ // By default, the returned value will be undefined. If call is successful, the key value will have the data requested
2810
+ values[key] = undefined;
2811
+
2812
+ if (
2813
+ this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.REST &&
2814
+ key === 'camera_snapshot' &&
2815
+ deviceUUID.startsWith('quartz.') === true
2816
+ ) {
2817
+ // Attempt to retrieve snapshot from camera via REST API
2818
+ let request = {
2819
+ method: 'get',
2820
+ url: this.#rawData[deviceUUID].value.nexus_api_http_server_url + '/get_image?uuid=' + deviceUUID.split('.')[1],
2821
+ headers: {
2822
+ referer: 'https://' + this.#connections[this.#rawData[deviceUUID].connection].referer,
2823
+ 'User-Agent': USERAGENT,
2824
+ accept: '*/*',
2825
+ [this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.key]:
2826
+ this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.value +
2827
+ this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.token,
2828
+ },
2829
+ responseType: 'arraybuffer',
2830
+ timeout: 3000,
2831
+ };
2832
+
2833
+ // if (typeof keyValue keyValue !== '')
2834
+ /* (url =
2835
+ this.#rawData[deviceUUID].value.nexus_api_http_server_url +
2836
+ '/event_snapshot/' +
2837
+ deviceUUID.split('.')[1] +
2838
+ '/' +
2839
+ id +
2840
+ '?crop_type=timeline&cachebuster=' +
2841
+ Math.floor(Date.now() / 1000)), */
2842
+
2843
+ await axios(request)
2844
+ .then((response) => {
2845
+ if (typeof response.status !== 'number' || response.status !== 200) {
2846
+ throw new Error('REST API camera snapshot failed with error');
2847
+ }
2848
+
2849
+ values[key] = response.data;
2850
+ })
2851
+ .catch((error) => {
2852
+ this?.log?.debug &&
2853
+ this.log.debug('REST API camera snapshot failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code);
2854
+ });
2855
+ }
2856
+
2857
+ if (
2858
+ this.#rawData[deviceUUID]?.source === NestAccfactory.DataSource.PROTOBUF &&
2859
+ this.#connections[this.#rawData[deviceUUID].connection].protobufRoot !== null &&
2860
+ this.#rawData[deviceUUID]?.value?.device_identity?.vendorProductId !== undefined &&
2861
+ key === 'camera_snapshot'
2862
+ ) {
2863
+ // Attempt to retrieve snapshot from camera via protobuf API
2864
+ // First, request to get snapshot url image updated
2865
+ let commandResponse = await this.#protobufCommand(deviceUUID, [
2866
+ {
2867
+ traitLabel: 'upload_live_image',
2868
+ command: {
2869
+ type_url: 'type.nestlabs.com/nest.trait.product.camera.UploadLiveImageTrait.UploadLiveImageRequest',
2870
+ value: {},
2871
+ },
2872
+ },
2873
+ ]);
2874
+
2875
+ if (commandResponse?.resourceCommandResponse?.[0]?.traitOperations?.[0]?.progress === 'COMPLETE') {
2876
+ // Snapshot url image has beeen updated, so no retrieve it
2877
+ let request = {
2878
+ method: 'get',
2879
+ url: this.#rawData[deviceUUID].value.upload_live_image.liveImageUrl,
2880
+ headers: {
2881
+ 'User-Agent': USERAGENT,
2882
+ accept: '*/*',
2883
+ [this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.key]:
2884
+ this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.value +
2885
+ this.#connections[this.#rawData[deviceUUID].connection].cameraAPI.token,
2886
+ },
2887
+ responseType: 'arraybuffer',
2888
+ timeout: 3000,
2889
+ };
2890
+ await axios(request)
2891
+ .then((response) => {
2892
+ if (typeof response.status !== 'number' || response.status !== 200) {
2893
+ throw new Error('protobuf API camera snapshot failed with error');
2894
+ }
2895
+
2896
+ values[key] = response.data;
2897
+ })
2898
+ .catch((error) => {
2899
+ this?.log?.debug &&
2900
+ this.log.debug('protobuf API camera snapshot failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code);
2901
+ });
2902
+ }
2903
+ }
2904
+ }),
2905
+ );
2906
+
2907
+ // Send results back via event
2908
+ this.#eventEmitter.emit(HomeKitDevice.GET + '->' + deviceUUID, values);
2909
+ }
2910
+
2911
+ async #getWeatherData(connectionType, deviceUUID, latitude, longitude) {
2912
+ let weatherData = {};
2913
+ if (typeof this.#rawData[deviceUUID]?.value?.weather === 'object') {
2914
+ weatherData = this.#rawData[deviceUUID].value.weather;
2915
+ }
2916
+
2917
+ let request = {
2918
+ method: 'get',
2919
+ url: this.#connections[connectionType].weather_url + latitude + ',' + longitude,
2920
+ headers: {
2921
+ 'User-Agent': USERAGENT,
2922
+ },
2923
+ responseType: 'json',
2924
+ timeout: NESTAPITIMEOUT,
2925
+ };
2926
+ await axios(request)
2927
+ .then((response) => {
2928
+ if (typeof response.status !== 'number' || response.status !== 200) {
2929
+ throw new Error('REST API failed to retireve weather details');
2930
+ }
2931
+
2932
+ if (typeof response.data[latitude + ',' + longitude].current === 'object') {
2933
+ // Store the lat/long details in the weather data object
2934
+ weatherData.latitude = latitude;
2935
+ weatherData.longitude = longitude;
2936
+
2937
+ // Update weather data object
2938
+ weatherData.current_temperature = adjustTemperature(response.data[latitude + ',' + longitude].current.temp_c, 'C', 'C', false);
2939
+ weatherData.current_humidity = response.data[latitude + ',' + longitude].current.humidity;
2940
+ weatherData.condition = response.data[latitude + ',' + longitude].current.condition;
2941
+ weatherData.wind_direction = response.data[latitude + ',' + longitude].current.wind_dir;
2942
+ weatherData.wind_speed = response.data[latitude + ',' + longitude].current.wind_mph * 1.609344; // convert to km/h
2943
+ weatherData.sunrise = response.data[latitude + ',' + longitude].current.sunrise;
2944
+ weatherData.sunset = response.data[latitude + ',' + longitude].current.sunset;
2945
+ weatherData.station = response.data[latitude + ',' + longitude].location.short_name;
2946
+ weatherData.forecast = response.data[latitude + ',' + longitude].forecast.daily[0].condition;
2947
+ }
2948
+ })
2949
+ .catch((error) => {
2950
+ this?.log?.debug &&
2951
+ this.log.debug('REST API failed to retireve weather details for uuid "%s". Error was "%s"', deviceUUID, error?.code);
2952
+ });
2953
+ return weatherData;
2954
+ }
2955
+
2956
+ async #protobufCommand(deviceUUID, commands) {
2957
+ if (
2958
+ typeof deviceUUID !== 'string' ||
2959
+ typeof this.#rawData?.[deviceUUID] !== 'object' ||
2960
+ this.#rawData[deviceUUID]?.source !== NestAccfactory.DataSource.PROTOBUF ||
2961
+ Array.isArray(commands === false) ||
2962
+ typeof this.#connections[this.#rawData[deviceUUID]?.connection] !== 'object'
2963
+ ) {
2964
+ return;
2965
+ }
2966
+
2967
+ let commandResponse = undefined;
2968
+ let encodedData = undefined;
2969
+
2970
+ // Build the protobuf command object for encoding
2971
+ let protobufElement = {
2972
+ resourceRequest: {
2973
+ resourceId: deviceUUID,
2974
+ requestId: crypto.randomUUID(),
2975
+ },
2976
+ resourceCommands: commands,
2977
+ };
2978
+
2979
+ // End code each of the commands
2980
+ protobufElement.resourceCommands.forEach((command) => {
2981
+ let trait = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot.lookup(command.command.type_url.split('/')[1]);
2982
+ if (trait !== null) {
2983
+ command.command.value = trait.encode(trait.fromObject(command.command.value)).finish();
2984
+ }
2985
+ });
2986
+
2987
+ let TraitMap = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot.lookup(
2988
+ 'nestlabs.gateway.v1.ResourceCommandRequest',
2989
+ );
2990
+ if (TraitMap !== null) {
2991
+ encodedData = TraitMap.encode(TraitMap.fromObject(protobufElement)).finish();
2992
+ }
2993
+
2994
+ if (encodedData !== undefined) {
2995
+ let request = {
2996
+ method: 'post',
2997
+ url:
2998
+ 'https://' +
2999
+ this.#connections[this.#rawData[deviceUUID].connection].protobufAPIHost +
3000
+ '/nestlabs.gateway.v1.ResourceApi/SendCommand',
3001
+ headers: {
3002
+ 'User-Agent': USERAGENT,
3003
+ Authorization: 'Basic ' + this.#connections[this.#rawData[deviceUUID].connection].token,
3004
+ 'Content-Type': 'application/x-protobuf',
3005
+ 'X-Accept-Content-Transfer-Encoding': 'binary',
3006
+ 'X-Accept-Response-Streaming': 'true',
3007
+ },
3008
+ responseType: 'arraybuffer',
3009
+ data: encodedData,
3010
+ };
3011
+
3012
+ await axios(request)
3013
+ .then((response) => {
3014
+ if (typeof response.status !== 'number' || response.status !== 200) {
3015
+ throw new Error('protobuf command send failed with error');
3016
+ }
3017
+
3018
+ commandResponse = this.#connections[this.#rawData[deviceUUID].connection].protobufRoot
3019
+ .lookup('nestlabs.gateway.v1.ResourceCommandResponseFromAPI')
3020
+ .decode(response.data)
3021
+ .toJSON();
3022
+ })
3023
+ .catch((error) => {
3024
+ this?.log?.debug &&
3025
+ this.log.debug('protobuf command send failed with error for uuid "%s". Error was "%s"', deviceUUID, error?.code);
3026
+ });
3027
+ }
3028
+
3029
+ return commandResponse;
3030
+ }
3031
+ }
3032
+
3033
+ // General helper functions which don't need to be part of an object class
3034
+ function adjustTemperature(temperature, currentTemperatureUnit, targetTemperatureUnit, round) {
3035
+ // Converts temperatures between C/F and vice-versa
3036
+ // Also rounds temperatures to 0.5 increments for C and 1.0 for F
3037
+ if (targetTemperatureUnit.toUpperCase() === 'C') {
3038
+ if (currentTemperatureUnit.toUpperCase() === 'F') {
3039
+ // convert from F to C
3040
+ temperature = ((temperature - 32) * 5) / 9;
3041
+ }
3042
+ if (round === true) {
3043
+ // round to nearest 0.5C
3044
+ temperature = Math.round(temperature * 2) * 0.5;
3045
+ }
3046
+ }
3047
+
3048
+ if (targetTemperatureUnit.toUpperCase() === 'F') {
3049
+ if (currentTemperatureUnit.toUpperCase() === 'C') {
3050
+ // convert from C to F
3051
+ temperature = (temperature * 9) / 5 + 32;
3052
+ }
3053
+ if (round === true) {
3054
+ // round to nearest 1F
3055
+ temperature = Math.round(temperature);
3056
+ }
3057
+ }
3058
+
3059
+ return temperature;
3060
+ }
3061
+
3062
+ function makeHomeKitName(nameToMakeValid) {
3063
+ // Strip invalid characters to meet HomeKit naming requirements
3064
+ // Ensure only letters or numbers are at the beginning AND/OR end of string
3065
+ // Matches against uni-code characters
3066
+ return nameToMakeValid
3067
+ .replace(/[^\p{L}\p{N}\p{Z}\u2019.,-]/gu, '')
3068
+ .replace(/^[^\p{L}\p{N}]*/gu, '')
3069
+ .replace(/[^\p{L}\p{N}]+$/gu, '');
3070
+ }
3071
+
3072
+ function crc24(valueToHash) {
3073
+ const crc24HashTable = [
3074
+ 0x000000, 0x864cfb, 0x8ad50d, 0x0c99f6, 0x93e6e1, 0x15aa1a, 0x1933ec, 0x9f7f17, 0xa18139, 0x27cdc2, 0x2b5434, 0xad18cf, 0x3267d8,
3075
+ 0xb42b23, 0xb8b2d5, 0x3efe2e, 0xc54e89, 0x430272, 0x4f9b84, 0xc9d77f, 0x56a868, 0xd0e493, 0xdc7d65, 0x5a319e, 0x64cfb0, 0xe2834b,
3076
+ 0xee1abd, 0x685646, 0xf72951, 0x7165aa, 0x7dfc5c, 0xfbb0a7, 0x0cd1e9, 0x8a9d12, 0x8604e4, 0x00481f, 0x9f3708, 0x197bf3, 0x15e205,
3077
+ 0x93aefe, 0xad50d0, 0x2b1c2b, 0x2785dd, 0xa1c926, 0x3eb631, 0xb8faca, 0xb4633c, 0x322fc7, 0xc99f60, 0x4fd39b, 0x434a6d, 0xc50696,
3078
+ 0x5a7981, 0xdc357a, 0xd0ac8c, 0x56e077, 0x681e59, 0xee52a2, 0xe2cb54, 0x6487af, 0xfbf8b8, 0x7db443, 0x712db5, 0xf7614e, 0x19a3d2,
3079
+ 0x9fef29, 0x9376df, 0x153a24, 0x8a4533, 0x0c09c8, 0x00903e, 0x86dcc5, 0xb822eb, 0x3e6e10, 0x32f7e6, 0xb4bb1d, 0x2bc40a, 0xad88f1,
3080
+ 0xa11107, 0x275dfc, 0xdced5b, 0x5aa1a0, 0x563856, 0xd074ad, 0x4f0bba, 0xc94741, 0xc5deb7, 0x43924c, 0x7d6c62, 0xfb2099, 0xf7b96f,
3081
+ 0x71f594, 0xee8a83, 0x68c678, 0x645f8e, 0xe21375, 0x15723b, 0x933ec0, 0x9fa736, 0x19ebcd, 0x8694da, 0x00d821, 0x0c41d7, 0x8a0d2c,
3082
+ 0xb4f302, 0x32bff9, 0x3e260f, 0xb86af4, 0x2715e3, 0xa15918, 0xadc0ee, 0x2b8c15, 0xd03cb2, 0x567049, 0x5ae9bf, 0xdca544, 0x43da53,
3083
+ 0xc596a8, 0xc90f5e, 0x4f43a5, 0x71bd8b, 0xf7f170, 0xfb6886, 0x7d247d, 0xe25b6a, 0x641791, 0x688e67, 0xeec29c, 0x3347a4, 0xb50b5f,
3084
+ 0xb992a9, 0x3fde52, 0xa0a145, 0x26edbe, 0x2a7448, 0xac38b3, 0x92c69d, 0x148a66, 0x181390, 0x9e5f6b, 0x01207c, 0x876c87, 0x8bf571,
3085
+ 0x0db98a, 0xf6092d, 0x7045d6, 0x7cdc20, 0xfa90db, 0x65efcc, 0xe3a337, 0xef3ac1, 0x69763a, 0x578814, 0xd1c4ef, 0xdd5d19, 0x5b11e2,
3086
+ 0xc46ef5, 0x42220e, 0x4ebbf8, 0xc8f703, 0x3f964d, 0xb9dab6, 0xb54340, 0x330fbb, 0xac70ac, 0x2a3c57, 0x26a5a1, 0xa0e95a, 0x9e1774,
3087
+ 0x185b8f, 0x14c279, 0x928e82, 0x0df195, 0x8bbd6e, 0x872498, 0x016863, 0xfad8c4, 0x7c943f, 0x700dc9, 0xf64132, 0x693e25, 0xef72de,
3088
+ 0xe3eb28, 0x65a7d3, 0x5b59fd, 0xdd1506, 0xd18cf0, 0x57c00b, 0xc8bf1c, 0x4ef3e7, 0x426a11, 0xc426ea, 0x2ae476, 0xaca88d, 0xa0317b,
3089
+ 0x267d80, 0xb90297, 0x3f4e6c, 0x33d79a, 0xb59b61, 0x8b654f, 0x0d29b4, 0x01b042, 0x87fcb9, 0x1883ae, 0x9ecf55, 0x9256a3, 0x141a58,
3090
+ 0xefaaff, 0x69e604, 0x657ff2, 0xe33309, 0x7c4c1e, 0xfa00e5, 0xf69913, 0x70d5e8, 0x4e2bc6, 0xc8673d, 0xc4fecb, 0x42b230, 0xddcd27,
3091
+ 0x5b81dc, 0x57182a, 0xd154d1, 0x26359f, 0xa07964, 0xace092, 0x2aac69, 0xb5d37e, 0x339f85, 0x3f0673, 0xb94a88, 0x87b4a6, 0x01f85d,
3092
+ 0x0d61ab, 0x8b2d50, 0x145247, 0x921ebc, 0x9e874a, 0x18cbb1, 0xe37b16, 0x6537ed, 0x69ae1b, 0xefe2e0, 0x709df7, 0xf6d10c, 0xfa48fa,
3093
+ 0x7c0401, 0x42fa2f, 0xc4b6d4, 0xc82f22, 0x4e63d9, 0xd11cce, 0x575035, 0x5bc9c3, 0xdd8538,
3094
+ ];
3095
+
3096
+ let crc24 = 0xb704ce; // init crc24 hash;
3097
+ valueToHash = Buffer.from(valueToHash); // convert value into buffer for processing
3098
+ for (let index = 0; index < valueToHash.length; index++) {
3099
+ crc24 = (crc24HashTable[((crc24 >> 16) ^ valueToHash[index]) & 0xff] ^ (crc24 << 8)) & 0xffffff;
3100
+ }
3101
+ return crc24.toString(16); // return crc24 as hex string
3102
+ }
3103
+
3104
+ function scaleValue(value, sourceRangeMin, sourceRangeMax, targetRangeMin, targetRangeMax) {
3105
+ if (value < sourceRangeMin) {
3106
+ value = sourceRangeMin;
3107
+ }
3108
+ if (value > sourceRangeMax) {
3109
+ value = sourceRangeMax;
3110
+ }
3111
+ return ((value - sourceRangeMin) * (targetRangeMax - targetRangeMin)) / (sourceRangeMax - sourceRangeMin) + targetRangeMin;
3112
+ }