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
package/lib/manager.js ADDED
@@ -0,0 +1,1609 @@
1
+ 'use strict';
2
+
3
+ const mqtt = require('mqtt');
4
+ const crypto = require('crypto');
5
+ const EventEmitter = require('events');
6
+ const { MEROSS_MQTT_DOMAIN } = require('./model/constants');
7
+ const { TransportMode } = require('./model/enums');
8
+ const {
9
+ buildDeviceRequestTopic,
10
+ buildClientResponseTopic,
11
+ buildClientUserTopic,
12
+ deviceUuidFromPushNotification,
13
+ generateClientAndAppId,
14
+ generateMqttPassword
15
+ } = require('./utilities/mqtt');
16
+ const ErrorBudgetManager = require('./error-budget');
17
+ const { MqttStatsCounter } = require('./utilities/stats');
18
+ const RequestQueue = require('./utilities/request-queue');
19
+ const {
20
+ CommandError,
21
+ CommandTimeoutError,
22
+ MqttError,
23
+ AuthenticationError
24
+ } = require('./model/exception');
25
+ const { HttpApiError } = require('./model/http/exception');
26
+
27
+ /**
28
+ * Manages Meross cloud connections and device communication
29
+ *
30
+ * Handles authentication, device discovery, MQTT connections, and provides
31
+ * a unified interface for controlling Meross IoT devices. Supports both cloud MQTT
32
+ * and local HTTP communication modes.
33
+ *
34
+ * @class
35
+ * @extends EventEmitter
36
+ */
37
+ class MerossManager extends EventEmitter {
38
+ /**
39
+ * Creates a new MerossManager instance
40
+ *
41
+ * @param {Object} options - Configuration options
42
+ * @param {MerossHttpClient} options.httpClient - HTTP client instance (required)
43
+ * @param {Function} [options.logger] - Optional logger function for debug output
44
+ * @param {number} [options.transportMode=TransportMode.MQTT_ONLY] - Transport mode for device communication
45
+ * @param {number} [options.timeout=10000] - Request timeout in milliseconds
46
+ * @param {boolean} [options.autoRetryOnBadDomain=true] - Automatically retry on domain redirect errors
47
+ * @param {number} [options.maxErrors=1] - Maximum errors allowed per device before skipping LAN HTTP
48
+ * @param {number} [options.errorBudgetTimeWindow=60000] - Time window in milliseconds for error budget
49
+ * @param {boolean} [options.enableStats=false] - Enable statistics tracking for HTTP and MQTT requests
50
+ * @param {number} [options.maxStatsSamples=1000] - Maximum number of samples to keep in statistics
51
+ * @param {number} [options.requestBatchSize=1] - Number of concurrent requests per device
52
+ * @param {number} [options.requestBatchDelay=200] - Delay in milliseconds between batches
53
+ * @param {boolean} [options.enableRequestThrottling=true] - Enable/disable request throttling
54
+ */
55
+ constructor(options) {
56
+ super();
57
+
58
+ if (!options || !options.httpClient) {
59
+ throw new Error('httpClient is required. Use MerossHttpClient.fromUserPassword() to create a client.');
60
+ }
61
+
62
+ this.options = options || {};
63
+ this.token = null;
64
+ this.key = null;
65
+ this.userId = null;
66
+ this.userEmail = null;
67
+ this.authenticated = false;
68
+ this.httpDomain = null;
69
+ this.mqttDomain = MEROSS_MQTT_DOMAIN;
70
+ this.issuedOn = null;
71
+
72
+ if (options.transportMode !== undefined) {
73
+ this._defaultTransportMode = options.transportMode;
74
+ } else {
75
+ this._defaultTransportMode = TransportMode.MQTT_ONLY;
76
+ }
77
+
78
+ this.autoRetryOnBadDomain = options.autoRetryOnBadDomain !== undefined ? !!options.autoRetryOnBadDomain : true;
79
+
80
+ this.timeout = options.timeout || 10000;
81
+
82
+ // Error budget prevents repeatedly attempting LAN HTTP on devices that consistently fail
83
+ const maxErrors = options.maxErrors !== undefined ? options.maxErrors : 1;
84
+ const timeWindowMs = options.errorBudgetTimeWindow !== undefined ? options.errorBudgetTimeWindow : 60000;
85
+ this._errorBudgetManager = new ErrorBudgetManager(maxErrors, timeWindowMs);
86
+
87
+ // Conservative defaults prevent overwhelming devices that cannot handle concurrent requests
88
+ const enableRequestThrottling = options.enableRequestThrottling !== undefined ? options.enableRequestThrottling : true;
89
+ this._requestQueue = enableRequestThrottling ? new RequestQueue({
90
+ batchSize: options.requestBatchSize || 1,
91
+ batchDelay: options.requestBatchDelay || 200,
92
+ logger: options.logger
93
+ }) : null;
94
+
95
+ this.mqttConnections = {};
96
+ this._mqttConnectionPromises = new Map();
97
+ this._deviceRegistry = new MerossManager.DeviceRegistry();
98
+ this._appId = null;
99
+ this.clientResponseTopic = null;
100
+ this._pendingMessagesFutures = new Map();
101
+
102
+ const enableStats = options.enableStats === true;
103
+ this._mqttStatsCounter = enableStats ? new MqttStatsCounter(options.maxStatsSamples || 1000) : null;
104
+
105
+ this.httpClient = options.httpClient;
106
+
107
+ // Reuse authentication from HTTP client if already authenticated to avoid
108
+ // redundant authentication and share credentials between HTTP and MQTT connections
109
+ if (this.httpClient.token) {
110
+ this.token = this.httpClient.token;
111
+ this.key = this.httpClient.key || null;
112
+ this.userId = this.httpClient.userId || null;
113
+ this.userEmail = this.httpClient.userEmail || null;
114
+ this.httpDomain = this.httpClient.httpDomain;
115
+ this.mqttDomain = this.httpClient.mqttDomain || this.mqttDomain;
116
+ this.authenticated = true;
117
+ // Generate appId once per manager instance to maintain consistent client identity
118
+ const { appId } = generateClientAndAppId();
119
+ this._appId = appId;
120
+ // Set clientResponseTopic immediately to avoid race conditions during connection setup
121
+ this.clientResponseTopic = buildClientResponseTopic(this.userId, this._appId);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Gets the default transport mode for device communication
127
+ * @returns {number} Transport mode from TransportMode enum
128
+ */
129
+ get defaultTransportMode() {
130
+ return this._defaultTransportMode;
131
+ }
132
+
133
+ /**
134
+ * Sets the default transport mode for device communication
135
+ * @param {number} value - Transport mode from TransportMode enum
136
+ * @throws {MqttError} If invalid transport mode is provided
137
+ */
138
+ set defaultTransportMode(value) {
139
+ if (!Object.values(TransportMode).includes(value)) {
140
+ throw new MqttError(`Invalid transport mode: ${value}. Must be one of: ${Object.values(TransportMode).join(', ')}`);
141
+ }
142
+ this._defaultTransportMode = value;
143
+ }
144
+
145
+ /**
146
+ * Gets the current authentication token data for reuse
147
+ *
148
+ * Returns token data that can be saved and reused in future sessions using
149
+ * MerossHttpClient.fromCredentials().
150
+ *
151
+ * @returns {Object|null} Token data object or null if not authenticated
152
+ * @returns {string} returns.token - Authentication token
153
+ * @returns {string} returns.key - Encryption key
154
+ * @returns {string} returns.userId - User ID
155
+ * @returns {string} returns.userEmail - User email
156
+ * @returns {string} returns.domain - HTTP API domain
157
+ * @returns {string} returns.mqttDomain - MQTT domain
158
+ * @returns {string} returns.issued_on - ISO timestamp when token was issued
159
+ */
160
+ getTokenData() {
161
+ if (!this.authenticated || !this.token) {
162
+ return null;
163
+ }
164
+ return {
165
+ token: this.token,
166
+ key: this.key,
167
+ userId: this.userId,
168
+ userEmail: this.userEmail,
169
+ domain: this.httpDomain,
170
+ mqttDomain: this.mqttDomain,
171
+ // eslint-disable-next-line camelcase
172
+ issued_on: this.issuedOn || new Date().toISOString()
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Authenticates with Meross cloud and discovers devices
178
+ *
179
+ * Retrieves device list and initializes device connections.
180
+ * The httpClient should already be authenticated when passed to the constructor.
181
+ * If the client is not authenticated, this method will attempt to get devices
182
+ * which may trigger authentication errors.
183
+ *
184
+ * @returns {Promise<number>} Promise that resolves with the number of devices discovered
185
+ * @throws {HttpApiError} If API request fails
186
+ * @throws {TokenExpiredError} If authentication token has expired
187
+ */
188
+ async login() {
189
+ return await this.getDevices();
190
+ }
191
+
192
+ /**
193
+ * Retrieves and initializes all devices from the Meross cloud
194
+ *
195
+ * Fetches device list from cloud API, creates device instances, and sets up
196
+ * MQTT connections. Emits 'deviceInitialized' event for each device.
197
+ *
198
+ * @returns {Promise<number>} Promise that resolves with the number of devices initialized
199
+ * @throws {HttpApiError} If API request fails
200
+ * @throws {TokenExpiredError} If authentication token has expired
201
+ */
202
+ async getDevices() {
203
+ const deviceList = await this.httpClient.getDevices();
204
+
205
+ if (!deviceList || !Array.isArray(deviceList)) {
206
+ return 0;
207
+ }
208
+
209
+ const { OnlineStatus } = require('../lib/model/enums');
210
+ const { buildDevice } = require('./device-factory');
211
+
212
+ const onlineDevices = deviceList.filter(dev => dev.onlineStatus === OnlineStatus.ONLINE);
213
+
214
+ const devicesByDomain = new Map();
215
+ onlineDevices.forEach(dev => {
216
+ const domain = dev.domain || this.mqttDomain;
217
+ if (!devicesByDomain.has(domain)) {
218
+ devicesByDomain.set(domain, []);
219
+ }
220
+ devicesByDomain.get(domain).push(dev);
221
+ });
222
+
223
+ for (const [domain, devices] of devicesByDomain) {
224
+ if (devices.length > 0) {
225
+ const firstDevice = devices[0];
226
+ this.initMqtt({
227
+ uuid: firstDevice.uuid,
228
+ domain
229
+ });
230
+ }
231
+ }
232
+
233
+ const devicePromises = onlineDevices.map(async (dev) => {
234
+ try {
235
+ // Query abilities before device creation to determine device class and features
236
+ // Extended timeout accounts for devices that respond slowly during discovery
237
+ let abilities = null;
238
+ try {
239
+ abilities = await this._queryDeviceAbilities(
240
+ dev.uuid,
241
+ dev.domain || this.mqttDomain,
242
+ 10000
243
+ );
244
+ } catch (err) {
245
+ return null;
246
+ }
247
+
248
+ if (!abilities) {
249
+ return null;
250
+ }
251
+
252
+ // Detect hubs by checking for hub-specific ability rather than device type
253
+ // Device type strings are unreliable, but abilities are consistent
254
+ const { HUB_DISCRIMINATING_ABILITY } = require('./device-factory');
255
+ const isHub = abilities && typeof abilities === 'object' &&
256
+ HUB_DISCRIMINATING_ABILITY in abilities;
257
+
258
+ // Fetch subdevice metadata for hubs, but defer creation until after hub enrollment
259
+ // This ensures hub is fully initialized before subdevices are created
260
+ let subDeviceList = null;
261
+ if (isHub) {
262
+ try {
263
+ subDeviceList = await this.httpClient.getSubDevices(dev.uuid);
264
+ } catch (err) {
265
+ subDeviceList = [];
266
+ }
267
+ }
268
+
269
+ // Build device with complete feature set determined from abilities
270
+ // This avoids needing to query abilities again after device creation
271
+ const device = buildDevice(dev, abilities, this, subDeviceList);
272
+
273
+ // Store abilities in device instance for runtime capability checks
274
+ device.updateAbilities(abilities);
275
+
276
+ this._deviceRegistry.registerDevice(device);
277
+ await this.connectDevice(device, dev);
278
+
279
+ return device;
280
+ } catch (err) {
281
+ if (this.options.logger) {
282
+ this.options.logger(`Error enrolling device ${dev.uuid}: ${err.message}`);
283
+ }
284
+ return null;
285
+ }
286
+ });
287
+
288
+ const devices = (await Promise.all(devicePromises)).filter(d => d !== null);
289
+
290
+ // Create subdevices after all hubs are enrolled to ensure parent hub is fully initialized
291
+ // Subdevices inherit capabilities from their parent hub's abilities
292
+ const { buildSubdevice, getSubdeviceAbilities } = require('./device-factory');
293
+ const { MerossHubDevice } = require('./controller/hub-device');
294
+ for (const device of devices) {
295
+ // Use instanceof to check device type since device classes are dynamically
296
+ // created at runtime and cannot be checked using static class references
297
+ if (device instanceof MerossHubDevice && typeof device.getSubdevices === 'function') {
298
+ const subDeviceList = device.subDeviceList || [];
299
+ const hubAbilities = device.abilities;
300
+
301
+ if (hubAbilities && subDeviceList && Array.isArray(subDeviceList) && subDeviceList.length > 0) {
302
+ const HttpSubdeviceInfo = require('./model/http/subdevice');
303
+ for (const subdeviceInfoRaw of subDeviceList) {
304
+ try {
305
+ const subdeviceInfo = HttpSubdeviceInfo.fromDict(subdeviceInfoRaw);
306
+ // Build subdevice using hub's abilities since subdevices don't have their own
307
+ const subdevice = buildSubdevice(
308
+ subdeviceInfo,
309
+ device.uuid,
310
+ hubAbilities,
311
+ this
312
+ );
313
+
314
+ device.registerSubdevice(subdevice);
315
+ this._deviceRegistry.registerDevice(subdevice);
316
+
317
+ // Extract subdevice-specific abilities from hub's full ability set
318
+ if (subdevice && subdevice.type) {
319
+ const subdeviceAbilities = getSubdeviceAbilities(subdevice.type, hubAbilities);
320
+ if (Object.keys(subdeviceAbilities).length > 0) {
321
+ subdevice.updateAbilities(subdeviceAbilities);
322
+ }
323
+ }
324
+ } catch (err) {
325
+ // Skip subdevices that fail to initialize to avoid blocking hub enrollment
326
+ }
327
+ }
328
+ }
329
+ }
330
+ }
331
+
332
+ return devices.length;
333
+ }
334
+
335
+ /**
336
+ * Query device abilities via MQTT (internal method)
337
+ *
338
+ * Queries a device's supported capabilities (namespaces) via MQTT. This is called
339
+ * automatically during device discovery.
340
+ *
341
+ * @param {string} deviceUuid - Device UUID
342
+ * @param {string} domain - MQTT domain for the device
343
+ * @param {number} [timeout=5000] - Timeout in milliseconds
344
+ * @returns {Promise<Object|null>} Abilities object or null if query fails or times out
345
+ * @throws {CommandTimeoutError} If query times out
346
+ * @throws {MqttError} If MQTT connection fails
347
+ * @private
348
+ */
349
+ async _queryDeviceAbilities(deviceUuid, domain, timeout = 5000) {
350
+ if (!this.authenticated || !this.token || !this.key || !this.userId) {
351
+ if (this.options.logger) {
352
+ this.options.logger('Cannot query abilities: not authenticated');
353
+ }
354
+ return null;
355
+ }
356
+
357
+ const mqttDomain = domain || this.mqttDomain;
358
+
359
+ if (!this.mqttConnections[mqttDomain] || !this.mqttConnections[mqttDomain].client) {
360
+ const minimalDev = { uuid: deviceUuid, domain: mqttDomain };
361
+ await this.initMqtt(minimalDev);
362
+ }
363
+
364
+ const mqttConnection = this.mqttConnections[mqttDomain];
365
+
366
+ if (!mqttConnection.client.connected) {
367
+ const connectionPromise = this._mqttConnectionPromises.get(mqttDomain);
368
+ if (!connectionPromise) {
369
+ return null;
370
+ }
371
+ try {
372
+ await connectionPromise;
373
+ } catch {
374
+ return null;
375
+ }
376
+ }
377
+
378
+ if (!this.clientResponseTopic) {
379
+ if (this.options.logger) {
380
+ this.options.logger('Client response topic not set');
381
+ }
382
+ return null;
383
+ }
384
+
385
+ const message = this.encodeMessage('GET', 'Appliance.System.Ability', {}, deviceUuid);
386
+ const { messageId } = message.header;
387
+
388
+ return new Promise((resolve, reject) => {
389
+ const timeoutHandle = setTimeout(() => {
390
+ if (this._pendingMessagesFutures.has(messageId)) {
391
+ this._pendingMessagesFutures.delete(messageId);
392
+ reject(new CommandTimeoutError(
393
+ `Ability query timeout after ${timeout}ms`,
394
+ deviceUuid,
395
+ timeout,
396
+ { method: 'GET', namespace: 'Appliance.System.Ability' }
397
+ ));
398
+ }
399
+ }, timeout);
400
+
401
+ this._pendingMessagesFutures.set(messageId, {
402
+ resolve: (response) => {
403
+ clearTimeout(timeoutHandle);
404
+ if (response && response.ability) {
405
+ resolve(response.ability);
406
+ } else {
407
+ resolve(null);
408
+ }
409
+ },
410
+ reject: (error) => {
411
+ clearTimeout(timeoutHandle);
412
+ reject(error);
413
+ },
414
+ timeout: timeoutHandle
415
+ });
416
+
417
+ try {
418
+ const topic = buildDeviceRequestTopic(deviceUuid);
419
+ mqttConnection.client.publish(topic, JSON.stringify(message), (err) => {
420
+ if (err) {
421
+ if (this._pendingMessagesFutures.has(messageId)) {
422
+ clearTimeout(timeoutHandle);
423
+ this._pendingMessagesFutures.delete(messageId);
424
+ }
425
+ reject(err);
426
+ }
427
+ });
428
+ } catch (err) {
429
+ if (this._pendingMessagesFutures.has(messageId)) {
430
+ clearTimeout(timeoutHandle);
431
+ this._pendingMessagesFutures.delete(messageId);
432
+ }
433
+ reject(err);
434
+ }
435
+ }).catch((error) => {
436
+ if (this.options.logger) {
437
+ this.options.logger(`Error querying abilities for ${deviceUuid}: ${error.message}`);
438
+ }
439
+ return null;
440
+ });
441
+ }
442
+
443
+ /**
444
+ * Connects to Meross cloud and initializes all devices
445
+ *
446
+ * Performs device discovery. The httpClient should already be authenticated
447
+ * when passed to the constructor.
448
+ *
449
+ * @returns {Promise<number>} Promise that resolves with the number of devices connected
450
+ * @throws {HttpApiError} If API request fails
451
+ * @throws {TokenExpiredError} If authentication token has expired
452
+ */
453
+ async connect() {
454
+ try {
455
+ const deviceListLength = await this.getDevices();
456
+ this.authenticated = true;
457
+ return deviceListLength;
458
+ } catch (err) {
459
+ if (err.message && err.message.includes('Token')) {
460
+ if (this.options.logger) {
461
+ this.options.logger('Token seems invalid. Ensure httpClient is authenticated.');
462
+ }
463
+ }
464
+ throw err;
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Logs out from Meross cloud and disconnects all devices
470
+ *
471
+ * Closes all MQTT connections, disconnects all devices, and clears authentication.
472
+ * Should be called when shutting down the application to properly clean up resources.
473
+ *
474
+ * @returns {Promise<Object|null>} Promise that resolves with logout response data from Meross API (or null if empty)
475
+ * @throws {AuthenticationError} If not authenticated
476
+ */
477
+ async logout() {
478
+ if (!this.authenticated || !this.token) {
479
+ throw new AuthenticationError('Not authenticated');
480
+ }
481
+ const response = await this.httpClient.logout();
482
+ this.token = null;
483
+ this.key = null;
484
+ this.userId = null;
485
+ this.userEmail = null;
486
+ this.authenticated = false;
487
+
488
+ this.disconnectAll();
489
+
490
+ return response;
491
+ }
492
+
493
+ /**
494
+ * Gets a device by UUID
495
+ *
496
+ * @param {string} uuid - Device UUID
497
+ * @returns {MerossDevice|MerossHubDevice|undefined} Device instance or undefined if not found
498
+ */
499
+ getDevice(uuid) {
500
+ return this._deviceRegistry.lookupByUuid(uuid);
501
+ }
502
+
503
+ /**
504
+ * Finds devices matching the specified filters
505
+ *
506
+ * @param {Object} [filters={}] - Filter criteria
507
+ * @param {string[]} [filters.device_uuids] - Array of device UUIDs to match (snake_case to match API)
508
+ * @param {string[]} [filters.internal_ids] - Array of internal IDs to match
509
+ * @param {string} [filters.device_type] - Device type to match (e.g., 'mss310')
510
+ * @param {string} [filters.device_name] - Device name to match
511
+ * @param {number} [filters.online_status] - Online status to match (from OnlineStatus enum)
512
+ * @param {string|Function|Array} [filters.device_class] - Device class filter (string, function, or array)
513
+ * @returns {Array<MerossDevice|MerossHubDevice>} Array of matching devices
514
+ */
515
+ findDevices(filters = {}) {
516
+ return this._deviceRegistry.findDevices(filters);
517
+ }
518
+
519
+ /**
520
+ * Gets all registered devices
521
+ *
522
+ * @returns {Array<MerossDevice|MerossHubDevice>} Array of all devices
523
+ */
524
+ getAllDevices() {
525
+ return this._deviceRegistry.getAllDevices();
526
+ }
527
+
528
+ /**
529
+ * Gets a device by UUID (internal helper method)
530
+ *
531
+ * @param {string} uuid - Device UUID
532
+ * @returns {MerossDevice|MerossHubDevice|undefined} Device instance or undefined if not found
533
+ * @private
534
+ */
535
+ _getDeviceByUuid(uuid) {
536
+ return this._deviceRegistry.lookupByUuid(uuid);
537
+ }
538
+
539
+ /**
540
+ * Disconnects all devices and closes all MQTT connections
541
+ *
542
+ * Clears the device registry (which disconnects all devices), clears all request queues,
543
+ * and closes all MQTT connections. This should be called when shutting down the application.
544
+ *
545
+ * @param {boolean} [force] - Force disconnect flag (passed to MQTT client end() method)
546
+ * @returns {void}
547
+ */
548
+ disconnectAll(force) {
549
+ // Retrieve devices before clearing registry to allow queue cleanup
550
+ const devices = this._deviceRegistry.getAllDevices();
551
+ this._deviceRegistry.clear();
552
+
553
+ if (this._requestQueue) {
554
+ devices.forEach(device => {
555
+ const uuid = device.uuid || device.dev?.uuid;
556
+ if (uuid) {
557
+ this._requestQueue.clearQueue(uuid);
558
+ }
559
+ });
560
+ }
561
+
562
+ for (const domain of Object.keys(this.mqttConnections)) {
563
+ if (this.mqttConnections[domain] && this.mqttConnections[domain].client) {
564
+ this.mqttConnections[domain].client.removeAllListeners();
565
+ this.mqttConnections[domain].client.end(force);
566
+ }
567
+ }
568
+
569
+ this.mqttConnections = {};
570
+ this._mqttConnectionPromises.clear();
571
+ }
572
+
573
+ /**
574
+ * Initializes MQTT connection for a device
575
+ *
576
+ * Creates or retrieves the MQTT client for the device's domain and ensures
577
+ * the device is added to the connection's device list. This is a simplified
578
+ * wrapper around `_getMqttClient()` that handles device-specific setup.
579
+ *
580
+ * @param {Object} dev - Device definition object with uuid and optional domain
581
+ * @returns {Promise<void>} Promise that resolves when MQTT connection is ready
582
+ */
583
+ async initMqtt(dev) {
584
+ const domain = dev.domain || this.mqttDomain;
585
+
586
+ if (!this.mqttConnections[domain]) {
587
+ this.mqttConnections[domain] = {};
588
+ }
589
+
590
+ await this._getMqttClient(domain, dev.uuid);
591
+ }
592
+
593
+ /**
594
+ * Creates a new MQTT client and sets up all event listeners
595
+ *
596
+ * This method creates a new MQTT client instance and sets up all event listeners
597
+ * (connect, error, close, reconnect, message) exactly once.
598
+ *
599
+ * @param {string} domain - MQTT domain for the connection
600
+ * @returns {mqtt.MqttClient} The created MQTT client
601
+ * @private
602
+ */
603
+ _createMqttClient(domain) {
604
+ // Generate appId if not already set (should be set in constructor when authenticated)
605
+ if (!this._appId) {
606
+ const { appId } = generateClientAndAppId();
607
+ this._appId = appId;
608
+ if (this.userId) {
609
+ this.clientResponseTopic = buildClientResponseTopic(this.userId, this._appId);
610
+ }
611
+ }
612
+ // Reuse same clientId for all clients since each domain connects to a different broker
613
+ const clientId = `app:${this._appId}`;
614
+
615
+ // Meross MQTT authentication requires password as MD5 hash of userId + key
616
+ const hashedPassword = generateMqttPassword(this.userId, this.key);
617
+
618
+ const client = mqtt.connect({
619
+ 'protocol': 'mqtts',
620
+ 'host': domain,
621
+ 'port': 2001,
622
+ clientId,
623
+ 'username': this.userId,
624
+ 'password': hashedPassword,
625
+ 'rejectUnauthorized': true,
626
+ 'keepalive': 30,
627
+ 'reconnectPeriod': 5000
628
+ });
629
+
630
+ // Set up all event listeners once when client is created to prevent duplicate
631
+ // listeners that would cause MaxListenersExceededWarning if the client is reused
632
+ client.on('connect', () => {
633
+ const userTopic = buildClientUserTopic(this.userId);
634
+ client.subscribe(userTopic, (err) => {
635
+ if (err) {
636
+ this.emit('error', err, null);
637
+ }
638
+ });
639
+
640
+ client.subscribe(this.clientResponseTopic, (err) => {
641
+ if (err) {
642
+ this.emit('error', err, null);
643
+ }
644
+ });
645
+
646
+ // Resolve connection promise after subscriptions complete to ensure client is fully ready
647
+ if (this.mqttConnections[domain]._connectionResolve) {
648
+ this.mqttConnections[domain]._connectionResolve();
649
+ this.mqttConnections[domain]._connectionResolve = null;
650
+ }
651
+
652
+ this.mqttConnections[domain].deviceList.forEach(devId => {
653
+ const device = this._getDeviceByUuid(devId);
654
+ if (device) {
655
+ device.emit('connected');
656
+ }
657
+ });
658
+ });
659
+
660
+ client.on('error', (error) => {
661
+ // MQTT client handles reconnection automatically via reconnectPeriod option
662
+ this.mqttConnections[domain].deviceList.forEach(devId => {
663
+ const device = this._getDeviceByUuid(devId);
664
+ if (device) {
665
+ device.emit('error', error ? error.toString() : null);
666
+ }
667
+ });
668
+ });
669
+
670
+ client.on('close', (error) => {
671
+ this.mqttConnections[domain].deviceList.forEach(devId => {
672
+ const device = this._getDeviceByUuid(devId);
673
+ if (device) {
674
+ device.emit('close', error ? error.toString() : null);
675
+ }
676
+ });
677
+ });
678
+
679
+ client.on('reconnect', () => {
680
+ this.mqttConnections[domain].deviceList.forEach(devId => {
681
+ const device = this._getDeviceByUuid(devId);
682
+ if (device) {
683
+ device.emit('reconnect');
684
+ }
685
+ });
686
+ });
687
+
688
+ client.on('message', (topic, message) => {
689
+ if (!message) {return;}
690
+ try {
691
+ message = JSON.parse(message.toString());
692
+ } catch (err) {
693
+ this.emit('error', new Error(`JSON parse error: ${err}`), null);
694
+ return;
695
+ }
696
+
697
+ if (!message.header) {return;}
698
+
699
+ const { messageId } = message.header;
700
+ const messageMethod = message.header.method;
701
+
702
+ // Handle manager-level queries (e.g., ability queries) before routing to devices.
703
+ // These queries use messageId-based futures rather than device-specific handlers
704
+ // because they are initiated by the manager, not by individual devices
705
+ if (messageId && this._pendingMessagesFutures.has(messageId)) {
706
+ const pendingFuture = this._pendingMessagesFutures.get(messageId);
707
+
708
+ if (pendingFuture.timeout) {
709
+ clearTimeout(pendingFuture.timeout);
710
+ }
711
+
712
+ if (messageMethod === 'ERROR') {
713
+ const errorPayload = message.payload || {};
714
+ const deviceUuid = message.header?.from ? deviceUuidFromPushNotification(message.header.from) : null;
715
+ pendingFuture.reject(new CommandError(
716
+ `Device returned error: ${JSON.stringify(errorPayload)}`,
717
+ errorPayload,
718
+ deviceUuid
719
+ ));
720
+ } else if (messageMethod === 'GETACK' || messageMethod === 'SETACK' || messageMethod === 'DELETEACK') {
721
+ pendingFuture.resolve(message.payload || message);
722
+ } else {
723
+ const topic = message.header?.from || null;
724
+ pendingFuture.reject(new MqttError(
725
+ `Unexpected message method: ${messageMethod}`,
726
+ topic,
727
+ message
728
+ ));
729
+ }
730
+
731
+ this._pendingMessagesFutures.delete(messageId);
732
+ return;
733
+ }
734
+
735
+ if (!message.header.from) {return;}
736
+ const deviceUuid = deviceUuidFromPushNotification(message.header.from);
737
+ const device = this._getDeviceByUuid(deviceUuid);
738
+ if (device) {
739
+ device.handleMessage(message);
740
+ }
741
+ });
742
+
743
+ return client;
744
+ }
745
+
746
+ /**
747
+ * Gets existing MQTT client or creates a new one for the given domain
748
+ *
749
+ * This method manages the MQTT client lifecycle and uses promise-based serialization
750
+ * to prevent concurrent connection attempts. If a client already exists and is connected, it returns immediately. If a client
751
+ * exists but is not connected, it waits for the existing connection promise. If no
752
+ * client exists, it creates a new one and waits for connection.
753
+ *
754
+ * @param {string} domain - MQTT domain for the connection
755
+ * @param {string} deviceUuid - Device UUID (for device list tracking)
756
+ * @returns {Promise<mqtt.MqttClient>} Promise that resolves with the MQTT client
757
+ * @private
758
+ */
759
+ async _getMqttClient(domain, deviceUuid) {
760
+ if (!this.mqttConnections[domain]) {
761
+ this.mqttConnections[domain] = {};
762
+ }
763
+
764
+ let client = this.mqttConnections[domain].client;
765
+ if (!client) {
766
+ client = this._createMqttClient(domain);
767
+ this.mqttConnections[domain].client = client;
768
+ this.mqttConnections[domain].deviceList = this.mqttConnections[domain].deviceList || [];
769
+ if (!this.mqttConnections[domain].deviceList.includes(deviceUuid)) {
770
+ this.mqttConnections[domain].deviceList.push(deviceUuid);
771
+ }
772
+ } else {
773
+ if (client.connected) {
774
+ if (!this.mqttConnections[domain].deviceList.includes(deviceUuid)) {
775
+ this.mqttConnections[domain].deviceList.push(deviceUuid);
776
+ }
777
+ return client;
778
+ }
779
+ }
780
+
781
+ // Serialize connection attempts using promises to prevent concurrent connections
782
+ // to the same domain. Multiple calls will wait for the same connection promise,
783
+ // ensuring only one connection attempt is made per domain at a time
784
+ let connectionPromise = this._mqttConnectionPromises.get(domain);
785
+ if (!connectionPromise) {
786
+ connectionPromise = new Promise((resolve, reject) => {
787
+ this.mqttConnections[domain]._connectionResolve = resolve;
788
+
789
+ setTimeout(() => {
790
+ if (this.mqttConnections[domain]) {
791
+ this.mqttConnections[domain]._connectionResolve = null;
792
+ }
793
+ this._mqttConnectionPromises.delete(domain);
794
+ reject(new MqttError(`MQTT connection timeout for domain ${domain}`, null, null));
795
+ }, 30000);
796
+ });
797
+
798
+ this._mqttConnectionPromises.set(domain, connectionPromise);
799
+ }
800
+
801
+ await connectionPromise;
802
+ return this.mqttConnections[domain].client;
803
+ }
804
+
805
+ /**
806
+ * Sends a message to a device via MQTT
807
+ *
808
+ * Publishes a message to the device's MQTT topic. Tracks MQTT statistics if enabled.
809
+ * Emits error events on the device if publish fails.
810
+ *
811
+ * @param {MerossDevice|MerossHubDevice|MerossSubDevice} device - Device instance
812
+ * @param {Object} data - Message data object with header and payload
813
+ * @returns {boolean} True if message was sent successfully, false if MQTT connection not available
814
+ */
815
+ sendMessageMqtt(device, data) {
816
+ const domain = device.domain || this.mqttDomain;
817
+ if (!this.mqttConnections[domain] || !this.mqttConnections[domain].client) {
818
+ return false;
819
+ }
820
+
821
+ if (this.options.logger) {
822
+ this.options.logger(`MQTT-Cloud-Call ${device.uuid}: ${JSON.stringify(data)}`);
823
+ }
824
+
825
+ if (this._mqttStatsCounter && data && data.header) {
826
+ const namespace = data.header.namespace || 'Unknown';
827
+ const method = data.header.method || 'Unknown';
828
+ this._mqttStatsCounter.notifyApiCall(device.uuid, namespace, method);
829
+ }
830
+
831
+ const topic = buildDeviceRequestTopic(device.uuid);
832
+ this.mqttConnections[domain].client.publish(topic, JSON.stringify(data), undefined, err => {
833
+ if (err) {
834
+ const deviceObj = this._getDeviceByUuid(device.uuid);
835
+ if (deviceObj) {
836
+ deviceObj.emit('error', err);
837
+ }
838
+ }
839
+ });
840
+ return true;
841
+ }
842
+
843
+ /**
844
+ * Sends a message to a device via LAN HTTP
845
+ *
846
+ * Sends an HTTP POST request directly to the device's local IP address. Handles encryption
847
+ * if the device supports it. Decrypts and parses the response, then routes it to the device's
848
+ * handleMessage method. Tracks HTTP statistics if enabled.
849
+ *
850
+ * @param {MerossDevice|MerossHubDevice|MerossSubDevice} device - Device instance
851
+ * @param {string} ip - Device LAN IP address
852
+ * @param {Object} payload - Message payload object with header and payload
853
+ * @param {number} [timeoutOverride=this.timeout] - Request timeout in milliseconds
854
+ * @returns {Promise<void>} Promise that resolves when message is sent and response is handled
855
+ * @throws {CommandError} If encryption is required but MAC address not available
856
+ * @throws {HttpApiError} If HTTP request fails or response is invalid
857
+ */
858
+ async sendMessageHttp(device, ip, payload, timeoutOverride = this.timeout) {
859
+ const url = `http://${ip}/config`;
860
+ let messageData = JSON.stringify(payload);
861
+ let decryptResponse = false;
862
+
863
+ if (device && typeof device.supportEncryption === 'function' && device.supportEncryption()) {
864
+ if (!device.isEncryptionKeySet()) {
865
+ // Encryption key is derived from MAC address, so it must be available
866
+ if (device._macAddress && this.key) {
867
+ device.setEncryptionKey(device.uuid, this.key, device._macAddress);
868
+ } else {
869
+ if (this.options.logger) {
870
+ this.options.logger(`Warning: Device ${device.uuid} supports encryption but MAC address not available yet. Falling back to MQTT.`);
871
+ }
872
+ throw new CommandError('Encryption required but MAC address not available', null, device.uuid);
873
+ }
874
+ }
875
+
876
+ try {
877
+ messageData = device.encryptMessage(messageData);
878
+ decryptResponse = true;
879
+ } catch (err) {
880
+ if (this.options.logger) {
881
+ this.options.logger(`Error encrypting message for ${device.uuid}: ${err.message}`);
882
+ }
883
+ throw err;
884
+ }
885
+ }
886
+
887
+ const options = {
888
+ url,
889
+ method: 'POST',
890
+ json: payload,
891
+ timeout: timeoutOverride
892
+ };
893
+ if (this.options.logger) {
894
+ this.options.logger(`HTTP-Local-Call ${device.uuid}${decryptResponse ? ' [ENCRYPTED]' : ''}: ${JSON.stringify(options)}`);
895
+ }
896
+
897
+ try {
898
+ const controller = new AbortController();
899
+ const timeoutId = setTimeout(() => controller.abort(), timeoutOverride);
900
+
901
+ let response;
902
+ try {
903
+ response = await fetch(url, {
904
+ method: 'POST',
905
+ headers: {
906
+ 'Content-Type': 'application/json'
907
+ },
908
+ body: messageData,
909
+ signal: controller.signal
910
+ });
911
+ clearTimeout(timeoutId);
912
+ } catch (error) {
913
+ clearTimeout(timeoutId);
914
+ if (error.name === 'AbortError') {
915
+ throw new Error('Request timeout');
916
+ }
917
+ throw error;
918
+ }
919
+
920
+ if (response.status !== 200) {
921
+ if (this.options.logger) {
922
+ this.options.logger(`HTTP-Local-Response ${device.uuid}${decryptResponse ? ' [ENCRYPTED]' : ''} Error: Status=${response.status}`);
923
+ }
924
+ throw new HttpApiError(`HTTP ${response.status}: ${response.statusText}`, null, response.status);
925
+ }
926
+
927
+ const body = await response.text();
928
+ if (this.options.logger) {
929
+ this.options.logger(`HTTP-Local-Response ${device.uuid}${decryptResponse ? ' [ENCRYPTED]' : ''} OK: ${body}`);
930
+ }
931
+
932
+ // Track HTTP success before parsing to avoid counting parsing errors as HTTP failures
933
+ if (this.httpClient && this.httpClient.stats) {
934
+ this.httpClient.stats.notifyHttpRequest(url, 'POST', 200, null);
935
+ }
936
+
937
+ let responseBody = body;
938
+
939
+ if (decryptResponse && device) {
940
+ try {
941
+ const decrypted = device.decryptMessage(body);
942
+ // Remove null padding that encryption adds to match block size
943
+ responseBody = decrypted.toString('utf8').replace(/\0+$/, '');
944
+ try {
945
+ responseBody = JSON.parse(responseBody);
946
+ } catch (parseErr) {
947
+ if (this.options.logger) {
948
+ this.options.logger(`Error parsing decrypted response for ${device.uuid}: ${parseErr.message}`);
949
+ }
950
+ throw new HttpApiError(`Failed to parse decrypted response: ${parseErr.message}`, null, null);
951
+ }
952
+ } catch (decryptErr) {
953
+ if (this.options.logger) {
954
+ this.options.logger(`Error decrypting response for ${device.uuid}: ${decryptErr.message}`);
955
+ }
956
+ throw decryptErr;
957
+ }
958
+ } else {
959
+ try {
960
+ responseBody = JSON.parse(responseBody);
961
+ } catch (parseErr) {
962
+ if (this.options.logger) {
963
+ this.options.logger(`Error parsing response for ${device.uuid}: ${parseErr.message}`);
964
+ }
965
+ }
966
+ }
967
+
968
+ if (responseBody && typeof responseBody === 'object') {
969
+ setImmediate(() => {
970
+ if (device) {
971
+ device.handleMessage(responseBody);
972
+ }
973
+ });
974
+ return;
975
+ }
976
+ throw new HttpApiError(`Invalid response: ${typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)}`, null, null);
977
+ } catch (error) {
978
+ // Track HTTP-level errors only, not parsing errors that occur after successful HTTP 200
979
+ if (this.httpClient && this.httpClient.stats) {
980
+ let errorHttpCode = null;
981
+ const errorApiCode = null;
982
+
983
+ if (error instanceof HttpApiError && error.httpStatusCode !== null && error.httpStatusCode !== undefined) {
984
+ errorHttpCode = error.httpStatusCode;
985
+ } else if (!(error instanceof HttpApiError)) {
986
+ // Extract HTTP status code from network errors, timeouts, and connection failures
987
+ if (error.statusCode) {
988
+ errorHttpCode = error.statusCode;
989
+ } else if (error.message && error.message.includes('HTTP')) {
990
+ const match = error.message.match(/HTTP (\d+)/);
991
+ if (match) {
992
+ errorHttpCode = parseInt(match[1], 10);
993
+ }
994
+ }
995
+ if (errorHttpCode === null) {
996
+ errorHttpCode = 0;
997
+ }
998
+ }
999
+
1000
+ if (errorHttpCode !== null) {
1001
+ this.httpClient.stats.notifyHttpRequest(url, 'POST', errorHttpCode, errorApiCode);
1002
+ }
1003
+ }
1004
+
1005
+ throw error;
1006
+ }
1007
+ }
1008
+
1009
+ /**
1010
+ * Encodes a message for Meross device communication
1011
+ *
1012
+ * Creates a properly formatted message object with header containing messageId, signature,
1013
+ * timestamp, and other required fields. The signature is computed as MD5(messageId + key + timestamp).
1014
+ *
1015
+ * @param {string} method - Message method ('GET', 'SET', 'PUSH')
1016
+ * @param {string} namespace - Message namespace (e.g., 'Appliance.Control.ToggleX')
1017
+ * @param {Object} payload - Message payload object
1018
+ * @param {string} deviceUuid - Target device UUID
1019
+ * @returns {Object} Encoded message object with header and payload
1020
+ * @returns {Object} returns.header - Message header
1021
+ * @returns {string} returns.header.from - Response topic
1022
+ * @returns {string} returns.header.messageId - Unique message ID (MD5 hash)
1023
+ * @returns {string} returns.header.method - Message method
1024
+ * @returns {string} returns.header.namespace - Message namespace
1025
+ * @returns {number} returns.header.payloadVersion - Payload version (always 1)
1026
+ * @returns {string} returns.header.sign - Message signature (MD5 hash)
1027
+ * @returns {number} returns.header.timestamp - Unix timestamp in seconds
1028
+ * @returns {string} returns.header.triggerSrc - Trigger source (always 'Android')
1029
+ * @returns {string} returns.header.uuid - Device UUID
1030
+ * @returns {Object} returns.payload - Message payload
1031
+ */
1032
+ encodeMessage(method, namespace, payload, deviceUuid) {
1033
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
1034
+ let randomstring = '';
1035
+ for (let i = 0; i < 16; i++) {
1036
+ randomstring += chars.charAt(Math.floor(Math.random() * chars.length));
1037
+ }
1038
+ const messageId = crypto.createHash('md5').update(randomstring).digest('hex').toLowerCase();
1039
+ const timestamp = Math.round(new Date().getTime() / 1000);
1040
+
1041
+ const signature = crypto.createHash('md5').update(messageId + this.key + timestamp).digest('hex');
1042
+
1043
+ return {
1044
+ 'header': {
1045
+ 'from': this.clientResponseTopic,
1046
+ messageId,
1047
+ method,
1048
+ namespace,
1049
+ 'payloadVersion': 1,
1050
+ 'sign': signature,
1051
+ timestamp,
1052
+ 'triggerSrc': 'Android',
1053
+ 'uuid': deviceUuid
1054
+ },
1055
+ payload
1056
+ };
1057
+ }
1058
+
1059
+ /**
1060
+ * Requests a message to be sent to a device (with throttling support)
1061
+ *
1062
+ * This method queues requests per device and processes them in batches to prevent
1063
+ * rate limiting. Requests are throttled regardless of transport mode (HTTP or MQTT).
1064
+ * If throttling is disabled, requests are executed immediately.
1065
+ *
1066
+ * @param {MerossDevice|MerossHubDevice|MerossSubDevice} device - Device instance
1067
+ * @param {string|null} ip - Device LAN IP address (null if not available)
1068
+ * @param {Object} data - Message data object with header and payload
1069
+ * @param {number|null} [overrideTransportMode=null] - Override transport mode (from TransportMode enum)
1070
+ * @returns {Promise<boolean>} Promise that resolves to true if message was sent successfully
1071
+ * @throws {CommandError} If message cannot be sent
1072
+ * @throws {HttpApiError} If HTTP request fails
1073
+ * @throws {MqttError} If MQTT publish fails
1074
+ */
1075
+ async requestMessage(device, ip, data, overrideTransportMode = null) {
1076
+ if (!this._requestQueue) {
1077
+ return this._sendMessage(device, ip, data, overrideTransportMode);
1078
+ }
1079
+
1080
+ return this._requestQueue.enqueue(device.uuid, () =>
1081
+ this._sendMessage(device, ip, data, overrideTransportMode)
1082
+ );
1083
+ }
1084
+
1085
+ /**
1086
+ * Sends a message to a device via HTTP or MQTT (internal implementation)
1087
+ *
1088
+ * This method handles the actual message sending logic, including transport mode
1089
+ * selection, error budget checking, HTTP fallback to MQTT, and error handling.
1090
+ * It is called by requestMessage() which handles throttling/queuing.
1091
+ *
1092
+ * @param {MerossDevice|MerossHubDevice|MerossSubDevice} device - Device instance
1093
+ * @param {string|null} ip - Device LAN IP address (null if not available)
1094
+ * @param {Object} data - Message data object with header and payload
1095
+ * @param {number|null} [overrideTransportMode=null] - Override transport mode (from TransportMode enum)
1096
+ * @returns {Promise<boolean>} Promise that resolves to true if message was sent successfully
1097
+ * @throws {CommandError} If message cannot be sent
1098
+ * @throws {HttpApiError} If HTTP request fails
1099
+ * @throws {MqttError} If MQTT publish fails
1100
+ * @private
1101
+ */
1102
+ async _sendMessage(device, ip, data, overrideTransportMode = null) {
1103
+ const transportMode = overrideTransportMode !== null
1104
+ ? overrideTransportMode
1105
+ : this._defaultTransportMode;
1106
+
1107
+ const method = data?.header?.method?.toUpperCase();
1108
+ const isGetMessage = method === 'GET';
1109
+
1110
+ let attemptLan = false;
1111
+ if (transportMode === TransportMode.LAN_HTTP_FIRST) {
1112
+ attemptLan = true;
1113
+ } else if (transportMode === TransportMode.LAN_HTTP_FIRST_ONLY_GET) {
1114
+ attemptLan = isGetMessage;
1115
+ }
1116
+
1117
+ if (attemptLan && ip) {
1118
+ // Skip LAN HTTP if error budget exhausted to prevent repeated failures on unreliable devices
1119
+ if (this._errorBudgetManager.isOutOfBudget(device.uuid)) {
1120
+ if (this.options.logger) {
1121
+ this.options.logger(
1122
+ `Cannot issue command via LAN (http) against device ${device.uuid} - device has no more error budget left. Using MQTT.`
1123
+ );
1124
+ }
1125
+ const shouldFallback = transportMode === TransportMode.LAN_HTTP_FIRST
1126
+ || transportMode === TransportMode.LAN_HTTP_FIRST_ONLY_GET;
1127
+
1128
+ if (shouldFallback) {
1129
+ return this.sendMessageMqtt(device, data);
1130
+ }
1131
+ attemptLan = false;
1132
+ }
1133
+
1134
+ if (attemptLan) {
1135
+ try {
1136
+ // Use shorter timeout for LAN requests to fail fast and fallback to MQTT
1137
+ const lanTimeout = Math.min(this.timeout, 1000);
1138
+ await this.sendMessageHttp(device, ip, data, lanTimeout);
1139
+ return true;
1140
+ } catch (err) {
1141
+ // Distinguish HTTP-level failures from parsing errors that occur after successful HTTP 200
1142
+ const isHttpFailure = !(err instanceof HttpApiError) ||
1143
+ (err instanceof HttpApiError && err.httpStatusCode !== null && err.httpStatusCode !== undefined);
1144
+
1145
+ if (isHttpFailure) {
1146
+ this._errorBudgetManager.notifyError(device.uuid);
1147
+ }
1148
+
1149
+ if (this.options.logger) {
1150
+ this.options.logger(
1151
+ `An error occurred while attempting to send a message over internal LAN to device ${device.uuid}. Retrying with MQTT transport.`
1152
+ );
1153
+ }
1154
+
1155
+ const shouldFallback = transportMode === TransportMode.LAN_HTTP_FIRST
1156
+ || transportMode === TransportMode.LAN_HTTP_FIRST_ONLY_GET;
1157
+
1158
+ if (shouldFallback) {
1159
+ return this.sendMessageMqtt(device, data);
1160
+ }
1161
+ throw err;
1162
+ }
1163
+ }
1164
+ }
1165
+
1166
+ return this.sendMessageMqtt(device, data);
1167
+ }
1168
+
1169
+ /**
1170
+ * Connects a device to the manager and sets up event handling
1171
+ *
1172
+ * Forwards device events to manager and initializes MQTT connection.
1173
+ * Device abilities are already known at this point (queried before device creation).
1174
+ * The device should already be registered in the device registry before calling this method.
1175
+ *
1176
+ * @param {MerossDevice|MerossHubDevice} deviceObj - Device instance to connect
1177
+ * @param {Object} dev - Device definition object from API
1178
+ * @returns {Promise<void>} Promise that resolves when device connection is set up
1179
+ */
1180
+ async connectDevice(deviceObj, dev) {
1181
+ const deviceId = deviceObj.uuid;
1182
+
1183
+ deviceObj.on('close', (error) => {
1184
+ this.emit('close', deviceId, error);
1185
+ });
1186
+ deviceObj.on('error', (error) => {
1187
+ this.emit('error', error, deviceId);
1188
+ });
1189
+ deviceObj.on('rawSendData', (message) => {
1190
+ this.emit('rawData', deviceId, message);
1191
+ });
1192
+ deviceObj.on('pushNotification', (notification) => {
1193
+ this.emit('pushNotification', deviceId, notification, deviceObj);
1194
+ });
1195
+
1196
+ if (this._subscriptionManager) {
1197
+ deviceObj.on('stateChange', () => {
1198
+ // Subscription manager has its own listeners for stateChange events
1199
+ });
1200
+ }
1201
+
1202
+ deviceObj.on('connected', () => {
1203
+ this.emit('connected', deviceId);
1204
+
1205
+ // Refresh hub state after connection to populate subdevice statuses.
1206
+ // Delay ensures MQTT connection is fully established and stable before
1207
+ // querying devices, preventing race conditions during connection setup
1208
+ if (typeof deviceObj.getSubdevices === 'function') {
1209
+ const subdevices = deviceObj.getSubdevices();
1210
+ if (subdevices.length > 0) {
1211
+ setTimeout(async () => {
1212
+ try {
1213
+ await deviceObj.refreshState();
1214
+ } catch (err) {
1215
+ const logger = this.options?.logger;
1216
+ if (logger && typeof logger === 'function') {
1217
+ logger(`Failed to refresh hub ${deviceId} subdevice statuses: ${err.message}`);
1218
+ }
1219
+ }
1220
+ }, 2000);
1221
+ }
1222
+ }
1223
+ });
1224
+
1225
+ this.emit('deviceInitialized', deviceId, dev, deviceObj);
1226
+
1227
+ await this.initMqtt(dev);
1228
+
1229
+ deviceObj.connect();
1230
+ }
1231
+
1232
+ /**
1233
+ * Gets or creates the SubscriptionManager instance
1234
+ *
1235
+ * Provides automatic polling and data provisioning for devices.
1236
+ * Uses lazy initialization to create the manager only when needed.
1237
+ *
1238
+ * @param {Object} [options={}] - Configuration options for SubscriptionManager
1239
+ * @returns {SubscriptionManager} SubscriptionManager instance
1240
+ */
1241
+ getSubscriptionManager(options = {}) {
1242
+ if (!this._subscriptionManager) {
1243
+ const SubscriptionManager = require('./subscription');
1244
+ this._subscriptionManager = new SubscriptionManager(this, {
1245
+ logger: this.options?.logger,
1246
+ ...options
1247
+ });
1248
+ }
1249
+ return this._subscriptionManager;
1250
+ }
1251
+
1252
+ }
1253
+
1254
+ /**
1255
+ * Registry for managing Meross devices and subdevices.
1256
+ *
1257
+ * Maintains indexes for efficient device lookups across base devices and subdevices.
1258
+ * Base devices can be looked up by UUID, while internal IDs enable unified lookup
1259
+ * for both base devices and subdevices.
1260
+ *
1261
+ * Internal IDs unify device identification:
1262
+ * - Base devices: `#BASE:{uuid}`
1263
+ * - Subdevices: `#SUB:{hubUuid}:{subdeviceId}`
1264
+ *
1265
+ * @class DeviceRegistry
1266
+ */
1267
+ class DeviceRegistry {
1268
+ /**
1269
+ * Creates a new device registry.
1270
+ *
1271
+ * Initializes two indexes: one by internal ID (supports all devices) and one by UUID
1272
+ * (base devices only). The UUID index enables O(1) lookups for base devices without
1273
+ * requiring internal ID generation.
1274
+ */
1275
+ constructor() {
1276
+ this._devicesByInternalId = new Map();
1277
+ this._devicesByUuid = new Map();
1278
+ }
1279
+
1280
+ /**
1281
+ * Generates an internal ID for a device or subdevice.
1282
+ *
1283
+ * Internal IDs provide a unified identifier format that works for both base devices
1284
+ * and subdevices, enabling consistent lookup operations regardless of device type.
1285
+ * The prefix distinguishes device types to prevent ID collisions.
1286
+ *
1287
+ * @param {string} uuid - Device UUID (for base devices) or hub UUID (for subdevices)
1288
+ * @param {boolean} [isSubdevice=false] - Whether this is a subdevice
1289
+ * @param {string} [hubUuid] - Hub UUID (required if isSubdevice is true)
1290
+ * @param {string} [subdeviceId] - Subdevice ID (required if isSubdevice is true)
1291
+ * @returns {string} Internal ID string
1292
+ */
1293
+ static generateInternalId(uuid, isSubdevice = false, hubUuid = null, subdeviceId = null) {
1294
+ if (isSubdevice) {
1295
+ return `#SUB:${hubUuid}:${subdeviceId}`;
1296
+ }
1297
+ return `#BASE:${uuid}`;
1298
+ }
1299
+
1300
+ /**
1301
+ * Registers a device in the registry.
1302
+ *
1303
+ * Prevents duplicate registrations by checking for existing internal ID.
1304
+ * Base devices are indexed by both internal ID and UUID to support both lookup methods.
1305
+ *
1306
+ * @param {MerossDevice|MerossHubDevice|MerossSubDevice} device - Device instance to register
1307
+ * @returns {void}
1308
+ */
1309
+ registerDevice(device) {
1310
+ const internalId = this._getInternalId(device);
1311
+
1312
+ if (this._devicesByInternalId.has(internalId)) {
1313
+ return;
1314
+ }
1315
+
1316
+ this._devicesByInternalId.set(internalId, device);
1317
+
1318
+ const uuid = device.uuid;
1319
+ if (uuid && !this._isSubdevice(device)) {
1320
+ this._devicesByUuid.set(uuid, device);
1321
+ }
1322
+ }
1323
+
1324
+ /**
1325
+ * Removes a device from the registry.
1326
+ *
1327
+ * Removes the device from all indexes to maintain consistency. Base devices are removed
1328
+ * from both the internal ID and UUID indexes, while subdevices are only in the internal ID index.
1329
+ *
1330
+ * @param {MerossDevice|MerossHubDevice|MerossSubDevice} device - Device instance to remove
1331
+ * @returns {void}
1332
+ */
1333
+ removeDevice(device) {
1334
+ const internalId = this._getInternalId(device);
1335
+ const uuid = device.uuid;
1336
+
1337
+ this._devicesByInternalId.delete(internalId);
1338
+
1339
+ if (uuid && !this._isSubdevice(device)) {
1340
+ this._devicesByUuid.delete(uuid);
1341
+ }
1342
+ }
1343
+
1344
+ /**
1345
+ * Looks up a device by its internal ID.
1346
+ *
1347
+ * Uses the unified internal ID format to support lookups for both base devices and subdevices
1348
+ * through a single method, eliminating the need for type-specific lookup logic.
1349
+ *
1350
+ * @param {string} internalId - Internal ID (e.g., "#BASE:{uuid}" or "#SUB:{hubUuid}:{subdeviceId}")
1351
+ * @returns {MerossDevice|MerossHubDevice|MerossSubDevice|null} Device instance, or null if not found
1352
+ */
1353
+ lookupByInternalId(internalId) {
1354
+ return this._devicesByInternalId.get(internalId) || null;
1355
+ }
1356
+
1357
+ /**
1358
+ * Looks up a base device by its UUID.
1359
+ *
1360
+ * Provides O(1) lookup for base devices using the UUID index. Subdevices do not have
1361
+ * unique UUIDs and must be looked up by internal ID instead.
1362
+ *
1363
+ * @param {string} uuid - Device UUID
1364
+ * @returns {MerossDevice|MerossHubDevice|null} Device instance, or null if not found
1365
+ */
1366
+ lookupByUuid(uuid) {
1367
+ return this._devicesByUuid.get(uuid) || null;
1368
+ }
1369
+
1370
+ /**
1371
+ * Gets all registered devices.
1372
+ *
1373
+ * Returns both base devices and subdevices from the internal ID index, which contains
1374
+ * all registered devices regardless of type.
1375
+ *
1376
+ * @returns {Array<MerossDevice|MerossHubDevice|MerossSubDevice>} Array of all registered devices
1377
+ */
1378
+ getAllDevices() {
1379
+ return Array.from(this._devicesByInternalId.values());
1380
+ }
1381
+
1382
+ /**
1383
+ * Finds devices matching the specified filters.
1384
+ *
1385
+ * This method supports multiple filter criteria that can be combined.
1386
+ * All filters are applied as AND conditions (device must match all specified filters).
1387
+ *
1388
+ * @param {Object} [filters={}] - Filter criteria
1389
+ * @param {Array<string>} [filters.device_uuids] - Array of device UUIDs to match (snake_case to match API)
1390
+ * @param {Array<string>} [filters.internal_ids] - Array of internal IDs to match
1391
+ * @param {string} [filters.device_type] - Device type to match (e.g., "mss310")
1392
+ * @param {string} [filters.device_name] - Device name to match
1393
+ * @param {number} [filters.online_status] - Online status to match (0 = offline, 1 = online)
1394
+ * @param {string|Array<string>|Function} [filters.device_class] - Device capability/class filter.
1395
+ * Can be:
1396
+ * - String: 'light', 'thermostat', 'toggle', 'rollerShutter', 'garageDoor', 'diffuser', 'spray', 'hub'
1397
+ * - Array of strings: matches if device has any of the capabilities
1398
+ * - Function: custom filter function that receives device and returns boolean
1399
+ * @returns {Array<MerossDevice|MerossHubDevice|MerossSubDevice>} Array of matching devices
1400
+ */
1401
+ findDevices(filters = {}) {
1402
+ let devices = this.getAllDevices();
1403
+
1404
+ if (filters.device_uuids && Array.isArray(filters.device_uuids) && filters.device_uuids.length > 0) {
1405
+ const uuidSet = new Set(filters.device_uuids);
1406
+ devices = devices.filter(device => {
1407
+ const uuid = device.uuid;
1408
+ return uuidSet.has(uuid);
1409
+ });
1410
+ }
1411
+
1412
+ if (filters.internal_ids && Array.isArray(filters.internal_ids) && filters.internal_ids.length > 0) {
1413
+ const idSet = new Set(filters.internal_ids);
1414
+ devices = devices.filter(device => {
1415
+ return idSet.has(this._getInternalId(device));
1416
+ });
1417
+ }
1418
+
1419
+ if (filters.device_type) {
1420
+ devices = devices.filter(device => {
1421
+ const deviceType = device.deviceType;
1422
+ return deviceType === filters.device_type;
1423
+ });
1424
+ }
1425
+
1426
+ if (filters.device_name) {
1427
+ devices = devices.filter(device => {
1428
+ const name = device.name;
1429
+ return name === filters.device_name;
1430
+ });
1431
+ }
1432
+
1433
+ if (filters.online_status !== undefined) {
1434
+ devices = devices.filter(device => {
1435
+ const status = device.onlineStatus || device._onlineStatus;
1436
+ return status === filters.online_status;
1437
+ });
1438
+ }
1439
+
1440
+ if (filters.device_class) {
1441
+ const capabilityChecks = Array.isArray(filters.device_class)
1442
+ ? filters.device_class
1443
+ : [filters.device_class];
1444
+
1445
+ devices = devices.filter(device => {
1446
+ return capabilityChecks.some(check => {
1447
+ if (typeof check === 'function') {
1448
+ return check(device);
1449
+ } else if (typeof check === 'string') {
1450
+ return this._hasCapability(device, check);
1451
+ }
1452
+ return false;
1453
+ });
1454
+ });
1455
+ }
1456
+
1457
+ return devices;
1458
+ }
1459
+
1460
+ /**
1461
+ * Checks if a device has a specific capability.
1462
+ *
1463
+ * Determines capability by checking for the presence of device-specific methods or
1464
+ * constructor names, rather than relying on device type strings which may vary.
1465
+ *
1466
+ * @param {MerossDevice|MerossHubDevice|MerossSubDevice} device - Device to check
1467
+ * @param {string} capability - Capability name (e.g., 'light', 'thermostat', 'toggle')
1468
+ * @returns {boolean} True if device has the capability, false otherwise
1469
+ * @private
1470
+ */
1471
+ _hasCapability(device, capability) {
1472
+ const capabilityMap = {
1473
+ 'light': () => typeof device.getLightState === 'function' ||
1474
+ typeof device.getCachedLightState === 'function',
1475
+ 'thermostat': () => typeof device.getThermostatMode === 'function' ||
1476
+ typeof device.getCachedThermostatState === 'function',
1477
+ 'toggle': () => typeof device.setToggle === 'function' ||
1478
+ typeof device.setToggleX === 'function',
1479
+ 'rollerShutter': () => typeof device.getRollerShutterState === 'function',
1480
+ 'garageDoor': () => typeof device.getGarageDoorState === 'function',
1481
+ 'diffuser': () => typeof device.getDiffuserLightState === 'function',
1482
+ 'spray': () => typeof device.getSprayState === 'function',
1483
+ 'hub': () => typeof device.getSubdevices === 'function'
1484
+ };
1485
+
1486
+ const check = capabilityMap[capability.toLowerCase()];
1487
+ return check ? check() : false;
1488
+ }
1489
+
1490
+ /**
1491
+ * Gets or generates the internal ID for a device.
1492
+ *
1493
+ * Caches the generated ID on the device to avoid repeated computation. Handles
1494
+ * multiple property access patterns for subdevices to support different device implementations.
1495
+ *
1496
+ * @param {MerossDevice|MerossHubDevice|MerossSubDevice} device - Device instance
1497
+ * @returns {string} Internal ID string
1498
+ * @throws {UnknownDeviceTypeError} If required identifiers are missing
1499
+ * @private
1500
+ */
1501
+ _getInternalId(device) {
1502
+ const { UnknownDeviceTypeError } = require('./model/exception');
1503
+
1504
+ if (device._internalId) {
1505
+ return device._internalId;
1506
+ }
1507
+
1508
+ if (this._isSubdevice(device)) {
1509
+ const hubUuid = device.hub?.uuid || device._hub?.uuid || device.hub?.dev?.uuid || device._hub?.dev?.uuid;
1510
+ const subdeviceId = device.subdeviceId || device._subdeviceId;
1511
+
1512
+ if (!hubUuid || !subdeviceId) {
1513
+ throw new UnknownDeviceTypeError('Cannot generate internal ID for subdevice: missing hub UUID or subdevice ID');
1514
+ }
1515
+
1516
+ const internalId = MerossManager.DeviceRegistry.generateInternalId(hubUuid, true, hubUuid, subdeviceId);
1517
+ device._internalId = internalId;
1518
+ return internalId;
1519
+ }
1520
+
1521
+ const uuid = device.uuid;
1522
+ if (!uuid) {
1523
+ throw new UnknownDeviceTypeError('Cannot generate internal ID: device missing UUID');
1524
+ }
1525
+
1526
+ const internalId = MerossManager.DeviceRegistry.generateInternalId(uuid);
1527
+ device._internalId = internalId;
1528
+ return internalId;
1529
+ }
1530
+
1531
+ /**
1532
+ * Determines if a device is a subdevice.
1533
+ *
1534
+ * Checks for the presence of subdevice-specific properties rather than relying on
1535
+ * device type, as property names may vary between implementations.
1536
+ *
1537
+ * @param {MerossDevice|MerossHubDevice|MerossSubDevice} device - Device to check
1538
+ * @returns {boolean} True if device is a subdevice, false otherwise
1539
+ * @private
1540
+ */
1541
+ _isSubdevice(device) {
1542
+ return !!(device.subdeviceId || device._subdeviceId) && !!(device.hub || device._hub);
1543
+ }
1544
+
1545
+ /**
1546
+ * Clears all devices from the registry.
1547
+ *
1548
+ * Disconnects all devices before removal to ensure proper cleanup of connections
1549
+ * and event listeners. Both indexes are cleared to maintain consistency.
1550
+ *
1551
+ * @returns {void}
1552
+ */
1553
+ clear() {
1554
+ const devices = this.getAllDevices();
1555
+ devices.forEach(device => {
1556
+ if (device.disconnect) {
1557
+ device.disconnect();
1558
+ }
1559
+ });
1560
+ this._devicesByInternalId.clear();
1561
+ this._devicesByUuid.clear();
1562
+ }
1563
+
1564
+ /**
1565
+ * Gets the total number of devices registered (including subdevices).
1566
+ *
1567
+ * @returns {number} Total number of registered devices
1568
+ */
1569
+ get size() {
1570
+ return this._devicesByInternalId.size;
1571
+ }
1572
+ }
1573
+
1574
+ // Attach DeviceRegistry as nested class to MerossManager
1575
+ MerossManager.DeviceRegistry = DeviceRegistry;
1576
+
1577
+ /**
1578
+ * Events emitted by MerossManager instance
1579
+ *
1580
+ * @typedef {Object} MerossCloudEvents
1581
+ * @property {Function} deviceInitialized - Emitted when a device is initialized
1582
+ * @param {string} deviceId - Device UUID
1583
+ * @param {Object} deviceDef - Device definition object from API
1584
+ * @param {MerossDevice|MerossHubDevice} device - Device instance
1585
+ * @property {Function} connected - Emitted when a device connects
1586
+ * @param {string} deviceId - Device UUID
1587
+ * @property {Function} close - Emitted when a device connection closes
1588
+ * @param {string} deviceId - Device UUID
1589
+ * @param {string|null} error - Error message if connection closed due to error
1590
+ * @property {Function} error - Emitted when an error occurs
1591
+ * @param {string} deviceId - Device UUID (or null for manager-level errors)
1592
+ * @param {Error|string} error - Error object or error message
1593
+ * @property {Function} reconnect - Emitted when a device reconnects
1594
+ * @param {string} deviceId - Device UUID
1595
+ * @property {Function} data - Emitted when data is received from a device
1596
+ * @param {string} deviceId - Device UUID
1597
+ * @param {Object} payload - Data payload
1598
+ * @property {Function} pushNotification - Emitted when a push notification is received
1599
+ * @param {string} deviceId - Device UUID
1600
+ * @param {GenericPushNotification} notification - Push notification object
1601
+ * @param {MerossDevice|MerossHubDevice} device - Device instance
1602
+ * @property {Function} rawData - Emitted with raw message data (for debugging)
1603
+ * @param {string} deviceId - Device UUID
1604
+ * @param {Object} message - Raw message object
1605
+ */
1606
+
1607
+ module.exports = MerossManager;
1608
+ module.exports.DeviceRegistry = DeviceRegistry;
1609
+