homebridge-tuya-plus 3.14.0-beta.1 → 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
 
@@ -579,6 +579,14 @@
579
579
  "functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
580
580
  }
581
581
  },
582
+ "stopBeforeCloseMs": {
583
+ "type": "integer",
584
+ "placeholder": "1500",
585
+ "description": "Optional. The controller ignores a close command while the gate is actively moving, so unless the state DP already reads 11 (stopped) the plugin sends stop, waits this many milliseconds, then sends close. Tune to roughly how long the gate takes to halt after a stop. Default 1500.",
586
+ "condition": {
587
+ "functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
588
+ }
589
+ },
582
590
  "partialOpenMs": {
583
591
  "type": "integer",
584
592
  "placeholder": "2000",
@@ -13,14 +13,21 @@ const BaseAccessory = require('./BaseAccessory');
13
13
  // sync no matter how the gate was triggered (Home app, a physical remote, the
14
14
  // Tuya app, ...).
15
15
  //
16
- // Unlike the original open/stop/close-only gates, this controller switches
17
- // direction on its own sending open while it is closing (or vice versa)
18
- // just reverses it so there is no stop-first dance: a HomeKit toggle simply
19
- // fires the matching action.
16
+ // Opening is symmetric and forgiving: the controller reverses on its own, so
17
+ // an open command works directly even while the gate is closing we just fire
18
+ // it. Closing is asymmetric, though: the controller IGNORES a close command
19
+ // while the gate is actively moving. The one exception is when the status DP
20
+ // reads exactly 11 (stopped) — e.g. after a partial-open or an external stop —
21
+ // where the gate is idle and accepts close immediately. So unless we can see
22
+ // it's already stopped, a close is sent as stop -> wait -> close.
20
23
  const STATE_STOPPED = 11;
21
24
  const STATE_OPENING_OR_OPEN = 12;
22
25
  const STATE_CLOSING_OR_CLOSED = 13;
23
26
 
27
+ // How long to wait between the stop and the close in the stop-before-close
28
+ // path. Overridable per-device via the `stopBeforeCloseMs` config option.
29
+ const DEFAULT_STOP_BEFORE_CLOSE_MS = 1500;
30
+
24
31
  class SimpleGarageDoorAccessory extends BaseAccessory {
25
32
  static getCategory(Categories) {
26
33
  return Categories.GARAGE_DOOR_OPENER;
@@ -82,8 +89,15 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
82
89
  const partialOpenMs = parseInt(this.device.context.partialOpenMs, 10);
83
90
  this.partialOpenMs = Number.isFinite(partialOpenMs) && partialOpenMs > 0 ? partialOpenMs : 0;
84
91
 
85
- // The only pending side effect we track is the partial-open auto-stop.
92
+ const stopBeforeCloseMs = parseInt(this.device.context.stopBeforeCloseMs, 10);
93
+ this.stopBeforeCloseMs = Number.isFinite(stopBeforeCloseMs) && stopBeforeCloseMs >= 0
94
+ ? stopBeforeCloseMs
95
+ : DEFAULT_STOP_BEFORE_CLOSE_MS;
96
+
97
+ // Pending side effects we may need to cancel: the partial-open auto-stop
98
+ // and the close that trails a stop in the stop-before-close path.
86
99
  this.partialStopTimer = null;
100
+ this.pendingCloseTimer = null;
87
101
 
88
102
  // Seed the initial state from whatever the device has already reported.
89
103
  // If it hasn't reported yet, fall back to the persisted target, then to
@@ -177,6 +191,10 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
177
191
 
178
192
  _onDeviceChange(changes) {
179
193
  if (!changes || !changes.hasOwnProperty(this.dpState)) return;
194
+ // Mid stop-before-close: the stop we just issued can briefly report 11
195
+ // (=OPEN), which would fight the close we're about to send. Ignore
196
+ // status reports until the close has gone out.
197
+ if (this.pendingCloseTimer) return;
180
198
  const isOpen = this._mapDpState(changes[this.dpState]);
181
199
  if (isOpen === null) {
182
200
  this.log.info(`[SimpleGarageDoor] ${this.device.context.name}: ignoring unknown state DP value ${JSON.stringify(changes[this.dpState])}`);
@@ -228,9 +246,56 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
228
246
  const open = value === Characteristic.TargetDoorState.OPEN;
229
247
  this.accessory.context.cachedTargetDoorState = value;
230
248
  if (this.characteristicTargetDoorState) this.characteristicTargetDoorState.updateValue(value);
231
- const dp = open ? this.dpOpen : this.dpClose;
232
- this.log.info(`[SimpleGarageDoor] ${this.device.context.name}: ${open ? 'open' : 'close'} (dp${dp})`);
233
- this.setMultiStateLegacyAsync({[dp]: true});
249
+ if (open) this._sendOpen();
250
+ else this._sendClose();
251
+ }
252
+
253
+ // Open is always safe to fire directly — the controller reverses on its
254
+ // own, even mid-close. Abandon any pending stop-before-close.
255
+ _sendOpen() {
256
+ this._cancelPendingClose();
257
+ this.log.info(`[SimpleGarageDoor] ${this.device.context.name}: open (dp${this.dpOpen})`);
258
+ this.setMultiStateLegacyAsync({[this.dpOpen]: true});
259
+ }
260
+
261
+ // Close is ignored while the gate is actively moving, so fire it directly
262
+ // only when the status DP shows the gate is already stopped (11). Otherwise
263
+ // stop first, wait stopBeforeCloseMs, then close.
264
+ _sendClose() {
265
+ const name = this.device.context.name;
266
+ if (this.pendingCloseTimer) {
267
+ // A stop-before-close is already running — don't restart it (and
268
+ // push the close out) on a duplicate or retransmitted request.
269
+ this.log.info(`[SimpleGarageDoor] ${name}: close ignored — a stop-before-close is already running`);
270
+ return;
271
+ }
272
+ if (this._isStopped()) {
273
+ this.log.info(`[SimpleGarageDoor] ${name}: close (dp${this.dpClose}) — gate already stopped`);
274
+ this.setMultiStateLegacyAsync({[this.dpClose]: true});
275
+ return;
276
+ }
277
+ this.log.info(`[SimpleGarageDoor] ${name}: stop-before-close — stop (dp${this.dpStop}) now, close (dp${this.dpClose}) in ${this.stopBeforeCloseMs}ms`);
278
+ this.setMultiStateLegacyAsync({[this.dpStop]: true});
279
+ this.pendingCloseTimer = setTimeout(() => {
280
+ this.pendingCloseTimer = null;
281
+ this.log.info(`[SimpleGarageDoor] ${name}: stop-before-close — firing close (dp${this.dpClose})`);
282
+ this.setMultiStateLegacyAsync({[this.dpClose]: true});
283
+ }, this.stopBeforeCloseMs);
284
+ }
285
+
286
+ // True only when the controller reports the gate is stopped (11) — the one
287
+ // state where it will accept a close without a stop first.
288
+ _isStopped() {
289
+ const raw = this.device.state ? this.device.state[this.dpState] : undefined;
290
+ const value = typeof raw === 'string' ? parseInt(raw, 10) : raw;
291
+ return value === STATE_STOPPED;
292
+ }
293
+
294
+ _cancelPendingClose() {
295
+ if (this.pendingCloseTimer) {
296
+ clearTimeout(this.pendingCloseTimer);
297
+ this.pendingCloseTimer = null;
298
+ }
234
299
  }
235
300
 
236
301
  // Partial open: fire the open action, then after partialOpenMs fire a stop
@@ -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.1",
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": {
@@ -49,7 +49,9 @@ function makeSimpleGarage(initialContext = {}) {
49
49
  instance.dpStop = '103';
50
50
  instance.dpState = '105';
51
51
  instance.partialOpenMs = 0;
52
+ instance.stopBeforeCloseMs = 1500;
52
53
  instance.partialStopTimer = null;
54
+ instance.pendingCloseTimer = null;
53
55
  instance.currentDoorState = CDS.CLOSED;
54
56
  instance.characteristicCurrentDoorState = {
55
57
  value: CDS.CLOSED,
@@ -77,7 +79,7 @@ function emitState(device, value) {
77
79
  }
78
80
 
79
81
  // ---------------------------------------------------------------------------
80
- // State DP -> door state mapping (the heart of the new behaviour)
82
+ // State DP -> door state mapping
81
83
  // ---------------------------------------------------------------------------
82
84
  describe('SimpleGarageDoorAccessory._onDeviceChange', () => {
83
85
  test('State 12 (opening/open) drives Current and Target to OPEN', () => {
@@ -165,39 +167,37 @@ describe('SimpleGarageDoorAccessory._onDeviceChange', () => {
165
167
  });
166
168
 
167
169
  // ---------------------------------------------------------------------------
168
- // setTargetDoorStatefires the matching action, no stop-first dance
170
+ // Openalways fired directly, even mid-motion
169
171
  // ---------------------------------------------------------------------------
170
- describe('SimpleGarageDoorAccessory.setTargetDoorState', () => {
171
- test('OPEN fires the open action immediately (no stop first)', () => {
172
- const { instance, device, accessory } = makeSimpleGarage();
172
+ describe('SimpleGarageDoorAccessory.setTargetDoorState — open', () => {
173
+ test('OPEN fires the open action immediately, even while the gate is closing', () => {
174
+ const { instance, device } = makeSimpleGarage();
175
+ device.state['105'] = STATE_CLOSING_OR_CLOSED;
173
176
 
174
177
  instance.setTargetDoorState(TDS.OPEN);
175
178
 
176
179
  expect(device.update).toHaveBeenCalledTimes(1);
177
180
  expect(device.update).toHaveBeenCalledWith({ '101': true });
178
- expect(accessory.context.cachedTargetDoorState).toBe(TDS.OPEN);
179
- expect(instance.characteristicTargetDoorState.value).toBe(TDS.OPEN);
180
181
  });
181
182
 
182
- test('CLOSE fires the close action immediately (no stop first)', () => {
183
- const { instance, device, accessory } = makeSimpleGarage();
184
- instance.currentDoorState = CDS.OPEN;
183
+ test('OPEN never fires a stop first', () => {
184
+ const { instance, device } = makeSimpleGarage();
185
+ device.state['105'] = STATE_OPENING_OR_OPEN;
185
186
 
186
- instance.setTargetDoorState(TDS.CLOSED);
187
+ instance.setTargetDoorState(TDS.OPEN);
187
188
 
188
- expect(device.update).toHaveBeenCalledTimes(1);
189
- expect(device.update).toHaveBeenCalledWith({ '102': true });
190
- expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
191
- expect(instance.characteristicTargetDoorState.value).toBe(TDS.CLOSED);
189
+ expect(device.update).not.toHaveBeenCalledWith({ '103': true });
190
+ expect(device.update).toHaveBeenCalledWith({ '101': true });
192
191
  });
193
192
 
194
- test('CurrentDoorState is not touched until the device reports it', () => {
195
- const { instance, device } = makeSimpleGarage();
193
+ test('Target + persistence update optimistically; Current waits for the DP', () => {
194
+ const { instance, device, accessory } = makeSimpleGarage();
196
195
  instance.currentDoorState = CDS.CLOSED;
197
196
  instance.characteristicCurrentDoorState.value = CDS.CLOSED;
198
197
 
199
198
  instance.setTargetDoorState(TDS.OPEN);
200
- // Target updated, but Current waits for the status DP.
199
+ expect(accessory.context.cachedTargetDoorState).toBe(TDS.OPEN);
200
+ expect(instance.characteristicTargetDoorState.value).toBe(TDS.OPEN);
201
201
  expect(instance.characteristicCurrentDoorState.updateValue).not.toHaveBeenCalled();
202
202
  expect(instance.currentDoorState).toBe(CDS.CLOSED);
203
203
 
@@ -207,25 +207,165 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState', () => {
207
207
  expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
208
208
  });
209
209
 
210
- test('Custom DPs are respected', () => {
210
+ test('Custom open DP is respected', () => {
211
211
  const { instance, device } = makeSimpleGarage();
212
212
  instance.dpOpen = '1';
213
- instance.dpClose = '2';
214
213
 
215
214
  instance.setTargetDoorState(TDS.OPEN);
215
+
216
216
  expect(device.update).toHaveBeenCalledWith({ '1': true });
217
+ });
218
+ });
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Close — direct only when already stopped (state 11), otherwise stop-first
222
+ // ---------------------------------------------------------------------------
223
+ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-close)', () => {
224
+ beforeEach(() => jest.useFakeTimers());
225
+ afterEach(() => jest.useRealTimers());
226
+
227
+ test('CLOSE fires immediately when the gate is already stopped (state 11)', () => {
228
+ const { instance, device, accessory } = makeSimpleGarage();
229
+ device.state['105'] = STATE_STOPPED;
230
+
231
+ instance.setTargetDoorState(TDS.CLOSED);
232
+
233
+ expect(device.update).toHaveBeenCalledTimes(1);
234
+ expect(device.update).toHaveBeenCalledWith({ '102': true });
235
+ expect(instance.pendingCloseTimer).toBeNull();
236
+ expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
237
+ expect(instance.characteristicTargetDoorState.value).toBe(TDS.CLOSED);
238
+ });
239
+
240
+ test('CLOSE while opening (state 12) fires stop, then close after stopBeforeCloseMs', () => {
241
+ const { instance, device } = makeSimpleGarage();
242
+ instance.stopBeforeCloseMs = 1500;
243
+ device.state['105'] = STATE_OPENING_OR_OPEN;
244
+
245
+ instance.setTargetDoorState(TDS.CLOSED);
246
+
247
+ // Stop goes out immediately, close is deferred.
248
+ expect(device.update).toHaveBeenCalledTimes(1);
249
+ expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
250
+
251
+ jest.advanceTimersByTime(1500 - 1);
252
+ expect(device.update).toHaveBeenCalledTimes(1);
253
+
254
+ jest.advanceTimersByTime(1);
255
+ expect(device.update).toHaveBeenCalledTimes(2);
256
+ expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
257
+ expect(instance.pendingCloseTimer).toBeNull();
258
+ });
259
+
260
+ test('CLOSE while closing (state 13) also uses stop-before-close', () => {
261
+ const { instance, device } = makeSimpleGarage();
262
+ instance.stopBeforeCloseMs = 1500;
263
+ device.state['105'] = STATE_CLOSING_OR_CLOSED;
217
264
 
218
265
  instance.setTargetDoorState(TDS.CLOSED);
219
- expect(device.update).toHaveBeenCalledWith({ '2': true });
266
+ expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
267
+
268
+ jest.advanceTimersByTime(1500);
269
+ expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
220
270
  });
221
271
 
222
- test('Stop is never fired by a normal open/close', () => {
272
+ test('CLOSE with no reported state yet uses stop-before-close', () => {
223
273
  const { instance, device } = makeSimpleGarage();
274
+ instance.stopBeforeCloseMs = 1500;
275
+ // device.state['105'] intentionally left undefined
276
+
277
+ instance.setTargetDoorState(TDS.CLOSED);
278
+ expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
224
279
 
280
+ jest.advanceTimersByTime(1500);
281
+ expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
282
+ });
283
+
284
+ test('A duplicate CLOSE during the wait does not restart or double-fire', () => {
285
+ const { instance, device } = makeSimpleGarage();
286
+ instance.stopBeforeCloseMs = 1500;
287
+ device.state['105'] = STATE_OPENING_OR_OPEN;
288
+
289
+ instance.setTargetDoorState(TDS.CLOSED);
290
+ expect(device.update).toHaveBeenCalledTimes(1); // stop
291
+
292
+ jest.advanceTimersByTime(1000);
293
+ instance.setTargetDoorState(TDS.CLOSED); // retransmit / second tap
294
+ expect(device.update).toHaveBeenCalledTimes(1); // no extra stop, timer not pushed out
295
+
296
+ jest.advanceTimersByTime(500);
297
+ expect(device.update).toHaveBeenCalledTimes(2);
298
+ expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
299
+ });
300
+
301
+ test('OPEN during the wait cancels the pending close and opens instead', () => {
302
+ const { instance, device } = makeSimpleGarage();
303
+ instance.stopBeforeCloseMs = 1500;
304
+ device.state['105'] = STATE_OPENING_OR_OPEN;
305
+
306
+ instance.setTargetDoorState(TDS.CLOSED);
307
+ expect(device.update).toHaveBeenNthCalledWith(1, { '103': true }); // stop
308
+
309
+ jest.advanceTimersByTime(500);
225
310
  instance.setTargetDoorState(TDS.OPEN);
311
+ expect(instance.pendingCloseTimer).toBeNull();
312
+ expect(device.update).toHaveBeenNthCalledWith(2, { '101': true }); // open
313
+
314
+ // The trailing close must never fire.
315
+ jest.advanceTimersByTime(5000);
316
+ expect(device.update).toHaveBeenCalledTimes(2);
317
+ });
318
+
319
+ test('Status reports are ignored during the stop-before-close window', () => {
320
+ const { instance, device, accessory } = makeSimpleGarage();
321
+ instance.stopBeforeCloseMs = 1500;
322
+ device.state['105'] = STATE_OPENING_OR_OPEN;
323
+ instance.currentDoorState = CDS.OPEN;
324
+ instance.characteristicCurrentDoorState.value = CDS.OPEN;
325
+
226
326
  instance.setTargetDoorState(TDS.CLOSED);
327
+ expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
227
328
 
228
- expect(device.update).not.toHaveBeenCalledWith({ '103': true });
329
+ // The stop makes the controller momentarily report 11 (=OPEN). That
330
+ // must be ignored, or it would bounce Target back to OPEN mid-close.
331
+ emitState(device, STATE_STOPPED);
332
+ expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
333
+ expect(instance.currentDoorState).toBe(CDS.OPEN);
334
+
335
+ // Close fires, then the controller reports 13 and we resume mirroring.
336
+ jest.advanceTimersByTime(1500);
337
+ expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
338
+ emitState(device, STATE_CLOSING_OR_CLOSED);
339
+ expect(instance.currentDoorState).toBe(CDS.CLOSED);
340
+ expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
341
+ });
342
+
343
+ test('stopBeforeCloseMs is configurable', () => {
344
+ const { instance, device } = makeSimpleGarage();
345
+ instance.stopBeforeCloseMs = 300;
346
+ device.state['105'] = STATE_OPENING_OR_OPEN;
347
+
348
+ instance.setTargetDoorState(TDS.CLOSED);
349
+ expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
350
+
351
+ jest.advanceTimersByTime(299);
352
+ expect(device.update).toHaveBeenCalledTimes(1);
353
+ jest.advanceTimersByTime(1);
354
+ expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
355
+ });
356
+
357
+ test('Custom stop/close DPs are respected in the stop-before-close path', () => {
358
+ const { instance, device } = makeSimpleGarage();
359
+ instance.dpClose = '2';
360
+ instance.dpStop = '4';
361
+ instance.stopBeforeCloseMs = 1000;
362
+ device.state['105'] = STATE_OPENING_OR_OPEN;
363
+
364
+ instance.setTargetDoorState(TDS.CLOSED);
365
+ expect(device.update).toHaveBeenNthCalledWith(1, { '4': true });
366
+
367
+ jest.advanceTimersByTime(1000);
368
+ expect(device.update).toHaveBeenNthCalledWith(2, { '2': true });
229
369
  });
230
370
  });
231
371
 
@@ -248,7 +388,9 @@ describe('SimpleGarageDoorAccessory — disconnected', () => {
248
388
  // ---------------------------------------------------------------------------
249
389
  describe('SimpleGarageDoorAccessory persistence', () => {
250
390
  test('Stores the latest target on the accessory context', () => {
251
- const { instance, accessory } = makeSimpleGarage();
391
+ const { instance, device, accessory } = makeSimpleGarage();
392
+ // Already stopped, so the close fires immediately (no dangling timer).
393
+ device.state['105'] = STATE_STOPPED;
252
394
 
253
395
  instance.setTargetDoorState(TDS.OPEN);
254
396
  expect(accessory.context.cachedTargetDoorState).toBe(TDS.OPEN);
@@ -310,22 +452,28 @@ describe('SimpleGarageDoorAccessory._handlePartialOpen', () => {
310
452
  expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
311
453
  });
312
454
 
313
- test('A direct open/close cancels the armed auto-stop', () => {
455
+ test('A direct close cancels the partial auto-stop and runs stop-before-close', () => {
314
456
  const { instance, device } = makeSimpleGarage();
315
457
  instance.partialOpenMs = 2000;
458
+ instance.stopBeforeCloseMs = 1500;
459
+ device.state['105'] = STATE_OPENING_OR_OPEN; // gate is moving during the partial
316
460
 
317
461
  instance._handlePartialOpen();
318
462
  expect(instance.partialStopTimer).not.toBeNull();
463
+ expect(device.update).toHaveBeenNthCalledWith(1, { '101': true }); // open
319
464
 
320
- // User takes manual control before the auto-stop fires.
465
+ // User closes before the partial auto-stop fires.
321
466
  jest.advanceTimersByTime(500);
322
467
  instance.setTargetDoorState(TDS.CLOSED);
323
- expect(instance.partialStopTimer).toBeNull();
324
- expect(device.update).toHaveBeenLastCalledWith({ '102': true });
468
+ expect(instance.partialStopTimer).toBeNull(); // partial auto-stop cancelled
469
+ expect(device.update).toHaveBeenNthCalledWith(2, { '103': true }); // stop-before-close stop
325
470
 
326
- // The stop must never fire now.
471
+ jest.advanceTimersByTime(1500);
472
+ expect(device.update).toHaveBeenNthCalledWith(3, { '102': true }); // close
473
+
474
+ // The cancelled partial stop never fires a stray write.
327
475
  jest.advanceTimersByTime(5000);
328
- expect(device.update).not.toHaveBeenCalledWith({ '103': true });
476
+ expect(device.update).toHaveBeenCalledTimes(3);
329
477
  });
330
478
 
331
479
  test('Does nothing when partialOpenMs is not configured', () => {
@@ -375,8 +523,9 @@ describe('SimpleGarageDoorAccessory — force switches', () => {
375
523
  expect(accessory.context.cachedTargetDoorState).toBe(TDS.OPEN);
376
524
  });
377
525
 
378
- test('Force Close fires the close action', () => {
526
+ test('Force Close fires the close action (immediately when already stopped)', () => {
379
527
  const { instance, device, accessory } = makeSimpleGarage();
528
+ device.state['105'] = STATE_STOPPED;
380
529
 
381
530
  instance.setTargetDoorState(TDS.CLOSED);
382
531
 
@@ -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
+ });
@@ -428,9 +428,12 @@ Both the current and target door state are mirrored straight from this DP, so
428
428
  HomeKit stays in sync however the gate was operated (Home app, a physical
429
429
  remote, the Tuya app, ...).
430
430
 
431
- A HomeKit toggle simply fires the matching action the controller reverses
432
- direction on its own, so no stop-first command is needed. There is no
433
- obstruction detection.
431
+ Opening is direct: the controller reverses on its own, so an open command is
432
+ fired straight away even while the gate is closing. Closing is asymmetric — the
433
+ controller ignores a close command while the gate is actively moving. So unless
434
+ the status DP already reads `11` (stopped, e.g. after a partial-open or an
435
+ external stop, where close is accepted immediately), a close is sent as
436
+ **stop → wait `stopBeforeCloseMs` → close**. There is no obstruction detection.
434
437
 
435
438
  ```json5
436
439
  {
@@ -450,7 +453,7 @@ obstruction detection.
450
453
  "dpClose": 102,
451
454
 
452
455
  /* Override the default datapoint identifier for the stop action
453
- (only used by the partial-open feature) */
456
+ (used by the partial-open feature and the stop-before-close) */
454
457
  "dpStop": 103,
455
458
 
456
459
  /* Override the default datapoint identifier for the reported state.
@@ -458,6 +461,12 @@ obstruction detection.
458
461
  (closing/closed) is treated as CLOSED. */
459
462
  "dpState": 105,
460
463
 
464
+ /* Optional. The controller ignores a close while the gate is moving,
465
+ so unless the state DP already reads 11 (stopped) the plugin sends
466
+ stop, waits this many milliseconds, then sends close. Tune to about
467
+ how long the gate takes to halt after a stop. Default 1500. */
468
+ "stopBeforeCloseMs": 1500,
469
+
461
470
  /* Optional. If set, exposes an extra stateful switch that mirrors
462
471
  whether the gate is currently open in HomeKit's view. Tapping it
463
472
  ON triggers a partial-open: the gate opens and then stops itself