meross-iot 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +153 -0
  4. package/index.d.ts +2344 -0
  5. package/index.js +131 -0
  6. package/lib/controller/device.js +1317 -0
  7. package/lib/controller/features/alarm-feature.js +89 -0
  8. package/lib/controller/features/child-lock-feature.js +61 -0
  9. package/lib/controller/features/config-feature.js +54 -0
  10. package/lib/controller/features/consumption-feature.js +210 -0
  11. package/lib/controller/features/control-feature.js +62 -0
  12. package/lib/controller/features/diffuser-feature.js +411 -0
  13. package/lib/controller/features/digest-timer-feature.js +22 -0
  14. package/lib/controller/features/digest-trigger-feature.js +22 -0
  15. package/lib/controller/features/dnd-feature.js +79 -0
  16. package/lib/controller/features/electricity-feature.js +144 -0
  17. package/lib/controller/features/encryption-feature.js +259 -0
  18. package/lib/controller/features/garage-feature.js +337 -0
  19. package/lib/controller/features/hub-feature.js +687 -0
  20. package/lib/controller/features/light-feature.js +408 -0
  21. package/lib/controller/features/presence-sensor-feature.js +297 -0
  22. package/lib/controller/features/roller-shutter-feature.js +456 -0
  23. package/lib/controller/features/runtime-feature.js +74 -0
  24. package/lib/controller/features/screen-feature.js +67 -0
  25. package/lib/controller/features/sensor-history-feature.js +47 -0
  26. package/lib/controller/features/smoke-config-feature.js +50 -0
  27. package/lib/controller/features/spray-feature.js +166 -0
  28. package/lib/controller/features/system-feature.js +269 -0
  29. package/lib/controller/features/temp-unit-feature.js +55 -0
  30. package/lib/controller/features/thermostat-feature.js +804 -0
  31. package/lib/controller/features/timer-feature.js +507 -0
  32. package/lib/controller/features/toggle-feature.js +223 -0
  33. package/lib/controller/features/trigger-feature.js +333 -0
  34. package/lib/controller/hub-device.js +185 -0
  35. package/lib/controller/subdevice.js +1537 -0
  36. package/lib/device-factory.js +463 -0
  37. package/lib/error-budget.js +138 -0
  38. package/lib/http-api.js +766 -0
  39. package/lib/manager.js +1609 -0
  40. package/lib/model/channel-info.js +79 -0
  41. package/lib/model/constants.js +119 -0
  42. package/lib/model/enums.js +819 -0
  43. package/lib/model/exception.js +363 -0
  44. package/lib/model/http/device.js +215 -0
  45. package/lib/model/http/error-codes.js +121 -0
  46. package/lib/model/http/exception.js +151 -0
  47. package/lib/model/http/subdevice.js +133 -0
  48. package/lib/model/push/alarm.js +112 -0
  49. package/lib/model/push/bind.js +97 -0
  50. package/lib/model/push/common.js +282 -0
  51. package/lib/model/push/diffuser-light.js +100 -0
  52. package/lib/model/push/diffuser-spray.js +83 -0
  53. package/lib/model/push/factory.js +229 -0
  54. package/lib/model/push/generic.js +115 -0
  55. package/lib/model/push/hub-battery.js +59 -0
  56. package/lib/model/push/hub-mts100-all.js +64 -0
  57. package/lib/model/push/hub-mts100-mode.js +59 -0
  58. package/lib/model/push/hub-mts100-temperature.js +62 -0
  59. package/lib/model/push/hub-online.js +59 -0
  60. package/lib/model/push/hub-sensor-alert.js +61 -0
  61. package/lib/model/push/hub-sensor-all.js +59 -0
  62. package/lib/model/push/hub-sensor-smoke.js +110 -0
  63. package/lib/model/push/hub-sensor-temphum.js +62 -0
  64. package/lib/model/push/hub-subdevicelist.js +50 -0
  65. package/lib/model/push/hub-togglex.js +60 -0
  66. package/lib/model/push/index.js +81 -0
  67. package/lib/model/push/online.js +53 -0
  68. package/lib/model/push/presence-study.js +61 -0
  69. package/lib/model/push/sensor-latestx.js +106 -0
  70. package/lib/model/push/timerx.js +63 -0
  71. package/lib/model/push/togglex.js +78 -0
  72. package/lib/model/push/triggerx.js +62 -0
  73. package/lib/model/push/unbind.js +34 -0
  74. package/lib/model/push/water-leak.js +107 -0
  75. package/lib/model/states/diffuser-light-state.js +119 -0
  76. package/lib/model/states/diffuser-spray-state.js +58 -0
  77. package/lib/model/states/garage-door-state.js +71 -0
  78. package/lib/model/states/index.js +38 -0
  79. package/lib/model/states/light-state.js +134 -0
  80. package/lib/model/states/presence-sensor-state.js +239 -0
  81. package/lib/model/states/roller-shutter-state.js +82 -0
  82. package/lib/model/states/spray-state.js +58 -0
  83. package/lib/model/states/thermostat-state.js +297 -0
  84. package/lib/model/states/timer-state.js +192 -0
  85. package/lib/model/states/toggle-state.js +105 -0
  86. package/lib/model/states/trigger-state.js +155 -0
  87. package/lib/subscription.js +587 -0
  88. package/lib/utilities/conversion.js +62 -0
  89. package/lib/utilities/debug.js +165 -0
  90. package/lib/utilities/mqtt.js +152 -0
  91. package/lib/utilities/network.js +53 -0
  92. package/lib/utilities/options.js +64 -0
  93. package/lib/utilities/request-queue.js +161 -0
  94. package/lib/utilities/ssid.js +37 -0
  95. package/lib/utilities/state-changes.js +66 -0
  96. package/lib/utilities/stats.js +687 -0
  97. package/lib/utilities/timer.js +310 -0
  98. package/lib/utilities/trigger.js +286 -0
  99. package/package.json +73 -0
