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.
@@ -0,0 +1,5 @@
1
+ # Changes to CI/release automation require the maintainer's review.
2
+ # Enforced only when branch protection on `main` has
3
+ # "Require review from Code Owners" enabled.
4
+ /.github/workflows/ @adrianjagielak
5
+ /.github/CODEOWNERS @adrianjagielak
@@ -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
 
@@ -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.14.0-beta.2",
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
+ });