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