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.
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/index.d.ts +2344 -0
- package/index.js +131 -0
- package/lib/controller/device.js +1317 -0
- package/lib/controller/features/alarm-feature.js +89 -0
- package/lib/controller/features/child-lock-feature.js +61 -0
- package/lib/controller/features/config-feature.js +54 -0
- package/lib/controller/features/consumption-feature.js +210 -0
- package/lib/controller/features/control-feature.js +62 -0
- package/lib/controller/features/diffuser-feature.js +411 -0
- package/lib/controller/features/digest-timer-feature.js +22 -0
- package/lib/controller/features/digest-trigger-feature.js +22 -0
- package/lib/controller/features/dnd-feature.js +79 -0
- package/lib/controller/features/electricity-feature.js +144 -0
- package/lib/controller/features/encryption-feature.js +259 -0
- package/lib/controller/features/garage-feature.js +337 -0
- package/lib/controller/features/hub-feature.js +687 -0
- package/lib/controller/features/light-feature.js +408 -0
- package/lib/controller/features/presence-sensor-feature.js +297 -0
- package/lib/controller/features/roller-shutter-feature.js +456 -0
- package/lib/controller/features/runtime-feature.js +74 -0
- package/lib/controller/features/screen-feature.js +67 -0
- package/lib/controller/features/sensor-history-feature.js +47 -0
- package/lib/controller/features/smoke-config-feature.js +50 -0
- package/lib/controller/features/spray-feature.js +166 -0
- package/lib/controller/features/system-feature.js +269 -0
- package/lib/controller/features/temp-unit-feature.js +55 -0
- package/lib/controller/features/thermostat-feature.js +804 -0
- package/lib/controller/features/timer-feature.js +507 -0
- package/lib/controller/features/toggle-feature.js +223 -0
- package/lib/controller/features/trigger-feature.js +333 -0
- package/lib/controller/hub-device.js +185 -0
- package/lib/controller/subdevice.js +1537 -0
- package/lib/device-factory.js +463 -0
- package/lib/error-budget.js +138 -0
- package/lib/http-api.js +766 -0
- package/lib/manager.js +1609 -0
- package/lib/model/channel-info.js +79 -0
- package/lib/model/constants.js +119 -0
- package/lib/model/enums.js +819 -0
- package/lib/model/exception.js +363 -0
- package/lib/model/http/device.js +215 -0
- package/lib/model/http/error-codes.js +121 -0
- package/lib/model/http/exception.js +151 -0
- package/lib/model/http/subdevice.js +133 -0
- package/lib/model/push/alarm.js +112 -0
- package/lib/model/push/bind.js +97 -0
- package/lib/model/push/common.js +282 -0
- package/lib/model/push/diffuser-light.js +100 -0
- package/lib/model/push/diffuser-spray.js +83 -0
- package/lib/model/push/factory.js +229 -0
- package/lib/model/push/generic.js +115 -0
- package/lib/model/push/hub-battery.js +59 -0
- package/lib/model/push/hub-mts100-all.js +64 -0
- package/lib/model/push/hub-mts100-mode.js +59 -0
- package/lib/model/push/hub-mts100-temperature.js +62 -0
- package/lib/model/push/hub-online.js +59 -0
- package/lib/model/push/hub-sensor-alert.js +61 -0
- package/lib/model/push/hub-sensor-all.js +59 -0
- package/lib/model/push/hub-sensor-smoke.js +110 -0
- package/lib/model/push/hub-sensor-temphum.js +62 -0
- package/lib/model/push/hub-subdevicelist.js +50 -0
- package/lib/model/push/hub-togglex.js +60 -0
- package/lib/model/push/index.js +81 -0
- package/lib/model/push/online.js +53 -0
- package/lib/model/push/presence-study.js +61 -0
- package/lib/model/push/sensor-latestx.js +106 -0
- package/lib/model/push/timerx.js +63 -0
- package/lib/model/push/togglex.js +78 -0
- package/lib/model/push/triggerx.js +62 -0
- package/lib/model/push/unbind.js +34 -0
- package/lib/model/push/water-leak.js +107 -0
- package/lib/model/states/diffuser-light-state.js +119 -0
- package/lib/model/states/diffuser-spray-state.js +58 -0
- package/lib/model/states/garage-door-state.js +71 -0
- package/lib/model/states/index.js +38 -0
- package/lib/model/states/light-state.js +134 -0
- package/lib/model/states/presence-sensor-state.js +239 -0
- package/lib/model/states/roller-shutter-state.js +82 -0
- package/lib/model/states/spray-state.js +58 -0
- package/lib/model/states/thermostat-state.js +297 -0
- package/lib/model/states/timer-state.js +192 -0
- package/lib/model/states/toggle-state.js +105 -0
- package/lib/model/states/trigger-state.js +155 -0
- package/lib/subscription.js +587 -0
- package/lib/utilities/conversion.js +62 -0
- package/lib/utilities/debug.js +165 -0
- package/lib/utilities/mqtt.js +152 -0
- package/lib/utilities/network.js +53 -0
- package/lib/utilities/options.js +64 -0
- package/lib/utilities/request-queue.js +161 -0
- package/lib/utilities/ssid.js +37 -0
- package/lib/utilities/state-changes.js +66 -0
- package/lib/utilities/stats.js +687 -0
- package/lib/utilities/timer.js +310 -0
- package/lib/utilities/trigger.js +286 -0
- package/package.json +73 -0
package/lib/http-api.js
ADDED
|
@@ -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
|
+
|