homebridge-tuya-plus 3.13.1-dev.1 → 3.14.0-alpha.1
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 +359 -29
- package/Changelog.md +11 -0
- package/Readme.MD +23 -1
- package/config.schema.json +82 -13
- package/index.js +92 -3
- package/lib/IrrigationSystemAccessory.js +78 -17
- package/lib/TuyaAccessory.js +39 -1
- package/lib/TuyaCloudApi.js +290 -0
- package/lib/TuyaCloudDevice.js +188 -0
- package/lib/TuyaCloudMessaging.js +237 -0
- package/package.json +6 -2
- package/scripts/cleanup-pr-tags.js +122 -0
- package/test/IrrigationSystemAccessory.test.js +94 -0
- package/test/TuyaAccessory.protocol.test.js +98 -0
- package/test/TuyaCloudApi.test.js +196 -0
- package/test/TuyaCloudDevice.test.js +105 -0
- package/test/TuyaCloudMessaging.test.js +118 -0
- package/test/support/mocks.js +2 -0
- package/wiki/Supported-Device-Types.md +14 -0
- package/wiki/Tuya-Cloud-Setup.md +160 -0
|
@@ -1,45 +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
|
-
# * The privileged `publish` job is minimal and isolated: it does not
|
|
13
|
-
# run `npm ci`, a build, or tests, and uses `--ignore-scripts`, so no
|
|
14
|
-
# repo/dependency code executes while the OIDC permission is present.
|
|
15
|
-
# Tests run in a separate, unprivileged job that gates publishing.
|
|
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.
|
|
16
14
|
#
|
|
17
|
-
#
|
|
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.
|
|
26
|
+
#
|
|
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):
|
|
18
61
|
# 1. npmjs.com -> package Settings -> Trusted Publisher:
|
|
19
62
|
# Publisher: GitHub Actions
|
|
20
63
|
# Organization/user: adrianjagielak
|
|
21
64
|
# Repository: homebridge-tuya-plus
|
|
22
65
|
# Workflow filename: publish-dev.yml
|
|
23
66
|
# Environment: npm-dev
|
|
24
|
-
# 2. GitHub repo Settings -> Environments ->
|
|
25
|
-
#
|
|
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.
|
|
26
70
|
|
|
27
71
|
on:
|
|
28
72
|
push:
|
|
29
73
|
branches: [main]
|
|
30
74
|
workflow_dispatch:
|
|
75
|
+
# Maintainer types `/publish` on a PR to cut a test build of that PR.
|
|
76
|
+
issue_comment:
|
|
77
|
+
types: [created]
|
|
31
78
|
|
|
32
|
-
# Least privilege by default;
|
|
79
|
+
# Least privilege by default; jobs opt into more only where needed.
|
|
33
80
|
permissions:
|
|
34
81
|
contents: read
|
|
35
82
|
|
|
36
|
-
#
|
|
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).
|
|
37
85
|
concurrency:
|
|
38
|
-
group: publish-dev
|
|
86
|
+
group: ${{ github.event_name == 'issue_comment' && format('publish-pr-{0}', github.event.issue.number) || 'publish-dev' }}
|
|
39
87
|
cancel-in-progress: false
|
|
40
88
|
|
|
41
89
|
jobs:
|
|
90
|
+
# ===========================================================================
|
|
91
|
+
# DEV PATH — push to main / manual dispatch. (Behavior unchanged.)
|
|
92
|
+
# ===========================================================================
|
|
42
93
|
test:
|
|
94
|
+
if: github.event_name != 'issue_comment'
|
|
43
95
|
runs-on: ubuntu-latest
|
|
44
96
|
steps:
|
|
45
97
|
- uses: actions/checkout@v4
|
|
@@ -50,8 +102,61 @@ jobs:
|
|
|
50
102
|
- run: npm run lint
|
|
51
103
|
- run: npm run test
|
|
52
104
|
|
|
105
|
+
# Unprivileged: decides whether this push should publish a dev build, and
|
|
106
|
+
# computes the dev version. Kept out of the `publish` job so no extra work
|
|
107
|
+
# runs while the OIDC publishing permission is present.
|
|
108
|
+
prepare:
|
|
109
|
+
if: github.event_name != 'issue_comment'
|
|
110
|
+
runs-on: ubuntu-latest
|
|
111
|
+
outputs:
|
|
112
|
+
should_publish: ${{ steps.check.outputs.should_publish }}
|
|
113
|
+
version: ${{ steps.ver.outputs.version }}
|
|
114
|
+
steps:
|
|
115
|
+
# Full history so we can read package.json from before this push.
|
|
116
|
+
- uses: actions/checkout@v4
|
|
117
|
+
with:
|
|
118
|
+
fetch-depth: 0
|
|
119
|
+
|
|
120
|
+
# Skip dev builds for release commits: if package.json's version
|
|
121
|
+
# changed anywhere in this push (before -> after), treat it as a manual
|
|
122
|
+
# release and publish nothing. If we can't determine the previous
|
|
123
|
+
# version (first push, manual dispatch, missing file), default to
|
|
124
|
+
# publishing — a dev build is the safe fallback.
|
|
125
|
+
- name: Decide whether to publish a dev build
|
|
126
|
+
id: check
|
|
127
|
+
run: |
|
|
128
|
+
CURR="$(node -p "require('./package.json').version")"
|
|
129
|
+
BEFORE="${{ github.event.before }}"
|
|
130
|
+
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
|
131
|
+
BEFORE="$(git rev-parse --verify --quiet HEAD~1 || true)"
|
|
132
|
+
fi
|
|
133
|
+
PREV=""
|
|
134
|
+
if [ -n "$BEFORE" ] && git cat-file -e "$BEFORE:package.json" 2>/dev/null; then
|
|
135
|
+
git show "$BEFORE:package.json" > "$RUNNER_TEMP/prev-package.json"
|
|
136
|
+
PREV="$(node -p "require('$RUNNER_TEMP/prev-package.json').version")"
|
|
137
|
+
fi
|
|
138
|
+
echo "Previous version: ${PREV:-<unknown>}"
|
|
139
|
+
echo "Current version: $CURR"
|
|
140
|
+
if [ -n "$PREV" ] && [ "$PREV" != "$CURR" ]; then
|
|
141
|
+
echo "::notice::package.json version changed ($PREV -> $CURR); skipping dev publish (treated as a manual release commit)."
|
|
142
|
+
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
|
143
|
+
else
|
|
144
|
+
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
|
145
|
+
fi
|
|
146
|
+
|
|
147
|
+
# Dev builds target the next MINOR release (minor +1, patch -> 0).
|
|
148
|
+
- name: Compute dev version
|
|
149
|
+
id: ver
|
|
150
|
+
run: |
|
|
151
|
+
NEXT="$(node -e "const v=require('./package.json').version.split('.'); v[1]=Number(v[1])+1; v[2]=0; console.log(v.join('.'))")"
|
|
152
|
+
VERSION="${NEXT}-dev.${GITHUB_RUN_NUMBER}"
|
|
153
|
+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
154
|
+
echo "Will publish $VERSION"
|
|
155
|
+
|
|
53
156
|
publish:
|
|
54
|
-
needs: test
|
|
157
|
+
needs: [test, prepare]
|
|
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'
|
|
55
160
|
runs-on: ubuntu-latest
|
|
56
161
|
# Bind this environment in repo Settings to the `main` branch so that
|
|
57
162
|
# only main can ever reach the publish step.
|
|
@@ -71,17 +176,9 @@ jobs:
|
|
|
71
176
|
- name: Ensure OIDC-capable npm
|
|
72
177
|
run: npm install -g npm@latest
|
|
73
178
|
|
|
74
|
-
- name: Compute dev version
|
|
75
|
-
id: ver
|
|
76
|
-
run: |
|
|
77
|
-
NEXT="$(node -e "const v=require('./package.json').version.split('.'); v[2]=Number(v[2])+1; console.log(v.join('.'))")"
|
|
78
|
-
VERSION="${NEXT}-dev.${GITHUB_RUN_NUMBER}"
|
|
79
|
-
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
80
|
-
echo "Will publish $VERSION"
|
|
81
|
-
|
|
82
179
|
# Writes package.json on the runner only; nothing is committed.
|
|
83
180
|
- name: Set version
|
|
84
|
-
run: npm version "${{
|
|
181
|
+
run: npm version "${{ needs.prepare.outputs.version }}" --no-git-tag-version --allow-same-version --ignore-scripts
|
|
85
182
|
|
|
86
183
|
# No NODE_AUTH_TOKEN: npm exchanges the OIDC id-token for a
|
|
87
184
|
# short-lived, package-scoped credential at publish time.
|
|
@@ -89,3 +186,236 @@ jobs:
|
|
|
89
186
|
# --provenance attaches a signed build attestation (needs a public repo).
|
|
90
187
|
- name: Publish (dev tag)
|
|
91
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 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
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. This project uses [semantic versioning](https://semver.org/).
|
|
4
4
|
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
* [+] **Tuya Cloud support for devices that can't be reached over the LAN** — most notably battery-powered "sleepy" irrigation/sprinkler timers, which sleep almost all the time and only ever talk to Tuya's cloud, so the local protocol can never reach them. The plugin stays LAN-first: cloud is strictly opt-in. Add a top-level `cloud` credentials block (or a per-device `cloud` object) and set `"cloud": true` on the device.
|
|
8
|
+
* 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
|
+
* Works with both **Custom** and **Smart Home** Cloud projects (the latter via app-account login).
|
|
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
|
+
* See the wiki: **[Tuya Cloud Setup](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Tuya-Cloud-Setup.md)**.
|
|
12
|
+
* [*] **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.
|
|
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
|
+
|
|
5
16
|
## 2.0.1 (2021-03-25)
|
|
6
17
|
This update includes the following changes:
|
|
7
18
|
|
package/Readme.MD
CHANGED
|
@@ -69,6 +69,14 @@ Protocol **3.5** is currently the newest Tuya LAN protocol in existence: it is t
|
|
|
69
69
|
|
|
70
70
|
This plugin is nevertheless **3.6-ready**: if a device reports a newer protocol version (e.g. `3.6`) in its broadcast, the plugin automatically talks to it using the newest (3.5/GCM) protocol stack while tagging payloads with the device's reported version — the same forward-compatibility strategy used by tinytuya. Should such a device misbehave, you can pin it to a specific protocol with the `forceVersion` device option (e.g. `"forceVersion": "3.5"`), or use `version` to set a protocol for devices that can't be discovered (e.g. on another subnet).
|
|
71
71
|
|
|
72
|
+
## Cloud devices (for hardware that can't be controlled locally)
|
|
73
|
+
|
|
74
|
+
This is a **LAN-first** plugin — virtually every Tuya device is controlled locally, which is faster, more private, and keeps working without internet. A few devices, though, simply **can't** be reached over the LAN: battery-powered "sleepy" devices — for example the **irrigation / faucet timers** — sleep almost all the time and only ever connect out to Tuya's cloud, so they never answer on the local network.
|
|
75
|
+
|
|
76
|
+
For exactly these cases the plugin can talk to a device through the **Tuya Cloud** instead — strictly **opt-in, per device**. You add your Tuya Cloud project credentials once and set `"cloud": true` on the device; everything else works the same.
|
|
77
|
+
|
|
78
|
+
👉 **[Tuya Cloud Setup guide](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Tuya-Cloud-Setup.md)** — how to get credentials and configure a cloud device.
|
|
79
|
+
|
|
72
80
|
## Installation Instructions
|
|
73
81
|
|
|
74
82
|
#### Option 1: Install via Homebridge Config UI X:
|
|
@@ -91,9 +99,23 @@ won't affect normal installs. To try the latest in-development build:
|
|
|
91
99
|
sudo npm install -g homebridge-tuya-plus@dev
|
|
92
100
|
```
|
|
93
101
|
|
|
94
|
-
These are versioned like `3.
|
|
102
|
+
These are versioned like `3.14.0-dev.<n>` and may be unstable; use the default
|
|
95
103
|
install above for production.
|
|
96
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
|
+
|
|
97
119
|
## Configuration
|
|
98
120
|
> UI
|
|
99
121
|
|
package/config.schema.json
CHANGED
|
@@ -19,12 +19,71 @@
|
|
|
19
19
|
"placeholder": "Timeout (millisecond) for device IP address discovery",
|
|
20
20
|
"default": 60000
|
|
21
21
|
},
|
|
22
|
+
"cloud": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"title": "Tuya Cloud (optional — only for devices that can't be reached over the LAN)",
|
|
25
|
+
"description": "This plugin is LAN-first. A few devices — notably battery-powered 'sleepy' irrigation timers — sleep most of the time and only ever talk to Tuya's cloud, so they cannot be controlled locally. Create a free Cloud project at iot.tuya.com, enter its credentials here, then tick 'Control via Tuya Cloud' on each such device. See the wiki for step-by-step setup.",
|
|
26
|
+
"properties": {
|
|
27
|
+
"accessId": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"title": "Access ID / Client ID"
|
|
30
|
+
},
|
|
31
|
+
"accessKey": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"title": "Access Secret / Client Secret"
|
|
34
|
+
},
|
|
35
|
+
"region": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"title": "Data center / region",
|
|
38
|
+
"default": "us",
|
|
39
|
+
"description": "Must match the region your Tuya / Smart Life app account is registered in.",
|
|
40
|
+
"oneOf": [
|
|
41
|
+
{ "title": "Central Europe", "enum": ["eu"] },
|
|
42
|
+
{ "title": "Western Europe (Azure)", "enum": ["eu-w"] },
|
|
43
|
+
{ "title": "Western America", "enum": ["us"] },
|
|
44
|
+
{ "title": "Eastern America (Azure)", "enum": ["us-e"] },
|
|
45
|
+
{ "title": "China", "enum": ["cn"] },
|
|
46
|
+
{ "title": "India", "enum": ["in"] },
|
|
47
|
+
{ "title": "Singapore", "enum": ["sg"] }
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"username": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"title": "App account email/phone (Smart Home projects only)",
|
|
53
|
+
"description": "For 'Smart Home' Cloud projects: the Tuya / Smart Life app account that owns the devices. Leave blank for 'Custom' projects (which link devices by QR scan)."
|
|
54
|
+
},
|
|
55
|
+
"password": {
|
|
56
|
+
"type": "string",
|
|
57
|
+
"title": "App account password (Smart Home projects only)"
|
|
58
|
+
},
|
|
59
|
+
"countryCode": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"title": "Country calling code (Smart Home projects only)",
|
|
62
|
+
"placeholder": "e.g. 1, 44, 48"
|
|
63
|
+
},
|
|
64
|
+
"schema": {
|
|
65
|
+
"type": "string",
|
|
66
|
+
"title": "App (Smart Home projects only)",
|
|
67
|
+
"default": "tuyaSmart",
|
|
68
|
+
"oneOf": [
|
|
69
|
+
{ "title": "Tuya Smart", "enum": ["tuyaSmart"] },
|
|
70
|
+
{ "title": "Smart Life", "enum": ["smartlife"] }
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
"realtime": {
|
|
74
|
+
"type": "boolean",
|
|
75
|
+
"title": "Realtime updates (MQTT)",
|
|
76
|
+
"default": true,
|
|
77
|
+
"description": "Receive instant updates over Tuya's MQTT message service (uses the 'mqtt' package, installed automatically). If disabled, devices stay controllable and show their state at startup, but external changes (physical buttons, the device's own timers) won't be reflected until restart."
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
22
81
|
"devices": {
|
|
23
82
|
"type": "array",
|
|
24
83
|
"orderable": false,
|
|
25
84
|
"items": {
|
|
26
85
|
"type": "object",
|
|
27
|
-
"required": ["type", "name", "id"
|
|
86
|
+
"required": ["type", "name", "id"],
|
|
28
87
|
"properties": {
|
|
29
88
|
"type": {
|
|
30
89
|
"type": "string",
|
|
@@ -136,8 +195,18 @@
|
|
|
136
195
|
}
|
|
137
196
|
},
|
|
138
197
|
"key": {
|
|
139
|
-
"title": "Tuya Key",
|
|
198
|
+
"title": "Tuya Key (local key — required for LAN devices)",
|
|
140
199
|
"type": "string",
|
|
200
|
+
"description": "The device's local key. Required for normal (LAN) devices. Not needed for cloud devices (tick 'Control via Tuya Cloud' below).",
|
|
201
|
+
"condition": {
|
|
202
|
+
"functionBody": "return model.devices && model.devices[arrayIndices].type !== 'null';"
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
"cloud": {
|
|
206
|
+
"type": ["boolean", "object"],
|
|
207
|
+
"title": "Control via Tuya Cloud (instead of the LAN)",
|
|
208
|
+
"default": false,
|
|
209
|
+
"description": "Enable for devices that can't be reached locally (e.g. battery-powered irrigation timers). Uses the platform-level 'cloud' credentials above. Advanced: instead of a checkbox you can set this to an object with per-device { accessId, accessKey, region, … } to use different credentials for this one device.",
|
|
141
210
|
"condition": {
|
|
142
211
|
"functionBody": "return model.devices && model.devices[arrayIndices].type !== 'null';"
|
|
143
212
|
}
|
|
@@ -648,7 +717,7 @@
|
|
|
648
717
|
"type": "integer",
|
|
649
718
|
"title": "Number of Valves / Zones",
|
|
650
719
|
"placeholder": 4,
|
|
651
|
-
"description": "How many valves/zones the controller has. They are assumed to be on data-points 1, 2, 3, … For non-sequential data-points use the 'valves' list instead.",
|
|
720
|
+
"description": "How many valves/zones the controller has. They are assumed to be on data-points 1, 2, 3, … (or, for cloud devices, on the codes switch_1, switch_2, …). For non-sequential data-points use the 'valves' list instead.",
|
|
652
721
|
"condition": {
|
|
653
722
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type);"
|
|
654
723
|
}
|
|
@@ -662,7 +731,7 @@
|
|
|
662
731
|
"type": "object",
|
|
663
732
|
"properties": {
|
|
664
733
|
"name": { "type": "string", "title": "Zone name", "placeholder": "Lawn" },
|
|
665
|
-
"dp": { "type": "integer", "title": "Data-point", "placeholder": 1 },
|
|
734
|
+
"dp": { "type": ["integer", "string"], "title": "Data-point (numeric id, or cloud code)", "placeholder": "1 or switch_1" },
|
|
666
735
|
"defaultDuration": { "type": "integer", "title": "Default run time (seconds, 0 = run indefinitely)", "placeholder": 600 }
|
|
667
736
|
}
|
|
668
737
|
},
|
|
@@ -724,9 +793,9 @@
|
|
|
724
793
|
}
|
|
725
794
|
},
|
|
726
795
|
"dpBattery": {
|
|
727
|
-
"type": "integer",
|
|
728
|
-
"placeholder": 46,
|
|
729
|
-
"title": "Battery data-point",
|
|
796
|
+
"type": ["integer", "string"],
|
|
797
|
+
"placeholder": "46 or battery_percentage",
|
|
798
|
+
"title": "Battery data-point (numeric id, or cloud code)",
|
|
730
799
|
"condition": {
|
|
731
800
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type) && !model.devices[arrayIndices].noBattery;"
|
|
732
801
|
}
|
|
@@ -741,9 +810,9 @@
|
|
|
741
810
|
}
|
|
742
811
|
},
|
|
743
812
|
"dpCharging": {
|
|
744
|
-
"type": "integer",
|
|
745
|
-
"placeholder": 101,
|
|
746
|
-
"title": "Charging-status data-point",
|
|
813
|
+
"type": ["integer", "string"],
|
|
814
|
+
"placeholder": "101 or charge_state",
|
|
815
|
+
"title": "Charging-status data-point (numeric id, or cloud code)",
|
|
747
816
|
"description": "Boolean data-point reporting whether the battery is charging (e.g. solar / USB-C units). HomeKit shows Charging / Not Charging; if your controller doesn't report this, it shows Not Chargeable.",
|
|
748
817
|
"condition": {
|
|
749
818
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type) && !model.devices[arrayIndices].noBattery;"
|
|
@@ -778,9 +847,9 @@
|
|
|
778
847
|
}
|
|
779
848
|
},
|
|
780
849
|
"dpRain": {
|
|
781
|
-
"type": "integer",
|
|
782
|
-
"placeholder": 49,
|
|
783
|
-
"title": "Rain sensor data-point",
|
|
850
|
+
"type": ["integer", "string"],
|
|
851
|
+
"placeholder": "49 or rain_sensor_state",
|
|
852
|
+
"title": "Rain sensor data-point (numeric id, or cloud code)",
|
|
784
853
|
"condition": {
|
|
785
854
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type) && !model.devices[arrayIndices].noRainSensor;"
|
|
786
855
|
}
|