homebridge-tuya-plus 3.5.2 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/test.yml +23 -0
- package/config.schema.json +7 -7
- package/index.js +3 -1
- package/lib/PercentBlindsAccessory.js +80 -0
- package/package.json +1 -1
- package/test/uuidStability.test.js +75 -0
- package/wiki/Supported-Device-Types.md +38 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: Test Code
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
|
|
15
|
+
- name: Use Node.js
|
|
16
|
+
uses: actions/setup-node@v4
|
|
17
|
+
with:
|
|
18
|
+
node-version: "23"
|
|
19
|
+
|
|
20
|
+
- run: npm ci
|
|
21
|
+
|
|
22
|
+
- name: Run Jest
|
|
23
|
+
run: npm run test
|
package/config.schema.json
CHANGED
|
@@ -7,23 +7,27 @@
|
|
|
7
7
|
"schema": {
|
|
8
8
|
"type": "object",
|
|
9
9
|
"properties": {
|
|
10
|
+
"name": {
|
|
11
|
+
"title": "Name",
|
|
12
|
+
"type": "string",
|
|
13
|
+
"default": "Tuya LAN"
|
|
14
|
+
},
|
|
10
15
|
"discoverTimeout": {
|
|
11
16
|
"title": "Device IP auto-discovery timeout",
|
|
12
17
|
"type": "integer",
|
|
13
18
|
"minimum": 0,
|
|
14
19
|
"placeholder": "Timeout (millisecond) for device IP address discovery",
|
|
15
|
-
"default": 60000
|
|
16
|
-
"required": false
|
|
20
|
+
"default": 60000
|
|
17
21
|
},
|
|
18
22
|
"devices": {
|
|
19
23
|
"type": "array",
|
|
20
24
|
"orderable": false,
|
|
21
25
|
"items": {
|
|
22
26
|
"type": "object",
|
|
27
|
+
"required": ["type", "name", "id", "key"],
|
|
23
28
|
"properties": {
|
|
24
29
|
"type": {
|
|
25
30
|
"type": "string",
|
|
26
|
-
"required": true,
|
|
27
31
|
"default": "null",
|
|
28
32
|
"oneOf": [
|
|
29
33
|
{
|
|
@@ -107,7 +111,6 @@
|
|
|
107
111
|
"name": {
|
|
108
112
|
"type": "string",
|
|
109
113
|
"description": "Anything you'd like to use to identify this device. You can always change the name from within the Home app later.",
|
|
110
|
-
"required": true,
|
|
111
114
|
"condition": {
|
|
112
115
|
"functionBody": "return model.devices && model.devices[arrayIndices].type !== 'null';"
|
|
113
116
|
}
|
|
@@ -116,7 +119,6 @@
|
|
|
116
119
|
"type": "string",
|
|
117
120
|
"title": "Tuya ID",
|
|
118
121
|
"description": "If you don't have the Tuya ID or Key, follow the steps found on the <a href='https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Get-Local-Keys-for-Your-Devices.md' target='_blank'>Setup Instructions</a> page.",
|
|
119
|
-
"required": true,
|
|
120
122
|
"condition": {
|
|
121
123
|
"functionBody": "return model.devices && model.devices[arrayIndices].type !== 'null';"
|
|
122
124
|
}
|
|
@@ -124,7 +126,6 @@
|
|
|
124
126
|
"key": {
|
|
125
127
|
"title": "Tuya Key",
|
|
126
128
|
"type": "string",
|
|
127
|
-
"required": true,
|
|
128
129
|
"condition": {
|
|
129
130
|
"functionBody": "return model.devices && model.devices[arrayIndices].type !== 'null';"
|
|
130
131
|
}
|
|
@@ -132,7 +133,6 @@
|
|
|
132
133
|
"ip": {
|
|
133
134
|
"title": "IP Address",
|
|
134
135
|
"type": "string",
|
|
135
|
-
"required": false,
|
|
136
136
|
"condition": {
|
|
137
137
|
"functionBody": "return model.devices && model.devices[arrayIndices].type !== 'null';"
|
|
138
138
|
}
|
package/index.js
CHANGED
|
@@ -24,6 +24,7 @@ const ValveAccessory = require('./lib/ValveAccessory');
|
|
|
24
24
|
const OilDiffuserAccessory = require('./lib/OilDiffuserAccessory');
|
|
25
25
|
const DoorbellAccessory = require('./lib/DoorbellAccessory');
|
|
26
26
|
const VerticalBlindsWithTilt = require('./lib/VerticalBlindsWithTilt');
|
|
27
|
+
const PercentBlindsAccessory = require('./lib/PercentBlindsAccessory');
|
|
27
28
|
|
|
28
29
|
const PLUGIN_NAME = 'homebridge-tuya-plus';
|
|
29
30
|
const PLATFORM_NAME = 'TuyaLan';
|
|
@@ -56,7 +57,8 @@ const CLASS_DEF = {
|
|
|
56
57
|
watervalve: ValveAccessory,
|
|
57
58
|
oildiffuser: OilDiffuserAccessory,
|
|
58
59
|
doorbell: DoorbellAccessory,
|
|
59
|
-
verticalblindswithtilt: VerticalBlindsWithTilt
|
|
60
|
+
verticalblindswithtilt: VerticalBlindsWithTilt,
|
|
61
|
+
percentblinds: PercentBlindsAccessory
|
|
60
62
|
};
|
|
61
63
|
|
|
62
64
|
let Characteristic, Formats, Perms, Categories, PlatformAccessory, Service, AdaptiveLightingController, UUID;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const BaseAccessory = require('./BaseAccessory');
|
|
2
|
+
|
|
3
|
+
class PercentBlindsAccessory extends BaseAccessory {
|
|
4
|
+
static getCategory(Categories) {
|
|
5
|
+
return Categories.WINDOW_COVERING;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
constructor(...props) {
|
|
9
|
+
super(...props);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
_registerPlatformAccessory() {
|
|
13
|
+
const {Service} = this.hap;
|
|
14
|
+
this.accessory.addService(Service.WindowCovering, this.device.context.name);
|
|
15
|
+
super._registerPlatformAccessory();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_registerCharacteristics(dps) {
|
|
19
|
+
const {Service, Characteristic} = this.hap;
|
|
20
|
+
const service = this.accessory.getService(Service.WindowCovering);
|
|
21
|
+
this._checkServiceName(service, this.device.context.name);
|
|
22
|
+
|
|
23
|
+
this.dpPercentControl = this._getCustomDP(this.device.context.dpPercentControl) || '2';
|
|
24
|
+
this.dpPercentState = this._getCustomDP(this.device.context.dpPercentState) || '2';
|
|
25
|
+
this.flipState = !!this.device.context.flipState;
|
|
26
|
+
|
|
27
|
+
const characteristicCurrentPosition = service.getCharacteristic(Characteristic.CurrentPosition)
|
|
28
|
+
.updateValue(this._mapPosition(dps[this.dpPercentState] !== undefined ? dps[this.dpPercentState] : 0))
|
|
29
|
+
.on('get', this.getCurrentPosition.bind(this));
|
|
30
|
+
|
|
31
|
+
const characteristicTargetPosition = service.getCharacteristic(Characteristic.TargetPosition)
|
|
32
|
+
.updateValue(this._mapPosition(dps[this.dpPercentControl] !== undefined ? dps[this.dpPercentControl] : 0))
|
|
33
|
+
.on('get', this.getTargetPosition.bind(this))
|
|
34
|
+
.on('set', this.setTargetPosition.bind(this));
|
|
35
|
+
|
|
36
|
+
service.getCharacteristic(Characteristic.PositionState)
|
|
37
|
+
.updateValue(Characteristic.PositionState.STOPPED)
|
|
38
|
+
.on('get', callback => callback(null, Characteristic.PositionState.STOPPED));
|
|
39
|
+
|
|
40
|
+
this.device.on('change', changes => {
|
|
41
|
+
if (changes.hasOwnProperty(this.dpPercentState)) {
|
|
42
|
+
const position = this._mapPosition(changes[this.dpPercentState]);
|
|
43
|
+
this.log.debug(`[TuyaAccessory] Blind current position updated to ${position}`);
|
|
44
|
+
characteristicCurrentPosition.updateValue(position);
|
|
45
|
+
}
|
|
46
|
+
if (changes.hasOwnProperty(this.dpPercentControl)) {
|
|
47
|
+
const position = this._mapPosition(changes[this.dpPercentControl]);
|
|
48
|
+
this.log.debug(`[TuyaAccessory] Blind target position updated to ${position}`);
|
|
49
|
+
characteristicTargetPosition.updateValue(position);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_mapPosition(value) {
|
|
55
|
+
const position = parseInt(value) || 0;
|
|
56
|
+
return this.flipState ? 100 - position : position;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
getCurrentPosition(callback) {
|
|
60
|
+
this.getState(this.dpPercentState, (err, dp) => {
|
|
61
|
+
if (err) return callback(err);
|
|
62
|
+
callback(null, this._mapPosition(dp));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getTargetPosition(callback) {
|
|
67
|
+
this.getState(this.dpPercentControl, (err, dp) => {
|
|
68
|
+
if (err) return callback(err);
|
|
69
|
+
callback(null, this._mapPosition(dp));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setTargetPosition(value, callback) {
|
|
74
|
+
const position = this._mapPosition(value);
|
|
75
|
+
this.log.debug(`[TuyaAccessory] Setting blind position to ${position}`);
|
|
76
|
+
this.setState(this.dpPercentControl, position, callback);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = PercentBlindsAccessory;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-tuya-plus",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"description": "A community-maintained Homebridge plugin for controlling Tuya devices locally over LAN. Includes new features, fixes, and updated device support.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Regression guard for HomeKit accessory identity.
|
|
4
|
+
//
|
|
5
|
+
// HomeKit identifies accessories by UUID. Every UUID this plugin produces
|
|
6
|
+
// must be derived from the legacy seed 'homebridge-tuya' (the PLUGIN_NAME
|
|
7
|
+
// used in v3.4.0 and earlier) - NOT from the current npm package name
|
|
8
|
+
// 'homebridge-tuya-plus'. v3.5.0 broke this and reset every device in
|
|
9
|
+
// HomeKit; v3.5.1 restored it.
|
|
10
|
+
//
|
|
11
|
+
// These tests fail loudly if anyone "cleans up" the seed back to PLUGIN_NAME.
|
|
12
|
+
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const indexSource = fs.readFileSync(path.join(__dirname, '..', 'index.js'), 'utf8');
|
|
18
|
+
|
|
19
|
+
// HAP-NodeJS's uuid.generate algorithm, reproduced here so the test does not
|
|
20
|
+
// require pulling in homebridge or hap-nodejs. Matches
|
|
21
|
+
// https://github.com/homebridge/HAP-NodeJS/blob/master/src/lib/util/uuid.ts
|
|
22
|
+
function hapGenerate(data) {
|
|
23
|
+
const sha1 = crypto.createHash('sha1').update(data).digest('hex');
|
|
24
|
+
let i = -1;
|
|
25
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
26
|
+
i += 1;
|
|
27
|
+
if (c === 'y') return ((parseInt(sha1[i], 16) & 0x3) | 0x8).toString(16);
|
|
28
|
+
return sha1[i];
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('index.js UUID seed constants', () => {
|
|
33
|
+
test("UUID_SEED is 'homebridge-tuya' (legacy v3.4.0 PLUGIN_NAME)", () => {
|
|
34
|
+
expect(indexSource).toMatch(/const\s+UUID_SEED\s*=\s*'homebridge-tuya'\s*;/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("PLUGIN_NAME is 'homebridge-tuya-plus' (current npm package name)", () => {
|
|
38
|
+
expect(indexSource).toMatch(/const\s+PLUGIN_NAME\s*=\s*'homebridge-tuya-plus'\s*;/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('every UUID.generate(...) call uses UUID_SEED, never PLUGIN_NAME', () => {
|
|
42
|
+
const calls = indexSource.match(/UUID\.generate\([^)]*\)/g) || [];
|
|
43
|
+
expect(calls.length).toBeGreaterThan(0);
|
|
44
|
+
for (const call of calls) {
|
|
45
|
+
expect(call).toMatch(/UUID_SEED/);
|
|
46
|
+
expect(call).not.toMatch(/PLUGIN_NAME/);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('UUID stability against v3.4.0', () => {
|
|
52
|
+
// Pinned outputs derived from `homebridge-tuya:<deviceId>` (the v3.4.0
|
|
53
|
+
// seed). If any of these change, every existing user's HomeKit identity
|
|
54
|
+
// resets on upgrade.
|
|
55
|
+
const fixtures = [
|
|
56
|
+
{
|
|
57
|
+
seed: 'homebridge-tuya:bf1234567890abcdef1234',
|
|
58
|
+
uuid: 'f7bd0a51-7bb8-4405-9ea6-d3da81023f2f',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
seed: 'homebridge-tuya:fake:device-001',
|
|
62
|
+
uuid: '4922e84f-cbd0-415f-8ad4-43d7891ace87',
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
test.each(fixtures)('UUID for "$seed" is stable', ({ seed, uuid }) => {
|
|
67
|
+
expect(hapGenerate(seed)).toBe(uuid);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('legacy seed and v3.5.0 broken seed produce different UUIDs', () => {
|
|
71
|
+
const id = 'bf1234567890abcdef1234';
|
|
72
|
+
expect(hapGenerate('homebridge-tuya:' + id))
|
|
73
|
+
.not.toBe(hapGenerate('homebridge-tuya-plus:' + id));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -21,6 +21,7 @@ If you are looking for verified configurations for your specific device, please
|
|
|
21
21
|
|Simple Blinds|`SimpleBlinds`<sup>[11](#simple-blinds)</sup>|Smart blinds and smart switches that control blinds <small>([instructions](#simple-blinds))</small>|
|
|
22
22
|
|Simple Blinds2|`SimpleBlinds2`<sup>[11](#simple-blinds)</sup>|Smart blinds and smart switches that control blinds(Use if simple Blinds (1) doesn't work for you. <small>([instructions](#simple-blinds))</small>|
|
|
23
23
|
|Vertical Blinds with Tilt|`VerticalBlindsWithTilt`<sup>[11](#vertical-blinds-with-tilt)</sup>|Smart vertical blinds with open/close and panel rotation <small>([instructions](#vertical-blinds-with-tilt))</small>|
|
|
24
|
+
|Percent Control Blinds|`PercentBlinds`<sup>[11](#percent-control-blinds)</sup>|Blinds that natively report and accept a percentage position via a `percent_control` datapoint <small>([instructions](#percent-control-blinds))</small>|
|
|
24
25
|
|Smart Plug w/ White and Color Lights|`RGBTWOutlet`<sup>[12](#outlets-with-white-and-color-lights)</sup>|Smart plugs that have controllable RGBTW LEDs <small>([instructions](#outlets-with-white-and-color-lights))</small>|
|
|
25
26
|
|Smart Fan Regulator|`SimpleFanAccessory`<sup>[more](#smart-fan-regulators-and-accessories)</sup>|Smart Fan Regulators that have controllable Speeds <small>([instructions](#smart-fan-regulators-and-accessories))</small>|
|
|
26
27
|
|Smart Fan with Light|`SimpleFanLightAccessory`<sup>[more](#smart-fan-with-light)</sup>|Smart Fan devices that have controllable Speeds, Directions and a built-in Light<small>([instructions](#smart-fan-with-light))</small>|
|
|
@@ -456,6 +457,43 @@ Support for Tuya/Graywind Smart Vertical Blinds with open/close (retract/extend)
|
|
|
456
457
|
}
|
|
457
458
|
```
|
|
458
459
|
|
|
460
|
+
### Percent Control Blinds
|
|
461
|
+
These are blinds or roller shades that natively report their current position and accept a target position as a percentage via a `percent_control` datapoint. Unlike `SimpleBlinds`, no timing calibration is needed — the device reports its actual position directly.
|
|
462
|
+
|
|
463
|
+
#### Minimal Configuration
|
|
464
|
+
```json
|
|
465
|
+
{
|
|
466
|
+
"name": "My Blinds",
|
|
467
|
+
"type": "PercentBlinds",
|
|
468
|
+
"id": "032000123456789abcde",
|
|
469
|
+
"key": "0123456789abcdef"
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
#### Full Configuration
|
|
474
|
+
```json5
|
|
475
|
+
{
|
|
476
|
+
"name": "My Blinds",
|
|
477
|
+
"type": "PercentBlinds",
|
|
478
|
+
"manufacturer": "Tuya",
|
|
479
|
+
"model": "Smart Roller Blind",
|
|
480
|
+
"id": "032000123456789abcde",
|
|
481
|
+
"key": "0123456789abcdef",
|
|
482
|
+
|
|
483
|
+
/* Additional parameters to override defaults only if needed */
|
|
484
|
+
|
|
485
|
+
/* Override the default datapoint identifier for setting target position (0–100) */
|
|
486
|
+
"dpPercentControl": "2",
|
|
487
|
+
|
|
488
|
+
/* Override the default datapoint identifier for reading current position (0–100).
|
|
489
|
+
Use "3" if your device reports position on a separate datapoint from control. */
|
|
490
|
+
"dpPercentState": "2",
|
|
491
|
+
|
|
492
|
+
/* If the device reports 0 as fully open instead of fully closed, flip the range */
|
|
493
|
+
"flipState": true
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
459
497
|
### Outlets with White and Color Lights
|
|
460
498
|
These are plugs with a single outlet that that have controllable white and colored LEDs on them.
|
|
461
499
|
|