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.
- package/.github/CODEOWNERS +5 -0
- package/.github/workflows/publish-dev.yml +146 -0
- package/Readme.MD +13 -0
- package/config.schema.json +8 -0
- package/lib/SimpleGarageDoorAccessory.js +73 -8
- package/lib/TuyaAccessory.js +39 -1
- package/package.json +1 -1
- package/test/SimpleGarageDoorAccessory.test.js +180 -31
- package/test/TuyaAccessory.protocol.test.js +98 -0
- package/wiki/Supported-Device-Types.md +13 -4
|
@@ -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/config.schema.json
CHANGED
|
@@ -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
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
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": {
|
|
@@ -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
|
|
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
|
-
//
|
|
170
|
+
// Open — always fired directly, even mid-motion
|
|
169
171
|
// ---------------------------------------------------------------------------
|
|
170
|
-
describe('SimpleGarageDoorAccessory.setTargetDoorState', () => {
|
|
171
|
-
test('OPEN fires the open action immediately
|
|
172
|
-
const { instance, device
|
|
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('
|
|
183
|
-
const { instance, device
|
|
184
|
-
|
|
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.
|
|
187
|
+
instance.setTargetDoorState(TDS.OPEN);
|
|
187
188
|
|
|
188
|
-
expect(device.update).
|
|
189
|
-
expect(device.update).toHaveBeenCalledWith({ '
|
|
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('
|
|
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
|
-
|
|
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
|
|
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).
|
|
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('
|
|
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
|
-
|
|
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
|
|
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
|
|
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).
|
|
468
|
+
expect(instance.partialStopTimer).toBeNull(); // partial auto-stop cancelled
|
|
469
|
+
expect(device.update).toHaveBeenNthCalledWith(2, { '103': true }); // stop-before-close stop
|
|
325
470
|
|
|
326
|
-
|
|
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).
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
(
|
|
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
|