homebridge-nest-accfactory 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,41 +1,42 @@
1
1
  // HomeKitDevice class
2
2
  //
3
- // This is the base class for all HomeKit accessories we code for in Homebridge/HAP-NodeJS
3
+ // Base class for all HomeKit accessories using Homebridge or HAP-NodeJS.
4
4
  //
5
- // The deviceData structure should at a minimum contain the following elements:
5
+ // Provides internal device tracking, metadata validation, lifecycle management,
6
+ // centralized message dispatch, and optional EveHome-compatible history logging.
6
7
  //
7
- // Homebridge Plugin:
8
+ // The `deviceData` object must include:
9
+ // serialNumber, softwareVersion, description, manufacturer, model
8
10
  //
9
- // serialNumber
10
- // softwareVersion
11
- // description
12
- // manufacturer
13
- // model
11
+ // For enabling EveHome history support, include in the `deviceData`:
12
+ // eveHistory
14
13
  //
15
- // HAP-NodeJS Library Accessory:
14
+ // For HAP-NodeJS standalone mode, also required:
15
+ // hkUsername, hkPairingCode
16
16
  //
17
- // serialNumber
18
- // softwareVersion
19
- // description
20
- // manufacturer
21
- // model
22
- // hkUsername
23
- // hkPairingCode
17
+ // The following static constants should be defined in subclasses:
18
+ // HomeKitDevice.PLUGIN_NAME // Required (string)
19
+ // HomeKitDevice.PLATFORM_NAME // Required (string)
20
+ // HomeKitDevice.TYPE // Optional (device type string)
21
+ // HomeKitDevice.VERSION // Optional (device code version)
22
+ // HomeKitDevice.EVEHOME // Optional (EveHome-compatible history module)
24
23
  //
25
- // Following constants should be overridden in the module loading this class file
24
+ // The following instance methods can be optionally implemented by subclasses:
25
+ // async onAdd(message, ...args) // Called when HomeKitDevice.ADD is received
26
+ // async onUpdate(deviceData, ...args) // Called when HomeKitDevice.UPDATE is received
27
+ // async onRemove(message, ...args) // Called when HomeKitDevice.REMOVE is received
28
+ // async onHistory(target, entry) // Called after a history entry is logged
29
+ // async onGet(message, ...args) // Called when HomeKitDevice.GET is received
30
+ // async onSet(message, ...args) // Called when HomeKitDevice.SET is received
31
+ // async onMessage(type, message) // Called for unhandled or custom message types
26
32
  //
27
- // HomeKitDevice.HOMEKITHISTORY
28
- // HomeKitDevice.PLUGIN_NAME
29
- // HomeKitDevice.PLATFORM_NAME
30
- // HomeKitDevice.TYPE
31
- // HomeKitDevice.VERSION
33
+ // Messages should be sent via:
34
+ // await device.message(type, message, ...args);
32
35
  //
33
- // The following functions should be overriden in your class which extends this
36
+ // All internal lifecycle events (`add`, `update`, `remove`, `history`, `get`, `set`) and external
37
+ // interactions should use the `message()` dispatch system for consistency.
34
38
  //
35
- // HomeKitDevice.setupDevice()
36
- // HomeKitDevice.removeDevice()
37
- // HomeKitDevice.updateDevice(deviceData)
38
- // HomeKitDevice.messageDevice(type, message)
39
+ // See README.md for usage examples and detailed documentation.
39
40
  //
40
41
  // Mark Hulskamp
41
42
  'use strict';
@@ -54,12 +55,18 @@ const LOG_LEVELS = {
54
55
  };
55
56
 
56
57
  // Define our HomeKit device class
