@homebridge-plugins/homebridge-ewelink 12.13.1-beta.2 → 12.14.1-beta.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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-ewelink` will be documented in this file.
4
4
 
5
+ ## v12.14.1 (Pending Release)
6
+
7
+ - fix(garage-one): send single-outlet payload on switchSCM devices (#774) (@nico81)
8
+ - fix(ws): do not retry sendUpdate on timeout (#775) (@nico81)
9
+ - fix(platform): do not serialize hidden accessories to the cache
10
+ - chore: dependency updates
11
+
12
+ ## v12.14.0 (2026-05-05)
13
+
14
+ ### Changed
15
+
16
+ - dependency updates
17
+
5
18
  ## v12.13.0 (2026-04-12)
6
19
 
7
20
  ### Changed
@@ -11,7 +11,7 @@ import { hasProperty, parseError, sleep } from '../utils/functions.js'
11
11
  const emitter = new events()
12
12
 
13
13
  export default class {
14
- constructor(platform, authData, httpClient) {
14
+ constructor(platform, authData) {
15
15
  // Set up variables from the platform
16
16
  this.appId = platform.config.appId || platformConsts.appId
17
17
  this.appSecret = platform.config.appSecret || platformConsts.appSecret
@@ -19,9 +19,6 @@ export default class {
19
19
  this.lang = platform.lang
20
20
  this.log = platform.log
21
21
 
22
- // HTTP client used to refresh the bearer token when a 406 invalidates the session
23
- this.httpClient = httpClient
24
-
25
22
  // Set up variables from the authData (from HTTP)
26
23
  this.httpHost = authData.httpHost
27
24
  this.aToken = authData.aToken
@@ -29,51 +26,6 @@ export default class {
29
26
 
30
27
  // Flag used to determine ws connection status
31
28
  this.wsIsConnected = false
32
-
33
- // Retry state — a single counter/timer serialises all reconnection paths
34
- this.retryCount = 0
35
- this.loginInProgress = false
36
- this.reconnectTimer = null
37
- }
38
-
39
- scheduleReconnect(reason) {
40
- // Another retry is already queued or a login is actively running — nothing to do
41
- if (this.reconnectTimer || this.loginInProgress) {
42
- return
43
- }
44
- if (this.retryCount >= 10) {
45
- this.log.warn('%s [giving up after %s attempts, reason: %s].', this.lang.wsLoginError, this.retryCount, reason)
46
- return
47
- }
48
- const delay = Math.min(5000 * 2 ** this.retryCount, 300000)
49
- this.retryCount += 1
50
- // Log recovery at info level — users need to see that retries are happening
51
- this.log('WS reconnect scheduled [%s] in %s ms (attempt %s).', reason, delay, this.retryCount)
52
- this.reconnectTimer = setTimeout(() => {
53
- this.reconnectTimer = null
54
- this.login().catch(() => {
55
- // login() schedules its own retry on internal failure — nothing to do here
56
- })
57
- }, delay)
58
- }
59
-
60
- async refreshHttpToken() {
61
- // Re-run HTTP login to get a fresh aToken; a 406 means the old one is invalidated.
62
- // This does NOT re-fetch homes/devices — only the bearer token changes.
63
- if (!this.httpClient) {
64
- return false
65
- }
66
- try {
67
- const authData = await this.httpClient.login()
68
- this.aToken = authData.aToken
69
- this.apiKey = authData.apiKey
70
- this.httpHost = authData.httpHost
71
- this.log('HTTP token refreshed after 406.')
72
- return true
73
- } catch (err) {
74
- this.log.warn('%s %s.', 'HTTP token refresh failed:', parseError(err))
75
- return false
76
- }
77
29
  }
78
30
 
79
31
  async getHost(attempt = 0) {
@@ -129,21 +81,17 @@ export default class {
129
81
  }
130
82
 
131
83
  async login() {
132
- // Used to create the web socket connection and authenticate.
133
- // All reconnection paths (onClose, onError, 406, open-fail) funnel through
134
- // scheduleReconnect(), which guards against concurrent logins via loginInProgress.
135
- if (this.loginInProgress) {
136
- return
137
- }
138
- this.loginInProgress = true
139
-
140
- // Cancel any pending reconnect — we're attempting a login right now
141
- if (this.reconnectTimer) {
142
- clearTimeout(this.reconnectTimer)
143
- this.reconnectTimer = null
144
- }
145
-
84
+ // Used to create the web socket connection and authenticate
146
85
  try {
86
+ // This may be called onClose and onError, we just want it to run once
87
+ if (this.debounce) {
88
+ return
89
+ }
90
+ this.debounce = true
91
+ setTimeout(() => {
92
+ this.debounce = false
93
+ }, 4000)
94
+
147
95
  // Close any existing web socket connection
148
96
  await this.closeConnection()
149
97
 
@@ -211,19 +159,20 @@ export default class {
211
159
  }
212
160
  }, (res.config.hbInterval + 7) * 1000)
213
161
 
214
- // Login was successful — reset the retry counter and log if appropriate
215
- this.retryCount = 0
162
+ // Login was successful and log if appropriate
163
+ this.authRetries = 0
216
164
  if (this.debug) {
217
165
  this.log('%s.', this.lang.wsLoginSuccess)
218
166
  }
219
167
  } else if (res.error === 406) {
220
- // Token invalidated (e.g. concurrent session from the mobile app).
221
- // Re-sending userOnline with the same aToken will keep returning 406,
222
- // so refresh the HTTP token first, then reconnect with backoff.
168
+ // Token invalidated (e.g. concurrent session), re-authenticate with backoff
223
169
  this.log.warn(this.lang.wsLogin406)
224
- await this.refreshHttpToken()
225
- await this.closeConnection()
226
- this.scheduleReconnect('406')
170
+ const delay = Math.min(5000 * 2 ** (this.authRetries || 0), 300000)
171
+ this.authRetries = (this.authRetries || 0) + 1
172
+ if (this.authRetries <= 10) {
173
+ await sleep(delay)
174
+ await this.login()
175
+ }
227
176
  } else {
228
177
  // There was a problem with the authentication response
229
178
  const str = JSON.stringify(res, null, 2)
@@ -232,8 +181,6 @@ export default class {
232
181
  } catch (err) {
233
182
  // Catch any errors authenticating the WS connection
234
183
  this.log.warn('%s %s.', this.lang.wsLoginError, parseError(err))
235
- await this.closeConnection()
236
- this.scheduleReconnect('auth-error')
237
184
  }
238
185
  })
239
186
 
@@ -320,14 +267,14 @@ export default class {
320
267
  msg.params.updateSource = 'WS'
321
268
  emitter.emit('update', { deviceid: msg.deviceid, params: msg.params })
322
269
  }
323
- } else if (msg.error !== 406) {
270
+ } else {
324
271
  // WS message received has an unknown action
325
272
  this.log.warn('%s.\n%s', this.lang.wsUnkCmd, JSON.stringify(msg, null, 2))
326
273
  }
327
274
  })
328
275
 
329
276
  // Add a listener for when the web socket closes for any reason
330
- this.wsClient.onClose.addListener((e) => {
277
+ this.wsClient.onClose.addListener(async (e) => {
331
278
  // Don't continue further with logging/reconnection if it's a wanted closure
332
279
  if (e === 1005) {
333
280
  return
@@ -337,23 +284,30 @@ export default class {
337
284
  this.log.warn('%s [%s].', this.lang.wsReconnectError, e)
338
285
  }
339
286
 
340
- this.wsIsConnected = false
341
- this.scheduleReconnect(`close-${e}`)
287
+ // Reconnect with exponential backoff
288
+ this.reconnectAttempts = (this.reconnectAttempts || 0) + 1
289
+ const delay = Math.min(5000 * 2 ** (this.reconnectAttempts - 1), 300000)
290
+ await sleep(delay)
291
+ await this.login()
342
292
  })
343
293
 
344
294
  // Add a listener for when the web socket throws an error
345
- this.wsClient.onError.addListener((e) => {
295
+ this.wsClient.onError.addListener(async (e) => {
346
296
  if (this.debug) {
347
297
  this.log.warn('%s [%s].', this.lang.wsReconnectClose, e)
348
298
  }
349
299
 
350
- this.scheduleReconnect('error')
300
+ this.reconnectAttempts = (this.reconnectAttempts || 0) + 1
301
+ const delay = Math.min(5000 * 2 ** (this.reconnectAttempts - 1), 300000)
302
+ await sleep(delay)
303
+ await this.login()
351
304
  })
352
305
 
353
- // Open the web socket connection.
354
- // NB: retryCount is reset only on successful auth, not on TCP handshake —
355
- // otherwise a repeated auth-failure loop would reset backoff every cycle.
306
+ // Open the web socket connection
356
307
  await this.wsClient.open()
308
+
309
+ // Reset reconnect counter on successful connection
310
+ this.reconnectAttempts = 0
357
311
  } catch (err) {
358
312
  const errToShow = parseError(err)
359
313
 
@@ -361,13 +315,14 @@ export default class {
361
315
  this.log('%s: %s.', this.lang.wsLoginErrRecon, errToShow)
362
316
  }
363
317
 
364
- this.scheduleReconnect('open-failed')
365
- } finally {
366
- this.loginInProgress = false
318
+ this.reconnectAttempts = (this.reconnectAttempts || 0) + 1
319
+ const delay = Math.min(5000 * 2 ** (this.reconnectAttempts - 1), 300000)
320
+ await sleep(delay)
321
+ await this.login()
367
322
  }
368
323
  }
369
324
 
370
- async sendUpdate(json, retries = 0) {
325
+ async sendUpdate(json) {
371
326
  // Generate the payload to send
372
327
  const toSend = {
373
328
  ...json,
@@ -413,13 +368,11 @@ export default class {
413
368
  throw new Error(`${this.lang.wsUnkRes} [${res.error}]`)
414
369
  }
415
370
  } catch (err) {
416
- // Retry up to 3 times on timeout errors only
417
- const errMsg = parseError(err)
418
- if (retries < 3 && (errMsg.includes('504') || errMsg.includes('timeout') || errMsg.includes('Timeout'))) {
419
- await sleep(1000)
420
- return this.sendUpdate(json, retries + 1)
421
- }
422
- throw new Error(errMsg)
371
+ // Do not retry on timeout: the original command may have already been
372
+ // applied by the device but the cloud failed to deliver a formal ACK.
373
+ // Retrying non-idempotent writes (e.g. inching/momentary actuators)
374
+ // produces additional physical pulses, causing actuators to bounce.
375
+ throw new Error(parseError(err))
423
376
  }
424
377
  } else {
425
378
  // The web socket isn't currently open so log this
@@ -464,8 +417,7 @@ export default class {
464
417
  }
465
418
 
466
419
  async closeConnection() {
467
- // This is called when refreshing connection or if Homebridge shuts down.
468
- // Idempotent — safe to call regardless of current connection state.
420
+ // This is called when refreshing connection or if Homebridge shuts down
469
421
 
470
422
  // Clear any existing ping interval
471
423
  if (this.hbInterval) {
@@ -475,22 +427,21 @@ export default class {
475
427
 
476
428
  // Check the ws client is set up
477
429
  if (this.wsClient) {
478
- const wasConnected = this.wsIsConnected
479
-
480
- // Remove any existing listeners so onClose/onError don't retrigger reconnection
430
+ // Remove any existing listeners
481
431
  this.wsClient.removeAllListeners()
482
- this.wsIsConnected = false
483
432
 
484
- // Always attempt to close the underlying socket — auth-failure paths (e.g. 406)
485
- // leave wsIsConnected=false but the TCP/WS socket is still open.
486
- try {
433
+ // Close the web socket connection
434
+ if (this.wsIsConnected) {
435
+ // Set the connection flag to false
436
+ this.wsIsConnected = false
437
+
438
+ // Close the connection
487
439
  await this.wsClient.close()
488
- } catch {
489
- // Already closed, never fully opened, or close threw — nothing actionable
490
- }
491
440
 
492
- if (wasConnected && this.debug) {
493
- this.log('%s.', this.lang.stoppedWS)
441
+ // Log if appropriate
442
+ if (this.debug) {
443
+ this.log('%s.', this.lang.stoppedWS)
444
+ }
494
445
  }
495
446
  }
496
447
  }
@@ -205,21 +205,33 @@ export default class {
205
205
  await sleep(delay)
206
206
  this.service.updateCharacteristic(this.hapChar.TargetDoorState, newPos)
207
207
  this.service.updateCharacteristic(this.hapChar.CurrentDoorState, newPos + 2)
208
+ // SCM (single-channel multi-format) devices use the switches[] array
209
+ // but only expose outlet 0. True dual-channel garage controllers
210
+ // (e.g. DUALR3) use separate outlets for open/close relay action.
211
+ const isScm = platformConsts.devices.switchSCM.includes(this.accessory.context.eweUIID)
212
+ || platformConsts.devices.switchSCMPower.includes(this.accessory.context.eweUIID)
213
+
208
214
  switch (this.setup) {
209
215
  case 'switchSingle':
210
216
  params.switch = 'on'
211
217
  break
212
218
  case 'switchMulti':
213
- params.switches = [
214
- {
215
- switch: newPos === 0 ? 'on' : 'off',
216
- outlet: 0,
217
- },
218
- {
219
- switch: newPos === 1 ? 'on' : 'off',
220
- outlet: 1,
221
- },
222
- ]
219
+ if (isScm) {
220
+ // SCM device: send a single momentary pulse on outlet 0
221
+ params.switches = [{ switch: 'on', outlet: 0 }]
222
+ } else {
223
+ // Dual-channel device with separate open/close relays
224
+ params.switches = [
225
+ {
226
+ switch: newPos === 0 ? 'on' : 'off',
227
+ outlet: 0,
228
+ },
229
+ {
230
+ switch: newPos === 1 ? 'on' : 'off',
231
+ outlet: 1,
232
+ },
233
+ ]
234
+ }
223
235
  break
224
236
  default:
225
237
  return
package/lib/platform.js CHANGED
@@ -513,7 +513,7 @@ export default class {
513
513
 
514
514
  // Set up the WS client, get the user WS host and login
515
515
  if (this.config.mode !== 'lan') {
516
- this.wsClient = new wsClient(this, authData, this.httpClient)
516
+ this.wsClient = new wsClient(this, authData)
517
517
  await this.wsClient.login()
518
518
 
519
519
  // Refresh the WS connection every 60 minutes
@@ -1427,8 +1427,7 @@ export default class {
1427
1427
  }
1428
1428
 
1429
1429
  // Update any changes to the sub accessory to the platform
1430
- this.api.updatePlatformAccessories([subAccessory])
1431
- devicesInHB.set(subAccessory.UUID, subAccessory)
1430
+ this.saveAccessoryUpdate(subAccessory)
1432
1431
  if (i === 0) {
1433
1432
  accessory = subAccessory
1434
1433
  }
@@ -1567,8 +1566,7 @@ export default class {
1567
1566
  }
1568
1567
 
1569
1568
  // Update any changes to the sub accessory to the platform
1570
- this.api.updatePlatformAccessories([subAccessory])
1571
- devicesInHB.set(subAccessory.UUID, subAccessory)
1569
+ this.saveAccessoryUpdate(subAccessory)
1572
1570
 
1573
1571
  // Increment the counter
1574
1572
  rfChlCounter += Object.keys(subDevice.buttons || {}).length
@@ -1826,8 +1824,7 @@ export default class {
1826
1824
  }
1827
1825
 
1828
1826
  // Update any changes to the device into our devicesInHB map
1829
- this.api.updatePlatformAccessories([accessory])
1830
- devicesInHB.set(accessory.UUID, accessory)
1827
+ this.saveAccessoryUpdate(accessory)
1831
1828
  if (uiid === 5000) {
1832
1829
  this.log(
1833
1830
  '[%s] %s [%s] [%s].',
@@ -1947,6 +1944,20 @@ export default class {
1947
1944
  }
1948
1945
  }
1949
1946
 
1947
+ saveAccessoryUpdate(accessory) {
1948
+ // Persist any context/characteristic changes to Homebridge, then keep the
1949
+ // accessory in our in-memory map. Hidden accessories are deliberately never
1950
+ // registered (see addAccessory), so they have no associated plugin and would
1951
+ // make Homebridge throw when it serialises its accessory cache to disk
1952
+ // ('Cannot serialize accessory ... - missing associated plugin'). We still
1953
+ // keep them in devicesInHB so lookups (e.g. the SW0 master of a multi-channel
1954
+ // device) continue to work.
1955
+ if (!accessory.context.hidden) {
1956
+ this.api.updatePlatformAccessories([accessory])
1957
+ }
1958
+ devicesInHB.set(accessory.UUID, accessory)
1959
+ }
1960
+
1950
1961
  addExternalAccessory(device, hbDeviceId, category) {
1951
1962
  try {
1952
1963
  // Add the new accessory to Homebridge
@@ -2092,8 +2103,7 @@ export default class {
2092
2103
  // The update is from WS so update the WS online/offline status
2093
2104
  if (device.params.online !== accessory.context.reachableWAN) {
2094
2105
  accessory.context.reachableWAN = device.params.online
2095
- this.api.updatePlatformAccessories([accessory])
2096
- devicesInHB.set(accessory.UUID, accessory)
2106
+ this.saveAccessoryUpdate(accessory)
2097
2107
 
2098
2108
  // Flag this true to update the sub accessories later
2099
2109
  reachableChange = true
@@ -2154,8 +2164,7 @@ export default class {
2154
2164
 
2155
2165
  // Update the accessory context if the device is back online or the IP changed
2156
2166
  if (reachableChange) {
2157
- this.api.updatePlatformAccessories([accessory])
2158
- devicesInHB.set(accessory.UUID, accessory)
2167
+ this.saveAccessoryUpdate(accessory)
2159
2168
  }
2160
2169
  }
2161
2170
 
@@ -2183,8 +2192,7 @@ export default class {
2183
2192
  }
2184
2193
 
2185
2194
  // Save the sub accessory updates to the platform
2186
- this.api.updatePlatformAccessories([subAccessory])
2187
- devicesInHB.set(subAccessory.UUID, subAccessory)
2195
+ this.saveAccessoryUpdate(subAccessory)
2188
2196
  }
2189
2197
  }
2190
2198
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "displayName": "Homebridge eWeLink",
4
4
  "alias": "eWeLink",
5
5
  "type": "module",
6
- "version": "12.13.1-beta.2",
6
+ "version": "12.14.1-beta.0",
7
7
  "description": "Homebridge plugin to integrate eWeLink devices into HomeKit.",
8
8
  "author": {
9
9
  "name": "bwp91",
@@ -53,22 +53,22 @@
53
53
  ],
54
54
  "main": "lib/index.js",
55
55
  "engines": {
56
- "homebridge": "^1.6.0 || ^2.0.0-beta.0",
57
- "node": "^22 || ^24"
56
+ "homebridge": "^1.6.0 || ^2.0.0",
57
+ "node": "^22.12.0 || ^24.0.0"
58
58
  },
59
59
  "scripts": {
60
60
  "lint": "eslint . --max-warnings=0",
61
61
  "lint:fix": "npm run lint -- --fix"
62
62
  },
63
63
  "dependencies": {
64
- "@homebridge/plugin-ui-utils": "^2.2.3",
65
- "axios": "^1.15.1",
64
+ "@homebridge/plugin-ui-utils": "^2.2.4",
65
+ "axios": "^1.17.0",
66
66
  "node-persist": "^4.0.4",
67
- "p-queue": "^9.1.2",
67
+ "p-queue": "^9.3.0",
68
68
  "websocket-as-promised": "^3.0.1",
69
- "ws": "^8.20.0"
69
+ "ws": "^8.21.0"
70
70
  },
71
71
  "devDependencies": {
72
- "@antfu/eslint-config": "^8.2.0"
72
+ "@antfu/eslint-config": "^9.0.0"
73
73
  }
74
74
  }