homebridge-tuya-plus 4.0.0 → 4.1.0-dev.64

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/Changelog.md CHANGED
@@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. This projec
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ * [+] **Garage door: support DASPI "Passage" openers.** Set `"manufacturer": "Daspi"` on a `GarageDoor` device to select its data-points (`dpAction` `1`, `dpStatus` `7`, still overridable). DASPI reports state as strings on DP `7` (`full_opened`/`opening`/`open_softstop`/`full_closed`/`closing`/`close_softstop`) and takes capitalized `Open`/`Close` commands on DP `1`; the soft-stop phases map to HomeKit's Opening/Closing so the tile animates correctly mid-travel.
7
8
  * [+] **Tuya Cloud is now a transparent, global fallback for every device.** What started as an experiment to reach battery-powered "sleepy" irrigation timers (which never appear on the LAN) is now a general, optional fallback: add a top-level `cloud` credentials block once and the plugin keeps a single Tuya Cloud/MQTT session alive in the background, falling back to it whenever a device can't be reached on the LAN — a flaky moment, or hardware that's never local. The plugin stays **LAN-first** (local is always tried first and preferred) and cloud remains **strictly opt-in** (nothing runs unless credentials are present).
8
9
  * **No per-device configuration.** There is one global session and no per-device cloud settings. Every device automatically uses the LAN first and the cloud as a fallback; a device with no local `key` is reached over the cloud only. Existing LAN configs (numeric data-points) keep working over the cloud — the data-point id↔code map is learned from Tuya's device shadow (`/v2.0/cloud/thing/{id}/shadow/properties`) — and if both transports fail, HomeKit shows "No Response" as before. This mirrors the official Tuya/Smart Life app (LAN when possible, cloud as a backup).
9
10
  * 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.
@@ -538,6 +538,23 @@
538
538
  "functionBody": "return model.devices && model.devices[arrayIndices] && ['Fan', 'FanLight'].includes(model.devices[arrayIndices].type);"
539
539
  }
540
540
  },
