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
JavaScript
// 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
;
// 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 };