homebridge-nest-accfactory
Version:
Homebridge support for Nest/Google devices including HomeKit Secure Video (HKSV) support for doorbells and cameras
355 lines (312 loc) • 11.8 kB
JavaScript
// Translation Engine - generic field-map data normalisation
// Part of homebridge-nest-accfactory
//
// Provides a lightweight translation layer for converting raw source data
// into a unified internal device model.
//
// Responsibilities:
// - Map source-specific data to canonical device fields
// - Resolve values using configurable field maps (string paths or functions)
// - Support source preference and fallback handling
// - Allow custom merge logic for complex field resolution
// - Support partial (delta) updates and full object construction
// - Provide reusable helpers for mapping, merging, and context construction
//
// Features:
// - Field map definitions supporting:
// - String-based path extraction
// - Function-based value resolution
// - Object-based source config with:
// - translate: value resolver
// - fields: raw fields on the current object this mapping depends on
// - related: raw fields on related objects (via rawData) this mapping depends on
// - required: marks fields needed for a minimum viable (full) object
// - Generic source handling (not tied to Nest / Google)
// - Automatic fallback and default value support
// - Custom merge functions per field
// - Safe value resolution with error handling
//
// Delta / inclusion behaviour:
// - buildMappedObject() can accept a Set of changed raw field names
// - A mapped field is included if any of its declared dependencies match:
// fields ∪ related
// - fields and related are treated equally for inclusion decisions
// - If no dependencies are declared, the field is always included
//
// Output model:
// - buildMappedObject() returns:
// {
// data: <mapped fields>,
// hasRequired: <true if all required fields are present>
// }
// - hasRequired indicates whether the result is a complete "minimum viable"
// object (e.g. safe to construct a device) or a partial update
//
// Notes:
// - Designed to isolate raw source differences from device implementations
// - Each device module defines its own FIELD_MAP using this engine
// - Missing or invalid values resolve to undefined (not placeholder defaults)
// - Related data is resolved via rawData during translation; callers are
// responsible for including relevant changed field names in delta updates
//
// Data flow:
// - createMappingContext() builds a source-aware context object
// - buildMappedObject() resolves all fields using the field map
// - resolveMappedField() applies preference, merge logic, and fallbacks
// - mergeMappedObjects() combines mapped results when required
//
// Basic usage:
// - Define a FIELD_MAP in a device module:
// const FIELD_MAP = {
// online: {
// required: true,
// google: {
// fields: ['liveness'],
// translate: ({ raw }) => raw?.value === 'LIVENESS_DEVICE_STATUS_ONLINE',
// },
// nest: 'online',
// prefer: 'google',
// defaultValue: false,
// },
// };
//
// - Build a source-aware context:
// let context = createMappingContext(rawData, objectKey, {
// google: googleRawObject,
// nest: nestRawObject,
// });
//
// - Build a mapped object:
// let result = buildMappedObject(FIELD_MAP, context, new Set(['liveness']));
//
// - Use result.data for device updates and result.hasRequired to determine
// whether the mapped result is complete enough to create a new device.
//
// Code version 2026.05.06
// Mark Hulskamp
// Safely read a dot-separated path from an object
function getPath(object, path) {
if (object === null || typeof object !== 'object' || typeof path !== 'string' || path.trim() === '') {
return undefined;
}
return path.split('.').reduce((value, key) => value?.[key], object);
}
// Determine if a mapped value should be considered usable
function hasMappedValue(value) {
return value !== undefined && value !== null && value !== '';
}
// Determine if all required fields defined in the field map
// are present in the mapped data object.
//
// A field is considered required if `required: true` is set
// in the field map definition.
//
// This is used to determine whether the mapped result represents
// a complete "minimum viable" object (e.g. safe to create a device),
// versus a partial update (delta) where only some fields are present.
function hasRequiredFields(mappedData, fieldMap) {
if (
mappedData === null ||
typeof mappedData !== 'object' ||
mappedData?.constructor !== Object ||
fieldMap === null ||
typeof fieldMap !== 'object' ||
fieldMap?.constructor !== Object
) {
return false;
}
// Check that every required field exists in the mapped data
return Object.entries(fieldMap)
.filter(([, mapEntry]) => mapEntry?.required === true)
.every(([fieldName]) => mappedData[fieldName] !== undefined);
}
// Resolve one mapping entry for a single source
// Supported mapping types:
// - function
// - string path
// - object containing translate function or translate string path
function getMappedValue(mapping, context) {
if (context?.raw === undefined) {
return undefined;
}
if (typeof mapping === 'function') {
try {
return mapping(context);
// eslint-disable-next-line no-unused-vars
} catch (error) {
return undefined;
}
}
if (typeof mapping === 'string' && mapping.trim() !== '') {
return getPath(context.raw, mapping);
}
if (mapping !== null && typeof mapping === 'object' && mapping?.constructor === Object) {
if (typeof mapping.translate === 'function') {
try {
return mapping.translate(context);
// eslint-disable-next-line no-unused-vars
} catch (error) {
return undefined;
}
}
if (typeof mapping.translate === 'string' && mapping.translate.trim() !== '') {
return getPath(context.raw, mapping.translate);
}
}
return undefined;
}
// Helper to normalise a source object for translation
function normaliseSourceContext(source, objectKey, raw, extraContext = undefined) {
let context = {
source: source,
objectKey: objectKey,
raw: raw,
};
if (extraContext !== null && typeof extraContext === 'object' && extraContext?.constructor === Object) {
Object.entries(extraContext).forEach(([key, value]) => {
context[key] = value;
});
}
return context;
}
// Resolve a single canonical field from a field map entry
function resolveMappedField(fieldName, fieldMap, context) {
let values = {};
let preferredValue = undefined;
let resolvedValue = undefined;
let sourceKeys = [];
if (fieldMap === null || typeof fieldMap !== 'object' || fieldMap?.constructor !== Object) {
return undefined;
}
sourceKeys = Object.keys(context?.sources ?? {}).filter(
(sourceKey) => Object.hasOwn(fieldMap, sourceKey) === true && ['merge', 'prefer', 'defaultValue'].includes(sourceKey) === false,
);
sourceKeys.forEach((sourceKey) => {
let sourceContext = {
...context,
...(context?.sources?.[sourceKey] ?? {}),
};
values[sourceKey] = getMappedValue(fieldMap[sourceKey], sourceContext);
});
if (typeof fieldMap?.merge === 'function') {
try {
return fieldMap.merge({
fieldName: fieldName,
fieldMap: fieldMap,
context: context,
values: values,
defaultValue: fieldMap?.defaultValue,
});
// eslint-disable-next-line no-unused-vars
} catch (error) {
return hasMappedValue(fieldMap?.defaultValue) === true ? fieldMap.defaultValue : undefined;
}
}
if (typeof fieldMap?.prefer === 'string' && hasMappedValue(values?.[fieldMap.prefer]) === true) {
preferredValue = values[fieldMap.prefer];
}
if (hasMappedValue(preferredValue) === true) {
return preferredValue;
}
resolvedValue = Object.values(values).find((value) => hasMappedValue(value) === true);
if (hasMappedValue(resolvedValue) === true) {
return resolvedValue;
}
return hasMappedValue(fieldMap?.defaultValue) === true ? fieldMap.defaultValue : undefined;
}
// Build a mapped object from a field map definition.
// Only mapped fields whose declared dependencies match `includeFields`
// will be resolved from the raw source data.
//
// `includeFields` must be a Set of changed raw field names for the current object.
// If `includeFields` is not a Set, no fields will be mapped and an empty result
// will be returned.
//
// For inclusion checks, `fields` and `related` are treated the same way here.
// The distinction is still useful in the field map for readability:
// - fields -> depends on the current raw object
// - related -> depends on another raw object looked up via rawData
//
// Returns:
// - data: mapped fields that were included and resolved
// - hasRequired: true if all required fields are present in the mapped result
function buildMappedObject(fieldMap, context, includeFields = undefined) {
let mappedObject = {};
if (fieldMap === null || typeof fieldMap !== 'object' || fieldMap?.constructor !== Object) {
return { data: mappedObject, hasRequired: false };
}
// No includeFields means no work to do for this object
if (includeFields instanceof Set !== true) {
return { data: mappedObject, hasRequired: false };
}
Object.entries(fieldMap).forEach(([fieldName, mapEntry]) => {
let sourceKeys = Object.keys(context?.sources ?? {}).filter((sourceKey) => Object.hasOwn(mapEntry, sourceKey) === true);
let shouldInclude = sourceKeys.some((sourceKey) => {
let sourceMap = mapEntry?.[sourceKey];
let dependencies = [
...(Array.isArray(sourceMap?.fields) === true ? sourceMap.fields : []),
...(Array.isArray(sourceMap?.related) === true ? sourceMap.related : []),
];
// No declared dependencies for this source, so always include it
if (dependencies.length === 0) {
return true;
}
return dependencies.some((field) => includeFields.has(field) === true);
});
if (shouldInclude !== true) {
return;
}
mappedObject[fieldName] = resolveMappedField(fieldName, mapEntry, context);
});
return {
data: mappedObject,
hasRequired: hasRequiredFields(mappedObject, fieldMap),
};
}
// Merge two already-mapped objects together using a preferred source ordering
function mergeMappedObjects(primaryObject, secondaryObject) {
let mergedObject = {};
if (secondaryObject !== null && typeof secondaryObject === 'object' && secondaryObject?.constructor === Object) {
Object.entries(secondaryObject).forEach(([key, value]) => {
mergedObject[key] = value;
});
}
if (primaryObject !== null && typeof primaryObject === 'object' && primaryObject?.constructor === Object) {
Object.entries(primaryObject).forEach(([key, value]) => {
if (hasMappedValue(value) === true) {
mergedObject[key] = value;
}
});
}
return mergedObject;
}
// Helper to build a mapping context object
function createMappingContext(rawData, objectKey, sources = {}, extraContext = undefined) {
let context = {
rawData: rawData,
objectKey: objectKey,
sources: {},
};
if (sources !== null && typeof sources === 'object' && sources?.constructor === Object) {
Object.entries(sources).forEach(([sourceKey, raw]) => {
context.sources[sourceKey] = normaliseSourceContext(sourceKey, objectKey, raw);
});
}
if (extraContext !== null && typeof extraContext === 'object' && extraContext?.constructor === Object) {
Object.entries(extraContext).forEach(([key, value]) => {
context[key] = value;
});
}
return context;
}
export {
buildMappedObject,
createMappingContext,
getMappedValue,
getPath,
hasMappedValue,
hasRequiredFields,
mergeMappedObjects,
normaliseSourceContext,
resolveMappedField,
};