homebridge-tuya-plus 3.14.0-beta.2 → 3.14.0-dev.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/CODEOWNERS +5 -0
- package/.github/workflows/publish-dev.yml +146 -0
- package/Changelog.md +8 -0
- package/Readme.MD +21 -0
- package/config.schema.json +82 -13
- package/index.js +92 -3
- package/lib/IrrigationSystemAccessory.js +31 -7
- package/lib/TuyaAccessory.js +39 -1
- package/lib/TuyaCloudApi.js +290 -0
- package/lib/TuyaCloudDevice.js +188 -0
- package/lib/TuyaCloudMessaging.js +232 -0
- package/package.json +4 -1
- package/test/IrrigationSystemAccessory.test.js +49 -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 +94 -0
- package/wiki/Supported-Device-Types.md +14 -0
- package/wiki/Tuya-Cloud-Setup.md +160 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
name: Publish dev to npm
|
|
2
|
+
|
|
3
|
+
# Publishes commits on `main` to npm under the `dev` dist-tag.
|
|
4
|
+
#
|
|
5
|
+
# Versioning:
|
|
6
|
+
# * Dev builds target the NEXT minor release, since releases here are
|
|
7
|
+
# usually a minor bump. With package.json at e.g. 3.13.0, dev builds
|
|
8
|
+
# are published as 3.14.0-dev.<run-number> (minor +1, patch reset to 0,
|
|
9
|
+
# prerelease tag = the GitHub Actions run number). In semver these sort
|
|
10
|
+
# below the eventual 3.14.0 release, so `latest` always wins once cut.
|
|
11
|
+
# * Release commits are skipped: if a push changes package.json's version
|
|
12
|
+
# (i.e. you bumped it to cut a real release and publish to npm
|
|
13
|
+
# manually), no dev build is published for that push.
|
|
14
|
+
#
|
|
15
|
+
# Security model (why a malicious PR cannot steal publishing rights):
|
|
16
|
+
# * No npm token is stored anywhere. Publishing uses npm Trusted
|
|
17
|
+
# Publishing (OIDC): GitHub mints a short-lived, cryptographically
|
|
18
|
+
# signed token that npm only accepts when its claims match THIS repo +
|
|
19
|
+
# THIS workflow file. There is no durable credential to exfiltrate.
|
|
20
|
+
# * This workflow never runs on `pull_request`, only on pushes to `main`
|
|
21
|
+
# (post-merge) and manual dispatch — so fork PRs get nothing.
|
|
22
|
+
# * The privileged `publish` job is minimal and isolated: it does not
|
|
23
|
+
# run `npm ci`, a build, or tests, and uses `--ignore-scripts`, so no
|
|
24
|
+
# repo/dependency code executes while the OIDC permission is present.
|
|
25
|
+
# Tests run in a separate, unprivileged job that gates publishing, and
|
|
26
|
+
# the version is computed in an unprivileged `prepare` job.
|
|
27
|
+
#
|
|
28
|
+
# One-time setup required for this to succeed (see PR/commit notes):
|
|
29
|
+
# 1. npmjs.com -> package Settings -> Trusted Publisher:
|
|
30
|
+
# Publisher: GitHub Actions
|
|
31
|
+
# Organization/user: adrianjagielak
|
|
32
|
+
# Repository: homebridge-tuya-plus
|
|
33
|
+
# Workflow filename: publish-dev.yml
|
|
34
|
+
# Environment: npm-dev
|
|
35
|
+
# 2. GitHub repo Settings -> Environments -> create `npm-dev`,
|
|
36
|
+
# restrict deployment branches to `main` (optional: required reviewer).
|
|
37
|
+
|
|
38
|
+
on:
|
|
39
|
+
push:
|
|
40
|
+
branches: [main]
|
|
41
|
+
workflow_dispatch:
|
|
42
|
+
|
|
43
|
+
# Least privilege by default; only the publish job opts into id-token.
|
|
44
|
+
permissions:
|
|
45
|
+
contents: read
|
|
46
|
+
|
|
47
|
+
# Don't cancel an in-flight publish; let each commit publish its version.
|
|
48
|
+
concurrency:
|
|
49
|
+
group: publish-dev
|
|
50
|
+
cancel-in-progress: false
|
|
51
|
+
|
|
52
|
+
jobs:
|
|
53
|
+
test:
|
|
54
|
+
runs-on: ubuntu-latest
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@v4
|
|
57
|
+
- uses: actions/setup-node@v4
|
|
58
|
+
with:
|
|
59
|
+
node-version: "22"
|
|
60
|
+
- run: npm ci
|
|
61
|
+
- run: npm run lint
|
|
62
|
+
- run: npm run test
|
|
63
|
+
|
|
64
|
+
# Unprivileged: decides whether this push should publish a dev build, and
|
|
65
|
+
# computes the dev version. Kept out of the `publish` job so no extra work
|
|
66
|
+
# runs while the OIDC publishing permission is present.
|
|
67
|
+
prepare:
|
|
68
|
+
runs-on: ubuntu-latest
|
|
69
|
+
outputs:
|
|
70
|
+
should_publish: ${{ steps.check.outputs.should_publish }}
|
|
71
|
+
version: ${{ steps.ver.outputs.version }}
|
|
72
|
+
steps:
|
|
73
|
+
# Full history so we can read package.json from before this push.
|
|
74
|
+
- uses: actions/checkout@v4
|
|
75
|
+
with:
|
|
76
|
+
fetch-depth: 0
|
|
77
|
+
|
|
78
|
+
# Skip dev builds for release commits: if package.json's version
|
|
79
|
+
# changed anywhere in this push (before -> after), treat it as a manual
|
|
80
|
+
# release and publish nothing. If we can't determine the previous
|
|
81
|
+
# version (first push, manual dispatch, missing file), default to
|
|
82
|
+
# publishing — a dev build is the safe fallback.
|
|
83
|
+
- name: Decide whether to publish a dev build
|
|
84
|
+
id: check
|
|
85
|
+
run: |
|
|
86
|
+
CURR="$(node -p "require('./package.json').version")"
|
|
87
|
+
BEFORE="${{ github.event.before }}"
|
|
88
|
+
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
|
89
|
+
BEFORE="$(git rev-parse --verify --quiet HEAD~1 || true)"
|
|
90
|
+
fi
|
|
91
|
+
PREV=""
|
|
92
|
+
if [ -n "$BEFORE" ] && git cat-file -e "$BEFORE:package.json" 2>/dev/null; then
|
|
93
|
+
git show "$BEFORE:package.json" > "$RUNNER_TEMP/prev-package.json"
|
|
94
|
+
PREV="$(node -p "require('$RUNNER_TEMP/prev-package.json').version")"
|
|
95
|
+
fi
|
|
96
|
+
echo "Previous version: ${PREV:-<unknown>}"
|
|
97
|
+
echo "Current version: $CURR"
|
|
98
|
+
if [ -n "$PREV" ] && [ "$PREV" != "$CURR" ]; then
|
|
99
|
+
echo "::notice::package.json version changed ($PREV -> $CURR); skipping dev publish (treated as a manual release commit)."
|
|
100
|
+
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
|
101
|
+
else
|
|
102
|
+
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
# Dev builds target the next MINOR release (minor +1, patch -> 0).
|
|
106
|
+
- name: Compute dev version
|
|
107
|
+
id: ver
|
|
108
|
+
run: |
|
|
109
|
+
NEXT="$(node -e "const v=require('./package.json').version.split('.'); v[1]=Number(v[1])+1; v[2]=0; console.log(v.join('.'))")"
|
|
110
|
+
VERSION="${NEXT}-dev.${GITHUB_RUN_NUMBER}"
|
|
111
|
+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
112
|
+
echo "Will publish $VERSION"
|
|
113
|
+
|
|
114
|
+
publish:
|
|
115
|
+
needs: [test, prepare]
|
|
116
|
+
# Skip release commits (version bumped in this push).
|
|
117
|
+
if: needs.prepare.outputs.should_publish == 'true'
|
|
118
|
+
runs-on: ubuntu-latest
|
|
119
|
+
# Bind this environment in repo Settings to the `main` branch so that
|
|
120
|
+
# only main can ever reach the publish step.
|
|
121
|
+
environment: npm-dev
|
|
122
|
+
permissions:
|
|
123
|
+
contents: read # checkout
|
|
124
|
+
id-token: write # OIDC -> npm Trusted Publishing (no stored token)
|
|
125
|
+
steps:
|
|
126
|
+
- uses: actions/checkout@v4
|
|
127
|
+
|
|
128
|
+
- uses: actions/setup-node@v4
|
|
129
|
+
with:
|
|
130
|
+
node-version: "22"
|
|
131
|
+
registry-url: "https://registry.npmjs.org"
|
|
132
|
+
|
|
133
|
+
# Trusted Publishing (OIDC) requires npm >= 11.5.1.
|
|
134
|
+
- name: Ensure OIDC-capable npm
|
|
135
|
+
run: npm install -g npm@latest
|
|
136
|
+
|
|
137
|
+
# Writes package.json on the runner only; nothing is committed.
|
|
138
|
+
- name: Set version
|
|
139
|
+
run: npm version "${{ needs.prepare.outputs.version }}" --no-git-tag-version --allow-same-version --ignore-scripts
|
|
140
|
+
|
|
141
|
+
# No NODE_AUTH_TOKEN: npm exchanges the OIDC id-token for a
|
|
142
|
+
# short-lived, package-scoped credential at publish time.
|
|
143
|
+
# --ignore-scripts ensures no lifecycle script runs with that credential.
|
|
144
|
+
# --provenance attaches a signed build attestation (needs a public repo).
|
|
145
|
+
- name: Publish (dev tag)
|
|
146
|
+
run: npm publish --tag dev --provenance --ignore-scripts
|
package/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 — most notably the multi-zone **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 (Tuya's own docs confirm LAN control is unavailable for these low-power devices).
|
|
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 (including the full Irrigation System accessory — all zones, durations, battery) works the same. Live updates arrive instantly over Tuya's MQTT message service.
|
|
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,19 @@ 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.13.1-dev.<n>` and may be unstable; use the default
|
|
103
|
+
install above for production.
|
|
104
|
+
|
|
84
105
|
## Configuration
|
|
85
106
|
> UI
|
|
86
107
|
|
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
|
}
|
package/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
const TuyaAccessory = require('./lib/TuyaAccessory');
|
|
2
2
|
const TuyaDiscovery = require('./lib/TuyaDiscovery');
|
|
3
|
+
const TuyaCloudApi = require('./lib/TuyaCloudApi');
|
|
4
|
+
const TuyaCloudDevice = require('./lib/TuyaCloudDevice');
|
|
5
|
+
const TuyaCloudMessaging = require('./lib/TuyaCloudMessaging');
|
|
3
6
|
|
|
4
7
|
const OutletAccessory = require('./lib/OutletAccessory');
|
|
5
8
|
const SimpleLightAccessory = require('./lib/SimpleLightAccessory');
|
|
@@ -36,6 +39,13 @@ const PLATFORM_NAME = 'TuyaLan';
|
|
|
36
39
|
const UUID_SEED = 'homebridge-tuya';
|
|
37
40
|
const DEFAULT_DISCOVER_TIMEOUT = 60000;
|
|
38
41
|
|
|
42
|
+
// Lenient boolean coercion (matches BaseAccessory._coerceBoolean) so config
|
|
43
|
+
// values like true / "true" / 1 all read as true.
|
|
44
|
+
const coerceBoolean = (b, df = false) =>
|
|
45
|
+
typeof b === 'boolean' ? b :
|
|
46
|
+
typeof b === 'string' ? b.toLowerCase().trim() === 'true' :
|
|
47
|
+
typeof b === 'number' ? b !== 0 : df;
|
|
48
|
+
|
|
39
49
|
const CLASS_DEF = {
|
|
40
50
|
outlet: OutletAccessory,
|
|
41
51
|
simplelight: SimpleLightAccessory,
|
|
@@ -82,6 +92,11 @@ class TuyaLan {
|
|
|
82
92
|
[this.log, this.config, this.api] = [...props];
|
|
83
93
|
|
|
84
94
|
this.cachedAccessories = new Map();
|
|
95
|
+
// Shared Tuya Cloud clients, keyed by credential set, so several
|
|
96
|
+
// cloud devices on the same project share one token + one realtime
|
|
97
|
+
// (MQTT) connection. Empty unless cloud devices are configured.
|
|
98
|
+
this.cloudApis = new Map();
|
|
99
|
+
this.cloudMessagers = new Map();
|
|
85
100
|
this.api.hap.EnergyCharacteristics = require('./lib/EnergyCharacteristics')(this.api.hap);
|
|
86
101
|
|
|
87
102
|
if(!this.config || !this.config.devices) {
|
|
@@ -100,10 +115,12 @@ class TuyaLan {
|
|
|
100
115
|
const devices = {};
|
|
101
116
|
const connectedDevices = [];
|
|
102
117
|
const fakeDevices = [];
|
|
118
|
+
const cloudDevices = [];
|
|
103
119
|
this.config.devices.forEach(device => {
|
|
104
120
|
try {
|
|
105
121
|
device.id = ('' + device.id).trim();
|
|
106
|
-
|
|
122
|
+
// Cloud devices don't need a local key; only trim when present.
|
|
123
|
+
if (device.key != null) device.key = ('' + device.key).trim();
|
|
107
124
|
device.type = ('' + device.type).trim();
|
|
108
125
|
|
|
109
126
|
device.ip = ('' + (device.ip || '')).trim();
|
|
@@ -112,12 +129,20 @@ class TuyaLan {
|
|
|
112
129
|
if (!device.type) return this.log.error('%s (%s) doesn\'t have a type defined.', device.name || 'Unnamed device', device.id);
|
|
113
130
|
if (!CLASS_DEF[device.type.toLowerCase()]) return this.log.error('%s (%s) doesn\'t have a valid type defined.', device.name || 'Unnamed device', device.id);
|
|
114
131
|
|
|
115
|
-
if (device
|
|
132
|
+
if (this._isCloudDevice(device)) cloudDevices.push({name: device.id.slice(8), ...device});
|
|
133
|
+
else if (device.fake) fakeDevices.push({name: device.id.slice(8), ...device});
|
|
116
134
|
else devices[device.id] = {name: device.id.slice(8), ...device};
|
|
117
135
|
});
|
|
118
136
|
|
|
137
|
+
// Cloud devices are reached over the internet, not the LAN, so they need
|
|
138
|
+
// no discovery — wire them up right away.
|
|
139
|
+
cloudDevices.forEach(config => this._addCloudAccessory(config));
|
|
140
|
+
|
|
119
141
|
const deviceIds = Object.keys(devices);
|
|
120
|
-
if (deviceIds.length === 0)
|
|
142
|
+
if (deviceIds.length === 0) {
|
|
143
|
+
if (cloudDevices.length === 0) this.log.error('No valid configured devices found.');
|
|
144
|
+
return; // cloud-only (or empty) configuration: nothing to discover over LAN
|
|
145
|
+
}
|
|
121
146
|
|
|
122
147
|
this.log.info('Starting discovery...');
|
|
123
148
|
|
|
@@ -176,6 +201,70 @@ class TuyaLan {
|
|
|
176
201
|
}, this.config.discoverTimeout ?? DEFAULT_DISCOVER_TIMEOUT);
|
|
177
202
|
}
|
|
178
203
|
|
|
204
|
+
/* ------------------------------------------------------------------ *
|
|
205
|
+
* Tuya Cloud helpers (for devices that can't be reached over the LAN,
|
|
206
|
+
* e.g. battery-powered "sleepy" irrigation timers). This plugin stays
|
|
207
|
+
* LAN-first; these paths are only exercised by devices opting in with
|
|
208
|
+
* `cloud: true` (or a per-device `cloud` credentials object).
|
|
209
|
+
* ------------------------------------------------------------------ */
|
|
210
|
+
|
|
211
|
+
_isCloudDevice(device) {
|
|
212
|
+
return !!(device && (device.cloud === true || (typeof device.cloud === 'object' && device.cloud)));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Effective cloud credentials/options for a device: the platform-level
|
|
216
|
+
// `cloud` block, overlaid with any per-device `cloud` object.
|
|
217
|
+
_resolveCloudConfig(device) {
|
|
218
|
+
const platform = (this.config.cloud && typeof this.config.cloud === 'object') ? this.config.cloud : {};
|
|
219
|
+
const perDevice = (typeof device.cloud === 'object' && device.cloud) ? device.cloud : {};
|
|
220
|
+
return {...platform, ...perDevice};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// One TuyaCloudApi per credential set, so multiple cloud devices on the
|
|
224
|
+
// same Tuya project share a single token.
|
|
225
|
+
_getCloudApi(cloudCfg) {
|
|
226
|
+
const key = TuyaCloudApi.keyFor(cloudCfg);
|
|
227
|
+
if (!this.cloudApis.has(key)) {
|
|
228
|
+
this.cloudApis.set(key, new TuyaCloudApi({...cloudCfg, log: this.log}));
|
|
229
|
+
}
|
|
230
|
+
return this.cloudApis.get(key);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// One shared realtime (MQTT) stream per credential set, unless realtime is
|
|
234
|
+
// disabled. Returns null when realtime is off — the device then shows its
|
|
235
|
+
// initial state and stays controllable, but won't receive live updates.
|
|
236
|
+
_getCloudMessaging(api, cloudCfg) {
|
|
237
|
+
const realtime = cloudCfg.realtime === undefined ? true : coerceBoolean(cloudCfg.realtime, true);
|
|
238
|
+
if (!realtime) return null;
|
|
239
|
+
const key = TuyaCloudApi.keyFor(cloudCfg);
|
|
240
|
+
if (!this.cloudMessagers.has(key)) {
|
|
241
|
+
this.cloudMessagers.set(key, new TuyaCloudMessaging({api, log: this.log}));
|
|
242
|
+
}
|
|
243
|
+
return this.cloudMessagers.get(key);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
_addCloudAccessory(config) {
|
|
247
|
+
const cloudCfg = this._resolveCloudConfig(config);
|
|
248
|
+
if (!cloudCfg.accessId || !cloudCfg.accessKey) {
|
|
249
|
+
return this.log.error('%s (%s) is configured for the Tuya Cloud, but no credentials were found. Add a top-level "cloud" block (accessId, accessKey, region) or a per-device "cloud" object.', config.name, config.id);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const api = this._getCloudApi(cloudCfg);
|
|
253
|
+
const messaging = this._getCloudMessaging(api, cloudCfg);
|
|
254
|
+
|
|
255
|
+
this.log.info('Adding cloud device: %s (%s) via %s', config.name, config.id, api.endpoint);
|
|
256
|
+
|
|
257
|
+
this.addAccessory(new TuyaCloudDevice({
|
|
258
|
+
...config,
|
|
259
|
+
cloud: true, // normalise so accessories can detect cloud mode
|
|
260
|
+
cloudApi: api,
|
|
261
|
+
messaging,
|
|
262
|
+
log: this.log,
|
|
263
|
+
UUID: UUID.generate(UUID_SEED + ':' + config.id),
|
|
264
|
+
connect: false
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
|
|
179
268
|
registerPlatformAccessories(platformAccessories) {
|
|
180
269
|
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, Array.isArray(platformAccessories) ? platformAccessories : [platformAccessories]);
|
|
181
270
|
}
|
|
@@ -45,15 +45,20 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
45
45
|
// Self-contained (no reliance on instance state) so it is safe to call
|
|
46
46
|
// both during early service reconciliation and later when wiring
|
|
47
47
|
// characteristics.
|
|
48
|
+
const cloud = this._isCloud();
|
|
48
49
|
const defaultDuration = isFinite(this.device.context.defaultDuration) ? parseInt(this.device.context.defaultDuration) : 600;
|
|
49
50
|
|
|
50
51
|
if (Array.isArray(this.device.context.valves) && this.device.context.valves.length) {
|
|
51
52
|
return this.device.context.valves.map((valve, i) => {
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
// A data-point may be a numeric LAN id (1, 2, …) or a Tuya Cloud
|
|
54
|
+
// code (e.g. "switch_1"). Accept either; only an empty value is
|
|
55
|
+
// invalid.
|
|
56
|
+
const dp = (valve && valve.dp !== undefined && valve.dp !== null) ? ('' + valve.dp).trim() : '';
|
|
57
|
+
if (!dp) {
|
|
58
|
+
throw new Error(`The valve definition #${i + 1} is missing a 'dp': ${JSON.stringify(valve)}`);
|
|
54
59
|
}
|
|
55
60
|
return {
|
|
56
|
-
dp
|
|
61
|
+
dp,
|
|
57
62
|
name: (('' + (valve.name || '')).trim()) || ('Zone ' + (i + 1)),
|
|
58
63
|
index: i + 1,
|
|
59
64
|
duration: isFinite(valve.defaultDuration) ? parseInt(valve.defaultDuration) : defaultDuration
|
|
@@ -66,7 +71,9 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
66
71
|
const configs = [];
|
|
67
72
|
for (let i = 0; i < count; i++) {
|
|
68
73
|
configs.push({
|
|
69
|
-
|
|
74
|
+
// Cloud devices address valves by code (switch_1, switch_2, …);
|
|
75
|
+
// LAN devices by numeric data-point id (1, 2, …).
|
|
76
|
+
dp: cloud ? ('switch_' + (i + 1)) : String(i + 1),
|
|
70
77
|
name: 'Valve ' + (letters[i] || (i + 1)),
|
|
71
78
|
index: i + 1,
|
|
72
79
|
duration: defaultDuration
|
|
@@ -75,6 +82,21 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
75
82
|
return configs;
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
// True when this device is reached over the Tuya Cloud (data-points keyed by
|
|
86
|
+
// string code) rather than the LAN (numeric data-point ids).
|
|
87
|
+
_isCloud() {
|
|
88
|
+
return this._coerceBoolean(this.device.context.cloud, false);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Resolve a configurable data-point that may be given as a numeric LAN id or
|
|
92
|
+
// a Tuya Cloud code, falling back to a sensible default for the active
|
|
93
|
+
// transport.
|
|
94
|
+
_resolveDP(value, cloudDefault, lanDefault) {
|
|
95
|
+
const v = (value === undefined || value === null) ? '' : ('' + value).trim();
|
|
96
|
+
if (v !== '') return v;
|
|
97
|
+
return this._isCloud() ? cloudDefault : lanDefault;
|
|
98
|
+
}
|
|
99
|
+
|
|
78
100
|
_hasBattery() {
|
|
79
101
|
return !this._coerceBoolean(this.device.context.noBattery, false);
|
|
80
102
|
}
|
|
@@ -186,9 +208,11 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
186
208
|
this._cascadeOn = this._coerceBoolean(this.device.context.masterTurnsOnAllZones, true);
|
|
187
209
|
this._cascadeOff = this._coerceBoolean(this.device.context.masterTurnsOffAllZones, true);
|
|
188
210
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
this.
|
|
211
|
+
// Data-points accept a numeric LAN id or a Tuya Cloud code; defaults
|
|
212
|
+
// differ per transport (cloud uses the standard Tuya codes).
|
|
213
|
+
this.dpBattery = this._resolveDP(this.device.context.dpBattery, 'battery_percentage', '46');
|
|
214
|
+
this.dpCharging = this._resolveDP(this.device.context.dpCharging, 'charge_state', '101');
|
|
215
|
+
this.dpRain = this._resolveDP(this.device.context.dpRain, 'rain_sensor_state', '49');
|
|
192
216
|
this._rainOnValue = ('' + (this.device.context.rainOnValue || 'rain')).trim();
|
|
193
217
|
this._rainInverted = this._coerceBoolean(this.device.context.rainInverted, false);
|
|
194
218
|
|
package/lib/TuyaAccessory.js
CHANGED
|
@@ -226,9 +226,21 @@ class TuyaAccessory extends EventEmitter {
|
|
|
226
226
|
}
|
|
227
227
|
});
|
|
228
228
|
|
|
229
|
-
this._socket.on('close',
|
|
229
|
+
this._socket.on('close', () => {
|
|
230
230
|
this.connected = false;
|
|
231
231
|
this.session_key = null;
|
|
232
|
+
|
|
233
|
+
// A heartbeat timer must never outlive its socket. The pinger
|
|
234
|
+
// callbacks act on `this._socket`, so a stale timer left over from a
|
|
235
|
+
// dead socket would later fire against a freshly reconnected one and
|
|
236
|
+
// trip a spurious ERR_PING_TIMED_OUT. Clearing it here covers every
|
|
237
|
+
// teardown path (error-driven destroy as well as the graceful end
|
|
238
|
+
// below).
|
|
239
|
+
if (this._socket._pinger) {
|
|
240
|
+
clearTimeout(this._socket._pinger);
|
|
241
|
+
this._socket._pinger = null;
|
|
242
|
+
}
|
|
243
|
+
|
|
232
244
|
//this.log.info('Closed connection with', this.context.name);
|
|
233
245
|
});
|
|
234
246
|
|
|
@@ -236,6 +248,32 @@ class TuyaAccessory extends EventEmitter {
|
|
|
236
248
|
this.connected = false;
|
|
237
249
|
this.session_key = null;
|
|
238
250
|
this.log.info('Disconnected from', this.context.name);
|
|
251
|
+
|
|
252
|
+
// The device closed the connection on its own. This is routine for
|
|
253
|
+
// many Tuya devices (e.g. LED ceiling lights) that recycle their
|
|
254
|
+
// long-lived LAN sockets every few minutes. Previously nothing here
|
|
255
|
+
// tore the socket down or reconnected: the heartbeat timer kept
|
|
256
|
+
// running and, because `connected` is now false, its pings went
|
|
257
|
+
// nowhere until the full ping timeout elapsed and emitted a
|
|
258
|
+
// misleading "ERR_PING_TIMED_OUT" ~30s later - which was the only
|
|
259
|
+
// thing that ultimately triggered a reconnect. Tear down and
|
|
260
|
+
// reconnect promptly instead, so recovery is quick and the logs
|
|
261
|
+
// reflect what actually happened (a clean disconnect, not a ping
|
|
262
|
+
// failure).
|
|
263
|
+
if (this._socket._pinger) {
|
|
264
|
+
clearTimeout(this._socket._pinger);
|
|
265
|
+
this._socket._pinger = null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Destroying first means a trailing RST can't resurface as an
|
|
269
|
+
// 'error' and schedule a second, competing reconnect.
|
|
270
|
+
this._socket.destroy();
|
|
271
|
+
|
|
272
|
+
if (!this._socket._errorReconnect) {
|
|
273
|
+
this._socket._errorReconnect = setTimeout(() => {
|
|
274
|
+
process.nextTick(this._connect.bind(this));
|
|
275
|
+
}, 5000);
|
|
276
|
+
}
|
|
239
277
|
});
|
|
240
278
|
}
|
|
241
279
|
|