57
- export default class HomeKitDevice {
58
- static UPDATE = 'HomeKitDevice.update'; // Device update message
59
- static REMOVE = 'HomeKitDevice.remove'; // Device remove message
60
- static SET = 'HomeKitDevice.set'; // Device set property message
61
- static GET = 'HomeKitDevice.get'; // Device get property message
62
-
58
+ export default class HomeKitDevice extends EventEmitter {
59
+ // Device messages
60
+ static ADD = 'HomeKitDevice.onAdd';
61
+ static UPDATE = 'HomeKitDevice.onUpdate';
62
+ static REMOVE = 'HomeKitDevice.onRemove';
63
+ static HISTORY = 'HomeKitDevice.onHistory';
64
+ static SET = 'HomeKitDevice.onSet';
65
+ static GET = 'HomeKitDevice.onGet';
66
+ static MESSAGE = 'HomeKitDevice.onMessage';
67
+ static SHUTDOWN = 'HomeKitDevice.onShutdown';
68
+
69
+ // HomeKit pin format and MAC address regex's
63
70
  static HK_PIN_3_2_3 = /^\d{3}-\d{2}-\d{3}$/;
64
71
  static HK_PIN_4_4 = /^\d{4}-\d{4}$/;
65
72
  static MAC_ADDR = /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/;
@@ -67,85 +74,88 @@ export default class HomeKitDevice {
67
74
  // Override this in the class which extends
68
75
  static PLUGIN_NAME = undefined; // Homebridge plugin name
69
76
  static PLATFORM_NAME = undefined; // Homebridge platform name
70
- static HISTORY = undefined; // HomeKit History object
77
+ static EVEHOME = undefined; // HomeKit History object
71
78
  static TYPE = 'base'; // String naming type of device
72
- static VERSION = '2025.06.15'; // Code version
79
+ static VERSION = '2025.07.29'; // Code version
80
+
81
+ // Backend types
82
+ static HOMEBRIDGE = 'homebridge';
83
+ static HAP_NODEJS = 'hap-nodejs';
84
+
85
+ // Internal device and listener registry
86
+ static #listeners = {};
87
+ static #deviceRegistry = new Map();
73
88
 
74
89
  deviceData = {}; // The devices data we store
75
90
  historyService = undefined; // HomeKit history service
76
91
  accessory = undefined; // HomeKit accessory service for this device
77
92
  hap = undefined; // HomeKit Accessory Protocol (HAP) API stub
78
93
  log = undefined; // Logging function object
79
- uuid = undefined; // UUID for this instance
94
+ backend = undefined; // Backend library type
80
95
 
81
96
  // Internal data only for this class
82
- #platform = undefined; // Homebridge platform api
83
- #eventEmitter = undefined; // Event emitter to use for comms
97
+ #uuid = undefined; // UUID for this instance
98
+ #platform = undefined; // Homebridge platform API
84
99
  #postSetupDetails = []; // Use for extra output details once a device has been setup
85
100
 
86
- constructor(accessory = undefined, api = undefined, log = undefined, eventEmitter = undefined, deviceData = {}) {
101
+ constructor(accessory = undefined, api = undefined, log = undefined, deviceData = {}) {
102
+ super(); // Setup event emitter for our class ONLY
103
+
87
104
  // Validate the passed in logging object. We are expecting certain functions to be present
88
105
  if (Object.values(LOG_LEVELS).every((fn) => typeof log?.[fn] === 'function')) {
89
106
  this.log = log;
90
107
  }
91
108
 
92
- // Workout if we're running under Homebridge or HAP-NodeJS library
93
- if (isNaN(api?.version) === false && typeof api?.hap === 'object' && api?.HAPLibraryVersion === undefined) {
94
- // We have the Homebridge version number and hap API object
109
+ // Determine runtime environment (Homebridge vs HAP-NodeJS)
110
+ if (typeof api?.hap === 'object' && isNaN(api?.version) === false && typeof api?.HAPLibraryVersion === 'undefined') {
95
111
  this.hap = api.hap;
96
112
  this.#platform = api;
97
-
113
+ this.backend = HomeKitDevice.HOMEBRIDGE;
98
114
  this.postSetupDetail('Homebridge backend', LOG_LEVELS.DEBUG);
99
115
  }
100
116
 
101
- if (typeof api?.HAPLibraryVersion === 'function' && api?.version === undefined && api?.hap === undefined) {
102
- // As we're missing the Homebridge entry points but have the HAP library version
117
+ if (typeof api?.hap === 'undefined' && isNaN(api?.version) === true && typeof api?.HAPLibraryVersion === 'function') {
103
118
  this.hap = api;
104
-
119
+ this.backend = HomeKitDevice.HAP_NODEJS;
105
120
  this.postSetupDetail('HAP-NodeJS library', LOG_LEVELS.DEBUG);
106
121
  }
107
122
 
108
123
  // Generate UUID for this device instance
109
124
  // Will either be a random generated one or HAP generated one
110
125
  // HAP is based upon defined plugin name and devices serial number
111
- this.uuid = HomeKitDevice.generateUUID(HomeKitDevice.PLUGIN_NAME, api, deviceData.serialNumber);
126
+ this.#uuid = HomeKitDevice.generateUUID(HomeKitDevice.PLUGIN_NAME, api, deviceData.serialNumber);
127
+
128
+ // Register this device instance in the static device registry
129
+ HomeKitDevice.#deviceRegistry.set(this.#uuid, this);
112
130
 
113
131
  // See if we were passed in an existing accessory object or array of accessory objects
114
132
  // Mainly used to restore a Homebridge cached accessory
115
- if (typeof accessory === 'object' && this.#platform !== undefined) {
133
+ if (typeof accessory === 'object' && this.backend === HomeKitDevice.HOMEBRIDGE) {
116
134
  if (Array.isArray(accessory) === true) {
117
- this.accessory = accessory.find((accessory) => this?.uuid !== undefined && accessory?.UUID === this.uuid);
135
+ this.accessory = accessory.find((accessory) => this?.uuid !== undefined && accessory?.UUID === this.#uuid);
118
136
  }
119
- if (Array.isArray(accessory) === false && accessory?.UUID === this.uuid) {
137
+ if (Array.isArray(accessory) === false && accessory?.UUID === this.#uuid) {
120
138
  this.accessory = accessory;
121
139
  }
122
140
  }
123
141
 
124
- // Validate if eventEmitter object passed to us is an instance of EventEmitter
125
- // If valid, setup an event listener for messages to this device using our generated uuid
126
- if (eventEmitter instanceof EventEmitter === true) {
127
- this.#eventEmitter = eventEmitter;
128
- this.#eventEmitter.addListener(this.uuid, this.#message.bind(this));
129
- }
130
-
131
142
  // Make a clone of current data and store in this object
132
143
  // Important that we done have a 'linked' copy of the object data
133
- // eslint-disable-next-line no-undef
134
144
  this.deviceData = structuredClone(deviceData);
135
145
  }
136
146
 
137
147
  // Class functions
138
- async add(accessoryName, accessoryCategory, useHistoryService) {
148
+ async add(hapAccessoryName, hapCategory, enableHistory = false) {
139
149
  if (
140
150
  this.hap === undefined ||
141
151
  typeof HomeKitDevice.PLUGIN_NAME !== 'string' ||
142
152
  HomeKitDevice.PLUGIN_NAME === '' ||
143
153
  typeof HomeKitDevice.PLATFORM_NAME !== 'string' ||
144
154
  HomeKitDevice.PLATFORM_NAME === '' ||
145
- typeof accessoryName !== 'string' ||
146
- accessoryName === '' ||
147
- typeof this.hap.Categories[accessoryCategory] === 'undefined' ||
148
- typeof useHistoryService !== 'boolean' ||
155
+ typeof hapAccessoryName !== 'string' ||
156
+ hapAccessoryName === '' ||
157
+ typeof this.hap.Categories[hapCategory] === 'undefined' ||
158
+ typeof enableHistory !== 'boolean' ||
149
159
  typeof this.deviceData !== 'object' ||
150
160
  typeof this.deviceData?.serialNumber !== 'string' ||
151
161
  this.deviceData.serialNumber === '' ||
@@ -167,23 +177,33 @@ export default class HomeKitDevice {
167
177
  }
168
178
 
169
179
  // If we do not have an existing accessory object, create a new one
170
- if (this.accessory === undefined && this.#platform !== undefined) {
180
+ if (
181
+ this.accessory === undefined &&
182
+ typeof this.#platform?.platformAccessory === 'function' &&
183
+ typeof this.#platform?.registerPlatformAccessories === 'function' &&
184
+ this.backend === HomeKitDevice.HOMEBRIDGE
185
+ ) {
171
186
  // Create Homebridge platform accessory
172
- this.accessory = new this.#platform.platformAccessory(this.deviceData.description, this.uuid);
187
+ this.accessory = new this.#platform.platformAccessory(this.deviceData.description, this.#uuid);
173
188
  this.#platform.registerPlatformAccessories(HomeKitDevice.PLUGIN_NAME, HomeKitDevice.PLATFORM_NAME, [this.accessory]);
174
189
  }
175
190
 
176
- if (this.accessory === undefined && this.#platform === undefined) {
191
+ if (this.accessory === undefined && this.backend === HomeKitDevice.HAP_NODEJS) {
177
192
  // Create HAP-NodeJS libray accessory
178
- this.accessory = new this.hap.Accessory(accessoryName, this.uuid);
193
+ this.accessory = new this.hap.Accessory(hapAccessoryName, this.#uuid);
179
194
 
180
195
  this.accessory.username = this.deviceData.hkUsername;
181
196
  this.accessory.pincode = this.deviceData.hkPairingCode;
182
- this.accessory.category = accessoryCategory;
197
+ this.accessory.category = hapCategory;
183
198
  }
184
199
 
185
200
  // Setup accessory information
186
201
  let informationService = this.accessory.getService(this.hap.Service.AccessoryInformation);
202
+ if (informationService === undefined) {
203
+ this?.log?.error?.('AccessoryInformation service not found on accessory for "%s"', this.deviceData.description);
204
+ return;
205
+ }
206
+
187
207
  if (informationService !== undefined) {
188
208
  informationService.updateCharacteristic(this.hap.Characteristic.Manufacturer, this.deviceData.manufacturer);
189
209
  informationService.updateCharacteristic(this.hap.Characteristic.Model, this.deviceData.model);
@@ -193,251 +213,359 @@ export default class HomeKitDevice {
193
213
  }
194
214
 
195
215
  // Setup our history service if module has been defined and requested to be active for this device
196
- if (typeof HomeKitDevice?.HISTORY === 'function' && this.historyService === undefined && useHistoryService === true) {
197
- this.historyService = new HomeKitDevice.HISTORY(this.accessory, this.log, this.hap, {});
216
+ if (typeof HomeKitDevice?.EVEHOME === 'function' && this.historyService === undefined && enableHistory === true) {
217
+ this.historyService = new HomeKitDevice.EVEHOME(this.accessory, this.hap, this.log, {});
198
218
  }
199
219
 
200
- if (typeof this?.setupDevice === 'function') {
201
- try {
202
- this.postSetupDetail('Serial number "%s"', this.deviceData.serialNumber, LOG_LEVELS.DEBUG);
220
+ this.postSetupDetail('Serial number "%s"', this.deviceData.serialNumber, LOG_LEVELS.DEBUG);
203
221
 
204
- await this.setupDevice();
222
+ // Trigger registered handlers (onAdd + listeners)
223
+ await this.message(HomeKitDevice.ADD);
205
224
 
206
- if (this.historyService?.EveHome !== undefined) {
207
- this.postSetupDetail('EveHome support as "%s"', this.historyService.EveHome.evetype);
208
- }
225
+ if (this.historyService?.EveHome !== undefined) {
226
+ this.postSetupDetail('EveHome support as "%s"', this.historyService.EveHome.evetype);
227
+ }
209
228
 
210
- this?.log?.info?.('Setup %s %s as "%s"', this.deviceData.manufacturer, this.deviceData.model, this.deviceData.description);
211
- this.#postSetupDetails.forEach((entry) => {
212
- if (typeof entry === 'string') {
213
- this?.log?.[LOG_LEVELS.INFO]?.(' += %s', entry);
214
- } else if (typeof entry?.message === 'string') {
215
- let level =
216
- Object.hasOwn(LOG_LEVELS, entry?.level?.toUpperCase?.()) &&
217
- typeof this?.log?.[LOG_LEVELS[entry.level.toUpperCase()]] === 'function'
218
- ? LOG_LEVELS[entry.level.toUpperCase()]
219
- : LOG_LEVELS.INFO;
220
-
221
- this?.log?.[level]?.(' += ' + entry.message, ...(Array.isArray(entry?.args) ? entry.args : []));
222
- }
223
- });
224
- } catch (error) {
225
- this?.log?.error('setupDevice call for device "%s" failed. Error was', this.deviceData.description, error);
229
+ this?.log?.success?.('Setup %s %s as "%s"', this.deviceData.manufacturer, this.deviceData.model, this.deviceData.description);
230
+ this.#postSetupDetails.forEach((entry) => {
231
+ if (typeof entry === 'string') {
232
+ this?.log?.[LOG_LEVELS.INFO]?.(' += %s', entry);
233
+ } else if (typeof entry?.message === 'string') {
234
+ let level =
235
+ Object.hasOwn(LOG_LEVELS, entry?.level?.toUpperCase?.()) &&
236
+ typeof this?.log?.[LOG_LEVELS[entry.level.toUpperCase()]] === 'function'
237
+ ? LOG_LEVELS[entry.level.toUpperCase()]
238
+ : LOG_LEVELS.INFO;
239
+
240
+ this?.log?.[level]?.(' += ' + entry.message, ...(Array.isArray(entry?.args) ? entry.args : []));
226
241
  }
227
- }
242
+ });
228
243
 
229
- // Perform an initial update using current data
230
- this.update(this.deviceData, true);
244
+ // Trigger registered handlers (onUpdate + listeners) for initial device data updates
245
+ await this.message(HomeKitDevice.UPDATE, this.deviceData, { force: true });
231
246
 
232
247
  // If using HAP-NodeJS library, publish accessory on local network
233
- if (this.#platform === undefined && this.accessory !== undefined) {
248
+ if (this.accessory !== undefined && this.backend === HomeKitDevice.HAP_NODEJS) {
234
249
  this.accessory.publish({
235
250
  username: this.accessory.username,
236
251
  pincode: this.accessory.pincode,
237
252
  category: this.accessory.category,
238
253
  });
239
254
 
240
- this?.log?.info(' += Advertising as "%s"', this.accessory.displayName);
241
- this?.log?.info(' += Pairing code is "%s"', this.accessory.pincode);
255
+ this?.log?.info?.(' += Advertising as "%s"', this.accessory.displayName);
256
+ this?.log?.info?.(' += Pairing code is "%s"', this.accessory.pincode);
242
257
  }
243
258
  this.#postSetupDetails = []; // Don't need these anymore
244
259
  return this.accessory; // Return our HomeKit accessory
245
260
  }
246
261
 
247
- remove() {
248
- this?.log?.warn?.('Device "%s" has been removed', this.deviceData.description);
262
+ async remove() {
263
+ // Trigger registered handlers (onRemove + listeners)
264
+ await this.message(HomeKitDevice.REMOVE);
265
+ }
249
266
 
250
- if (this.#eventEmitter !== undefined) {
251
- // Remove listener for 'messages'
252
- this.#eventEmitter.removeAllListeners(this.uuid);
267
+ async update(deviceData, ...args) {
268
+ if (typeof deviceData !== 'object') {
269
+ return;
253
270
  }
254
271
 
255
- if (typeof this?.removeDevice === 'function') {
256
- try {
257
- this.removeDevice();
258
- } catch (error) {
259
- this?.log?.error('removeDevice call for device "%s" failed. Error was', this.deviceData.description, error);
260
- }
261
- }
272
+ // Trigger registered handlers (onUpdate + listeners)
273
+ await this.message(HomeKitDevice.UPDATE, deviceData, ...args);
274
+ }
262
275
 
263
- if (this.accessory !== undefined && this.#platform !== undefined) {
264
- // Unregister the accessory from Homebridge platform
265
- this.#platform.unregisterPlatformAccessories(HomeKitDevice.PLUGIN_NAME, HomeKitDevice.PLATFORM_NAME, [this.accessory]);
276
+ async history(target, entry, options = {}) {
277
+ if (
278
+ typeof this.historyService !== 'object' ||
279
+ typeof this.historyService.addHistory !== 'function' ||
280
+ typeof entry !== 'object' ||
281
+ typeof target !== 'object' ||
282
+ typeof target.UUID !== 'string'
283
+ ) {
284
+ return;
266
285
  }
267
286
 
268
- if (this.accessory !== undefined && this.#platform === undefined) {
269
- // Unpublish the accessory from HAP-NodeJS library
270
- this.accessory.unpublish();
287
+ // Trigger registered handlers (onHistory + listeners)
288
+ await this.message(HomeKitDevice.HISTORY, target, entry, options);
289
+ }
290
+
291
+ async set(values, ...args) {
292
+ if (typeof values !== 'object' || values === null) {
293
+ return;
271
294
  }
272
295
 
273
- this.deviceData = {};
274
- this.accessory = undefined;
275
- this.historyService = undefined;
276
- this.hap = undefined;
277
- this.log = undefined;
278
- this.uuid = undefined;
279
- this.#platform = undefined;
280
- this.#eventEmitter = undefined;
296
+ // Trigger registered handlers (onSet + listeners)
297
+ await this.message(HomeKitDevice.SET, values, ...args);
298
+ }
281
299
 
282
- // Do we destroy this object??
283
- // this = null;
284
- // delete this;
300
+ async get(values, ...args) {
301
+ // Trigger registered handlers (onGet + listeners)
302
+ return await this.message(HomeKitDevice.GET, values, ...args);
285
303
  }
286
304
 
287
- update(deviceData, forceUpdate) {
288
- if (typeof deviceData !== 'object' || typeof forceUpdate !== 'boolean') {
305
+ static async message(uuid, type, message = undefined, ...args) {
306
+ if (typeof uuid !== 'string' || uuid === '' || typeof type !== 'string' || type === '') {
289
307
  return;
290
308
  }
291
309
 
292
- // Updated data may only contain selected fields, so we'll handle that here by taking our internally stored data
293
- // and merge with the updates to ensure we have a complete data object
294
- Object.entries(this.deviceData).forEach(([key, value]) => {
295
- if (typeof deviceData[key] === 'undefined') {
296
- // Updated data doesn't have this key, so add it to our internally stored data
297
- deviceData[key] = value;
310
+ if (typeof message === 'function' || (typeof message === 'object' && message !== null && message?.constructor !== Object)) {
311
+ if (this.#listeners?.[uuid] === undefined) {
312
+ this.#listeners[uuid] = {};
313
+ }
314
+ if (Array.isArray(this.#listeners[uuid][type]) === false) {
315
+ this.#listeners[uuid][type] = [];
298
316
  }
299
- });
300
317
 
301
- // Check updated device data with our internally stored data. Flag if changes between the two
302
- let changedData = false;
303
- Object.keys(deviceData).forEach((key) => {
304
- if (JSON.stringify(deviceData[key]) !== JSON.stringify(this.deviceData[key])) {
305
- changedData = true;
318
+ let handler, context;
319
+
320
+ if (typeof message === 'function' || typeof message === 'string') {
321
+ handler = message;
322
+ context = undefined;
323
+ } else {
324
+ context = message;
325
+ handler = typeof type === 'string' ? type.match(/\.?(on[A-Z][a-zA-Z0-9]*)$/)?.[1] : undefined;
306
326
  }
307
- });
308
327
 
309
- // If we have any changed data OR we've been requested to force an update, do so here
310
- if ((changedData === true || forceUpdate === true) && this.accessory !== undefined) {
311
- let informationService = this.accessory.getService(this.hap.Service.AccessoryInformation);
312
- if (informationService !== undefined) {
313
- // Update details associated with the accessory
314
- // ie: Name, Manufacturer, Model, Serial # and firmware version
315
- if (typeof deviceData?.description === 'string' && deviceData.description !== this.deviceData.description) {
316
- // Update serial number on the HomeKit accessory
317
- informationService.updateCharacteristic(this.hap.Characteristic.Name, this.deviceData.description);
328
+ if (handler !== undefined) {
329
+ if (this.#listeners?.[uuid]?.[type]?.find?.((h) => h.handler === handler && h.context === context) === undefined) {
330
+ this.#listeners[uuid][type].push({ handler, context });
318
331
  }
332
+ }
319
333
 
320
- if (
321
- typeof deviceData?.manufacturer === 'string' &&
322
- deviceData.manufacturer !== '' &&
323
- deviceData.manufacturer !== this.deviceData.manufacturer
324
- ) {
325
- // Update manufacturer number on the HomeKit accessory
326
- informationService.updateCharacteristic(this.hap.Characteristic.Manufacturer, deviceData.manufacturer);
327
- }
334
+ return;
335
+ }
328
336
 
329
- if (typeof deviceData?.model === 'string' && deviceData.model !== '' && deviceData.model !== this.deviceData.model) {
330
- // Update model on the HomeKit accessory
331
- informationService.updateCharacteristic(this.hap.Characteristic.Model, deviceData.model);
332
- }
337
+ // Handle message delivery
338
+ return await this.#deviceRegistry.get(uuid)?.message?.(type, message, ...args);
339
+ }
333
340
 
334
- if (
335
- typeof deviceData?.softwareVersion === 'string' &&
336
- deviceData.softwareVersion !== '' &&
337
- deviceData.softwareVersion !== this.deviceData.softwareVersion
338
- ) {
339
- // Update software version on the HomeKit accessory
340
- informationService.updateCharacteristic(this.hap.Characteristic.FirmwareRevision, deviceData.softwareVersion);
341
- }
341
+ async message(type, message, ...args) {
342
+ if (typeof type !== 'string' || type === '') {
343
+ return;
344
+ }
342
345
 
343
- // Check for devices serial number changing. Really shouldn't occur, but handle case anyway
344
- if (
345
- typeof deviceData?.serialNumber === 'string' &&
346
- deviceData.serialNumber !== '' &&
347
- deviceData.serialNumber.toUpperCase() !== this.deviceData.serialNumber.toUpperCase()
348
- ) {
349
- this?.log?.warn?.('Serial number on "%s" has changed', deviceData.description);
350
- this?.log?.warn?.('This may cause the device to become unresponsive in HomeKit');
346
+ let result = { call: undefined, handler: undefined };
347
+ let handled = false;
348
+ let handler =
349
+ Array.isArray(HomeKitDevice.#listeners?.[this.#uuid]?.[type]) === true
350
+ ? HomeKitDevice.#listeners[this.#uuid][type]
351
+ : HomeKitDevice.#listeners?.[this.#uuid]?.[type] !== undefined
352
+ ? [HomeKitDevice.#listeners[this.#uuid][type]]
353
+ : [];
354
+
355
+ // Dynamically extract the handler method name from the type string (e.g., "HomeKitDevice.onAdd" becomes "onAdd")
356
+ // This allows consistent routing to instance methods like onAdd, onSet, onUpdate, etc.
357
+ let methodName = typeof type === 'string' ? type.match(/\.?(on[A-Z][a-zA-Z0-9]*)$/)?.[1] : undefined;
358
+
359
+ // Internal helper to call handlers with error trapping. Will also walk up the prototype chain
360
+ const callLifecycleHook = async (labelOrFn, ...params) => {
361
+ let results = [];
362
+ let called = new Set(); // track calls using context + function identity
363
+
364
+ const callMethodWithProtoChain = async (obj, method, contextLabel) => {
365
+ let current = obj;
366
+ let seen = new Set();
367
+
368
+ while (current && typeof current === 'object' && seen.has(current) === false) {
369
+ seen.add(current);
370
+
371
+ let fn = current?.[method];
372
+ if (typeof fn === 'function') {
373
+ let key = fn + '@' + obj;
374
+ if (called.has(key) === false) {
375
+ called.add(key);
376
+ try {
377
+ results.push(await fn.apply(obj, params));
378
+ } catch (error) {
379
+ this?.log?.warn?.('Error in %s.%s(): %s', contextLabel, method, String(error?.stack || error));
380
+ }
381
+ }
382
+ }
351
383
 
352
- // Update software version on the HomeKit accessory
353
- informationService.updateCharacteristic(this.hap.Characteristic.SerialNumber, deviceData.serialNumber);
384
+ current = Object.getPrototypeOf(current);
354
385
  }
355
- }
356
-
357
- if (typeof deviceData?.online === 'boolean' && deviceData.online !== this.deviceData.online) {
358
- // Output device online/offline status
359
- if (deviceData.online === false) {
360
- this?.log?.warn?.('Device "%s" is offline', deviceData.description);
386
+ };
387
+
388
+ if (typeof labelOrFn === 'string') {
389
+ await callMethodWithProtoChain(this, labelOrFn, this?.constructor?.name ?? 'this');
390
+ } else if (typeof labelOrFn === 'function') {
391
+ let key = labelOrFn + '@' + this;
392
+ if (called.has(key) === false) {
393
+ called.add(key);
394
+ try {
395
+ results.push(await labelOrFn(...params));
396
+ } catch (error) {
397
+ this?.log?.warn?.('Error in inline function handler: %s', String(error?.stack || error));
398
+ }
361
399
  }
362
-
363
- if (deviceData.online === true) {
364
- this?.log?.success?.('Device "%s" is online', deviceData.description);
400
+ } else if (Array.isArray(labelOrFn) === true) {
401
+ let [label, list] = labelOrFn;
402
+
403
+ for (let item of list || []) {
404
+ let fn = item?.handler;
405
+ let context = item?.context ?? this;
406
+ let key = fn + '@' + context;
407
+
408
+ if (typeof fn === 'function') {
409
+ if (called.has(key) === false) {
410
+ called.add(key);
411
+ try {
412
+ results.push(await fn.call(context, ...params));
413
+ } catch (error) {
414
+ this?.log?.warn?.('Error in registered %s(): %s', label, String(error?.stack || error));
415
+ }
416
+ }
417
+ } else if (typeof fn === 'string' && context) {
418
+ await callMethodWithProtoChain(context, fn, context?.constructor?.name ?? 'handler');
419
+ }
365
420
  }
366
421
  }
367
422
 
368
- if (typeof this?.updateDevice === 'function') {
369
- try {
370
- this.updateDevice(deviceData); // Pass updated data on for accessory to process as it needs
371
- } catch (error) {
372
- this?.log?.error('updateDevice call for device "%s" failed. Error was', deviceData.description, error);
423
+ return results.length === 1 ? results[0] : results;
424
+ };
425
+
426
+ // Handle built-in types with special behavior
427
+ if (type === HomeKitDevice.ADD || type === HomeKitDevice.REMOVE || type === HomeKitDevice.SET) {
428
+ // Call the dynamic on<Type> method (ie. onAdd, onRemove, onSet) and after
429
+ // Any static handler registered via HomeKitDevice.message(uuid, type, handler)
430
+ await callLifecycleHook(methodName, message, ...args);
431
+ await callLifecycleHook(['handler for ' + type, handler], message, ...args);
432
+ handled = true;
433
+
434
+ // Special setup for ADD
435
+ if (type === HomeKitDevice.ADD) {
436
+ // After the accessory is initialized and onAdd has run, link any EveHome services that requested it
437
+ if (this.deviceData?.eveHistory === true && typeof this.historyService?.linkToEveHome === 'function') {
438
+ for (let service of this.accessory?.services || []) {
439
+ let options = service?.[HomeKitDevice?.EVEHOME?.EVE_OPTIONS];
440
+ if (options !== undefined) {
441
+ delete service[HomeKitDevice?.EVEHOME?.EVE_OPTIONS];
442
+ this.historyService.linkToEveHome(service, options);
443
+ }
444
+ }
373
445
  }
374
446
  }
375
447
 
376
- // Finally, update our internally stored data with the new data
377
- // eslint-disable-next-line no-undef
378
- this.deviceData = structuredClone(deviceData);
379
- }
380
- }
448
+ // Special teardown for REMOVE
449
+ if (type === HomeKitDevice.REMOVE) {
450
+ this?.log?.warn?.('Device "%s" has been removed', this.deviceData.description);
451
+ this?.removeAllListeners?.();
452
+ HomeKitDevice.#deviceRegistry.delete(this.#uuid);
453
+ delete HomeKitDevice.#listeners[this.#uuid];
381
454
 
382
- async set(values) {
383
- if (typeof values !== 'object' || this.#eventEmitter === undefined) {
384
- return;
385
- }
455
+ if (this.accessory !== undefined && typeof this.#platform?.unregisterPlatformAccessories === 'function') {
456
+ this.#platform.unregisterPlatformAccessories(HomeKitDevice.PLUGIN_NAME, HomeKitDevice.PLATFORM_NAME, [this.accessory]);
457
+ }
386
458
 
387
- // Send event with data to set
388
- this.#eventEmitter.emit(HomeKitDevice.SET, this.uuid, values);
459
+ if (this.accessory !== undefined && this.#platform === undefined) {
460
+ this.accessory.unpublish();
461
+ }
389
462
 
390
- // Update the internal data for the set values, as could take sometime once we emit the event
391
- Object.entries(values).forEach(([key, value]) => {
392
- if (this.deviceData[key] !== undefined) {
393
- this.deviceData[key] = value;
463
+ this.deviceData = {};
464
+ this.accessory = undefined;
465
+ this.historyService = undefined;
466
+ this.hap = undefined;
467
+ this.log = undefined;
468
+ this.#uuid = undefined;
469
+ this.#platform = undefined;
394
470
  }
395
- });
396
- }
397
-
398
- async get(values) {
399
- if (typeof values !== 'object' || this.#eventEmitter === undefined) {
400
- return;
401
- }
402
-
403
- // Send event with data to get
404
- // Once get has completed, we'll get an event back with the requested data
405
- this.#eventEmitter.emit(HomeKitDevice.GET, this.uuid, values);
406
471
 
407
- // This should always return, but we probably should put in a timeout?
408
- let results = await EventEmitter.once(this.#eventEmitter, HomeKitDevice.GET + '->' + this.uuid);
409
- return results?.[0];
410
- }
411
-
412
- #message(type, message) {
413
- switch (type) {
414
- case HomeKitDevice.UPDATE: {
415
- // Got some device data, so process any updates
416
- this.update(message, false);
417
- break;
472
+ // Update the internal data for the set values, as could take some time once we emit the event
473
+ if (type === HomeKitDevice.SET) {
474
+ if (typeof message === 'object' && message !== null) {
475
+ Object.entries(message).forEach(([key, value]) => {
476
+ if (this.deviceData?.[key] !== undefined) {
477
+ this.deviceData[key] = value;
478
+ }
479
+ });
480
+ }
418
481
  }
482
+ } else if (type === HomeKitDevice.UPDATE) {
483
+ if (typeof message === 'object' && message !== null) {
484
+ let { merged, changed } = this.#mergeDeviceData(message);
485
+ this.#updateAccessoryInformation(merged);
486
+
487
+ if (changed === true || (typeof args?.[0] === 'object' && args?.[0]?.force === true)) {
488
+ // Call the onUpdate method and after any static handler registered via HomeKitDevice.message(uuid, type, handler)
489
+ await callLifecycleHook('onUpdate', merged, ...args);
490
+ await callLifecycleHook(['handler for UPDATE', handler], merged, ...args);
491
+ }
419
492
 
420
- case HomeKitDevice.REMOVE: {
421
- // Got message for device removal
422
- this.remove();
423
- break;
493
+ // Finally, update our internally stored data with the new data
494
+ this.deviceData = structuredClone(merged);
424
495
  }
496
+ handled = true;
497
+ } else if (type === HomeKitDevice.HISTORY) {
498
+ let [target, entry, options = {}] = [message, args[0], args[1]];
499
+ let skipHistory = false;
500
+
501
+ if (
502
+ typeof this.historyService === 'object' &&
503
+ typeof this.historyService?.addHistory === 'function' &&
504
+ typeof entry === 'object' &&
505
+ typeof target === 'object' &&
506
+ typeof target.UUID === 'string'
507
+ ) {
508
+ if (isNaN(entry?.time) === true) {
509
+ entry.time = Math.floor(Date.now() / 1000);
510
+ }
425
511
 
426
- default: {
427
- // This is not a message we know about, so pass onto accessory for it to perform any processing
428
- if (typeof this?.messageDevice === 'function') {
429
- try {
430
- this.messageDevice(type, message);
431
- } catch (error) {
432
- this?.log?.error('messageDevice call for device "%s" failed. Error was', this.deviceData.description, error);
512
+ if (options?.force !== true && typeof this.historyService?.lastHistory === 'function') {
513
+ let last = this.historyService.lastHistory(target);
514
+ if (typeof last === 'object') {
515
+ let changed = Object.keys(entry).some((key) => {
516
+ if (key === 'time') {
517
+ return false;
518
+ }
519
+ let v = entry[key];
520
+ let lv = last[key];
521
+ return typeof v === 'object' ? JSON.stringify(v) !== JSON.stringify(lv) : v !== lv;
522
+ });
523
+ if (changed === false) {
524
+ skipHistory = true;
525
+ }
433
526
  }
434
527
  }
435
- break;
528
+
529
+ if (skipHistory === false) {
530
+ this.historyService.addHistory(target, entry, isNaN(options?.timegap) === false ? options.timegap : undefined);
531
+ }
436
532
  }
533
+
534
+ // Call the onHistory method and after any static handler registered via HomeKitDevice.message(uuid, type, handler)
535
+ await callLifecycleHook('onHistory', target, entry, options);
536
+ await callLifecycleHook(['handler for HISTORY', handler], target, entry, options);
537
+
538
+ handled = true;
539
+ }
540
+
541
+ // Dynamically handle any remaining on<Type> method (e.g., onGet etc that we haven’t handled yet)
542
+ // Any static handler registered via HomeKitDevice.message(uuid, type, handler)
543
+ if (handled === false && (typeof this?.[methodName] === 'function' || (Array.isArray(handler) === true && handler.length > 0))) {
544
+ // Use string method name so we get inheritance merging;
545
+ result.call = await callLifecycleHook(methodName, message, ...args);
546
+ result.handler = await callLifecycleHook(['handler for ' + type, handler], message, ...args);
547
+ handled = true;
548
+ }
549
+
550
+ // Call generic handler if present and we haven't handled the message yet
551
+ if (handled === false) {
552
+ result.call = await callLifecycleHook('onMessage', type, message, ...args);
553
+ handled = true;
554
+ }
555
+
556
+ // No handler at all — not even onMessage()
557
+ if (handled === false && (Array.isArray(handler) === false || handler.length === 0) && typeof this?.[methodName] !== 'function') {
558
+ this?.log?.warn?.('Unhandled message type "%s" for device "%s"', type, this.deviceData.description);
437
559
  }
560
+
561
+ if (typeof result.call === 'object' && typeof result.handler === 'object') {
562
+ return Object.assign({}, result.call, result.handler);
563
+ }
564
+
565
+ return result.call !== undefined ? result.call : result.handler;
438
566
  }
439
567
 
440
- addHKService(hkServiceType, name = '', subType = undefined) {
568
+ addHKService(hkServiceType, name = '', subType = undefined, eveOptions = undefined) {
441
569
  let service = undefined;
442
570
 
443
571
  if (
@@ -455,12 +583,22 @@ export default class HomeKitDevice {
455
583
  if (service === undefined) {
456
584
  service = this.accessory.addService(hkServiceType, name, subType);
457
585
  }
586
+
587
+ // Setup for EveHome history if enabled. The actual linkage will be done in .add() after returning from .onAdd()
588
+ if (
589
+ service !== undefined &&
590
+ typeof eveOptions === 'object' &&
591
+ this.deviceData?.eveHistory === true &&
592
+ typeof this.historyService?.linkToEveHome === 'function'
593
+ ) {
594
+ service[HomeKitDevice?.EVEHOME?.EVE_OPTIONS] = eveOptions;
595
+ }
458
596
  }
459
597
 
460
598
  return service;
461
599
  }
462
600
 
463
- addHKCharacteristic(hkService, hkCharacteristicType, { props, onSet, onGet } = {}) {
601
+ addHKCharacteristic(hkService, hkCharacteristicType, { props, onSet, onGet, initialValue } = {}) {
464
602
  let characteristic = undefined;
465
603
 
466
604
  if (
@@ -472,9 +610,8 @@ export default class HomeKitDevice {
472
610
  ) {
473
611
  if (hkService.testCharacteristic(hkCharacteristicType) === false) {
474
612
  if (
475
- Array.isArray(hkService?.optionalCharacteristics) &&
476
- hkService.optionalCharacteristics.includes(hkCharacteristicType) &&
477
- typeof hkService?.addOptionalCharacteristic === 'function'
613
+ Array.isArray(hkService?.optionalCharacteristics) === true &&
614
+ hkService.optionalCharacteristics.includes(hkCharacteristicType) === true
478
615
  ) {
479
616
  hkService.addOptionalCharacteristic(hkCharacteristicType);
480
617
  } else {
@@ -494,7 +631,13 @@ export default class HomeKitDevice {
494
631
  if (typeof props === 'object' && typeof characteristic.setProps === 'function') {
495
632
  characteristic.setProps(props);
496
633
  }
634
+
635
+ // Set initial value if provided
636
+ if (typeof initialValue !== 'undefined' && typeof hkService?.updateCharacteristic === 'function') {
637
+ hkService.updateCharacteristic(hkCharacteristicType, initialValue);
638
+ }
497
639
  }
640
+
498
641
  return characteristic;
499
642
  }
500
643
 
@@ -519,17 +662,13 @@ export default class HomeKitDevice {
519
662
  }
520
663
 
521
664
  static generateUUID(PLUGIN_NAME, api, serialNumber) {
522
- let hap = undefined;
665
+ let hap;
523
666
  let uuid = crypto.randomUUID();
524
667
 
525
- // Workout if we're running under Homebridge or HAP-NodeJS library
526
- if (isNaN(api?.version) === false && typeof api?.hap === 'object' && api?.HAPLibraryVersion === undefined) {
527
- // We have the Homebridge version number and hap API object
668
+ // Determine runtime environment (Homebridge vs HAP-NodeJS)
669
+ if (typeof api?.hap === 'object' && isNaN(api?.version) === false && typeof api?.HAPLibraryVersion === 'undefined') {
528
670
  hap = api.hap;
529
- }
530
-
531
- if (typeof api?.HAPLibraryVersion === 'function' && api?.version === undefined && api?.hap === undefined) {
532
- // As we're missing the Homebridge entry points but have the HAP library version
671
+ } else if (typeof api?.HAPLibraryVersion === 'function' && typeof api?.version === 'undefined' && typeof api?.hap === 'undefined') {
533
672
  hap = api;
534
673
  }
535
674
 
@@ -546,15 +685,109 @@ export default class HomeKitDevice {
546
685
  return uuid;
547
686
  }
548
687
 
549
- static makeHomeKitName(name) {
688
+ static makeValidHKName(name) {
550
689
  // Strip invalid characters to meet HomeKit naming requirements
551
690
  // Ensure only letters or numbers are at the beginning AND/OR end of string
552
691
  // Matches against uni-code characters
553
692
  return typeof name === 'string'
554
693
  ? name
555
- .replace(/[^\p{L}\p{N}\p{Z}\u2019.,-]/gu, '')
556
- .replace(/^[^\p{L}\p{N}]*/gu, '')
557
- .replace(/[^\p{L}\p{N}]+$/gu, '')
694
+ .replace(/[^\p{L}\p{N}\p{Z}\u2019.,-]/gu, '')
695
+ .replace(/^[^\p{L}\p{N}]*/gu, '')
696
+ .replace(/[^\p{L}\p{N}]+$/gu, '')
558
697
  : name;
559
698
  }
699
+
700
+ get uuid() {
701
+ return this.#uuid;
702
+ }
703
+
704
+ #mergeDeviceData(deviceDataUpdates = {}) {
705
+ let merged = { ...deviceDataUpdates };
706
+
707
+ // Updated data may only contain selected fields, so we'll handle that here by taking our internally stored data
708
+ // and merge with the updates to ensure we have a complete data object
709
+ Object.entries(this.deviceData).forEach(([key, value]) => {
710
+ if (typeof merged[key] === 'undefined') {
711
+ // Updated data doesn't have this key, so add it to our internally stored data
712
+ merged[key] = value;
713
+ }
714
+ });
715
+
716
+ // Check updated device data with our internally stored data. Flag if changes between the two
717
+ let changed = false;
718
+ Object.keys(merged).forEach((key) => {
719
+ if (JSON.stringify(merged[key]) !== JSON.stringify(this.deviceData[key])) {
720
+ changed = true;
721
+ }
722
+ });
723
+
724
+ return { merged, changed };
725
+ }
726
+
727
+ #updateAccessoryInformation(deviceData) {
728
+ // Always update accessory information if we have changed data
729
+ if (this.accessory === undefined) {
730
+ return;
731
+ }
732
+
733
+ let informationService = this.accessory.getService(this.hap.Service.AccessoryInformation);
734
+ if (informationService === undefined) {
735
+ this?.log?.error?.('AccessoryInformation service not found on accessory for "%s"', this.deviceData.description);
736
+ return;
737
+ }
738
+
739
+ // Update details associated with the accessory
740
+ // ie: Name, Manufacturer, Model, Serial # and firmware version
741
+ if (typeof deviceData?.description === 'string' && deviceData.description !== this.deviceData.description) {
742
+ // Update devices description on the HomeKit accessory
743
+ informationService.updateCharacteristic(this.hap.Characteristic.Name, deviceData.description);
744
+ }
745
+
746
+ if (
747
+ typeof deviceData?.manufacturer === 'string' &&
748
+ deviceData.manufacturer !== '' &&
749
+ deviceData.manufacturer !== this.deviceData.manufacturer
750
+ ) {
751
+ // Update manufacturer number on the HomeKit accessory
752
+ informationService.updateCharacteristic(this.hap.Characteristic.Manufacturer, deviceData.manufacturer);
753
+ }
754
+
755
+ if (typeof deviceData?.model === 'string' && deviceData.model !== '' && deviceData.model !== this.deviceData.model) {
756
+ // Update model on the HomeKit accessory
757
+ informationService.updateCharacteristic(this.hap.Characteristic.Model, deviceData.model);
758
+ }
759
+
760
+ if (
761
+ typeof deviceData?.softwareVersion === 'string' &&
762
+ deviceData.softwareVersion !== '' &&
763
+ deviceData.softwareVersion !== this.deviceData.softwareVersion
764
+ ) {
765
+ // Update software version on the HomeKit accessory
766
+ informationService.updateCharacteristic(this.hap.Characteristic.FirmwareRevision, deviceData.softwareVersion);
767
+ }
768
+
769
+ // Check for devices serial number changing. Really shouldn't occur, but handle case anyway
770
+ if (
771
+ typeof deviceData?.serialNumber === 'string' &&
772
+ deviceData.serialNumber !== '' &&
773
+ deviceData.serialNumber.toUpperCase() !== this.deviceData.serialNumber?.toUpperCase()
774
+ ) {
775
+ this?.log?.warn?.('Serial number on "%s" has changed', deviceData.description);
776
+ this?.log?.warn?.('This may cause the device to become unresponsive in HomeKit');
777
+
778
+ // Update serial number on the HomeKit accessory
779
+ informationService.updateCharacteristic(this.hap.Characteristic.SerialNumber, deviceData.serialNumber);
780
+ }
781
+
782
+ if (typeof deviceData?.online === 'boolean' && deviceData.online !== this.deviceData.online) {
783
+ // Output device online/offline status
784
+ if (deviceData.online === false) {
785
+ this?.log?.warn?.('Device "%s" is offline', deviceData.description);
786
+ }
787
+
788
+ if (deviceData.online === true) {
789
+ this?.log?.success?.('Device "%s" is online', deviceData.description);
790
+ }
791
+ }
792
+ }
560
793
  }