homebridge-tuya-plus 3.14.0-beta.2 → 3.14.0-dev.3
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/.github/CODEOWNERS +5 -0
- package/.github/workflows/publish-dev.yml +146 -0
- package/Readme.MD +13 -0
- package/lib/TuyaAccessory.js +39 -1
- package/package.json +1 -1
- package/test/TuyaAccessory.protocol.test.js +98 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
name: Publish dev to npm
|
|
2
|
+
|
|
3
|
+
# Publishes commits on `main` to npm under the `dev` dist-tag.
|
|
4
|
+
#
|
|
5
|
+
# Versioning:
|
|
6
|
+
# * Dev builds target the NEXT minor release, since releases here are
|
|
7
|
+
# usually a minor bump. With package.json at e.g. 3.13.0, dev builds
|
|
8
|
+
# are published as 3.14.0-dev.<run-number> (minor +1, patch reset to 0,
|
|
9
|
+
# prerelease tag = the GitHub Actions run number). In semver these sort
|
|
10
|
+
# below the eventual 3.14.0 release, so `latest` always wins once cut.
|
|
11
|
+
# * Release commits are skipped: if a push changes package.json's version
|
|
12
|
+
# (i.e. you bumped it to cut a real release and publish to npm
|
|
13
|
+
# manually), no dev build is published for that push.
|
|
14
|
+
#
|
|
15
|
+
# Security model (why a malicious PR cannot steal publishing rights):
|
|
16
|
+
# * No npm token is stored anywhere. Publishing uses npm Trusted
|
|
17
|
+
# Publishing (OIDC): GitHub mints a short-lived, cryptographically
|
|
18
|
+
# signed token that npm only accepts when its claims match THIS repo +
|
|
19
|
+
# THIS workflow file. There is no durable credential to exfiltrate.
|
|
20
|
+
# * This workflow never runs on `pull_request`, only on pushes to `main`
|
|
21
|
+
# (post-merge) and manual dispatch — so fork PRs get nothing.
|
|
22
|
+
# * The privileged `publish` job is minimal and isolated: it does not
|
|
23
|
+
# run `npm ci`, a build, or tests, and uses `--ignore-scripts`, so no
|
|
24
|
+
# repo/dependency code executes while the OIDC permission is present.
|
|
25
|
+
# Tests run in a separate, unprivileged job that gates publishing, and
|
|
26
|
+
# the version is computed in an unprivileged `prepare` job.
|
|
27
|
+
#
|
|
28
|
+
# One-time setup required for this to succeed (see PR/commit notes):
|
|
29
|
+
# 1. npmjs.com -> package Settings -> Trusted Publisher:
|
|
30
|
+
# Publisher: GitHub Actions
|
|
31
|
+
# Organization/user: adrianjagielak
|
|
32
|
+
# Repository: homebridge-tuya-plus
|
|
33
|
+
# Workflow filename: publish-dev.yml
|
|
34
|
+
# Environment: npm-dev
|
|
35
|
+
# 2. GitHub repo Settings -> Environments -> create `npm-dev`,
|
|
36
|
+
# restrict deployment branches to `main` (optional: required reviewer).
|
|
37
|
+
|
|
38
|
+
on:
|
|
39
|
+
push:
|
|
40
|
+
branches: [main]
|
|
41
|
+
workflow_dispatch:
|
|
42
|
+
|
|
43
|
+
# Least privilege by default; only the publish job opts into id-token.
|
|
44
|
+
permissions:
|
|
45
|
+
contents: read
|
|
46
|
+
|
|
47
|
+
# Don't cancel an in-flight publish; let each commit publish its version.
|
|
48
|
+
concurrency:
|
|
49
|
+
group: publish-dev
|
|
50
|
+
cancel-in-progress: false
|
|
51
|
+
|
|
52
|
+
jobs:
|
|
53
|
+
test:
|
|
54
|
+
runs-on: ubuntu-latest
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@v4
|
|
57
|
+
- uses: actions/setup-node@v4
|
|
58
|
+
with:
|
|
59
|
+
node-version: "22"
|
|
60
|
+
- run: npm ci
|
|
61
|
+
- run: npm run lint
|
|
62
|
+
- run: npm run test
|
|
63
|
+
|
|
64
|
+
# Unprivileged: decides whether this push should publish a dev build, and
|
|
65
|
+
# computes the dev version. Kept out of the `publish` job so no extra work
|
|
66
|
+
# runs while the OIDC publishing permission is present.
|
|
67
|
+
prepare:
|
|
68
|
+
runs-on: ubuntu-latest
|
|
69
|
+
outputs:
|
|
70
|
+
should_publish: ${{ steps.check.outputs.should_publish }}
|
|
71
|
+
version: ${{ steps.ver.outputs.version }}
|
|
72
|
+
steps:
|
|
73
|
+
# Full history so we can read package.json from before this push.
|
|
74
|
+
- uses: actions/checkout@v4
|
|
75
|
+
with:
|
|
76
|
+
fetch-depth: 0
|
|
77
|
+
|
|
78
|
+
# Skip dev builds for release commits: if package.json's version
|
|
79
|
+
# changed anywhere in this push (before -> after), treat it as a manual
|
|
80
|
+
# release and publish nothing. If we can't determine the previous
|
|
81
|
+
# version (first push, manual dispatch, missing file), default to
|
|
82
|
+
# publishing — a dev build is the safe fallback.
|
|
83
|
+
- name: Decide whether to publish a dev build
|
|
84
|
+
id: check
|
|
85
|
+
run: |
|
|
86
|
+
CURR="$(node -p "require('./package.json').version")"
|
|
87
|
+
BEFORE="${{ github.event.before }}"
|
|
88
|
+
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
|
89
|
+
BEFORE="$(git rev-parse --verify --quiet HEAD~1 || true)"
|
|
90
|
+
fi
|
|
91
|
+
PREV=""
|
|
92
|
+
if [ -n "$BEFORE" ] && git cat-file -e "$BEFORE:package.json" 2>/dev/null; then
|
|
93
|
+
git show "$BEFORE:package.json" > "$RUNNER_TEMP/prev-package.json"
|
|
94
|
+
PREV="$(node -p "require('$RUNNER_TEMP/prev-package.json').version")"
|
|
95
|
+
fi
|
|
96
|
+
echo "Previous version: ${PREV:-<unknown>}"
|
|
97
|
+
echo "Current version: $CURR"
|
|
98
|
+
if [ -n "$PREV" ] && [ "$PREV" != "$CURR" ]; then
|
|
99
|
+
echo "::notice::package.json version changed ($PREV -> $CURR); skipping dev publish (treated as a manual release commit)."
|
|
100
|
+
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
|
101
|
+
else
|
|
102
|
+
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
# Dev builds target the next MINOR release (minor +1, patch -> 0).
|
|
106
|
+
- name: Compute dev version
|
|
107
|
+
id: ver
|
|
108
|
+
run: |
|
|
109
|
+
NEXT="$(node -e "const v=require('./package.json').version.split('.'); v[1]=Number(v[1])+1; v[2]=0; console.log(v.join('.'))")"
|
|
110
|
+
VERSION="${NEXT}-dev.${GITHUB_RUN_NUMBER}"
|
|
111
|
+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
112
|
+
echo "Will publish $VERSION"
|
|
113
|
+
|
|
114
|
+
publish:
|
|
115
|
+
needs: [test, prepare]
|
|
116
|
+
# Skip release commits (version bumped in this push).
|
|
117
|
+
if: needs.prepare.outputs.should_publish == 'true'
|
|
118
|
+
runs-on: ubuntu-latest
|
|
119
|
+
# Bind this environment in repo Settings to the `main` branch so that
|
|
120
|
+
# only main can ever reach the publish step.
|
|
121
|
+
environment: npm-dev
|
|
122
|
+
permissions:
|
|
123
|
+
contents: read # checkout
|
|
124
|
+
id-token: write # OIDC -> npm Trusted Publishing (no stored token)
|
|
125
|
+
steps:
|
|
126
|
+
- uses: actions/checkout@v4
|
|
127
|
+
|
|
128
|
+
- uses: actions/setup-node@v4
|
|
129
|
+
with:
|
|
130
|
+
node-version: "22"
|
|
131
|
+
registry-url: "https://registry.npmjs.org"
|
|
132
|
+
|
|
133
|
+
# Trusted Publishing (OIDC) requires npm >= 11.5.1.
|
|
134
|
+
- name: Ensure OIDC-capable npm
|
|
135
|
+
run: npm install -g npm@latest
|
|
136
|
+
|
|
137
|
+
# Writes package.json on the runner only; nothing is committed.
|
|
138
|
+
- name: Set version
|
|
139
|
+
run: npm version "${{ needs.prepare.outputs.version }}" --no-git-tag-version --allow-same-version --ignore-scripts
|
|
140
|
+
|
|
141
|
+
# No NODE_AUTH_TOKEN: npm exchanges the OIDC id-token for a
|
|
142
|
+
# short-lived, package-scoped credential at publish time.
|
|
143
|
+
# --ignore-scripts ensures no lifecycle script runs with that credential.
|
|
144
|
+
# --provenance attaches a signed build attestation (needs a public repo).
|
|
145
|
+
- name: Publish (dev tag)
|
|
146
|
+
run: npm publish --tag dev --provenance --ignore-scripts
|
package/Readme.MD
CHANGED
|
@@ -81,6 +81,19 @@ Search for "Tuya" in [homebridge-config-ui-x](https://github.com/oznu/homebridge
|
|
|
81
81
|
sudo npm install -g homebridge-tuya-plus
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
+
#### Bleeding-edge (`dev`) builds:
|
|
85
|
+
|
|
86
|
+
Every commit to `main` is automatically published to npm under the `dev` tag — a
|
|
87
|
+
separate, unstable channel. The stable release always stays on `latest`, so this
|
|
88
|
+
won't affect normal installs. To try the latest in-development build:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
sudo npm install -g homebridge-tuya-plus@dev
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
These are versioned like `3.13.1-dev.<n>` and may be unstable; use the default
|
|
95
|
+
install above for production.
|
|
96
|
+
|
|
84
97
|
## Configuration
|
|
85
98
|
> UI
|
|
86
99
|
|
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.14.0-
|
|
3
|
+
"version": "3.14.0-dev.3",
|
|
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
|
+
});
|