garagedoor-accfactory
Version:
HomeKit garage door opener system using HAP-NodeJS library
1,275 lines (1,098 loc) • 51.8 kB
JavaScript
// 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