541
+ "dpSwing": {
542
+ "title": "Swing/oscillation DP",
543
+ "description": "Data point of the fan's oscillation switch (a boolean DP). When set, a Swing (oscillation) control is shown in the Home app. Leave empty if the fan has no oscillation.",
544
+ "type": "integer",
545
+ "condition": {
546
+ "functionBody": "return model.devices && model.devices[arrayIndices] && ['Fan'].includes(model.devices[arrayIndices].type);"
547
+ }
548
+ },
549
+ "noDirection": {
550
+ "title": "Hide the rotation direction control",
551
+ "description": "Enable if the fan has no forward/reverse direction, or if the direction data point is actually a mode enum. Prevents writing forward/reverse into that data point.",
552
+ "type": "boolean",
553
+ "placeholder": false,
554
+ "condition": {
555
+ "functionBody": "return model.devices && model.devices[arrayIndices] && ['Fan'].includes(model.devices[arrayIndices].type);"
556
+ }
557
+ },
541
558
  "maxSpeed": {
542
559
  "type": "integer",
543
560
  "placeholder": "3",
@@ -12,8 +12,16 @@ const GARAGE_DOOR_OPENNING = 'openning';
12
12
  const GARAGE_DOOR_OPENING = 'opening';
13
13
  const GARAGE_DOOR_CLOSING = 'closing';
14
14
 
15
+ // For Daspi garage door
16
+ const GARAGE_DOOR_FULL_OPENED = 'full_opened';
17
+ const GARAGE_DOOR_FULL_CLOSED = 'full_closed';
18
+ const GARAGE_DOOR_OPEN_SOFTSTOP = 'open_softstop';
19
+ const GARAGE_DOOR_CLOSE_SOFTSTOP = 'close_softstop';
20
+ // Daspi garage door appears to have no stopped status
21
+
15
22
  const GARAGE_DOOR_MANUFACTURER_KOGAN = 'Kogan';
16
23
  const GARAGE_DOOR_MANUFACTURER_WOFEA = 'Wofea';
24
+ const GARAGE_DOOR_MANUFACTURER_DASPI = 'Daspi';
17
25
 
18
26
  class GarageDoorAccessory extends BaseAccessory {
19
27
  static getCategory(Categories) {
@@ -50,6 +58,10 @@ class GarageDoorAccessory extends BaseAccessory {
50
58
  return this.manufacturer === GARAGE_DOOR_MANUFACTURER_WOFEA.trim();
51
59
  }
52
60
 
61
+ _isDaspi() {
62
+ return this.manufacturer === GARAGE_DOOR_MANUFACTURER_DASPI.trim();
63
+ }
64
+
53
65
  _registerCharacteristics(dps) {
54
66
  const {Service, Characteristic} = this.hap;
55
67
  const service = this.accessory.getService(Service.GarageDoorOpener);
@@ -59,6 +71,8 @@ class GarageDoorAccessory extends BaseAccessory {
59
71
  this.manufacturer = GARAGE_DOOR_MANUFACTURER_KOGAN.trim();
60
72
  } else if (this.device.context.manufacturer.trim().toLowerCase() === GARAGE_DOOR_MANUFACTURER_WOFEA.trim().toLowerCase()) {
61
73
  this.manufacturer = this.device.context.manufacturer.trim();
74
+ } else if (this.device.context.manufacturer.trim().toLowerCase() === GARAGE_DOOR_MANUFACTURER_DASPI.trim().toLowerCase()) {
75
+ this.manufacturer = GARAGE_DOOR_MANUFACTURER_DASPI.trim();
62
76
  } else if (this.device.context.manufacturer) {
63
77
  this.manufacturer = this.device.context.manufacturer.trim();
64
78
  } else {
@@ -73,6 +87,10 @@ class GarageDoorAccessory extends BaseAccessory {
73
87
  this._debugLog('_registerCharacteristics setting dpAction and dpStatus for ' + GARAGE_DOOR_MANUFACTURER_WOFEA + ' garage door');
74
88
  this.dpAction = this._getCustomDP(this.device.context.dpAction) || '1';
75
89
  this.dpStatus = this._getCustomDP(this.device.context.dpStatus) || '101';
90
+ } else if (this._isDaspi()) {
91
+ this._debugLog('_registerCharacteristics setting dpAction and dpStatus for ' + GARAGE_DOOR_MANUFACTURER_DASPI + ' garage door');
92
+ this.dpAction = this._getCustomDP(this.device.context.dpAction) || '1';
93
+ this.dpStatus = this._getCustomDP(this.device.context.dpStatus) || '7';
76
94
  } else {
77
95
  this._debugLog('_registerCharacteristics setting dpAction and dpStatus for generic door');
78
96
  this.dpAction = this._getCustomDP(this.device.context.dpAction) || '1';
@@ -143,6 +161,23 @@ class GarageDoorAccessory extends BaseAccessory {
143
161
  case GARAGE_DOOR_CLOSING:
144
162
  return this.targetClosed;
145
163
 
164
+ default:
165
+ this._alwaysLog('_getTargetDoorState UNKNOWN STATE ' + JSON.stringify(dp));
166
+ }
167
+ } else if (this._isDaspi()) {
168
+ switch (dp.toLowerCase()) {
169
+ case GARAGE_DOOR_OPEN:
170
+ case GARAGE_DOOR_OPENING:
171
+ case GARAGE_DOOR_OPEN_SOFTSTOP:
172
+ case GARAGE_DOOR_FULL_OPENED:
173
+ return this.targetOpen;
174
+
175
+ case GARAGE_DOOR_CLOSE:
176
+ case GARAGE_DOOR_CLOSING:
177
+ case GARAGE_DOOR_CLOSE_SOFTSTOP:
178
+ case GARAGE_DOOR_FULL_CLOSED:
179
+ return this.targetClosed;
180
+
146
181
  default:
147
182
  this._alwaysLog('_getTargetDoorState UNKNOWN STATE ' + JSON.stringify(dp));
148
183
  }
@@ -161,13 +196,14 @@ class GarageDoorAccessory extends BaseAccessory {
161
196
  this._debugLog('setTargetDoorState value ' + value + ' targetOpen ' + this.targetOpen + ' targetClosed ' + this.targetClosed);
162
197
  let newValue = GARAGE_DOOR_CLOSE;
163
198
 
164
- if (this._isKogan()) {
199
+ if (this._isKogan() || this._isDaspi()) {
200
+ // Daspi expects capitalized command strings ('Open'/'Close'); Kogan uses lowercase.
165
201
  switch (value) {
166
202
  case this.targetOpen:
167
- newValue = GARAGE_DOOR_OPEN;
203
+ newValue = this._isDaspi() ? 'Open' : GARAGE_DOOR_OPEN;
168
204
  break;
169
205
  case this.targetClosed:
170
- newValue = GARAGE_DOOR_CLOSE;
206
+ newValue = this._isDaspi() ? 'Close' : GARAGE_DOOR_CLOSE;
171
207
  break;
172
208
  default:
173
209
  this._alwaysLog('setTargetDoorState UNKNOWN STATE ' + JSON.stringify(value));
@@ -210,6 +246,25 @@ class GarageDoorAccessory extends BaseAccessory {
210
246
  default:
211
247
  this._alwaysLog('_getCurrentDoorState UNKNOWN STATUS ' + JSON.stringify(dpStatusValue));
212
248
  }
249
+ } else if (this._isDaspi()) {
250
+ switch (dpStatusValue.toLowerCase()) {
251
+ case GARAGE_DOOR_FULL_OPENED:
252
+ return this.currentOpen;
253
+
254
+ case GARAGE_DOOR_OPEN_SOFTSTOP:
255
+ case GARAGE_DOOR_OPENING:
256
+ return this.currentOpening;
257
+
258
+ case GARAGE_DOOR_CLOSE_SOFTSTOP:
259
+ case GARAGE_DOOR_CLOSING:
260
+ return this.currentClosing;
261
+
262
+ case GARAGE_DOOR_FULL_CLOSED:
263
+ return this.currentClosed;
264
+
265
+ default:
266
+ this._alwaysLog('_getCurrentDoorState UNKNOWN STATUS ' + JSON.stringify(dpStatusValue));
267
+ }
213
268
  } else {
214
269
  if (dpStatusValue === true) {
215
270
  return this.currentOpen;
@@ -22,6 +22,13 @@ class SimpleFanAccessory extends BaseAccessory {
22
22
  this.dpFanOn = this._getCustomDP(this.device.context.dpFanOn) || '1';
23
23
  this.dpRotationSpeed = this._getCustomDP(this.device.context.dpRotationSpeed) || '3';
24
24
  this.dpFanDirection = this._getCustomDP(this.device.context.dpFanDirection) || '2';
25
+ // Oscillation is opt-in: SwingMode is only exposed when the fan's boolean
26
+ // swing DP is configured, so existing setups are unchanged (issue #96).
27
+ this.dpSwing = this._getCustomDP(this.device.context.dpSwing);
28
+ // Some fans use DP 2 for a mode enum (nature/sleep/…) rather than a
29
+ // rotation direction; RotationDirection would then write forward/reverse
30
+ // into that enum. noDirection drops the characteristic for those fans.
31
+ this.noDirection = this._coerceBoolean(this.device.context.noDirection, false);
25
32
 
26
33
  this.maxSpeed = parseInt(this.device.context.maxSpeed) || 3;
27
34
  this.fanDefaultSpeed = parseInt(this.device.context.fanDefaultSpeed) || 1;
@@ -44,10 +51,21 @@ class SimpleFanAccessory extends BaseAccessory {
44
51
  .onGet(() => this.getSpeed())
45
52
  .onSet(value => this.setSpeed(value));
46
53
 
47
- const characteristicFanDirection = serviceFan.getCharacteristic(Characteristic.RotationDirection)
48
- .updateValue(this._getFanDirection(dps[this.dpFanDirection]))
49
- .onGet(() => this.getFanDirection())
50
- .onSet(value => this.setFanDirection(value));
54
+ let characteristicFanDirection;
55
+ if (!this.noDirection) {
56
+ characteristicFanDirection = serviceFan.getCharacteristic(Characteristic.RotationDirection)
57
+ .updateValue(this._getFanDirection(dps[this.dpFanDirection]))
58
+ .onGet(() => this.getFanDirection())
59
+ .onSet(value => this.setFanDirection(value));
60
+ } else this._removeCharacteristic(serviceFan, Characteristic.RotationDirection);
61
+
62
+ let characteristicSwingMode;
63
+ if (this.dpSwing) {
64
+ characteristicSwingMode = serviceFan.getCharacteristic(Characteristic.SwingMode)
65
+ .updateValue(this._getSwingMode(dps[this.dpSwing]))
66
+ .onGet(() => this.getSwingMode())
67
+ .onSet(value => this.setSwingMode(value));
68
+ } else this._removeCharacteristic(serviceFan, Characteristic.SwingMode);
51
69
 
52
70
  this.device.on('change', (changes, state) => {
53
71
  if (changes.hasOwnProperty(this.dpFanOn) && characteristicFanOn.value !== changes[this.dpFanOn])
@@ -61,6 +79,11 @@ class SimpleFanAccessory extends BaseAccessory {
61
79
  if (characteristicFanDirection.value !== dir) characteristicFanDirection.updateValue(dir);
62
80
  }
63
81
 
82
+ if (characteristicSwingMode && changes.hasOwnProperty(this.dpSwing)) {
83
+ const swing = this._getSwingMode(changes[this.dpSwing]);
84
+ if (characteristicSwingMode.value !== swing) characteristicSwingMode.updateValue(swing);
85
+ }
86
+
64
87
  this.log.debug('SimpleFan changed: ' + JSON.stringify(state));
65
88
  });
66
89
  }
@@ -134,6 +157,20 @@ class SimpleFanAccessory extends BaseAccessory {
134
157
  return this.setStateAsync(this.dpFanDirection, tuyaVal);
135
158
  }
136
159
 
160
+ getSwingMode() {
161
+ return this._getSwingMode(this.getStateAsync(this.dpSwing));
162
+ }
163
+
164
+ _getSwingMode(dp) {
165
+ const {Characteristic} = this.hap;
166
+ return dp ? Characteristic.SwingMode.SWING_ENABLED : Characteristic.SwingMode.SWING_DISABLED;
167
+ }
168
+
169
+ setSwingMode(value) {
170
+ const {Characteristic} = this.hap;
171
+ return this.setStateAsync(this.dpSwing, value === Characteristic.SwingMode.SWING_ENABLED);
172
+ }
173
+
137
174
  convertRotationSpeedFromTuyaToHomeKit(value) {
138
175
  const v = parseInt(value) || 0;
139
176
  if (v <= 0) return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "4.0.0",
3
+ "version": "4.1.0-dev.64",
4
4
  "description": "A community-maintained Homebridge plugin for controlling Tuya devices locally over LAN, with an optional Tuya Cloud fallback for whatever the LAN can't reach. Includes new features, fixes, and updated device support. PRs welcome: if it runs, it ships (almost).",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -52,6 +52,21 @@ describe('GarageDoorAccessory._getTargetDoorState — Kogan (string)', () => {
52
52
  test('"closing" → targetClosed', () => expect(instance._getTargetDoorState('closing')).toBe(TDS.CLOSED));
53
53
  });
54
54
 
55
+ describe('GarageDoorAccessory._getTargetDoorState — Daspi (string)', () => {
56
+ let instance;
57
+ beforeEach(() => ({ instance } = makeGarage('Daspi')));
58
+
59
+ test('"open" → targetOpen', () => expect(instance._getTargetDoorState('open')).toBe(TDS.OPEN));
60
+ test('"opening" → targetOpen', () => expect(instance._getTargetDoorState('opening')).toBe(TDS.OPEN));
61
+ test('"open_softstop" → targetOpen', () => expect(instance._getTargetDoorState('open_softstop')).toBe(TDS.OPEN));
62
+ test('"full_opened" → targetOpen', () => expect(instance._getTargetDoorState('full_opened')).toBe(TDS.OPEN));
63
+ test('"close" → targetClosed', () => expect(instance._getTargetDoorState('close')).toBe(TDS.CLOSED));
64
+ test('"closing" → targetClosed', () => expect(instance._getTargetDoorState('closing')).toBe(TDS.CLOSED));
65
+ test('"close_softstop" → targetClosed', () => expect(instance._getTargetDoorState('close_softstop')).toBe(TDS.CLOSED));
66
+ test('"full_closed" → targetClosed', () => expect(instance._getTargetDoorState('full_closed')).toBe(TDS.CLOSED));
67
+ test('mixed case "FULL_OPENED" → targetOpen', () => expect(instance._getTargetDoorState('FULL_OPENED')).toBe(TDS.OPEN));
68
+ });
69
+
55
70
  // ---------------------------------------------------------------------------
56
71
  // _getCurrentDoorState
57
72
  // ---------------------------------------------------------------------------
@@ -78,6 +93,19 @@ describe('GarageDoorAccessory._getCurrentDoorState — Kogan (string)', () => {
78
93
  test('"closed" → CLOSED', () => expect(instance._getCurrentDoorState('closed')).toBe(CDS.CLOSED));
79
94
  });
80
95
 
96
+ describe('GarageDoorAccessory._getCurrentDoorState — Daspi (string)', () => {
97
+ let instance;
98
+ beforeEach(() => ({ instance } = makeGarage('Daspi')));
99
+
100
+ test('"full_opened" → OPEN', () => expect(instance._getCurrentDoorState('full_opened')).toBe(CDS.OPEN));
101
+ test('"open_softstop" → OPENING', () => expect(instance._getCurrentDoorState('open_softstop')).toBe(CDS.OPENING));
102
+ test('"opening" → OPENING', () => expect(instance._getCurrentDoorState('opening')).toBe(CDS.OPENING));
103
+ test('"close_softstop" → CLOSING', () => expect(instance._getCurrentDoorState('close_softstop')).toBe(CDS.CLOSING));
104
+ test('"closing" → CLOSING', () => expect(instance._getCurrentDoorState('closing')).toBe(CDS.CLOSING));
105
+ test('"full_closed" → CLOSED', () => expect(instance._getCurrentDoorState('full_closed')).toBe(CDS.CLOSED));
106
+ test('mixed case "FULL_CLOSED" → CLOSED', () => expect(instance._getCurrentDoorState('FULL_CLOSED')).toBe(CDS.CLOSED));
107
+ });
108
+
81
109
  // ---------------------------------------------------------------------------
82
110
  // setTargetDoorState — device command
83
111
  // ---------------------------------------------------------------------------
@@ -113,6 +141,23 @@ describe('GarageDoorAccessory.setTargetDoorState — Kogan', () => {
113
141
  });
114
142
  });
115
143
 
144
+ describe('GarageDoorAccessory.setTargetDoorState — Daspi', () => {
145
+ // Daspi expects capitalized command strings, unlike Kogan's lowercase.
146
+ test('OPEN sends "Open" to dpAction (1)', () => {
147
+ const { instance, device } = makeGarage('Daspi');
148
+ device.state['1'] = 'full_closed';
149
+ instance.setTargetDoorState(TDS.OPEN);
150
+ expect(device.update).toHaveBeenCalledWith({ '1': 'Open' });
151
+ });
152
+
153
+ test('CLOSED sends "Close" to dpAction (1)', () => {
154
+ const { instance, device } = makeGarage('Daspi');
155
+ device.state['1'] = 'full_opened';
156
+ instance.setTargetDoorState(TDS.CLOSED);
157
+ expect(device.update).toHaveBeenCalledWith({ '1': 'Close' });
158
+ });
159
+ });
160
+
116
161
  // ---------------------------------------------------------------------------
117
162
  // flipState
118
163
  // ---------------------------------------------------------------------------
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ const SimpleFanAccessory = require('../lib/SimpleFanAccessory');
4
+ const { makeInstance, HAP } = require('./support/mocks');
5
+
6
+ const { SwingMode, RotationDirection } = HAP.Characteristic;
7
+
8
+ // The mock Fan service shares a single characteristic, so the write-path tests
9
+ // wire the DP fields manually (mirroring what _registerCharacteristics would do)
10
+ // and assert on the conversion helpers and the DP packets that go out. The
11
+ // registration tests below drive _registerCharacteristics itself.
12
+ function makeFan(state = {}, context = {}) {
13
+ const result = makeInstance(SimpleFanAccessory, state, { type: 'Fan', ...context });
14
+ const { instance } = result;
15
+
16
+ instance.dpFanOn = '1';
17
+ instance.dpRotationSpeed = '3';
18
+ instance.dpFanDirection = '2';
19
+ instance.dpSwing = instance._getCustomDP(context.dpSwing);
20
+ return result;
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // _getSwingMode — boolean DP maps to HomeKit SwingMode
25
+ // ---------------------------------------------------------------------------
26
+ describe('SimpleFanAccessory._getSwingMode', () => {
27
+ test('truthy DP enables swing', () => {
28
+ const { instance } = makeFan();
29
+ expect(instance._getSwingMode(true)).toBe(SwingMode.SWING_ENABLED);
30
+ expect(instance._getSwingMode(1)).toBe(SwingMode.SWING_ENABLED);
31
+ });
32
+
33
+ test('falsy DP disables swing', () => {
34
+ const { instance } = makeFan();
35
+ expect(instance._getSwingMode(false)).toBe(SwingMode.SWING_DISABLED);
36
+ expect(instance._getSwingMode(0)).toBe(SwingMode.SWING_DISABLED);
37
+ expect(instance._getSwingMode(undefined)).toBe(SwingMode.SWING_DISABLED);
38
+ });
39
+ });
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // getSwingMode / setSwingMode — read and write the configured swing DP
43
+ // ---------------------------------------------------------------------------
44
+ describe('SimpleFanAccessory.getSwingMode / setSwingMode', () => {
45
+ test('getSwingMode reads and converts the swing DP', () => {
46
+ const { instance } = makeFan({ '4': true }, { dpSwing: 4 });
47
+ expect(instance.getSwingMode()).toBe(SwingMode.SWING_ENABLED);
48
+ });
49
+
50
+ test('setSwingMode writes a boolean true to the swing DP', () => {
51
+ const { instance, device } = makeFan({ '4': false }, { dpSwing: 4 });
52
+ instance.setSwingMode(SwingMode.SWING_ENABLED);
53
+ expect(device.update).toHaveBeenCalledWith({ '4': true });
54
+ });
55
+
56
+ test('setSwingMode writes a boolean false to the swing DP', () => {
57
+ const { instance, device } = makeFan({ '4': true }, { dpSwing: 4 });
58
+ instance.setSwingMode(SwingMode.SWING_DISABLED);
59
+ expect(device.update).toHaveBeenCalledWith({ '4': false });
60
+ });
61
+
62
+ test('setSwingMode rejects (No Response) and writes nothing when disconnected', async () => {
63
+ const { instance, device } = makeFan({ '4': false }, { dpSwing: 4 });
64
+ device.connected = false;
65
+ await expect(instance.setSwingMode(SwingMode.SWING_ENABLED)).rejects.toBeInstanceOf(HAP.HapStatusError);
66
+ expect(device.update).not.toHaveBeenCalled();
67
+ });
68
+ });
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Registration — SwingMode is opt-in and RotationDirection is opt-out
72
+ // ---------------------------------------------------------------------------
73
+ describe('SimpleFanAccessory._registerCharacteristics', () => {
74
+ const register = context => {
75
+ const { instance, accessory } = makeInstance(SimpleFanAccessory, {}, { type: 'Fan', ...context });
76
+ const service = accessory._mockService;
77
+ service.getCharacteristic.mockClear();
78
+ instance._registerCharacteristics(instance.device.state);
79
+ return service;
80
+ };
81
+
82
+ test('registers SwingMode when dpSwing is configured', () => {
83
+ const service = register({ dpSwing: 4 });
84
+ expect(service.getCharacteristic).toHaveBeenCalledWith(SwingMode);
85
+ });
86
+
87
+ test('does not register SwingMode when dpSwing is absent (backwards compatible)', () => {
88
+ const service = register({});
89
+ expect(service.getCharacteristic).not.toHaveBeenCalledWith(SwingMode);
90
+ });
91
+
92
+ test('registers RotationDirection by default', () => {
93
+ const service = register({});
94
+ expect(service.getCharacteristic).toHaveBeenCalledWith(RotationDirection);
95
+ });
96
+
97
+ test('drops RotationDirection when noDirection is set', () => {
98
+ const service = register({ noDirection: true });
99
+ expect(service.getCharacteristic).not.toHaveBeenCalledWith(RotationDirection);
100
+ });
101
+ });
@@ -466,6 +466,15 @@ While still in early testing, you can use this to open and close the garage door
466
466
  }
467
467
  ```
468
468
 
469
+ **Manufacturer-specific defaults.** Most openers report a plain boolean state on a numeric data-point. Some report string states and use different action/status data-points; set `manufacturer` and the plugin picks sensible defaults for you (each still overridable with `dpAction`/`dpStatus`):
470
+
471
+ | `manufacturer` | `dpAction` | `dpStatus` | State values |
472
+ |---|---|---|---|
473
+ | _(any other)_ | `1` | `2` | boolean (`true`/`false`) |
474
+ | `Kogan` | `101` | `102` | strings (`opened` / `openning` / `opening` / `closing` / `closed`) |
475
+ | `Wofea` | `1` | `101` | boolean (`true`/`false`) |
476
+ | `Daspi` | `1` | `7` | strings (`full_opened` / `opening` / `open_softstop` / `full_closed` / `closing` / `close_softstop`); commands are capitalized `Open` / `Close` |
477
+
469
478
  ### Simple Garage Doors
470
479
  For sliding gate openers and garage door controllers that expose momentary
471
480
  open/stop/close action DPs plus a single status DP that reports the gate's
@@ -723,7 +732,16 @@ These are accessories that may act as a regulator switch or an inbuilt regulator
723
732
  "dpRotationSpeed": "2",
724
733
 
725
734
  /* Override the default datapoint identifier of direction control (forward/reverse) */
726
- "dpRotationDirection": 63
735
+ "dpRotationDirection": 63,
736
+
737
+ /* Datapoint of the oscillation switch (a boolean DP). When set, a Swing
738
+ (oscillation) control is shown in the Home app. Omit if the fan has none. */
739
+ "dpSwing": 4,
740
+
741
+ /* Hide the rotation direction control. Enable for fans that have no
742
+ forward/reverse, or whose direction datapoint is actually a mode enum,
743
+ so forward/reverse is never written into it. */
744
+ "noDirection": true
727
745
  }
728
746
  ```
729
747