meross-iot 0.1.0

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.
Files changed (99) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +153 -0
  4. package/index.d.ts +2344 -0
  5. package/index.js +131 -0
  6. package/lib/controller/device.js +1317 -0
  7. package/lib/controller/features/alarm-feature.js +89 -0
  8. package/lib/controller/features/child-lock-feature.js +61 -0
  9. package/lib/controller/features/config-feature.js +54 -0
  10. package/lib/controller/features/consumption-feature.js +210 -0
  11. package/lib/controller/features/control-feature.js +62 -0
  12. package/lib/controller/features/diffuser-feature.js +411 -0
  13. package/lib/controller/features/digest-timer-feature.js +22 -0
  14. package/lib/controller/features/digest-trigger-feature.js +22 -0
  15. package/lib/controller/features/dnd-feature.js +79 -0
  16. package/lib/controller/features/electricity-feature.js +144 -0
  17. package/lib/controller/features/encryption-feature.js +259 -0
  18. package/lib/controller/features/garage-feature.js +337 -0
  19. package/lib/controller/features/hub-feature.js +687 -0
  20. package/lib/controller/features/light-feature.js +408 -0
  21. package/lib/controller/features/presence-sensor-feature.js +297 -0
  22. package/lib/controller/features/roller-shutter-feature.js +456 -0
  23. package/lib/controller/features/runtime-feature.js +74 -0
  24. package/lib/controller/features/screen-feature.js +67 -0
  25. package/lib/controller/features/sensor-history-feature.js +47 -0
  26. package/lib/controller/features/smoke-config-feature.js +50 -0
  27. package/lib/controller/features/spray-feature.js +166 -0
  28. package/lib/controller/features/system-feature.js +269 -0
  29. package/lib/controller/features/temp-unit-feature.js +55 -0
  30. package/lib/controller/features/thermostat-feature.js +804 -0
  31. package/lib/controller/features/timer-feature.js +507 -0
  32. package/lib/controller/features/toggle-feature.js +223 -0
  33. package/lib/controller/features/trigger-feature.js +333 -0
  34. package/lib/controller/hub-device.js +185 -0
  35. package/lib/controller/subdevice.js +1537 -0
  36. package/lib/device-factory.js +463 -0
  37. package/lib/error-budget.js +138 -0
  38. package/lib/http-api.js +766 -0
  39. package/lib/manager.js +1609 -0
  40. package/lib/model/channel-info.js +79 -0
  41. package/lib/model/constants.js +119 -0
  42. package/lib/model/enums.js +819 -0
  43. package/lib/model/exception.js +363 -0
  44. package/lib/model/http/device.js +215 -0
  45. package/lib/model/http/error-codes.js +121 -0
  46. package/lib/model/http/exception.js +151 -0
  47. package/lib/model/http/subdevice.js +133 -0
  48. package/lib/model/push/alarm.js +112 -0
  49. package/lib/model/push/bind.js +97 -0
  50. package/lib/model/push/common.js +282 -0
  51. package/lib/model/push/diffuser-light.js +100 -0
  52. package/lib/model/push/diffuser-spray.js +83 -0
  53. package/lib/model/push/factory.js +229 -0
  54. package/lib/model/push/generic.js +115 -0
  55. package/lib/model/push/hub-battery.js +59 -0
  56. package/lib/model/push/hub-mts100-all.js +64 -0
  57. package/lib/model/push/hub-mts100-mode.js +59 -0
  58. package/lib/model/push/hub-mts100-temperature.js +62 -0
  59. package/lib/model/push/hub-online.js +59 -0
  60. package/lib/model/push/hub-sensor-alert.js +61 -0
  61. package/lib/model/push/hub-sensor-all.js +59 -0
  62. package/lib/model/push/hub-sensor-smoke.js +110 -0
  63. package/lib/model/push/hub-sensor-temphum.js +62 -0
  64. package/lib/model/push/hub-subdevicelist.js +50 -0
  65. package/lib/model/push/hub-togglex.js +60 -0
  66. package/lib/model/push/index.js +81 -0
  67. package/lib/model/push/online.js +53 -0
  68. package/lib/model/push/presence-study.js +61 -0
  69. package/lib/model/push/sensor-latestx.js +106 -0
  70. package/lib/model/push/timerx.js +63 -0
  71. package/lib/model/push/togglex.js +78 -0
  72. package/lib/model/push/triggerx.js +62 -0
  73. package/lib/model/push/unbind.js +34 -0
  74. package/lib/model/push/water-leak.js +107 -0
  75. package/lib/model/states/diffuser-light-state.js +119 -0
  76. package/lib/model/states/diffuser-spray-state.js +58 -0
  77. package/lib/model/states/garage-door-state.js +71 -0
  78. package/lib/model/states/index.js +38 -0
  79. package/lib/model/states/light-state.js +134 -0
  80. package/lib/model/states/presence-sensor-state.js +239 -0
  81. package/lib/model/states/roller-shutter-state.js +82 -0
  82. package/lib/model/states/spray-state.js +58 -0
  83. package/lib/model/states/thermostat-state.js +297 -0
  84. package/lib/model/states/timer-state.js +192 -0
  85. package/lib/model/states/toggle-state.js +105 -0
  86. package/lib/model/states/trigger-state.js +155 -0
  87. package/lib/subscription.js +587 -0
  88. package/lib/utilities/conversion.js +62 -0
  89. package/lib/utilities/debug.js +165 -0
  90. package/lib/utilities/mqtt.js +152 -0
  91. package/lib/utilities/network.js +53 -0
  92. package/lib/utilities/options.js +64 -0
  93. package/lib/utilities/request-queue.js +161 -0
  94. package/lib/utilities/ssid.js +37 -0
  95. package/lib/utilities/state-changes.js +66 -0
  96. package/lib/utilities/stats.js +687 -0
  97. package/lib/utilities/timer.js +310 -0
  98. package/lib/utilities/trigger.js +286 -0
  99. package/package.json +73 -0
