homebridge-tuya-plus 3.14.0-beta.2 → 3.14.0-dev.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ # Changes to CI/release automation require the maintainer's review.
2
+ # Enforced only when branch protection on `main` has
3
+ # "Require review from Code Owners" enabled.
4
+ /.github/workflows/ @adrianjagielak
5
+ /.github/CODEOWNERS @adrianjagielak
@@ -0,0 +1,413 @@
1
+ name: Publish to npm (dev + PR test builds)
2
+
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`.
6
+ #
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
+ #
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):
61
+ # 1. npmjs.com -> package Settings -> Trusted Publisher:
62
+ # Publisher: GitHub Actions
63
+ # Organization/user: adrianjagielak
64
+ # Repository: homebridge-tuya-plus
65
+ # Workflow filename: publish-dev.yml
66
+ # Environment: npm-dev
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.
70
+
71
+ on:
72
+ push:
73
+ branches: [main]
74
+ workflow_dispatch:
75
+ # Maintainer types `/publish` on a PR to cut a test build of that PR.
76
+ issue_comment:
77
+ types: [created]
78
+
79
+ # Least privilege by default; jobs opt into more only where needed.
80
+ permissions:
81
+ contents: read
82
+
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).
85
+ concurrency:
86
+ group: ${{ github.event_name == 'issue_comment' && format('publish-pr-{0}', github.event.issue.number) || 'publish-dev' }}
87
+ cancel-in-progress: false
88
+
89
+ jobs:
90
+ # ===========================================================================
91
+ # DEV PATH — push to main / manual dispatch. (Behavior unchanged.)
92
+ # ===========================================================================
93
+ test:
94
+ if: github.event_name != 'issue_comment'
95
+ runs-on: ubuntu-latest
96
+ steps:
97
+ - uses: actions/checkout@v4
98
+ - uses: actions/setup-node@v4
99
+ with:
100
+ node-version: "22"
101
+ - run: npm ci
102
+ - run: npm run lint
103
+ - run: npm run test
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
+
156
+ publish:
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'
160
+ runs-on: ubuntu-latest
161
+ # Bind this environment in repo Settings to the `main` branch so that
162
+ # only main can ever reach the publish step.
163
+ environment: npm-dev
164
+ permissions:
165
+ contents: read # checkout
166
+ id-token: write # OIDC -> npm Trusted Publishing (no stored token)
167
+ steps:
168
+ - uses: actions/checkout@v4
169
+
170
+ - uses: actions/setup-node@v4
171
+ with:
172
+ node-version: "22"
173
+ registry-url: "https://registry.npmjs.org"
174
+
175
+ # Trusted Publishing (OIDC) requires npm >= 11.5.1.
176
+ - name: Ensure OIDC-capable npm
177
+ run: npm install -g npm@latest
178
+
179
+ # Writes package.json on the runner only; nothing is committed.
180
+ - name: Set version
181
+ run: npm version "${{ needs.prepare.outputs.version }}" --no-git-tag-version --allow-same-version --ignore-scripts
182
+
183
+ # No NODE_AUTH_TOKEN: npm exchanges the OIDC id-token for a
184
+ # short-lived, package-scoped credential at publish time.
185
+ # --ignore-scripts ensures no lifecycle script runs with that credential.
186
+ # --provenance attaches a signed build attestation (needs a public repo).
187
+ - name: Publish (dev tag)
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,120 @@
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
+ ## Backwards compatibility (read before changing behavior)
95
+
96
+ This is the most important rule in this repo. Users have working setups and
97
+ devices already paired in HomeKit; a careless change can break them silently.
98
+
99
+ - **Config is a public API.** Don't rename, repurpose, or remove existing config
100
+ keys (platform- or device-level). Add new options as optional with safe
101
+ defaults, and keep `config.schema.json` in sync. Be lenient in what you
102
+ accept (see `_coerceBoolean` / `coerceBoolean`).
103
+ - **Keep cloud strictly opt-in.** LAN remains the default path. Cloud code runs
104
+ only when a device sets `cloud: true` (or a `cloud` object) and credentials
105
+ are present. `mqtt` is an optional dependency — don't make it mandatory.
106
+ - **Preserve protocol support.** The plugin speaks Tuya LAN 3.1-3.5 and is
107
+ forward-compatible with newer versions; don't drop versions or break version
108
+ routing.
109
+ - When a change must alter behavior, make it opt-in.
110
+
111
+ ## Git & PR workflow
112
+
113
+ - Develop on the branch you've been assigned; never push to `main` directly.
114
+ - `main` is protected and merges via squash; each commit there is auto-published
115
+ to npm under the `dev` tag, so keep `main` releasable.
116
+ - Match the existing commit style: a concise, imperative subject (the PR number
117
+ is appended automatically on merge, e.g. `... (#62)`).
118
+
119
+ </content>
120
+ </invoke>
package/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ See @AGENTS.md for project guidance.
package/Changelog.md CHANGED
@@ -2,6 +2,14 @@
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
+
5
13
  ## 2.0.1 (2021-03-25)
6
14
  This update includes the following changes:
7
15
 
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:
@@ -81,6 +89,33 @@ Search for "Tuya" in [homebridge-config-ui-x](https://github.com/oznu/homebridge
81
89
  sudo npm install -g homebridge-tuya-plus
82
90
  ```
83
91
 
92
+ #### Bleeding-edge (`dev`) builds:
93
+
94
+ Every commit to `main` is automatically published to npm under the `dev` tag — a
95
+ separate, unstable channel. The stable release always stays on `latest`, so this
96
+ won't affect normal installs. To try the latest in-development build:
97
+
98
+ ```
99
+ sudo npm install -g homebridge-tuya-plus@dev
100
+ ```
101
+
102
+ These are versioned like `3.14.0-dev.<n>` and may be unstable; use the default
103
+ install above for production.
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
+
84
119
  ## Configuration
85
120
  > UI
86
121