homebridge-nest-accfactory 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/utils.js ADDED
@@ -0,0 +1,327 @@
1
+ // General helper functions
2
+ // Part of homebridge-nest-accfactory
3
+ //
4
+ // Code version 2025.08.02
5
+ // Mark Hulskamp
6
+ 'use strict';
7
+
8
+ // Define nodejs module requirements
9
+ import { Buffer } from 'node:buffer';
10
+ import { setTimeout } from 'node:timers';
11
+
12
+ // Define our modules
13
+ import HomeKitDevice from './HomeKitDevice.js';
14
+
15
+ // Define external library requirements
16
+ import { Agent } from 'undici';
17
+
18
+ // Define constants
19
+ const defaultFetchAgent = new Agent(); // shared across all requests
20
+
21
+ function adjustTemperature(temperature, currentTemperatureUnit, targetTemperatureUnit, round) {
22
+ currentTemperatureUnit = currentTemperatureUnit?.toUpperCase?.();
23
+ targetTemperatureUnit = targetTemperatureUnit?.toUpperCase?.();
24
+
25
+ if (currentTemperatureUnit === 'F' && targetTemperatureUnit === 'C') {
26
+ temperature = ((temperature - 32) * 5) / 9;
27
+ if (round === true) {
28
+ temperature = Math.round(temperature * 2) / 2; // round to nearest 0.5°C
29
+ }
30
+ } else if (currentTemperatureUnit === 'C' && targetTemperatureUnit === 'F') {
31
+ temperature = (temperature * 9) / 5 + 32;
32
+ if (round === true) {
33
+ temperature = Math.round(temperature); // round to nearest 1°F
34
+ }
35
+ } else if (round === true) {
36
+ // No conversion, just rounding
37
+ temperature = targetTemperatureUnit === 'C' ? Math.round(temperature * 2) / 2 : Math.round(temperature);
38
+ }
39
+
40
+ return temperature;
41
+ }
42
+
43
+ function crc24(valueToHash) {
44
+ const crc24HashTable = [
45
+ 0x000000, 0x864cfb, 0x8ad50d, 0x0c99f6, 0x93e6e1, 0x15aa1a, 0x1933ec, 0x9f7f17, 0xa18139, 0x27cdc2, 0x2b5434, 0xad18cf, 0x3267d8,
46
+ 0xb42b23, 0xb8b2d5, 0x3efe2e, 0xc54e89, 0x430272, 0x4f9b84, 0xc9d77f, 0x56a868, 0xd0e493, 0xdc7d65, 0x5a319e, 0x64cfb0, 0xe2834b,
47
+ 0xee1abd, 0x685646, 0xf72951, 0x7165aa, 0x7dfc5c, 0xfbb0a7, 0x0cd1e9, 0x8a9d12, 0x8604e4, 0x00481f, 0x9f3708, 0x197bf3, 0x15e205,
48
+ 0x93aefe, 0xad50d0, 0x2b1c2b, 0x2785dd, 0xa1c926, 0x3eb631, 0xb8faca, 0xb4633c, 0x322fc7, 0xc99f60, 0x4fd39b, 0x434a6d, 0xc50696,
49
+ 0x5a7981, 0xdc357a, 0xd0ac8c, 0x56e077, 0x681e59, 0xee52a2, 0xe2cb54, 0x6487af, 0xfbf8b8, 0x7db443, 0x712db5, 0xf7614e, 0x19a3d2,
50
+ 0x9fef29, 0x9376df, 0x153a24, 0x8a4533, 0x0c09c8, 0x00903e, 0x86dcc5, 0xb822eb, 0x3e6e10, 0x32f7e6, 0xb4bb1d, 0x2bc40a, 0xad88f1,
51
+ 0xa11107, 0x275dfc, 0xdced5b, 0x5aa1a0, 0x563856, 0xd074ad, 0x4f0bba, 0xc94741, 0xc5deb7, 0x43924c, 0x7d6c62, 0xfb2099, 0xf7b96f,
52
+ 0x71f594, 0xee8a83, 0x68c678, 0x645f8e, 0xe21375, 0x15723b, 0x933ec0, 0x9fa736, 0x19ebcd, 0x8694da, 0x00d821, 0x0c41d7, 0x8a0d2c,
53
+ 0xb4f302, 0x32bff9, 0x3e260f, 0xb86af4, 0x2715e3, 0xa15918, 0xadc0ee, 0x2b8c15, 0xd03cb2, 0x567049, 0x5ae9bf, 0xdca544, 0x43da53,
54
+ 0xc596a8, 0xc90f5e, 0x4f43a5, 0x71bd8b, 0xf7f170, 0xfb6886, 0x7d247d, 0xe25b6a, 0x641791, 0x688e67, 0xeec29c, 0x3347a4, 0xb50b5f,
55
+ 0xb992a9, 0x3fde52, 0xa0a145, 0x26edbe, 0x2a7448, 0xac38b3, 0x92c69d, 0x148a66, 0x181390, 0x9e5f6b, 0x01207c, 0x876c87, 0x8bf571,
56
+ 0x0db98a, 0xf6092d, 0x7045d6, 0x7cdc20, 0xfa90db, 0x65efcc, 0xe3a337, 0xef3ac1, 0x69763a, 0x578814, 0xd1c4ef, 0xdd5d19, 0x5b11e2,
57
+ 0xc46ef5, 0x42220e, 0x4ebbf8, 0xc8f703, 0x3f964d, 0xb9dab6, 0xb54340, 0x330fbb, 0xac70ac, 0x2a3c57, 0x26a5a1, 0xa0e95a, 0x9e1774,
58
+ 0x185b8f, 0x14c279, 0x928e82, 0x0df195, 0x8bbd6e, 0x872498, 0x016863, 0xfad8c4, 0x7c943f, 0x700dc9, 0xf64132, 0x693e25, 0xef72de,
59
+ 0xe3eb28, 0x65a7d3, 0x5b59fd, 0xdd1506, 0xd18cf0, 0x57c00b, 0xc8bf1c, 0x4ef3e7, 0x426a11, 0xc426ea, 0x2ae476, 0xaca88d, 0xa0317b,
60
+ 0x267d80, 0xb90297, 0x3f4e6c, 0x33d79a, 0xb59b61, 0x8b654f, 0x0d29b4, 0x01b042, 0x87fcb9, 0x1883ae, 0x9ecf55, 0x9256a3, 0x141a58,
61
+ 0xefaaff, 0x69e604, 0x657ff2, 0xe33309, 0x7c4c1e, 0xfa00e5, 0xf69913, 0x70d5e8, 0x4e2bc6, 0xc8673d, 0xc4fecb, 0x42b230, 0xddcd27,
62
+ 0x5b81dc, 0x57182a, 0xd154d1, 0x26359f, 0xa07964, 0xace092, 0x2aac69, 0xb5d37e, 0x339f85, 0x3f0673, 0xb94a88, 0x87b4a6, 0x01f85d,
63
+ 0x0d61ab, 0x8b2d50, 0x145247, 0x921ebc, 0x9e874a, 0x18cbb1, 0xe37b16, 0x6537ed, 0x69ae1b, 0xefe2e0, 0x709df7, 0xf6d10c, 0xfa48fa,
64
+ 0x7c0401, 0x42fa2f, 0xc4b6d4, 0xc82f22, 0x4e63d9, 0xd11cce, 0x575035, 0x5bc9c3, 0xdd8538,
65
+ ];
66
+
67
+ let crc = 0xb704ce;
68
+
69
+ let buffer = Buffer.from(valueToHash);
70
+
71
+ for (let i = 0; i < buffer.length; i++) {
72
+ let index = ((crc >> 16) ^ buffer[i]) & 0xff;
73
+ crc = (crc24HashTable[index] ^ (crc << 8)) & 0xffffff;
74
+ }
75
+
76
+ return crc.toString(16).padStart(6, '0'); // ensures 6-digit hex
77
+ }
78
+
79
+ function scaleValue(value, sourceMin, sourceMax, targetMin, targetMax) {
80
+ if (sourceMax === sourceMin) {
81
+ return targetMin;
82
+ }
83
+
84
+ value = Math.max(sourceMin, Math.min(sourceMax, value));
85
+
86
+ return ((value - sourceMin) * (targetMax - targetMin)) / (sourceMax - sourceMin) + targetMin;
87
+ }
88
+
89
+ async function fetchWrapper(method, url, options, data) {
90
+ if ((method !== 'get' && method !== 'post') || typeof url !== 'string' || url === '' || typeof options !== 'object') {
91
+ return;
92
+ }
93
+
94
+ if (isNaN(options?.timeout) === false && Number(options.timeout) > 0) {
95
+ // eslint-disable-next-line no-undef
96
+ options.signal = AbortSignal.timeout(Number(options.timeout));
97
+ }
98
+
99
+ if (isNaN(options.retry) || options.retry < 1) {
100
+ options.retry = 1;
101
+ }
102
+
103
+ if (isNaN(options._retryCount)) {
104
+ options._retryCount = 0;
105
+ }
106
+
107
+ options.method = method;
108
+
109
+ if (method === 'post' && data !== undefined) {
110
+ if (typeof data === 'object' && data !== null && data.constructor === Object) {
111
+ options.body = JSON.stringify(data);
112
+
113
+ // Set Content-Type header only if not already set
114
+ options.headers = options.headers || {};
115
+ if (options.headers['Content-Type'] === undefined) {
116
+ options.headers['Content-Type'] = 'application/json';
117
+ }
118
+ } else {
119
+ options.body = data;
120
+ }
121
+ }
122
+
123
+ try {
124
+ // eslint-disable-next-line no-undef
125
+ let response = await fetch(url, {
126
+ ...options,
127
+ dispatcher: options?.dispatcher ?? defaultFetchAgent, // Always use a secure default agent unless explicitly overridden
128
+ });
129
+
130
+ if (response?.ok === false) {
131
+ if (options.retry > 1) {
132
+ options.retry--;
133
+ options._retryCount++;
134
+
135
+ let delay = 500 * Math.pow(2, options._retryCount - 1);
136
+ await new Promise((resolve) => {
137
+ resolve = resolve;
138
+ setTimeout(resolve, delay);
139
+ });
140
+
141
+ return fetchWrapper(method, url, options, data);
142
+ }
143
+
144
+ // Optionally get response body
145
+ let body;
146
+ try {
147
+ body = await response.text();
148
+ // eslint-disable-next-line no-unused-vars
149
+ } catch (error) {
150
+ body = '';
151
+ }
152
+
153
+ throw Object.assign(
154
+ new Error('HTTP ' + response.status + ' on ' + method.toUpperCase() + ' ' + url + ': ' + (response.statusText || 'Unknown error')),
155
+ { code: response.status, status: response.status, body },
156
+ );
157
+ }
158
+
159
+ return response;
160
+ } catch (error) {
161
+ if (
162
+ options.retry > 1 &&
163
+ (error?.cause?.code === 'UND_ERR_HEADERS_TIMEOUT' || error?.name === 'AbortError' || error?.name === 'TypeError')
164
+ ) {
165
+ options.retry--;
166
+ options._retryCount++;
167
+
168
+ let delay = 500 * Math.pow(2, options._retryCount - 1);
169
+ await new Promise((resolve) => setTimeout(resolve, delay));
170
+ }
171
+
172
+ throw new Error(
173
+ method.toUpperCase() +
174
+ ' ' +
175
+ url +
176
+ ' failed after ' +
177
+ (options._retryCount + 1) +
178
+ ' attempt' +
179
+ (options._retryCount + 1 > 1 ? 's' : '') +
180
+ ': ' +
181
+ (error?.message || String(error)) +
182
+ (error?.cause?.code ? ' (' + error.cause.code + ')' : ''),
183
+ { cause: error },
184
+ );
185
+ }
186
+ }
187
+
188
+ function parseDurationToSeconds(inputDuration, { defaultValue = null, min = 0, max = Infinity } = {}) {
189
+ let normalisedSeconds = defaultValue;
190
+
191
+ if (inputDuration !== undefined && inputDuration !== null && inputDuration !== '') {
192
+ inputDuration = String(inputDuration).trim().toLowerCase();
193
+
194
+ // Case: plain numeric seconds (e.g. "30")
195
+ if (/^\d+$/.test(inputDuration) === true) {
196
+ normalisedSeconds = Number(inputDuration);
197
+ } else {
198
+ // Process input into normalised units. We'll convert in standard h (hours), m (minutes), s (seconds)
199
+ inputDuration = inputDuration
200
+ .replace(/hrs?|hours?/g, 'h')
201
+ .replace(/mins?|minutes?/g, 'm')
202
+ .replace(/secs?|s\b/g, 's')
203
+ .replace(/ +/g, '');
204
+
205
+ // Match duration format like "1h30m15s"
206
+ let match = inputDuration.match(/^((\d+)h)?((\d+)m)?((\d+)s?)?$/);
207
+
208
+ if (Array.isArray(match) === true) {
209
+ let total = Number(match[2] || 0) * 3600 + Number(match[4] || 0) * 60 + Number(match[6] || 0);
210
+ normalisedSeconds = Math.floor(total / 3600) * 3600 + Math.floor((total % 3600) / 60) * 60 + (total % 60);
211
+ }
212
+ }
213
+
214
+ if (normalisedSeconds === null || isNaN(normalisedSeconds) === true) {
215
+ normalisedSeconds = defaultValue;
216
+ }
217
+
218
+ if (isNaN(min) === false && normalisedSeconds < min) {
219
+ normalisedSeconds = min;
220
+ }
221
+ if (isNaN(max) === false && normalisedSeconds > max) {
222
+ normalisedSeconds = max;
223
+ }
224
+ }
225
+
226
+ return normalisedSeconds;
227
+ }
228
+
229
+ function processCommonData(deviceUUID, data, config) {
230
+ if (
231
+ typeof deviceUUID !== 'string' ||
232
+ deviceUUID === '' ||
233
+ data === null ||
234
+ typeof data !== 'object' ||
235
+ data?.constructor !== Object ||
236
+ typeof config !== 'object' ||
237
+ config?.constructor !== Object
238
+ ) {
239
+ return;
240
+ }
241
+ // Process common data for all devices
242
+
243
+ // Process software version strings and return as x.x.x
244
+ // handles things like:
245
+ // 1.0a17 -> 1.0.17
246
+ // 3.6rc8 -> 3.6.8
247
+ // rquartz-user 1 OPENMASTER 507800056 test-keys stable-channel stable-channel -> 507800056
248
+ // nq-user 1.73 OPENMASTER 422270 release-keys stable-channel stable-channel -> 422270
249
+ const process_software_version = (versionString) => {
250
+ let version = '0.0.0';
251
+ if (typeof versionString === 'string') {
252
+ let normalised = versionString.replace(/[-_]/g, '.');
253
+ let tokens = normalised.split(/\s+/);
254
+ let candidate = tokens[3] || normalised;
255
+ let match = candidate.match(/\d+(?:\.\d+)*[a-zA-Z]*\d*/) || normalised.match(/\d+(?:\.\d+)*[a-zA-Z]*\d*/);
256
+
257
+ if (Array.isArray(match) === true) {
258
+ let raw = match[0];
259
+ if (raw.includes('.') === false) {
260
+ return raw; // Return single-number version like "422270" as-is
261
+ }
262
+
263
+ let parts = raw.split('.').flatMap((part) => {
264
+ let [, n1, , n2] = part.match(/^(\d+)([a-zA-Z]+)?(\d+)?$/) || [];
265
+ return [n1, n2].filter(Boolean).map(Number);
266
+ });
267
+
268
+ while (parts.length < 3) {
269
+ parts.push(0);
270
+ }
271
+ version = parts.slice(0, 3).join('.');
272
+ }
273
+ }
274
+
275
+ return version;
276
+ };
277
+
278
+ let processed = {};
279
+ try {
280
+ // Fix up data we need to
281
+ let deviceOptions = config?.devices?.find((device) => device?.serialNumber?.toUpperCase?.() === data?.serialNumber?.toUpperCase?.());
282
+ data.nest_google_uuid = deviceUUID;
283
+ data.serialNumber = data.serialNumber.toUpperCase(); // ensure serial numbers are in upper case
284
+ data.excluded = this?.config?.options?.exclude === true ? deviceOptions?.exclude !== false : deviceOptions?.exclude === true;
285
+ data.manufacturer = typeof data?.manufacturer === 'string' && data.manufacturer !== '' ? data.manufacturer : 'Nest';
286
+ data.softwareVersion = process_software_version(data.softwareVersion);
287
+ let description = typeof data?.description === 'string' ? data.description : '';
288
+ let location = typeof data?.location === 'string' ? data.location : '';
289
+ if (description === '' && location !== '') {
290
+ description = location;
291
+ location = '';
292
+ }
293
+ if (description === '' && location === '') {
294
+ description = 'unknown description';
295
+ }
296
+ data.description = HomeKitDevice.makeValidHKName(location === '' ? description : description + ' - ' + location);
297
+ delete data.location;
298
+
299
+ processed = data;
300
+ // eslint-disable-next-line no-unused-vars
301
+ } catch (error) {
302
+ // Empty
303
+ }
304
+ return processed;
305
+ }
306
+
307
+ function logJSONObject(log, object) {
308
+ if (typeof object !== 'object' || object.constructor !== Object) {
309
+ return;
310
+ }
311
+
312
+ Object.entries(object).forEach(([key, value]) => {
313
+ if (typeof value === 'object' && value !== null) {
314
+ log?.debug?.(' %s:', key);
315
+ String(JSON.stringify(value, null, 2))
316
+ .split('\n')
317
+ .forEach((line) => {
318
+ log?.debug?.(' %s', line);
319
+ });
320
+ } else {
321
+ log?.debug?.(' %s: %j', key, value);
322
+ }
323
+ });
324
+ }
325
+
326
+ // Define exports
327
+ export { processCommonData, adjustTemperature, crc24, scaleValue, fetchWrapper, parseDurationToSeconds, logJSONObject };