UNPKG

garagedoor-accfactory

Version:

HomeKit garage door opener system using HAP-NodeJS library

1,275 lines (1,098 loc) 51.8 kB
// Base Class: HomeKitDevice // // Shared base class for HomeKit-enabled devices across multiple projects. // Supports both Homebridge and direct HAP-NodeJS backends. // // Provides a unified abstraction layer that standardises accessory creation, // lifecycle handling, message routing, timer management, and optional // EveHome history support across all device types. // // Responsibilities: // - Manage HomeKit accessory creation and removal // - Provide unified message routing for device lifecycle and custom events // - Maintain internal device registry -> cross-device messaging // - Standardise HomeKit service and characteristic helper methods // - Integrate optional EveHome-compatible history support // - Provide internal timer management for device instances // // Lifecycle Hooks (optional in subclasses): // - onAdd(message, ...args) -> called when HomeKitDevice.ADD is received // - onSet(message, ...args) -> called when HomeKitDevice.SET is received // - onUpdate(deviceData, ...args) -> called when HomeKitDevice.UPDATE is received // - onRemove(message, ...args) -> called when HomeKitDevice.REMOVE is received // - onShutdown(message, ...args) -> called when HomeKitDevice.SHUTDOWN is received // - onTimer(message, ...args) -> called when HomeKitDevice.TIMER is received // - onGet(message, ...args) -> called when HomeKitDevice.GET is received // - onHistory(target, entry, options) // -> called after history processing // - onMessage(type, message, ...args) // -> fallback for unhandled or custom message types // // Messaging Model: // - device.message(type, message, ...args) // -> routes a message to this device instance // - HomeKitDevice.message(uuid, type, message, ...args) // -> routes a message to another registered device instance // - Internal lifecycle events and custom interactions use the same message system // // Key Features: // - addService() / addCharacteristic() // -> simplified HomeKit setup helpers // - addTimer() / removeTimer() / hasTimer() // -> per-device timer management // - history() // -> EveHome-compatible history logging and hook dispatch // - Static device registry // -> enables global device message routing // // Architecture: // - Designed to be extended per device type (e.g. Camera, Thermostat, Valve) // - Operates as the abstraction layer between raw device data and HomeKit // - Can run under Homebridge or standalone HAP-NodeJS environments // // Example: // // class MyDevice extends HomeKitDevice { // async onAdd() { // let service = this.addService(this.hap.Service.Switch, this.deviceData.description); // } // } // // HomeKitDevice.LOGGER = log; // let device = new MyDevice(undefined, hap, deviceData); // await device.add('My Device', hap.Categories.SWITCH); // // Notes: // - Designed for subclassing only // - Supports both Homebridge and HAP-NodeJS backends // - Homebridge platform shutdown and process exit cleanup are handled centrally // - Accessory/service structure changes are automatically pushed back to Homebridge // // Mark Hulskamp 'use strict'; // Define nodejs module requirements import crypto from 'crypto'; import EventEmitter from 'node:events'; import { setInterval, setTimeout, clearInterval, clearTimeout } from 'node:timers'; import process from 'node:process'; // Define constants const LOG_LEVELS = { INFO: 'info', SUCCESS: 'success', WARN: 'warn', ERROR: 'error', DEBUG: 'debug', }; // Define our HomeKit device class export default class HomeKitDevice extends EventEmitter { // Device messages static ADD = 'HomeKitDevice.onAdd'; static UPDATE = 'HomeKitDevice.onUpdate'; static REMOVE = 'HomeKitDevice.onRemove'; static HISTORY = 'HomeKitDevice.onHistory'; static SET = 'HomeKitDevice.onSet'; static GET = 'HomeKitDevice.onGet'; static MESSAGE = 'HomeKitDevice.onMessage'; static SHUTDOWN = 'HomeKitDevice.onShutdown'; static TIMER = 'HomeKitDevice.onTimer'; static ONLINE = 'HomeKitDevice._online'; static OFFLINE = 'HomeKitDevice._offline'; // HomeKit pin format and MAC address regex patterns static HK_PIN_3_2_3 = /^\d{3}-\d{2}-\d{3}$/; static HK_PIN_4_4 = /^\d{4}-\d{4}$/; static MAC_ADDR = /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/; // Override this in the class which extends static PLUGIN_NAME = undefined; // Homebridge plugin name static PLATFORM_NAME = undefined; // Homebridge platform name static EVEHOME = undefined; // HomeKitHistory object static LOGGER = undefined; // Logging object static TYPE = 'base'; // String naming type of device static VERSION = '2026.05.10'; // Code version // Backend types static HOMEBRIDGE = 'homebridge'; static HAP_NODEJS = 'hap-nodejs'; // Global internal device and listener registry static #listeners = {}; static #deviceRegistry = new Map(); static #shutdownRegistered = false; static #shutdownFired = false; deviceData = {}; // The devices data we store historyService = undefined; // HomeKit history service accessory = undefined; // HomeKit accessory service for this device hap = undefined; // HomeKit Accessory Protocol (HAP) API stub log = undefined; // Logging function object backend = undefined; // Backend library type // Internal data only for this class #uuid = undefined; // UUID for this instance #platform = undefined; // Homebridge platform API #postSetupDetails = []; // Use for extra output details once a device has been setup #timers = new Map(); // Internal timers for this device constructor(accessory = undefined, api = undefined, deviceData = {}) { super(); // Setup event emitter for our class ONLY // Build logger from configured backend using only functions that exist. let logger = {}; Object.values(LOG_LEVELS).forEach((level) => { if (typeof HomeKitDevice.LOGGER?.[level] === 'function') { logger[level] = HomeKitDevice.LOGGER[level].bind(HomeKitDevice.LOGGER); } }); if (Object.keys(logger).length !== 0) { this.log = logger; } // Determine runtime environment (Homebridge vs HAP-NodeJS) if (typeof api?.hap === 'object' && isNaN(api?.version) === false && typeof api?.HAPLibraryVersion === 'undefined') { this.hap = api.hap; this.#platform = api; this.backend = HomeKitDevice.HOMEBRIDGE; this.postSetupDetail('Homebridge backend', LOG_LEVELS.DEBUG); } if (typeof api?.hap === 'undefined' && isNaN(api?.version) === true && typeof api?.HAPLibraryVersion === 'function') { this.hap = api; this.backend = HomeKitDevice.HAP_NODEJS; this.postSetupDetail('HAP-NodeJS library', LOG_LEVELS.DEBUG); } if (this.backend === HomeKitDevice.HAP_NODEJS || this.backend === HomeKitDevice.HOMEBRIDGE) { if (HomeKitDevice.#shutdownRegistered !== true) { HomeKitDevice.#shutdownRegistered = true; let shutdown = async () => { // Notify all registered devices of backend shutdown. // This allows them to do any necessary cleanup before the process exits if (HomeKitDevice.#shutdownFired === true) { return; } HomeKitDevice.#shutdownFired = true; await HomeKitDevice.shutdown(); }; if (this.backend === HomeKitDevice.HOMEBRIDGE) { this.#platform.on('shutdown', shutdown); } if (this.backend === HomeKitDevice.HAP_NODEJS) { ['SIGINT', 'SIGTERM'].forEach((signal) => { process.on(signal, shutdown); }); } } } // Validate the data passed in to the constructor to ensure we have the minimum required data to create a HomeKit accessory if (this.#validDeviceData(deviceData, true) === false) { throw new TypeError('Invalid device data supplied to HomeKitDevice'); } // Make a clone of current data and store in this object // Important that we don't have a 'linked' copy of the object data this.deviceData = structuredClone(deviceData); // Generate UUID for this device instance // Will either be a random generated one or HAP generated one // HAP is based upon defined plugin name and devices serial number this.#uuid = HomeKitDevice.generateUUID(HomeKitDevice.PLUGIN_NAME, api, this.deviceData.serialNumber); // Register this device instance in the static device registry HomeKitDevice.#deviceRegistry.set(this.#uuid, this); // See if we were passed in an existing accessory object or array of accessory objects // Mainly used to restore a Homebridge cached accessory if (typeof accessory === 'object' && accessory !== null && this.backend === HomeKitDevice.HOMEBRIDGE) { if (Array.isArray(accessory) === true) { this.accessory = accessory.find((accessory) => accessory?.UUID === this.#uuid); } if (Array.isArray(accessory) === false && accessory?.UUID === this.#uuid) { this.accessory = accessory; } } } // Class functions async add(hapAccessoryName, hapCategory, enableHistory = false) { if ( this.hap === undefined || // HAP API not initialised typeof HomeKitDevice.PLUGIN_NAME !== 'string' || // Plugin name must be defined HomeKitDevice.PLUGIN_NAME === '' || typeof HomeKitDevice.PLATFORM_NAME !== 'string' || // Platform name must be defined HomeKitDevice.PLATFORM_NAME === '' || // HAP-NodeJS only: accessory name must be valid (this.backend === HomeKitDevice.HAP_NODEJS && (typeof hapAccessoryName !== 'string' || hapAccessoryName === '')) || // HAP-NodeJS only: category must be valid (this.backend === HomeKitDevice.HAP_NODEJS && typeof this.hap.Categories[hapCategory] === 'undefined') || typeof enableHistory !== 'boolean' || // History flag must be boolean this.#validDeviceData(this.deviceData, true) === false // Device data failed validation (core + pairing if required) ) { return; } // If we do not have an existing accessory object, create a new one if ( this.accessory === undefined && typeof this.#platform?.platformAccessory === 'function' && typeof this.#platform?.registerPlatformAccessories === 'function' && this.backend === HomeKitDevice.HOMEBRIDGE ) { // Create Homebridge platform accessory this.accessory = new this.#platform.platformAccessory(this.deviceData.description, this.#uuid); try { this.#platform.registerPlatformAccessories(HomeKitDevice.PLUGIN_NAME, HomeKitDevice.PLATFORM_NAME, [this.accessory]); // eslint-disable-next-line no-unused-vars } catch (error) { // Empty } } if (this.accessory === undefined && this.backend === HomeKitDevice.HAP_NODEJS) { // Create HAP-NodeJS library accessory this.accessory = new this.hap.Accessory(hapAccessoryName, this.#uuid); this.accessory.username = this.deviceData.hkUsername; this.accessory.pincode = this.deviceData.hkPairingCode; this.accessory.category = hapCategory; } // Setup accessory information let informationService = this.accessory.getService(this.hap.Service.AccessoryInformation); if (informationService === undefined) { this?.log?.error?.('AccessoryInformation service not found on accessory for "%s"', this.deviceData.description); return; } informationService.updateCharacteristic(this.hap.Characteristic.Manufacturer, this.deviceData.manufacturer); informationService.updateCharacteristic(this.hap.Characteristic.Model, this.deviceData.model); informationService.updateCharacteristic(this.hap.Characteristic.SerialNumber, this.deviceData.serialNumber); informationService.updateCharacteristic(this.hap.Characteristic.FirmwareRevision, this.deviceData.softwareVersion); informationService.updateCharacteristic(this.hap.Characteristic.Name, this.deviceData.description); // Setup our history service if module has been defined and requested to be active for this device if (typeof HomeKitDevice?.EVEHOME === 'function' && this.historyService === undefined && enableHistory === true) { this.historyService = new HomeKitDevice.EVEHOME(this.accessory, this.hap, this.log, {}); } this.postSetupDetail('Serial number "%s"', this.deviceData.serialNumber, LOG_LEVELS.DEBUG); this.postSetupDetail('Software version "%s"', this.deviceData.softwareVersion, LOG_LEVELS.DEBUG); // Trigger registered handlers (onAdd + listeners) await this.message(HomeKitDevice.ADD); if (this.historyService?.EveHome !== undefined) { this.postSetupDetail('EveHome support as "%s"', this.historyService.EveHome.evetype); } this?.log?.success?.('Setup %s as "%s"', hapAccessoryName, this.deviceData.description); this.#postSetupDetails.forEach((entry) => { if (typeof entry === 'string') { this?.log?.[LOG_LEVELS.INFO]?.(' += %s', entry); } else if (typeof entry?.message === 'string') { let level = Object.hasOwn(LOG_LEVELS, entry?.level?.toUpperCase?.()) && typeof this?.log?.[LOG_LEVELS[entry.level.toUpperCase()]] === 'function' ? LOG_LEVELS[entry.level.toUpperCase()] : LOG_LEVELS.INFO; this?.log?.[level]?.(' += ' + entry.message, ...(Array.isArray(entry?.args) ? entry.args : [])); } }); // Trigger registered handlers (onUpdate + listeners) for initial device data updates await this.message(HomeKitDevice.UPDATE, this.deviceData, { force: true }); // If using HAP-NodeJS library, publish accessory on local network if (this.accessory !== undefined && this.backend === HomeKitDevice.HAP_NODEJS) { this.accessory.publish({ username: this.accessory.username, pincode: this.accessory.pincode, category: this.accessory.category, }); this?.log?.info?.(' += Advertising as "%s"', this.accessory.displayName); this?.log?.info?.(' += Pairing code is "%s"', this.accessory.pincode); } this.#postSetupDetails = []; // Don't need these anymore return this.accessory; // Return our HomeKit accessory } async remove() { // Trigger registered handlers (onRemove + listeners) await this.message(HomeKitDevice.REMOVE); } static async shutdown() { // Notify all registered devices of process shutdown. // Calls the instance shutdown() method on each registered device. for (let device of Array.from(HomeKitDevice.#deviceRegistry.values())) { try { await device.shutdown(); // eslint-disable-next-line no-unused-vars } catch (error) { // Empty } } } async shutdown() { // Trigger registered handlers (onShutdown + listeners) await this.message(HomeKitDevice.SHUTDOWN); } async update(deviceData, ...args) { if ( deviceData === null || // Must not be null typeof deviceData !== 'object' || // Must be an object deviceData.constructor !== Object || // Must be a plain JSON object this.#validDeviceData(deviceData) === false // Partial validation ) { return; } // Trigger registered handlers (onUpdate + listeners) await this.message(HomeKitDevice.UPDATE, deviceData, ...args); } async history(target, entry, options = {}) { if ( typeof this.historyService !== 'object' || this.historyService === null || typeof this.historyService.addHistory !== 'function' || // entry must be a plain JSON object entry === null || typeof entry !== 'object' || entry.constructor !== Object || // target must be a valid HomeKit service object typeof target !== 'object' || target === null || typeof target.UUID !== 'string' || target.UUID === '' || // options must be a plain JSON object options === null || typeof options !== 'object' || options.constructor !== Object ) { return; } // Trigger registered handlers (onHistory + listeners) await this.message(HomeKitDevice.HISTORY, target, entry, options); } async set(values, ...args) { if ( values === null || // Must not be null typeof values !== 'object' || // Must be an object values.constructor !== Object // Must be a plain JSON object ) { return; } // Trigger registered handlers (onSet + listeners) await this.message(HomeKitDevice.SET, values, ...args); } async get(values, ...args) { // Trigger registered handlers (onGet + listeners) return this.message(HomeKitDevice.GET, values, ...args); } static async message(uuid, type, message = undefined, ...args) { if (typeof uuid !== 'string' || uuid === '' || typeof type !== 'string' || type === '') { return; } if (typeof message === 'function' || (typeof message === 'object' && message !== null && message?.constructor !== Object)) { if (this.#listeners?.[uuid] === undefined) { this.#listeners[uuid] = {}; } if (Array.isArray(this.#listeners[uuid][type]) === false) { this.#listeners[uuid][type] = []; } let handler, context; if (typeof message === 'function') { handler = message; context = undefined; } else { context = message; handler = typeof type === 'string' ? type.match(/\.?(on[A-Z][a-zA-Z0-9]*)$/)?.[1] : undefined; } if (handler !== undefined) { if (this.#listeners?.[uuid]?.[type]?.find?.((h) => h.handler === handler && h.context === context) === undefined) { this.#listeners[uuid][type].push({ handler, context }); } } return; } // Handle message delivery return this.#deviceRegistry.get(uuid)?.message?.(type, message, ...args); } async message(type, message, ...args) { if (typeof type !== 'string' || type === '') { return; } if ( (message === undefined || message === null) && (type === HomeKitDevice.ADD || type === HomeKitDevice.UPDATE || type === HomeKitDevice.REMOVE || type === HomeKitDevice.SET) ) { // Normalise undefined or null message to empty object only for lifecycle types that expect object payloads message = {}; } let result = { call: undefined, handler: undefined }; let handled = false; let handler = Array.isArray(HomeKitDevice.#listeners?.[this.#uuid]?.[type]) === true ? HomeKitDevice.#listeners[this.#uuid][type] : HomeKitDevice.#listeners?.[this.#uuid]?.[type] !== undefined ? [HomeKitDevice.#listeners[this.#uuid][type]] : []; try { // Dynamically extract the handler method name from the type string (e.g., "HomeKitDevice.onAdd" becomes "onAdd") // This allows consistent routing to instance methods like onAdd, onSet, onUpdate, etc. let methodName = typeof type === 'string' ? type.match(/\.?(on[A-Z][a-zA-Z0-9]*)$/)?.[1] : undefined; // Internal helper to call handlers with error trapping. Will also walk up the prototype chain const callLifecycleHook = async (labelOrFn, ...params) => { let results = []; let called = new Set(); // track calls using context + function identity const callMethodWithProtoChain = async (obj, method, contextLabel) => { let current = obj; let seen = new Set(); while (current && typeof current === 'object' && seen.has(current) === false) { seen.add(current); let fn = current?.[method]; if (typeof fn === 'function') { let key = fn + '@' + obj; if (called.has(key) === false) { called.add(key); try { results.push(await fn.apply(obj, params)); } catch (error) { this?.log?.warn?.('Error in %s.%s(): %s', contextLabel, method, String(error?.stack || error)); } } } current = Object.getPrototypeOf(current); } }; if (typeof labelOrFn === 'string') { await callMethodWithProtoChain(this, labelOrFn, this?.constructor?.name ?? 'this'); } else if (typeof labelOrFn === 'function') { let key = labelOrFn + '@' + this; if (called.has(key) === false) { called.add(key); try { results.push(await labelOrFn(...params)); } catch (error) { this?.log?.warn?.('Error in inline function handler: %s', String(error?.stack || error)); } } } else if (Array.isArray(labelOrFn) === true) { let [label, list] = labelOrFn; for (let item of list || []) { let fn = item?.handler; let context = item?.context ?? this; let key = fn + '@' + context; if (typeof fn === 'function') { if (called.has(key) === false) { called.add(key); try { results.push(await fn.call(context, ...params)); } catch (error) { this?.log?.warn?.('Error in registered %s(): %s', label, String(error?.stack || error)); } } } else if (typeof fn === 'string' && context) { await callMethodWithProtoChain(context, fn, context?.constructor?.name ?? 'handler'); } } } return results.length === 1 ? results[0] : results; }; // Internal helper to snapshot accessory structure relating to services and characteristics const snapshotAccessoryStructure = (accessory) => { return Array.isArray(accessory?.services) === true ? accessory.services .map((service) => ({ UUID: service.UUID, subtype: service.subtype ?? '', characteristics: Array.isArray(service.characteristics) === true ? service.characteristics.map((characteristic) => characteristic.UUID).sort() : [], })) .sort((a, b) => (a.UUID === b.UUID ? String(a.subtype).localeCompare(String(b.subtype)) : a.UUID.localeCompare(b.UUID))) : []; }; // First up, we want to take a "snapshot" of services and characteristics on this accessory // This will be used after all message calling to see if any changes have occurred on the accessory // And if so, and running under Homebridge, we'll notify it of the changes let originalServices = this.backend === HomeKitDevice.HOMEBRIDGE && this.accessory !== undefined && typeof this.#platform?.updatePlatformAccessories === 'function' ? snapshotAccessoryStructure(this.accessory) : []; // Handle built-in types with special behavior if (type === HomeKitDevice.ADD || type === HomeKitDevice.REMOVE || type === HomeKitDevice.SET) { // Call the dynamic on<Type> method (ie. onAdd, onRemove, onSet) and after // Any static handler registered via HomeKitDevice.message(uuid, type, handler) await callLifecycleHook(methodName, message, ...args); await callLifecycleHook(['handler for ' + type, handler], message, ...args); handled = true; // Special setup for ADD if (type === HomeKitDevice.ADD) { // After the accessory is initialised and onAdd has run, link or unlink any EveHome services for (let service of [...(this.accessory?.services || [])]) { let options = service?.[HomeKitDevice?.EVEHOME?.EVE_OPTIONS]; if (options !== undefined) { delete service[HomeKitDevice?.EVEHOME?.EVE_OPTIONS]; } // Link to EveHome if eveHistory is enabled. if (this.deviceData?.eveHistory === true && options !== undefined) { this?.historyService?.linkToEveHome?.(service, options); } // Otherwise unlink in case it was previously enabled and has now been disabled. if (this.deviceData?.eveHistory !== true) { for (let characteristic of [...(service.characteristics || [])]) { // EveHome history characteristics have UUIDs that start with E863F1 as defined in HomeKitHistory.js // If we find any, remove them from the service to unlink from EveHome if (characteristic?.UUID?.startsWith?.('E863F1') === true && typeof service?.removeCharacteristic === 'function') { service.removeCharacteristic(characteristic); } } if (service?.UUID === this.hap.Service?.EveHomeHistory?.UUID) { this.accessory.removeService(service); } } } } // Special teardown for REMOVE if (type === HomeKitDevice.REMOVE) { this?.log?.warn?.('Notified to remove device "%s"', this.deviceData.description); // Clear any internal timers we have running for this device this.#clearTimers(); // Cleanup all listeners and references to allow for garbage collection of this instance this?.removeAllListeners?.(); HomeKitDevice.#deviceRegistry.delete(this.#uuid); delete HomeKitDevice.#listeners[this.#uuid]; if (this.accessory !== undefined && typeof this.#platform?.unregisterPlatformAccessories === 'function') { try { this.#platform.unregisterPlatformAccessories(HomeKitDevice.PLUGIN_NAME, HomeKitDevice.PLATFORM_NAME, [this.accessory]); // eslint-disable-next-line no-unused-vars } catch (error) { // Empty } } if (this.accessory !== undefined && this.#platform === undefined) { this.accessory.unpublish(); } this.deviceData = {}; this.accessory = undefined; this.historyService = undefined; this.hap = undefined; this.log = undefined; this.#uuid = undefined; this.#platform = undefined; } // Update the internal data for the set values, as could take some time once we emit the event if (type === HomeKitDevice.SET) { if (message !== null && typeof message === 'object' && message.constructor === Object) { Object.entries(message).forEach(([key, value]) => { if (this.deviceData?.[key] !== undefined) { this.deviceData[key] = value; } }); } } } else if (type === HomeKitDevice.SHUTDOWN) { if (HomeKitDevice.#deviceRegistry.has(this.#uuid) === true) { // Deregister first so we don't get shutdown twice via global broadcaster HomeKitDevice.#deviceRegistry.delete(this.#uuid); delete HomeKitDevice.#listeners[this.#uuid]; this?.log?.debug?.('Notifying device "%s" of shutdown', this.deviceData.description); // Now run shutdown hooks + cleanup await callLifecycleHook(methodName, message, ...args); await callLifecycleHook(['handler for ' + type, handler], message, ...args); // Clear any internal timers we have running for this device this.#clearTimers(); this?.removeAllListeners?.(); } handled = true; } else if (type === HomeKitDevice.UPDATE) { if (message !== null && typeof message === 'object' && message.constructor === Object) { let { merged, changed } = this.#mergeDeviceData(message); if (this.#validDeviceData(merged, true) !== true) { handled = true; return; } await this.#updateAccessoryInformation(merged); if (changed === true || (typeof args?.[0] === 'object' && args?.[0]?.force === true)) { // Call the onUpdate method and after any static handler registered via HomeKitDevice.message(uuid, type, handler) await callLifecycleHook('onUpdate', merged, ...args); await callLifecycleHook(['handler for UPDATE', handler], merged, ...args); } // Update our internally stored data with the new data this.deviceData = structuredClone(merged); } handled = true; } else if (type === HomeKitDevice.HISTORY) { let [target, entry, options = {}] = [message, args[0], args[1]]; let skipHistory = false; if ( this.historyService !== null && typeof this.historyService === 'object' && typeof this.historyService?.addHistory === 'function' && entry !== null && typeof entry === 'object' && entry.constructor === Object && target !== null && typeof target === 'object' && typeof target.UUID === 'string' && target.UUID !== '' && options !== null && typeof options === 'object' && options.constructor === Object ) { if (Number.isFinite(Number(entry?.time)) === false) { entry.time = Math.floor(Date.now() / 1000); } if (options?.force !== true && typeof this.historyService?.lastHistory === 'function') { let last = this.historyService.lastHistory(target); if (typeof last === 'object') { let changed = Object.keys(entry).some((key) => { if (key === 'time') { return false; } let value = entry[key]; let lastValue = last[key]; return value !== null && typeof value === 'object' ? JSON.stringify(HomeKitDevice.#normaliseForCompare(value)) !== JSON.stringify(HomeKitDevice.#normaliseForCompare(lastValue)) : value !== lastValue; }); if (changed === false) { skipHistory = true; } } } if (skipHistory === false) { this.historyService.addHistory( target, entry, Number.isFinite(Number(options?.timegap)) === true ? Number(options.timegap) : undefined, ); } } // Call the onHistory method and after any static handler registered via HomeKitDevice.message(uuid, type, handler) await callLifecycleHook('onHistory', target, entry, options); await callLifecycleHook(['handler for HISTORY', handler], target, entry, options); handled = true; } // Dynamically handle any remaining on<Type> method (e.g., onGet etc that we haven’t handled yet) // Any static handler registered via HomeKitDevice.message(uuid, type, handler) if (handled === false && (typeof this?.[methodName] === 'function' || (Array.isArray(handler) === true && handler.length > 0))) { // Use string method name so we get inheritance merging; result.call = await callLifecycleHook(methodName, message, ...args); result.handler = await callLifecycleHook(['handler for ' + type, handler], message, ...args); handled = true; } // Call generic handler if present and we haven't handled the message yet if (handled === false && typeof this?.onMessage === 'function') { result.call = await callLifecycleHook('onMessage', type, message, ...args); handled = true; } if ( this.backend === HomeKitDevice.HOMEBRIDGE && this.accessory !== undefined && typeof this.#platform?.updatePlatformAccessories === 'function' ) { // Let's see what's changed (if anything) on the accessory let newServices = snapshotAccessoryStructure(this.accessory); if (JSON.stringify(originalServices) !== JSON.stringify(newServices)) { // We have changes detected for our accessory (services and/or characteristics) // Notify Homebridge if that's our "backend" system this.#platform.updatePlatformAccessories([this.accessory]); } } // No handler at all — not even onMessage() if (handled === false && (Array.isArray(handler) === false || handler.length === 0) && typeof this?.[methodName] !== 'function') { this?.log?.debug?.('Unhandled message type "%s" for device "%s"', type, this.deviceData.description); } if (typeof result.call === 'object' || typeof result.handler === 'object') { return Object.assign({}, result.call ?? {}, result.handler ?? {}); } } catch (error) { this?.log?.warn?.( 'Unhandled error while processing message "%s" for device "%s": %s', type, this.deviceData?.description, typeof error?.stack === 'string' ? error.stack : String(error), ); } return result.call !== undefined ? result.call : result.handler; } addTimer(timerHandle, options = {}, callback = undefined) { // Register a timer (timeout, interval, or both) that either calls a callback or dispatches via message system // Supports three patterns: // - delay only: fires once after delay (e.g., motion cooldown) // - interval only: fires repeatedly (e.g., periodic polling) // - delay + interval: fires once after delay, then repeats (e.g., initial delay before polling) // Returns true if timer was added, false if invalid parameters or duplicate (use reset:true to replace) if (typeof timerHandle !== 'string' || timerHandle === '') { return false; } if (options === null || typeof options !== 'object' || options.constructor !== Object) { options = {}; } let delay = Number.isFinite(Number(options?.delay)) && Number(options.delay) > 0 ? Number(options.delay) : 0; let interval = Number.isFinite(Number(options?.interval)) && Number(options.interval) > 0 ? Number(options.interval) : 0; let reset = options?.reset === true; let timerMessage = typeof options?.message === 'object' && options.message !== null && options.message.constructor === Object ? options.message : {}; // Nothing to schedule if (delay === 0 && interval === 0) { return false; } // Extend/reset existing timer (eg. motion cooldown) if (reset === true) { this.removeTimer(timerHandle); } // If we didn't reset and one exists, keep it if (reset === false && this.#timers.has(timerHandle) === true) { return true; } let entry = { delay: delay, interval: interval, timeout: undefined, intervalHandle: undefined, started: Date.now(), message: timerMessage, callback: typeof callback === 'function' ? callback : undefined, running: false, cancelled: false, }; let fire = (removeAfterRun = false) => { // Prevent overlapping timer executions and ignore cancelled timers if (entry.running === true || entry.cancelled === true) { return; } entry.running = true; Promise.resolve( typeof entry.callback === 'function' ? entry.callback(timerHandle, entry.message) : this.message(HomeKitDevice.TIMER, { timer: timerHandle, ...entry.message, }), ) .catch(() => { // Empty }) .finally(() => { if (entry.cancelled === true) { return; } entry.running = false; if (removeAfterRun === true) { this.removeTimer(timerHandle); } }); }; // delay only => fire once if (delay > 0 && interval === 0) { entry.timeout = setTimeout(() => { entry.timeout = undefined; fire(true); }, delay); this.#timers.set(timerHandle, entry); return true; } // interval only => repeat if (delay === 0 && interval > 0) { entry.intervalHandle = setInterval(() => { fire(); }, interval); this.#timers.set(timerHandle, entry); return true; } // delay + interval => fire once after delay, then repeat entry.timeout = setTimeout(() => { fire(); entry.timeout = undefined; entry.intervalHandle = setInterval(() => { fire(); }, interval); }, delay); this.#timers.set(timerHandle, entry); return true; } removeTimer(timerHandle) { // Clear a timer by handle. Returns true even if timer doesn't exist (idempotent, safe to call multiple times) if (typeof timerHandle !== 'string' || timerHandle === '') { return false; } if (this.#timers.has(timerHandle) === false) { return true; } let entry = this.#timers.get(timerHandle); // Mark as cancelled so any in-flight async completion knows it's no longer valid entry.cancelled = true; try { clearTimeout(entry?.timeout); clearInterval(entry?.intervalHandle); // eslint-disable-next-line no-unused-vars } catch (error) { // Empty } // Defensive cleanup entry.timeout = undefined; entry.intervalHandle = undefined; entry.running = false; this.#timers.delete(timerHandle); return true; } hasTimer(timerHandle) { // Check if a timer with this handle is currently active/registered if (typeof timerHandle !== 'string' || timerHandle === '') { return false; } return this.#timers.has(timerHandle) === true; } addService(serviceType, name = '', subType = undefined, eveOptions = undefined) { let service = undefined; if ( serviceType !== undefined && typeof this?.accessory?.getService === 'function' && typeof this?.accessory?.getServiceById === 'function' && typeof this?.accessory?.addService === 'function' ) { if (subType !== undefined) { service = this.accessory.getServiceById(serviceType, subType); } else { service = this.accessory.getService(serviceType); } if (service === undefined) { service = this.accessory.addService(serviceType, name, subType); } // Setup for EveHome history if enabled. The actual linkage will be done in .add() after returning from .onAdd() if (service !== undefined && eveOptions !== null && typeof eveOptions === 'object' && eveOptions.constructor === Object) { service[HomeKitDevice?.EVEHOME?.EVE_OPTIONS] = eveOptions; } } return service; } removeService(serviceOrType, subType = undefined) { let service = undefined; let isServiceInstance = typeof this?.hap?.Service === 'function' && serviceOrType instanceof this.hap.Service; // Accessory must support service removal. if (typeof this?.accessory?.removeService !== 'function') { return false; } // Accept an existing service instance directly. if (isServiceInstance === true) { service = serviceOrType; } else if ( serviceOrType !== undefined && typeof this?.accessory?.getService === 'function' && typeof this?.accessory?.getServiceById === 'function' ) { // Or resolve the service by type, optionally with a subtype. if (subType !== undefined) { service = this.accessory.getServiceById(serviceOrType, subType); } else { service = this.accessory.getService(serviceOrType); } } // Nothing to remove. if (service === undefined) { return false; } this.accessory.removeService(service); return true; } addCharacteristic(service, characteristicType, { props, onSet, onGet, initialValue } = {}) { let characteristic = undefined; if ( characteristicType !== undefined && typeof service?.getCharacteristic === 'function' && typeof service?.testCharacteristic === 'function' && typeof service?.addCharacteristic === 'function' && typeof service?.addOptionalCharacteristic === 'function' ) { if (service.testCharacteristic(characteristicType) === false) { if ( Array.isArray(service?.optionalCharacteristics) === true && service.optionalCharacteristics.includes(characteristicType) === true ) { service.addOptionalCharacteristic(characteristicType); } else { service.addCharacteristic(characteristicType); } } characteristic = service.getCharacteristic(characteristicType); // Apply optional config if (typeof onSet === 'function') { characteristic.onSet(onSet); } if (typeof onGet === 'function') { characteristic.onGet(onGet); } if (props !== null && typeof props === 'object' && props.constructor === Object && typeof characteristic.setProps === 'function') { characteristic.setProps(props); } // Set initial value if provided if (typeof initialValue !== 'undefined' && typeof service?.updateCharacteristic === 'function') { service.updateCharacteristic(characteristicType, initialValue); } } return characteristic; } removeCharacteristic(service, characteristicOrType) { let characteristic = undefined; let isCharacteristicInstance = typeof this?.hap?.Characteristic === 'function' && characteristicOrType instanceof this.hap.Characteristic; if (typeof service?.removeCharacteristic !== 'function' || Array.isArray(service?.characteristics) !== true) { return false; } // Accept an existing characteristic instance directly. if (isCharacteristicInstance === true) { characteristic = characteristicOrType; } else if (characteristicOrType !== undefined) { // Or resolve by type without calling getCharacteristic(), which can add optional characteristics. characteristic = service.characteristics.find((entry) => entry?.UUID === characteristicOrType?.UUID); } // Nothing to remove. if (characteristic === undefined) { return false; } service.removeCharacteristic(characteristic); return true; } postSetupDetail(message, ...args) { if (typeof message !== 'string' || message === '') { return; } let levelKey = 'INFO'; let lastArg = args.at(-1); if (typeof lastArg === 'string' && Object.hasOwn(LOG_LEVELS, lastArg.toUpperCase()) === true) { levelKey = lastArg.toUpperCase(); args = args.slice(0, -1); } this.#postSetupDetails.push({ level: LOG_LEVELS[levelKey], // 'info', 'debug', etc. message, args: args.length > 0 ? args : undefined, }); } static generateUUID(PLUGIN_NAME, api, serialNumber) { let hap; let uuid = crypto.randomUUID(); // Determine runtime environment (Homebridge vs HAP-NodeJS) if (typeof api?.hap === 'object' && isNaN(api?.version) === false && typeof api?.HAPLibraryVersion === 'undefined') { hap = api.hap; } else if (typeof api?.HAPLibraryVersion === 'function' && typeof api?.version === 'undefined' && typeof api?.hap === 'undefined') { hap = api; } if ( typeof PLUGIN_NAME === 'string' && PLUGIN_NAME !== '' && typeof serialNumber === 'string' && serialNumber !== '' && typeof hap?.uuid?.generate === 'function' ) { uuid = hap.uuid.generate(PLUGIN_NAME + '_' + serialNumber.toUpperCase()); } return uuid; } static makeValidHKName(name) { // Strip invalid characters to meet HomeKit naming requirements // Ensure only letters or numbers are at the beginning AND/OR end of string // Matches against uni-code characters return typeof name === 'string' ? name .replace(/[^\p{L}\p{N}\p{Z}\u2019.,-]/gu, '') .replace(/^[^\p{L}\p{N}]*/gu, '') .replace(/[^\p{L}\p{N}]+$/gu, '') : name; } get uuid() { return this.#uuid; } static #normaliseForCompare(value) { // Normalise values before comparison so JSON.stringify is stable: // - object keys are sorted recursively to avoid false positives from key order // - arrays retain their order // - undefined is converted to a string placeholder so it is not dropped return Array.isArray(value) === true ? value.map((entry) => HomeKitDevice.#normaliseForCompare(entry)) : typeof value === 'object' && value !== null ? Object.keys(value) .sort() .reduce((result, key) => { result[key] = HomeKitDevice.#normaliseForCompare(value[key] === undefined ? 'undefined' : value[key]); return result; }, {}) : value === undefined ? 'undefined' : value; } #mergeDeviceData(deviceDataUpdates = {}) { let merged = { ...deviceDataUpdates }; // Updated data may only contain selected fields, so merge with our internally stored // data to ensure we always end up with a complete deviceData object. Object.entries(this.deviceData).forEach(([key, value]) => { if (typeof merged[key] === 'undefined') { merged[key] = value; } }); // Check updated device data with our internally stored data and flag if changes exist. // This compares the full merged view rather than only the incoming partial update. let changed = Object.keys(merged).some( (key) => JSON.stringify(HomeKitDevice.#normaliseForCompare(merged[key])) !== JSON.stringify(HomeKitDevice.#normaliseForCompare(this.deviceData[key])), ); return { merged, changed }; } async #updateAccessoryInformation(deviceData) { // Always update accessory information if we have changed data let informationService = this.accessory?.getService?.(this.hap.Service.AccessoryInformation); if (informationService === undefined) { this?.log?.error?.('AccessoryInformation service not found on accessory for "%s"', this.deviceData.description); return; } // Update details associated with the accessory: Name, Manufacturer, Model, Serial # and firmware version // Check against actual characteristic values to ensure sync regardless of how state got out of sync // Description/Name if (typeof deviceData?.description === 'string' && deviceData.description !== '') { informationService.updateCharacteristic(this.hap.Characteristic.Name, deviceData.description); if (this.accessory !== undefined && typeof this.accessory === 'object' && this.accessory.displayName !== deviceData.description) { this.accessory.displayName = deviceData.description; } } // Manufacturer if (typeof deviceData?.manufacturer === 'string' && deviceData.manufacturer !== '') { informationService.updateCharacteristic(this.hap.Characteristic.Manufacturer, deviceData.manufacturer); } // Model if (typeof deviceData?.model === 'string' && deviceData.model !== '') { informationService.updateCharacteristic(this.hap.Characteristic.Model, deviceData.model); } // Firmware Revision if (typeof deviceData?.softwareVersion === 'string' && deviceData.softwareVersion !== '') { informationService.updateCharacteristic(this.hap.Characteristic.FirmwareRevision, deviceData.softwareVersion); // Remove SoftwareRevision if it exists if (informationService.testCharacteristic(this.hap.Characteristic.SoftwareRevision) === true) { this.removeCharacteristic(informationService, this.hap.Characteristic.SoftwareRevision); } } // SerialNumber if (typeof deviceData?.serialNumber === 'string' && deviceData.serialNumber !== '') { let currentSerial = informationService.getCharacteristic(this.hap.Characteristic.SerialNumber)?.value; if (currentSerial !== deviceData.serialNumber) { // Log warning if serial actually changed from stored data if (deviceData.serialNumber.toUpperCase() !== this.deviceData.serialNumber?.toUpperCase()) { this?.log?.warn?.('Serial number on "%s" has changed', deviceData.description); this?.log?.warn?.('This may cause the device to become unresponsive in HomeKit'); } informationService.updateCharacteristic(this.hap.Characteristic.SerialNumber, deviceData.serialNumber); } } if (typeof deviceData?.online === 'boolean' && deviceData.online !== this.deviceData.online) { // Device online status has changed. Log and send message to trigger any handlers for this change if (deviceData.online === false) { this?.log?.warn?.('Device "%s" is offline', deviceData.description); await this.message(HomeKitDevice.OFFLINE); } if (deviceData.online === true) { this?.log?.success?.('Device "%s" is online', deviceData.description); await this.message(HomeKitDevice.ONLINE); } } } #validDeviceData(deviceData = {}, strict = false) { if ( deviceData === null || // Must not be null typeof deviceData !== 'object' || // Must be an object deviceData.constructor !== Object // Must be a plain JSON object ) { return false; } let keys = ['serialNumber', 'softwareVersion', 'description', 'model', 'manufacturer']; let isFull = strict === true || keys.every((key) => typeof deviceData[key] !== 'undefined'); for (let key of keys) { if (isFull === true) { // Full validation: required fields must exist and be valid if (typeof deviceData[key] !== 'string' || deviceData[key] === '') { return false; } } if (isFull === false && typeof deviceData[key] !== 'undefined') { // Partial update: only validate fields that are present if (typeof deviceData[key] !== 'string' || deviceData[key] === '') { return false; } } } // Pairing validation (HAP-NodeJS only — no Homebridge platform