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,766 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const { v4: uuidv4 } = require('uuid');
5
+ const {
6
+ SECRET,
7
+ MEROSS_DOMAIN,
8
+ LOGIN_URL,
9
+ LOGOUT_URL,
10
+ LOG_URL,
11
+ DEV_LIST,
12
+ SUBDEV_LIST
13
+ } = require('./model/constants');
14
+ const { getErrorMessage } = require('./model/http/error-codes');
15
+ const { HttpStatsCounter } = require('./utilities/stats');
16
+ const {
17
+ HttpApiError,
18
+ TokenExpiredError,
19
+ TooManyTokensError,
20
+ WrongMFAError,
21
+ MFARequiredError,
22
+ BadDomainError
23
+ } = require('./model/http/exception');
24
+ const { AuthenticationError, ApiLimitReachedError, ResourceAccessDeniedError } = require('./model/exception');
25
+
26
+ /**
27
+ * Generates a random alphanumeric string (nonce) for API request signing.
28
+ *
29
+ * Nonces are required by the Meross API to prevent replay attacks. Each request
30
+ * must include a unique nonce that is combined with the timestamp and secret
31
+ * to generate the request signature.
32
+ *
33
+ * @param {number} length - Length of the string to generate
34
+ * @returns {string} Random alphanumeric string
35
+ * @private
36
+ */
37
+ function _generateNonce(length) {
38
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
39
+ let nonce = '';
40
+ for (let i = 0; i < length; i++) {
41
+ nonce += chars.charAt(Math.floor(Math.random() * chars.length));
42
+ }
43
+ return nonce;
44
+ }
45
+
46
+ /**
47
+ * Encodes parameters to base64 for API requests.
48
+ *
49
+ * The Meross API requires request parameters to be base64-encoded JSON strings
50
+ * in the request payload, rather than sending JSON directly.
51
+ *
52
+ * @param {Object} parameters - Parameters object to encode
53
+ * @returns {string} Base64-encoded JSON string
54
+ * @private
55
+ */
56
+ function _encodeParams(parameters) {
57
+ const jsonstring = JSON.stringify(parameters);
58
+ return Buffer.from(jsonstring).toString('base64');
59
+ }
60
+
61
+
62
+ /**
63
+ * HTTP client for Meross cloud API communication
64
+ *
65
+ * Centralizes HTTP communication with Meross cloud servers to ensure consistent request signing,
66
+ * error handling, and domain management. The Meross API requires MD5-based request signatures
67
+ * and may redirect requests to region-specific domains, which this client handles automatically.
68
+ *
69
+ * @class
70
+ * @example
71
+ * const client = new MerossHttpClient({
72
+ * logger: console.log,
73
+ * timeout: 10000,
74
+ * enableStats: true
75
+ * });
76
+ *
77
+ * const loginResult = await client.login('email@example.com', 'password');
78
+ * const devices = await client.getDevices();
79
+ */
80
+ class MerossHttpClient {
81
+ /**
82
+ * Creates a new MerossHttpClient instance
83
+ *
84
+ * @param {Object} [options={}] - Configuration options
85
+ * @param {Function} [options.logger] - Optional logger function for debug output
86
+ * @param {number} [options.timeout=10000] - Request timeout in milliseconds
87
+ * @param {boolean} [options.autoRetryOnBadDomain=true] - Automatically retry on domain redirect errors
88
+ * @param {string|null} [options.mqttDomain=null] - MQTT domain (set automatically after login)
89
+ * @param {boolean} [options.enableStats=false] - Enable HTTP statistics tracking
90
+ * @param {number} [options.maxStatsSamples=1000] - Maximum number of samples to keep in statistics
91
+ * @param {string} [options.userAgent] - Custom User-Agent header (default: iOS app user agent)
92
+ * @param {string} [options.appVersion] - Custom AppVersion header (default: '3.22.4')
93
+ * @param {string} [options.appType] - Custom AppType header (default: 'iOS')
94
+ */
95
+ constructor(options) {
96
+ this.options = options || {};
97
+ this.token = null;
98
+ this.key = null;
99
+ this.userId = null;
100
+ this.userEmail = null;
101
+ this.httpDomain = MEROSS_DOMAIN;
102
+ this.mqttDomain = options.mqttDomain || null;
103
+ this.timeout = options.timeout || 10000;
104
+ this.autoRetryOnBadDomain = options.autoRetryOnBadDomain !== undefined ? !!options.autoRetryOnBadDomain : true;
105
+ this.httpRequestCounter = 0;
106
+ this._logIdentifier = _generateNonce(30) + uuidv4();
107
+
108
+ const enableStats = options.enableStats === true;
109
+ this._httpStatsCounter = enableStats ? new HttpStatsCounter(options.maxStatsSamples || 1000) : null;
110
+ }
111
+
112
+ /**
113
+ * Sets the authentication token
114
+ *
115
+ * Required for all authenticated API calls after login. The token is included in the
116
+ * Authorization header for each request.
117
+ *
118
+ * @param {string} token - Authentication token from login
119
+ */
120
+ setToken(token) {
121
+ this.token = token;
122
+ }
123
+
124
+ /**
125
+ * Sets authentication credentials (token, key, userId, userEmail)
126
+ *
127
+ * @param {string} token - Authentication token
128
+ * @param {string} key - Encryption key
129
+ * @param {string} userId - User ID
130
+ * @param {string} userEmail - User email
131
+ */
132
+ setCredentials(token, key, userId, userEmail) {
133
+ this.token = token;
134
+ this.key = key;
135
+ this.userId = userId;
136
+ this.userEmail = userEmail;
137
+ }
138
+
139
+ /**
140
+ * Sets the HTTP API domain
141
+ *
142
+ * Used when the API redirects to a region-specific domain (e.g., EU vs US servers).
143
+ * Typically set automatically during login, but can be overridden if needed.
144
+ *
145
+ * @param {string} domain - HTTP API domain (e.g., 'iotx-eu.meross.com')
146
+ */
147
+ setHttpDomain(domain) {
148
+ this.httpDomain = domain;
149
+ }
150
+
151
+ /**
152
+ * Sets the MQTT domain
153
+ *
154
+ * Used for MQTT connections to receive device updates. Typically set automatically
155
+ * during login based on the user's region, but can be overridden if needed.
156
+ *
157
+ * @param {string|null} domain - MQTT domain (e.g., 'eu-iotx.meross.com') or null
158
+ */
159
+ setMqttDomain(domain) {
160
+ this.mqttDomain = domain;
161
+ }
162
+
163
+ /**
164
+ * Gets the HTTP statistics counter (if statistics tracking is enabled)
165
+ *
166
+ * @returns {HttpStatsCounter|null} Statistics counter or null if not enabled
167
+ */
168
+ get stats() {
169
+ return this._httpStatsCounter;
170
+ }
171
+
172
+ /**
173
+ * Prepares an authenticated HTTP request with signing, headers, and payload
174
+ *
175
+ * Generates nonce, timestamp, encodes parameters, creates MD5 signature,
176
+ * and builds request headers and payload according to Meross API requirements.
177
+ *
178
+ * @param {string} endpoint - API endpoint path (e.g., '/v1/Auth/signIn')
179
+ * @param {Object} paramsData - Request parameters object to be encoded and sent
180
+ * @returns {Object} Request configuration object
181
+ * @returns {Object} returns.headers - HTTP headers for the request
182
+ * @returns {Object} returns.payload - Request payload with signature
183
+ * @returns {string} returns.url - Full URL for the request
184
+ * @private
185
+ */
186
+ _prepareAuthenticatedRequest(endpoint, paramsData) {
187
+ const nonce = _generateNonce(16);
188
+ const timestampMillis = Date.now();
189
+ const loginParams = _encodeParams(paramsData);
190
+
191
+ // Meross API requires MD5 signature of secret + timestamp + nonce + encoded params
192
+ const datatosign = SECRET + timestampMillis + nonce + loginParams;
193
+ const md5hash = crypto.createHash('md5').update(datatosign).digest('hex');
194
+ const headers = {
195
+ 'Authorization': `Basic ${this.token || ''}`,
196
+ 'Vendor': 'meross',
197
+ 'AppVersion': this.options.appVersion || '3.22.4',
198
+ 'AppType': this.options.appType || 'iOS',
199
+ 'AppLanguage': 'en',
200
+ 'User-Agent': this.options.userAgent || 'intellect_socket/3.22.4 (iPhone; iOS 17.2; Scale/2.00)',
201
+ 'Content-Type': 'application/json'
202
+ };
203
+
204
+ const payload = {
205
+ 'params': loginParams,
206
+ 'sign': md5hash,
207
+ 'timestamp': timestampMillis,
208
+ nonce
209
+ };
210
+
211
+ const url = `https://${this.httpDomain}${endpoint}`;
212
+
213
+ return { headers, payload, url };
214
+ }
215
+
216
+ /**
217
+ * Executes an HTTP POST request with timeout handling and response parsing.
218
+ *
219
+ * Uses AbortController to enforce request timeouts, preventing indefinite
220
+ * hangs when devices are unreachable. Parses JSON responses and throws
221
+ * HttpApiError for non-200 status codes to provide consistent error handling.
222
+ *
223
+ * @param {string} url - Full URL for the request
224
+ * @param {Object} headers - HTTP headers for the request
225
+ * @param {Object} payload - Request payload
226
+ * @param {number} requestCounter - Request counter for logging
227
+ * @returns {Promise<Object>} Promise that resolves with response data
228
+ * @returns {Response} returns.response - Fetch Response object
229
+ * @returns {Object} returns.body - Parsed JSON response body
230
+ * @returns {string} returns.bodyText - Raw response text
231
+ * @throws {HttpApiError} If HTTP status is not 200
232
+ * @throws {Error} If request timeout occurs
233
+ * @private
234
+ */
235
+ async _executeHttpRequest(url, headers, payload, requestCounter) {
236
+ const controller = new AbortController();
237
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
238
+
239
+ let response;
240
+ try {
241
+ response = await fetch(url, {
242
+ method: 'POST',
243
+ headers,
244
+ body: JSON.stringify(payload),
245
+ signal: controller.signal
246
+ });
247
+ clearTimeout(timeoutId);
248
+ } catch (error) {
249
+ clearTimeout(timeoutId);
250
+ if (error.name === 'AbortError') {
251
+ throw new Error('Request timeout');
252
+ }
253
+ throw error;
254
+ }
255
+
256
+ if (response.status !== 200) {
257
+ if (this.options.logger) {
258
+ this.options.logger(`HTTP-Response (${requestCounter}) Error: Status=${response.status}`);
259
+ }
260
+ throw new HttpApiError(`HTTP ${response.status}: ${response.statusText}`, null, response.status);
261
+ }
262
+
263
+ const bodyText = await response.text();
264
+ if (this.options.logger) {
265
+ this.options.logger(`HTTP-Response (${requestCounter}) OK: ${bodyText}`);
266
+ }
267
+
268
+ let body;
269
+ try {
270
+ body = JSON.parse(bodyText);
271
+ } catch (err) {
272
+ body = {};
273
+ }
274
+
275
+ return { response, body, bodyText };
276
+ }
277
+
278
+ /**
279
+ * Extracts HTTP and API status codes from various error types
280
+ *
281
+ * Handles HttpApiError, statusCode property, and message parsing to extract
282
+ * status codes for statistics tracking.
283
+ *
284
+ * @param {Error} error - Error object to extract codes from
285
+ * @returns {Object} Extracted status codes
286
+ * @returns {number} returns.httpCode - HTTP status code (0 if not found)
287
+ * @returns {number|null} returns.apiCode - API status code (null if not found)
288
+ * @private
289
+ */
290
+ _extractErrorCodes(error) {
291
+ let httpCode = 0;
292
+ let apiCode = null;
293
+
294
+ if (error instanceof HttpApiError && error.httpStatusCode) {
295
+ httpCode = error.httpStatusCode;
296
+ if (error.apiStatusCode !== undefined && error.apiStatusCode !== null) {
297
+ apiCode = error.apiStatusCode;
298
+ }
299
+ } else if (error.statusCode) {
300
+ httpCode = error.statusCode;
301
+ } else if (error.message && error.message.includes('HTTP')) {
302
+ const match = error.message.match(/HTTP (\d+)/);
303
+ if (match) {
304
+ httpCode = parseInt(match[1], 10);
305
+ }
306
+ }
307
+
308
+ return { httpCode, apiCode };
309
+ }
310
+
311
+
312
+ /**
313
+ * Handles domain redirect (status code 1030) with retry logic
314
+ *
315
+ * Validates retry count, checks auto-retry setting, updates domain and MQTT domain,
316
+ * and returns recursive call to authenticatedPost or throws BadDomainError.
317
+ *
318
+ * @param {string} endpoint - API endpoint path
319
+ * @param {Object} paramsData - Request parameters object
320
+ * @param {Object} body - Response body containing redirect information
321
+ * @param {number} retryCount - Current retry attempt count
322
+ * @returns {Promise<Object>} Promise that resolves with API response data after retry
323
+ * @throws {BadDomainError} If max retries exceeded or auto-retry disabled
324
+ * @private
325
+ */
326
+ async _handleDomainRedirect(endpoint, paramsData, body, retryCount) {
327
+ const MAX_RETRIES = 3;
328
+ // Remove protocol prefix if present since httpDomain should not include it
329
+ const newApiDomain = body.data.domain.startsWith('https://')
330
+ ? body.data.domain.substring(8)
331
+ : body.data.domain;
332
+ const newMqttDomain = body.data.mqttDomain;
333
+
334
+ if (retryCount >= MAX_RETRIES) {
335
+ throw new BadDomainError(
336
+ `Max retries (${MAX_RETRIES}) exceeded for domain redirect`,
337
+ newApiDomain,
338
+ newMqttDomain
339
+ );
340
+ }
341
+
342
+ if (!this.autoRetryOnBadDomain) {
343
+ throw new BadDomainError(
344
+ `Login API redirected to different region: ${newApiDomain}. Auto-retry is disabled.`,
345
+ newApiDomain,
346
+ newMqttDomain
347
+ );
348
+ }
349
+
350
+ if (this.options.logger) {
351
+ this.options.logger(
352
+ `Login API redirected to different region: ${newApiDomain}. Retrying (attempt ${retryCount + 1}/${MAX_RETRIES})...`
353
+ );
354
+ }
355
+
356
+ const oldDomain = this.httpDomain;
357
+ this.httpDomain = newApiDomain;
358
+ this.mqttDomain = newMqttDomain;
359
+
360
+ if (this.options.logger) {
361
+ this.options.logger(
362
+ `Domain changed from ${oldDomain} to ${this.httpDomain}, MQTT domain changed to ${this.mqttDomain}`
363
+ );
364
+ }
365
+
366
+ return await this.authenticatedPost(endpoint, paramsData, retryCount + 1);
367
+ }
368
+
369
+ /**
370
+ * Maps API status codes to appropriate error classes and throws them
371
+ *
372
+ * Consolidates the sequential if statements into a cleaner structure using
373
+ * a switch statement for known error codes. Throws appropriate error instances.
374
+ *
375
+ * @param {number} apiStatus - API status code from response
376
+ * @param {Object} body - Response body containing error information
377
+ * @throws {MFARequiredError} If MFA is required but code not provided (apiStatus 1033)
378
+ * @throws {WrongMFAError} If MFA code is incorrect (apiStatus 1032)
379
+ * @throws {TokenExpiredError} If authentication token has expired (apiStatus 1019, 1022, 1200)
380
+ * @throws {TooManyTokensError} If too many tokens are active (apiStatus 1301)
381
+ * @throws {AuthenticationError} If authentication fails (apiStatus 1000-1008)
382
+ * @throws {ApiLimitReachedError} If API rate limit is reached (apiStatus 1042)
383
+ * @throws {ResourceAccessDeniedError} If resource access is denied (apiStatus 1043)
384
+ * @throws {MerossError} For other API error status codes
385
+ * @private
386
+ */
387
+ _throwApiStatusError(apiStatus, body) {
388
+ const message = body.info || getErrorMessage(apiStatus);
389
+
390
+ switch (apiStatus) {
391
+ case 1033:
392
+ throw new MFARequiredError(message);
393
+ case 1032:
394
+ throw new WrongMFAError(message);
395
+ case 1019:
396
+ case 1022:
397
+ case 1200:
398
+ throw new TokenExpiredError(message, apiStatus);
399
+ case 1301:
400
+ throw new TooManyTokensError(message);
401
+ case 1042:
402
+ throw new ApiLimitReachedError(message);
403
+ case 1043:
404
+ throw new ResourceAccessDeniedError(message);
405
+ }
406
+
407
+ // Authentication failures (status codes 1000-1008) indicate invalid credentials
408
+ // or expired sessions, which should be handled differently from other errors
409
+ if (apiStatus >= 1000 && apiStatus <= 1008) {
410
+ throw new AuthenticationError(message, apiStatus);
411
+ }
412
+
413
+ const { MerossError } = require('./model/exception');
414
+ throw new MerossError(
415
+ `${apiStatus} (${getErrorMessage(apiStatus)})${body.info ? ` - ${body.info}` : ''}`,
416
+ apiStatus
417
+ );
418
+ }
419
+
420
+ /**
421
+ * Performs an authenticated POST request to the Meross HTTP API
422
+ *
423
+ * Centralizes request signing, error handling, and domain management to ensure all API calls
424
+ * follow Meross protocol requirements. The Meross API requires MD5 signatures based on a secret,
425
+ * timestamp, nonce, and encoded parameters. Domain redirects are handled automatically because
426
+ * the API may route requests to region-specific servers based on account location.
427
+ *
428
+ * @param {string} endpoint - API endpoint path (e.g., '/v1/Auth/signIn')
429
+ * @param {Object} paramsData - Request parameters object to be encoded and sent
430
+ * @param {number} [retryCount=0] - Internal retry counter (used for domain redirect retries)
431
+ * @returns {Promise<Object>} Promise that resolves with the API response data
432
+ * @throws {BadDomainError} If domain redirect occurs and max retries exceeded or auto-retry disabled
433
+ * @throws {MFARequiredError} If MFA is required but code not provided (apiStatus 1033)
434
+ * @throws {WrongMFAError} If MFA code is incorrect (apiStatus 1032)
435
+ * @throws {TokenExpiredError} If authentication token has expired (apiStatus 1019, 1022, 1200)
436
+ * @throws {TooManyTokensError} If too many tokens are active (apiStatus 1301)
437
+ * @throws {AuthenticationError} If authentication fails (apiStatus 1000-1008)
438
+ * @throws {ApiLimitReachedError} If API rate limit is reached (apiStatus 1042)
439
+ * @throws {ResourceAccessDeniedError} If resource access is denied (apiStatus 1043)
440
+ * @throws {HttpApiError} If HTTP request fails (network errors, timeouts)
441
+ * @throws {MerossError} For other API error status codes
442
+ * @private
443
+ */
444
+ async authenticatedPost(endpoint, paramsData, retryCount = 0) {
445
+ const { headers, payload, url } = this._prepareAuthenticatedRequest(endpoint, paramsData);
446
+
447
+ const requestCounter = this.httpRequestCounter++;
448
+ if (this.options.logger) {
449
+ this.options.logger(`HTTP-Call (${requestCounter}): POST ${url} headers=${JSON.stringify(headers)} body=${JSON.stringify(payload)}`);
450
+ }
451
+
452
+ try {
453
+ const { body } = await this._executeHttpRequest(url, headers, payload, requestCounter);
454
+
455
+ const apiResponseCode = body.apiStatus ?? null;
456
+
457
+ // Track statistics after parsing response to capture both HTTP and API-level status codes
458
+ if (this._httpStatsCounter) {
459
+ this._httpStatsCounter.notifyHttpRequest(url, 'POST', 200, apiResponseCode);
460
+ }
461
+
462
+ if (body.apiStatus === 0) {
463
+ return body.data;
464
+ }
465
+
466
+ // API may redirect to region-specific domain based on account location
467
+ if (body.apiStatus === 1030 && body.data && body.data.domain) {
468
+ return await this._handleDomainRedirect(endpoint, paramsData, body, retryCount);
469
+ }
470
+
471
+ // Map API status codes to specific error classes for better error handling
472
+ // upstream, allowing callers to handle different error types appropriately
473
+ this._throwApiStatusError(body.apiStatus, body);
474
+ } catch (error) {
475
+ // Track error statistics by extracting HTTP and API status codes from various error types
476
+ const { httpCode, apiCode } = this._extractErrorCodes(error);
477
+ if (this._httpStatsCounter) {
478
+ this._httpStatsCounter.notifyHttpRequest(url, 'POST', httpCode, apiCode);
479
+ }
480
+
481
+ // Preserve custom error types for proper error handling upstream
482
+ const { MerossError } = require('./model/exception');
483
+ if (error instanceof MerossError) {
484
+ throw error;
485
+ }
486
+ // Wrap fetch-related network errors for consistent error handling
487
+ if (error.name === 'TypeError' && error.message && error.message.includes('fetch')) {
488
+ throw new HttpApiError(`HTTP request failed: ${error.message}`, null, null);
489
+ }
490
+ throw error;
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Authenticates with Meross cloud API
496
+ *
497
+ * Establishes an authenticated session by exchanging credentials for tokens. The password
498
+ * is hashed with MD5 before transmission as required by the Meross API. If MFA is enabled
499
+ * on the account, the mfaCode parameter must be provided.
500
+ *
501
+ * @param {string} email - Meross account email address
502
+ * @param {string} password - Meross account password (will be hashed)
503
+ * @param {string} [mfaCode] - Multi-factor authentication code (required if MFA is enabled)
504
+ * @returns {Promise<Object>} Login result object
505
+ * @returns {string} returns.token - Authentication token
506
+ * @returns {string} returns.key - Encryption key
507
+ * @returns {string} returns.userId - User ID
508
+ * @returns {string} returns.email - User email
509
+ * @throws {AuthenticationError} If email or password is missing
510
+ * @throws {MFARequiredError} If MFA is required but code not provided
511
+ * @throws {WrongMFAError} If MFA code is incorrect
512
+ * @throws {TokenExpiredError} If token has expired
513
+ * @throws {BadDomainError} If domain redirect occurs and auto-retry fails
514
+ * @example
515
+ * const result = await client.login('email@example.com', 'password');
516
+ * client.setToken(result.token);
517
+ */
518
+ async login(email, password, mfaCode) {
519
+ if (!email) {
520
+ throw new AuthenticationError('Email missing');
521
+ }
522
+ if (!password) {
523
+ throw new AuthenticationError('Password missing');
524
+ }
525
+ // Meross API expects MD5 hash of password, not plain text
526
+ const passwordHash = crypto.createHash('md5').update(password).digest('hex');
527
+
528
+ const data = {
529
+ email,
530
+ password: passwordHash,
531
+ encryption: 1,
532
+ accountCountryCode: '--',
533
+ mobileInfo: {
534
+ resolution: '--',
535
+ carrier: '--',
536
+ deviceModel: '--',
537
+ mobileOs: process.platform,
538
+ mobileOSVersion: '--',
539
+ uuid: this._logIdentifier
540
+ },
541
+ agree: 1,
542
+ mfaCode: mfaCode || undefined
543
+ };
544
+
545
+ if (this.options.logger) {
546
+ this.options.logger(`Login to Meross${mfaCode ? ' with MFA code' : ''}`);
547
+ }
548
+ const loginResponse = await this.authenticatedPost(LOGIN_URL, data);
549
+
550
+ if (!loginResponse) {
551
+ const { MerossError } = require('./model/exception');
552
+ throw new MerossError('No valid Login Response data received');
553
+ }
554
+
555
+ // Log user activity asynchronously after successful login.
556
+ // This call is non-blocking and failures are ignored to prevent login
557
+ // from failing due to telemetry issues.
558
+ this._logUserActivity().catch(err => {
559
+ if (this.options.logger) {
560
+ this.options.logger(`Log API call failed (non-critical): ${err.message}`);
561
+ }
562
+ });
563
+
564
+ return {
565
+ token: loginResponse.token,
566
+ key: loginResponse.key,
567
+ userId: loginResponse.userid,
568
+ email: loginResponse.email,
569
+ mqttDomain: this.mqttDomain
570
+ };
571
+ }
572
+
573
+ /**
574
+ * Gets the list of devices from Meross cloud
575
+ *
576
+ * @returns {Promise<Array>} Promise that resolves with array of device objects
577
+ * @throws {HttpApiError} If API request fails
578
+ * @throws {TokenExpiredError} If authentication token has expired
579
+ * @throws {UnauthorizedError} If not authenticated
580
+ * @example
581
+ * const devices = await client.getDevices();
582
+ * console.log(`Found ${devices.length} devices`);
583
+ */
584
+ async getDevices() {
585
+ if (this.options.logger) {
586
+ this.options.logger('Get Devices from Meross cloud server');
587
+ }
588
+ const deviceList = await this.authenticatedPost(DEV_LIST, {});
589
+ return deviceList || [];
590
+ }
591
+
592
+ /**
593
+ * Gets subdevices for a hub device
594
+ *
595
+ * @param {string} deviceUuid - Hub device UUID
596
+ * @returns {Promise<Array>} Promise that resolves with array of subdevice objects
597
+ * @throws {HttpApiError} If API request fails
598
+ * @throws {TokenExpiredError} If authentication token has expired
599
+ * @throws {UnauthorizedError} If not authenticated
600
+ * @example
601
+ * const subdevices = await client.getSubDevices(hubUuid);
602
+ * console.log(`Hub has ${subdevices.length} subdevices`);
603
+ */
604
+ async getSubDevices(deviceUuid) {
605
+ if (this.options.logger) {
606
+ this.options.logger(`Get SubDevices for hub ${deviceUuid}`);
607
+ }
608
+ const subDeviceList = await this.authenticatedPost(SUBDEV_LIST, { uuid: deviceUuid });
609
+ return subDeviceList || [];
610
+ }
611
+
612
+ /**
613
+ * Logs user activity to Meross servers
614
+ *
615
+ * Called automatically after successful login. Sends client information
616
+ * for analytics and telemetry purposes.
617
+ *
618
+ * @returns {Promise<void>} Promise that resolves when log is sent
619
+ * @private
620
+ */
621
+ async _logUserActivity() {
622
+ const logData = {
623
+ system: process.platform,
624
+ vendor: 'meross',
625
+ uuid: this._logIdentifier,
626
+ extra: '',
627
+ model: process.arch,
628
+ version: process.version
629
+ };
630
+
631
+ try {
632
+ await this.authenticatedPost(LOG_URL, logData);
633
+ } catch (err) {
634
+ // Re-throw to allow caller to handle, but caller treats this as non-critical
635
+ throw err;
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Logs out from Meross cloud API
641
+ *
642
+ * Invalidates the current authentication token on the server. Should be called when
643
+ * shutting down to prevent token leakage and ensure proper session cleanup.
644
+ *
645
+ * @returns {Promise<Object|null>} Promise that resolves with logout response data (or null if empty)
646
+ * @throws {AuthenticationError} If not authenticated
647
+ * @example
648
+ * const response = await client.logout();
649
+ * console.log('Logged out successfully', response);
650
+ */
651
+ async logout() {
652
+ if (!this.token) {
653
+ throw new AuthenticationError('Not authenticated');
654
+ }
655
+ const response = await this.authenticatedPost(LOGOUT_URL, {});
656
+ return response || null;
657
+ }
658
+
659
+ /**
660
+ * Factory method: Creates an HTTP client from username/password credentials
661
+ *
662
+ * Performs login and returns an authenticated client instance.
663
+ *
664
+ * @param {Object} options - Login options
665
+ * @param {string} options.email - Meross account email address
666
+ * @param {string} options.password - Meross account password
667
+ * @param {string} [options.mfaCode] - Multi-factor authentication code
668
+ * @param {Function} [options.logger] - Optional logger function
669
+ * @param {number} [options.timeout=10000] - Request timeout in milliseconds
670
+ * @param {boolean} [options.autoRetryOnBadDomain=true] - Automatically retry on domain redirect
671
+ * @param {boolean} [options.enableStats=false] - Enable statistics tracking
672
+ * @param {number} [options.maxStatsSamples=1000] - Maximum number of samples
673
+ * @param {string} [options.userAgent] - Custom User-Agent header
674
+ * @param {string} [options.appVersion] - Custom AppVersion header
675
+ * @param {string} [options.appType] - Custom AppType header
676
+ * @returns {Promise<MerossHttpClient>} Authenticated HTTP client instance
677
+ * @static
678
+ * @example
679
+ * const client = await MerossHttpClient.fromUserPassword({
680
+ * email: 'user@example.com',
681
+ * password: 'password'
682
+ * });
683
+ */
684
+ static async fromUserPassword(options) {
685
+ const client = new MerossHttpClient({
686
+ logger: options.logger,
687
+ timeout: options.timeout,
688
+ autoRetryOnBadDomain: options.autoRetryOnBadDomain,
689
+ enableStats: options.enableStats,
690
+ maxStatsSamples: options.maxStatsSamples,
691
+ userAgent: options.userAgent,
692
+ appVersion: options.appVersion,
693
+ appType: options.appType
694
+ });
695
+
696
+ const loginResult = await client.login(options.email, options.password, options.mfaCode);
697
+ client.setCredentials(loginResult.token, loginResult.key, loginResult.userId, loginResult.email);
698
+ client.setHttpDomain(client.httpDomain);
699
+ if (loginResult.mqttDomain) {
700
+ client.setMqttDomain(loginResult.mqttDomain);
701
+ }
702
+
703
+ return client;
704
+ }
705
+
706
+ /**
707
+ * Factory method: Creates an HTTP client from saved credentials
708
+ *
709
+ * Creates a client with pre-authenticated token data. Useful for reusing
710
+ * tokens across sessions without re-authenticating.
711
+ *
712
+ * @param {Object} credentials - Saved credentials object
713
+ * @param {string} credentials.token - Authentication token
714
+ * @param {string} credentials.key - Encryption key
715
+ * @param {string} credentials.userId - User ID
716
+ * @param {string} credentials.domain - HTTP API domain
717
+ * @param {string} [credentials.mqttDomain] - MQTT domain
718
+ * @param {Function} [options.logger] - Optional logger function
719
+ * @param {number} [options.timeout=10000] - Request timeout in milliseconds
720
+ * @param {boolean} [options.autoRetryOnBadDomain=true] - Automatically retry on domain redirect
721
+ * @param {boolean} [options.enableStats=false] - Enable statistics tracking
722
+ * @param {number} [options.maxStatsSamples=1000] - Maximum number of samples
723
+ * @param {string} [options.userAgent] - Custom User-Agent header
724
+ * @param {string} [options.appVersion] - Custom AppVersion header
725
+ * @param {string} [options.appType] - Custom AppType header
726
+ * @returns {MerossHttpClient} HTTP client instance with credentials set
727
+ * @static
728
+ * @example
729
+ * const client = MerossHttpClient.fromCredentials({
730
+ * token: 'saved_token',
731
+ * key: 'saved_key',
732
+ * userId: 'user_id',
733
+ * domain: 'iotx-eu.meross.com',
734
+ * mqttDomain: 'eu-iotx.meross.com'
735
+ * });
736
+ */
737
+ static fromCredentials(credentials, options = {}) {
738
+ const client = new MerossHttpClient({
739
+ logger: options.logger,
740
+ timeout: options.timeout,
741
+ autoRetryOnBadDomain: options.autoRetryOnBadDomain,
742
+ mqttDomain: credentials.mqttDomain,
743
+ enableStats: options.enableStats,
744
+ maxStatsSamples: options.maxStatsSamples,
745
+ userAgent: options.userAgent,
746
+ appVersion: options.appVersion,
747
+ appType: options.appType
748
+ });
749
+
750
+ client.setCredentials(
751
+ credentials.token,
752
+ credentials.key,
753
+ credentials.userId,
754
+ credentials.userEmail || null
755
+ );
756
+ client.setHttpDomain(credentials.domain);
757
+ if (credentials.mqttDomain) {
758
+ client.setMqttDomain(credentials.mqttDomain);
759
+ }
760
+
761
+ return client;
762
+ }
763
+ }
764
+
765
+ module.exports = MerossHttpClient;
766
+