@homebridge-plugins/homebridge-ewelink 12.13.0 → 12.13.1-beta.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.
@@ -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) {
14
+ constructor(platform, authData, httpClient) {
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,6 +19,9 @@ 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
+
22
25
  // Set up variables from the authData (from HTTP)
23
26
  this.httpHost = authData.httpHost
24
27
  this.aToken = authData.aToken
@@ -26,6 +29,54 @@ export default class {
26
29
 
27
30
  // Flag used to determine ws connection status
28
31
  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
+ if (this.debug) {
51
+ this.log('%s [%s] in %s ms (attempt %s).', this.lang.wsLoginErrRecon, reason, delay, this.retryCount)
52
+ }
53
+ this.reconnectTimer = setTimeout(() => {
54
+ this.reconnectTimer = null
55
+ this.login().catch(() => {
56
+ // login() schedules its own retry on internal failure — nothing to do here
57
+ })
58
+ }, delay)
59
+ }
60
+
61
+ async refreshHttpToken() {
62
+ // Re-run HTTP login to get a fresh aToken; a 406 means the old one is invalidated.
63
+ // This does NOT re-fetch homes/devices — only the bearer token changes.
64
+ if (!this.httpClient) {
65
+ return false
66
+ }
67
+ try {
68
+ const authData = await this.httpClient.login()
69
+ this.aToken = authData.aToken
70
+ this.apiKey = authData.apiKey
71
+ this.httpHost = authData.httpHost
72
+ if (this.debug) {
73
+ this.log('%s.', 'HTTP token refreshed after 406')
74
+ }
75
+ return true
76
+ } catch (err) {
77
+ this.log.warn('%s %s.', 'HTTP token refresh failed:', parseError(err))
78
+ return false
79
+ }
29
80
  }
30
81
 
31
82
  async getHost(attempt = 0) {
@@ -81,17 +132,21 @@ export default class {
81
132
  }
82
133
 
83
134
  async login() {
84
- // Used to create the web socket connection and authenticate
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)
135
+ // Used to create the web socket connection and authenticate.
136
+ // All reconnection paths (onClose, onError, 406, open-fail) funnel through
137
+ // scheduleReconnect(), which guards against concurrent logins via loginInProgress.
138
+ if (this.loginInProgress) {
139
+ return
140
+ }
141
+ this.loginInProgress = true
94
142
 
143
+ // Cancel any pending reconnect — we're attempting a login right now
144
+ if (this.reconnectTimer) {
145
+ clearTimeout(this.reconnectTimer)
146
+ this.reconnectTimer = null
147
+ }
148
+
149
+ try {
95
150
  // Close any existing web socket connection
96
151
  await this.closeConnection()
97
152
 
@@ -159,20 +214,19 @@ export default class {
159
214
  }
160
215
  }, (res.config.hbInterval + 7) * 1000)
161
216
 
162
- // Login was successful and log if appropriate
163
- this.authRetries = 0
217
+ // Login was successful — reset the retry counter and log if appropriate
218
+ this.retryCount = 0
164
219
  if (this.debug) {
165
220
  this.log('%s.', this.lang.wsLoginSuccess)
166
221
  }
167
222
  } else if (res.error === 406) {
168
- // Token invalidated (e.g. concurrent session), re-authenticate with backoff
223
+ // Token invalidated (e.g. concurrent session from the mobile app).
224
+ // Re-sending userOnline with the same aToken will keep returning 406,
225
+ // so refresh the HTTP token first, then reconnect with backoff.
169
226
  this.log.warn(this.lang.wsLogin406)
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
+ await this.refreshHttpToken()
228
+ await this.closeConnection()
229
+ this.scheduleReconnect('406')
176
230
  } else {
177
231
  // There was a problem with the authentication response
178
232
  const str = JSON.stringify(res, null, 2)
@@ -181,6 +235,8 @@ export default class {
181
235
  } catch (err) {
182
236
  // Catch any errors authenticating the WS connection
183
237
  this.log.warn('%s %s.', this.lang.wsLoginError, parseError(err))
238
+ await this.closeConnection()
239
+ this.scheduleReconnect('auth-error')
184
240
  }
185
241
  })
186
242
 
@@ -267,14 +323,14 @@ export default class {
267
323
  msg.params.updateSource = 'WS'
268
324
  emitter.emit('update', { deviceid: msg.deviceid, params: msg.params })
269
325
  }
270
- } else {
326
+ } else if (msg.error !== 406) {
271
327
  // WS message received has an unknown action
272
328
  this.log.warn('%s.\n%s', this.lang.wsUnkCmd, JSON.stringify(msg, null, 2))
273
329
  }
274
330
  })