@@ -0,0 +1,1537 @@
1
+ 'use strict';
2
+
3
+ const { MerossDevice } = require('./device');
4
+ const { OnlineStatus, SmokeAlarmStatus } = require('../model/enums');
5
+ // DeviceRegistry is nested in MerossManager but exported separately to avoid circular dependencies
6
+ const { MerossManager } = require('../manager');
7
+ const { UnknownDeviceTypeError, CommandError } = require('../model/exception');
8
+
9
+ /**
10
+ * Base class for all Meross subdevices.
11
+ *
12
+ * Subdevices connect through a hub device and route all commands through the hub.
13
+ * They cannot communicate directly with the Meross cloud, so all MQTT messages and
14
+ * HTTP requests must go through the parent hub device.
15
+ *
16
+ * Subdevices include sensors (temperature/humidity, water leak, smoke), thermostat
17
+ * valves, and other devices that connect to hub devices rather than directly to WiFi.
18
+ *
19
+ * @class MerossSubDevice
20
+ * @extends MerossDevice
21
+ * @example
22
+ * // Subdevices are typically created automatically when hubs are discovered
23
+ * const hub = devices.find(d => d instanceof MerossHubDevice);
24
+ * const subdevices = hub.getSubdevices();
25
+ *
26
+ * // Access subdevice properties
27
+ * const sensor = subdevices[0];
28
+ * console.log(`Subdevice ID: ${sensor.subdeviceId}`);
29
+ * console.log(`Hub UUID: ${sensor.hub.uuid}`);
30
+ * console.log(`Name: ${sensor.name}`);
31
+ */
32
+ class MerossSubDevice extends MerossDevice {
33
+ /**
34
+ * Creates a new MerossSubDevice instance
35
+ * @param {string} hubDeviceUuid - UUID of the hub device this subdevice belongs to
36
+ * @param {string} subdeviceId - Subdevice ID
37
+ * @param {Object} manager - The MerossCloud manager instance
38
+ * @param {Object} [kwargs] - Additional subdevice information
39
+ * @param {string} [kwargs.subDeviceType] - Subdevice type (or type)
40
+ * @param {string} [kwargs.subDeviceName] - Subdevice name (or name)
41
+ * @throws {Error} If hub device is not found or subdevice ID is missing
42
+ */
43
+ constructor(hubDeviceUuid, subdeviceId, manager, kwargs = {}) {
44
+ // eslint-disable-next-line camelcase
45
+ const hubs = manager.findDevices({ device_uuids: [hubDeviceUuid] });
46
+ if (!hubs || hubs.length < 1) {
47
+ throw new UnknownDeviceTypeError(`Specified hub device ${hubDeviceUuid} is not present`);
48
+ }
49
+ const hub = hubs[0];
50
+
51
+ super(manager, hubDeviceUuid, hub.mqttHost, hub.mqttPort);
52
+
53
+ this._hub = hub;
54
+ this._subdeviceId = subdeviceId;
55
+ this._type = kwargs.subDeviceType || kwargs.type;
56
+ this._name = kwargs.subDeviceName || kwargs.name;
57
+
58
+ if (!this._subdeviceId) {
59
+ throw new UnknownDeviceTypeError('Subdevice ID is required');
60
+ }
61
+
62
+ // Subdevices share hub's network configuration since they communicate through the hub
63
+ this._mqttHost = hub.mqttHost;
64
+ this._mqttPort = hub.mqttPort;
65
+ this._lanIp = hub.lanIp;
66
+
67
+ this._onlineStatus = OnlineStatus.UNKNOWN;
68
+ }
69
+
70
+ /**
71
+ * Gets the subdevice ID.
72
+ *
73
+ * This is the unique identifier for this subdevice within the hub.
74
+ *
75
+ * @returns {string} Subdevice ID
76
+ */
77
+ get subdeviceId() {
78
+ return this._subdeviceId;
79
+ }
80
+
81
+ /**
82
+ * Gets the hub device this subdevice belongs to.
83
+ *
84
+ * All subdevice commands are routed through this hub device.
85
+ *
86
+ * @returns {MerossHubDevice} Hub device instance
87
+ */
88
+ get hub() {
89
+ return this._hub;
90
+ }
91
+
92
+ /**
93
+ * Gets the subdevice type
94
+ * @returns {string|undefined} Subdevice type or undefined if not available
95
+ */
96
+ get type() {
97
+ return this._type;
98
+ }
99
+
100
+ /**
101
+ * Gets the subdevice name
102
+ * @returns {string} Subdevice name or subdevice ID if name not available
103
+ */
104
+ get name() {
105
+ return this._name || this._subdeviceId;
106
+ }
107
+
108
+ /**
109
+ * Gets the UUID (uses hub's UUID for MQTT routing).
110
+ *
111
+ * Subdevices use their parent hub's UUID for MQTT message routing since
112
+ * they cannot communicate directly with the Meross cloud.
113
+ *
114
+ * @returns {string} Hub device UUID
115
+ */
116
+ get uuid() {
117
+ return this._hub.uuid;
118
+ }
119
+
120
+ /**
121
+ * Gets the internal ID used for device registry.
122
+ *
123
+ * Generates and caches a composite ID combining hub UUID and subdevice ID on first
124
+ * access (format: #BASE:{hubUuid}#SUB:{subdeviceId}) to ensure unique identification
125
+ * in the device registry.
126
+ *
127
+ * @returns {string} Internal ID string
128
+ * @throws {Error} If hub UUID is missing
129
+ */
130
+ get internalId() {
131
+ if (this._internalId) {
132
+ return this._internalId;
133
+ }
134
+
135
+ const hubUuid = this._hub.uuid || this._hub.dev?.uuid;
136
+ if (!hubUuid) {
137
+ throw new UnknownDeviceTypeError('Cannot generate internal ID: hub missing UUID');
138
+ }
139
+
140
+ this._internalId = MerossManager.DeviceRegistry.generateInternalId(hubUuid, true, hubUuid, this._subdeviceId);
141
+ return this._internalId;
142
+ }
143
+
144
+ /**
145
+ * Gets the online status of the subdevice.
146
+ *
147
+ * If the hub is offline, the subdevice is also considered offline since subdevices
148
+ * cannot communicate without the hub.
149
+ *
150
+ * @returns {number} Online status from OnlineStatus enum
151
+ */
152
+ get onlineStatus() {
153
+ if (this._hub.onlineStatus !== OnlineStatus.ONLINE) {
154
+ return this._hub.onlineStatus;
155
+ }
156
+ return this._onlineStatus;
157
+ }
158
+
159
+ /**
160
+ * Refreshes the subdevice state through the hub.
161
+ *
162
+ * Delegates to the hub's refreshState() method, which updates both the hub and
163
+ * all its subdevices in a single operation. Subclasses may override to add
164
+ * subdevice-specific refresh logic.
165
+ *
166
+ * @returns {Promise<void>} Promise that resolves when state is refreshed
167
+ * @example
168
+ * await sensor.refreshState();
169
+ * // Sensor state is now updated
170
+ */
171
+ async refreshState() {
172
+ await this._hub.refreshState();
173
+ }
174
+
175
+ /**
176
+ * Gets the cached battery level percentage for this subdevice.
177
+ *
178
+ * Returns the last known battery value without fetching from the device.
179
+ * Use getBattery() to fetch fresh battery data.
180
+ *
181
+ * @returns {number|null} Cached battery level (0-100), or null if not available
182
+ * @example
183
+ * const battery = sensor.getCachedBattery();
184
+ * if (battery !== null) {
185
+ * console.log(`Battery: ${battery}%`);
186
+ * }
187
+ */
188
+ getCachedBattery() {
189
+ return this._battery !== undefined && this._battery !== null ? this._battery : null;
190
+ }
191
+
192
+ /**
193
+ * Gets the battery level percentage for this subdevice by fetching from the device.
194
+ *
195
+ * Always fetches fresh battery data from the device and updates the cache.
196
+ * Use getCachedBattery() to get the last known value without making a request.
197
+ *
198
+ * @returns {Promise<number|null>} Promise that resolves with battery level (0-100), or null if not available
199
+ * @example
200
+ * const battery = await sensor.getBattery();
201
+ * if (battery !== null) {
202
+ * console.log(`Battery: ${battery}%`);
203
+ * }
204
+ */
205
+ async getBattery() {
206
+ try {
207
+ if (typeof this._hub.getHubBattery !== 'function') {
208
+ return null;
209
+ }
210
+ const response = await this._hub.getHubBattery();
211
+ if (response && response.battery && Array.isArray(response.battery)) {
212
+ const batteryData = response.battery.find(b => b.id === this._subdeviceId);
213
+ if (batteryData && batteryData.value !== undefined && batteryData.value !== null) {
214
+ // 0xFFFFFFFF and -1 are sentinel values indicating battery reporting is unsupported
215
+ if (batteryData.value === 0xFFFFFFFF || batteryData.value === -1) {
216
+ return null;
217
+ }
218
+ this._battery = batteryData.value;
219
+ return batteryData.value;
220
+ }
221
+ }
222
+ return null;
223
+ } catch (error) {
224
+ const logger = this.cloudInst?.options?.logger || console.error;
225
+ logger(`Failed to get battery for subdevice ${this._subdeviceId}: ${error.message}`);
226
+ return null;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Publishes a message by routing it through the hub device.
232
+ *
233
+ * All subdevice commands must be routed through the hub since subdevices cannot
234
+ * communicate directly with the Meross cloud. Delegates to the hub's publishMessage() method.
235
+ *
236
+ * @param {string} method - Message method ('GET', 'SET', etc.)
237
+ * @param {string} namespace - Message namespace (e.g., 'Appliance.Hub.Sensor.TempHum')
238
+ * @param {Object} payload - Message payload (must include subdevice ID)
239
+ * @param {number|null} [transportMode=null] - Transport mode from {@link TransportMode} enum
240
+ * @returns {Promise<Object>} Promise that resolves with the response payload
241
+ * @example
242
+ * // Get sensor data
243
+ * const response = await sensor.publishMessage(
244
+ * 'GET',
245
+ * 'Appliance.Hub.Sensor.TempHum',
246
+ * { id: sensor.subdeviceId }
247
+ * );
248
+ */
249
+ async publishMessage(method, namespace, payload, transportMode = null) {
250
+ return await this._hub.publishMessage(method, namespace, payload, transportMode);
251
+ }
252
+
253
+ /**
254
+ * Maps numeric online status codes to OnlineStatus enum values.
255
+ *
256
+ * @private
257
+ * @param {number} statusValue - Numeric status code (0-3)
258
+ * @returns {number} OnlineStatus enum value
259
+ */
260
+ _mapOnlineStatus(statusValue) {
261
+ const statusMap = {
262
+ 0: OnlineStatus.NOT_ONLINE,
263
+ 1: OnlineStatus.ONLINE,
264
+ 2: OnlineStatus.OFFLINE,
265
+ 3: OnlineStatus.UPGRADING
266
+ };
267
+ return statusMap[statusValue] ?? OnlineStatus.UNKNOWN;
268
+ }
269
+
270
+ /**
271
+ * Updates online status from notification data.
272
+ *
273
+ * Handles both data.online.status pattern (from Sensor.All, Mts100.All) and
274
+ * data.status pattern (from Hub.Online namespace) for compatibility with different
275
+ * notification formats.
276
+ *
277
+ * @private
278
+ * @param {Object} data - Notification data payload
279
+ * @returns {Object|null} Object with status and lastActiveTime if present, null otherwise
280
+ */
281
+ _updateOnlineStatus(data) {
282
+ let statusValue;
283
+ let lastActiveTime;
284
+
285
+ if (data.online && data.online.status !== undefined) {
286
+ statusValue = data.online.status;
287
+ lastActiveTime = data.online.lastActiveTime;
288
+ } else if (data.status !== undefined) {
289
+ statusValue = data.status;
290
+ lastActiveTime = data.lastActiveTime;
291
+ }
292
+
293
+ if (statusValue !== undefined) {
294
+ return {
295
+ status: this._mapOnlineStatus(statusValue),
296
+ lastActiveTime
297
+ };
298
+ }
299
+
300
+ return null;
301
+ }
302
+
303
+ /**
304
+ * Handles notifications routed from hub to this subdevice.
305
+ *
306
+ * Subclasses override this method to process notification data and update cached state.
307
+ *
308
+ * @param {string} namespace - The namespace of the notification
309
+ * @param {object} data - The data payload for this subdevice
310
+ */
311
+ async handleSubdeviceNotification(namespace, data) {
312
+ this.emit('subdeviceNotification', namespace, data);
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Hub Temperature/Humidity Sensor subdevice (MS100, MS100F, MS130, etc.).
318
+ *
319
+ * This class represents temperature and humidity sensors that connect through
320
+ * a Meross hub device. It provides methods to read temperature and humidity values,
321
+ * access historical samples, and check sensor capabilities.
322
+ *
323
+ * @class HubTempHumSensor
324
+ * @extends MerossSubDevice
325
+ * @example
326
+ * const sensor = hub.getSubdevice('sensor123');
327
+ * if (sensor instanceof HubTempHumSensor) {
328
+ * // Get current temperature
329
+ * const temp = sensor.getLastSampledTemperature();
330
+ * console.log(`Temperature: ${temp}°C`);
331
+ *
332
+ * // Get current humidity
333
+ * const humidity = sensor.getLastSampledHumidity();
334
+ * console.log(`Humidity: ${humidity}%`);
335
+ *
336
+ * // Get temperature range
337
+ * const minTemp = sensor.getMinSupportedTemperature();
338
+ * const maxTemp = sensor.getMaxSupportedTemperature();
339
+ * console.log(`Range: ${minTemp}°C to ${maxTemp}°C`);
340
+ * }
341
+ */
342
+ class HubTempHumSensor extends MerossSubDevice {
343
+ /**
344
+ * Creates a new HubTempHumSensor instance
345
+ * @param {string} hubDeviceUuid - UUID of the hub device
346
+ * @param {string} subdeviceId - Subdevice ID
347
+ * @param {Object} manager - The MerossCloud manager instance
348
+ * @param {Object} [kwargs] - Additional subdevice information
349
+ */
350
+ constructor(hubDeviceUuid, subdeviceId, manager, kwargs = {}) {
351
+ super(hubDeviceUuid, subdeviceId, manager, kwargs);
352
+
353
+ this._temperature = {};
354
+ this._humidity = {};
355
+ this._battery = null;
356
+ this._lux = null;
357
+ this._samples = [];
358
+ this._lastSampledTime = null;
359
+ }
360
+
361
+ /**
362
+ * Namespace handler registry for routing notifications
363
+ * @private
364
+ */
365
+ static get NAMESPACE_HANDLERS() {
366
+ return [
367
+ {
368
+ namespace: 'Appliance.Hub.Sensor.All',
369
+ handler: '_handleSensorAll'
370
+ },
371
+ {
372
+ namespace: 'Appliance.Hub.Sensor.TempHum',
373
+ handler: '_handleSensorAll'
374
+ },
375
+ {
376
+ namespace: 'Appliance.Control.Sensor.LatestX',
377
+ handler: '_handleLatestX'
378
+ },
379
+ {
380
+ namespace: 'Appliance.Hub.Battery',
381
+ handler: '_handleBattery'
382
+ }
383
+ ];
384
+ }
385
+
386
+ /**
387
+ * Handles subdevice-specific notifications from the hub.
388
+ *
389
+ * Routes notifications to appropriate handler methods based on namespace registry.
390
+ *
391
+ * @param {string} namespace - Notification namespace
392
+ * @param {Object} data - Notification data payload
393
+ */
394
+ async handleSubdeviceNotification(namespace, data) {
395
+ await super.handleSubdeviceNotification(namespace, data);
396
+
397
+ const handler = HubTempHumSensor.NAMESPACE_HANDLERS.find(route => route.namespace === namespace);
398
+ if (handler && typeof this[handler.handler] === 'function') {
399
+ await this[handler.handler](data);
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Handles Appliance.Hub.Sensor.All and Appliance.Hub.Sensor.TempHum namespaces.
405
+ *
406
+ * @private
407
+ * @param {Object} data - Notification data payload
408
+ */
409
+ _handleSensorAll(data) {
410
+ const onlineStatus = this._updateOnlineStatus(data);
411
+ if (onlineStatus) {
412
+ this._onlineStatus = onlineStatus.status;
413
+ }
414
+
415
+ // Merge to preserve existing properties like min/max temperature ranges
416
+ if (data.temperature) {
417
+ this._temperature = { ...this._temperature, ...data.temperature };
418
+ }
419
+
420
+ if (data.humidity) {
421
+ this._humidity = { ...this._humidity, ...data.humidity };
422
+ }
423
+
424
+ if (data.battery !== undefined && data.battery !== null) {
425
+ this._battery = data.battery;
426
+ }
427
+
428
+ if (data.sample && Array.isArray(data.sample)) {
429
+ this._samples = this._parseSampleArray(data.sample);
430
+ }
431
+
432
+ if (data.syncedTime) {
433
+ this._lastSampledTime = new Date(data.syncedTime * 1000);
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Handles Appliance.Control.Sensor.LatestX namespace.
439
+ *
440
+ * @private
441
+ * @param {Object} data - Notification data payload
442
+ */
443
+ _handleLatestX(data) {
444
+ if (!data.data) {
445
+ return;
446
+ }
447
+
448
+ const tempReading = this._extractLatestReading(data.data, 'temp');
449
+ if (tempReading) {
450
+ this._temperature.latest = tempReading.value;
451
+ if (tempReading.timestamp) {
452
+ this._temperature.latestSampleTime = tempReading.timestamp;
453
+ this._lastSampledTime = new Date(tempReading.timestamp * 1000);
454
+ }
455
+ }
456
+
457
+ const humiReading = this._extractLatestReading(data.data, 'humi');
458
+ if (humiReading) {
459
+ this._humidity.latest = humiReading.value;
460
+ if (humiReading.timestamp) {
461
+ this._humidity.latestSampleTime = humiReading.timestamp;
462
+ }
463
+ }
464
+
465
+ const lightReading = this._extractLatestReading(data.data, 'light');
466
+ if (lightReading) {
467
+ this._lux = lightReading.value;
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Handles Appliance.Hub.Battery namespace.
473
+ *
474
+ * @private
475
+ * @param {Object} data - Notification data payload
476
+ */
477
+ _handleBattery(data) {
478
+ // Filter out sentinel values (0xFFFFFFFF, -1) indicating battery reporting is unsupported
479
+ if (data.value !== undefined && data.value !== null &&
480
+ data.value !== 0xFFFFFFFF && data.value !== -1) {
481
+ this._battery = data.value;
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Parses sample array into structured objects.
487
+ *
488
+ * @private
489
+ * @param {Array} samples - Array of sample data
490
+ * @returns {Array} Array of parsed sample objects
491
+ */
492
+ _parseSampleArray(samples) {
493
+ return samples.map(sample => {
494
+ const [temp, hum, fromTs, toTs] = sample;
495
+ return {
496
+ fromTs,
497
+ toTs,
498
+ temperature: temp ? parseFloat(temp) / 10 : null,
499
+ humidity: hum ? parseFloat(hum) / 10 : null
500
+ };
501
+ });
502
+ }
503
+
504
+ /**
505
+ * Extracts latest reading from LatestX data format.
506
+ *
507
+ * @private
508
+ * @param {Object} data - LatestX data object
509
+ * @param {string} type - Reading type ('temp', 'humi', 'light')
510
+ * @returns {Object|null} Reading object with value and timestamp, or null if not available
511
+ */
512
+ _extractLatestReading(data, type) {
513
+ const readingArray = data[type];
514
+ if (!Array.isArray(readingArray) || readingArray.length === 0) {
515
+ return null;
516
+ }
517
+
518
+ const readingData = readingArray[0];
519
+ if (readingData.value === undefined || readingData.value === null) {
520
+ return null;
521
+ }
522
+
523
+ return {
524
+ value: readingData.value,
525
+ timestamp: readingData.timestamp
526
+ };
527
+ }
528
+
529
+ /**
530
+ * Gets the last sampled temperature in Celsius.
531
+ *
532
+ * Temperature values are stored as tenths of degrees (×10). Handles edge case where
533
+ * some firmware versions double-scale values (×100), detected by values > 1000.
534
+ *
535
+ * @returns {number|null} Temperature in Celsius, or null if not available
536
+ * @example
537
+ * const temp = sensor.getLastSampledTemperature();
538
+ * if (temp !== null) {
539
+ * console.log(`Current temperature: ${temp}°C`);
540
+ * }
541
+ */
542
+ getLastSampledTemperature() {
543
+ const temp = this._temperature.latest;
544
+ if (temp === undefined || temp === null) {
545
+ return null;
546
+ }
547
+ const tempValue = parseFloat(temp);
548
+ if (tempValue > 1000) {
549
+ return tempValue / 100.0;
550
+ }
551
+ return tempValue / 10.0;
552
+ }
553
+
554
+ /**
555
+ * Gets the last sampled humidity percentage.
556
+ *
557
+ * @returns {number|null} Humidity percentage (0-100), or null if not available
558
+ * @example
559
+ * const humidity = sensor.getLastSampledHumidity();
560
+ * if (humidity !== null) {
561
+ * console.log(`Current humidity: ${humidity}%`);
562
+ * }
563
+ */
564
+ getLastSampledHumidity() {
565
+ const hum = this._humidity.latest;
566
+ if (hum === undefined || hum === null) {
567
+ return null;
568
+ }
569
+ return parseFloat(hum) / 10.0;
570
+ }
571
+
572
+ /**
573
+ * Gets the timestamp of the last sample
574
+ * @returns {Date|null} Date object or null if not available
575
+ */
576
+ getLastSampledTime() {
577
+ return this._lastSampledTime;
578
+ }
579
+
580
+ /**
581
+ * Gets the minimum supported temperature in Celsius
582
+ * @returns {number|null} Minimum temperature in Celsius or null if not available
583
+ */
584
+ getMinSupportedTemperature() {
585
+ return this._temperature.min ? parseFloat(this._temperature.min) / 10 : null;
586
+ }
587
+
588
+ /**
589
+ * Gets the maximum supported temperature in Celsius
590
+ * @returns {number|null} Maximum temperature in Celsius or null if not available
591
+ */
592
+ getMaxSupportedTemperature() {
593
+ return this._temperature.max ? parseFloat(this._temperature.max) / 10 : null;
594
+ }
595
+
596
+
597
+ /**
598
+ * Gets the lux (illuminance) reading.
599
+ *
600
+ * @returns {number|null} Lux value, or null if not available
601
+ * @example
602
+ * const lux = sensor.getLux();
603
+ * if (lux !== null) {
604
+ * console.log(`Light: ${lux} lx`);
605
+ * }
606
+ */
607
+ getLux() {
608
+ return this._lux !== undefined && this._lux !== null ? this._lux : null;
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Hub Thermostat Valve subdevice (MTS100v3, etc.).
614
+ *
615
+ * This class represents smart thermostat valves that connect through a Meross hub.
616
+ * It provides comprehensive control over heating/cooling modes, target temperatures,
617
+ * presets, and valve on/off state.
618
+ *
619
+ * @class HubThermostatValve
620
+ * @extends MerossSubDevice
621
+ * @example
622
+ * const valve = hub.getSubdevice('valve123');
623
+ * if (valve instanceof HubThermostatValve) {
624
+ * // Turn valve on
625
+ * await valve.turnOn();
626
+ *
627
+ * // Set target temperature
628
+ * await valve.setTargetTemperature(22); // 22°C
629
+ *
630
+ * // Set thermostat mode
631
+ * await valve.setMode(ThermostatMode.AUTO);
632
+ *
633
+ * // Check if heating
634
+ * if (valve.isHeating()) {
635
+ * console.log('Valve is currently heating');
636
+ * }
637
+ *
638
+ * // Use preset temperatures
639
+ * await valve.setPresetTemperature('comfort', 23);
640
+ * const comfortTemp = valve.getPresetTemperature('comfort');
641
+ * }
642
+ */
643
+ class HubThermostatValve extends MerossSubDevice {
644
+ /**
645
+ * Creates a new HubThermostatValve instance
646
+ * @param {string} hubDeviceUuid - UUID of the hub device
647
+ * @param {string} subdeviceId - Subdevice ID
648
+ * @param {Object} manager - The MerossCloud manager instance
649
+ * @param {Object} [kwargs] - Additional subdevice information
650
+ */
651
+ constructor(hubDeviceUuid, subdeviceId, manager, kwargs = {}) {
652
+ super(hubDeviceUuid, subdeviceId, manager, kwargs);
653
+
654
+ this._togglex = {};
655
+ this._mode = {};
656
+ this._temperature = {};
657
+ this._adjust = {};
658
+ this._scheduleBMode = null;
659
+ this._lastActiveTime = null;
660
+ }
661
+
662
+ /**
663
+ * Namespace handler registry for routing notifications.
664
+ *
665
+ * Maps namespace strings to handler methods for processing incoming notifications.
666
+ *
667
+ * @private
668
+ */
669
+ static get NAMESPACE_HANDLERS() {
670
+ return [
671
+ {
672
+ namespace: 'Appliance.Hub.Mts100.All',
673
+ handler: '_handleMts100All'
674
+ },
675
+ {
676
+ namespace: 'Appliance.Hub.ToggleX',
677
+ handler: '_handleToggleX'
678
+ },
679
+ {
680
+ namespace: 'Appliance.Hub.Mts100.Mode',
681
+ handler: '_handleMts100Mode'
682
+ },
683
+ {
684
+ namespace: 'Appliance.Hub.Mts100.Temperature',
685
+ handler: '_handleMts100Temperature'
686
+ },
687
+ {
688
+ namespace: 'Appliance.Hub.Online',
689
+ handler: '_handleOnline'
690
+ }
691
+ ];
692
+ }
693
+
694
+ /**
695
+ * Handles subdevice-specific notifications from the hub.
696
+ *
697
+ * Routes notifications to appropriate handler methods based on namespace registry.
698
+ *
699
+ * @param {string} namespace - Notification namespace
700
+ * @param {Object} data - Notification data payload
701
+ */
702
+ async handleSubdeviceNotification(namespace, data) {
703
+ await super.handleSubdeviceNotification(namespace, data);
704
+
705
+ const handler = HubThermostatValve.NAMESPACE_HANDLERS.find(route => route.namespace === namespace);
706
+ if (handler && typeof this[handler.handler] === 'function') {
707
+ await this[handler.handler](data);
708
+ }
709
+ }
710
+
711
+ /**
712
+ * Handles Appliance.Hub.Mts100.All namespace.
713
+ *
714
+ * @private
715
+ * @param {Object} data - Notification data payload
716
+ */
717
+ _handleMts100All(data) {
718
+ this._scheduleBMode = data.scheduleBMode;
719
+
720
+ const onlineStatus = this._updateOnlineStatus(data);
721
+ if (onlineStatus) {
722
+ this._onlineStatus = onlineStatus.status;
723
+ this._lastActiveTime = onlineStatus.lastActiveTime;
724
+ }
725
+
726
+ if (data.togglex) {
727
+ this._togglex = { ...this._togglex, ...data.togglex };
728
+ }
729
+ if (data.mode) {
730
+ this._mode = { ...this._mode, ...data.mode };
731
+ }
732
+ if (data.temperature) {
733
+ this._temperature = { ...this._temperature, ...data.temperature };
734
+ this._temperature.latestSampleTime = Date.now();
735
+ }
736
+ if (data.adjust) {
737
+ this._adjust = { ...this._adjust, ...data.adjust };
738
+ this._adjust.latestSampleTime = Date.now();
739
+ }
740
+ }
741
+
742
+ /**
743
+ * Handles Appliance.Hub.ToggleX namespace
744
+ * @private
745
+ * @param {Object} data - Notification data payload
746
+ */
747
+ _handleToggleX(data) {
748
+ if (data.onoff !== undefined) {
749
+ this._togglex.onoff = data.onoff;
750
+ }
751
+ }
752
+
753
+ /**
754
+ * Handles Appliance.Hub.Mts100.Mode namespace
755
+ * @private
756
+ * @param {Object} data - Notification data payload
757
+ */
758
+ _handleMts100Mode(data) {
759
+ if (data.state !== undefined) {
760
+ this._mode.state = data.state;
761
+ }
762
+ }
763
+
764
+ /**
765
+ * Handles Appliance.Hub.Mts100.Temperature namespace
766
+ * @private
767
+ * @param {Object} data - Notification data payload
768
+ */
769
+ _handleMts100Temperature(data) {
770
+ if (data) {
771
+ this._temperature = { ...this._temperature, ...data };
772
+ this._temperature.latestSampleTime = Date.now();
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Handles Appliance.Hub.Online namespace
778
+ * @private
779
+ * @param {Object} data - Notification data payload
780
+ */
781
+ _handleOnline(data) {
782
+ const onlineStatus = this._updateOnlineStatus(data);
783
+ if (onlineStatus) {
784
+ this._onlineStatus = onlineStatus.status;
785
+ this._lastActiveTime = onlineStatus.lastActiveTime;
786
+ }
787
+ }
788
+
789
+ /**
790
+ * Checks if the valve is currently on.
791
+ *
792
+ * @returns {boolean} True if valve is on, false otherwise
793
+ * @example
794
+ * if (valve.isOn()) {
795
+ * console.log('Valve is open');
796
+ * } else {
797
+ * console.log('Valve is closed');
798
+ * }
799
+ */
800
+ isOn() {
801
+ return this._togglex.onoff === 1;
802
+ }
803
+
804
+ /**
805
+ * Turns the valve on
806
+ * @returns {Promise<void>} Promise that resolves when command is sent
807
+ */
808
+ async turnOn() {
809
+ await this.publishMessage('SET', 'Appliance.Hub.ToggleX', {
810
+ togglex: [{ id: this._subdeviceId, onoff: 1, channel: 0 }]
811
+ }, null);
812
+ this._togglex.onoff = 1;
813
+ }
814
+
815
+ /**
816
+ * Turns the valve off
817
+ * @returns {Promise<void>} Promise that resolves when command is sent
818
+ */
819
+ async turnOff() {
820
+ await this.publishMessage('SET', 'Appliance.Hub.ToggleX', {
821
+ togglex: [{ id: this._subdeviceId, onoff: 0, channel: 0 }]
822
+ }, null);
823
+ this._togglex.onoff = 0;
824
+ }
825
+
826
+ /**
827
+ * Toggles the valve state (on/off)
828
+ * @returns {Promise<void>} Promise that resolves when command is sent
829
+ */
830
+ async toggle() {
831
+ if (this.isOn()) {
832
+ await this.turnOff();
833
+ } else {
834
+ await this.turnOn();
835
+ }
836
+ }
837
+
838
+ /**
839
+ * Gets the current thermostat mode
840
+ * @returns {number|undefined} Mode value from ThermostatMode enum or undefined if not available
841
+ */
842
+ getMode() {
843
+ return this._mode.state;
844
+ }
845
+
846
+ /**
847
+ * Sets the thermostat mode.
848
+ *
849
+ * @param {number} mode - Mode value from {@link ThermostatMode} enum
850
+ * @returns {Promise<void>} Promise that resolves when command is sent
851
+ * @example
852
+ * // Set to auto mode
853
+ * await valve.setMode(ThermostatMode.AUTO);
854
+ *
855
+ * // Set to heating mode
856
+ * await valve.setMode(ThermostatMode.HEAT);
857
+ */
858
+ async setMode(mode) {
859
+ await this.publishMessage('SET', 'Appliance.Hub.Mts100.Mode', {
860
+ mode: [{ id: this._subdeviceId, state: mode }]
861
+ }, null);
862
+ this._mode.state = mode;
863
+ }
864
+
865
+ /**
866
+ * Gets the target temperature in Celsius
867
+ * @returns {number|null} Target temperature in Celsius or null if not available
868
+ */
869
+ getTargetTemperature() {
870
+ const temp = this._temperature.currentSet;
871
+ if (temp === undefined || temp === null) {
872
+ return null;
873
+ }
874
+ return parseFloat(temp) / 10.0;
875
+ }
876
+
877
+ /**
878
+ * Sets the target temperature
879
+ * @param {number} temperature - Target temperature in Celsius
880
+ * @returns {Promise<void>} Promise that resolves when command is sent
881
+ */
882
+ async setTargetTemperature(temperature) {
883
+ const targetTemp = temperature * 10;
884
+ await this.publishMessage('SET', 'Appliance.Hub.Mts100.Temperature', {
885
+ temperature: [{ id: this._subdeviceId, custom: targetTemp }]
886
+ }, null);
887
+ this._temperature.currentSet = targetTemp;
888
+ }
889
+
890
+ /**
891
+ * Gets the last sampled room temperature in Celsius
892
+ * @returns {number|null} Room temperature in Celsius or null if not available
893
+ */
894
+ getLastSampledTemperature() {
895
+ const temp = this._temperature.room;
896
+ if (temp === undefined || temp === null) {
897
+ return null;
898
+ }
899
+ return parseFloat(temp) / 10.0;
900
+ }
901
+
902
+ /**
903
+ * Gets the minimum supported temperature in Celsius
904
+ * @returns {number|null} Minimum temperature in Celsius or null if not available
905
+ */
906
+ getMinSupportedTemperature() {
907
+ const temp = this._temperature.min;
908
+ if (temp === undefined || temp === null) {
909
+ return null;
910
+ }
911
+ return parseFloat(temp) / 10.0;
912
+ }
913
+
914
+ /**
915
+ * Gets the maximum supported temperature in Celsius
916
+ * @returns {number|null} Maximum temperature in Celsius or null if not available
917
+ */
918
+ getMaxSupportedTemperature() {
919
+ const temp = this._temperature.max;
920
+ if (temp === undefined || temp === null) {
921
+ return null;
922
+ }
923
+ return parseFloat(temp) / 10.0;
924
+ }
925
+
926
+ /**
927
+ * Checks if the valve is currently heating
928
+ * @returns {boolean} True if heating, false otherwise
929
+ */
930
+ isHeating() {
931
+ return this._temperature.heating === 1;
932
+ }
933
+
934
+ /**
935
+ * Checks if window open detection is active
936
+ * @returns {boolean} True if window is detected as open, false otherwise
937
+ */
938
+ isWindowOpen() {
939
+ return this._temperature.openWindow === 1;
940
+ }
941
+
942
+ /**
943
+ * Gets the list of supported temperature presets
944
+ * @returns {Array<string>} Array of preset names: ['custom', 'comfort', 'economy', 'away']
945
+ */
946
+ getSupportedPresets() {
947
+ return ['custom', 'comfort', 'economy', 'away'];
948
+ }
949
+
950
+ /**
951
+ * Gets the temperature for a specific preset
952
+ * @param {string} preset - Preset name (must be one of: 'custom', 'comfort', 'economy', 'away')
953
+ * @returns {number|null} Temperature in Celsius or null if preset not supported or not available
954
+ */
955
+ getPresetTemperature(preset) {
956
+ if (!this.getSupportedPresets().includes(preset)) {
957
+ return null;
958
+ }
959
+ const val = this._temperature[preset];
960
+ if (val === undefined || val === null) {
961
+ return null;
962
+ }
963
+ return parseFloat(val) / 10.0;
964
+ }
965
+
966
+ /**
967
+ * Sets the temperature for a specific preset
968
+ * @param {string} preset - Preset name (must be one of: 'custom', 'comfort', 'economy', 'away')
969
+ * @param {number} temperature - Temperature in Celsius
970
+ * @returns {Promise<void>} Promise that resolves when command is sent
971
+ * @throws {Error} If preset is not supported
972
+ */
973
+ async setPresetTemperature(preset, temperature) {
974
+ if (!this.getSupportedPresets().includes(preset)) {
975
+ throw new CommandError(`Preset ${preset} is not supported`, { preset }, this.uuid);
976
+ }
977
+ const targetTemp = temperature * 10;
978
+ await this.publishMessage('SET', 'Appliance.Hub.Mts100.Temperature', {
979
+ temperature: [{ id: this._subdeviceId, [preset]: targetTemp }]
980
+ }, null);
981
+ this._temperature[preset] = targetTemp;
982
+ }
983
+
984
+ /**
985
+ * Gets the temperature adjustment offset in Celsius
986
+ * @returns {number|null} Adjustment offset in Celsius or null if not available
987
+ */
988
+ getAdjust() {
989
+ const adjust = this._adjust.temperature;
990
+ if (adjust === undefined || adjust === null) {
991
+ return null;
992
+ }
993
+ return parseFloat(adjust) / 100.0;
994
+ }
995
+
996
+ /**
997
+ * Sets the temperature adjustment offset
998
+ * @param {number} temperature - Adjustment offset in Celsius
999
+ * @returns {Promise<void>} Promise that resolves when command is sent
1000
+ */
1001
+ async setAdjust(temperature) {
1002
+ const adjustTemp = temperature * 100;
1003
+ await this.publishMessage('SET', 'Appliance.Hub.Mts100.Adjust', {
1004
+ adjust: [{ id: this._subdeviceId, temperature: adjustTemp }]
1005
+ }, null);
1006
+ this._adjust.temperature = adjustTemp;
1007
+ this._adjust.latestSampleTime = Date.now();
1008
+ }
1009
+ }
1010
+
1011
+ /**
1012
+ * Hub Water Leak Sensor subdevice (MS405, MS400, etc.).
1013
+ *
1014
+ * This class represents water leak detection sensors that connect through a Meross hub.
1015
+ * It provides methods to check for water leaks, access leak event history, and monitor
1016
+ * sensor status.
1017
+ *
1018
+ * @class HubWaterLeakSensor
1019
+ * @extends MerossSubDevice
1020
+ * @example
1021
+ * const sensor = hub.getSubdevice('leak123');
1022
+ * if (sensor instanceof HubWaterLeakSensor) {
1023
+ * // Check for leaks
1024
+ * const isLeaking = sensor.isLeaking();
1025
+ * if (isLeaking) {
1026
+ * console.warn('Water leak detected!');
1027
+ * const leakTime = sensor.getLatestDetectedWaterLeakTs();
1028
+ * console.log(`Leak detected at: ${new Date(leakTime * 1000)}`);
1029
+ * }
1030
+ *
1031
+ * // Get leak event history
1032
+ * const events = sensor.getLastEvents();
1033
+ * console.log(`Total events: ${events.length}`);
1034
+ * }
1035
+ */
1036
+ class HubWaterLeakSensor extends MerossSubDevice {
1037
+ /**
1038
+ * Creates a new HubWaterLeakSensor instance
1039
+ * @param {string} hubDeviceUuid - UUID of the hub device
1040
+ * @param {string} subdeviceId - Subdevice ID
1041
+ * @param {Object} manager - The MerossCloud manager instance
1042
+ * @param {Object} [kwargs] - Additional subdevice information
1043
+ */
1044
+ constructor(hubDeviceUuid, subdeviceId, manager, kwargs = {}) {
1045
+ super(hubDeviceUuid, subdeviceId, manager, kwargs);
1046
+
1047
+ this._waterLeakState = null;
1048
+ this._lastEventTs = null;
1049
+ this._cachedEvents = [];
1050
+ this._maxEventsQueueLen = 30;
1051
+ this._lastWaterLeakEventTs = null;
1052
+ }
1053
+
1054
+ /**
1055
+ * Namespace handler registry for routing notifications.
1056
+ *
1057
+ * Maps namespace strings to handler methods for processing incoming notifications.
1058
+ *
1059
+ * @private
1060
+ */
1061
+ static get NAMESPACE_HANDLERS() {
1062
+ return [
1063
+ {
1064
+ namespace: 'Appliance.Hub.Sensor.WaterLeak',
1065
+ handler: '_handleWaterLeak'
1066
+ },
1067
+ {
1068
+ namespace: 'Appliance.Hub.Sensor.All',
1069
+ handler: '_handleSensorAll'
1070
+ },
1071
+ {
1072
+ namespace: 'Appliance.Hub.Online',
1073
+ handler: '_handleOnline'
1074
+ }
1075
+ ];
1076
+ }
1077
+
1078
+ /**
1079
+ * Handles subdevice-specific notifications from the hub.
1080
+ *
1081
+ * Routes notifications to appropriate handler methods based on namespace registry.
1082
+ *
1083
+ * @param {string} namespace - Notification namespace
1084
+ * @param {Object} data - Notification data payload
1085
+ */
1086
+ async handleSubdeviceNotification(namespace, data) {
1087
+ await super.handleSubdeviceNotification(namespace, data);
1088
+
1089
+ const handler = HubWaterLeakSensor.NAMESPACE_HANDLERS.find(route => route.namespace === namespace);
1090
+ if (handler && typeof this[handler.handler] === 'function') {
1091
+ await this[handler.handler](data);
1092
+ }
1093
+ }
1094
+
1095
+ /**
1096
+ * Handles Appliance.Hub.Sensor.WaterLeak namespace
1097
+ * @private
1098
+ * @param {Object} data - Notification data payload
1099
+ */
1100
+ _handleWaterLeak(data) {
1101
+ const { latestWaterLeak } = data;
1102
+ const { latestSampleTime } = data;
1103
+
1104
+ if (latestSampleTime !== undefined && latestSampleTime !== null) {
1105
+ this._handleWaterLeakFreshData(latestWaterLeak === 1, latestSampleTime);
1106
+ }
1107
+ }
1108
+
1109
+ /**
1110
+ * Handles Appliance.Hub.Sensor.All namespace
1111
+ * @private
1112
+ * @param {Object} data - Notification data payload
1113
+ */
1114
+ _handleSensorAll(data) {
1115
+ // Update online status if present
1116
+ const onlineStatus = this._updateOnlineStatus(data);
1117
+ if (onlineStatus) {
1118
+ this._onlineStatus = onlineStatus.status;
1119
+ }
1120
+
1121
+ // Extract water leak data if present
1122
+ if (data.waterLeak) {
1123
+ const { latestWaterLeak } = data.waterLeak;
1124
+ const { latestSampleTime } = data.waterLeak;
1125
+
1126
+ if (latestSampleTime !== undefined && latestSampleTime !== null) {
1127
+ this._handleWaterLeakFreshData(latestWaterLeak === 1, latestSampleTime);
1128
+ }
1129
+ }
1130
+ }
1131
+
1132
+ /**
1133
+ * Handles Appliance.Hub.Online namespace
1134
+ * @private
1135
+ * @param {Object} data - Notification data payload
1136
+ */
1137
+ _handleOnline(data) {
1138
+ const onlineStatus = this._updateOnlineStatus(data);
1139
+ if (onlineStatus) {
1140
+ this._onlineStatus = onlineStatus.status;
1141
+ this._lastActiveTime = onlineStatus.lastActiveTime;
1142
+ }
1143
+ }
1144
+
1145
+ /**
1146
+ * Checks if water leak is currently detected.
1147
+ *
1148
+ * @returns {boolean|null} True if leaking, false if not leaking, null if state is unknown
1149
+ * @example
1150
+ * const isLeaking = sensor.isLeaking();
1151
+ * if (isLeaking === true) {
1152
+ * console.warn('Water leak detected!');
1153
+ * } else if (isLeaking === false) {
1154
+ * console.log('No water leak detected');
1155
+ * } else {
1156
+ * console.log('Sensor state unknown');
1157
+ * }
1158
+ */
1159
+ isLeaking() {
1160
+ return this._waterLeakState;
1161
+ }
1162
+
1163
+ /**
1164
+ * Gets the timestamp of the latest sample
1165
+ * @returns {number|null} Timestamp or null if no samples received
1166
+ */
1167
+ getLatestSampleTime() {
1168
+ return this._lastEventTs;
1169
+ }
1170
+
1171
+ /**
1172
+ * Gets the timestamp of the latest detected water leak event
1173
+ * @returns {number|null} Timestamp or null if no leak events detected
1174
+ */
1175
+ getLatestDetectedWaterLeakTs() {
1176
+ return this._lastWaterLeakEventTs;
1177
+ }
1178
+
1179
+ /**
1180
+ * Gets the last water leak events (cached queue, max 30 events)
1181
+ * @returns {Array<Object>} Array of event objects with leaking (boolean) and timestamp (number) properties
1182
+ */
1183
+ getLastEvents() {
1184
+ return [...this._cachedEvents];
1185
+ }
1186
+
1187
+ /**
1188
+ * Handles fresh water leak data with timestamp validation.
1189
+ *
1190
+ * Ignores stale updates to maintain chronological order and prevent race conditions
1191
+ * from out-of-order notifications. Maintains bounded event history using FIFO queue.
1192
+ *
1193
+ * @private
1194
+ * @param {boolean} leaking - Whether water leak is detected
1195
+ * @param {number} timestamp - Event timestamp
1196
+ */
1197
+ _handleWaterLeakFreshData(leaking, timestamp) {
1198
+ if (this._lastEventTs !== null && timestamp <= this._lastEventTs) {
1199
+ return;
1200
+ }
1201
+
1202
+ if (this._lastEventTs === null || timestamp >= this._lastEventTs) {
1203
+ this._lastEventTs = timestamp;
1204
+ this._waterLeakState = leaking;
1205
+ }
1206
+
1207
+ if (leaking && (this._lastWaterLeakEventTs === null || timestamp >= this._lastWaterLeakEventTs)) {
1208
+ this._lastWaterLeakEventTs = timestamp;
1209
+ }
1210
+
1211
+ if (this._cachedEvents.length >= this._maxEventsQueueLen) {
1212
+ this._cachedEvents.shift();
1213
+ }
1214
+ this._cachedEvents.push({
1215
+ leaking,
1216
+ timestamp
1217
+ });
1218
+ }
1219
+ }
1220
+
1221
+ /**
1222
+ * Hub Smoke Detector subdevice (MA151, etc.).
1223
+ *
1224
+ * This class represents smoke detector devices that connect through a Meross hub.
1225
+ * It provides methods to check alarm status, mute alarms, access test event history,
1226
+ * and monitor sensor status.
1227
+ *
1228
+ * @class HubSmokeDetector
1229
+ * @extends MerossSubDevice
1230
+ * @example
1231
+ * const detector = hub.getSubdevice('smoke123');
1232
+ * if (detector instanceof HubSmokeDetector) {
1233
+ * // Check alarm status
1234
+ * const status = detector.getSmokeAlarmStatus();
1235
+ * if (status === SmokeAlarmStatus.NORMAL) {
1236
+ * console.log('No alarms detected');
1237
+ * } else if (status === SmokeAlarmStatus.MUTE_SMOKE_ALARM) {
1238
+ * console.log('Smoke alarm is muted');
1239
+ * }
1240
+ *
1241
+ * // Mute smoke alarm
1242
+ * await detector.muteAlarm(true);
1243
+ *
1244
+ * // Get test events
1245
+ * const testEvents = detector.getTestEvents();
1246
+ * console.log(`Test events: ${testEvents.length}`);
1247
+ *
1248
+ * // Refresh alarm status
1249
+ * await detector.refreshAlarmStatus();
1250
+ * }
1251
+ */
1252
+ class HubSmokeDetector extends MerossSubDevice {
1253
+ /**
1254
+ * Creates a new HubSmokeDetector instance
1255
+ * @param {string} hubDeviceUuid - UUID of the hub device
1256
+ * @param {string} subdeviceId - Subdevice ID
1257
+ * @param {Object} manager - The MerossCloud manager instance
1258
+ * @param {Object} [kwargs] - Additional subdevice information
1259
+ */
1260
+ constructor(hubDeviceUuid, subdeviceId, manager, kwargs = {}) {
1261
+ super(hubDeviceUuid, subdeviceId, manager, kwargs);
1262
+
1263
+ this._alarmStatus = null;
1264
+ this._interConn = null;
1265
+ this._lastStatusUpdate = null;
1266
+ this._testEvents = [];
1267
+ this._maxTestEvents = 10;
1268
+ }
1269
+
1270
+ /**
1271
+ * Namespace handler registry for routing notifications.
1272
+ *
1273
+ * Maps namespace strings to handler methods for processing incoming notifications.
1274
+ *
1275
+ * @private
1276
+ */
1277
+ static get NAMESPACE_HANDLERS() {
1278
+ return [
1279
+ {
1280
+ namespace: 'Appliance.Hub.Sensor.Smoke',
1281
+ handler: '_handleSmoke'
1282
+ },
1283
+ {
1284
+ namespace: 'Appliance.Hub.Sensor.All',
1285
+ handler: '_handleSensorAll'
1286
+ },
1287
+ {
1288
+ namespace: 'Appliance.Hub.Online',
1289
+ handler: '_handleOnline'
1290
+ }
1291
+ ];
1292
+ }
1293
+
1294
+ /**
1295
+ * Handles subdevice-specific notifications from the hub.
1296
+ *
1297
+ * Routes notifications to appropriate handler methods based on namespace registry.
1298
+ *
1299
+ * @param {string} namespace - Notification namespace
1300
+ * @param {Object} data - Notification data payload
1301
+ */
1302
+ async handleSubdeviceNotification(namespace, data) {
1303
+ await super.handleSubdeviceNotification(namespace, data);
1304
+
1305
+ const handler = HubSmokeDetector.NAMESPACE_HANDLERS.find(route => route.namespace === namespace);
1306
+ if (handler && typeof this[handler.handler] === 'function') {
1307
+ await this[handler.handler](data);
1308
+ }
1309
+ }
1310
+
1311
+ /**
1312
+ * Handles Appliance.Hub.Sensor.Smoke namespace
1313
+ * @private
1314
+ * @param {Object} data - Notification data payload
1315
+ */
1316
+ _handleSmoke(data) {
1317
+ this._handleSmokeAlarmData(data);
1318
+ }
1319
+
1320
+ /**
1321
+ * Handles Appliance.Hub.Sensor.All namespace
1322
+ * @private
1323
+ * @param {Object} data - Notification data payload
1324
+ */
1325
+ _handleSensorAll(data) {
1326
+ // Update online status if present
1327
+ const onlineStatus = this._updateOnlineStatus(data);
1328
+ if (onlineStatus) {
1329
+ this._onlineStatus = onlineStatus.status;
1330
+ }
1331
+
1332
+ // Extract smoke alarm data if present
1333
+ if (data.smokeAlarm) {
1334
+ this._handleSmokeAlarmData(data.smokeAlarm);
1335
+ }
1336
+ }
1337
+
1338
+ /**
1339
+ * Handles Appliance.Hub.Online namespace
1340
+ * @private
1341
+ * @param {Object} data - Notification data payload
1342
+ */
1343
+ _handleOnline(data) {
1344
+ const onlineStatus = this._updateOnlineStatus(data);
1345
+ if (onlineStatus) {
1346
+ this._onlineStatus = onlineStatus.status;
1347
+ this._lastActiveTime = onlineStatus.lastActiveTime;
1348
+ }
1349
+ }
1350
+
1351
+ /**
1352
+ * Handles smoke alarm data with timestamp validation.
1353
+ *
1354
+ * Normalizes array format responses to single object and validates timestamp freshness
1355
+ * to prevent state regression from out-of-order notifications.
1356
+ *
1357
+ * @private
1358
+ * @param {Object|Array} data - Smoke alarm data (can be object or array)
1359
+ */
1360
+ _handleSmokeAlarmData(data) {
1361
+ const alarmData = this._normalizeAlarmData(data);
1362
+
1363
+ const status = alarmData?.status;
1364
+ const interConn = alarmData?.interConn;
1365
+ const timestamp = alarmData?.timestamp;
1366
+ const event = alarmData?.event;
1367
+
1368
+ if (!this._validateAlarmTimestamp(timestamp)) {
1369
+ return;
1370
+ }
1371
+
1372
+ if (timestamp !== undefined && timestamp !== null) {
1373
+ this._lastStatusUpdate = timestamp;
1374
+ }
1375
+
1376
+ if (status !== undefined && status !== null) {
1377
+ this._alarmStatus = status;
1378
+ }
1379
+
1380
+ if (interConn !== undefined && interConn !== null) {
1381
+ this._interConn = interConn;
1382
+ }
1383
+
1384
+ if (event && event.test) {
1385
+ this._processTestEvent(event.test);
1386
+ }
1387
+ }
1388
+
1389
+ /**
1390
+ * Normalizes alarm data from array format to single object.
1391
+ *
1392
+ * @private
1393
+ * @param {Object|Array} data - Smoke alarm data (can be object or array)
1394
+ * @returns {Object} Normalized alarm data object
1395
+ */
1396
+ _normalizeAlarmData(data) {
1397
+ if (Array.isArray(data) && data.length > 0) {
1398
+ return data[0];
1399
+ }
1400
+ return data;
1401
+ }
1402
+
1403
+ /**
1404
+ * Validates alarm timestamp freshness.
1405
+ *
1406
+ * @private
1407
+ * @param {number|undefined|null} timestamp - Timestamp to validate
1408
+ * @returns {boolean} True if update should proceed, false if stale
1409
+ */
1410
+ _validateAlarmTimestamp(timestamp) {
1411
+ if (timestamp === undefined || timestamp === null) {
1412
+ return true;
1413
+ }
1414
+ if (this._lastStatusUpdate !== null && timestamp <= this._lastStatusUpdate) {
1415
+ return false;
1416
+ }
1417
+ return true;
1418
+ }
1419
+
1420
+ /**
1421
+ * Processes test event data.
1422
+ *
1423
+ * Maintains FIFO queue with size limit to prevent unbounded memory growth.
1424
+ *
1425
+ * @private
1426
+ * @param {Object} testEvent - Test event object with type and timestamp
1427
+ */
1428
+ _processTestEvent(testEvent) {
1429
+ const testType = testEvent.type;
1430
+ const testTimestamp = testEvent.timestamp;
1431
+
1432
+ if (this._testEvents.length >= this._maxTestEvents) {
1433
+ this._testEvents.shift();
1434
+ }
1435
+ this._testEvents.push({
1436
+ type: testType,
1437
+ timestamp: testTimestamp
1438
+ });
1439
+ }
1440
+
1441
+ /**
1442
+ * Gets the current smoke alarm status.
1443
+ *
1444
+ * @returns {number|null} Alarm status code from {@link SmokeAlarmStatus} enum (23, 26, 27), or null if unknown
1445
+ * @example
1446
+ * const status = detector.getSmokeAlarmStatus();
1447
+ * if (status === SmokeAlarmStatus.NORMAL) {
1448
+ * console.log('No alarms');
1449
+ * } else if (status === SmokeAlarmStatus.MUTE_SMOKE_ALARM) {
1450
+ * console.log('Smoke alarm muted');
1451
+ * }
1452
+ */
1453
+ getSmokeAlarmStatus() {
1454
+ return this._alarmStatus;
1455
+ }
1456
+
1457
+ /**
1458
+ * Gets the interconnection status
1459
+ * @returns {number|null} Interconnection status (0 = not interconnected, 1 = interconnected) or null if unknown
1460
+ */
1461
+ getInterConnStatus() {
1462
+ return this._interConn;
1463
+ }
1464
+
1465
+ /**
1466
+ * Gets the timestamp of the last status update
1467
+ * @returns {number|null} Timestamp or null if no updates received
1468
+ */
1469
+ getLastStatusUpdate() {
1470
+ return this._lastStatusUpdate;
1471
+ }
1472
+
1473
+ /**
1474
+ * Mutes the smoke alarm or temperature alarm.
1475
+ *
1476
+ * @param {boolean} [muteSmoke=true] - If true, mute smoke alarm (status 27), else mute temperature alarm (status 26)
1477
+ * @returns {Promise<Object>} Promise that resolves with the response from the device
1478
+ * @example
1479
+ * // Mute smoke alarm
1480
+ * await detector.muteAlarm(true);
1481
+ *
1482
+ * // Mute temperature alarm
1483
+ * await detector.muteAlarm(false);
1484
+ */
1485
+ async muteAlarm(muteSmoke = true) {
1486
+ const status = muteSmoke ? SmokeAlarmStatus.MUTE_SMOKE_ALARM : SmokeAlarmStatus.MUTE_TEMPERATURE_ALARM;
1487
+
1488
+ const response = await this.publishMessage('SET', 'Appliance.Hub.Sensor.Smoke', {
1489
+ smokeAlarm: [{
1490
+ id: this._subdeviceId,
1491
+ status
1492
+ }]
1493
+ }, null);
1494
+
1495
+ if (response) {
1496
+ this._alarmStatus = status;
1497
+ }
1498
+
1499
+ return response;
1500
+ }
1501
+
1502
+ /**
1503
+ * Gets the cached test events
1504
+ * @returns {Array<Object>} Array of test event objects with type and timestamp properties
1505
+ */
1506
+ getTestEvents() {
1507
+ return [...this._testEvents];
1508
+ }
1509
+
1510
+ /**
1511
+ * Refreshes the smoke alarm status from the device.
1512
+ *
1513
+ * @returns {Promise<Object>} Promise that resolves with the alarm status response
1514
+ */
1515
+ async refreshAlarmStatus() {
1516
+ const response = await this.publishMessage('GET', 'Appliance.Hub.Sensor.Smoke', {
1517
+ smokeAlarm: [{
1518
+ id: this._subdeviceId
1519
+ }]
1520
+ }, null);
1521
+
1522
+ if (response && response.smokeAlarm) {
1523
+ this._handleSmokeAlarmData(response.smokeAlarm);
1524
+ }
1525
+
1526
+ return response;
1527
+ }
1528
+ }
1529
+
1530
+ module.exports = {
1531
+ MerossSubDevice,
1532
+ HubTempHumSensor,
1533
+ HubThermostatValve,
1534
+ HubWaterLeakSensor,
1535
+ HubSmokeDetector
1536
+ };
1537
+