homebridge-tuya-plus 3.13.1-dev.1 → 3.13.1-dev.2

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.
@@ -226,9 +226,21 @@ class TuyaAccessory extends EventEmitter {
226
226
  }
227
227
  });
228
228
 
229
- this._socket.on('close', err => {
229
+ this._socket.on('close', () => {
230
230
  this.connected = false;
231
231
  this.session_key = null;
232
+
233
+ // A heartbeat timer must never outlive its socket. The pinger
234
+ // callbacks act on `this._socket`, so a stale timer left over from a
235
+ // dead socket would later fire against a freshly reconnected one and
236
+ // trip a spurious ERR_PING_TIMED_OUT. Clearing it here covers every
237
+ // teardown path (error-driven destroy as well as the graceful end
238
+ // below).
239
+ if (this._socket._pinger) {
240
+ clearTimeout(this._socket._pinger);
241
+ this._socket._pinger = null;
242
+ }
243
+
232
244
  //this.log.info('Closed connection with', this.context.name);
233
245
  });
234
246
 
@@ -236,6 +248,32 @@ class TuyaAccessory extends EventEmitter {
236
248
  this.connected = false;
237
249
  this.session_key = null;
238
250
  this.log.info('Disconnected from', this.context.name);
251
+
252
+ // The device closed the connection on its own. This is routine for
253
+ // many Tuya devices (e.g. LED ceiling lights) that recycle their
254
+ // long-lived LAN sockets every few minutes. Previously nothing here
255
+ // tore the socket down or reconnected: the heartbeat timer kept
256
+ // running and, because `connected` is now false, its pings went
257
+ // nowhere until the full ping timeout elapsed and emitted a
258
+ // misleading "ERR_PING_TIMED_OUT" ~30s later - which was the only
259
+ // thing that ultimately triggered a reconnect. Tear down and
260
+ // reconnect promptly instead, so recovery is quick and the logs
261
+ // reflect what actually happened (a clean disconnect, not a ping
262
+ // failure).
263
+ if (this._socket._pinger) {
264
+ clearTimeout(this._socket._pinger);
265
+ this._socket._pinger = null;
266
+ }
267
+
268
+ // Destroying first means a trailing RST can't resurface as an
269
+ // 'error' and schedule a second, competing reconnect.
270
+ this._socket.destroy();
271
+
272
+ if (!this._socket._errorReconnect) {
273
+ this._socket._errorReconnect = setTimeout(() => {
274
+ process.nextTick(this._connect.bind(this));
275
+ }, 5000);
276
+ }
239
277
  });
240
278
  }
241
279
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.13.1-dev.1",
3
+ "version": "3.13.1-dev.2",
4
4
  "description": "A community-maintained Homebridge plugin for controlling Tuya devices locally over LAN. Includes new features, fixes, and updated device support.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -10,6 +10,7 @@
10
10
  // mix of legacy paths.
11
11
 
12
12
  const crypto = require('crypto');
13
+ const EventEmitter = require('events');
13
14
  const TuyaAccessory = require('../lib/TuyaAccessory');
14
15
  const TuyaDiscovery = require('../lib/TuyaDiscovery');
15
16
 
@@ -526,3 +527,100 @@ describe('discovery of 3.5+/3.6 devices', () => {
526
527
  expect(found[0].version).toBe('3.6');
527
528
  });
528
529
  });
530
+
531
+ // Many Tuya devices (e.g. LED ceiling lights) recycle their long-lived LAN
532
+ // sockets every few minutes, closing the connection with a TCP FIN ('end').
533
+ // The handler must tear that connection down and reconnect promptly instead of
534
+ // leaving a half-dead socket whose stale heartbeat timer later fires a
535
+ // misleading ERR_PING_TIMED_OUT - which used to be the only thing that
536
+ // triggered a reconnect.
537
+ describe('graceful disconnect handling', () => {
538
+ const net = require('net');
539
+ let realNetSocket;
540
+ let createdSockets;
541
+
542
+ const makeFakeSocket = () => {
543
+ const s = new EventEmitter();
544
+ s.destroyed = false;
545
+ s.setKeepAlive = () => {};
546
+ s.setNoDelay = () => {};
547
+ s.connect = jest.fn();
548
+ s.write = jest.fn(() => true);
549
+ s.destroy = jest.fn(function destroy() { this.destroyed = true; });
550
+ return s;
551
+ };
552
+
553
+ beforeEach(() => {
554
+ jest.useFakeTimers();
555
+ createdSockets = [];
556
+ realNetSocket = net.Socket;
557
+ // TuyaAccessory calls net.Socket() dynamically, so swapping the property
558
+ // is enough to hand it a controllable fake (no real network I/O).
559
+ net.Socket = function FakeSocket() {
560
+ const s = makeFakeSocket();
561
+ createdSockets.push(s);
562
+ return s;
563
+ };
564
+ });
565
+
566
+ afterEach(() => {
567
+ net.Socket = realNetSocket;
568
+ jest.clearAllTimers();
569
+ jest.useRealTimers();
570
+ });
571
+
572
+ // Bring a device up to a steady, connected state on a fake socket.
573
+ const establish = device => {
574
+ device._connect();
575
+ const socket = device._socket;
576
+ // An established connection has already cleared its connect watchdog.
577
+ clearTimeout(socket._connTimeout);
578
+ socket._connTimeout = null;
579
+ device.connected = true;
580
+ return socket;
581
+ };
582
+
583
+ test("a device-initiated 'end' clears the heartbeat, tears down, and schedules a reconnect without a spurious ping timeout", () => {
584
+ const device = makeDevice('3.5');
585
+ const socket = establish(device);
586
+
587
+ // A heartbeat retry timer is in flight. With the old handler it survived
588
+ // the disconnect and later emitted a misleading ERR_PING_TIMED_OUT.
589
+ const pingErrors = [];
590
+ socket.on('error', err => {
591
+ if (err && err.message === 'ERR_PING_TIMED_OUT') pingErrors.push(err);
592
+ });
593
+ socket._pinger = setTimeout(
594
+ () => device._socket.emit('error', new Error('ERR_PING_TIMED_OUT')),
595
+ 50
596
+ );
597
+
598
+ // The device closes the LAN connection on its own (TCP FIN -> 'end').
599
+ socket.emit('end');
600
+
601
+ expect(device.connected).toBe(false);
602
+ expect(socket._pinger).toBeNull(); // stale timer cleared
603
+ expect(socket.destroy).toHaveBeenCalledTimes(1); // socket torn down
604
+ expect(socket._errorReconnect).toBeTruthy(); // a reconnect is scheduled
605
+
606
+ // Advance well past where the leaked timer would have fired: it must not.
607
+ jest.advanceTimersByTime(1000);
608
+ expect(pingErrors).toHaveLength(0);
609
+
610
+ // The log reflects a clean disconnect, never a ping failure.
611
+ expect(device.log.info).toHaveBeenCalledWith('Disconnected from', device.context.name);
612
+ const logged = device.log.info.mock.calls.flat().join(' ');
613
+ expect(logged).not.toMatch(/ERR_PING_TIMED_OUT/);
614
+ });
615
+
616
+ test("the 'close' teardown clears any lingering heartbeat timer", () => {
617
+ const device = makeDevice('3.5');
618
+ const socket = establish(device);
619
+
620
+ socket._pinger = setTimeout(() => {}, 10000);
621
+ socket.emit('close');
622
+
623
+ expect(socket._pinger).toBeNull();
624
+ expect(device.connected).toBe(false);
625
+ });
626
+ });