homebridge-tuya-plus 3.14.0-dev.4 → 3.14.0-dev.40
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 +298 -31
- package/AGENTS.md +162 -0
- package/CLAUDE.md +1 -0
- package/Changelog.md +16 -3
- package/Readme.MD +8 -32
- package/config.schema.json +63 -78
- package/index.js +599 -358
- package/lib/AirPurifierAccessory.js +1 -1
- package/lib/BaseAccessory.js +54 -17
- package/lib/ConvectorAccessory.js +1 -1
- package/lib/CustomMultiOutletAccessory.js +2 -1
- package/lib/DoorbellAccessory.js +9 -16
- package/lib/GarageDoorAccessory.js +1 -1
- package/lib/IrrigationSystemAccessory.js +93 -114
- package/lib/MultiOutletAccessory.js +3 -2
- package/lib/OilDiffuserAccessory.js +10 -8
- package/lib/RGBTWLightAccessory.js +13 -11
- package/lib/RGBTWOutletAccessory.js +11 -9
- package/lib/SimpleBlindsAccessory.js +5 -5
- package/lib/SimpleDimmer2Accessory.js +1 -1
- package/lib/SimpleDimmerAccessory.js +10 -6
- package/lib/SimpleGarageDoorAccessory.js +78 -24
- package/lib/SimpleHeaterAccessory.js +4 -4
- package/lib/SimpleLightAccessory.js +1 -1
- package/lib/SwitchAccessory.js +3 -2
- package/lib/TWLightAccessory.js +2 -2
- package/lib/TuyaAccessory.js +75 -42
- package/lib/TuyaCloudApi.js +90 -4
- package/lib/TuyaCloudDevice.js +204 -25
- package/lib/TuyaCloudMessaging.js +28 -41
- package/lib/TuyaDevice.js +269 -0
- package/lib/TuyaDiscovery.js +25 -9
- package/lib/ValveAccessory.js +16 -12
- package/lib/VerticalBlindsWithTilt.js +27 -25
- package/lib/WledDimmerAccessory.js +46 -81
- package/package.json +5 -6
- package/scripts/cleanup-pr-tags.js +122 -0
- package/test/BaseAccessory.test.js +94 -15
- package/test/IrrigationSystemAccessory.test.js +122 -68
- package/test/MultiOutletAccessory.test.js +18 -3
- package/test/RGBTWLightAccessory.test.js +3 -3
- package/test/SimpleFanLightAccessory.test.js +3 -3
- package/test/SimpleGarageDoorAccessory.test.js +63 -9
- package/test/TuyaAccessory.protocol.test.js +76 -3
- package/test/TuyaCloudApi.test.js +80 -0
- package/test/TuyaCloudDevice.test.js +253 -1
- package/test/TuyaCloudMessaging.test.js +24 -5
- package/test/TuyaDevice.test.js +266 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +271 -0
- package/test/support/mocks.js +13 -0
- package/wiki/Supported-Device-Types.md +48 -30
- package/wiki/Tuya-Cloud-Setup.md +24 -113
|
@@ -1,56 +1,97 @@
|
|
|
1
|
-
name: Publish dev
|
|
1
|
+
name: Publish to npm (dev + PR test builds)
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# This workflow publishes two kinds of UNSTABLE builds to npm. Stable releases
|
|
4
|
+
# always live on the `latest` dist-tag and are cut manually; nothing here ever
|
|
5
|
+
# touches `latest`.
|
|
4
6
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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.
|
|
7
|
+
# 1. Dev builds (automatic) — every push to `main`:
|
|
8
|
+
# * Published under the `dev` dist-tag.
|
|
9
|
+
# * Versioned as <next-minor>.0-dev.<run-number> (e.g. 3.14.0-dev.42).
|
|
10
|
+
# package.json at 3.13.0 -> dev builds target 3.14.0. In semver these
|
|
11
|
+
# sort below the eventual 3.14.0 release, so `latest` always wins.
|
|
12
|
+
# * Release commits are skipped: if a push changes package.json's version
|
|
13
|
+
# (you bumped it to cut a real release), no dev build is published.
|
|
14
14
|
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
# the version is computed in an unprivileged `prepare` job.
|
|
15
|
+
# 2. PR test builds (manual) — a maintainer comments `/publish` on a PR:
|
|
16
|
+
# * Published under a PR-specific `pr-<N>` dist-tag (e.g. pr-57), with a
|
|
17
|
+
# unique version <next-minor>.0-pr.<N>.<run-number> (e.g. 3.14.0-pr.57.43).
|
|
18
|
+
# * Lets you install and try an unmerged PR on real hardware without
|
|
19
|
+
# disturbing `latest` OR `dev`: npm i -g homebridge-tuya-plus@pr-57
|
|
20
|
+
# * Cleanup is a token-free local step: run `npm run cleanup:pr-tags` to
|
|
21
|
+
# delete the `pr-<N>` tags of merged/closed PRs (uses your npm login).
|
|
22
|
+
# OIDC can't manage dist-tags, so this is intentionally not a CI job.
|
|
23
|
+
# * Triggered ONLY by someone with push (write) access to the repo — the
|
|
24
|
+
# owner and any write/maintain/admin collaborator. A drive-by `/publish`
|
|
25
|
+
# from an outside PR author (no push access) does nothing.
|
|
27
26
|
#
|
|
28
|
-
#
|
|
27
|
+
# Why a separate `pr-<N>` tag instead of reusing `dev`: `dev` is a moving
|
|
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):
|
|
29
61
|
# 1. npmjs.com -> package Settings -> Trusted Publisher:
|
|
30
62
|
# Publisher: GitHub Actions
|
|
31
63
|
# Organization/user: adrianjagielak
|
|
32
64
|
# Repository: homebridge-tuya-plus
|
|
33
65
|
# Workflow filename: publish-dev.yml
|
|
34
66
|
# Environment: npm-dev
|
|
35
|
-
# 2. GitHub repo Settings -> Environments ->
|
|
36
|
-
#
|
|
67
|
+
# 2. GitHub repo Settings -> Environments -> `npm-dev`, deployment branches
|
|
68
|
+
# restricted to `main` (optional: required reviewer). The PR path runs on
|
|
69
|
+
# `main`'s ref too, so this restriction is satisfied for both paths.
|
|
37
70
|
|
|
38
71
|
on:
|
|
39
72
|
push:
|
|
40
73
|
branches: [main]
|
|
41
74
|
workflow_dispatch:
|
|
75
|
+
# Maintainer types `/publish` on a PR to cut a test build of that PR.
|
|
76
|
+
issue_comment:
|
|
77
|
+
types: [created]
|
|
42
78
|
|
|
43
|
-
# Least privilege by default;
|
|
79
|
+
# Least privilege by default; jobs opt into more only where needed.
|
|
44
80
|
permissions:
|
|
45
81
|
contents: read
|
|
46
82
|
|
|
47
|
-
#
|
|
83
|
+
# Serialize per lane, never cancel an in-flight publish: `publish-dev` for the
|
|
84
|
+
# dev path, `publish-pr-<N>` per PR for the PR path (so they don't block).
|
|
48
85
|
concurrency:
|
|
49
|
-
group: publish-dev
|
|
86
|
+
group: ${{ github.event_name == 'issue_comment' && format('publish-pr-{0}', github.event.issue.number) || 'publish-dev' }}
|
|
50
87
|
cancel-in-progress: false
|
|
51
88
|
|
|
52
89
|
jobs:
|
|
90
|
+
# ===========================================================================
|
|
91
|
+
# DEV PATH — push to main / manual dispatch. (Behavior unchanged.)
|
|
92
|
+
# ===========================================================================
|
|
53
93
|
test:
|
|
94
|
+
if: github.event_name != 'issue_comment'
|
|
54
95
|
runs-on: ubuntu-latest
|
|
55
96
|
steps:
|
|
56
97
|
- uses: actions/checkout@v4
|
|
@@ -65,6 +106,7 @@ jobs:
|
|
|
65
106
|
# computes the dev version. Kept out of the `publish` job so no extra work
|
|
66
107
|
# runs while the OIDC publishing permission is present.
|
|
67
108
|
prepare:
|
|
109
|
+
if: github.event_name != 'issue_comment'
|
|
68
110
|
runs-on: ubuntu-latest
|
|
69
111
|
outputs:
|
|
70
112
|
should_publish: ${{ steps.check.outputs.should_publish }}
|
|
@@ -113,8 +155,8 @@ jobs:
|
|
|
113
155
|
|
|
114
156
|
publish:
|
|
115
157
|
needs: [test, prepare]
|
|
116
|
-
#
|
|
117
|
-
if: needs.prepare.outputs.should_publish == 'true'
|
|
158
|
+
# Dev path only, and skip release commits (version bumped in this push).
|
|
159
|
+
if: github.event_name != 'issue_comment' && needs.prepare.outputs.should_publish == 'true'
|
|
118
160
|
runs-on: ubuntu-latest
|
|
119
161
|
# Bind this environment in repo Settings to the `main` branch so that
|
|
120
162
|
# only main can ever reach the publish step.
|
|
@@ -144,3 +186,228 @@ jobs:
|
|
|
144
186
|
# --provenance attaches a signed build attestation (needs a public repo).
|
|
145
187
|
- name: Publish (dev tag)
|
|
146
188
|
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 **\`${TAG}\`** for testing — commit \`${sha}\`.`,
|
|
399
|
+
``,
|
|
400
|
+
'```sh',
|
|
401
|
+
`npm install -g homebridge-tuya-plus@${TAG}`,
|
|
402
|
+
'```',
|
|
403
|
+
`Exact version: \`${VERSION}\``,
|
|
404
|
+
].join('\n');
|
|
405
|
+
await github.rest.issues.createComment({ owner, repo, issue_number, body });
|
|
406
|
+
await github.rest.reactions.createForIssueComment({ owner, repo, comment_id, content: 'rocket' });
|
|
407
|
+
} else {
|
|
408
|
+
await github.rest.issues.createComment({
|
|
409
|
+
owner, repo, issue_number,
|
|
410
|
+
body: `❌ \`/publish\` did not complete — the build, tests, or publish step failed. See the [run logs](${runUrl}).`,
|
|
411
|
+
});
|
|
412
|
+
await github.rest.reactions.createForIssueComment({ owner, repo, comment_id, content: 'confused' });
|
|
413
|
+
}
|
package/AGENTS.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Guidance for AI coding agents (Claude Code, Codex, etc.) working in this
|
|
4
|
+
repository. Human contributors may find it useful too.
|
|
5
|
+
|
|
6
|
+
## What this project is
|
|
7
|
+
|
|
8
|
+
`homebridge-tuya-plus` is a community-maintained [Homebridge](https://homebridge.io)
|
|
9
|
+
plugin that exposes Tuya smart-home devices to Apple HomeKit. It is
|
|
10
|
+
**LAN-first**: virtually every device is controlled locally over Tuya's LAN
|
|
11
|
+
protocol (faster, more private, works without internet). A small, strictly
|
|
12
|
+
opt-in **Tuya Cloud** path exists only for hardware that genuinely cannot be
|
|
13
|
+
reached on the LAN (e.g. battery-powered "sleepy" irrigation timers).
|
|
14
|
+
|
|
15
|
+
It is published to npm and installed by end users into their Homebridge setups,
|
|
16
|
+
many of whom already have devices paired with HomeKit. **Treat it as production
|
|
17
|
+
software with a large existing install base.**
|
|
18
|
+
|
|
19
|
+
## Tech stack & layout
|
|
20
|
+
|
|
21
|
+
- **Node.js, CommonJS** (`require`/`module.exports`). No TypeScript, no build
|
|
22
|
+
step — the source that ships is the source in the repo.
|
|
23
|
+
- Target runtimes: Node `^20.18 || ^22.10 || ^24`, Homebridge `^1.8 || ^2.0`
|
|
24
|
+
(see `engines` in `package.json`). Keep changes compatible with both
|
|
25
|
+
Homebridge 1.x and 2.x.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
index.js Platform entry point. Registers the TuyaLan platform,
|
|
29
|
+
maps config "type" -> accessory class (CLASS_DEF), runs
|
|
30
|
+
discovery, and creates/reuses cached HomeKit accessories.
|
|
31
|
+
lib/
|
|
32
|
+
BaseAccessory.js Shared base class for all accessories (state helpers,
|
|
33
|
+
unit/color conversions, getCategory()).
|
|
34
|
+
*Accessory.js One class per device type (lights, fans, garage doors,
|
|
35
|
+
irrigation, AC, blinds, ...). Each extends BaseAccessory.
|
|
36
|
+
TuyaAccessory.js The LAN protocol implementation (framing, encryption,
|
|
37
|
+
discovery, reconnection) for protocol versions 3.1-3.5+.
|
|
38
|
+
TuyaDiscovery.js UDP broadcast discovery of devices on the LAN.
|
|
39
|
+
TuyaCloud*.js Opt-in Tuya Cloud client (OpenAPI + MQTT realtime).
|
|
40
|
+
bin/ Standalone CLI helpers (tuya-lan*, key discovery/decode).
|
|
41
|
+
test/ Jest unit tests; shared HAP mocks in test/support/mocks.js.
|
|
42
|
+
scripts/ Maintenance scripts (e.g. PR-tag cleanup).
|
|
43
|
+
wiki/ User-facing docs (device setup, cloud setup, mappings).
|
|
44
|
+
config.schema.json Drives the Homebridge Config UI X settings form.
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Commands
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm ci # install (CI uses this; lockfile is committed)
|
|
51
|
+
npm test # Jest unit tests — keep these green
|
|
52
|
+
npm run lint # ESLint (flat config in eslint.config.mjs)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
CI runs `npm test` and `npm run lint` on every pull request to `main`. Run both
|
|
56
|
+
locally before you consider a change done.
|
|
57
|
+
|
|
58
|
+
## How an accessory works
|
|
59
|
+
|
|
60
|
+
When adding or changing a device type, follow the existing pattern:
|
|
61
|
+
|
|
62
|
+
1. Create `lib/<Name>Accessory.js` extending `BaseAccessory`.
|
|
63
|
+
2. Implement `static getCategory(Categories)` returning a real HAP category
|
|
64
|
+
constant (don't leave the `OTHER` fallback unless truly generic).
|
|
65
|
+
3. Build HomeKit services/characteristics in `_registerCharacteristics(dps)`,
|
|
66
|
+
which runs once the device reports its first state.
|
|
67
|
+
4. Read/write device data points (DPs) via the `BaseAccessory` helpers
|
|
68
|
+
(`getState`/`setState`/`setMultiState` and their `*Async` variants) rather
|
|
69
|
+
than poking `this.device` directly.
|
|
70
|
+
5. Register the type in `CLASS_DEF` in `index.js`.
|
|
71
|
+
6. Add the device's config options to `config.schema.json` so they appear in the
|
|
72
|
+
Homebridge UI.
|
|
73
|
+
7. Add Jest tests using `makeInstance(...)` from `test/support/mocks.js`.
|
|
74
|
+
|
|
75
|
+
## Conventions
|
|
76
|
+
|
|
77
|
+
- **Match the surrounding code.** Mirror the existing file's naming, spacing,
|
|
78
|
+
quoting (single quotes), and structure. Don't introduce a new style.
|
|
79
|
+
- **Write code that reads clearly without comments.** Prefer descriptive names
|
|
80
|
+
and straightforward control flow over cleverness.
|
|
81
|
+
- **Comment the "why", not the "what".** Existing comments explain non-obvious
|
|
82
|
+
protocol quirks, HomeKit/Homebridge gotchas, and decisions that protect
|
|
83
|
+
backwards compatibility (often citing an issue/PR number). Add comments of
|
|
84
|
+
that kind where they save the next reader real time; skip narration of obvious
|
|
85
|
+
code.
|
|
86
|
+
- **Tests where they make sense.** Pure logic (state mapping, value
|
|
87
|
+
conversions, protocol encode/decode, config coercion) should have Jest tests.
|
|
88
|
+
The HAP layer is mocked — don't reach for real hardware or the network in
|
|
89
|
+
tests.
|
|
90
|
+
- ESLint currently disables a few rules (`no-unused-vars`, `no-empty`,
|
|
91
|
+
`no-prototype-builtins`) as tech debt; don't rely on or expand that. Keep new
|
|
92
|
+
code clean.
|
|
93
|
+
|
|
94
|
+
## Logging
|
|
95
|
+
|
|
96
|
+
Users run this in Homebridge and read these logs; a chatty plugin is a real
|
|
97
|
+
complaint. The bar for a non-debug line is high.
|
|
98
|
+
|
|
99
|
+
- **`debug` is the default.** Anything routine, per-message, per-state-change,
|
|
100
|
+
per-connect, or protocol-level (odd/raw/malformed frames, reconnects, socket
|
|
101
|
+
recycling, DP dumps, "X changed: …") goes to `this.log.debug`. If it can fire
|
|
102
|
+
more than a handful of times in normal operation, it's `debug`.
|
|
103
|
+
- **`info`/`warn`/`error` are for what the user must see or act on**, and the
|
|
104
|
+
level must match real severity — a harmless condition (e.g. a cloud-fallback
|
|
105
|
+
failure while the device is reachable over the LAN) is not a `warn`/`error`.
|
|
106
|
+
Prefer one actionable line over a vague one; name the device.
|
|
107
|
+
- **Never spam.** A condition that recurs on a timer or hot path must be
|
|
108
|
+
deduplicated (surface once, then drop repeats to `debug` until it changes) and
|
|
109
|
+
its retry backed off — see `TuyaCloudDevice._onConnectFailure` and the
|
|
110
|
+
discovery port-in-use guard for the pattern.
|
|
111
|
+
- **Use the Homebridge logger** (`this.log` / `this.log.debug|info|warn|error`),
|
|
112
|
+
never `console.*`, in plugin/runtime code. (The standalone `bin/` CLIs are the
|
|
113
|
+
exception: their `console.*` is intentional user-facing output.)
|
|
114
|
+
- **Logging must never throw and must never leak secrets** — don't dump a whole
|
|
115
|
+
config/props object (it carries the device's local `key`); log the id/name.
|
|
116
|
+
|
|
117
|
+
## Backwards compatibility (read before changing behavior)
|
|
118
|
+
|
|
119
|
+
This is the most important rule in this repo. Users have working setups and
|
|
120
|
+
devices already paired in HomeKit; a careless change can break them silently.
|
|
121
|
+
|
|
122
|
+
- **Config is a public API.** Don't rename, repurpose, or remove existing config
|
|
123
|
+
keys (platform- or device-level). Add new options as optional with safe
|
|
124
|
+
defaults, and keep `config.schema.json` in sync. Be lenient in what you
|
|
125
|
+
accept (see `_coerceBoolean` / `coerceBoolean`).
|
|
126
|
+
- **Keep cloud strictly opt-in.** LAN remains the default path. Cloud code runs
|
|
127
|
+
only when a device sets `cloud: true` (or a `cloud` object) and credentials
|
|
128
|
+
are present. `mqtt` is an optional dependency — don't make it mandatory.
|
|
129
|
+
- **Preserve protocol support.** The plugin speaks Tuya LAN 3.1-3.5 and is
|
|
130
|
+
forward-compatible with newer versions; don't drop versions or break version
|
|
131
|
+
routing.
|
|
132
|
+
- When a change must alter behavior, make it opt-in.
|
|
133
|
+
|
|
134
|
+
## Debug config block
|
|
135
|
+
|
|
136
|
+
The platform reads an **undocumented** top-level `debug` object from the config
|
|
137
|
+
(see `_debugConfig()` in `index.js`). It holds dev/test-only switches and is
|
|
138
|
+
deliberately **kept out of `config.schema.json`** so it never appears in the
|
|
139
|
+
Homebridge UI. Treat it as the one place such switches belong; add new ones as
|
|
140
|
+
optional, off-by-default flags read through `_debugConfig()` and coerced with
|
|
141
|
+
`coerceBoolean`.
|
|
142
|
+
|
|
143
|
+
- `debug.forceCloudFallback` — pretend LAN discovery fails for every device, so
|
|
144
|
+
the whole platform runs over the Tuya Cloud fallback. Lets the cloud path be
|
|
145
|
+
exercised end-to-end without taking devices off the LAN (needs a configured
|
|
146
|
+
`cloud` block to be useful).
|
|
147
|
+
- `debug.logCloudHttp` — trace every Tuya Cloud HTTP request/response (method,
|
|
148
|
+
path, headers, body, status, response) at `debug`, with all credentials
|
|
149
|
+
(token, signature, password, access key/id, uid) redacted. Verbose and
|
|
150
|
+
per-request — keep it off in normal operation, and remember Homebridge
|
|
151
|
+
debug logging must be on to see it.
|
|
152
|
+
|
|
153
|
+
## Git & PR workflow
|
|
154
|
+
|
|
155
|
+
- Develop on the branch you've been assigned; never push to `main` directly.
|
|
156
|
+
- `main` is protected and merges via squash; each commit there is auto-published
|
|
157
|
+
to npm under the `dev` tag, so keep `main` releasable.
|
|
158
|
+
- Match the existing commit style: a concise, imperative subject (the PR number
|
|
159
|
+
is appended automatically on merge, e.g. `... (#62)`).
|
|
160
|
+
|
|
161
|
+
</content>
|
|
162
|
+
</invoke>
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
See @AGENTS.md for project guidance.
|
package/Changelog.md
CHANGED
|
@@ -4,11 +4,24 @@ All notable changes to this project will be documented in this file. This projec
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
-
* [+] **Tuya Cloud
|
|
7
|
+
* [+] **Tuya Cloud is now a transparent, global fallback for every device.** What started as an experiment to reach battery-powered "sleepy" irrigation timers (which never appear on the LAN) is now a general, optional fallback: add a top-level `cloud` credentials block once and the plugin keeps a single Tuya Cloud/MQTT session alive in the background, falling back to it whenever a device can't be reached on the LAN — a flaky moment, or hardware that's never local. The plugin stays **LAN-first** (local is always tried first and preferred) and cloud remains **strictly opt-in** (nothing runs unless credentials are present).
|
|
8
|
+
* **No per-device configuration.** There is one global session and no per-device cloud settings. Every device automatically uses the LAN first and the cloud as a fallback; a device with no local `key` is reached over the cloud only. Existing LAN configs (numeric data-points) keep working over the cloud — the data-point id↔code map is learned from Tuya's device shadow (`/v2.0/cloud/thing/{id}/shadow/properties`) — and if both transports fail, HomeKit shows "No Response" as before. This mirrors the official Tuya/Smart Life app (LAN when possible, cloud as a backup).
|
|
8
9
|
* Realtime updates arrive over Tuya's **MQTT** message service (via the optional `mqtt` dependency, installed automatically); initial state and control use the Tuya OpenAPI. There is no polling.
|
|
9
10
|
* Works with both **Custom** and **Smart Home** Cloud projects (the latter via app-account login).
|
|
10
|
-
* The
|
|
11
|
-
* See the wiki: **[Tuya Cloud Setup](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Tuya-Cloud-Setup.md)**.
|
|
11
|
+
* The **IrrigationSystem** accessory no longer has any cloud-specific handling — like every other accessory it's transport-agnostic, with the LAN+cloud fallback handled underneath.
|
|
12
|
+
* If your devices sometimes drop off the LAN and show "No Response", adding cloud credentials is an easy way to smooth that over. See the wiki: **[Tuya Cloud Setup](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Tuya-Cloud-Setup.md)**.
|
|
13
|
+
* [*] **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.
|
|
14
|
+
* [*] **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).
|
|
15
|
+
* [*] **IrrigationSystem: remove the rain sensor.** It never reported reliably on these devices, and bundling a sensor (a different HomeKit category) in the same accessory forced the Home app to fragment the sprinkler into "sub-accessories" — blocking control from the main tile and hiding the system master on/off. The accessory is now a single, clean sprinkler tile (IrrigationSystem + valves + optional battery); any leftover Contact/Leak sensor service from a previous build is removed automatically on restart. The `noRainSensor`, `rainSensorType`, `rainInverted`, `dpRain` and `rainOnValue` options are gone.
|
|
16
|
+
* [*] **IrrigationSystem: add the HAP Service Label service for multi-valve controllers** — an accessory that exposes a collection of same-type services (more than one `Valve`) must include a `ServiceLabel` service to anchor each valve's `ServiceLabelIndex`. It was missing, so stricter Home app clients (notably iOS) scattered the zones as separate tiles instead of nesting them under the single irrigation tile. The service is added automatically (with the Arabic-numerals namespace) whenever there is more than one valve; user-set zone names still take precedence.
|
|
17
|
+
* [*] **IrrigationSystem: stop the valve toggle flickering after a press** — tapping a zone briefly snapped back to the old state before settling on the new one. The `Active` getters returned the raw `device.state`, which (for cloud devices) only advances once the realtime stream echoes the write back, so a read in that window reported the pre-press value. The getters now report the value HomeKit already shows (optimistic on press, then confirmed/corrected by device-side change events); they still surface "No Response" while disconnected.
|
|
18
|
+
* [*] **Tuya Cloud: report real device online/offline status.** Cloud devices previously always showed as reachable. They now mirror Tuya's `online` flag (read from the device record on connect and re-checked when the realtime stream reconnects), so HomeKit shows **"No Response"** when the device is genuinely offline. If the lookup isn't permitted (the project lacks the device-management API), the device is assumed reachable so control is never blocked.
|
|
19
|
+
* [*] **Signal unreachable devices with `HapStatusError`.** The shared getters (`getStateAsync` / `getDividedStateAsync`, used by nearly every accessory's read handlers, plus the irrigation `Active` getter) threw a plain `Error` to make HomeKit show "No Response". Newer Homebridge logs a characteristic warning for that before falling back to a generic status; they now throw `HapStatusError(SERVICE_COMMUNICATION_FAILURE)` directly — same "No Response", no warning spam. No behaviour change for users.
|
|
20
|
+
* [*] **Surface write and connection failures to HomeKit, universally across device types.** Previously a command sent to an unreachable device was silently dropped while HomeKit still reported the tap as successful, and several accessories kept showing their last-known state instead of going "No Response" — the clearest example was a LAN gate that logged `skipping write, device not connected` yet still appeared online and "accepted" open/close taps. Now:
|
|
21
|
+
* **Writes** to a disconnected device (or a write the device/cloud rejects) fail the HomeKit operation instead of pretending success. The shared write helpers (`setState` / `setMultiState` / `setMultiStateLegacy` and their `*Async` variants) now signal a communication failure, so accessories whose set handlers swallowed it (garage door, switches/outlets, RGB hue + saturation, blinds, vertical-tilt blinds) report it on a tap.
|
|
22
|
+
* **Reads** that returned a cached/optimistic or constant value (brightness, colour, hue/saturation, blind position/state, garage-door state, valve in-use/duration, heater state, …) now report **"No Response"** when the device is unreachable — matching the read handlers that already did.
|
|
23
|
+
* **Cloud** commands that fail over HTTP (a network error, or a command Tuya rejects) are surfaced too: `TuyaCloudDevice.update()` now resolves to the real command result so the write helper can await it, rather than firing and forgetting.
|
|
24
|
+
* Internal/background writes (auto-shutoff timers, multi-step open/close sequences, debounced batches) stay non-fatal via dedicated non-throwing helpers (`setStateInBackground` / `setMultiStateInBackground` / `setMultiStateLegacyInBackground`), so an unreachable device can never crash Homebridge with an unhandled error.
|
|
12
25
|
|
|
13
26
|
## 2.0.1 (2021-03-25)
|
|
14
27
|
This update includes the following changes:
|