homebridge-tuya-plus 3.13.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 +231 -12
- package/index.js +21 -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 +178 -262
- 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 +258 -538
- 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 +167 -22
- 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"]
|
|
@@ -109,6 +113,10 @@
|
|
|
109
113
|
{
|
|
110
114
|
"title": "Air Purifier",
|
|
111
115
|
"enum": ["AirPurifier"]
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"title": "Irrigation System (Multi-Valve / Sprinkler)",
|
|
119
|
+
"enum": ["IrrigationSystem"]
|
|
112
120
|
}
|
|
113
121
|
]
|
|
114
122
|
},
|
|
@@ -141,6 +149,24 @@
|
|
|
141
149
|
"functionBody": "return model.devices && model.devices[arrayIndices].type !== 'null';"
|
|
142
150
|
}
|
|
143
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
|
+
},
|
|
144
170
|
"manufacturer": {
|
|
145
171
|
"type": "string",
|
|
146
172
|
"description": "Anything you'd like to use to help identify this device.",
|
|
@@ -208,16 +234,24 @@
|
|
|
208
234
|
"type": "integer",
|
|
209
235
|
"placeholder": "1",
|
|
210
236
|
"condition": {
|
|
211
|
-
"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);"
|
|
212
238
|
}
|
|
213
239
|
},
|
|
214
240
|
"dpBrightness": {
|
|
215
241
|
"type": "integer",
|
|
216
242
|
"placeholder": "2",
|
|
217
243
|
"condition": {
|
|
218
|
-
"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);"
|
|
219
245
|
}
|
|
220
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
|
+
},
|
|
221
255
|
"dpColorTemperature": {
|
|
222
256
|
"type": "integer",
|
|
223
257
|
"placeholder": "3",
|
|
@@ -229,14 +263,14 @@
|
|
|
229
263
|
"type": "integer",
|
|
230
264
|
"placeholder": "140",
|
|
231
265
|
"condition": {
|
|
232
|
-
"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);"
|
|
233
267
|
}
|
|
234
268
|
},
|
|
235
269
|
"maxWhiteColor": {
|
|
236
270
|
"type": "integer",
|
|
237
271
|
"placeholder": "400",
|
|
238
272
|
"condition": {
|
|
239
|
-
"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);"
|
|
240
274
|
}
|
|
241
275
|
},
|
|
242
276
|
"dpMode": {
|
|
@@ -260,11 +294,18 @@
|
|
|
260
294
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['RGBTWLight','RGBTWOutlet'].includes(model.devices[arrayIndices].type);"
|
|
261
295
|
}
|
|
262
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
|
+
},
|
|
263
304
|
"scaleBrightness": {
|
|
264
305
|
"type": "integer",
|
|
265
306
|
"placeholder": "255",
|
|
266
307
|
"condition": {
|
|
267
|
-
"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);"
|
|
268
309
|
}
|
|
269
310
|
},
|
|
270
311
|
"scaleWhiteColor": {
|
|
@@ -422,6 +463,15 @@
|
|
|
422
463
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['Fan', 'FanLight'].includes(model.devices[arrayIndices].type);"
|
|
423
464
|
}
|
|
424
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
|
+
},
|
|
425
475
|
"dpChildLock": {
|
|
426
476
|
"type": "integer",
|
|
427
477
|
"placeholder": "6",
|
|
@@ -499,21 +549,40 @@
|
|
|
499
549
|
},
|
|
500
550
|
"dpOpen": {
|
|
501
551
|
"type": "integer",
|
|
502
|
-
"placeholder": "
|
|
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.",
|
|
503
562
|
"condition": {
|
|
504
563
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
505
564
|
}
|
|
506
565
|
},
|
|
507
566
|
"dpStop": {
|
|
508
567
|
"type": "integer",
|
|
509
|
-
"placeholder": "
|
|
568
|
+
"placeholder": "103",
|
|
569
|
+
"description": "Datapoint identifier for the stop action (used only for the partial-open feature).",
|
|
510
570
|
"condition": {
|
|
511
571
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
512
572
|
}
|
|
513
573
|
},
|
|
514
|
-
"
|
|
574
|
+
"dpState": {
|
|
515
575
|
"type": "integer",
|
|
516
|
-
"placeholder": "
|
|
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.",
|
|
517
586
|
"condition": {
|
|
518
587
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
519
588
|
}
|
|
@@ -521,14 +590,14 @@
|
|
|
521
590
|
"partialOpenMs": {
|
|
522
591
|
"type": "integer",
|
|
523
592
|
"placeholder": "2000",
|
|
524
|
-
"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
|
|
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.",
|
|
525
594
|
"condition": {
|
|
526
595
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
527
596
|
}
|
|
528
597
|
},
|
|
529
598
|
"forceSwitches": {
|
|
530
599
|
"type": "boolean",
|
|
531
|
-
"description": "Optional. Exposes extra Force Open and Force Close momentary switches alongside the main GarageDoorOpener. They
|
|
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).",
|
|
532
601
|
"condition": {
|
|
533
602
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleGarageDoor'].includes(model.devices[arrayIndices].type);"
|
|
534
603
|
}
|
|
@@ -574,10 +643,160 @@
|
|
|
574
643
|
"condition": {
|
|
575
644
|
"functionBody": "return model.devices && model.devices[arrayIndices] && ['SimpleBlinds'].includes(model.devices[arrayIndices].type);"
|
|
576
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
|
+
}
|
|
577
796
|
}
|
|
578
797
|
}
|
|
579
798
|
}
|
|
580
799
|
}
|
|
581
800
|
}
|
|
582
801
|
}
|
|
583
|
-
}
|
|
802
|
+
}
|
package/index.js
CHANGED
|
@@ -14,7 +14,7 @@ const DehumidifierAccessory = require('./lib/DehumidifierAccessory');
|
|
|
14
14
|
const ConvectorAccessory = require('./lib/ConvectorAccessory');
|
|
15
15
|
const GarageDoorAccessory = require('./lib/GarageDoorAccessory');
|
|
16
16
|
const SimpleGarageDoorAccessory = require('./lib/SimpleGarageDoorAccessory');
|
|
17
|
-
const
|
|
17
|
+
const WledDimmerAccessory = require('./lib/WledDimmerAccessory');
|
|
18
18
|
const SimpleDimmer2Accessory = require('./lib/SimpleDimmer2Accessory');
|
|
19
19
|
const SimpleBlindsAccessory = require('./lib/SimpleBlindsAccessory');
|
|
20
20
|
const SimpleHeaterAccessory = require('./lib/SimpleHeaterAccessory');
|
|
@@ -22,6 +22,7 @@ const SimpleFanAccessory = require('./lib/SimpleFanAccessory');
|
|
|
22
22
|
const SimpleFanLightAccessory = require('./lib/SimpleFanLightAccessory');
|
|
23
23
|
const SwitchAccessory = require('./lib/SwitchAccessory');
|
|
24
24
|
const ValveAccessory = require('./lib/ValveAccessory');
|
|
25
|
+
const IrrigationSystemAccessory = require('./lib/IrrigationSystemAccessory');
|
|
25
26
|
const OilDiffuserAccessory = require('./lib/OilDiffuserAccessory');
|
|
26
27
|
const DoorbellAccessory = require('./lib/DoorbellAccessory');
|
|
27
28
|
const VerticalBlindsWithTilt = require('./lib/VerticalBlindsWithTilt');
|
|
@@ -49,7 +50,8 @@ const CLASS_DEF = {
|
|
|
49
50
|
convector: ConvectorAccessory,
|
|
50
51
|
garagedoor: GarageDoorAccessory,
|
|
51
52
|
simplegaragedoor: SimpleGarageDoorAccessory,
|
|
52
|
-
simpledimmer:
|
|
53
|
+
simpledimmer: WledDimmerAccessory,
|
|
54
|
+
wleddimmer: WledDimmerAccessory,
|
|
53
55
|
simpledimmer2: SimpleDimmer2Accessory,
|
|
54
56
|
simpleblinds: SimpleBlindsAccessory,
|
|
55
57
|
simpleheater: SimpleHeaterAccessory,
|
|
@@ -57,6 +59,7 @@ const CLASS_DEF = {
|
|
|
57
59
|
fan: SimpleFanAccessory,
|
|
58
60
|
fanlight: SimpleFanLightAccessory,
|
|
59
61
|
watervalve: ValveAccessory,
|
|
62
|
+
irrigationsystem: IrrigationSystemAccessory,
|
|
60
63
|
oildiffuser: OilDiffuserAccessory,
|
|
61
64
|
doorbell: DoorbellAccessory,
|
|
62
65
|
verticalblindswithtilt: VerticalBlindsWithTilt,
|
|
@@ -127,8 +130,12 @@ class TuyaLan {
|
|
|
127
130
|
|
|
128
131
|
this.log.info('Discovered %s (%s) identified as %s (%s)', devices[config.id].name, config.id, devices[config.id].type, config.version);
|
|
129
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).
|
|
130
136
|
const device = new TuyaAccessory({
|
|
131
137
|
...devices[config.id], ...config,
|
|
138
|
+
...(devices[config.id].forceVersion ? {version: devices[config.id].forceVersion} : {}),
|
|
132
139
|
log: this.log,
|
|
133
140
|
UUID: UUID.generate(UUID_SEED + ':' + config.id),
|
|
134
141
|
connect: false
|
|
@@ -156,6 +163,7 @@ class TuyaLan {
|
|
|
156
163
|
|
|
157
164
|
const device = new TuyaAccessory({
|
|
158
165
|
...devices[deviceId],
|
|
166
|
+
...(devices[deviceId].forceVersion ? {version: devices[deviceId].forceVersion} : {}),
|
|
159
167
|
log: this.log,
|
|
160
168
|
UUID: UUID.generate(UUID_SEED + ':' + deviceId),
|
|
161
169
|
connect: false
|
|
@@ -212,14 +220,22 @@ class TuyaLan {
|
|
|
212
220
|
let accessory = this.cachedAccessories.get(deviceConfig.UUID),
|
|
213
221
|
isCached = true;
|
|
214
222
|
|
|
215
|
-
|
|
216
|
-
|
|
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);
|
|
217
233
|
this.removeAccessory(accessory);
|
|
218
234
|
accessory = null;
|
|
219
235
|
}
|
|
220
236
|
|
|
221
237
|
if (!accessory) {
|
|
222
|
-
accessory = new PlatformAccessory(deviceConfig.name, deviceConfig.UUID,
|
|
238
|
+
accessory = new PlatformAccessory(deviceConfig.name, deviceConfig.UUID, expectedCategory);
|
|
223
239
|
accessory.getService(Service.AccessoryInformation)
|
|
224
240
|
.setCharacteristic(Characteristic.Manufacturer, deviceConfig.manufacturer || "Unknown")
|
|
225
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];
|