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.
package/lib/TuyaAccessory.js
CHANGED
|
@@ -226,9 +226,21 @@ class TuyaAccessory extends EventEmitter {
|
|
|
226
226
|
}
|
|
227
227
|
});
|
|
228
228
|
|
|
229
|
-
this._socket.on('close',
|
|
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.
|
|
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
|
+
});
|