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/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
|
+
|