@@ -0,0 +1,1317 @@
1
+ 'use strict';
2
+
3
+ const EventEmitter = require('events');
4
+ const { OnlineStatus } = require('../model/enums');
5
+ const { parsePushNotification } = require('../model/push');
6
+ // DeviceRegistry is nested in MerossManager but exported separately to avoid circular dependencies
7
+ const { MerossManager } = require('../manager');
8
+ const ChannelInfo = require('../model/channel-info');
9
+ const HttpDeviceInfo = require('../model/http/device');
10
+ const {
11
+ CommandTimeoutError,
12
+ UnconnectedError,
13
+ UnknownDeviceTypeError
14
+ } = require('../model/exception');
15
+
16
+ const systemFeature = require('./features/system-feature');
17
+ const toggleFeature = require('./features/toggle-feature');
18
+ const lightFeature = require('./features/light-feature');
19
+ const thermostatFeature = require('./features/thermostat-feature');
20
+ const rollerShutterFeature = require('./features/roller-shutter-feature');
21
+ const garageFeature = require('./features/garage-feature');
22
+ const diffuserFeature = require('./features/diffuser-feature');
23
+ const sprayFeature = require('./features/spray-feature');
24
+ const consumptionFeature = require('./features/consumption-feature');
25
+ const electricityFeature = require('./features/electricity-feature');
26
+ const encryptionFeature = require('./features/encryption-feature');
27
+
28
+ /**
29
+ * Base class for all Meross cloud devices.
30
+ *
31
+ * Manages device communication via MQTT and LAN HTTP, maintains cached state per channel,
32
+ * and composes feature modules to provide device-specific capabilities. All device commands
33
+ * and state updates flow through this class.
34
+ *
35
+ * @extends EventEmitter
36
+ */
37
+ class MerossDevice extends EventEmitter {
38
+ /**
39
+ * Creates a new MerossDevice instance
40
+ * @param {Object} cloudInstance - The MerossCloud manager instance
41
+ * @param {Object|string} devOrUuid - Device information object from the API, or device UUID (string) for subdevices
42
+ * @param {string} [devOrUuid.uuid] - Device UUID (if devOrUuid is object)
43
+ * @param {string} [devOrUuid.devName] - Device name
44
+ * @param {string} [devOrUuid.fmwareVersion] - Firmware version
45
+ * @param {string} [devOrUuid.hdwareVersion] - Hardware version
46
+ * @param {number} [devOrUuid.onlineStatus] - Initial online status (from OnlineStatus enum)
47
+ * @param {string} [devOrUuid.deviceType] - Device type
48
+ * @param {string} [devOrUuid.domain] - MQTT domain
49
+ * @param {string} [domain] - MQTT domain (for subdevices, passed separately)
50
+ * @param {number} [port] - MQTT port (for subdevices, passed separately)
51
+ */
52
+ constructor(cloudInstance, devOrUuid, domain = null, port = null) {
53
+ super();
54
+
55
+ // Accept both object and string to support subdevices initialized with UUID only
56
+ const dev = typeof devOrUuid === 'string' ? { uuid: devOrUuid } : devOrUuid;
57
+
58
+ if (!dev || !dev.uuid) {
59
+ throw new UnknownDeviceTypeError('Device UUID is required');
60
+ }
61
+
62
+ this._initializeCoreProperties(dev, domain, port);
63
+ this._initializeStateCaches();
64
+ this._initializeConnectionState(cloudInstance);
65
+ this._initializeHttpInfo(dev);
66
+ }
67
+
68
+ /**
69
+ * Initializes core device properties.
70
+ *
71
+ * Checks for getter-only properties before assignment to prevent overwriting subdevice
72
+ * getters that compute values dynamically (e.g., uuid delegates to hub UUID).
73
+ *
74
+ * @private
75
+ * @param {Object} dev - Device information object
76
+ * @param {string|null} domain - MQTT domain
77
+ * @param {number|null} port - MQTT port
78
+ */
79
+ _initializeCoreProperties(dev, domain, port) {
80
+ // Subdevices override uuid, name, and onlineStatus as getter-only properties
81
+ // to compute values from parent hub, so we must check before assignment
82
+ if (!MerossDevice._isGetterOnly(this, 'uuid')) {
83
+ this.uuid = dev.uuid;
84
+ }
85
+
86
+ if (!MerossDevice._isGetterOnly(this, 'name')) {
87
+ this.name = dev.devName || dev.uuid || 'unknown';
88
+ }
89
+
90
+ this.deviceType = dev.deviceType;
91
+ this.firmwareVersion = dev.fmwareVersion || 'unknown';
92
+ this.hardwareVersion = dev.hdwareVersion || 'unknown';
93
+ this.domain = domain || dev.domain;
94
+
95
+ if (!MerossDevice._isGetterOnly(this, 'onlineStatus')) {
96
+ this.onlineStatus = dev.onlineStatus !== undefined ? dev.onlineStatus : OnlineStatus.UNKNOWN;
97
+ }
98
+
99
+ this._abilities = null;
100
+ this._macAddress = null;
101
+ this._lanIp = null;
102
+ this._mqttHost = null;
103
+ this._mqttPort = port;
104
+ this._lastFullUpdateTimestamp = null;
105
+ // Lazy initialization avoids registry computation during construction
106
+ this._internalId = null;
107
+ }
108
+
109
+ /**
110
+ * Initializes per-channel state caches.
111
+ *
112
+ * Feature modules populate these caches to avoid redundant API calls when
113
+ * multiple consumers request the same channel state.
114
+ *
115
+ * @private
116
+ */
117
+ _initializeStateCaches() {
118
+ this._toggleStateByChannel = new Map();
119
+ this._thermostatStateByChannel = new Map();
120
+ this._lightStateByChannel = new Map();
121
+ this._diffuserLightStateByChannel = new Map();
122
+ this._diffuserSprayStateByChannel = new Map();
123
+ this._sprayStateByChannel = new Map();
124
+ this._rollerShutterStateByChannel = new Map();
125
+ this._rollerShutterPositionByChannel = new Map();
126
+ this._rollerShutterConfigByChannel = new Map();
127
+ this._garageDoorStateByChannel = new Map();
128
+ this._garageDoorConfigByChannel = new Map();
129
+ this._timerxStateByChannel = new Map();
130
+ this._triggerxStateByChannel = new Map();
131
+ this._presenceSensorStateByChannel = new Map();
132
+ }
133
+
134
+ /**
135
+ * Initializes connection state and message tracking.
136
+ *
137
+ * @private
138
+ * @param {Object} cloudInstance - The MerossCloud manager instance
139
+ */
140
+ _initializeConnectionState(cloudInstance) {
141
+ this.cloudInst = cloudInstance;
142
+ this.deviceConnected = false;
143
+ this.clientResponseTopic = null;
144
+ this.waitingMessageIds = {};
145
+ // Track push notification activity to detect when device is actively sending updates
146
+ this._pushNotificationActive = false;
147
+ this._lastPushNotificationTime = null;
148
+ this._pushInactivityTimer = null;
149
+ }
150
+
151
+ /**
152
+ * Initializes HTTP device info and channels.
153
+ *
154
+ * Only creates HttpDeviceInfo when a full device object is available (not just UUID),
155
+ * as subdevices may be initialized with UUID only and lack HTTP API metadata.
156
+ *
157
+ * @private
158
+ * @param {Object} dev - Device information object
159
+ */
160
+ _initializeHttpInfo(dev) {
161
+ this._cachedHttpInfo = null;
162
+ this._channels = [];
163
+
164
+ if (dev && dev.uuid && typeof dev === 'object' && dev.deviceType !== undefined) {
165
+ try {
166
+ this._cachedHttpInfo = HttpDeviceInfo.fromDict(dev);
167
+ this._channels = MerossDevice._parseChannels(dev.channels);
168
+ } catch (error) {
169
+ // Continue without HttpDeviceInfo if parsing fails
170
+ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Checks if a property is getter-only (has getter but no setter).
176
+ *
177
+ * Traverses the prototype chain to detect getter-only properties that subdevices
178
+ * use to compute values dynamically (e.g., uuid from hub).
179
+ *
180
+ * @static
181
+ * @private
182
+ * @param {Object} instance - Instance to check
183
+ * @param {string} propName - Property name to check
184
+ * @returns {boolean} True if property is getter-only, false otherwise
185
+ */
186
+ static _isGetterOnly(instance, propName) {
187
+ let proto = Object.getPrototypeOf(instance);
188
+ while (proto && proto !== Object.prototype) {
189
+ const desc = Object.getOwnPropertyDescriptor(proto, propName);
190
+ if (desc) {
191
+ return desc.get && !desc.set;
192
+ }
193
+ proto = Object.getPrototypeOf(proto);
194
+ }
195
+ return false;
196
+ }
197
+
198
+
199
+ /**
200
+ * Checks if the device is currently online
201
+ * @returns {boolean} True if device status is ONLINE, false otherwise
202
+ */
203
+ get isOnline() {
204
+ return this.onlineStatus === OnlineStatus.ONLINE;
205
+ }
206
+
207
+ /**
208
+ * Updates the device's abilities dictionary.
209
+ *
210
+ * Abilities determine which features and namespaces the device supports. The encryption
211
+ * feature must be notified because key derivation depends on ability flags.
212
+ *
213
+ * @param {Object} abilities - Device abilities object
214
+ */
215
+ updateAbilities(abilities) {
216
+ this._abilities = abilities;
217
+ if (typeof this._updateAbilitiesWithEncryption === 'function') {
218
+ this._updateAbilitiesWithEncryption(abilities);
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Updates the device's MAC address.
224
+ *
225
+ * MAC address is used by the encryption feature for key derivation in some firmware versions.
226
+ *
227
+ * @param {string} mac - MAC address string
228
+ */
229
+ updateMacAddress(mac) {
230
+ this._macAddress = mac;
231
+ if (typeof this._updateMacAddressWithEncryption === 'function') {
232
+ this._updateMacAddressWithEncryption(mac);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Gets the device's MAC address
238
+ * @returns {string|null} MAC address or null if not available
239
+ */
240
+ get macAddress() {
241
+ return this._macAddress;
242
+ }
243
+
244
+ /**
245
+ * Gets the device's local network IP address
246
+ * @returns {string|null} LAN IP address or null if not available
247
+ */
248
+ get lanIp() {
249
+ return this._lanIp;
250
+ }
251
+
252
+ /**
253
+ * Gets the MQTT broker hostname
254
+ * @returns {string|null} MQTT hostname or null if not available
255
+ */
256
+ get mqttHost() {
257
+ return this._mqttHost;
258
+ }
259
+
260
+ /**
261
+ * Gets the MQTT broker port
262
+ * @returns {number|null} MQTT port or null if not available
263
+ */
264
+ get mqttPort() {
265
+ return this._mqttPort;
266
+ }
267
+
268
+ /**
269
+ * Gets the device's abilities dictionary
270
+ * @returns {Object|null} Abilities object or null if not available
271
+ */
272
+ get abilities() {
273
+ return this._abilities;
274
+ }
275
+
276
+ /**
277
+ * Gets the timestamp of the last full state update
278
+ * @returns {number|null} Timestamp in milliseconds or null if never updated
279
+ */
280
+ get lastFullUpdateTimestamp() {
281
+ return this._lastFullUpdateTimestamp;
282
+ }
283
+
284
+ /**
285
+ * Gets the internal ID used for device registry.
286
+ *
287
+ * Generates and caches the ID on first access to avoid repeated computation during
288
+ * device lookup operations.
289
+ *
290
+ * @returns {string} Internal ID string
291
+ * @throws {Error} If device UUID is missing
292
+ */
293
+ get internalId() {
294
+ if (this._internalId) {
295
+ return this._internalId;
296
+ }
297
+
298
+ if (!this.uuid) {
299
+ throw new UnknownDeviceTypeError('Cannot generate internal ID: device missing UUID');
300
+ }
301
+
302
+ this._internalId = MerossManager.DeviceRegistry.generateInternalId(this.uuid);
303
+ return this._internalId;
304
+ }
305
+
306
+ /**
307
+ * Gets the list of channels exposed by this device
308
+ *
309
+ * Multi-channel devices might expose a master switch at index 0.
310
+ * Channels are parsed from the HTTP device info during device initialization.
311
+ *
312
+ * @returns {Array<ChannelInfo>} Array of ChannelInfo objects
313
+ */
314
+ get channels() {
315
+ return this._channels;
316
+ }
317
+
318
+ /**
319
+ * Gets the cached HTTP device info
320
+ *
321
+ * Returns the original device information object from the HTTP API,
322
+ * or null if not available (e.g., for subdevices created without HTTP info).
323
+ *
324
+ * @returns {HttpDeviceInfo|null} Cached HTTP device info or null
325
+ */
326
+ get cachedHttpInfo() {
327
+ return this._cachedHttpInfo;
328
+ }
329
+
330
+ /**
331
+ * Validates that the device state has been refreshed.
332
+ *
333
+ * Logs a warning if state has not been refreshed to help developers catch cases
334
+ * where cached state may be stale or missing.
335
+ *
336
+ * @returns {boolean} True if state has been refreshed, false otherwise
337
+ */
338
+ validateState() {
339
+ const updateDone = this._lastFullUpdateTimestamp !== null;
340
+ if (!updateDone) {
341
+ const deviceName = this.name || this.uuid || 'unknown device';
342
+ const logger = this.cloudInst?.options?.logger || console.error;
343
+ logger(`Please invoke refreshState() for this device (${deviceName}) before accessing its state. Failure to do so may result in inconsistent state.`);
344
+ }
345
+ return updateDone;
346
+ }
347
+
348
+ /**
349
+ * Refreshes the device state by fetching System.All data.
350
+ *
351
+ * System.All provides complete device state in a single request, avoiding multiple
352
+ * round trips. Emits a 'stateRefreshed' event with the updated state.
353
+ *
354
+ * @returns {Promise<void>} Promise that resolves when state is refreshed
355
+ * @throws {Error} If device does not support refreshState()
356
+ */
357
+ async refreshState() {
358
+ if (typeof this.getSystemAllData === 'function') {
359
+ await this.getSystemAllData();
360
+
361
+ this.emit('stateRefreshed', {
362
+ timestamp: this._lastFullUpdateTimestamp || Date.now(),
363
+ state: this.getUnifiedState()
364
+ });
365
+ } else {
366
+ throw new UnknownDeviceTypeError('Device does not support refreshState()', this.deviceType);
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Gets a unified snapshot of all current device state.
372
+ *
373
+ * Aggregates all cached feature states into a single object for subscription managers
374
+ * and state distribution systems.
375
+ *
376
+ * @returns {Object} Unified state object with all available features
377
+ */
378
+ getUnifiedState() {
379
+ const state = {
380
+ online: this.onlineStatus,
381
+ timestamp: this._lastFullUpdateTimestamp || Date.now()
382
+ };
383
+
384
+ this._collectFeatureStates(state);
385
+
386
+ return state;
387
+ }
388
+
389
+ /**
390
+ * Collects all feature states into the unified state object.
391
+ *
392
+ * Uses a configuration-driven approach to iterate over feature getters, reducing
393
+ * code duplication when adding new features.
394
+ *
395
+ * @private
396
+ * @param {Object} state - State object to populate
397
+ */
398
+ _collectFeatureStates(state) {
399
+ const channels = this.channels || [{ index: 0 }];
400
+
401
+ // Toggle uses Map-based storage, handled separately
402
+ this._collectToggleState(state);
403
+
404
+ const channelFeatures = [
405
+ { key: 'electricity', getter: 'getCachedElectricity' },
406
+ { key: 'consumption', getter: 'getCachedConsumption' },
407
+ { key: 'light', getter: 'getCachedLightState' },
408
+ { key: 'thermostat', getter: 'getCachedThermostatState' },
409
+ { key: 'rollerShutter', getter: 'getCachedRollerShutterState' },
410
+ { key: 'garageDoor', getter: 'getCachedGarageDoorState' },
411
+ { key: 'spray', getter: 'getCachedSprayState' }
412
+ ];
413
+
414
+ for (const feature of channelFeatures) {
415
+ this._collectChannelFeature(state, feature.key, feature.getter, channels);
416
+ }
417
+
418
+ // Features requiring custom transforms for unified state format
419
+ this._collectPresenceSensorState(state, channels);
420
+ this._collectDiffuserLightState(state, channels);
421
+ this._collectDiffuserSprayState(state, channels);
422
+ }
423
+
424
+ /**
425
+ * Collects toggle state.
426
+ *
427
+ * Toggle state uses Map-based storage (getAllCachedToggleStates) rather than
428
+ * per-channel getters, so it's handled separately.
429
+ *
430
+ * @private
431
+ * @param {Object} state - State object to populate
432
+ */
433
+ _collectToggleState(state) {
434
+ if (typeof this.getAllCachedToggleStates !== 'function') {
435
+ return;
436
+ }
437
+
438
+ const toggleStates = this.getAllCachedToggleStates();
439
+ if (toggleStates && toggleStates.size > 0) {
440
+ state.toggle = {};
441
+ toggleStates.forEach((toggleState, channel) => {
442
+ state.toggle[channel] = toggleState.isOn;
443
+ });
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Collects a channel-based feature state.
449
+ *
450
+ * Iterates over channels and calls the feature's cached getter method for each,
451
+ * aggregating results into the unified state object.
452
+ *
453
+ * @private
454
+ * @param {Object} state - State object to populate
455
+ * @param {string} key - Feature key in state object
456
+ * @param {string} getterMethod - Name of getter method to call
457
+ * @param {Array} channels - Array of channel objects
458
+ */
459
+ _collectChannelFeature(state, key, getterMethod, channels) {
460
+ if (typeof this[getterMethod] !== 'function') {
461
+ return;
462
+ }
463
+
464
+ const featureState = {};
465
+ for (const ch of channels) {
466
+ const cached = this[getterMethod](ch.index);
467
+ if (cached) {
468
+ featureState[ch.index] = cached;
469
+ }
470
+ }
471
+
472
+ if (Object.keys(featureState).length > 0) {
473
+ state[key] = featureState;
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Collects presence sensor state with custom transform.
479
+ *
480
+ * Presence sensors expose additional fields (distance, light, timestamps) that
481
+ * require custom mapping to the unified state format.
482
+ *
483
+ * @private
484
+ * @param {Object} state - State object to populate
485
+ * @param {Array} channels - Array of channel objects
486
+ */
487
+ _collectPresenceSensorState(state, channels) {
488
+ if (typeof this.getCachedPresenceSensorState !== 'function') {
489
+ return;
490
+ }
491
+
492
+ const featureState = {};
493
+ for (const ch of channels) {
494
+ const cached = this.getCachedPresenceSensorState(ch.index);
495
+ if (cached) {
496
+ featureState[ch.index] = {
497
+ isPresent: cached.isPresent,
498
+ distance: cached.distanceRaw,
499
+ distanceMeters: cached.distanceMeters,
500
+ light: cached.lightLux,
501
+ presenceValue: cached.presenceValue,
502
+ presenceState: cached.presenceState,
503
+ presenceTimestamp: cached.presenceTimestamp,
504
+ lightTimestamp: cached.lightTimestamp,
505
+ presenceTimes: cached.presenceTimes
506
+ };
507
+ }
508
+ }
509
+
510
+ if (Object.keys(featureState).length > 0) {
511
+ state.presence = featureState;
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Collects diffuser light state with custom transform.
517
+ *
518
+ * Diffuser light state includes RGB tuples and luminance values that need
519
+ * custom formatting for the unified state format.
520
+ *
521
+ * @private
522
+ * @param {Object} state - State object to populate
523
+ * @param {Array} channels - Array of channel objects
524
+ */
525
+ _collectDiffuserLightState(state, channels) {
526
+ if (typeof this.getCachedDiffuserLightState !== 'function') {
527
+ return;
528
+ }
529
+
530
+ const featureState = {};
531
+ for (const ch of channels) {
532
+ const cached = this.getCachedDiffuserLightState(ch.index);
533
+ if (cached) {
534
+ featureState[ch.index] = {
535
+ isOn: cached.isOn,
536
+ mode: cached.mode,
537
+ rgb: cached.rgbInt,
538
+ rgbTuple: cached.rgbTuple,
539
+ luminance: cached.luminance
540
+ };
541
+ }
542
+ }
543
+
544
+ if (Object.keys(featureState).length > 0) {
545
+ state.diffuserLight = featureState;
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Collects diffuser spray state with custom transform.
551
+ *
552
+ * Diffuser spray state requires custom mapping to extract mode information
553
+ * for the unified state format.
554
+ *
555
+ * @private
556
+ * @param {Object} state - State object to populate
557
+ * @param {Array} channels - Array of channel objects
558
+ */
559
+ _collectDiffuserSprayState(state, channels) {
560
+ if (typeof this.getCachedDiffuserSprayState !== 'function') {
561
+ return;
562
+ }
563
+
564
+ const featureState = {};
565
+ for (const ch of channels) {
566
+ const cached = this.getCachedDiffuserSprayState(ch.index);
567
+ if (cached) {
568
+ featureState[ch.index] = {
569
+ mode: cached.mode
570
+ };
571
+ }
572
+ }
573
+
574
+ if (Object.keys(featureState).length > 0) {
575
+ state.diffuserSpray = featureState;
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Tracks push notification activity.
581
+ *
582
+ * Marks device as actively sending push notifications and resets the inactivity
583
+ * timer. Used to detect when devices are actively updating state.
584
+ *
585
+ * @private
586
+ */
587
+ _pushNotificationReceived() {
588
+ this._lastPushNotificationTime = Date.now();
589
+ this._pushNotificationActive = true;
590
+
591
+ clearTimeout(this._pushInactivityTimer);
592
+ this._pushInactivityTimer = setTimeout(() => {
593
+ this._pushNotificationActive = false;
594
+ }, 60000);
595
+ }
596
+
597
+ /**
598
+ * Checks if push notifications are currently active.
599
+ *
600
+ * Returns true if push notifications were received within the last 60 seconds,
601
+ * indicating the device is actively updating state.
602
+ *
603
+ * @returns {boolean} True if push notifications received recently
604
+ */
605
+ isPushNotificationActive() {
606
+ if (!this._pushNotificationActive) {
607
+ return false;
608
+ }
609
+
610
+ if (!this._lastPushNotificationTime) {
611
+ return false;
612
+ }
613
+
614
+ const timeSinceLastPush = Date.now() - this._lastPushNotificationTime;
615
+ return timeSinceLastPush < 60000;
616
+ }
617
+
618
+ /**
619
+ * Handles incoming MQTT messages from the device.
620
+ *
621
+ * Routes messages by type: responses resolve pending promises, push notifications
622
+ * update cached state, and System.All updates refresh device metadata. All messages
623
+ * emit rawData events for logging and debugging.
624
+ *
625
+ * @param {Object} message - The message object
626
+ * @param {Object} message.header - Message header
627
+ * @param {string} message.header.messageId - Message ID
628
+ * @param {string} message.header.method - Method (GET, SET, PUSH)
629
+ * @param {string} message.header.namespace - Namespace
630
+ * @param {string} [message.header.from] - Source device UUID
631
+ * @param {Object} [message.payload] - Message payload
632
+ */
633
+ handleMessage(message) {
634
+ if (!this._validateMessage(message)) {
635
+ return;
636
+ }
637
+
638
+ // System.All can appear in any message type, extract it first
639
+ if (message.payload?.all) {
640
+ this._handleSystemAllUpdate(message.payload);
641
+ }
642
+
643
+ if (this.waitingMessageIds[message.header.messageId]) {
644
+ this._handleResponseMessage(message);
645
+ } else if (message.header.method === 'PUSH') {
646
+ this._handlePushNotification(message);
647
+ }
648
+
649
+ this.emit('rawData', message);
650
+ }
651
+
652
+ /**
653
+ * Validates incoming message before processing.
654
+ *
655
+ * Ensures device is connected, message has required header, and source UUID
656
+ * matches this device (if provided).
657
+ *
658
+ * @private
659
+ * @param {Object} message - The message object
660
+ * @returns {boolean} True if message is valid, false otherwise
661
+ */
662
+ _validateMessage(message) {
663
+ if (!this.deviceConnected) {
664
+ return false;
665
+ }
666
+ if (!message?.header) {
667
+ return false;
668
+ }
669
+ if (message.header.from && !message.header.from.includes(this.uuid)) {
670
+ return false;
671
+ }
672
+ return true;
673
+ }
674
+
675
+ /**
676
+ * Handles System.All update responses.
677
+ *
678
+ * Extracts device metadata (abilities, MAC address), network configuration (LAN IP,
679
+ * MQTT host/port), and routes digest data to feature modules for state updates.
680
+ *
681
+ * @private
682
+ * @param {Object} payload - Message payload containing System.All data
683
+ */
684
+ _handleSystemAllUpdate(payload) {
685
+ if (payload.ability) {
686
+ this.updateAbilities(payload.ability);
687
+ }
688
+
689
+ const system = payload.all?.system;
690
+ if (!system) {
691
+ return;
692
+ }
693
+
694
+ if (system.hardware?.macAddress) {
695
+ this.updateMacAddress(system.hardware.macAddress);
696
+ }
697
+
698
+ const firmware = system.firmware;
699
+ if (firmware) {
700
+ if (firmware.innerIp) {
701
+ this._lanIp = firmware.innerIp;
702
+ }
703
+ if (firmware.server) {
704
+ this._mqttHost = firmware.server;
705
+ }
706
+ if (firmware.port) {
707
+ this._mqttPort = firmware.port;
708
+ }
709
+ this._lastFullUpdateTimestamp = Date.now();
710
+ }
711
+
712
+ if (system.online) {
713
+ this._updateOnlineStatus(system.online.status);
714
+ }
715
+
716
+ if (payload.all.digest) {
717
+ this._routeDigestToFeatures(payload.all.digest);
718
+ }
719
+ }
720
+
721
+ /**
722
+ * Routes digest data from System.All to appropriate feature modules.
723
+ *
724
+ * Digest contains feature state data that needs to be distributed to feature modules
725
+ * for cache updates. Handles both simple (direct key mapping) and nested structures.
726
+ *
727
+ * @private
728
+ * @param {Object} digest - Digest object containing feature state data
729
+ */
730
+ _routeDigestToFeatures(digest) {
731
+ if (!digest) {
732
+ return;
733
+ }
734
+
735
+ this._routeSimpleDigestFeatures(digest);
736
+ this._routeNestedDigestFeatures(digest);
737
+ }
738
+
739
+ /**
740
+ * Routes simple digest features with direct key-to-handler mapping.
741
+ *
742
+ * @private
743
+ * @param {Object} digest - Digest object
744
+ */
745
+ _routeSimpleDigestFeatures(digest) {
746
+ const simpleRoutes = [
747
+ { key: 'togglex', handler: '_updateToggleState' },
748
+ { key: 'light', handler: '_updateLightState' },
749
+ { key: 'spray', handler: '_updateSprayState' },
750
+ { key: 'timerx', handler: '_updateTimerXState' }
751
+ ];
752
+
753
+ for (const route of simpleRoutes) {
754
+ if (digest[route.key] && typeof this[route.handler] === 'function') {
755
+ this[route.handler](digest[route.key], 'poll');
756
+ }
757
+ }
758
+ }
759
+
760
+ /**
761
+ * Routes nested digest features with complex nested structures.
762
+ *
763
+ * @private
764
+ * @param {Object} digest - Digest object
765
+ */
766
+ _routeNestedDigestFeatures(digest) {
767
+ if (digest.thermostat) {
768
+ this._routeThermostatDigest(digest.thermostat);
769
+ }
770
+
771
+ if (digest.diffuser) {
772
+ this._routeDiffuserDigest(digest.diffuser);
773
+ }
774
+
775
+ if (digest.rollerShutter) {
776
+ this._routeRollerShutterDigest(digest.rollerShutter);
777
+ }
778
+
779
+ if (digest.garageDoor) {
780
+ this._routeGarageDoorDigest(digest.garageDoor);
781
+ }
782
+ }
783
+
784
+ /**
785
+ * Routes thermostat digest data to feature handlers.
786
+ *
787
+ * @private
788
+ * @param {Object} thermostat - Thermostat digest data
789
+ */
790
+ _routeThermostatDigest(thermostat) {
791
+ if (thermostat.mode && typeof this._updateThermostatMode === 'function') {
792
+ this._updateThermostatMode(thermostat.mode, 'poll');
793
+ }
794
+ if (thermostat.modeB && typeof this._updateThermostatModeB === 'function') {
795
+ this._updateThermostatModeB(thermostat.modeB, 'poll');
796
+ }
797
+ }
798
+
799
+ /**
800
+ * Routes diffuser digest data to feature handlers.
801
+ *
802
+ * @private
803
+ * @param {Object} diffuser - Diffuser digest data
804
+ */
805
+ _routeDiffuserDigest(diffuser) {
806
+ if (diffuser.light && typeof this._updateDiffuserLightState === 'function') {
807
+ this._updateDiffuserLightState(diffuser.light, 'poll');
808
+ }
809
+ if (diffuser.spray && typeof this._updateDiffuserSprayState === 'function') {
810
+ this._updateDiffuserSprayState(diffuser.spray, 'poll');
811
+ }
812
+ }
813
+
814
+ /**
815
+ * Routes roller shutter digest data to feature handlers.
816
+ *
817
+ * @private
818
+ * @param {Object} rollerShutter - Roller shutter digest data
819
+ */
820
+ _routeRollerShutterDigest(rollerShutter) {
821
+ if (rollerShutter.state && typeof this._updateRollerShutterState === 'function') {
822
+ this._updateRollerShutterState(rollerShutter.state, 'poll');
823
+ }
824
+ if (rollerShutter.position && typeof this._updateRollerShutterPosition === 'function') {
825
+ this._updateRollerShutterPosition(rollerShutter.position, 'poll');
826
+ }
827
+ }
828
+
829
+ /**
830
+ * Routes garage door digest data to feature handlers.
831
+ *
832
+ * @private
833
+ * @param {Object|Array} garageDoor - Garage door digest data
834
+ */
835
+ _routeGarageDoorDigest(garageDoor) {
836
+ if (typeof this._updateGarageDoorState === 'function' && Array.isArray(garageDoor)) {
837
+ this._updateGarageDoorState(garageDoor, 'poll');
838
+ }
839
+ }
840
+
841
+ /**
842
+ * Handles response messages by resolving pending promises.
843
+ *
844
+ * Matches response messageId to waiting promises and resolves them with the payload.
845
+ * Clears timeout to prevent duplicate rejections.
846
+ *
847
+ * @private
848
+ * @param {Object} message - The response message object
849
+ */
850
+ _handleResponseMessage(message) {
851
+ const messageId = message.header.messageId;
852
+ const pending = this.waitingMessageIds[messageId];
853
+
854
+ if (!pending) {
855
+ return;
856
+ }
857
+
858
+ if (pending.timeout) {
859
+ clearTimeout(pending.timeout);
860
+ }
861
+
862
+ pending.resolve(message.payload || message);
863
+ delete this.waitingMessageIds[messageId];
864
+ }
865
+
866
+ /**
867
+ * Handles push notification messages.
868
+ *
869
+ * Tracks notification activity, parses into typed notification objects, routes to
870
+ * feature modules, and emits events for both new and legacy consumers.
871
+ *
872
+ * @private
873
+ * @param {Object} message - The push notification message object
874
+ */
875
+ _handlePushNotification(message) {
876
+ this._pushNotificationReceived();
877
+
878
+ const namespace = message.header?.namespace || '';
879
+ const payload = message.payload || message;
880
+
881
+ const notification = parsePushNotification(namespace, payload, this.uuid);
882
+ if (notification) {
883
+ this.emit('pushNotification', notification);
884
+ }
885
+
886
+ this._routePushNotificationToFeatures(namespace, payload);
887
+
888
+ // Feature modules can override for custom routing (e.g., hub subdevices)
889
+ if (typeof this.handlePushNotification === 'function') {
890
+ this.handlePushNotification(namespace, payload);
891
+ }
892
+
893
+ this.emit('data', namespace, payload);
894
+ }
895
+
896
+ /**
897
+ * Routes push notifications to feature modules using registry pattern.
898
+ *
899
+ * Maps namespace strings to handler configurations, allowing new namespaces to be
900
+ * added without modifying routing logic.
901
+ *
902
+ * @private
903
+ * @param {string} namespace - Message namespace
904
+ * @param {Object} payload - Message payload
905
+ */
906
+ _routePushNotificationToFeatures(namespace, payload) {
907
+ const namespaceRoutes = [
908
+ {
909
+ namespace: 'Appliance.Control.ToggleX',
910
+ check: (p) => p?.togglex,
911
+ handler: '_updateToggleState',
912
+ getData: (p) => p.togglex,
913
+ source: 'push'
914
+ },
915
+ {
916
+ namespace: 'Appliance.Control.Toggle',
917
+ check: (p) => p?.toggle,
918
+ handler: '_updateToggleState',
919
+ getData: (p) => {
920
+ // Legacy namespace lacks channel field, default to 0 for compatibility
921
+ const toggleData = p.toggle;
922
+ if (toggleData.channel === undefined) {
923
+ toggleData.channel = 0;
924
+ }
925
+ return toggleData;
926
+ },
927
+ source: 'push'
928
+ },
929
+ {
930
+ namespace: 'Appliance.Control.Thermostat.Mode',
931
+ check: (p) => p?.mode,
932
+ handler: '_updateThermostatMode',
933
+ getData: (p) => p.mode,
934
+ source: 'push'
935
+ },
936
+ {
937
+ namespace: 'Appliance.Control.Thermostat.ModeB',
938
+ check: (p) => p?.modeB,
939
+ handler: '_updateThermostatModeB',
940
+ getData: (p) => p.modeB,
941
+ source: 'push'
942
+ },
943
+ {
944
+ namespace: 'Appliance.Control.Light',
945
+ check: (p) => p?.light,
946
+ handler: '_updateLightState',
947
+ getData: (p) => p.light,
948
+ source: 'push'
949
+ },
950
+ {
951
+ namespace: 'Appliance.Control.Diffuser.Light',
952
+ check: (p) => p?.light,
953
+ handler: '_updateDiffuserLightState',
954
+ getData: (p) => p.light,
955
+ source: 'push'
956
+ },
957
+ {
958
+ namespace: 'Appliance.Control.Diffuser.Spray',
959
+ check: (p) => p?.spray,
960
+ handler: '_updateDiffuserSprayState',
961
+ getData: (p) => p.spray,
962
+ source: 'push'
963
+ },
964
+ {
965
+ namespace: 'Appliance.Control.Spray',
966
+ check: (p) => p?.spray,
967
+ handler: '_updateSprayState',
968
+ getData: (p) => p.spray,
969
+ source: 'push'
970
+ },
971
+ {
972
+ namespace: 'Appliance.RollerShutter.State',
973
+ check: (p) => p?.state,
974
+ handler: '_updateRollerShutterState',
975
+ getData: (p) => p.state,
976
+ source: 'push'
977
+ },
978
+ {
979
+ namespace: 'Appliance.RollerShutter.Position',
980
+ check: (p) => p?.position,
981
+ handler: '_updateRollerShutterPosition',
982
+ getData: (p) => p.position,
983
+ source: 'push'
984
+ },
985
+ {
986
+ namespace: 'Appliance.GarageDoor.State',
987
+ check: (p) => p?.state,
988
+ handler: '_updateGarageDoorState',
989
+ getData: (p) => p.state,
990
+ source: 'push'
991
+ },
992
+ {
993
+ namespace: 'Appliance.GarageDoor.MultipleConfig',
994
+ check: (p) => p?.config,
995
+ handler: '_updateGarageDoorConfig',
996
+ getData: (p) => p.config,
997
+ source: null
998
+ },
999
+ {
1000
+ namespace: 'Appliance.System.Online',
1001
+ check: (p) => p?.online,
1002
+ handler: null,
1003
+ getData: (p) => p.online.status,
1004
+ source: null
1005
+ },
1006
+ {
1007
+ namespace: 'Appliance.Control.Alarm',
1008
+ check: (p) => p?.alarm,
1009
+ handler: '_updateAlarmEvents',
1010
+ getData: (p) => p.alarm,
1011
+ source: 'push'
1012
+ },
1013
+ {
1014
+ namespace: 'Appliance.Control.TimerX',
1015
+ check: (p) => p?.timerx,
1016
+ handler: '_updateTimerXState',
1017
+ getData: (p) => p.timerx,
1018
+ source: 'push'
1019
+ },
1020
+ {
1021
+ namespace: 'Appliance.Control.TriggerX',
1022
+ check: (p) => p?.triggerx,
1023
+ handler: '_updateTriggerXState',
1024
+ getData: (p) => p.triggerx,
1025
+ source: 'push'
1026
+ },
1027
+ {
1028
+ namespace: 'Appliance.Control.Sensor.LatestX',
1029
+ check: (p) => p?.latest,
1030
+ handler: '_updatePresenceState',
1031
+ getData: (p) => p.latest,
1032
+ source: 'push'
1033
+ }
1034
+ ];
1035
+
1036
+ const route = namespaceRoutes.find(r => r.namespace === namespace);
1037
+ if (!route) {
1038
+ return;
1039
+ }
1040
+
1041
+ if (!route.check(payload)) {
1042
+ return;
1043
+ }
1044
+
1045
+ // System.Online is handled directly, not through feature handlers
1046
+ if (namespace === 'Appliance.System.Online') {
1047
+ const onlineStatus = route.getData(payload);
1048
+ this._updateOnlineStatus(onlineStatus);
1049
+ return;
1050
+ }
1051
+
1052
+ if (route.handler && typeof this[route.handler] === 'function') {
1053
+ const data = route.getData(payload);
1054
+ if (route.source === 'push') {
1055
+ this[route.handler](data, 'push');
1056
+ } else {
1057
+ this[route.handler](data);
1058
+ }
1059
+ }
1060
+ }
1061
+
1062
+ /**
1063
+ * Connects the device and automatically fetches System.All data.
1064
+ *
1065
+ * Emits 'connected' event immediately to allow test code to proceed. Delays initial
1066
+ * state fetch to allow MQTT connection to stabilize before making requests.
1067
+ *
1068
+ */
1069
+ connect() {
1070
+ this.deviceConnected = true;
1071
+ setImmediate(() => {
1072
+ this.emit('connected');
1073
+ });
1074
+ setTimeout(() => {
1075
+ if (typeof this.getSystemAllData === 'function') {
1076
+ this.getSystemAllData().catch(_err => {
1077
+ // Ignore initial fetch failures as device may still be initializing
1078
+ });
1079
+ }
1080
+ }, 500);
1081
+ }
1082
+
1083
+ /**
1084
+ * Disconnects the device
1085
+ */
1086
+ disconnect() {
1087
+ this.deviceConnected = false;
1088
+ }
1089
+
1090
+ /**
1091
+ * Sets a known local IP address for LAN HTTP communication
1092
+ * @param {string} ip - Local IP address
1093
+ */
1094
+ setKnownLocalIp(ip) {
1095
+ this._lanIp = ip;
1096
+ }
1097
+
1098
+ /**
1099
+ * Removes the known local IP address
1100
+ */
1101
+ removeKnownLocalIp() {
1102
+ this._lanIp = null;
1103
+ }
1104
+
1105
+ /**
1106
+ * Publishes a message to the device via MQTT or LAN HTTP.
1107
+ *
1108
+ * Prefers LAN HTTP for faster local communication, falls back to MQTT if LAN IP
1109
+ * is unavailable. Tracks pending messages by messageId to match responses.
1110
+ *
1111
+ * @param {string} method - Message method (GET, SET)
1112
+ * @param {string} namespace - Message namespace
1113
+ * @param {Object} payload - Message payload
1114
+ * @param {number|null} [transportMode=null] - Transport mode from TransportMode enum
1115
+ * @returns {Promise<Object>} Promise that resolves with the response payload
1116
+ * @throws {Error} If device has no data connection available
1117
+ * @throws {Error} If message times out
1118
+ */
1119
+ async publishMessage(method, namespace, payload, transportMode = null) {
1120
+ const data = this.cloudInst.encodeMessage(method, namespace, payload, this.uuid);
1121
+ const { messageId } = data.header;
1122
+
1123
+ return new Promise(async (resolve, reject) => {
1124
+ try {
1125
+ if (!this.deviceConnected) {
1126
+ return reject(new UnconnectedError('Device is not connected', this.uuid));
1127
+ }
1128
+
1129
+ const res = await this.cloudInst.requestMessage(this, this._lanIp, data, transportMode);
1130
+ if (!res) {
1131
+ return reject(new UnconnectedError('Device has no data connection available', this.uuid));
1132
+ }
1133
+
1134
+ const timeoutDuration = this.cloudInst.timeout;
1135
+ this.waitingMessageIds[messageId] = {
1136
+ resolve,
1137
+ reject,
1138
+ timeout: setTimeout(() => {
1139
+ if (this.waitingMessageIds[messageId]) {
1140
+ const commandInfo = {
1141
+ method,
1142
+ namespace,
1143
+ messageId
1144
+ };
1145
+ this.waitingMessageIds[messageId].reject(
1146
+ new CommandTimeoutError(
1147
+ `Command timed out after ${timeoutDuration}ms`,
1148
+ this.uuid,
1149
+ timeoutDuration,
1150
+ commandInfo
1151
+ )
1152
+ );
1153
+ delete this.waitingMessageIds[messageId];
1154
+ }
1155
+ }, timeoutDuration)
1156
+ };
1157
+
1158
+ this.emit('rawSendData', data);
1159
+ } catch (error) {
1160
+ reject(error);
1161
+ }
1162
+ });
1163
+ }
1164
+
1165
+ /**
1166
+ * Updates the device's online status and emits events if changed.
1167
+ *
1168
+ * Emits both legacy 'data' events and new 'stateChange' events for unified state
1169
+ * handling by subscription managers.
1170
+ *
1171
+ * @private
1172
+ * @param {number} newStatus - New online status from OnlineStatus enum
1173
+ */
1174
+ _updateOnlineStatus(newStatus) {
1175
+ const oldStatus = this.onlineStatus;
1176
+ if (oldStatus !== newStatus) {
1177
+ this.onlineStatus = newStatus;
1178
+ this.emit('onlineStatusChange', newStatus, oldStatus);
1179
+ this.emit('data', 'Appliance.System.Online', { online: { status: newStatus } });
1180
+
1181
+ this.emit('stateChange', {
1182
+ type: 'online',
1183
+ channel: 0,
1184
+ value: newStatus,
1185
+ oldValue: oldStatus,
1186
+ source: 'push',
1187
+ timestamp: Date.now()
1188
+ });
1189
+ }
1190
+ }
1191
+
1192
+ /**
1193
+ * Parses channel data array into ChannelInfo objects
1194
+ *
1195
+ * Converts raw channel data from the HTTP API into structured ChannelInfo objects.
1196
+ * Each channel gets an index (0 for master, 1-n for sub-channels), name, type, and master flag.
1197
+ *
1198
+ * @param {Array<Object>|null} channelData - Raw channel data array from HTTP API
1199
+ * @returns {Array<ChannelInfo>} Array of ChannelInfo objects
1200
+ * @static
1201
+ */
1202
+ static _parseChannels(channelData) {
1203
+ const res = [];
1204
+ if (!channelData || !Array.isArray(channelData)) {
1205
+ return res;
1206
+ }
1207
+
1208
+ for (let i = 0; i < channelData.length; i++) {
1209
+ const val = channelData[i];
1210
+ const name = val && val.devName ? val.devName : (i === 0 ? 'Main channel' : null);
1211
+ const type = val && val.type ? val.type : null;
1212
+ const master = i === 0;
1213
+ res.push(new ChannelInfo(i, name, type, master));
1214
+ }
1215
+
1216
+ return res;
1217
+ }
1218
+
1219
+ /**
1220
+ * Looks up a channel by channel ID or channel name
1221
+ *
1222
+ * Searches for a channel matching the provided index (number) or name (string).
1223
+ * Returns the matching ChannelInfo object if exactly one match is found.
1224
+ *
1225
+ * @param {number|string} channelIdOrName - Channel index (number) or channel name (string)
1226
+ * @returns {ChannelInfo} Matching ChannelInfo object
1227
+ * @throws {Error} If channel is not found or multiple channels match
1228
+ * @example
1229
+ * const channel = device.lookupChannel(0); // Find by index
1230
+ * const channel2 = device.lookupChannel('Main channel'); // Find by name
1231
+ */
1232
+ lookupChannel(channelIdOrName) {
1233
+ let res = [];
1234
+ if (typeof channelIdOrName === 'string') {
1235
+ res = this._channels.filter(c => c.name === channelIdOrName);
1236
+ } else if (typeof channelIdOrName === 'number') {
1237
+ res = this._channels.filter(c => c.index === channelIdOrName);
1238
+ }
1239
+
1240
+ if (res.length === 1) {
1241
+ return res[0];
1242
+ }
1243
+
1244
+ throw new Error(`Could not find channel by id or name = ${channelIdOrName}`);
1245
+ }
1246
+
1247
+ /**
1248
+ * Updates device information from HTTP device info
1249
+ *
1250
+ * Updates the device's cached HTTP info, channels, and all device properties
1251
+ * from an HttpDeviceInfo object (created via HttpDeviceInfo.fromDict()). Validates that the UUID matches before updating.
1252
+ *
1253
+ * @param {HttpDeviceInfo} deviceInfo - HttpDeviceInfo object from HTTP API
1254
+ * @returns {Promise<MerossDevice>} Promise that resolves with this device instance
1255
+ * @throws {Error} If device UUID doesn't match
1256
+ * @example
1257
+ * const updatedInfo = HttpDeviceInfo.fromDict(deviceDataFromApi);
1258
+ * await device.updateFromHttpState(updatedInfo);
1259
+ */
1260
+ async updateFromHttpState(deviceInfo) {
1261
+ if (!deviceInfo || !deviceInfo.uuid) {
1262
+ throw new Error('Device info is required and must have a UUID');
1263
+ }
1264
+
1265
+ if (deviceInfo.uuid !== this.uuid) {
1266
+ throw new Error(`Cannot update device (${this.uuid}) with HttpDeviceInfo for device id ${deviceInfo.uuid}`);
1267
+ }
1268
+
1269
+ // Update cached HTTP info
1270
+ this._cachedHttpInfo = deviceInfo;
1271
+
1272
+ // Update channels
1273
+ this._channels = MerossDevice._parseChannels(deviceInfo.channels);
1274
+
1275
+ // Update device properties
1276
+ if (!MerossDevice._isGetterOnly(this, 'name')) {
1277
+ this.name = deviceInfo.devName || this.uuid || 'unknown';
1278
+ }
1279
+
1280
+ this.deviceType = deviceInfo.deviceType;
1281
+ this.firmwareVersion = deviceInfo.fmwareVersion || 'unknown';
1282
+ this.hardwareVersion = deviceInfo.hdwareVersion || 'unknown';
1283
+ this.domain = deviceInfo.domain || this.domain;
1284
+
1285
+ if (!MerossDevice._isGetterOnly(this, 'onlineStatus')) {
1286
+ this.onlineStatus = deviceInfo.onlineStatus !== undefined ? deviceInfo.onlineStatus : OnlineStatus.UNKNOWN;
1287
+ }
1288
+
1289
+ return this;
1290
+ }
1291
+ }
1292
+
1293
+ /**
1294
+ * @typedef {Object} MerossDeviceEvents
1295
+ * @property {Function} pushNotification - Emitted when a push notification is received
1296
+ * @property {Function} data - Emitted with namespace and payload for backward compatibility
1297
+ * @property {Function} rawData - Emitted with raw message data
1298
+ * @property {Function} rawSendData - Emitted when raw data is sent
1299
+ * @property {Function} onlineStatusChange - Emitted when online status changes (newStatus, oldStatus)
1300
+ */
1301
+
1302
+ // Mix feature modules into device prototype to enable device-specific capabilities.
1303
+ // Encryption is included for all devices as firmware may enable it dynamically.
1304
+ Object.assign(MerossDevice.prototype, encryptionFeature);
1305
+ Object.assign(MerossDevice.prototype, systemFeature);
1306
+ Object.assign(MerossDevice.prototype, toggleFeature);
1307
+ Object.assign(MerossDevice.prototype, lightFeature);
1308
+ Object.assign(MerossDevice.prototype, thermostatFeature);
1309
+ Object.assign(MerossDevice.prototype, rollerShutterFeature);
1310
+ Object.assign(MerossDevice.prototype, garageFeature);
1311
+ Object.assign(MerossDevice.prototype, diffuserFeature);
1312
+ Object.assign(MerossDevice.prototype, sprayFeature);
1313
+ Object.assign(MerossDevice.prototype, consumptionFeature);
1314
+ Object.assign(MerossDevice.prototype, electricityFeature);
1315
+
1316
+ module.exports = { MerossDevice };
1317
+