meross-iot 0.8.0 → 0.9.1

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 CHANGED
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.9.1] - 2026-01-22
9
+
10
+ ### Fixed
11
+ - Improve heartbeat offline detection by using response silence (≥ heartbeat interval) instead of treating individual command errors/timeouts as offline signals
12
+
13
+ ## [0.9.0] - 2026-01-22
14
+
15
+ ### Added
16
+ - Signal strength property (`device.signalStrength`) from Appliance.System.Runtime
17
+ - Provides signal strength percentage (1-100) on production firmware
18
+ - Automatically updated when runtime data is fetched
19
+ - Available in TypeScript definitions
20
+ - Runtime polling support in ManagerSubscription
21
+ - Added `runtimeInterval` option (default: 60000ms) for periodic runtime data polling
22
+ - Runtime data (signal strength, network type, IoT status) requires polling as it doesn't support push notifications
23
+ - Respects smart caching configuration to reduce network traffic
24
+ - Polling automatically skips when device is offline
25
+
8
26
  ## [0.8.0] - 2026-01-22
9
27
 
10
28
  ### Added
package/README.md CHANGED
@@ -26,7 +26,7 @@ The library can control devices locally via HTTP or via cloud MQTT server.
26
26
  npm install meross-iot@alpha
27
27
 
28
28
  # Or install specific version
