UNPKG

homebridge-nest-accfactory

Version:

Homebridge support for Nest/Google devices including HomeKit Secure Video (HKSV) support for doorbells and cameras

401 lines (345 loc) 14.9 kB
// Utility Helpers - shared functions and data processing // Part of homebridge-nest-accfactory // // Provides shared helper functions used across the plugin for data normalisation, // transformation, HTTP handling, and general utility operations. // // Responsibilities: // - Perform temperature conversion and normalisation // - Provide hashing utilities for device identity (CRC24) // - Scale and transform numeric values between ranges // - Handle HTTP requests with retry, timeout, and error handling // - Parse duration strings into normalised seconds // - Apply common device data processing and configuration overrides // // Features: // - Temperature conversion with optional rounding (Celsius / Fahrenheit) // - CRC24 hashing for stable device identifiers // - Value scaling with bounds checking // - Robust fetch wrapper with: // - retry logic (exponential backoff) // - timeout handling // - structured error reporting // - Flexible duration parsing (e.g. "1w3d2h15m30s") // // Notes: // - Used by all device and system modules // - fetchWrapper() is the primary HTTP client abstraction for the plugin // - Functions are designed to fail safely and return undefined where appropriate // // Code version 2026.05.06 // Mark Hulskamp 'use strict'; // Define nodejs module requirements import { Buffer } from 'node:buffer'; import { setTimeout } from 'node:timers'; // Define constants const CRC24_HASH_TABLE = [ 0x000000, 0x864cfb, 0x8ad50d, 0x0c99f6, 0x93e6e1, 0x15aa1a, 0x1933ec, 0x9f7f17, 0xa18139, 0x27cdc2, 0x2b5434, 0xad18cf, 0x3267d8, 0xb42b23, 0xb8b2d5, 0x3efe2e, 0xc54e89, 0x430272, 0x4f9b84, 0xc9d77f, 0x56a868, 0xd0e493, 0xdc7d65, 0x5a319e, 0x64cfb0, 0xe2834b, 0xee1abd, 0x685646, 0xf72951, 0x7165aa, 0x7dfc5c, 0xfbb0a7, 0x0cd1e9, 0x8a9d12, 0x8604e4, 0x00481f, 0x9f3708, 0x197bf3, 0x15e205, 0x93aefe, 0xad50d0, 0x2b1c2b, 0x2785dd, 0xa1c926, 0x3eb631, 0xb8faca, 0xb4633c, 0x322fc7, 0xc99f60, 0x4fd39b, 0x434a6d, 0xc50696, 0x5a7981, 0xdc357a, 0xd0ac8c, 0x56e077, 0x681e59, 0xee52a2, 0xe2cb54, 0x6487af, 0xfbf8b8, 0x7db443, 0x712db5, 0xf7614e, 0x19a3d2, 0x9fef29, 0x9376df, 0x153a24, 0x8a4533, 0x0c09c8, 0x00903e, 0x86dcc5, 0xb822eb, 0x3e6e10, 0x32f7e6, 0xb4bb1d, 0x2bc40a, 0xad88f1, 0xa11107, 0x275dfc, 0xdced5b, 0x5aa1a0, 0x563856, 0xd074ad, 0x4f0bba, 0xc94741, 0xc5deb7, 0x43924c, 0x7d6c62, 0xfb2099, 0xf7b96f, 0x71f594, 0xee8a83, 0x68c678, 0x645f8e, 0xe21375, 0x15723b, 0x933ec0, 0x9fa736, 0x19ebcd, 0x8694da, 0x00d821, 0x0c41d7, 0x8a0d2c, 0xb4f302, 0x32bff9, 0x3e260f, 0xb86af4, 0x2715e3, 0xa15918, 0xadc0ee, 0x2b8c15, 0xd03cb2, 0x567049, 0x5ae9bf, 0xdca544, 0x43da53, 0xc596a8, 0xc90f5e, 0x4f43a5, 0x71bd8b, 0xf7f170, 0xfb6886, 0x7d247d, 0xe25b6a, 0x641791, 0x688e67, 0xeec29c, 0x3347a4, 0xb50b5f, 0xb992a9, 0x3fde52, 0xa0a145, 0x26edbe, 0x2a7448, 0xac38b3, 0x92c69d, 0x148a66, 0x181390, 0x9e5f6b, 0x01207c, 0x876c87, 0x8bf571, 0x0db98a, 0xf6092d, 0x7045d6, 0x7cdc20, 0xfa90db, 0x65efcc, 0xe3a337, 0xef3ac1, 0x69763a, 0x578814, 0xd1c4ef, 0xdd5d19, 0x5b11e2, 0xc46ef5, 0x42220e, 0x4ebbf8, 0xc8f703, 0x3f964d, 0xb9dab6, 0xb54340, 0x330fbb, 0xac70ac, 0x2a3c57, 0x26a5a1, 0xa0e95a, 0x9e1774, 0x185b8f, 0x14c279, 0x928e82, 0x0df195, 0x8bbd6e, 0x872498, 0x016863, 0xfad8c4, 0x7c943f, 0x700dc9, 0xf64132, 0x693e25, 0xef72de, 0xe3eb28, 0x65a7d3, 0x5b59fd, 0xdd1506, 0xd18cf0, 0x57c00b, 0xc8bf1c, 0x4ef3e7, 0x426a11, 0xc426ea, 0x2ae476, 0xaca88d, 0xa0317b, 0x267d80, 0xb90297, 0x3f4e6c, 0x33d79a, 0xb59b61, 0x8b654f, 0x0d29b4, 0x01b042, 0x87fcb9, 0x1883ae, 0x9ecf55, 0x9256a3, 0x141a58, 0xefaaff, 0x69e604, 0x657ff2, 0xe33309, 0x7c4c1e, 0xfa00e5, 0xf69913, 0x70d5e8, 0x4e2bc6, 0xc8673d, 0xc4fecb, 0x42b230, 0xddcd27, 0x5b81dc, 0x57182a, 0xd154d1, 0x26359f, 0xa07964, 0xace092, 0x2aac69, 0xb5d37e, 0x339f85, 0x3f0673, 0xb94a88, 0x87b4a6, 0x01f85d, 0x0d61ab, 0x8b2d50, 0x145247, 0x921ebc, 0x9e874a, 0x18cbb1, 0xe37b16, 0x6537ed, 0x69ae1b, 0xefe2e0, 0x709df7, 0xf6d10c, 0xfa48fa, 0x7c0401, 0x42fa2f, 0xc4b6d4, 0xc82f22, 0x4e63d9, 0xd11cce, 0x575035, 0x5bc9c3, 0xdd8538, ]; function adjustTemperature(temperature, currentTemperatureUnit, targetTemperatureUnit, round) { // Validate temperature input if (Number.isFinite(Number(temperature)) !== true) { return undefined; } // Normalise units currentTemperatureUnit = currentTemperatureUnit?.toUpperCase?.(); targetTemperatureUnit = targetTemperatureUnit?.toUpperCase?.(); // Validate supported temperature units if (['C', 'F'].includes(currentTemperatureUnit) === false || ['C', 'F'].includes(targetTemperatureUnit) === false) { return undefined; } temperature = Number(temperature); // Convert temperature if units differ if (currentTemperatureUnit === 'F' && targetTemperatureUnit === 'C') { temperature = ((temperature - 32) * 5) / 9; } if (currentTemperatureUnit === 'C' && targetTemperatureUnit === 'F') { temperature = (temperature * 9) / 5 + 32; } // Apply HomeKit-friendly rounding rules if (round === true) { // HomeKit commonly uses 0.5°C precision and whole °F values temperature = targetTemperatureUnit === 'C' ? Math.round(temperature * 2) / 2 : Math.round(temperature); } return temperature; } function crc24(valueToHash) { // Validate input if (valueToHash === undefined || valueToHash === null) { return undefined; } let crc = 0xb704ce; let buffer = Buffer.from(String(valueToHash)); for (let i = 0; i < buffer.length; i++) { let index = ((crc >> 16) ^ buffer[i]) & 0xff; crc = (CRC24_HASH_TABLE[index] ^ (crc << 8)) & 0xffffff; } return crc.toString(16).padStart(6, '0'); // ensures 6-digit hex } function scaleValue(value, sourceMin, sourceMax, targetMin, targetMax) { // Validate numeric inputs if ( Number.isFinite(Number(value)) !== true || Number.isFinite(Number(sourceMin)) !== true || Number.isFinite(Number(sourceMax)) !== true || Number.isFinite(Number(targetMin)) !== true || Number.isFinite(Number(targetMax)) !== true ) { return undefined; } value = Number(value); sourceMin = Number(sourceMin); sourceMax = Number(sourceMax); targetMin = Number(targetMin); targetMax = Number(targetMax); // Prevent divide-by-zero scaling range if (sourceMax === sourceMin) { return targetMin; } // Clamp source value to input range value = Math.max(sourceMin, Math.min(sourceMax, value)); // Scale value proportionally into target range return ((value - sourceMin) * (targetMax - targetMin)) / (sourceMax - sourceMin) + targetMin; } async function fetchWrapper(method, url, options = {}, data) { // Only support GET and POST requests // Invalid inputs fail silently to match existing helper behaviour if ((method !== 'get' && method !== 'post') || typeof url !== 'string' || url === '' || typeof options !== 'object') { return; } // Retry count configuration // Defaults to a single attempt if not specified let retry = Number.isFinite(Number(options.retry)) && Number(options.retry) > 0 ? Number(options.retry) : 1; let retryCount = Number.isFinite(Number(options._retryCount)) ? Number(options._retryCount) : 0; // Clone fetch options so we can safely remove internal-only fields let fetchOptions = { ...options, method, _retryCount: retryCount, }; // Remove internal helper options before passing to fetch() delete fetchOptions.retry; delete fetchOptions.timeout; delete fetchOptions._retryCount; delete fetchOptions.signal; // Apply timeout using AbortSignal if configured if (Number.isFinite(Number(options?.timeout)) && Number(options.timeout) > 0) { // eslint-disable-next-line no-undef fetchOptions.signal = AbortSignal.timeout(Number(options.timeout)); } // Process POST body handling if (method === 'post' && data !== undefined) { // Automatically serialise plain objects as JSON if (typeof data === 'object' && data !== null && data.constructor === Object) { fetchOptions.body = JSON.stringify(data); fetchOptions.headers = fetchOptions.headers || {}; // Apply JSON content type if not already specified if (fetchOptions.headers['Content-Type'] === undefined) { fetchOptions.headers['Content-Type'] = 'application/json'; } } else { // Pass through raw body types unchanged fetchOptions.body = data; } } try { // Perform request // eslint-disable-next-line no-undef let response = await fetch(url, { ...fetchOptions, ...(options?.dispatcher !== undefined ? { dispatcher: options.dispatcher } : {}), }); // HTTP request completed but returned non-success status if (response?.ok === false) { // Only retry transient/server-side failures // Do not retry authentication or client configuration failures let retryableStatus = [408, 429, 500, 502, 503, 504].includes(response.status) === true; if (retry > 1 && retryableStatus === true) { // Exponential backoff delay let delay = 500 * Math.pow(2, retryCount); await new Promise((resolve) => setTimeout(resolve, delay)); return fetchWrapper( method, url, { ...options, retry: retry - 1, _retryCount: retryCount + 1, }, data, ); } // Attempt to capture response body for debugging let body = ''; try { body = await response.text(); // eslint-disable-next-line no-unused-vars } catch (error) { // Ignore body parsing failures } throw Object.assign( new Error('HTTP ' + response.status + ' on ' + method.toUpperCase() + ' ' + url + ': ' + (response.statusText || 'Unknown error')), { code: response.status, status: response.status, body, }, ); } return response; } catch (error) { // Preserve original/root cause where available let original = error?.cause ?? error; // Determine whether failure is considered transient/retryable let retryable = error?.name === 'AbortError' || error?.name === 'TimeoutError' || error?.name === 'TypeError' || original?.name === 'AbortError' || original?.name === 'TimeoutError' || original?.name === 'TypeError' || original?.code === 'UND_ERR_HEADERS_TIMEOUT' || original?.code === 'UND_ERR_CONNECT_TIMEOUT'; // Invalid URLs should fail immediately if (original?.code === 'ERR_INVALID_URL') { throw Object.assign(new Error('Invalid URL: ' + url), { code: 'ERR_INVALID_URL', cause: original, }); } // Retry transient transport/network failures if (retry > 1 && retryable === true) { // Exponential backoff delay let delay = 500 * Math.pow(2, retryCount); await new Promise((resolve) => setTimeout(resolve, delay)); return fetchWrapper( method, url, { ...options, retry: retry - 1, _retryCount: retryCount + 1, }, data, ); } // Final wrapped error after all retry attempts exhausted throw Object.assign( new Error( method.toUpperCase() + ' ' + url + ' failed after ' + (retryCount + 1) + ' attempt' + (retryCount + 1 > 1 ? 's' : '') + ': ' + (original?.message || String(original)), ), { code: original?.code, cause: original, }, ); } } function parseDurationToSeconds(inputDuration, { defaultValue = null, min = 0, max = Infinity } = {}) { let normalisedSeconds = defaultValue; if (inputDuration !== undefined && inputDuration !== null && inputDuration !== '') { inputDuration = String(inputDuration).trim().toLowerCase(); // Negative durations are not supported if (inputDuration.startsWith('-') === true) { return defaultValue; } // Case: plain numeric seconds (e.g. "30") if (/^\d+$/.test(inputDuration) === true) { normalisedSeconds = Number(inputDuration); } else { // Normalise all known unit types to single characters inputDuration = inputDuration .replace(/(?<=\d)\s*(weeks?|w)(?=\D|$)/gi, 'w') .replace(/(?<=\d)\s*(days?|d)(?=\D|$)/gi, 'd') .replace(/(?<=\d)\s*(hours?|hrs?|hr|h)(?=\D|$)/gi, 'h') .replace(/(?<=\d)\s*(minutes?|mins?|min|m)(?=\D|$)/gi, 'm') .replace(/(?<=\d)\s*(seconds?|secs?|sec|s)(?=\D|$)/gi, 's') .replace(/ +/g, ''); // Match format like "1w3d2h15m30s" let match = inputDuration.match(/^((\d+)w)?((\d+)d)?((\d+)h)?((\d+)m)?((\d+)s)?$/); if (Array.isArray(match) === true) { let total = Number(match[2] || 0) * 604800 + // weeks Number(match[4] || 0) * 86400 + // days Number(match[6] || 0) * 3600 + // hours Number(match[8] || 0) * 60 + // minutes Number(match[10] || 0); // seconds normalisedSeconds = total; } } // Fallback to default if parsing failed if (normalisedSeconds === null || Number.isFinite(normalisedSeconds) !== true) { normalisedSeconds = defaultValue; } // Apply optional bounds checking if (Number.isFinite(normalisedSeconds) === true) { if (Number.isFinite(min) === true && normalisedSeconds < min) { normalisedSeconds = min; } if (Number.isFinite(max) === true && normalisedSeconds > max) { normalisedSeconds = max; } } } return normalisedSeconds; } // Process software version strings and return as x.x.x // handles things like: // 1.0a17 -> 1.0.17 // 3.6rc8 -> 3.6.8 // rquartz-user 1 OPENMASTER 507800056 test-keys stable-channel stable-channel -> 507800056 // nq-user 1.73 OPENMASTER 422270 release-keys stable-channel stable-channel -> 422270 function processSoftwareVersion(versionString) { let version = '0.0.0'; if (typeof versionString === 'string') { let normalised = versionString.replace(/[-_]/g, '.'); let tokens = normalised.split(/\s+/); let candidate = tokens[3] || normalised; let match = candidate.match(/\d+(?:\.\d+)*[a-zA-Z]*\d*/) || normalised.match(/\d+(?:\.\d+)*[a-zA-Z]*\d*/); if (Array.isArray(match) === true) { let raw = match[0]; if (raw.includes('.') === false) { return raw; // Return single-number version like "422270" as-is } let parts = raw.split('.').flatMap((part) => { let [, n1, , n2] = part.match(/^(\d+)([a-zA-Z]+)?(\d+)?$/) || []; return [n1, n2].filter(Boolean).map(Number); }); while (parts.length < 3) { parts.push(0); } version = parts.slice(0, 3).join('.'); } } return version; } // Define exports export { processSoftwareVersion, adjustTemperature, crc24, scaleValue, fetchWrapper, parseDurationToSeconds };