homebridge-tuya-plus 3.14.0-dev.6 → 3.14.0-pr.59.7
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/workflows/publish-dev.yml +31 -306
- package/Changelog.md +2 -0
- package/Readme.MD +1 -15
- package/lib/IrrigationSystemAccessory.js +13 -10
- package/lib/TuyaCloudMessaging.js +16 -11
- package/package.json +2 -3
- package/test/IrrigationSystemAccessory.test.js +32 -0
- package/test/TuyaCloudMessaging.test.js +24 -0
- package/scripts/cleanup-pr-tags.js +0 -122
|
@@ -1,97 +1,56 @@
|
|
|
1
|
-
name: Publish to npm
|
|
1
|
+
name: Publish dev to npm
|
|
2
2
|
|
|
3
|
-
#
|
|
4
|
-
# always live on the `latest` dist-tag and are cut manually; nothing here ever
|
|
5
|
-
# touches `latest`.
|
|
3
|
+
# Publishes commits on `main` to npm under the `dev` dist-tag.
|
|
6
4
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
14
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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.
|
|
26
27
|
#
|
|
27
|
-
#
|
|
28
|
-
# channel users follow to get "latest main". Publishing unmerged PR code there
|
|
29
|
-
# would feed untested code to everyone on `@dev`. A per-PR tag keeps three clear
|
|
30
|
-
# lanes — `latest` (everyone), `dev` (main followers), `pr-<N>` (that one PR).
|
|
31
|
-
#
|
|
32
|
-
# ---------------------------------------------------------------------------
|
|
33
|
-
# Security model (why a malicious PR cannot steal publishing rights)
|
|
34
|
-
# ---------------------------------------------------------------------------
|
|
35
|
-
# * No npm token is stored anywhere. Publishing uses npm Trusted Publishing
|
|
36
|
-
# (OIDC): GitHub mints a short-lived token that npm only accepts when its
|
|
37
|
-
# claims match THIS repo + THIS workflow file (publish-dev.yml) + the
|
|
38
|
-
# `npm-dev` environment. There is no durable credential to exfiltrate.
|
|
39
|
-
# * npm allows only ONE trusted publisher per package, so BOTH paths must run
|
|
40
|
-
# from this file and use `environment: npm-dev` to authenticate. (The PR
|
|
41
|
-
# path needs no extra npm/GitHub setup beyond the existing dev config.)
|
|
42
|
-
# * The `dev` path never runs on `pull_request`; the PR path runs on
|
|
43
|
-
# `issue_comment`, which always uses the workflow file from `main` — a PR
|
|
44
|
-
# cannot alter this file to change what runs.
|
|
45
|
-
# * Untrusted PR code is fenced off from the publishing credential:
|
|
46
|
-
# - `pr-test` checks out and runs the PR's code (npm ci / lint / test)
|
|
47
|
-
# but is UNPRIVILEGED: `contents: read`, no secrets, no id-token, and
|
|
48
|
-
# `persist-credentials: false`. Same blast radius as ordinary fork CI.
|
|
49
|
-
# - `pr-publish` HOLDS the OIDC permission but runs NO PR code: no
|
|
50
|
-
# `npm ci`, no build, no tests, and `--ignore-scripts` on both
|
|
51
|
-
# `npm version` and `npm publish`, so no lifecycle script executes
|
|
52
|
-
# while the credential is present. It only packs + uploads the tarball.
|
|
53
|
-
# - The OIDC token is scoped to THIS package, so a PR that renames the
|
|
54
|
-
# package in package.json cannot hijack a different one (publish fails).
|
|
55
|
-
# - The head SHA is resolved once, up front, and pinned for every later
|
|
56
|
-
# job, so a push landed after `/publish` cannot swap in different code.
|
|
57
|
-
# * Comment/reaction jobs use a write-scoped token but check out NO PR code.
|
|
58
|
-
#
|
|
59
|
-
# ---------------------------------------------------------------------------
|
|
60
|
-
# One-time setup (already in place for the dev path; PR path reuses it):
|
|
28
|
+
# One-time setup required for this to succeed (see PR/commit notes):
|
|
61
29
|
# 1. npmjs.com -> package Settings -> Trusted Publisher:
|
|
62
30
|
# Publisher: GitHub Actions
|
|
63
31
|
# Organization/user: adrianjagielak
|
|
64
32
|
# Repository: homebridge-tuya-plus
|
|
65
33
|
# Workflow filename: publish-dev.yml
|
|
66
34
|
# Environment: npm-dev
|
|
67
|
-
# 2. GitHub repo Settings -> Environments -> `npm-dev`,
|
|
68
|
-
#
|
|
69
|
-
# `main`'s ref too, so this restriction is satisfied for both paths.
|
|
35
|
+
# 2. GitHub repo Settings -> Environments -> create `npm-dev`,
|
|
36
|
+
# restrict deployment branches to `main` (optional: required reviewer).
|
|
70
37
|
|
|
71
38
|
on:
|
|
72
39
|
push:
|
|
73
40
|
branches: [main]
|
|
74
41
|
workflow_dispatch:
|
|
75
|
-
# Maintainer types `/publish` on a PR to cut a test build of that PR.
|
|
76
|
-
issue_comment:
|
|
77
|
-
types: [created]
|
|
78
42
|
|
|
79
|
-
# Least privilege by default;
|
|
43
|
+
# Least privilege by default; only the publish job opts into id-token.
|
|
80
44
|
permissions:
|
|
81
45
|
contents: read
|
|
82
46
|
|
|
83
|
-
#
|
|
84
|
-
# dev path, `publish-pr-<N>` per PR for the PR path (so they don't block).
|
|
47
|
+
# Don't cancel an in-flight publish; let each commit publish its version.
|
|
85
48
|
concurrency:
|
|
86
|
-
group:
|
|
49
|
+
group: publish-dev
|
|
87
50
|
cancel-in-progress: false
|
|
88
51
|
|
|
89
52
|
jobs:
|
|
90
|
-
# ===========================================================================
|
|
91
|
-
# DEV PATH — push to main / manual dispatch. (Behavior unchanged.)
|
|
92
|
-
# ===========================================================================
|
|
93
53
|
test:
|
|
94
|
-
if: github.event_name != 'issue_comment'
|
|
95
54
|
runs-on: ubuntu-latest
|
|
96
55
|
steps:
|
|
97
56
|
- uses: actions/checkout@v4
|
|
@@ -106,7 +65,6 @@ jobs:
|
|
|
106
65
|
# computes the dev version. Kept out of the `publish` job so no extra work
|
|
107
66
|
# runs while the OIDC publishing permission is present.
|
|
108
67
|
prepare:
|
|
109
|
-
if: github.event_name != 'issue_comment'
|
|
110
68
|
runs-on: ubuntu-latest
|
|
111
69
|
outputs:
|
|
112
70
|
should_publish: ${{ steps.check.outputs.should_publish }}
|
|
@@ -155,8 +113,8 @@ jobs:
|
|
|
155
113
|
|
|
156
114
|
publish:
|
|
157
115
|
needs: [test, prepare]
|
|
158
|
-
#
|
|
159
|
-
if:
|
|
116
|
+
# Skip release commits (version bumped in this push).
|
|
117
|
+
if: needs.prepare.outputs.should_publish == 'true'
|
|
160
118
|
runs-on: ubuntu-latest
|
|
161
119
|
# Bind this environment in repo Settings to the `main` branch so that
|
|
162
120
|
# only main can ever reach the publish step.
|
|
@@ -186,236 +144,3 @@ jobs:
|
|
|
186
144
|
# --provenance attaches a signed build attestation (needs a public repo).
|
|
187
145
|
- name: Publish (dev tag)
|
|
188
146
|
run: npm publish --tag dev --provenance --ignore-scripts
|
|
189
|
-
|
|
190
|
-
# ===========================================================================
|
|
191
|
-
# PR PATH — maintainer comments `/publish` on a PR. (New.)
|
|
192
|
-
# ===========================================================================
|
|
193
|
-
|
|
194
|
-
# Gate + ack. Runs NO PR code. Verifies the commenter is a maintainer,
|
|
195
|
-
# resolves & pins the PR head, and reads the version base from trusted `main`.
|
|
196
|
-
authorize:
|
|
197
|
-
if: >
|
|
198
|
-
github.event_name == 'issue_comment' &&
|
|
199
|
-
github.event.issue.pull_request &&
|
|
200
|
-
startsWith(github.event.comment.body, '/publish') &&
|
|
201
|
-
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
|
|
202
|
-
runs-on: ubuntu-latest
|
|
203
|
-
permissions:
|
|
204
|
-
contents: read # checkout main to read the version base
|
|
205
|
-
issues: write # react to / comment on the triggering comment
|
|
206
|
-
pull-requests: write
|
|
207
|
-
outputs:
|
|
208
|
-
authorized: ${{ steps.auth.outputs.authorized }}
|
|
209
|
-
head_sha: ${{ steps.auth.outputs.head_sha }}
|
|
210
|
-
head_repo: ${{ steps.auth.outputs.head_repo }}
|
|
211
|
-
pr_number: ${{ steps.auth.outputs.pr_number }}
|
|
212
|
-
base_version: ${{ steps.basever.outputs.base_version }}
|
|
213
|
-
steps:
|
|
214
|
-
- name: Verify maintainer & resolve PR head
|
|
215
|
-
id: auth
|
|
216
|
-
uses: actions/github-script@v7
|
|
217
|
-
with:
|
|
218
|
-
script: |
|
|
219
|
-
// Require the command to be exactly `/publish` (optional trailing text).
|
|
220
|
-
const body = (context.payload.comment.body || '').trim();
|
|
221
|
-
if (!/^\/publish(?:\s|$)/.test(body)) {
|
|
222
|
-
core.setOutput('authorized', 'false');
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
// Authorization: the commenter must have PUSH (write) access. The
|
|
226
|
-
// REST `permission` field uses legacy roles, so `admin`/`write`
|
|
227
|
-
// covers admin/maintain/write (can push) and excludes triage/read.
|
|
228
|
-
// This is stricter and truer to intent than author_association (a
|
|
229
|
-
// COLLABORATOR could be read-only). The job `if` is only a coarse
|
|
230
|
-
// pre-filter; this API check is the authoritative gate.
|
|
231
|
-
const username = context.payload.comment.user.login;
|
|
232
|
-
let canPush = false;
|
|
233
|
-
try {
|
|
234
|
-
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
|
|
235
|
-
owner: context.repo.owner,
|
|
236
|
-
repo: context.repo.repo,
|
|
237
|
-
username,
|
|
238
|
-
});
|
|
239
|
-
canPush = ['admin', 'write'].includes(perm.permission);
|
|
240
|
-
core.info(`${username} permission: ${perm.permission} (role: ${perm.role_name || 'n/a'})`);
|
|
241
|
-
} catch (err) {
|
|
242
|
-
// If the permission API can't be read, fall back to the trusted
|
|
243
|
-
// author_association set so a maintainer is never locked out. This
|
|
244
|
-
// still keeps out drive-by PR authors (CONTRIBUTOR / NONE).
|
|
245
|
-
const assoc = context.payload.comment.author_association;
|
|
246
|
-
canPush = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
|
|
247
|
-
core.warning(`Permission API unavailable (${err.status || err.message}); fell back to association ${assoc}.`);
|
|
248
|
-
}
|
|
249
|
-
if (!canPush) {
|
|
250
|
-
core.notice(`Ignoring /publish from ${username}: no push access.`);
|
|
251
|
-
core.setOutput('authorized', 'false');
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
// Resolve the PR's CURRENT head and pin this exact SHA for every
|
|
255
|
-
// later job, so a push landed after `/publish` can't swap in code.
|
|
256
|
-
const { data: pr } = await github.rest.pulls.get({
|
|
257
|
-
owner: context.repo.owner,
|
|
258
|
-
repo: context.repo.repo,
|
|
259
|
-
pull_number: context.payload.issue.number,
|
|
260
|
-
});
|
|
261
|
-
if (!pr.head.repo) {
|
|
262
|
-
core.setFailed('PR head repository is unavailable (fork deleted?).');
|
|
263
|
-
core.setOutput('authorized', 'false');
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
core.setOutput('head_sha', pr.head.sha);
|
|
267
|
-
core.setOutput('head_repo', pr.head.repo.full_name);
|
|
268
|
-
core.setOutput('pr_number', String(pr.number));
|
|
269
|
-
core.setOutput('authorized', 'true');
|
|
270
|
-
// Acknowledge so the maintainer sees the command was accepted.
|
|
271
|
-
await github.rest.reactions.createForIssueComment({
|
|
272
|
-
owner: context.repo.owner,
|
|
273
|
-
repo: context.repo.repo,
|
|
274
|
-
comment_id: context.payload.comment.id,
|
|
275
|
-
content: 'eyes',
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
# Version base comes from TRUSTED main, never the PR's package.json, so an
|
|
279
|
-
# untrusted PR cannot influence the published version.
|
|
280
|
-
- name: Checkout main (trusted)
|
|
281
|
-
if: steps.auth.outputs.authorized == 'true'
|
|
282
|
-
uses: actions/checkout@v4
|
|
283
|
-
with:
|
|
284
|
-
persist-credentials: false
|
|
285
|
-
|
|
286
|
-
- name: Compute version base from main
|
|
287
|
-
id: basever
|
|
288
|
-
if: steps.auth.outputs.authorized == 'true'
|
|
289
|
-
run: |
|
|
290
|
-
NEXT="$(node -e "const v=require('./package.json').version.split('.'); v[1]=Number(v[1])+1; v[2]=0; console.log(v.join('.'))")"
|
|
291
|
-
echo "base_version=$NEXT" >> "$GITHUB_OUTPUT"
|
|
292
|
-
echo "Version base (from main): $NEXT"
|
|
293
|
-
|
|
294
|
-
# Unprivileged gate: runs the PR's code. No secrets, read-only token, no
|
|
295
|
-
# id-token, no persisted git credentials — same trust level as fork CI.
|
|
296
|
-
pr-test:
|
|
297
|
-
needs: authorize
|
|
298
|
-
if: needs.authorize.outputs.authorized == 'true'
|
|
299
|
-
runs-on: ubuntu-latest
|
|
300
|
-
permissions:
|
|
301
|
-
contents: read
|
|
302
|
-
steps:
|
|
303
|
-
- uses: actions/checkout@v4
|
|
304
|
-
with:
|
|
305
|
-
repository: ${{ needs.authorize.outputs.head_repo }}
|
|
306
|
-
ref: ${{ needs.authorize.outputs.head_sha }}
|
|
307
|
-
persist-credentials: false
|
|
308
|
-
- uses: actions/setup-node@v4
|
|
309
|
-
with:
|
|
310
|
-
node-version: "22"
|
|
311
|
-
- run: npm ci
|
|
312
|
-
- run: npm run lint
|
|
313
|
-
- run: npm run test
|
|
314
|
-
|
|
315
|
-
# Privileged publish. Holds the OIDC permission but runs NO PR code: no
|
|
316
|
-
# npm ci, no build, no tests; --ignore-scripts on version + publish.
|
|
317
|
-
pr-publish:
|
|
318
|
-
needs: [authorize, pr-test]
|
|
319
|
-
if: needs.authorize.outputs.authorized == 'true'
|
|
320
|
-
runs-on: ubuntu-latest
|
|
321
|
-
# Must match the trusted publisher's environment (npm allows one publisher
|
|
322
|
-
# per package). issue_comment runs on main's ref, so a main-only branch
|
|
323
|
-
# restriction on this environment is satisfied.
|
|
324
|
-
environment: npm-dev
|
|
325
|
-
permissions:
|
|
326
|
-
contents: read
|
|
327
|
-
id-token: write # OIDC -> npm Trusted Publishing (no stored token)
|
|
328
|
-
outputs:
|
|
329
|
-
version: ${{ steps.ver.outputs.version }}
|
|
330
|
-
tag: ${{ steps.ver.outputs.tag }}
|
|
331
|
-
steps:
|
|
332
|
-
# The PR's code — but we never execute it (see --ignore-scripts below).
|
|
333
|
-
- uses: actions/checkout@v4
|
|
334
|
-
with:
|
|
335
|
-
repository: ${{ needs.authorize.outputs.head_repo }}
|
|
336
|
-
ref: ${{ needs.authorize.outputs.head_sha }}
|
|
337
|
-
persist-credentials: false
|
|
338
|
-
|
|
339
|
-
- uses: actions/setup-node@v4
|
|
340
|
-
with:
|
|
341
|
-
node-version: "22"
|
|
342
|
-
registry-url: "https://registry.npmjs.org"
|
|
343
|
-
|
|
344
|
-
# Trusted Publishing (OIDC) requires npm >= 11.5.1.
|
|
345
|
-
- name: Ensure OIDC-capable npm
|
|
346
|
-
run: npm install -g npm@latest
|
|
347
|
-
|
|
348
|
-
# Unique, immutable version + a PR-specific dist-tag. Base comes from main
|
|
349
|
-
# (via authorize), so the PR's package.json cannot influence versioning.
|
|
350
|
-
- name: Compute PR build version + tag
|
|
351
|
-
id: ver
|
|
352
|
-
env:
|
|
353
|
-
BASE: ${{ needs.authorize.outputs.base_version }}
|
|
354
|
-
PR: ${{ needs.authorize.outputs.pr_number }}
|
|
355
|
-
run: |
|
|
356
|
-
VERSION="${BASE}-pr.${PR}.${GITHUB_RUN_NUMBER}"
|
|
357
|
-
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
358
|
-
echo "tag=pr-${PR}" >> "$GITHUB_OUTPUT"
|
|
359
|
-
echo "Will publish $VERSION under dist-tag pr-${PR}"
|
|
360
|
-
|
|
361
|
-
# Writes package.json on the runner only; --ignore-scripts so no PR
|
|
362
|
-
# version/preversion/postversion hook runs with the credential present.
|
|
363
|
-
- name: Set version
|
|
364
|
-
run: npm version "${{ steps.ver.outputs.version }}" --no-git-tag-version --allow-same-version --ignore-scripts
|
|
365
|
-
|
|
366
|
-
# Published under pr-<N>, NEVER latest/dev. No NODE_AUTH_TOKEN: npm
|
|
367
|
-
# exchanges the OIDC id-token for a short-lived, package-scoped credential.
|
|
368
|
-
# --ignore-scripts ensures no PR lifecycle script runs with that credential.
|
|
369
|
-
- name: Publish (pr-<N> tag)
|
|
370
|
-
run: npm publish --tag "${{ steps.ver.outputs.tag }}" --provenance --ignore-scripts
|
|
371
|
-
|
|
372
|
-
# Report the outcome back on the PR. Runs NO PR code.
|
|
373
|
-
notify:
|
|
374
|
-
needs: [authorize, pr-publish]
|
|
375
|
-
if: always() && needs.authorize.outputs.authorized == 'true'
|
|
376
|
-
runs-on: ubuntu-latest
|
|
377
|
-
permissions:
|
|
378
|
-
issues: write
|
|
379
|
-
pull-requests: write
|
|
380
|
-
steps:
|
|
381
|
-
- uses: actions/github-script@v7
|
|
382
|
-
env:
|
|
383
|
-
PUBLISH_RESULT: ${{ needs.pr-publish.result }}
|
|
384
|
-
PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
|
|
385
|
-
HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
|
|
386
|
-
VERSION: ${{ needs.pr-publish.outputs.version }}
|
|
387
|
-
TAG: ${{ needs.pr-publish.outputs.tag }}
|
|
388
|
-
with:
|
|
389
|
-
script: |
|
|
390
|
-
const { PUBLISH_RESULT, PR_NUMBER, HEAD_SHA, VERSION, TAG } = process.env;
|
|
391
|
-
const issue_number = Number(PR_NUMBER);
|
|
392
|
-
const comment_id = context.payload.comment.id;
|
|
393
|
-
const { owner, repo } = context.repo;
|
|
394
|
-
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
|
|
395
|
-
if (PUBLISH_RESULT === 'success') {
|
|
396
|
-
const sha = (HEAD_SHA || '').slice(0, 7);
|
|
397
|
-
const body = [
|
|
398
|
-
`🚀 Published this PR to npm for testing — built from \`${sha}\`.`,
|
|
399
|
-
``,
|
|
400
|
-
`**Install this exact build:**`,
|
|
401
|
-
'```sh',
|
|
402
|
-
`npm install -g homebridge-tuya-plus@${VERSION}`,
|
|
403
|
-
'```',
|
|
404
|
-
`**…or follow this PR's tag** (always points at this PR's newest \`/publish\` build):`,
|
|
405
|
-
'```sh',
|
|
406
|
-
`npm install -g homebridge-tuya-plus@${TAG}`,
|
|
407
|
-
'```',
|
|
408
|
-
``,
|
|
409
|
-
`In the Homebridge UI you can also paste \`${VERSION}\` into the plugin's “Install Alternate Version” field.`,
|
|
410
|
-
``,
|
|
411
|
-
`> ℹ️ Prerelease for testing only — **not** tagged \`latest\` or \`dev\`, so it won't reach normal installs.`,
|
|
412
|
-
].join('\n');
|
|
413
|
-
await github.rest.issues.createComment({ owner, repo, issue_number, body });
|
|
414
|
-
await github.rest.reactions.createForIssueComment({ owner, repo, comment_id, content: 'rocket' });
|
|
415
|
-
} else {
|
|
416
|
-
await github.rest.issues.createComment({
|
|
417
|
-
owner, repo, issue_number,
|
|
418
|
-
body: `❌ \`/publish\` did not complete — the build, tests, or publish step failed. See the [run logs](${runUrl}).`,
|
|
419
|
-
});
|
|
420
|
-
await github.rest.reactions.createForIssueComment({ owner, repo, comment_id, content: 'confused' });
|
|
421
|
-
}
|
package/Changelog.md
CHANGED
|
@@ -9,6 +9,8 @@ All notable changes to this project will be documented in this file. This projec
|
|
|
9
9
|
* Works with both **Custom** and **Smart Home** Cloud projects (the latter via app-account login).
|
|
10
10
|
* The existing **IrrigationSystem** accessory works unchanged over the cloud — its data-points are simply addressed by Tuya "code" (e.g. `switch_1`, `battery_percentage`) instead of a numeric id; the device logs its codes on startup. Battery-only controllers with no rain sensor: set `"noRainSensor": true`.
|
|
11
11
|
* See the wiki: **[Tuya Cloud Setup](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Tuya-Cloud-Setup.md)**.
|
|
12
|
+
* [*] **Fix realtime (MQTT) cloud updates being silently dropped** — external changes (physical buttons, the Tuya app, the device's own timers) now show up in HomeKit within a second or two. The decryptor was verifying the AES-GCM auth tag (`decipher.final()`), but Tuya's real status frames don't carry a tag that verifies against the documented AAD, so every realtime message was being thrown away. Now decrypts with `update()` only, matching the official `tuya/tuya-homebridge` and `0x5e/homebridge-tuya-platform` implementations.
|
|
13
|
+
* [*] **Fix cloud irrigation valves that could be turned on but not off** — the per-zone write coalescer was dropping any command that matched the last-known `device.state`. Cloud devices never optimistically advance `state` (it only moves once the realtime stream confirms the device), so an "off" issued before the "on" was echoed matched the stale "off" and was discarded — HomeKit showed the zone closed while it kept running. Queued commands are now sent as-is (callers already queue only genuine changes).
|
|
12
14
|
|
|
13
15
|
## 2.0.1 (2021-03-25)
|
|
14
16
|
This update includes the following changes:
|
package/Readme.MD
CHANGED
|
@@ -99,23 +99,9 @@ won't affect normal installs. To try the latest in-development build:
|
|
|
99
99
|
sudo npm install -g homebridge-tuya-plus@dev
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
These are versioned like `3.
|
|
102
|
+
These are versioned like `3.13.1-dev.<n>` and may be unstable; use the default
|
|
103
103
|
install above for production.
|
|
104
104
|
|
|
105
|
-
#### Testing a specific pull request:
|
|
106
|
-
|
|
107
|
-
Maintainers can publish an unmerged PR as a throwaway test build by commenting
|
|
108
|
-
`/publish` on it. The build goes to its own `pr-<number>` tag — never `latest`
|
|
109
|
-
or `dev` — so it never reaches normal installs. The bot replies on the PR with
|
|
110
|
-
the exact install command, e.g.:
|
|
111
|
-
|
|
112
|
-
```
|
|
113
|
-
sudo npm install -g homebridge-tuya-plus@pr-57
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
Each build also gets a unique, immutable version like `3.14.0-pr.57.<n>` that you
|
|
117
|
-
can pin or paste into the Homebridge UI's "Install Alternate Version" field.
|
|
118
|
-
|
|
119
105
|
## Configuration
|
|
120
106
|
> UI
|
|
121
107
|
|
|
@@ -614,16 +614,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
614
614
|
return;
|
|
615
615
|
}
|
|
616
616
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
617
|
+
// Send exactly what was queued. We deliberately do NOT drop data-points
|
|
618
|
+
// that appear to already match `device.state`: cloud devices never
|
|
619
|
+
// optimistically advance `state` (it only moves when the realtime/refresh
|
|
620
|
+
// stream confirms the device), so comparing against it would silently
|
|
621
|
+
// swallow a genuine command. The most visible symptom was a valve that
|
|
622
|
+
// could be turned on but never off — the "off" matched the stale "off"
|
|
623
|
+
// still in `state` (the "on" hadn't been echoed back yet) and was
|
|
624
|
+
// discarded, so HomeKit showed the zone closed while it kept running.
|
|
625
|
+
// Callers (_setValveActive, _setSystemActive, the auto-shutoff timers)
|
|
626
|
+
// already queue only real changes, judged against the HomeKit state the
|
|
627
|
+
// user sees, so there is nothing left to de-dupe here.
|
|
628
|
+
const dps = pending.dps;
|
|
629
|
+
if (Object.keys(dps).length) this.device.update(dps);
|
|
627
630
|
}
|
|
628
631
|
|
|
629
632
|
_valveService(cfg) {
|
|
@@ -145,12 +145,12 @@ class TuyaCloudMessaging extends EventEmitter {
|
|
|
145
145
|
envelope = JSON.parse(payload.toString());
|
|
146
146
|
} catch (_) { return; }
|
|
147
147
|
|
|
148
|
-
const {protocol, data
|
|
148
|
+
const {protocol, data} = envelope;
|
|
149
149
|
if (!data) return;
|
|
150
150
|
|
|
151
151
|
let plaintext;
|
|
152
152
|
try {
|
|
153
|
-
plaintext = this._decrypt(data, this.config && this.config.password
|
|
153
|
+
plaintext = this._decrypt(data, this.config && this.config.password);
|
|
154
154
|
} catch (ex) {
|
|
155
155
|
this.log.debug(`Tuya Cloud realtime decrypt failed: ${ex.message}`);
|
|
156
156
|
return;
|
|
@@ -179,26 +179,31 @@ class TuyaCloudMessaging extends EventEmitter {
|
|
|
179
179
|
// Decrypt the MQTT `data` field. msg_encrypted_version 2.0 → AES-128-GCM,
|
|
180
180
|
// 1.0 → AES-128-ECB. The AES key is the middle 16 chars of the broker
|
|
181
181
|
// password (NOT the project Access Secret).
|
|
182
|
-
|
|
182
|
+
//
|
|
183
|
+
// The GCM auth tag is intentionally NOT verified: Tuya's real status frames
|
|
184
|
+
// do not carry a tag that verifies against the documented AAD, so calling
|
|
185
|
+
// `decipher.final()` would throw on every genuine message and silently drop
|
|
186
|
+
// all realtime updates. AES-GCM is a stream cipher, so `update()` alone
|
|
187
|
+
// recovers the plaintext correctly regardless of the tag; a wrong key just
|
|
188
|
+
// yields garbage that the subsequent JSON.parse rejects. Both the official
|
|
189
|
+
// `tuya/tuya-homebridge` and `0x5e/homebridge-tuya-platform` decrypt with
|
|
190
|
+
// `update()` only, for exactly this reason — `final()` here was the bug that
|
|
191
|
+
// stopped external changes (physical buttons, the Tuya app) from showing up.
|
|
192
|
+
_decrypt(data, password) {
|
|
183
193
|
const key = Buffer.from(('' + (password || '')).substring(8, 24), 'utf8');
|
|
184
194
|
const buf = Buffer.from(data, 'base64');
|
|
185
195
|
|
|
186
196
|
// GCM (v2.0) layout: [ivLen(4) BE][iv(ivLen)][ciphertext][tag(16)].
|
|
187
|
-
// Try it first; if the header doesn't look like a sane IV length,
|
|
188
|
-
//
|
|
197
|
+
// Try it first; if the header doesn't look like a sane IV length, fall
|
|
198
|
+
// back to ECB (v1.0).
|
|
189
199
|
if (buf.length > 4 + GCM_TAG_LENGTH) {
|
|
190
200
|
const ivLen = buf.readUIntBE(0, 4);
|
|
191
201
|
if (ivLen >= 12 && ivLen <= 16 && (4 + ivLen + GCM_TAG_LENGTH) <= buf.length) {
|
|
192
202
|
try {
|
|
193
203
|
const iv = buf.slice(4, 4 + ivLen);
|
|
194
204
|
const ciphertext = buf.slice(4 + ivLen, buf.length - GCM_TAG_LENGTH);
|
|
195
|
-
const tag = buf.slice(buf.length - GCM_TAG_LENGTH);
|
|
196
205
|
const decipher = crypto.createDecipheriv('aes-128-gcm', key, iv);
|
|
197
|
-
decipher.
|
|
198
|
-
const aad = Buffer.allocUnsafe(6);
|
|
199
|
-
aad.writeUIntBE(Number(t) || 0, 0, 6);
|
|
200
|
-
decipher.setAAD(aad);
|
|
201
|
-
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
206
|
+
return decipher.update(ciphertext).toString('utf8');
|
|
202
207
|
} catch (gcmEx) {
|
|
203
208
|
// fall through to ECB
|
|
204
209
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-tuya-plus",
|
|
3
|
-
"version": "3.14.0-
|
|
3
|
+
"version": "3.14.0-pr.59.7",
|
|
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": {
|
|
7
7
|
"test": "jest",
|
|
8
|
-
"lint": "eslint index.js lib/**.js bin/**.js"
|
|
9
|
-
"cleanup:pr-tags": "node scripts/cleanup-pr-tags.js"
|
|
8
|
+
"lint": "eslint index.js lib/**.js bin/**.js"
|
|
10
9
|
},
|
|
11
10
|
"bin": {
|
|
12
11
|
"tuya-lan": "./bin/cli.js",
|
|
@@ -492,4 +492,36 @@ describe('IrrigationSystemAccessory — cloud mode (data-points keyed by code)',
|
|
|
492
492
|
jest.advanceTimersByTime(500);
|
|
493
493
|
expect(device.update).toHaveBeenCalledWith({ zone_b: true });
|
|
494
494
|
});
|
|
495
|
+
|
|
496
|
+
test('turning a zone off still writes false when the cloud never echoed the "on"', () => {
|
|
497
|
+
// The real cloud device never optimistically advances `state`; it only
|
|
498
|
+
// moves when the realtime stream confirms the device. Emulate that by
|
|
499
|
+
// making update() a no-op on state. A follow-up "off" must STILL be sent
|
|
500
|
+
// — otherwise the valve stays open while HomeKit shows it closed (the
|
|
501
|
+
// exact "can turn on but not off" report).
|
|
502
|
+
const { accessory, device } = makeHarness(cloudState(), { ...cloudCtx, defaultDuration: 0 });
|
|
503
|
+
device.update.mockImplementation(() => true); // writes never touch state
|
|
504
|
+
const v = valve(accessory, 'switch_1');
|
|
505
|
+
|
|
506
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
507
|
+
jest.advanceTimersByTime(500);
|
|
508
|
+
expect(device.update).toHaveBeenLastCalledWith({ switch_1: true });
|
|
509
|
+
|
|
510
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(0);
|
|
511
|
+
jest.advanceTimersByTime(500);
|
|
512
|
+
expect(device.update).toHaveBeenLastCalledWith({ switch_1: false });
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test('master OFF closes zones the cloud has not echoed as open', () => {
|
|
516
|
+
const { accessory, device } = makeHarness(cloudState(), { ...cloudCtx, defaultDuration: 0 });
|
|
517
|
+
device.update.mockImplementation(() => true); // writes never touch state
|
|
518
|
+
|
|
519
|
+
valve(accessory, 'switch_1').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
520
|
+
valve(accessory, 'switch_2').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
521
|
+
jest.advanceTimersByTime(500);
|
|
522
|
+
|
|
523
|
+
irrigation(accessory).getCharacteristic(Characteristic.Active).triggerSet(0);
|
|
524
|
+
jest.advanceTimersByTime(500);
|
|
525
|
+
expect(device.update).toHaveBeenLastCalledWith({ switch_1: false, switch_2: false });
|
|
526
|
+
});
|
|
495
527
|
});
|
|
@@ -56,6 +56,30 @@ describe('TuyaCloudMessaging — decryption + dispatch', () => {
|
|
|
56
56
|
expect(received[0]).toEqual([{code: 'switch_1', value: true, t}, {code: 'battery_percentage', value: 80, t}]);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
test('decrypts a GCM frame even when the auth tag does not verify (Tuya quirk)', () => {
|
|
60
|
+
// Real Tuya status frames carry a GCM tag that does NOT verify against
|
|
61
|
+
// the documented AAD. We must decrypt with update() only (no final(),
|
|
62
|
+
// no tag check) or every realtime update is silently dropped — which is
|
|
63
|
+
// exactly what stopped external changes from reaching HomeKit. Simulate
|
|
64
|
+
// it by clobbering the trailing 16-byte tag: the plaintext must still
|
|
65
|
+
// come through.
|
|
66
|
+
const mq = makeIdle();
|
|
67
|
+
const received = [];
|
|
68
|
+
mq.subscribeDevice('DEV1', status => received.push(status));
|
|
69
|
+
|
|
70
|
+
const t = Date.now();
|
|
71
|
+
const raw = Buffer.from(
|
|
72
|
+
encryptGCM(JSON.stringify({devId: 'DEV1', status: [{code: 'switch_1', value: true}]}), mq.config.password, t),
|
|
73
|
+
'base64'
|
|
74
|
+
);
|
|
75
|
+
crypto.randomBytes(16).copy(raw, raw.length - 16); // corrupt the auth tag
|
|
76
|
+
|
|
77
|
+
mq._onMessage('topic', Buffer.from(JSON.stringify({protocol: 4, data: raw.toString('base64'), t})));
|
|
78
|
+
|
|
79
|
+
expect(received).toHaveLength(1);
|
|
80
|
+
expect(received[0]).toEqual([{code: 'switch_1', value: true}]);
|
|
81
|
+
});
|
|
82
|
+
|
|
59
83
|
test('decrypts a legacy ECB (v1.0) frame too', () => {
|
|
60
84
|
const mq = makeIdle();
|
|
61
85
|
const received = [];
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Prune stale `pr-<N>` npm dist-tags created by the `/publish` PR-test-build
|
|
6
|
-
* workflow (.github/workflows/publish-dev.yml).
|
|
7
|
-
*
|
|
8
|
-
* Token-free by design: it shells out to `npm dist-tag`, which uses your
|
|
9
|
-
* existing local npm login — the same one you use to publish releases. No CI
|
|
10
|
-
* secret is involved. (npm's OIDC trusted publishing only covers `npm publish`,
|
|
11
|
-
* not dist-tag management, which is why this is a local maintainer script
|
|
12
|
-
* rather than a CI job.)
|
|
13
|
-
*
|
|
14
|
-
* Default: removes the `pr-<N>` tag for every PR that is MERGED or CLOSED, and
|
|
15
|
-
* keeps tags for PRs that are still open. PR state is read from the public
|
|
16
|
-
* GitHub API (unauthenticated).
|
|
17
|
-
*
|
|
18
|
-
* Usage:
|
|
19
|
-
* npm run cleanup:pr-tags # remove tags for merged/closed PRs
|
|
20
|
-
* npm run cleanup:pr-tags -- --dry-run # preview only, change nothing
|
|
21
|
-
* npm run cleanup:pr-tags -- --all # remove ALL pr-* tags (incl. open)
|
|
22
|
-
*
|
|
23
|
-
* Note: this removes the dist-tag pointer only. The underlying prerelease
|
|
24
|
-
* versions stay published (npm disallows unpublishing after 72h), but they sort
|
|
25
|
-
* below `latest`/`dev` and are never installed by default.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
const { execFileSync } = require('child_process');
|
|
29
|
-
const path = require('path');
|
|
30
|
-
|
|
31
|
-
const pkgJson = require(path.join(__dirname, '..', 'package.json'));
|
|
32
|
-
const PKG = pkgJson.name;
|
|
33
|
-
const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
34
|
-
|
|
35
|
-
const args = process.argv.slice(2);
|
|
36
|
-
const DRY_RUN = args.includes('--dry-run') || args.includes('-n');
|
|
37
|
-
const ALL = args.includes('--all');
|
|
38
|
-
|
|
39
|
-
// Derive "owner/repo" from package.json's repository URL.
|
|
40
|
-
function repoSlug() {
|
|
41
|
-
const url = (pkgJson.repository && pkgJson.repository.url) || '';
|
|
42
|
-
const m = url.match(/github\.com[/:]([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
43
|
-
if (!m) throw new Error(`Cannot parse a GitHub repo from repository.url: "${url}"`);
|
|
44
|
-
return `${m[1]}/${m[2]}`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Parse `npm dist-tag ls <pkg>` output ("pr-57: 3.14.0-pr.57.3") into pr-* tags.
|
|
48
|
-
function listPrTags() {
|
|
49
|
-
let out;
|
|
50
|
-
try {
|
|
51
|
-
out = execFileSync(NPM, ['dist-tag', 'ls', PKG], { encoding: 'utf8' });
|
|
52
|
-
} catch (err) {
|
|
53
|
-
throw new Error(`Failed to list dist-tags for ${PKG}: ${err.message}`);
|
|
54
|
-
}
|
|
55
|
-
const tags = [];
|
|
56
|
-
for (const line of out.split('\n')) {
|
|
57
|
-
const m = line.match(/^(pr-(\d+)):\s*(.+)$/);
|
|
58
|
-
if (m) tags.push({ tag: m[1], pr: Number(m[2]), version: m[3].trim() });
|
|
59
|
-
}
|
|
60
|
-
return tags;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function prState(slug, num) {
|
|
64
|
-
const res = await fetch(`https://api.github.com/repos/${slug}/pulls/${num}`, {
|
|
65
|
-
headers: { Accept: 'application/vnd.github+json', 'User-Agent': `${PKG}-cleanup` },
|
|
66
|
-
});
|
|
67
|
-
if (res.status === 404) return 'unknown';
|
|
68
|
-
if (!res.ok) throw new Error(`GitHub API responded ${res.status}`);
|
|
69
|
-
const data = await res.json();
|
|
70
|
-
return data.merged ? 'merged' : data.state; // 'merged' | 'open' | 'closed'
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function main() {
|
|
74
|
-
const slug = repoSlug();
|
|
75
|
-
const tags = listPrTags();
|
|
76
|
-
if (tags.length === 0) {
|
|
77
|
-
console.log(`No pr-* dist-tags on ${PKG}. Nothing to do.`);
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
console.log(`Found ${tags.length} pr-* tag(s) on ${PKG}${DRY_RUN ? ' (dry run)' : ''}:`);
|
|
81
|
-
|
|
82
|
-
let removed = 0;
|
|
83
|
-
for (const { tag, pr, version } of tags) {
|
|
84
|
-
let remove = ALL;
|
|
85
|
-
let reason = 'forced by --all';
|
|
86
|
-
if (!ALL) {
|
|
87
|
-
let state;
|
|
88
|
-
try {
|
|
89
|
-
state = await prState(slug, pr);
|
|
90
|
-
} catch (err) {
|
|
91
|
-
console.log(` • ${tag} -> ${version}: skip (could not check PR #${pr}: ${err.message})`);
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
remove = state === 'merged' || state === 'closed';
|
|
95
|
-
reason = `PR #${pr} is ${state}`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (!remove) {
|
|
99
|
-
console.log(` • ${tag} -> ${version}: keep (${reason})`);
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
if (DRY_RUN) {
|
|
103
|
-
console.log(` • ${tag} -> ${version}: would remove (${reason})`);
|
|
104
|
-
removed++;
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
try {
|
|
108
|
-
execFileSync(NPM, ['dist-tag', 'rm', PKG, tag], { encoding: 'utf8' });
|
|
109
|
-
console.log(` • ${tag} -> ${version}: removed (${reason})`);
|
|
110
|
-
removed++;
|
|
111
|
-
} catch (err) {
|
|
112
|
-
console.log(` • ${tag}: FAILED to remove (${err.message}). Are you \`npm login\`'d?`);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
console.log(DRY_RUN ? `Would remove ${removed} tag(s).` : `Removed ${removed} tag(s).`);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
main().catch((err) => {
|
|
120
|
-
console.error(err.message);
|
|
121
|
-
process.exit(1);
|
|
122
|
-
});
|