275
331
 
276
332
  // Add a listener for when the web socket closes for any reason
277
- this.wsClient.onClose.addListener(async (e) => {
333
+ this.wsClient.onClose.addListener((e) => {
278
334
  // Don't continue further with logging/reconnection if it's a wanted closure
279
335
  if (e === 1005) {
280
336
  return
@@ -284,30 +340,23 @@ export default class {
284
340
  this.log.warn('%s [%s].', this.lang.wsReconnectError, e)
285
341
  }
286
342
 
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()
343
+ this.wsIsConnected = false
344
+ this.scheduleReconnect(`close-${e}`)
292
345
  })
293
346
 
294
347
  // Add a listener for when the web socket throws an error
295
- this.wsClient.onError.addListener(async (e) => {
348
+ this.wsClient.onError.addListener((e) => {
296
349
  if (this.debug) {
297
350
  this.log.warn('%s [%s].', this.lang.wsReconnectClose, e)
298
351
  }
299
352
 
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()
353
+ this.scheduleReconnect('error')
304
354
  })
305
355
 
306
- // Open the web socket connection
356
+ // Open the web socket connection.
357
+ // NB: retryCount is reset only on successful auth, not on TCP handshake —
358
+ // otherwise a repeated auth-failure loop would reset backoff every cycle.
307
359
  await this.wsClient.open()
308
-
309
- // Reset reconnect counter on successful connection
310
- this.reconnectAttempts = 0
311
360
  } catch (err) {
312
361
  const errToShow = parseError(err)
313
362
 
@@ -315,10 +364,9 @@ export default class {
315
364
  this.log('%s: %s.', this.lang.wsLoginErrRecon, errToShow)
316
365
  }
317
366
 
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
+ this.scheduleReconnect('open-failed')
368
+ } finally {
369
+ this.loginInProgress = false
322
370
  }
323
371
  }
324
372
 
@@ -419,7 +467,8 @@ export default class {
419
467
  }
420
468
 
421
469
  async closeConnection() {
422
- // This is called when refreshing connection or if Homebridge shuts down
470
+ // This is called when refreshing connection or if Homebridge shuts down.
471
+ // Idempotent — safe to call regardless of current connection state.
423
472
 
424
473
  // Clear any existing ping interval
425
474
  if (this.hbInterval) {
@@ -429,21 +478,22 @@ export default class {
429
478
 
430
479
  // Check the ws client is set up
431
480
  if (this.wsClient) {
432
- // Remove any existing listeners
433
- this.wsClient.removeAllListeners()
481
+ const wasConnected = this.wsIsConnected
434
482
 
435
- // Close the web socket connection
436
- if (this.wsIsConnected) {
437
- // Set the connection flag to false
438
- this.wsIsConnected = false
483
+ // Remove any existing listeners so onClose/onError don't retrigger reconnection
484
+ this.wsClient.removeAllListeners()
485
+ this.wsIsConnected = false
439
486
 
440
- // Close the connection
487
+ // Always attempt to close the underlying socket — auth-failure paths (e.g. 406)
488
+ // leave wsIsConnected=false but the TCP/WS socket is still open.
489
+ try {
441
490
  await this.wsClient.close()
491
+ } catch {
492
+ // Already closed, never fully opened, or close threw — nothing actionable
493
+ }
442
494
 
443
- // Log if appropriate
444
- if (this.debug) {
445
- this.log('%s.', this.lang.stoppedWS)
446
- }
495
+ if (wasConnected && this.debug) {
496
+ this.log('%s.', this.lang.stoppedWS)
447
497
  }
448
498
  }
449
499
  }
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)
516
+ this.wsClient = new wsClient(this, authData, this.httpClient)
517
517
  await this.wsClient.login()
518
518
 
519
519
  // Refresh the WS connection every 60 minutes
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.0",
6
+ "version": "12.13.1-beta.1",
7
7
  "description": "Homebridge plugin to integrate eWeLink devices into HomeKit.",
8
8
  "author": {
9
9
  "name": "bwp91",
@@ -62,13 +62,13 @@
62
62
  },
63
63
  "dependencies": {
64
64
  "@homebridge/plugin-ui-utils": "^2.2.3",
65
- "axios": "^1.15.0",
65
+ "axios": "^1.15.1",
66
66
  "node-persist": "^4.0.4",
67
67
  "p-queue": "^9.1.2",
68
68
  "websocket-as-promised": "^3.0.1",
69
69
  "ws": "^8.20.0"
70
70
  },
71
71
  "devDependencies": {
72
- "@antfu/eslint-config": "^8.1.1"
72
+ "@antfu/eslint-config": "^8.2.0"
73
73
  }
74
74
  }