29
- npm install meross-iot@0.7.1
29
+ npm install meross-iot@0.9.1
30
30
  ```
31
31
 
32
32
  ## Usage & Documentation
@@ -177,6 +177,27 @@ Please create an issue on GitHub and include:
177
177
 
178
178
  ## Changelog
179
179
 
180
+ ### [0.9.1] - 2026-01-22
181
+
182
+ #### Fixed
183
+ - Improve heartbeat offline detection by using response silence (≥ heartbeat interval) instead of treating individual command errors/timeouts as offline signals
184
+
185
+ <details>
186
+ <summary>Older</summary>
187
+
188
+ ### [0.9.0] - 2026-01-22
189
+
190
+ #### Added
191
+ - Signal strength property (`device.signalStrength`) from Appliance.System.Runtime
192
+ - Provides signal strength percentage (1-100) on production firmware
193
+ - Automatically updated when runtime data is fetched
194
+ - Available in TypeScript definitions
195
+ - Runtime polling support in ManagerSubscription
196
+ - Added `runtimeInterval` option (default: 60000ms) for periodic runtime data polling
197
+ - Runtime data (signal strength, network type, IoT status) requires polling as it doesn't support push notifications
198
+ - Respects smart caching configuration to reduce network traffic
199
+ - Polling automatically skips when device is offline
200
+
180
201
  ### [0.8.0] - 2026-01-22
181
202
 
182
203
  #### Added
@@ -212,9 +233,6 @@ Please create an issue on GitHub and include:
212
233
  - Prevent redundant ability updates when abilities haven't changed
213
234
  - Device initialization event now properly emitted by device itself after System.All is received
214
235
 
215
- <details>
216
- <summary>Older</summary>
217
-
218
236
  ### [0.7.2] - 2026-01-21
219
237
 
220
238
  #### Changed
package/index.d.ts CHANGED
@@ -2854,6 +2854,8 @@ declare module 'meross-iot' {
2854
2854
  readonly lanIp: string | null
2855
2855
  readonly mqttHost: string | null
2856
2856
  readonly mqttPort: number | null
2857
+ /** Signal strength percentage (1-100) from Appliance.System.Runtime. Available on production firmware. */
2858
+ readonly signalStrength: number | null
2857
2859
  /** Device capabilities object containing supported features and namespaces */
2858
2860
  readonly abilities: Record<string, any> | null
2859
2861
  /** Normalized device capabilities map for easy feature discovery without namespace knowledge */
@@ -204,6 +204,7 @@ class MerossDevice extends EventEmitter {
204
204
  this.mqttPort = port;
205
205
  this.rssi = null;
206
206
  this.wifiSignal = null;
207
+ this.signalStrength = null;
207
208
  this.wifiSsid = null;
208
209
  this.wifiChannel = null;
209
210
  this.wifiSnr = null;
@@ -905,6 +906,7 @@ class MerossDevice extends EventEmitter {
905
906
  firmwareVersion: this.firmwareVersion,
906
907
  rssi: this.rssi,
907
908
  wifiSignal: this.wifiSignal,
909
+ signalStrength: this.signalStrength,
908
910
  wifiSsid: this.wifiSsid,
909
911
  wifiChannel: this.wifiChannel,
910
912
  wifiSnr: this.wifiSnr,
@@ -1066,9 +1068,6 @@ class MerossDevice extends EventEmitter {
1066
1068
 
1067
1069
  if (message.header.method === 'ERROR') {
1068
1070
  const errorPayload = message.payload || {};
1069
- if (this._heartbeat) {
1070
- this._heartbeat.recordFailure();
1071
- }
1072
1071
  pending.reject(new MerossErrorCommand(
1073
1072
  `Device returned error: ${JSON.stringify(errorPayload)}`,
1074
1073
  errorPayload,
@@ -1318,9 +1317,6 @@ class MerossDevice extends EventEmitter {
1318
1317
  namespace,
1319
1318
  messageId
1320
1319
  };
1321
- if (this._heartbeat) {
1322
- this._heartbeat.recordFailure();
1323
- }
1324
1320
  this.waitingMessageIds[messageId].reject(
1325
1321
  new MerossErrorCommandTimeout(
1326
1322
  `Command timed out after ${timeoutDuration}ms`,
@@ -1335,9 +1331,6 @@ class MerossDevice extends EventEmitter {
1335
1331
  };
1336
1332
 
1337
1333
  } catch (error) {
1338
- if (this._heartbeat) {
1339
- this._heartbeat.recordFailure();
1340
- }
1341
1334
  reject(error);
1342
1335
  }
1343
1336
  });
@@ -31,6 +31,12 @@ function createRuntimeFeature(device) {
31
31
  const result = await device.publishMessage('GET', 'Appliance.System.Runtime', {});
32
32
  const data = result && result.runtime ? result.runtime : {};
33
33
  device._runtimeInfo = data;
34
+
35
+ // Update signalStrength property from runtime data (production firmware)
36
+ if (data.signal !== undefined) {
37
+ device.signalStrength = data.signal;
38
+ }
39
+
34
40
  return data;
35
41
  },
36
42
 
@@ -210,30 +210,30 @@ function getCachedDeviceClass(deviceType, hardwareVersion, firmwareVersion) {
210
210
  function _buildDynamicClass(typeKey, abilities, BaseClass) {
211
211
  const features = new Set();
212
212
 
213
- features.add(require('./controller/features/system-feature'));
214
-
215
- if (abilities && typeof abilities === 'object') {
216
- const hasXVersion = new Set();
217
- for (const namespace of Object.keys(abilities)) {
218
- if (namespace.endsWith('X')) {
219
- const baseNamespace = namespace.slice(0, -1);
220
- hasXVersion.add(baseNamespace);
221
- }
213
+ features.add(require('./controller/features/system-feature'));
214
+
215
+ if (abilities && typeof abilities === 'object') {
216
+ const hasXVersion = new Set();
217
+ for (const namespace of Object.keys(abilities)) {
218
+ if (namespace.endsWith('X')) {
219
+ const baseNamespace = namespace.slice(0, -1);
220
+ hasXVersion.add(baseNamespace);
222
221
  }
222
+ }
223
223
 
224
- for (const [namespace] of Object.entries(abilities)) {
225
- const feature = ABILITY_MATRIX[namespace];
226
- if (feature) {
227
- if (namespace.endsWith('X')) {
224
+ for (const [namespace] of Object.entries(abilities)) {
225
+ const feature = ABILITY_MATRIX[namespace];
226
+ if (feature) {
227
+ if (namespace.endsWith('X')) {
228
+ features.add(feature);
229
+ } else {
230
+ if (!hasXVersion.has(namespace)) {
228
231
  features.add(feature);
229
- } else {
230
- if (!hasXVersion.has(namespace)) {
231
- features.add(feature);
232
- }
233
232
  }
234
233
  }
235
234
  }
236
235
  }
236
+ }
237
237
 
238
238
  class DynamicDevice extends BaseClass {}
239
239
 
@@ -10,11 +10,11 @@ const { OnlineStatus } = require('../model/enums');
10
10
  * Coordinates push notifications and targeted polling to provide a single event stream for
11
11
  * device state changes. Device state is polled once on initial subscription to establish a
12
12
  * baseline for listeners, then typically relies on push notifications for ongoing updates.
13
- * Some features (electricity, consumption) require periodic polling because they do not
13
+ * Some features (electricity, consumption, runtime) require periodic polling because they do not
14
14
  * emit push notifications.
15
15
  *
16
16
  * Push notifications reduce latency and network traffic compared to frequent polling.
17
- * Periodic polling is intended for features without push support (electricity, consumption)
17
+ * Periodic polling is intended for features without push support (electricity, consumption, runtime)
18
18
  * or as an explicit fallback when a device is not producing push updates.
19
19
  *
20
20
  * @class
@@ -30,6 +30,7 @@ class ManagerSubscription extends EventEmitter {
30
30
  * @param {number} [options.deviceStateInterval=0] - Device state polling interval in milliseconds (0 to disable periodic polling, rely on push only after initial state)
31
31
  * @param {number} [options.electricityInterval=30000] - Electricity metrics polling interval in milliseconds (0 to disable)
32
32
  * @param {number} [options.consumptionInterval=60000] - Power consumption polling interval in milliseconds (0 to disable)
33
+ * @param {number} [options.runtimeInterval=60000] - Runtime information polling interval in milliseconds (0 to disable)
33
34
  * @param {number} [options.httpDeviceListInterval=120000] - HTTP device list polling interval in milliseconds
34
35
  * @param {boolean} [options.smartCaching=true] - Skip polling when cached data is fresh to reduce network traffic
35
36
  * @param {number} [options.cacheMaxAge=10000] - Maximum cache age in milliseconds before considering data stale
@@ -49,6 +50,7 @@ class ManagerSubscription extends EventEmitter {
49
50
  deviceStateInterval: options.deviceStateInterval !== undefined ? options.deviceStateInterval : 0,
50
51
  electricityInterval: options.electricityInterval !== undefined ? options.electricityInterval : 30000,
51
52
  consumptionInterval: options.consumptionInterval !== undefined ? options.consumptionInterval : 60000,
53
+ runtimeInterval: options.runtimeInterval !== undefined ? options.runtimeInterval : 60000,
52
54
  httpDeviceListInterval: options.httpDeviceListInterval || 120000,
53
55
  smartCaching: options.smartCaching !== false,
54
56
  cacheMaxAge: options.cacheMaxAge || 10000,
@@ -64,16 +66,17 @@ class ManagerSubscription extends EventEmitter {
64
66
  * Subscribe to device updates.
65
67
  *
66
68
  * Registers event listeners for push notifications and starts periodic polling
67
- * based on configuration. For metrics (electricity/consumption), multiple calls merge by
68
- * selecting the shortest interval so all listeners receive updates at the required frequency.
69
+ * based on configuration. Configuration is only applied on the first subscription
70
+ * call for a device; subsequent calls reuse the existing configuration.
69
71
  * Device state polling is configured explicitly via `deviceStateInterval` (with a baseline
70
72
  * state poll on initial subscription).
71
73
  *
72
74
  * @param {MerossDevice} device - Device to subscribe to
73
- * @param {Object} [config={}] - Subscription configuration
75
+ * @param {Object} [config={}] - Subscription configuration (only applied on first subscription)
74
76
  * @param {number} [config.deviceStateInterval] - Device state polling interval in milliseconds (0 to disable periodic polling, rely on push only after initial state)
75
77
  * @param {number} [config.electricityInterval] - Electricity metrics polling interval in milliseconds (0 to disable)
76
78
  * @param {number} [config.consumptionInterval] - Power consumption polling interval in milliseconds (0 to disable)
79
+ * @param {number} [config.runtimeInterval] - Runtime information polling interval in milliseconds (0 to disable)
77
80
  * @param {boolean} [config.smartCaching] - Skip polling when cached data is fresh
78
81
  * @param {number} [config.cacheMaxAge] - Maximum cache age in milliseconds before refresh
79
82
  * @param {boolean} [config.pushOnly=false] - Prefer push-driven updates and emit cached state immediately when available
@@ -95,33 +98,24 @@ class ManagerSubscription extends EventEmitter {
95
98
  const subscription = this.subscriptions.get(deviceUuid);
96
99
  const isNewSubscription = !subscription.pollingIntervals || subscription.pollingIntervals.size === 0;
97
100
 
98
- const existingConfig = subscription.config || { ...this.defaultConfig };
99
- const pushOnly = config.pushOnly !== undefined ? config.pushOnly : (existingConfig.pushOnly || this.defaultConfig.pushOnly);
100
-
101
- subscription.config = {
102
- deviceStateInterval: config.deviceStateInterval !== undefined ? config.deviceStateInterval : (existingConfig.deviceStateInterval !== undefined ? existingConfig.deviceStateInterval : this.defaultConfig.deviceStateInterval),
103
- electricityInterval: Math.min(
104
- existingConfig.electricityInterval || this.defaultConfig.electricityInterval,
105
- config.electricityInterval !== undefined ? config.electricityInterval : (existingConfig.electricityInterval || this.defaultConfig.electricityInterval)
106
- ),
107
- consumptionInterval: Math.min(
108
- existingConfig.consumptionInterval || this.defaultConfig.consumptionInterval,
109
- config.consumptionInterval !== undefined ? config.consumptionInterval : (existingConfig.consumptionInterval || this.defaultConfig.consumptionInterval)
110
- ),
111
- smartCaching: config.smartCaching !== undefined ? config.smartCaching : existingConfig.smartCaching,
112
- cacheMaxAge: Math.min(
113
- existingConfig.cacheMaxAge || this.defaultConfig.cacheMaxAge,
114
- config.cacheMaxAge !== undefined ? config.cacheMaxAge : (existingConfig.cacheMaxAge || this.defaultConfig.cacheMaxAge)
115
- ),
116
- pushOnly
117
- };
118
-
119
101
  if (isNewSubscription) {
102
+ const getConfigValue = (key) => config[key] !== undefined ? config[key] : this.defaultConfig[key];
103
+
104
+ subscription.config = {
105
+ deviceStateInterval: getConfigValue('deviceStateInterval'),
106
+ electricityInterval: getConfigValue('electricityInterval'),
107
+ consumptionInterval: getConfigValue('consumptionInterval'),
108
+ runtimeInterval: getConfigValue('runtimeInterval'),
109
+ smartCaching: getConfigValue('smartCaching'),
110
+ cacheMaxAge: getConfigValue('cacheMaxAge'),
111
+ pushOnly: getConfigValue('pushOnly')
112
+ };
113
+
120
114
  this._startPolling(device, subscription);
121
- }
122
115
 
123
- if (subscription.config.pushOnly && isNewSubscription) {
124
- this._emitCachedState(device, subscription);
116
+ if (subscription.config.pushOnly) {
117
+ this._emitCachedState(device, subscription);
118
+ }
125
119
  }
126
120
 
127
121
  const listenerCount = this.listenerCount(eventName);
@@ -233,7 +227,7 @@ class ManagerSubscription extends EventEmitter {
233
227
  }
234
228
 
235
229
  /**
236
- * Start polling for device state, electricity, and consumption data.
230
+ * Start polling for device state, electricity, consumption, and runtime data.
237
231
  *
238
232
  * Performs a one-time device state poll on initial subscription so consumers
239
233
  * can receive a current baseline state without waiting for the next push event.
@@ -272,6 +266,14 @@ class ManagerSubscription extends EventEmitter {
272
266
 
273
267
  subscription.pollingIntervals.set('consumption', interval);
274
268
  }
269
+
270
+ if (config.runtimeInterval > 0 && device.runtime && typeof device.runtime.get === 'function') {
271
+ const interval = setInterval(async () => {
272
+ await this._pollRuntime(device, subscription, config);
273
+ }, config.runtimeInterval);
274
+
275
+ subscription.pollingIntervals.set('runtime', interval);
276
+ }
275
277
  }
276
278
 
277
279
  /**
@@ -429,6 +431,46 @@ class ManagerSubscription extends EventEmitter {
429
431
  }
430
432
  }
431
433
 
434
+ /**
435
+ * Poll runtime information from the device.
436
+ *
437
+ * Runtime data (signal strength, network type, IoT status) requires polling as it
438
+ * doesn't support push notifications. Skips polling when cached runtime data is fresh
439
+ * (if smartCaching enabled) to reduce network traffic.
440
+ *
441
+ * @private
442
+ * @param {MerossDevice} device - Device to poll
443
+ * @param {Object} subscription - Subscription state object
444
+ * @param {Object} config - Configuration object with caching settings
445
+ */
446
+ async _pollRuntime(device, subscription, config) {
447
+ if (device.onlineStatus !== OnlineStatus.ONLINE) {
448
+ return;
449
+ }
450
+
451
+ const namespace = 'Appliance.System.Runtime';
452
+ if (this._hasRecentPush(subscription, namespace, 5000)) {
453
+ return;
454
+ }
455
+
456
+ try {
457
+ if (config.smartCaching && device._runtimeInfo) {
458
+ const cached = device.runtime.getCached();
459
+ if (cached && Object.keys(cached).length > 0) {
460
+ const cacheAge = Date.now() - (subscription.lastPollTimes.get('runtime') || 0);
461
+ if (cacheAge < config.cacheMaxAge) {
462
+ return;
463
+ }
464
+ }
465
+ }
466
+
467
+ await device.runtime.get();
468
+ subscription.lastPollTimes.set('runtime', Date.now());
469
+ } catch (error) {
470
+ this.logger(`Error polling runtime for ${device.uuid}: ${error.message}`);
471
+ }
472
+ }
473
+
432
474
  /**
433
475
  * Mark push notifications as active for a specific namespace.
434
476
  *
@@ -45,7 +45,7 @@ class MerossError extends Error {
45
45
  }
46
46
  if (this.isOperational !== undefined) {
47
47
  result.isOperational = this.isOperational;
48
- }
48
+ }
49
49
  return result;
50
50
  }
51
51
  }
@@ -3,11 +3,12 @@
3
3
  const { OnlineStatus } = require('../model/enums');
4
4
 
5
5
  /**
6
- * Manages device online/offline detection using multiple strategies.
6
+ * Manages device online/offline detection using time-based silence detection.
7
7
  *
8
- * Provides comprehensive connectivity monitoring through periodic heartbeat checks,
9
- * response tracking, failure counting, and System.All monitoring. Automatically
10
- * updates device online status when connectivity issues are detected.
8
+ * Tracks the last response time from the device and marks it offline when no
9
+ * response is received for the configured timeout period. Uses time-based detection
10
+ * rather than failure counting to avoid false offline states from transient network
11
+ * issues or temporary HTTP unavailability when MQTT is still functional.
11
12
  *
12
13
  * @class Heartbeat
13
14
  */
@@ -17,21 +18,18 @@ class Heartbeat {
17
18
  *
18
19
  * @param {Object} device - MerossDevice instance to monitor
19
20
  * @param {Object} [options={}] - Configuration options
20
- * @param {number} [options.heartbeatInterval=120000] - Heartbeat interval in milliseconds (120 seconds)
21
- * @param {number} [options.consecutiveFailureThreshold=1] - Number of consecutive failures before marking offline
21
+ * @param {number} [options.heartbeatInterval=295000] - Heartbeat interval in milliseconds (295 seconds)
22
22
  * @param {boolean} [options.enabled=true] - Whether heartbeat monitoring is enabled
23
23
  */
24
24
  constructor(device, options = {}) {
25
25
  this.device = device;
26
- this.heartbeatInterval = options.heartbeatInterval || 120000;
27
- this.consecutiveFailureThreshold = options.consecutiveFailureThreshold || 1;
26
+ this.heartbeatInterval = options.heartbeatInterval || 295000;
28
27
  this.enabled = options.enabled !== false;
29
28
 
30
29
  this._lastResponseTime = null;
31
- this._consecutiveFailures = 0;
32
30
  this._heartbeatTimer = null;
33
31
  this._isRunning = false;
34
- // Exponential backoff: starts at base delay, doubles when offline, capped at heartbeat interval
32
+ // Start with shorter delay to quickly detect when device comes back online
35
33
  this._pollingDelay = Math.floor(this.heartbeatInterval / 2);
36
34
  }
37
35
 
@@ -53,7 +51,7 @@ class Heartbeat {
53
51
  /**
54
52
  * Stops heartbeat monitoring and cleans up timers.
55
53
  *
56
- * Should be called when device disconnects.
54
+ * Prevents memory leaks by clearing scheduled timers when device disconnects.
57
55
  */
58
56
  stop() {
59
57
  this._isRunning = false;
@@ -66,38 +64,26 @@ class Heartbeat {
66
64
  /**
67
65
  * Records a successful response from the device.
68
66
  *
69
- * Updates last response time and resets consecutive failure counter.
70
- * Resets polling delay to base interval when device comes back online.
71
- * Called whenever any successful response is received.
67
+ * Updates the last response timestamp to reset the silence timer. Resets polling
68
+ * delay when device transitions from offline to online to quickly detect if it
69
+ * goes offline again.
72
70
  */
73
71
  recordResponse() {
74
72
  const wasOffline = this.device.onlineStatus === OnlineStatus.OFFLINE;
75
73
  this._lastResponseTime = Date.now();
76
- this._consecutiveFailures = 0;
77
74
 
78
75
  if (wasOffline) {
79
76
  this._pollingDelay = Math.floor(this.heartbeatInterval / 2);
80
77
  }
81
-
82
- this._evaluateStatus();
83
- }
84
78
 
85
- /**
86
- * Records a command failure or timeout.
87
- *
88
- * Increments consecutive failure counter and evaluates if device should
89
- * be marked offline. Called when commands fail or timeout.
90
- */
91
- recordFailure() {
92
- this._consecutiveFailures++;
93
79
  this._evaluateStatus();
94
80
  }
95
81
 
96
82
  /**
97
83
  * Records a System.All response.
98
84
  *
99
- * System.All responses indicate device is active and responding.
100
- * Updates last response time and resets failure counter.
85
+ * System.All responses are comprehensive state updates that confirm device
86
+ * connectivity, so they reset the silence timer.
101
87
  */
102
88
  recordSystemAll() {
103
89
  this.recordResponse();
@@ -106,9 +92,8 @@ class Heartbeat {
106
92
  /**
107
93
  * Evaluates device status and updates if necessary.
108
94
  *
109
- * Checks multiple conditions to determine if device should be marked offline:
110
- * - 1 consecutive failure
111
- * - No response for > 2x heartbeat interval (240s) when device was online
95
+ * Only marks devices offline that are currently online to avoid redundant
96
+ * status updates and ensure we don't mark already-offline devices offline again.
112
97
  *
113
98
  * @private
114
99
  */
@@ -126,20 +111,18 @@ class Heartbeat {
126
111
  }
127
112
 
128
113
  /**
129
- * Determines if device should be marked offline based on tracking data.
114
+ * Determines if device should be marked offline based on silence timeout.
115
+ *
116
+ * Requires device to be currently online to avoid marking already-offline devices.
117
+ * Requires at least one previous response to have a baseline for silence detection.
130
118
  *
131
119
  * @private
132
120
  * @returns {boolean} True if device should be marked offline
133
121
  */
134
122
  _shouldBeOffline() {
135
- if (this._consecutiveFailures >= this.consecutiveFailureThreshold) {
136
- return true;
137
- }
138
-
139
123
  if (this._lastResponseTime && this.device.onlineStatus === OnlineStatus.ONLINE) {
140
124
  const timeSinceLastResponse = Date.now() - this._lastResponseTime;
141
- const maxSilenceInterval = this.heartbeatInterval * 2;
142
- if (timeSinceLastResponse > maxSilenceInterval) {
125
+ if (timeSinceLastResponse >= this.heartbeatInterval) {
143
126
  return true;
144
127
  }
145
128
  }
@@ -150,8 +133,9 @@ class Heartbeat {
150
133
  /**
151
134
  * Schedules the next heartbeat check.
152
135
  *
153
- * Uses exponential backoff delay when device is offline, otherwise uses
154
- * heartbeat interval. Checks if heartbeat is needed (conditional triggering).
136
+ * Uses shorter polling delay when offline to quickly detect when device comes
137
+ * back online. Uses standard heartbeat interval when online to avoid unnecessary
138
+ * network traffic when device is actively responding.
155
139
  *
156
140
  * @private
157
141
  */
@@ -160,8 +144,8 @@ class Heartbeat {
160
144
  return;
161
145
  }
162
146
 
163
- const delay = this.device.onlineStatus === OnlineStatus.OFFLINE
164
- ? this._pollingDelay
147
+ const delay = this.device.onlineStatus === OnlineStatus.OFFLINE
148
+ ? this._pollingDelay
165
149
  : this.heartbeatInterval;
166
150
 
167
151
  this._heartbeatTimer = setTimeout(() => {
@@ -172,10 +156,9 @@ class Heartbeat {
172
156
  /**
173
157
  * Performs a heartbeat check by querying System.Online.
174
158
  *
175
- * Only performs heartbeat if device has been silent for >= heartbeat interval
176
- * (conditional triggering, like meross_lan). On success, the response will be
177
- * handled by recordResponse() via the normal message handling flow. On failure,
178
- * records a failure and applies exponential backoff when offline.
159
+ * Skips heartbeat if device responded recently to avoid redundant requests when
160
+ * device is actively communicating. On failure, increases polling delay when
161
+ * already offline to reduce network load while waiting for device recovery.
179
162
  *
180
163
  * @private
181
164
  */
@@ -184,7 +167,6 @@ class Heartbeat {
184
167
  return;
185
168
  }
186
169
 
187
- // Only perform heartbeat if no response received for >= heartbeat interval
188
170
  if (this._lastResponseTime) {
189
171
  const timeSinceLastResponse = Date.now() - this._lastResponseTime;
190
172
  if (timeSinceLastResponse < this.heartbeatInterval) {
@@ -196,8 +178,8 @@ class Heartbeat {
196
178
  try {
197
179
  await this.device.system.getOnlineStatus();
198
180
  } catch (error) {
199
- this.recordFailure();
200
-
181
+ // Single heartbeat failure doesn't mark device offline; only extended
182
+ // silence triggers offline detection to handle transient network issues
201
183
  if (this.device.onlineStatus === OnlineStatus.OFFLINE) {
202
184
  this._pollingDelay = Math.min(
203
185
  this._pollingDelay * 2,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meross-iot",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Control Meross cloud devices using nodejs",
5
5
  "author": "Abe Haverkamp",
6
6
  "contributors": [