homebridge-tuya-plus 3.12.0 → 3.13.1-dev.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/CODEOWNERS +5 -0
- package/.github/workflows/publish-dev.yml +91 -0
- package/Readme.MD +33 -0
- package/config.schema.json +265 -6
- package/index.js +23 -5
- package/lib/BaseAccessory.js +7 -0
- package/lib/DehumidifierAccessory.js +1 -1
- package/lib/IrrigationSystemAccessory.js +611 -0
- package/lib/OilDiffuserAccessory.js +1 -1
- package/lib/SimpleFanLightAccessory.js +54 -12
- package/lib/SimpleGarageDoorAccessory.js +334 -0
- package/lib/TuyaAccessory.js +56 -27
- package/lib/TuyaDiscovery.js +52 -19
- package/lib/WledDimmerAccessory.js +703 -0
- package/package.json +1 -1
- package/test/IrrigationSystemAccessory.test.js +446 -0
- package/test/SimpleFanLightAccessory.test.js +299 -0
- package/test/SimpleGarageDoorAccessory.test.js +535 -0
- package/test/TuyaAccessory.protocol.test.js +528 -0
- package/test/getCategory.test.js +65 -0
- package/test/support/mocks.js +30 -2
- package/wiki/Supported-Device-Types.md +194 -7
- package/wiki/User-documented-device-config.md +57 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
name: Publish dev to npm
|
|
2
|
+
|
|
3
|
+
# Publishes every commit on `main` to npm under the `dev` dist-tag.
|
|
4
|
+
#
|
|
5
|
+
# Security model (why a malicious PR cannot steal publishing rights):
|
|
6
|
+
# * No npm token is stored anywhere. Publishing uses npm Trusted
|
|
7
|
+
# Publishing (OIDC): GitHub mints a short-lived, cryptographically
|
|
8
|
+
# signed token that npm only accepts when its claims match THIS repo +
|
|
9
|
+
# THIS workflow file. There is no durable credential to exfiltrate.
|
|
10
|
+
# * This workflow never runs on `pull_request`, only on pushes to `main`
|
|
11
|
+
# (post-merge) and manual dispatch — so fork PRs get nothing.
|
|
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.
|
|
16
|
+
#
|
|
17
|
+
# One-time setup required for this to succeed (see PR/commit notes):
|
|
18
|
+
# 1. npmjs.com -> package Settings -> Trusted Publisher:
|
|
19
|
+
# Publisher: GitHub Actions
|
|
20
|
+
# Organization/user: adrianjagielak
|
|
21
|
+
# Repository: homebridge-tuya-plus
|
|
22
|
+
# Workflow filename: publish-dev.yml
|
|
23
|
+
# Environment: npm-dev
|
|
24
|
+
# 2. GitHub repo Settings -> Environments -> create `npm-dev`,
|
|
25
|
+
# restrict deployment branches to `main` (optional: required reviewer).
|
|
26
|
+
|
|
27
|
+
on:
|
|
28
|
+
push:
|
|
29
|
+
branches: [main]
|
|
30
|
+
workflow_dispatch:
|
|
31
|
+
|
|
32
|
+
# Least privilege by default; only the publish job opts into id-token.
|
|
33
|
+
permissions:
|
|
34
|
+
contents: read
|
|
35
|
+
|
|
36
|
+
# Don't cancel an in-flight publish; let each commit publish its version.
|
|
37
|
+
concurrency:
|
|
38
|
+
group: publish-dev
|
|
39
|
+
cancel-in-progress: false
|
|
40
|
+
|
|
41
|
+
jobs:
|
|
42
|
+
test:
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@v4
|
|
46
|
+
- uses: actions/setup-node@v4
|
|
47
|
+
with:
|
|
48
|
+
node-version: "22"
|
|
49
|
+
- run: npm ci
|
|
50
|
+
- run: npm run lint
|
|
51
|
+
- run: npm run test
|
|
52
|
+
|
|
53
|
+
publish:
|
|
54
|
+
needs: test
|
|
55
|
+
runs-on: ubuntu-latest
|
|
56
|
+
# Bind this environment in repo Settings to the `main` branch so that
|
|
57
|
+
# only main can ever reach the publish step.
|
|
58
|
+
environment: npm-dev
|
|
59
|
+
permissions:
|
|
60
|
+
contents: read # checkout
|
|
61
|
+
id-token: write # OIDC -> npm Trusted Publishing (no stored token)
|
|
62
|
+
steps:
|
|
63
|
+
- uses: actions/checkout@v4
|
|
64
|
+
|
|
65
|
+
- uses: actions/setup-node@v4
|
|
66
|
+
with:
|
|
67
|
+
node-version: "22"
|
|
68
|
+
registry-url: "https://registry.npmjs.org"
|
|
69
|
+
|
|
70
|
+
# Trusted Publishing (OIDC) requires npm >= 11.5.1.
|
|
71
|
+
- name: Ensure OIDC-capable npm
|
|
72
|
+
run: npm install -g npm@latest
|
|
73
|
+
|
|
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
|
+
# Writes package.json on the runner only; nothing is committed.
|
|
83
|
+
- name: Set version
|
|
84
|
+
run: npm version "${{ steps.ver.outputs.version }}" --no-git-tag-version --allow-same-version --ignore-scripts
|
|
85
|
+
|
|
86
|
+
# No NODE_AUTH_TOKEN: npm exchanges the OIDC id-token for a
|
|
87
|
+
# short-lived, package-scoped credential at publish time.
|
|
88
|
+
# --ignore-scripts ensures no lifecycle script runs with that credential.
|
|
89
|
+
# --provenance attaches a signed build attestation (needs a public repo).
|
|
90
|
+
- name: Publish (dev tag)
|
|
91
|
+
run: npm publish --tag dev --provenance --ignore-scripts
|
package/Readme.MD
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
A community-maintained Homebridge plugin for controlling Tuya devices locally over LAN. Control your supported Tuya accessories locally in HomeKit.
|
|
18
18
|
|
|
19
19
|
* [Supported Device Types](#supported-device-types)
|
|
20
|
+
* [Supported Tuya Protocol Versions](#supported-tuya-protocol-versions)
|
|
20
21
|
* [Installation Instructions](#installation-instructions)
|
|
21
22
|
* [Configuration](#configuration)
|
|
22
23
|
* [Known Issues](#known-issues)
|
|
@@ -37,6 +38,7 @@ A community-maintained Homebridge plugin for controlling Tuya devices locally ov
|
|
|
37
38
|
* Fan v2<sup>[7](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md)</sup>
|
|
38
39
|
* Garages<sup>[8](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md#garage-doors)</sup>
|
|
39
40
|
* Heaters<sup>[9](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md)</sup>
|
|
41
|
+
* Irrigation Systems / Sprinklers<sup>[17](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md#irrigation-systems--sprinklers)</sup> (multi-valve, per-zone timers, rain sensor, battery)
|
|
40
42
|
* Lights
|
|
41
43
|
* On/Off<sup>[10](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md)</sup>
|
|
42
44
|
* Brightness<sup>[11](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md#tunable-white-light-bulbs)</sup>
|
|
@@ -48,6 +50,24 @@ A community-maintained Homebridge plugin for controlling Tuya devices locally ov
|
|
|
48
50
|
|
|
49
51
|
Note: Motion, and other sensor types don't behave well with responce requests, so they will not be added.
|
|
50
52
|
|
|
53
|
+
## Supported Tuya Protocol Versions
|
|
54
|
+
|
|
55
|
+
The plugin speaks every published Tuya LAN protocol version:
|
|
56
|
+
|
|
57
|
+
| Tuya LAN protocol | Framing | Encryption | Status |
|
|
58
|
+
|---|---|---|---|
|
|
59
|
+
| 3.1 | `0x55AA` | AES-128-ECB (base64, control only) | ✅ Supported |
|
|
60
|
+
| 3.2 | `0x55AA` | AES-128-ECB | ✅ Supported |
|
|
61
|
+
| 3.3 | `0x55AA` | AES-128-ECB | ✅ Supported |
|
|
62
|
+
| 3.4 | `0x55AA` | AES-128-ECB, session key, HMAC-SHA256 | ✅ Supported |
|
|
63
|
+
| 3.5 | `0x6699` | AES-128-GCM, session key | ✅ Supported |
|
|
64
|
+
| 3.6 and newer | — | — | ✅ Forward-compatible (see below) |
|
|
65
|
+
|
|
66
|
+
The protocol version is auto-detected from the device's discovery broadcast, so you normally don't need to configure anything.
|
|
67
|
+
|
|
68
|
+
Protocol **3.5** is currently the newest Tuya LAN protocol in existence: it is the latest version implemented by Tuya's own open-source device SDK ([TuyaOpen](https://github.com/tuya/TuyaOpen)) and by the reference reverse-engineered implementations ([tinytuya](https://github.com/jasonacox/tinytuya/blob/master/PROTOCOL.md)). No device firmware speaking a "3.6" protocol has been observed in the wild so far.
|
|
69
|
+
|
|
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).
|
|
51
71
|
|
|
52
72
|
## Installation Instructions
|
|
53
73
|
|
|
@@ -61,6 +81,19 @@ Search for "Tuya" in [homebridge-config-ui-x](https://github.com/oznu/homebridge
|
|
|
61
81
|
sudo npm install -g homebridge-tuya-plus
|
|
62
82
|
```
|
|
63
83
|
|
|
84
|
+
#### Bleeding-edge (`dev`) builds:
|
|
85
|
+
|
|
86
|
+
Every commit to `main` is automatically published to npm under the `dev` tag — a
|
|
87
|
+
separate, unstable channel. The stable release always stays on `latest`, so this
|
|
88
|
+
won't affect normal installs. To try the latest in-development build:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
sudo npm install -g homebridge-tuya-plus@dev
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
These are versioned like `3.13.1-dev.<n>` and may be unstable; use the default
|
|
95
|
+
install above for production.
|
|
96
|
+
|
|
64
97
|
## Configuration
|
|
65
98
|
> UI
|
|
66
99
|
|
package/config.schema.json
CHANGED
|
@@ -70,6 +70,10 @@
|
|
|
70
70
|
"title": "Heat Convector",
|
|
71
71
|
"enum": ["Convector"]
|
|
72
72
|
},
|
|
73
|
+
{
|
|
74
|
+
"title": "WLED Dimmer",
|
|
75
|
+
"enum": ["WledDimmer"]
|
|
76
|
+
},
|
|
73
77
|
{
|
|
74
78
|
"title": "Simple Dimmer",
|
|
75
79
|
"enum": ["SimpleDimmer"]
|
|
@@ -82,6 +86,10 @@
|
|
|
82
86
|
"title": "Garage Door",
|
|
83
87
|
"enum": ["GarageDoor"]
|
|
84
88
|
},
|
|
89
|
+
{
|
|
90
|
+
"title": "Simple Garage Door (Open/Stop/Close)",
|
|
91
|
+
"enum": ["SimpleGarageDoor"]
|
|
92
|
+
},
|
|
85
93
|
{
|
|
86
94
|
"title": "Simple Blinds",
|
|
87
95
|
"enum": ["SimpleBlinds"]
|
|
@@ -105,6 +113,10 @@
|
|
|
105
113
|
{
|
|
106
114
|
"title": "Air Purifier",
|
|
107
115
|
"enum": ["AirPurifier"]
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"title": "Irrigation System (Multi-Valve / Sprinkler)",
|
|
119
|
+
"enum": ["IrrigationSystem"]
|
|
108
120
|
}
|
|
109
121
|
]
|
|
110
122
|
},
|
|
@@ -137,6 +149,24 @@
|
|
|
137
149
|
"functionBody": "return model.devices && model.devices[arrayIndices].type !== 'null';"
|
|
138
150
|
}
|
|
139
151
|
},
|
|
152
|
+
"version": {
|
|
153
|
+
"title": "Tuya protocol version",
|
|
154
|
+
"type": "string",
|
|
155
|
+
"placeholder": "e.g. 3.3",
|
|
156
|
+
"description": "Leave empty to auto-detect. Used when the device cannot be discovered (e.g. fixed IP across subnets). Supported: 3.1, 3.2, 3.3, 3.4, 3.5 (newer reported versions such as 3.6 are handled with the 3.5 stack automatically).",
|
|
157
|
+
"condition": {
|
|
158
|
+
"functionBody": "return model.devices && model.devices[arrayIndices].type !== 'null';"
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
"forceVersion": {
|
|
162
|
+
"title": "Force Tuya protocol version",
|
|
163
|
+
"type": "string",
|
|
164
|
+
"placeholder": "e.g. 3.5",
|
|
165
|
+
"description": "Overrides the protocol version even when the device broadcasts a different one. Only set this if a device misbehaves with its auto-detected version.",
|
|
166
|
+
"condition": {
|
|
167
|
+
"functionBody": "return model.devices && model.devices[arrayIndices].type !== 'null';"
|
|
168
|
+
}
|
|
169
|
+
},
|
|
140
170
|
"manufacturer": {
|
|
141
171
|
"type": "string",
|
|
142
172
|
"description": "Anything you'd like to use to help identify this device.",
|
|
@@ -204,16 +234,24 @@
|
|
|
204
234
|
"type": "integer",
|
|
205
235
|
"placeholder": "1",
|
|
206
236
|
"condition": {
|
|
207
|
-
"functionBody": "return model.devices && model.devices[arrayIndices] && ['Outlet', 'TWLight', 'RGBTWLight', 'SimpleDimmer','RGBTWOutlet'].includes(model.devices[arrayIndices].type);"
|
|
237
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['Outlet', 'TWLight', 'RGBTWLight', 'WledDimmer', 'SimpleDimmer','RGBTWOutlet'].includes(model.devices[arrayIndices].type);"
|
|
208
238
|
}
|
|
209
239
|
},
|
|
210
240
|
"dpBrightness": {
|
|
211
241
|
"type": "integer",
|
|
212
242
|
"placeholder": "2",
|
|
213
243
|
"condition": {
|
|
214
|
-
"functionBody": "return model.devices && model.devices[arrayIndices] && ['TWLight', 'RGBTWLight', 'SimpleDimmer','RGBTWOutlet', 'FanLight'].includes(model.devices[arrayIndices].type);"
|
|
244
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['TWLight', 'RGBTWLight', 'WledDimmer', 'SimpleDimmer','RGBTWOutlet', 'FanLight'].includes(model.devices[arrayIndices].type);"
|
|
215
245
|
}
|
|
216
246
|
},
|
|
247
|
+
"syncBrightnessToWled": {
|
|
248
|
+
"type": "string",
|
|
249
|
+
"title": "Sync brightness to WLED (IP[:port])",
|
|
250
|
+
"placeholder": "192.168.1.100",
|
|
251
|
+
"condition": {
|
|
252
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['WledDimmer', 'SimpleDimmer'].includes(model.devices[arrayIndices].type);"
|
|
253
|
+
}
|
|
254
|
+
},
|
|
217
255
|
"dpColorTemperature": {
|
|
218
256
|
"type": "integer",
|
|
219
257
|
"placeholder": "3",
|
|
@@ -225,14 +263,14 @@
|
|
|
225
263
|
"type": "integer",
|
|
226
264
|
"placeholder": "140",
|
|
227
265
|
"condition": {
|
|
228
|
-
"functionBody": "return model.devices && model.devices[arrayIndices] && ['TWLight', 'RGBTWLight','RGBTWOutlet'].includes(model.devices[arrayIndices].type);"
|
|
266
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['TWLight', 'RGBTWLight','RGBTWOutlet', 'FanLight'].includes(model.devices[arrayIndices].type);"
|
|
229
267
|
}
|
|
230
268
|
},
|
|
231
269
|
"maxWhiteColor": {
|
|
232
270
|
"type": "integer",
|
|
233
271
|
"placeholder": "400",
|
|
234
272
|
"condition": {
|
|
235
|
-
"functionBody": "return model.devices && model.devices[arrayIndices] && ['TWLight', 'RGBTWLight','RGBTWOutlet'].includes(model.devices[arrayIndices].type);"
|
|
273
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['TWLight', 'RGBTWLight','RGBTWOutlet', 'FanLight'].includes(model.devices[arrayIndices].type);"
|
|
236
274
|
}
|
|
237
275
|
},
|
|
238
276
|
"dpMode": {
|
|
@@ -256,11 +294,18 @@
|
|
|
256
294
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['RGBTWLight','RGBTWOutlet'].includes(model.devices[arrayIndices].type);"
|
|
257
295
|
}
|
|
258
296
|
},
|
|
297
|
+
"minBrightness": {
|
|
298
|
+
"type": "integer",
|
|
299
|
+
"placeholder": "1",
|
|
300
|
+
"condition": {
|
|
301
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['FanLight','WledDimmer', 'SimpleDimmer'].includes(model.devices[arrayIndices].type);"
|
|
302
|
+
}
|
|
303
|
+
},
|
|
259
304
|
"scaleBrightness": {
|
|
260
305
|
"type": "integer",
|
|
261
306
|
"placeholder": "255",
|
|
262
307
|
"condition": {
|
|
263
|
-
"functionBody": "return model.devices && model.devices[arrayIndices] && ['
|
|
308
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['RGBTWLight','RGBTWOutlet','FanLight','WledDimmer', 'SimpleDimmer'].includes(model.devices[arrayIndices].type);"
|
|
264
309
|
}
|
|
265
310
|
},
|
|
266
311
|
"scaleWhiteColor": {
|
|
@@ -418,6 +463,15 @@
|
|
|
418
463
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['Fan', 'FanLight'].includes(model.devices[arrayIndices].type);"
|
|
419
464
|
}
|
|
420
465
|
},
|
|
466
|
+
"singleDpWrites": {
|
|
467
|
+
"title": "Send fan commands one DP at a time",
|
|
468
|
+
"description": "Enable if turning the fan on or changing its speed is silently ignored. Some fan firmwares reject LAN packets that carry more than one data point, so the fan on/off and speed are sent as separate packets instead of combined.",
|
|
469
|
+
"type": "boolean",
|
|
470
|
+
"placeholder": false,
|
|
471
|
+
"condition": {
|
|
472
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['FanLight'].includes(model.devices[arrayIndices].type);"
|
|
473
|
+
}
|
|
474
|
+
},
|
|
421
475
|
"dpChildLock": {
|
|
422
476
|
"type": "integer",
|
|
423
477
|
"placeholder": "6",
|
|
@@ -493,6 +547,61 @@
|
|
|
493
547
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['GarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
494
548
|
}
|
|
495
549
|
},
|
|
550
|
+
"dpOpen": {
|
|
551
|
+
"type": "integer",
|
|
552
|
+
"placeholder": "101",
|
|
553
|
+
"description": "Datapoint identifier for the open action.",
|
|
554
|
+
"condition": {
|
|
555
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
"dpClose": {
|
|
559
|
+
"type": "integer",
|
|
560
|
+
"placeholder": "102",
|
|
561
|
+
"description": "Datapoint identifier for the close action.",
|
|
562
|
+
"condition": {
|
|
563
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
"dpStop": {
|
|
567
|
+
"type": "integer",
|
|
568
|
+
"placeholder": "103",
|
|
569
|
+
"description": "Datapoint identifier for the stop action (used only for the partial-open feature).",
|
|
570
|
+
"condition": {
|
|
571
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
"dpState": {
|
|
575
|
+
"type": "integer",
|
|
576
|
+
"placeholder": "105",
|
|
577
|
+
"description": "Datapoint identifier for the reported state. Values 11 (stopped) and 12 (opening/open) are treated as OPEN; 13 (closing/closed) is treated as CLOSED.",
|
|
578
|
+
"condition": {
|
|
579
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
580
|
+
}
|
|
581
|
+
},
|
|
582
|
+
"stopBeforeCloseMs": {
|
|
583
|
+
"type": "integer",
|
|
584
|
+
"placeholder": "1500",
|
|
585
|
+
"description": "Optional. The controller ignores a close command while the gate is actively moving, so unless the state DP already reads 11 (stopped) the plugin sends stop, waits this many milliseconds, then sends close. Tune to roughly how long the gate takes to halt after a stop. Default 1500.",
|
|
586
|
+
"condition": {
|
|
587
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
588
|
+
}
|
|
589
|
+
},
|
|
590
|
+
"partialOpenMs": {
|
|
591
|
+
"type": "integer",
|
|
592
|
+
"placeholder": "2000",
|
|
593
|
+
"description": "Optional. If set, exposes an extra stateful switch that mirrors whether the gate is currently open. Tapping it ON triggers a partial-open (the gate opens and then stops itself this many milliseconds later, leaving the gate partially open). Tapping it OFF triggers a standard full close. Leave empty to skip the switch.",
|
|
594
|
+
"condition": {
|
|
595
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
"forceSwitches": {
|
|
599
|
+
"type": "boolean",
|
|
600
|
+
"description": "Optional. Exposes extra Force Open and Force Close momentary switches alongside the main GarageDoorOpener. They fire the same open/close actions as the main toggle, but as plain switches they can be used in HomeKit automations (which won't accept GarageDoorOpener targets directly).",
|
|
601
|
+
"condition": {
|
|
602
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
603
|
+
}
|
|
604
|
+
},
|
|
496
605
|
"flipState": {
|
|
497
606
|
"type": "boolean",
|
|
498
607
|
"condition": {
|
|
@@ -534,10 +643,160 @@
|
|
|
534
643
|
"condition": {
|
|
535
644
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleBlinds'].includes(model.devices[arrayIndices].type);"
|
|
536
645
|
}
|
|
646
|
+
},
|
|
647
|
+
"valveCount": {
|
|
648
|
+
"type": "integer",
|
|
649
|
+
"title": "Number of Valves / Zones",
|
|
650
|
+
"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.",
|
|
652
|
+
"condition": {
|
|
653
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type);"
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
"valves": {
|
|
657
|
+
"type": "array",
|
|
658
|
+
"title": "Valves / Zones (advanced)",
|
|
659
|
+
"orderable": false,
|
|
660
|
+
"description": "Optional. Define each zone explicitly to set custom names, data-points and default run times. Overrides 'Number of Valves'.",
|
|
661
|
+
"items": {
|
|
662
|
+
"type": "object",
|
|
663
|
+
"properties": {
|
|
664
|
+
"name": { "type": "string", "title": "Zone name", "placeholder": "Lawn" },
|
|
665
|
+
"dp": { "type": "integer", "title": "Data-point", "placeholder": 1 },
|
|
666
|
+
"defaultDuration": { "type": "integer", "title": "Default run time (seconds, 0 = run indefinitely)", "placeholder": 600 }
|
|
667
|
+
}
|
|
668
|
+
},
|
|
669
|
+
"condition": {
|
|
670
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type);"
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
"defaultDuration": {
|
|
674
|
+
"type": "integer",
|
|
675
|
+
"title": "Default Run Time (seconds)",
|
|
676
|
+
"placeholder": 600,
|
|
677
|
+
"description": "Default per-zone run time used until you change it in the Home app. Set to 0 to make zones run indefinitely (until turned off) by default.",
|
|
678
|
+
"condition": {
|
|
679
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type);"
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
"maxDuration": {
|
|
683
|
+
"type": "integer",
|
|
684
|
+
"title": "Maximum Run Time (seconds)",
|
|
685
|
+
"placeholder": 7200,
|
|
686
|
+
"description": "Upper bound advertised to HomeKit for the duration picker (HAP default is 3600 / 1h). Apple's Home app only offers presets up to 1h regardless; longer values can be set from the config or apps like Eve.",
|
|
687
|
+
"condition": {
|
|
688
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type);"
|
|
689
|
+
}
|
|
690
|
+
},
|
|
691
|
+
"masterTurnsOnAllZones": {
|
|
692
|
+
"type": "boolean",
|
|
693
|
+
"title": "Master switch turns ON all zones",
|
|
694
|
+
"placeholder": true,
|
|
695
|
+
"description": "When you switch the whole irrigation system on (the main tile), open every zone in a single command. Disable to make the system switch a passive 'enabled' indicator.",
|
|
696
|
+
"condition": {
|
|
697
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type);"
|
|
698
|
+
}
|
|
699
|
+
},
|
|
700
|
+
"masterTurnsOffAllZones": {
|
|
701
|
+
"type": "boolean",
|
|
702
|
+
"title": "Master switch turns OFF all zones",
|
|
703
|
+
"placeholder": true,
|
|
704
|
+
"description": "When you switch the whole irrigation system off, close every open zone in a single command.",
|
|
705
|
+
"condition": {
|
|
706
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type);"
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
"commandDebounce": {
|
|
710
|
+
"type": "integer",
|
|
711
|
+
"title": "Command Debounce (ms)",
|
|
712
|
+
"placeholder": 500,
|
|
713
|
+
"description": "Zone changes that happen within this window are merged into one Tuya command — useful for this kind of laggy, battery-powered device. The Home app updates optimistically, so this delay isn't visible in the UI.",
|
|
714
|
+
"condition": {
|
|
715
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type);"
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
"noBattery": {
|
|
719
|
+
"type": "boolean",
|
|
720
|
+
"title": "No battery service",
|
|
721
|
+
"description": "Disable the battery service (use if your controller is mains-powered or doesn't report 'battery_percentage').",
|
|
722
|
+
"condition": {
|
|
723
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type);"
|
|
724
|
+
}
|
|
725
|
+
},
|
|
726
|
+
"dpBattery": {
|
|
727
|
+
"type": "integer",
|
|
728
|
+
"placeholder": 46,
|
|
729
|
+
"title": "Battery data-point",
|
|
730
|
+
"condition": {
|
|
731
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type) && !model.devices[arrayIndices].noBattery;"
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
"lowBatteryThreshold": {
|
|
735
|
+
"type": "integer",
|
|
736
|
+
"placeholder": 20,
|
|
737
|
+
"title": "Low-battery threshold (%)",
|
|
738
|
+
"description": "At or below this level HomeKit shows a low-battery warning.",
|
|
739
|
+
"condition": {
|
|
740
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type) && !model.devices[arrayIndices].noBattery;"
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
"dpCharging": {
|
|
744
|
+
"type": "integer",
|
|
745
|
+
"placeholder": 101,
|
|
746
|
+
"title": "Charging-status data-point",
|
|
747
|
+
"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
|
+
"condition": {
|
|
749
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type) && !model.devices[arrayIndices].noBattery;"
|
|
750
|
+
}
|
|
751
|
+
},
|
|
752
|
+
"noRainSensor": {
|
|
753
|
+
"type": "boolean",
|
|
754
|
+
"title": "No rain sensor",
|
|
755
|
+
"description": "Disable the rain sensor (use if your controller doesn't report 'rain_sensor_state').",
|
|
756
|
+
"condition": {
|
|
757
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type);"
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
"rainSensorType": {
|
|
761
|
+
"type": "string",
|
|
762
|
+
"title": "Rain sensor type",
|
|
763
|
+
"default": "contact",
|
|
764
|
+
"oneOf": [
|
|
765
|
+
{ "title": "Contact sensor (Open / Closed)", "enum": ["contact"] },
|
|
766
|
+
{ "title": "Leak sensor (Leak Detected / Dry — note: fires critical alerts)", "enum": ["leak"] }
|
|
767
|
+
],
|
|
768
|
+
"condition": {
|
|
769
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type) && !model.devices[arrayIndices].noRainSensor;"
|
|
770
|
+
}
|
|
771
|
+
},
|
|
772
|
+
"rainInverted": {
|
|
773
|
+
"type": "boolean",
|
|
774
|
+
"title": "Invert rain sensor",
|
|
775
|
+
"description": "Flip the reported state if 'raining' and 'dry' appear reversed in HomeKit.",
|
|
776
|
+
"condition": {
|
|
777
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type) && !model.devices[arrayIndices].noRainSensor;"
|
|
778
|
+
}
|
|
779
|
+
},
|
|
780
|
+
"dpRain": {
|
|
781
|
+
"type": "integer",
|
|
782
|
+
"placeholder": 49,
|
|
783
|
+
"title": "Rain sensor data-point",
|
|
784
|
+
"condition": {
|
|
785
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type) && !model.devices[arrayIndices].noRainSensor;"
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
"rainOnValue": {
|
|
789
|
+
"type": "string",
|
|
790
|
+
"placeholder": "rain",
|
|
791
|
+
"title": "Rain sensor 'raining' value",
|
|
792
|
+
"description": "The enum value the device reports when it is raining (default 'rain').",
|
|
793
|
+
"condition": {
|
|
794
|
+
"functionBody": "return model.devices && model.devices[arrayIndices] && ['IrrigationSystem'].includes(model.devices[arrayIndices].type) && !model.devices[arrayIndices].noRainSensor;"
|
|
795
|
+
}
|
|
537
796
|
}
|
|
538
797
|
}
|
|
539
798
|
}
|
|
540
799
|
}
|
|
541
800
|
}
|
|
542
801
|
}
|
|
543
|
-
}
|
|
802
|
+
}
|
package/index.js
CHANGED
|
@@ -13,7 +13,8 @@ const AirPurifierAccessory = require('./lib/AirPurifierAccessory');
|
|
|
13
13
|
const DehumidifierAccessory = require('./lib/DehumidifierAccessory');
|
|
14
14
|
const ConvectorAccessory = require('./lib/ConvectorAccessory');
|
|
15
15
|
const GarageDoorAccessory = require('./lib/GarageDoorAccessory');
|
|
16
|
-
const
|
|
16
|
+
const SimpleGarageDoorAccessory = require('./lib/SimpleGarageDoorAccessory');
|
|
17
|
+
const WledDimmerAccessory = require('./lib/WledDimmerAccessory');
|
|
17
18
|
const SimpleDimmer2Accessory = require('./lib/SimpleDimmer2Accessory');
|
|
18
19
|
const SimpleBlindsAccessory = require('./lib/SimpleBlindsAccessory');
|
|
19
20
|
const SimpleHeaterAccessory = require('./lib/SimpleHeaterAccessory');
|
|
@@ -21,6 +22,7 @@ const SimpleFanAccessory = require('./lib/SimpleFanAccessory');
|
|
|
21
22
|
const SimpleFanLightAccessory = require('./lib/SimpleFanLightAccessory');
|
|
22
23
|
const SwitchAccessory = require('./lib/SwitchAccessory');
|
|
23
24
|
const ValveAccessory = require('./lib/ValveAccessory');
|
|
25
|
+
const IrrigationSystemAccessory = require('./lib/IrrigationSystemAccessory');
|
|
24
26
|
const OilDiffuserAccessory = require('./lib/OilDiffuserAccessory');
|
|
25
27
|
const DoorbellAccessory = require('./lib/DoorbellAccessory');
|
|
26
28
|
const VerticalBlindsWithTilt = require('./lib/VerticalBlindsWithTilt');
|
|
@@ -47,7 +49,9 @@ const CLASS_DEF = {
|
|
|
47
49
|
dehumidifier: DehumidifierAccessory,
|
|
48
50
|
convector: ConvectorAccessory,
|
|
49
51
|
garagedoor: GarageDoorAccessory,
|
|
50
|
-
|
|
52
|
+
simplegaragedoor: SimpleGarageDoorAccessory,
|
|
53
|
+
simpledimmer: WledDimmerAccessory,
|
|
54
|
+
wleddimmer: WledDimmerAccessory,
|
|
51
55
|
simpledimmer2: SimpleDimmer2Accessory,
|
|
52
56
|
simpleblinds: SimpleBlindsAccessory,
|
|
53
57
|
simpleheater: SimpleHeaterAccessory,
|
|
@@ -55,6 +59,7 @@ const CLASS_DEF = {
|
|
|
55
59
|
fan: SimpleFanAccessory,
|
|
56
60
|
fanlight: SimpleFanLightAccessory,
|
|
57
61
|
watervalve: ValveAccessory,
|
|
62
|
+
irrigationsystem: IrrigationSystemAccessory,
|
|
58
63
|
oildiffuser: OilDiffuserAccessory,
|
|
59
64
|
doorbell: DoorbellAccessory,
|
|
60
65
|
verticalblindswithtilt: VerticalBlindsWithTilt,
|
|
@@ -125,8 +130,12 @@ class TuyaLan {
|
|
|
125
130
|
|
|
126
131
|
this.log.info('Discovered %s (%s) identified as %s (%s)', devices[config.id].name, config.id, devices[config.id].type, config.version);
|
|
127
132
|
|
|
133
|
+
// The version broadcast by the device wins over a configured `version`,
|
|
134
|
+
// but `forceVersion` overrides everything (e.g. to pin a device that
|
|
135
|
+
// reports a newer protocol, like 3.6, to a specific stack).
|
|
128
136
|
const device = new TuyaAccessory({
|
|
129
137
|
...devices[config.id], ...config,
|
|
138
|
+
...(devices[config.id].forceVersion ? {version: devices[config.id].forceVersion} : {}),
|
|
130
139
|
log: this.log,
|
|
131
140
|
UUID: UUID.generate(UUID_SEED + ':' + config.id),
|
|
132
141
|
connect: false
|
|
@@ -154,6 +163,7 @@ class TuyaLan {
|
|
|
154
163
|
|
|
155
164
|
const device = new TuyaAccessory({
|
|
156
165
|
...devices[deviceId],
|
|
166
|
+
...(devices[deviceId].forceVersion ? {version: devices[deviceId].forceVersion} : {}),
|
|
157
167
|
log: this.log,
|
|
158
168
|
UUID: UUID.generate(UUID_SEED + ':' + deviceId),
|
|
159
169
|
connect: false
|
|
@@ -210,14 +220,22 @@ class TuyaLan {
|
|
|
210
220
|
let accessory = this.cachedAccessories.get(deviceConfig.UUID),
|
|
211
221
|
isCached = true;
|
|
212
222
|
|
|
213
|
-
|
|
214
|
-
|
|
223
|
+
const expectedCategory = Accessory.getCategory(Categories);
|
|
224
|
+
|
|
225
|
+
// Only treat a cached accessory as a "different type" when we actually
|
|
226
|
+
// have a category to compare against. If getCategory() resolves to
|
|
227
|
+
// undefined (e.g. an unknown HAP category constant), HomeKit stores the
|
|
228
|
+
// accessory as Categories.OTHER, so an undefined expectation would never
|
|
229
|
+
// match and we would needlessly unregister & recreate the accessory on
|
|
230
|
+
// every restart — wiping its HomeKit identity (name, room, automations).
|
|
231
|
+
if (accessory && expectedCategory !== undefined && accessory.category !== expectedCategory) {
|
|
232
|
+
this.log.info("%s has a different type (%s vs %s)", accessory.displayName, accessory.category, expectedCategory);
|
|
215
233
|
this.removeAccessory(accessory);
|
|
216
234
|
accessory = null;
|
|
217
235
|
}
|
|
218
236
|
|
|
219
237
|
if (!accessory) {
|
|
220
|
-
accessory = new PlatformAccessory(deviceConfig.name, deviceConfig.UUID,
|
|
238
|
+
accessory = new PlatformAccessory(deviceConfig.name, deviceConfig.UUID, expectedCategory);
|
|
221
239
|
accessory.getService(Service.AccessoryInformation)
|
|
222
240
|
.setCharacteristic(Characteristic.Manufacturer, deviceConfig.manufacturer || "Unknown")
|
|
223
241
|
.setCharacteristic(Characteristic.Model, deviceConfig.model || "Unknown")
|
package/lib/BaseAccessory.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
class BaseAccessory {
|
|
2
|
+
// Default HomeKit category. Subclasses should override with a real HAP
|
|
3
|
+
// category constant; falling back to OTHER keeps a forgotten override from
|
|
4
|
+
// throwing and from churning the accessory on every restart (see issue #45).
|
|
5
|
+
static getCategory(Categories) {
|
|
6
|
+
return Categories.OTHER;
|
|
7
|
+
}
|
|
8
|
+
|
|
2
9
|
constructor(...props) {
|
|
3
10
|
let isNew;
|
|
4
11
|
[this.platform, this.accessory, this.device, isNew = true] = [...props];
|