homebridge-tuya-plus 3.13.0 → 3.13.1-dev.2

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.
@@ -0,0 +1,299 @@
1
+ 'use strict';
2
+
3
+ const SimpleFanLightAccessory = require('../lib/SimpleFanLightAccessory');
4
+ const { makeInstance } = require('./support/mocks');
5
+
6
+ // Mirror the state that _registerCharacteristics would establish (the mock
7
+ // service shares a single characteristic, so wiring the fields manually keeps
8
+ // the assertions focused on the write path and the conversion helpers).
9
+ // minWhiteColor/maxWhiteColor are only copied onto the instance when the config
10
+ // provides them, so omitting them exercises the in-method default fallback.
11
+ function makeFanLight(state = {}, context = {}) {
12
+ const result = makeInstance(SimpleFanLightAccessory, state, { type: 'FanLight', ...context });
13
+ const { instance, device } = result;
14
+
15
+ instance.dpFanOn = '60';
16
+ instance.dpRotationSpeed = '62';
17
+ instance.dpFanDirection = '63';
18
+ instance.dpColorTemp = '23';
19
+ instance.maxSpeed = parseInt(device.context.maxSpeed) || 6;
20
+ instance.fanDefaultSpeed = parseInt(device.context.fanDefaultSpeed) || 1;
21
+ instance.fanCurrentSpeed = 0;
22
+ instance.useStrings = instance._coerceBoolean(device.context.useStrings, true);
23
+ instance.singleDpWrites = instance._coerceBoolean(device.context.singleDpWrites, false);
24
+ if (device.context.minWhiteColor !== undefined) instance.minWhiteColor = parseInt(device.context.minWhiteColor);
25
+ if (device.context.maxWhiteColor !== undefined) instance.maxWhiteColor = parseInt(device.context.maxWhiteColor);
26
+
27
+ return result;
28
+ }
29
+
30
+ const payloads = device => device.update.mock.calls.map(c => c[0]);
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Default behaviour: fan power + speed go out together in one legacy packet.
34
+ // ---------------------------------------------------------------------------
35
+ describe('SimpleFanLightAccessory — combined writes (default)', () => {
36
+ test('setFanOn(true) sends fan power and speed in a single packet', () => {
37
+ const { instance, device } = makeFanLight({ '60': false, '62': '1' });
38
+
39
+ instance.setFanOn(true);
40
+
41
+ expect(device.update).toHaveBeenCalledTimes(1);
42
+ expect(device.update).toHaveBeenCalledWith({ '60': true, '62': '1' });
43
+ });
44
+
45
+ test('setSpeed sends fan power and speed in a single packet', () => {
46
+ const { instance, device } = makeFanLight({ '60': false, '62': '1' });
47
+
48
+ instance.setSpeed(100); // maxSpeed 6 -> tuya 6
49
+
50
+ expect(device.update).toHaveBeenCalledTimes(1);
51
+ expect(device.update).toHaveBeenCalledWith({ '60': true, '62': '6' });
52
+ });
53
+
54
+ test('setSpeed(0) sends fan off and the reset speed in a single packet', () => {
55
+ const { instance, device } = makeFanLight({ '60': true, '62': '5' });
56
+
57
+ instance.setSpeed(0);
58
+
59
+ expect(device.update).toHaveBeenCalledTimes(1);
60
+ expect(device.update).toHaveBeenCalledWith({ '60': false, '62': '1' });
61
+ });
62
+ });
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // singleDpWrites: each DP goes out as its own packet (issue #43 — some `fsd`
66
+ // fan firmwares silently ignore any LAN packet carrying more than one DP).
67
+ // ---------------------------------------------------------------------------
68
+ describe('SimpleFanLightAccessory — singleDpWrites (per-DP packets)', () => {
69
+ test('every write carries at most one DP', () => {
70
+ const { instance, device } = makeFanLight(
71
+ { '60': false, '62': '1' },
72
+ { singleDpWrites: true }
73
+ );
74
+
75
+ instance.setSpeed(100);
76
+
77
+ for (const p of payloads(device)) {
78
+ expect(Object.keys(p).length).toBe(1);
79
+ }
80
+ });
81
+
82
+ test('setFanOn(true) emits the fan-power DP on its own (the packet that was ignored)', () => {
83
+ // Fan is off at its default speed, so turning it on only needs DP 60 —
84
+ // and it must go out alone, not bundled with the speed DP.
85
+ const { instance, device } = makeFanLight(
86
+ { '60': false, '62': '1' },
87
+ { singleDpWrites: true }
88
+ );
89
+
90
+ instance.setFanOn(true);
91
+
92
+ expect(device.update).toHaveBeenCalledTimes(1);
93
+ expect(device.update).toHaveBeenCalledWith({ '60': true });
94
+ });
95
+
96
+ test('setFanOn(true) splits power and speed into two packets when both change', () => {
97
+ const { instance, device } = makeFanLight(
98
+ { '60': false, '62': '1' },
99
+ { singleDpWrites: true, fanDefaultSpeed: 3 }
100
+ );
101
+
102
+ instance.setFanOn(true);
103
+
104
+ expect(device.update).toHaveBeenCalledTimes(2);
105
+ expect(device.update).toHaveBeenCalledWith({ '60': true });
106
+ expect(device.update).toHaveBeenCalledWith({ '62': '3' });
107
+ });
108
+
109
+ test('setSpeed splits power and speed into two packets', () => {
110
+ const { instance, device } = makeFanLight(
111
+ { '60': false, '62': '1' },
112
+ { singleDpWrites: true }
113
+ );
114
+
115
+ instance.setSpeed(100); // tuya 6, differs from current '1'
116
+
117
+ expect(device.update).toHaveBeenCalledTimes(2);
118
+ expect(device.update).toHaveBeenCalledWith({ '60': true });
119
+ expect(device.update).toHaveBeenCalledWith({ '62': '6' });
120
+ });
121
+
122
+ test('setSpeed(0) splits fan off and the reset speed into two packets', () => {
123
+ const { instance, device } = makeFanLight(
124
+ { '60': true, '62': '5' },
125
+ { singleDpWrites: true }
126
+ );
127
+
128
+ instance.setSpeed(0);
129
+
130
+ expect(device.update).toHaveBeenCalledTimes(2);
131
+ expect(device.update).toHaveBeenCalledWith({ '60': false });
132
+ expect(device.update).toHaveBeenCalledWith({ '62': '1' });
133
+ });
134
+
135
+ test('respects useStrings: false (numeric speed) in per-DP mode', () => {
136
+ const { instance, device } = makeFanLight(
137
+ { '60': false, '62': 1 },
138
+ { singleDpWrites: true, useStrings: false }
139
+ );
140
+
141
+ instance.setSpeed(100); // tuya 6 as a number
142
+
143
+ expect(device.update).toHaveBeenCalledWith({ '60': true });
144
+ expect(device.update).toHaveBeenCalledWith({ '62': 6 });
145
+ });
146
+ });
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Fan OFF always goes through a single-DP write, regardless of the flag — this
150
+ // is why "fan off works" was already true in the bug report.
151
+ // ---------------------------------------------------------------------------
152
+ describe('SimpleFanLightAccessory — setFanOn(false)', () => {
153
+ test.each([
154
+ ['default', {}],
155
+ ['singleDpWrites', { singleDpWrites: true }],
156
+ ])('sends a lone fan-off packet (%s)', (_label, context) => {
157
+ const { instance, device } = makeFanLight({ '60': true, '62': '3' }, context);
158
+
159
+ instance.setFanOn(false);
160
+
161
+ expect(instance.fanCurrentSpeed).toBe(0);
162
+ expect(device.update).toHaveBeenCalledTimes(1);
163
+ expect(device.update).toHaveBeenCalledWith({ '60': false });
164
+ });
165
+ });
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // convertColorTempFromTuyaToHomeKit — default range, inverted (issue #44)
169
+ // ---------------------------------------------------------------------------
170
+ describe('SimpleFanLightAccessory.convertColorTempFromTuyaToHomeKit (default range)', () => {
171
+ test('Tuya 0 (warmest) maps to the warm mired end (370)', () => {
172
+ const { instance } = makeFanLight();
173
+ expect(instance.convertColorTempFromTuyaToHomeKit(0)).toBe(370);
174
+ });
175
+
176
+ test('Tuya 1000 (coolest) maps to the cool mired end (154)', () => {
177
+ const { instance } = makeFanLight();
178
+ expect(instance.convertColorTempFromTuyaToHomeKit(1000)).toBe(154);
179
+ });
180
+
181
+ test('Tuya midpoint maps to the mired midpoint', () => {
182
+ const { instance } = makeFanLight();
183
+ // 370 - 500*(370-154)/1000 = 262
184
+ expect(instance.convertColorTempFromTuyaToHomeKit(500)).toBe(262);
185
+ });
186
+
187
+ test('accepts Tuya values as strings', () => {
188
+ const { instance } = makeFanLight();
189
+ expect(instance.convertColorTempFromTuyaToHomeKit('0')).toBe(370);
190
+ expect(instance.convertColorTempFromTuyaToHomeKit('1000')).toBe(154);
191
+ });
192
+
193
+ test('out-of-range / undefined Tuya values stay within bounds', () => {
194
+ const { instance } = makeFanLight();
195
+ expect(instance.convertColorTempFromTuyaToHomeKit(99999)).toBe(154);
196
+ expect(instance.convertColorTempFromTuyaToHomeKit(-5)).toBe(370);
197
+ const undef = instance.convertColorTempFromTuyaToHomeKit(undefined);
198
+ expect(Number.isFinite(undef)).toBe(true);
199
+ expect(undef).toBeGreaterThanOrEqual(154);
200
+ expect(undef).toBeLessThanOrEqual(370);
201
+ });
202
+ });
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // convertColorTempFromHomeKitToTuya — default range, inverted (issue #44)
206
+ // ---------------------------------------------------------------------------
207
+ describe('SimpleFanLightAccessory.convertColorTempFromHomeKitToTuya (default range)', () => {
208
+ test('warm mired end (370) maps to Tuya 0 (warmest)', () => {
209
+ const { instance } = makeFanLight();
210
+ expect(instance.convertColorTempFromHomeKitToTuya(370)).toBe(0);
211
+ });
212
+
213
+ test('cool mired end (154) maps to Tuya 1000 (coolest)', () => {
214
+ const { instance } = makeFanLight();
215
+ expect(instance.convertColorTempFromHomeKitToTuya(154)).toBe(1000);
216
+ });
217
+
218
+ test("Apple's warm preset (~370 mired) lands warm, not neutral (issue #44 regression)", () => {
219
+ const { instance } = makeFanLight();
220
+ // The old code produced 313 for the warm preset; it must now be the warm extreme.
221
+ expect(instance.convertColorTempFromHomeKitToTuya(370)).toBeLessThan(100);
222
+ });
223
+
224
+ test('cool input (low mired) produces a high Tuya value, warm input (high mired) a low one', () => {
225
+ const { instance } = makeFanLight();
226
+ const cool = instance.convertColorTempFromHomeKitToTuya(154);
227
+ const warm = instance.convertColorTempFromHomeKitToTuya(370);
228
+ expect(cool).toBeGreaterThan(warm);
229
+ });
230
+
231
+ test('values outside the configured range are clamped before mapping', () => {
232
+ const { instance } = makeFanLight();
233
+ expect(instance.convertColorTempFromHomeKitToTuya(500)).toBe(0); // clamped to 370 -> warm
234
+ expect(instance.convertColorTempFromHomeKitToTuya(140)).toBe(1000); // clamped to 154 -> cool
235
+ });
236
+ });
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Configurable range via minWhiteColor / maxWhiteColor (issue #44)
240
+ // ---------------------------------------------------------------------------
241
+ describe('SimpleFanLightAccessory color temperature with a configured range', () => {
242
+ test('honours a custom 140..500 mired range', () => {
243
+ const { instance } = makeFanLight({}, { minWhiteColor: 140, maxWhiteColor: 500 });
244
+ expect(instance.convertColorTempFromHomeKitToTuya(500)).toBe(0); // warm end
245
+ expect(instance.convertColorTempFromHomeKitToTuya(140)).toBe(1000); // cool end
246
+ expect(instance.convertColorTempFromTuyaToHomeKit(0)).toBe(500);
247
+ expect(instance.convertColorTempFromTuyaToHomeKit(1000)).toBe(140);
248
+ });
249
+
250
+ test('string config values are coerced to numbers', () => {
251
+ const { instance } = makeFanLight({}, { minWhiteColor: '154', maxWhiteColor: '370' });
252
+ expect(instance.convertColorTempFromHomeKitToTuya(370)).toBe(0);
253
+ expect(instance.convertColorTempFromTuyaToHomeKit(1000)).toBe(154);
254
+ });
255
+ });
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Round-trip stability
259
+ // ---------------------------------------------------------------------------
260
+ describe('SimpleFanLightAccessory color temperature round-trips', () => {
261
+ test('HomeKit -> Tuya -> HomeKit is stable across the default range', () => {
262
+ const { instance } = makeFanLight();
263
+ for (let hk = 154; hk <= 370; hk += 1) {
264
+ const tuya = instance.convertColorTempFromHomeKitToTuya(hk);
265
+ const back = instance.convertColorTempFromTuyaToHomeKit(tuya);
266
+ // Allow ±1 mired for rounding through the 0..1000 integer scale.
267
+ expect(Math.abs(back - hk)).toBeLessThanOrEqual(1);
268
+ }
269
+ });
270
+ });
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // getColorTemp / setColorTemp integration
274
+ // ---------------------------------------------------------------------------
275
+ describe('SimpleFanLightAccessory.getColorTemp / setColorTemp', () => {
276
+ test('getColorTemp reads and converts the Tuya DP', () => {
277
+ const { instance } = makeFanLight({ '23': 0 });
278
+ expect(instance.getColorTemp()).toBe(370); // Tuya 0 -> warm
279
+ });
280
+
281
+ test('setColorTemp writes the inverted, range-mapped Tuya value as a string', () => {
282
+ const { instance, device } = makeFanLight({ '23': 500 });
283
+ instance.setColorTemp(370); // warm preset -> Tuya 0
284
+ expect(device.update).toHaveBeenCalledWith({ '23': '0' });
285
+ });
286
+
287
+ test('setColorTemp writes a numeric value when useStrings is false', () => {
288
+ const { instance, device } = makeFanLight({ '23': 500 }, { useStrings: false });
289
+ instance.setColorTemp(154); // cool end -> Tuya 1000
290
+ expect(device.update).toHaveBeenCalledWith({ '23': 1000 });
291
+ });
292
+
293
+ test('setColorTemp does not write when the device is disconnected', () => {
294
+ const { instance, device } = makeFanLight({ '23': 500 });
295
+ device.connected = false;
296
+ expect(() => instance.setColorTemp(370)).not.toThrow();
297
+ expect(device.update).not.toHaveBeenCalled();
298
+ });
299
+ });