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.
- 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 +95 -28
- 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 +626 -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
|
@@ -5,10 +5,10 @@ const { HAP, makeInstance } = require('./support/mocks');
|
|
|
5
5
|
|
|
6
6
|
const { CurrentDoorState: CDS, TargetDoorState: TDS } = HAP.Characteristic;
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
8
|
+
// Raw status DP values reported by the controller (DP 105).
|
|
9
|
+
const STATE_STOPPED = 11;
|
|
10
|
+
const STATE_OPENING_OR_OPEN = 12;
|
|
11
|
+
const STATE_CLOSING_OR_CLOSED = 13;
|
|
12
12
|
|
|
13
13
|
// Give the device's `on`/`removeListener` mocks real subscription semantics
|
|
14
14
|
// so the accessory can wait for `change` events the way it does in production.
|
|
@@ -44,32 +44,28 @@ function makeSimpleGarage(initialContext = {}) {
|
|
|
44
44
|
// call it directly because the mock service shares a single mock
|
|
45
45
|
// characteristic across calls — wiring each role manually keeps the
|
|
46
46
|
// assertions untangled.
|
|
47
|
-
instance.dpOpen = '
|
|
48
|
-
instance.
|
|
49
|
-
instance.
|
|
47
|
+
instance.dpOpen = '101';
|
|
48
|
+
instance.dpClose = '102';
|
|
49
|
+
instance.dpStop = '103';
|
|
50
|
+
instance.dpState = '105';
|
|
50
51
|
instance.partialOpenMs = 0;
|
|
51
|
-
instance.
|
|
52
|
-
instance.desiredTarget = TDS.OPEN;
|
|
53
|
-
instance.worker = null;
|
|
54
|
-
instance.scheduleTimer = null;
|
|
52
|
+
instance.stopBeforeCloseMs = 1500;
|
|
55
53
|
instance.partialStopTimer = null;
|
|
56
|
-
instance.
|
|
57
|
-
instance.
|
|
58
|
-
instance._currentChangePending = null;
|
|
59
|
-
instance._currentChangeResolve = null;
|
|
54
|
+
instance.pendingCloseTimer = null;
|
|
55
|
+
instance.currentDoorState = CDS.CLOSED;
|
|
60
56
|
instance.characteristicCurrentDoorState = {
|
|
61
|
-
value: CDS.
|
|
57
|
+
value: CDS.CLOSED,
|
|
62
58
|
updateValue: jest.fn().mockImplementation(function(v) { this.value = v; return this; }),
|
|
63
59
|
};
|
|
64
60
|
instance.characteristicTargetDoorState = {
|
|
65
|
-
value: TDS.
|
|
61
|
+
value: TDS.CLOSED,
|
|
66
62
|
updateValue: jest.fn().mockImplementation(function(v) { this.value = v; return this; }),
|
|
67
63
|
};
|
|
68
64
|
instance.characteristicPartialOpen = {
|
|
69
|
-
value:
|
|
65
|
+
value: false,
|
|
70
66
|
updateValue: jest.fn().mockImplementation(function(v) { this.value = v; return this; }),
|
|
71
67
|
};
|
|
72
|
-
accessory.context.cachedTargetDoorState = TDS.
|
|
68
|
+
accessory.context.cachedTargetDoorState = TDS.CLOSED;
|
|
73
69
|
|
|
74
70
|
// Mirror the persistent change listener registered in production.
|
|
75
71
|
device.on('change', changes => instance._onDeviceChange(changes));
|
|
@@ -77,318 +73,299 @@ function makeSimpleGarage(initialContext = {}) {
|
|
|
77
73
|
return { instance, device, accessory, platform };
|
|
78
74
|
}
|
|
79
75
|
|
|
80
|
-
// Simulate the
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
function emitReset(device, dp) {
|
|
84
|
-
device.emit('change', { [dp]: false }, { [dp]: false });
|
|
76
|
+
// Simulate the controller reporting a new value on its status DP.
|
|
77
|
+
function emitState(device, value) {
|
|
78
|
+
device.emit('change', { '105': value });
|
|
85
79
|
}
|
|
86
80
|
|
|
87
81
|
// ---------------------------------------------------------------------------
|
|
88
|
-
//
|
|
82
|
+
// State DP -> door state mapping
|
|
89
83
|
// ---------------------------------------------------------------------------
|
|
90
84
|
describe('SimpleGarageDoorAccessory._onDeviceChange', () => {
|
|
91
|
-
test('
|
|
92
|
-
const { instance } = makeSimpleGarage();
|
|
85
|
+
test('State 12 (opening/open) drives Current and Target to OPEN', () => {
|
|
86
|
+
const { instance, accessory } = makeSimpleGarage();
|
|
93
87
|
instance.currentDoorState = CDS.CLOSED;
|
|
94
88
|
instance.characteristicCurrentDoorState.value = CDS.CLOSED;
|
|
89
|
+
accessory.context.cachedTargetDoorState = TDS.CLOSED;
|
|
95
90
|
|
|
96
|
-
instance._onDeviceChange({ '
|
|
91
|
+
instance._onDeviceChange({ '105': STATE_OPENING_OR_OPEN });
|
|
97
92
|
|
|
98
93
|
expect(instance.currentDoorState).toBe(CDS.OPEN);
|
|
99
94
|
expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
|
|
95
|
+
expect(instance.characteristicTargetDoorState.value).toBe(TDS.OPEN);
|
|
96
|
+
expect(accessory.context.cachedTargetDoorState).toBe(TDS.OPEN);
|
|
100
97
|
});
|
|
101
98
|
|
|
102
|
-
test('
|
|
99
|
+
test('State 11 (stopped) is treated as OPEN', () => {
|
|
103
100
|
const { instance } = makeSimpleGarage();
|
|
101
|
+
instance.currentDoorState = CDS.CLOSED;
|
|
102
|
+
instance.characteristicCurrentDoorState.value = CDS.CLOSED;
|
|
103
|
+
|
|
104
|
+
instance._onDeviceChange({ '105': STATE_STOPPED });
|
|
105
|
+
|
|
106
|
+
expect(instance.currentDoorState).toBe(CDS.OPEN);
|
|
107
|
+
expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('State 13 (closing/closed) drives Current and Target to CLOSED', () => {
|
|
111
|
+
const { instance, accessory } = makeSimpleGarage();
|
|
104
112
|
instance.currentDoorState = CDS.OPEN;
|
|
105
113
|
instance.characteristicCurrentDoorState.value = CDS.OPEN;
|
|
114
|
+
instance.characteristicTargetDoorState.value = TDS.OPEN;
|
|
115
|
+
accessory.context.cachedTargetDoorState = TDS.OPEN;
|
|
106
116
|
|
|
107
|
-
instance._onDeviceChange({ '
|
|
117
|
+
instance._onDeviceChange({ '105': STATE_CLOSING_OR_CLOSED });
|
|
108
118
|
|
|
109
119
|
expect(instance.currentDoorState).toBe(CDS.CLOSED);
|
|
110
120
|
expect(instance.characteristicCurrentDoorState.value).toBe(CDS.CLOSED);
|
|
121
|
+
expect(instance.characteristicTargetDoorState.value).toBe(TDS.CLOSED);
|
|
122
|
+
expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('String DP values are coerced', () => {
|
|
126
|
+
const { instance } = makeSimpleGarage();
|
|
127
|
+
instance.currentDoorState = CDS.CLOSED;
|
|
128
|
+
|
|
129
|
+
instance._onDeviceChange({ '105': '12' });
|
|
130
|
+
|
|
131
|
+
expect(instance.currentDoorState).toBe(CDS.OPEN);
|
|
111
132
|
});
|
|
112
133
|
|
|
113
|
-
test('
|
|
134
|
+
test('Unknown DP values are ignored', () => {
|
|
114
135
|
const { instance } = makeSimpleGarage();
|
|
115
136
|
instance.currentDoorState = CDS.CLOSED;
|
|
116
137
|
|
|
117
|
-
instance._onDeviceChange({ '
|
|
138
|
+
instance._onDeviceChange({ '105': 99 });
|
|
118
139
|
|
|
119
140
|
expect(instance.currentDoorState).toBe(CDS.CLOSED);
|
|
141
|
+
expect(instance.characteristicCurrentDoorState.updateValue).not.toHaveBeenCalled();
|
|
120
142
|
});
|
|
121
143
|
|
|
122
|
-
test('
|
|
144
|
+
test('Changes that do not include the state DP are ignored', () => {
|
|
123
145
|
const { instance } = makeSimpleGarage();
|
|
124
146
|
instance.currentDoorState = CDS.CLOSED;
|
|
125
147
|
|
|
126
|
-
|
|
148
|
+
// The action DPs echoing back are irrelevant — only the state DP matters.
|
|
149
|
+
instance._onDeviceChange({ '101': false });
|
|
150
|
+
instance._onDeviceChange({ '103': true });
|
|
127
151
|
|
|
128
152
|
expect(instance.currentDoorState).toBe(CDS.CLOSED);
|
|
153
|
+
expect(instance.characteristicCurrentDoorState.updateValue).not.toHaveBeenCalled();
|
|
129
154
|
});
|
|
130
155
|
|
|
131
|
-
test('No characteristic write when
|
|
156
|
+
test('No characteristic write when the state already matches', () => {
|
|
132
157
|
const { instance } = makeSimpleGarage();
|
|
133
158
|
instance.currentDoorState = CDS.OPEN;
|
|
159
|
+
instance.characteristicCurrentDoorState.value = CDS.OPEN;
|
|
160
|
+
instance.accessory.context.cachedTargetDoorState = TDS.OPEN;
|
|
134
161
|
|
|
135
|
-
instance._onDeviceChange({ '
|
|
162
|
+
instance._onDeviceChange({ '105': STATE_OPENING_OR_OPEN });
|
|
136
163
|
|
|
137
164
|
expect(instance.characteristicCurrentDoorState.updateValue).not.toHaveBeenCalled();
|
|
165
|
+
expect(instance.characteristicTargetDoorState.updateValue).not.toHaveBeenCalled();
|
|
138
166
|
});
|
|
139
167
|
});
|
|
140
168
|
|
|
141
169
|
// ---------------------------------------------------------------------------
|
|
142
|
-
//
|
|
170
|
+
// Open — always fired directly, even mid-motion
|
|
143
171
|
// ---------------------------------------------------------------------------
|
|
144
|
-
describe('SimpleGarageDoorAccessory.setTargetDoorState —
|
|
145
|
-
|
|
146
|
-
afterEach(() => jest.useRealTimers());
|
|
147
|
-
|
|
148
|
-
test('OPEN debounces for SETTLE_MS then sends stop, waits for reset+500ms, then sends open', async () => {
|
|
172
|
+
describe('SimpleGarageDoorAccessory.setTargetDoorState — open', () => {
|
|
173
|
+
test('OPEN fires the open action immediately, even while the gate is closing', () => {
|
|
149
174
|
const { instance, device } = makeSimpleGarage();
|
|
150
|
-
|
|
151
|
-
instance.characteristicCurrentDoorState.value = CDS.CLOSED;
|
|
175
|
+
device.state['105'] = STATE_CLOSING_OR_CLOSED;
|
|
152
176
|
|
|
153
177
|
instance.setTargetDoorState(TDS.OPEN);
|
|
154
|
-
await jest.advanceTimersByTimeAsync(SETTLE_MS - 1);
|
|
155
|
-
expect(device.update).not.toHaveBeenCalled();
|
|
156
178
|
|
|
157
|
-
|
|
158
|
-
expect(device.update).
|
|
159
|
-
|
|
160
|
-
emitReset(device, '2');
|
|
161
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
162
|
-
expect(device.update).toHaveBeenNthCalledWith(2, { '1': true });
|
|
163
|
-
|
|
164
|
-
emitReset(device, '1');
|
|
165
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
166
|
-
expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
|
|
167
|
-
expect(instance.worker).toBeNull();
|
|
168
|
-
expect(instance.scheduleTimer).toBeNull();
|
|
179
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
180
|
+
expect(device.update).toHaveBeenCalledWith({ '101': true });
|
|
169
181
|
});
|
|
170
182
|
|
|
171
|
-
test('
|
|
183
|
+
test('OPEN never fires a stop first', () => {
|
|
172
184
|
const { instance, device } = makeSimpleGarage();
|
|
173
|
-
|
|
174
|
-
instance.characteristicCurrentDoorState.value = CDS.OPEN;
|
|
175
|
-
|
|
176
|
-
instance.setTargetDoorState(TDS.CLOSED);
|
|
177
|
-
await jest.advanceTimersByTimeAsync(SETTLE_MS);
|
|
178
|
-
expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
|
|
185
|
+
device.state['105'] = STATE_OPENING_OR_OPEN;
|
|
179
186
|
|
|
180
|
-
|
|
181
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
182
|
-
expect(device.update).toHaveBeenNthCalledWith(2, { '3': true });
|
|
187
|
+
instance.setTargetDoorState(TDS.OPEN);
|
|
183
188
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
expect(instance.characteristicCurrentDoorState.value).toBe(CDS.CLOSED);
|
|
187
|
-
expect(instance.worker).toBeNull();
|
|
189
|
+
expect(device.update).not.toHaveBeenCalledWith({ '103': true });
|
|
190
|
+
expect(device.update).toHaveBeenCalledWith({ '101': true });
|
|
188
191
|
});
|
|
189
192
|
|
|
190
|
-
test('Target
|
|
191
|
-
const { instance, device } = makeSimpleGarage();
|
|
192
|
-
instance.currentDoorState = CDS.
|
|
193
|
-
instance.
|
|
193
|
+
test('Target + persistence update optimistically; Current waits for the DP', () => {
|
|
194
|
+
const { instance, device, accessory } = makeSimpleGarage();
|
|
195
|
+
instance.currentDoorState = CDS.CLOSED;
|
|
196
|
+
instance.characteristicCurrentDoorState.value = CDS.CLOSED;
|
|
194
197
|
|
|
195
198
|
instance.setTargetDoorState(TDS.OPEN);
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
);
|
|
199
|
+
expect(accessory.context.cachedTargetDoorState).toBe(TDS.OPEN);
|
|
200
|
+
expect(instance.characteristicTargetDoorState.value).toBe(TDS.OPEN);
|
|
201
|
+
expect(instance.characteristicCurrentDoorState.updateValue).not.toHaveBeenCalled();
|
|
202
|
+
expect(instance.currentDoorState).toBe(CDS.CLOSED);
|
|
199
203
|
|
|
200
|
-
|
|
201
|
-
|
|
204
|
+
// ~1s later the controller reports it is opening.
|
|
205
|
+
emitState(device, STATE_OPENING_OR_OPEN);
|
|
206
|
+
expect(instance.currentDoorState).toBe(CDS.OPEN);
|
|
207
|
+
expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
|
|
202
208
|
});
|
|
203
209
|
|
|
204
|
-
test('Custom
|
|
210
|
+
test('Custom open DP is respected', () => {
|
|
205
211
|
const { instance, device } = makeSimpleGarage();
|
|
206
|
-
instance.dpOpen = '
|
|
207
|
-
instance.dpStop = '102';
|
|
208
|
-
instance.dpClose = '103';
|
|
209
|
-
instance.currentDoorState = CDS.CLOSED;
|
|
212
|
+
instance.dpOpen = '1';
|
|
210
213
|
|
|
211
214
|
instance.setTargetDoorState(TDS.OPEN);
|
|
212
|
-
await jest.advanceTimersByTimeAsync(SETTLE_MS);
|
|
213
|
-
expect(device.update).toHaveBeenNthCalledWith(1, { '102': true });
|
|
214
215
|
|
|
215
|
-
|
|
216
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
217
|
-
expect(device.update).toHaveBeenNthCalledWith(2, { '101': true });
|
|
218
|
-
|
|
219
|
-
emitReset(device, '101');
|
|
220
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
221
|
-
expect(instance.characteristicCurrentDoorState.value).toBe(CDS.OPEN);
|
|
216
|
+
expect(device.update).toHaveBeenCalledWith({ '1': true });
|
|
222
217
|
});
|
|
223
218
|
});
|
|
224
219
|
|
|
225
220
|
// ---------------------------------------------------------------------------
|
|
226
|
-
//
|
|
221
|
+
// Close — direct only when already stopped (state 11), otherwise stop-first
|
|
227
222
|
// ---------------------------------------------------------------------------
|
|
228
|
-
describe('SimpleGarageDoorAccessory.setTargetDoorState —
|
|
223
|
+
describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-close)', () => {
|
|
229
224
|
beforeEach(() => jest.useFakeTimers());
|
|
230
225
|
afterEach(() => jest.useRealTimers());
|
|
231
226
|
|
|
232
|
-
test('
|
|
233
|
-
const { instance, device } = makeSimpleGarage();
|
|
234
|
-
|
|
227
|
+
test('CLOSE fires immediately when the gate is already stopped (state 11)', () => {
|
|
228
|
+
const { instance, device, accessory } = makeSimpleGarage();
|
|
229
|
+
device.state['105'] = STATE_STOPPED;
|
|
235
230
|
|
|
236
|
-
// 5 presses across ~800ms, all within SETTLE_MS of each other.
|
|
237
|
-
instance.setTargetDoorState(TDS.CLOSED);
|
|
238
|
-
await jest.advanceTimersByTimeAsync(200);
|
|
239
|
-
instance.setTargetDoorState(TDS.OPEN);
|
|
240
|
-
await jest.advanceTimersByTimeAsync(200);
|
|
241
231
|
instance.setTargetDoorState(TDS.CLOSED);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
232
|
+
|
|
233
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
234
|
+
expect(device.update).toHaveBeenCalledWith({ '102': true });
|
|
235
|
+
expect(instance.pendingCloseTimer).toBeNull();
|
|
236
|
+
expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
|
|
237
|
+
expect(instance.characteristicTargetDoorState.value).toBe(TDS.CLOSED);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('CLOSE while opening (state 12) fires stop, then close after stopBeforeCloseMs', () => {
|
|
241
|
+
const { instance, device } = makeSimpleGarage();
|
|
242
|
+
instance.stopBeforeCloseMs = 1500;
|
|
243
|
+
device.state['105'] = STATE_OPENING_OR_OPEN;
|
|
244
|
+
|
|
245
245
|
instance.setTargetDoorState(TDS.CLOSED);
|
|
246
246
|
|
|
247
|
-
//
|
|
248
|
-
expect(device.update).
|
|
247
|
+
// Stop goes out immediately, close is deferred.
|
|
248
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
249
|
+
expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
|
|
249
250
|
|
|
250
|
-
|
|
251
|
-
expect(device.update).
|
|
251
|
+
jest.advanceTimersByTime(1500 - 1);
|
|
252
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
252
253
|
|
|
253
|
-
|
|
254
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
255
|
-
expect(device.update).toHaveBeenNthCalledWith(2, { '3': true });
|
|
254
|
+
jest.advanceTimersByTime(1);
|
|
256
255
|
expect(device.update).toHaveBeenCalledTimes(2);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
260
|
-
expect(instance.characteristicCurrentDoorState.value).toBe(CDS.CLOSED);
|
|
261
|
-
expect(instance.worker).toBeNull();
|
|
256
|
+
expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
|
|
257
|
+
expect(instance.pendingCloseTimer).toBeNull();
|
|
262
258
|
});
|
|
263
259
|
|
|
264
|
-
test('
|
|
260
|
+
test('CLOSE while closing (state 13) also uses stop-before-close', () => {
|
|
265
261
|
const { instance, device } = makeSimpleGarage();
|
|
266
|
-
instance.
|
|
262
|
+
instance.stopBeforeCloseMs = 1500;
|
|
263
|
+
device.state['105'] = STATE_CLOSING_OR_CLOSED;
|
|
267
264
|
|
|
268
265
|
instance.setTargetDoorState(TDS.CLOSED);
|
|
269
|
-
|
|
270
|
-
instance.setTargetDoorState(TDS.OPEN);
|
|
271
|
-
await jest.advanceTimersByTimeAsync(200);
|
|
272
|
-
instance.setTargetDoorState(TDS.CLOSED);
|
|
273
|
-
await jest.advanceTimersByTimeAsync(200);
|
|
274
|
-
instance.setTargetDoorState(TDS.OPEN);
|
|
266
|
+
expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
|
|
275
267
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
expect(device.update).not.toHaveBeenCalled();
|
|
279
|
-
expect(instance.worker).toBeNull();
|
|
268
|
+
jest.advanceTimersByTime(1500);
|
|
269
|
+
expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
|
|
280
270
|
});
|
|
281
271
|
|
|
282
|
-
test('
|
|
272
|
+
test('CLOSE with no reported state yet uses stop-before-close', () => {
|
|
283
273
|
const { instance, device } = makeSimpleGarage();
|
|
284
|
-
instance.
|
|
274
|
+
instance.stopBeforeCloseMs = 1500;
|
|
275
|
+
// device.state['105'] intentionally left undefined
|
|
285
276
|
|
|
286
277
|
instance.setTargetDoorState(TDS.CLOSED);
|
|
287
|
-
|
|
288
|
-
expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
|
|
278
|
+
expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
|
|
289
279
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
emitReset(device, '2');
|
|
294
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
295
|
-
// Only stop went out — no direction, because target reverted to OPEN.
|
|
296
|
-
expect(device.update).toHaveBeenCalledTimes(1);
|
|
297
|
-
expect(instance.worker).toBeNull();
|
|
298
|
-
expect(instance.scheduleTimer).toBeNull();
|
|
280
|
+
jest.advanceTimersByTime(1500);
|
|
281
|
+
expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
|
|
299
282
|
});
|
|
300
283
|
|
|
301
|
-
test('
|
|
284
|
+
test('A duplicate CLOSE during the wait does not restart or double-fire', () => {
|
|
302
285
|
const { instance, device } = makeSimpleGarage();
|
|
303
|
-
instance.
|
|
304
|
-
|
|
286
|
+
instance.stopBeforeCloseMs = 1500;
|
|
287
|
+
device.state['105'] = STATE_OPENING_OR_OPEN;
|
|
305
288
|
|
|
306
289
|
instance.setTargetDoorState(TDS.CLOSED);
|
|
307
|
-
|
|
308
|
-
emitReset(device, '2');
|
|
309
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
310
|
-
expect(device.update).toHaveBeenNthCalledWith(2, { '3': true });
|
|
290
|
+
expect(device.update).toHaveBeenCalledTimes(1); // stop
|
|
311
291
|
|
|
312
|
-
|
|
313
|
-
instance.setTargetDoorState(TDS.
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
// another cycle behind the debounce.
|
|
318
|
-
expect(instance.characteristicCurrentDoorState.value).toBe(CDS.CLOSED);
|
|
292
|
+
jest.advanceTimersByTime(1000);
|
|
293
|
+
instance.setTargetDoorState(TDS.CLOSED); // retransmit / second tap
|
|
294
|
+
expect(device.update).toHaveBeenCalledTimes(1); // no extra stop, timer not pushed out
|
|
295
|
+
|
|
296
|
+
jest.advanceTimersByTime(500);
|
|
319
297
|
expect(device.update).toHaveBeenCalledTimes(2);
|
|
298
|
+
expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
|
|
299
|
+
});
|
|
320
300
|
|
|
321
|
-
|
|
322
|
-
|
|
301
|
+
test('OPEN during the wait cancels the pending close and opens instead', () => {
|
|
302
|
+
const { instance, device } = makeSimpleGarage();
|
|
303
|
+
instance.stopBeforeCloseMs = 1500;
|
|
304
|
+
device.state['105'] = STATE_OPENING_OR_OPEN;
|
|
323
305
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
expect(device.update).toHaveBeenNthCalledWith(4, { '1': true });
|
|
306
|
+
instance.setTargetDoorState(TDS.CLOSED);
|
|
307
|
+
expect(device.update).toHaveBeenNthCalledWith(1, { '103': true }); // stop
|
|
327
308
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
expect(instance.
|
|
331
|
-
expect(
|
|
309
|
+
jest.advanceTimersByTime(500);
|
|
310
|
+
instance.setTargetDoorState(TDS.OPEN);
|
|
311
|
+
expect(instance.pendingCloseTimer).toBeNull();
|
|
312
|
+
expect(device.update).toHaveBeenNthCalledWith(2, { '101': true }); // open
|
|
313
|
+
|
|
314
|
+
// The trailing close must never fire.
|
|
315
|
+
jest.advanceTimersByTime(5000);
|
|
316
|
+
expect(device.update).toHaveBeenCalledTimes(2);
|
|
332
317
|
});
|
|
333
318
|
|
|
334
|
-
test('
|
|
335
|
-
const { instance, device } = makeSimpleGarage();
|
|
319
|
+
test('Status reports are ignored during the stop-before-close window', () => {
|
|
320
|
+
const { instance, device, accessory } = makeSimpleGarage();
|
|
321
|
+
instance.stopBeforeCloseMs = 1500;
|
|
322
|
+
device.state['105'] = STATE_OPENING_OR_OPEN;
|
|
336
323
|
instance.currentDoorState = CDS.OPEN;
|
|
324
|
+
instance.characteristicCurrentDoorState.value = CDS.OPEN;
|
|
337
325
|
|
|
338
326
|
instance.setTargetDoorState(TDS.CLOSED);
|
|
339
|
-
|
|
340
|
-
const workerA = instance.worker;
|
|
341
|
-
expect(workerA).not.toBeNull();
|
|
327
|
+
expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
|
|
342
328
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
329
|
+
// The stop makes the controller momentarily report 11 (=OPEN). That
|
|
330
|
+
// must be ignored, or it would bounce Target back to OPEN mid-close.
|
|
331
|
+
emitState(device, STATE_STOPPED);
|
|
332
|
+
expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
|
|
333
|
+
expect(instance.currentDoorState).toBe(CDS.OPEN);
|
|
346
334
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
expect(instance.
|
|
335
|
+
// Close fires, then the controller reports 13 and we resume mirroring.
|
|
336
|
+
jest.advanceTimersByTime(1500);
|
|
337
|
+
expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
|
|
338
|
+
emitState(device, STATE_CLOSING_OR_CLOSED);
|
|
339
|
+
expect(instance.currentDoorState).toBe(CDS.CLOSED);
|
|
340
|
+
expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
|
|
352
341
|
});
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
// ---------------------------------------------------------------------------
|
|
356
|
-
// Timeout fallbacks
|
|
357
|
-
// ---------------------------------------------------------------------------
|
|
358
|
-
describe('SimpleGarageDoorAccessory — timeout fallbacks', () => {
|
|
359
|
-
beforeEach(() => jest.useFakeTimers());
|
|
360
|
-
afterEach(() => jest.useRealTimers());
|
|
361
342
|
|
|
362
|
-
test('
|
|
343
|
+
test('stopBeforeCloseMs is configurable', () => {
|
|
363
344
|
const { instance, device } = makeSimpleGarage();
|
|
364
|
-
instance.
|
|
365
|
-
|
|
366
|
-
instance.setTargetDoorState(TDS.OPEN);
|
|
367
|
-
await jest.advanceTimersByTimeAsync(SETTLE_MS);
|
|
368
|
-
expect(device.update).toHaveBeenCalledTimes(1);
|
|
345
|
+
instance.stopBeforeCloseMs = 300;
|
|
346
|
+
device.state['105'] = STATE_OPENING_OR_OPEN;
|
|
369
347
|
|
|
370
|
-
|
|
371
|
-
expect(device.update).toHaveBeenNthCalledWith(
|
|
348
|
+
instance.setTargetDoorState(TDS.CLOSED);
|
|
349
|
+
expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
|
|
372
350
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
351
|
+
jest.advanceTimersByTime(299);
|
|
352
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
353
|
+
jest.advanceTimersByTime(1);
|
|
354
|
+
expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
|
|
376
355
|
});
|
|
377
356
|
|
|
378
|
-
test('
|
|
357
|
+
test('Custom stop/close DPs are respected in the stop-before-close path', () => {
|
|
379
358
|
const { instance, device } = makeSimpleGarage();
|
|
380
|
-
instance.
|
|
381
|
-
instance.
|
|
359
|
+
instance.dpClose = '2';
|
|
360
|
+
instance.dpStop = '4';
|
|
361
|
+
instance.stopBeforeCloseMs = 1000;
|
|
362
|
+
device.state['105'] = STATE_OPENING_OR_OPEN;
|
|
382
363
|
|
|
383
|
-
instance.setTargetDoorState(TDS.
|
|
384
|
-
|
|
385
|
-
emitReset(device, '2');
|
|
386
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
387
|
-
expect(device.update).toHaveBeenNthCalledWith(2, { '1': true });
|
|
364
|
+
instance.setTargetDoorState(TDS.CLOSED);
|
|
365
|
+
expect(device.update).toHaveBeenNthCalledWith(1, { '4': true });
|
|
388
366
|
|
|
389
|
-
|
|
390
|
-
expect(
|
|
391
|
-
expect(instance.worker).toBeNull();
|
|
367
|
+
jest.advanceTimersByTime(1000);
|
|
368
|
+
expect(device.update).toHaveBeenNthCalledWith(2, { '2': true });
|
|
392
369
|
});
|
|
393
370
|
});
|
|
394
371
|
|
|
@@ -396,18 +373,12 @@ describe('SimpleGarageDoorAccessory — timeout fallbacks', () => {
|
|
|
396
373
|
// Disconnect handling
|
|
397
374
|
// ---------------------------------------------------------------------------
|
|
398
375
|
describe('SimpleGarageDoorAccessory — disconnected', () => {
|
|
399
|
-
|
|
400
|
-
afterEach(() => jest.useRealTimers());
|
|
401
|
-
|
|
402
|
-
test('Skips writes when the device is disconnected', async () => {
|
|
376
|
+
test('Skips writes when the device is disconnected', () => {
|
|
403
377
|
const { instance, device } = makeSimpleGarage();
|
|
404
378
|
device.connected = false;
|
|
405
|
-
instance.currentDoorState = CDS.CLOSED;
|
|
406
379
|
|
|
407
380
|
instance.setTargetDoorState(TDS.OPEN);
|
|
408
|
-
|
|
409
|
-
SETTLE_MS + STOP_RESET_TIMEOUT_MS + POST_RESET_DELAY_MS + DIRECTION_RESET_TIMEOUT_MS
|
|
410
|
-
);
|
|
381
|
+
|
|
411
382
|
expect(device.update).not.toHaveBeenCalled();
|
|
412
383
|
});
|
|
413
384
|
});
|
|
@@ -416,400 +387,149 @@ describe('SimpleGarageDoorAccessory — disconnected', () => {
|
|
|
416
387
|
// Persistence
|
|
417
388
|
// ---------------------------------------------------------------------------
|
|
418
389
|
describe('SimpleGarageDoorAccessory persistence', () => {
|
|
419
|
-
|
|
420
|
-
afterEach(() => jest.useRealTimers());
|
|
421
|
-
|
|
422
|
-
test('Stores the latest target on the accessory context', async () => {
|
|
390
|
+
test('Stores the latest target on the accessory context', () => {
|
|
423
391
|
const { instance, device, accessory } = makeSimpleGarage();
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
instance.setTargetDoorState(TDS.CLOSED);
|
|
427
|
-
expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
|
|
392
|
+
// Already stopped, so the close fires immediately (no dangling timer).
|
|
393
|
+
device.state['105'] = STATE_STOPPED;
|
|
428
394
|
|
|
429
395
|
instance.setTargetDoorState(TDS.OPEN);
|
|
430
396
|
expect(accessory.context.cachedTargetDoorState).toBe(TDS.OPEN);
|
|
431
397
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if (device.update.mock.calls.length > 0) {
|
|
435
|
-
emitReset(device, '2');
|
|
436
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
437
|
-
}
|
|
398
|
+
instance.setTargetDoorState(TDS.CLOSED);
|
|
399
|
+
expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
|
|
438
400
|
});
|
|
439
401
|
});
|
|
440
402
|
|
|
441
403
|
// ---------------------------------------------------------------------------
|
|
442
|
-
// Partial
|
|
404
|
+
// Partial open
|
|
443
405
|
// ---------------------------------------------------------------------------
|
|
444
406
|
describe('SimpleGarageDoorAccessory._handlePartialOpen', () => {
|
|
445
407
|
beforeEach(() => jest.useFakeTimers());
|
|
446
408
|
afterEach(() => jest.useRealTimers());
|
|
447
409
|
|
|
448
|
-
test('
|
|
410
|
+
test('Fires open, then fires stop after partialOpenMs', () => {
|
|
449
411
|
const { instance, device } = makeSimpleGarage();
|
|
450
412
|
instance.partialOpenMs = 2000;
|
|
451
|
-
instance.currentDoorState = CDS.CLOSED;
|
|
452
|
-
instance.characteristicCurrentDoorState.value = CDS.CLOSED;
|
|
453
|
-
instance.desiredTarget = TDS.CLOSED;
|
|
454
413
|
|
|
455
414
|
instance._handlePartialOpen();
|
|
456
415
|
|
|
457
|
-
// Open
|
|
458
|
-
|
|
459
|
-
expect(device.update).toHaveBeenNthCalledWith(1, { '
|
|
460
|
-
|
|
461
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
462
|
-
expect(device.update).toHaveBeenNthCalledWith(2, { '1': true });
|
|
463
|
-
|
|
464
|
-
// Until the open echo lands the stop timer is not scheduled.
|
|
465
|
-
// (Stay short of DIRECTION_RESET_TIMEOUT_MS so the belt-and-braces
|
|
466
|
-
// current-state flip doesn't run yet.)
|
|
467
|
-
await jest.advanceTimersByTimeAsync(1000);
|
|
468
|
-
expect(device.update).toHaveBeenCalledTimes(2);
|
|
416
|
+
// Open goes out immediately.
|
|
417
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
418
|
+
expect(device.update).toHaveBeenNthCalledWith(1, { '101': true });
|
|
419
|
+
expect(instance.characteristicTargetDoorState.value).toBe(TDS.OPEN);
|
|
469
420
|
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
474
|
-
expect(instance.currentDoorState).toBe(CDS.OPEN);
|
|
421
|
+
// Nothing happens until the timer elapses.
|
|
422
|
+
jest.advanceTimersByTime(2000 - 1);
|
|
423
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
475
424
|
|
|
476
|
-
|
|
425
|
+
// Then a single stop is fired.
|
|
426
|
+
jest.advanceTimersByTime(1);
|
|
477
427
|
expect(device.update).toHaveBeenCalledTimes(2);
|
|
428
|
+
expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
|
|
478
429
|
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
expect(device.update).toHaveBeenCalledTimes(3);
|
|
484
|
-
expect(device.update).toHaveBeenNthCalledWith(3, { '2': true });
|
|
485
|
-
|
|
486
|
-
await jest.advanceTimersByTimeAsync(1200);
|
|
487
|
-
expect(device.update).toHaveBeenCalledTimes(6);
|
|
488
|
-
expect(device.update).toHaveBeenNthCalledWith(4, { '2': true });
|
|
489
|
-
expect(device.update).toHaveBeenNthCalledWith(5, { '2': true });
|
|
490
|
-
expect(device.update).toHaveBeenNthCalledWith(6, { '2': true });
|
|
491
|
-
|
|
492
|
-
await jest.advanceTimersByTimeAsync(10_000);
|
|
493
|
-
expect(device.update).toHaveBeenCalledTimes(6);
|
|
494
|
-
// Gate is now partially open — CurrentDoorState stays OPEN.
|
|
495
|
-
expect(instance.currentDoorState).toBe(CDS.OPEN);
|
|
430
|
+
// And nothing more after that.
|
|
431
|
+
jest.advanceTimersByTime(10_000);
|
|
432
|
+
expect(device.update).toHaveBeenCalledTimes(2);
|
|
433
|
+
expect(instance.partialStopTimer).toBeNull();
|
|
496
434
|
});
|
|
497
435
|
|
|
498
|
-
test('
|
|
436
|
+
test('Re-entrant press while armed is ignored (idempotent)', () => {
|
|
499
437
|
const { instance, device } = makeSimpleGarage();
|
|
500
438
|
instance.partialOpenMs = 2000;
|
|
501
|
-
instance.currentDoorState = CDS.OPEN;
|
|
502
439
|
|
|
503
440
|
instance._handlePartialOpen();
|
|
504
|
-
|
|
505
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
506
|
-
// No open cycle needed — desiredTarget==current already.
|
|
507
|
-
expect(device.update).not.toHaveBeenCalled();
|
|
508
|
-
|
|
509
|
-
await jest.advanceTimersByTimeAsync(2000);
|
|
510
|
-
// Four stop sends spread over ~1.2 s; first one fires immediately at
|
|
511
|
-
// partialOpenMs.
|
|
512
441
|
expect(device.update).toHaveBeenCalledTimes(1);
|
|
513
|
-
expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
|
|
514
|
-
await jest.advanceTimersByTimeAsync(1200);
|
|
515
|
-
expect(device.update).toHaveBeenCalledTimes(4);
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
test('Pressing partial again while the stop timer is armed is ignored (idempotent)', async () => {
|
|
519
|
-
// HomeKit/iOS retransmit a WRITE if the 204 response is delayed or
|
|
520
|
-
// dropped, which fires onSet again. The retry must not cancel the
|
|
521
|
-
// armed stop and push it out — otherwise repeated retries can
|
|
522
|
-
// delay the auto-stop indefinitely and the gate runs all the way
|
|
523
|
-
// open. The retry should be ignored; the original timer fires at
|
|
524
|
-
// its original deadline.
|
|
525
|
-
const { instance, device } = makeSimpleGarage();
|
|
526
|
-
instance.partialOpenMs = 2000;
|
|
527
|
-
instance.currentDoorState = CDS.OPEN;
|
|
528
|
-
|
|
529
|
-
instance._handlePartialOpen();
|
|
530
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
531
|
-
|
|
532
|
-
await jest.advanceTimersByTimeAsync(1500);
|
|
533
|
-
// 1.5 s in, retry — should be ignored.
|
|
534
|
-
instance._handlePartialOpen();
|
|
535
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
536
|
-
expect(device.update).not.toHaveBeenCalled();
|
|
537
|
-
|
|
538
|
-
// Original timer fires at the original 2000 ms deadline.
|
|
539
|
-
await jest.advanceTimersByTimeAsync(499);
|
|
540
|
-
expect(device.update).not.toHaveBeenCalled();
|
|
541
|
-
|
|
542
|
-
await jest.advanceTimersByTimeAsync(1);
|
|
543
|
-
expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
|
|
544
|
-
});
|
|
545
|
-
|
|
546
|
-
test('Pressing partial again while the open wait is pending is ignored (idempotent)', async () => {
|
|
547
|
-
const { instance, device } = makeSimpleGarage();
|
|
548
|
-
instance.partialOpenMs = 2000;
|
|
549
|
-
instance.currentDoorState = CDS.CLOSED;
|
|
550
|
-
instance.characteristicCurrentDoorState.value = CDS.CLOSED;
|
|
551
|
-
instance.desiredTarget = TDS.CLOSED;
|
|
552
|
-
|
|
553
|
-
instance._handlePartialOpen();
|
|
554
|
-
await jest.advanceTimersByTimeAsync(SETTLE_MS);
|
|
555
|
-
expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
|
|
556
|
-
|
|
557
|
-
// Retry lands while we're still waiting for the stop reset / open
|
|
558
|
-
// echo. The retry should not bump the generation token or restart
|
|
559
|
-
// anything — the original flow should complete normally.
|
|
560
|
-
const idBefore = instance.partialOpenId;
|
|
561
|
-
instance._handlePartialOpen();
|
|
562
|
-
expect(instance.partialOpenId).toBe(idBefore);
|
|
563
|
-
|
|
564
|
-
emitReset(device, '2');
|
|
565
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
566
|
-
emitReset(device, '1');
|
|
567
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
568
|
-
|
|
569
|
-
await jest.advanceTimersByTimeAsync(2000);
|
|
570
|
-
expect(device.update).toHaveBeenNthCalledWith(3, { '2': true });
|
|
571
|
-
});
|
|
572
|
-
|
|
573
|
-
test('A same-value setTargetDoorState while the partial stop is armed does not cancel it', async () => {
|
|
574
|
-
// HomeKit / iOS Home may write TargetDoorState=OPEN back to the
|
|
575
|
-
// accessory after observing CurrentDoorState flip to OPEN (a
|
|
576
|
-
// resync). desiredTarget is already OPEN from the partial flow's
|
|
577
|
-
// own _setTarget, so this is a no-op write — but the public
|
|
578
|
-
// setTargetDoorState path would still cancel the armed partial
|
|
579
|
-
// stop and the auto-stop would never fire, leaving the gate to
|
|
580
|
-
// run all the way open. The no-op write should be ignored.
|
|
581
|
-
const { instance, device } = makeSimpleGarage();
|
|
582
|
-
instance.partialOpenMs = 2000;
|
|
583
|
-
instance.currentDoorState = CDS.CLOSED;
|
|
584
|
-
instance.characteristicCurrentDoorState.value = CDS.CLOSED;
|
|
585
|
-
instance.desiredTarget = TDS.CLOSED;
|
|
586
|
-
|
|
587
|
-
instance._handlePartialOpen();
|
|
588
|
-
await jest.advanceTimersByTimeAsync(SETTLE_MS);
|
|
589
|
-
emitReset(device, '2');
|
|
590
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
591
|
-
emitReset(device, '1');
|
|
592
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
593
|
-
expect(instance.partialStopTimer).not.toBeNull();
|
|
594
|
-
|
|
595
|
-
// Simulate the HomeKit-side resync write.
|
|
596
|
-
instance.setTargetDoorState(TDS.OPEN);
|
|
597
|
-
expect(instance.partialStopTimer).not.toBeNull();
|
|
598
|
-
|
|
599
|
-
await jest.advanceTimersByTimeAsync(2000);
|
|
600
|
-
expect(device.update).toHaveBeenNthCalledWith(3, { '2': true });
|
|
601
|
-
});
|
|
602
|
-
|
|
603
|
-
test('A same-value setTargetDoorState during the open wait does not supersede the partial flow', async () => {
|
|
604
|
-
const { instance, device } = makeSimpleGarage();
|
|
605
|
-
instance.partialOpenMs = 2000;
|
|
606
|
-
instance.currentDoorState = CDS.CLOSED;
|
|
607
|
-
instance.characteristicCurrentDoorState.value = CDS.CLOSED;
|
|
608
|
-
instance.desiredTarget = TDS.CLOSED;
|
|
609
442
|
|
|
443
|
+
// A retransmit 1.5s in must not re-open or push the stop out.
|
|
444
|
+
jest.advanceTimersByTime(1500);
|
|
610
445
|
instance._handlePartialOpen();
|
|
611
|
-
|
|
612
|
-
expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
|
|
613
|
-
|
|
614
|
-
// HomeKit pushes a TargetDoorState=OPEN write mid-cycle. The partial
|
|
615
|
-
// flow's _setTarget already set desiredTarget=OPEN, so this is
|
|
616
|
-
// redundant — it should not disturb the in-flight partial.
|
|
617
|
-
const idBefore = instance.partialOpenId;
|
|
618
|
-
instance.setTargetDoorState(TDS.OPEN);
|
|
619
|
-
expect(instance.partialOpenId).toBe(idBefore);
|
|
620
|
-
expect(instance._partialPending).toBe(true);
|
|
621
|
-
|
|
622
|
-
emitReset(device, '2');
|
|
623
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
624
|
-
emitReset(device, '1');
|
|
625
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
446
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
626
447
|
|
|
627
|
-
|
|
628
|
-
|
|
448
|
+
// Original timer still fires at its original 2000ms deadline.
|
|
449
|
+
jest.advanceTimersByTime(499);
|
|
450
|
+
expect(device.update).toHaveBeenCalledTimes(1);
|
|
451
|
+
jest.advanceTimersByTime(1);
|
|
452
|
+
expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
|
|
629
453
|
});
|
|
630
454
|
|
|
631
|
-
test('
|
|
455
|
+
test('A direct close cancels the partial auto-stop and runs stop-before-close', () => {
|
|
632
456
|
const { instance, device } = makeSimpleGarage();
|
|
633
457
|
instance.partialOpenMs = 2000;
|
|
634
|
-
instance.
|
|
635
|
-
|
|
636
|
-
instance.desiredTarget = TDS.CLOSED;
|
|
458
|
+
instance.stopBeforeCloseMs = 1500;
|
|
459
|
+
device.state['105'] = STATE_OPENING_OR_OPEN; // gate is moving during the partial
|
|
637
460
|
|
|
638
461
|
instance._handlePartialOpen();
|
|
639
|
-
|
|
640
|
-
// Run the open cycle.
|
|
641
|
-
await jest.advanceTimersByTimeAsync(SETTLE_MS);
|
|
642
|
-
emitReset(device, '2');
|
|
643
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
644
|
-
emitReset(device, '1');
|
|
645
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
646
|
-
expect(instance.currentDoorState).toBe(CDS.OPEN);
|
|
647
462
|
expect(instance.partialStopTimer).not.toBeNull();
|
|
463
|
+
expect(device.update).toHaveBeenNthCalledWith(1, { '101': true }); // open
|
|
648
464
|
|
|
649
|
-
//
|
|
650
|
-
|
|
651
|
-
await jest.advanceTimersByTimeAsync(500);
|
|
465
|
+
// User closes before the partial auto-stop fires.
|
|
466
|
+
jest.advanceTimersByTime(500);
|
|
652
467
|
instance.setTargetDoorState(TDS.CLOSED);
|
|
653
|
-
expect(instance.partialStopTimer).toBeNull();
|
|
654
|
-
|
|
468
|
+
expect(instance.partialStopTimer).toBeNull(); // partial auto-stop cancelled
|
|
469
|
+
expect(device.update).toHaveBeenNthCalledWith(2, { '103': true }); // stop-before-close stop
|
|
655
470
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
instance.partialOpenMs = 2000;
|
|
659
|
-
instance.currentDoorState = CDS.CLOSED;
|
|
660
|
-
instance.characteristicCurrentDoorState.value = CDS.CLOSED;
|
|
661
|
-
instance.desiredTarget = TDS.CLOSED;
|
|
471
|
+
jest.advanceTimersByTime(1500);
|
|
472
|
+
expect(device.update).toHaveBeenNthCalledWith(3, { '102': true }); // close
|
|
662
473
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
667
|
-
emitReset(device, '1');
|
|
668
|
-
// Before the loop notices and arms the stop timer, the user toggles
|
|
669
|
-
// to a real different target (CLOSED — the partial intended OPEN).
|
|
670
|
-
instance.setTargetDoorState(TDS.CLOSED);
|
|
671
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
672
|
-
|
|
673
|
-
// partialOpenId got bumped — the partial flow's wait returns silently.
|
|
674
|
-
expect(instance.partialStopTimer).toBeNull();
|
|
474
|
+
// The cancelled partial stop never fires a stray write.
|
|
475
|
+
jest.advanceTimersByTime(5000);
|
|
476
|
+
expect(device.update).toHaveBeenCalledTimes(3);
|
|
675
477
|
});
|
|
676
478
|
|
|
677
|
-
test('Does nothing when partialOpenMs is not configured',
|
|
479
|
+
test('Does nothing when partialOpenMs is not configured', () => {
|
|
678
480
|
const { instance, device } = makeSimpleGarage();
|
|
679
481
|
instance.partialOpenMs = 0;
|
|
680
|
-
instance.currentDoorState = CDS.OPEN;
|
|
681
482
|
|
|
682
483
|
instance._handlePartialOpen();
|
|
683
|
-
|
|
484
|
+
jest.advanceTimersByTime(10_000);
|
|
684
485
|
|
|
685
486
|
expect(device.update).not.toHaveBeenCalled();
|
|
686
487
|
expect(instance.partialStopTimer).toBeNull();
|
|
687
488
|
});
|
|
688
489
|
});
|
|
689
490
|
|
|
690
|
-
// ---------------------------------------------------------------------------
|
|
691
|
-
// Force open/close switches (just verify they route through setTargetDoorState
|
|
692
|
-
// — the queue/debounce behaviour is already covered by the earlier suites)
|
|
693
|
-
// ---------------------------------------------------------------------------
|
|
694
|
-
describe('SimpleGarageDoorAccessory — force switches', () => {
|
|
695
|
-
beforeEach(() => jest.useFakeTimers());
|
|
696
|
-
afterEach(() => jest.useRealTimers());
|
|
697
|
-
|
|
698
|
-
test('Force Open routes through the public setTargetDoorState path', async () => {
|
|
699
|
-
const { instance, device, accessory } = makeSimpleGarage();
|
|
700
|
-
instance.currentDoorState = CDS.CLOSED;
|
|
701
|
-
instance.characteristicCurrentDoorState.value = CDS.CLOSED;
|
|
702
|
-
instance.desiredTarget = TDS.CLOSED;
|
|
703
|
-
|
|
704
|
-
instance.setTargetDoorState(TDS.OPEN);
|
|
705
|
-
expect(accessory.context.cachedTargetDoorState).toBe(TDS.OPEN);
|
|
706
|
-
expect(instance.desiredTarget).toBe(TDS.OPEN);
|
|
707
|
-
|
|
708
|
-
await jest.advanceTimersByTimeAsync(SETTLE_MS);
|
|
709
|
-
expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
|
|
710
|
-
emitReset(device, '2');
|
|
711
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
712
|
-
expect(device.update).toHaveBeenNthCalledWith(2, { '1': true });
|
|
713
|
-
emitReset(device, '1');
|
|
714
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
715
|
-
expect(instance.currentDoorState).toBe(CDS.OPEN);
|
|
716
|
-
});
|
|
717
|
-
|
|
718
|
-
test('Force Close routes through the public setTargetDoorState path', async () => {
|
|
719
|
-
const { instance, device, accessory } = makeSimpleGarage();
|
|
720
|
-
instance.currentDoorState = CDS.OPEN;
|
|
721
|
-
|
|
722
|
-
instance.setTargetDoorState(TDS.CLOSED);
|
|
723
|
-
expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
|
|
724
|
-
|
|
725
|
-
await jest.advanceTimersByTimeAsync(SETTLE_MS);
|
|
726
|
-
emitReset(device, '2');
|
|
727
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
728
|
-
expect(device.update).toHaveBeenNthCalledWith(2, { '3': true });
|
|
729
|
-
emitReset(device, '3');
|
|
730
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
731
|
-
expect(instance.currentDoorState).toBe(CDS.CLOSED);
|
|
732
|
-
});
|
|
733
|
-
|
|
734
|
-
test('A force action with a different target cancels the in-flight partial', async () => {
|
|
735
|
-
const { instance, device } = makeSimpleGarage();
|
|
736
|
-
instance.partialOpenMs = 2000;
|
|
737
|
-
instance.currentDoorState = CDS.OPEN;
|
|
738
|
-
instance.characteristicCurrentDoorState.value = CDS.OPEN;
|
|
739
|
-
|
|
740
|
-
// Partial press while already open: just schedules the stop timer.
|
|
741
|
-
instance._handlePartialOpen();
|
|
742
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
743
|
-
expect(instance.partialStopTimer).not.toBeNull();
|
|
744
|
-
|
|
745
|
-
// Force Close pressed before the auto-stop fires. The different
|
|
746
|
-
// target indicates a real user override — cancel the partial.
|
|
747
|
-
instance.setTargetDoorState(TDS.CLOSED);
|
|
748
|
-
expect(instance.partialStopTimer).toBeNull();
|
|
749
|
-
|
|
750
|
-
// The new target (CLOSED) is acted on through the regular queue.
|
|
751
|
-
await jest.advanceTimersByTimeAsync(SETTLE_MS);
|
|
752
|
-
expect(device.update).toHaveBeenNthCalledWith(1, { '2': true });
|
|
753
|
-
});
|
|
754
|
-
});
|
|
755
|
-
|
|
756
491
|
// ---------------------------------------------------------------------------
|
|
757
492
|
// Partial-open switch is stateful: mirrors CurrentDoorState (ON when the gate
|
|
758
|
-
// is open)
|
|
493
|
+
// is open) via the status DP.
|
|
759
494
|
// ---------------------------------------------------------------------------
|
|
760
495
|
describe('SimpleGarageDoorAccessory — partial-open switch state', () => {
|
|
761
|
-
|
|
762
|
-
afterEach(() => jest.useRealTimers());
|
|
763
|
-
|
|
764
|
-
test('_setCurrentDoorState mirrors OPEN/CLOSED to the switch', () => {
|
|
496
|
+
test('Reported OPEN/CLOSED is mirrored onto the switch', () => {
|
|
765
497
|
const { instance } = makeSimpleGarage();
|
|
766
498
|
instance.partialOpenMs = 2000;
|
|
767
|
-
instance.currentDoorState = CDS.CLOSED;
|
|
768
499
|
instance.characteristicPartialOpen.value = false;
|
|
769
500
|
|
|
770
|
-
instance.
|
|
501
|
+
instance._onDeviceChange({ '105': STATE_OPENING_OR_OPEN });
|
|
771
502
|
expect(instance.characteristicPartialOpen.value).toBe(true);
|
|
772
503
|
|
|
773
|
-
instance.
|
|
504
|
+
instance._onDeviceChange({ '105': STATE_CLOSING_OR_CLOSED });
|
|
774
505
|
expect(instance.characteristicPartialOpen.value).toBe(false);
|
|
506
|
+
|
|
507
|
+
// A stop mid-travel (state 11) leaves the gate parked open.
|
|
508
|
+
instance._onDeviceChange({ '105': STATE_STOPPED });
|
|
509
|
+
expect(instance.characteristicPartialOpen.value).toBe(true);
|
|
775
510
|
});
|
|
511
|
+
});
|
|
776
512
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
instance
|
|
783
|
-
instance.desiredTarget = TDS.CLOSED;
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
// Force open/close switches route through setTargetDoorState
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
describe('SimpleGarageDoorAccessory — force switches', () => {
|
|
517
|
+
test('Force Open fires the open action', () => {
|
|
518
|
+
const { instance, device, accessory } = makeSimpleGarage();
|
|
784
519
|
|
|
785
|
-
instance.
|
|
786
|
-
await jest.advanceTimersByTimeAsync(SETTLE_MS);
|
|
787
|
-
emitReset(device, '2');
|
|
788
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
789
|
-
emitReset(device, '1');
|
|
790
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
520
|
+
instance.setTargetDoorState(TDS.OPEN);
|
|
791
521
|
|
|
792
|
-
expect(
|
|
793
|
-
expect(
|
|
522
|
+
expect(device.update).toHaveBeenCalledWith({ '101': true });
|
|
523
|
+
expect(accessory.context.cachedTargetDoorState).toBe(TDS.OPEN);
|
|
794
524
|
});
|
|
795
525
|
|
|
796
|
-
test('
|
|
797
|
-
const { instance, device } = makeSimpleGarage();
|
|
798
|
-
|
|
799
|
-
instance.currentDoorState = CDS.OPEN;
|
|
800
|
-
instance.characteristicCurrentDoorState.value = CDS.OPEN;
|
|
801
|
-
instance.characteristicPartialOpen.value = true;
|
|
526
|
+
test('Force Close fires the close action (immediately when already stopped)', () => {
|
|
527
|
+
const { instance, device, accessory } = makeSimpleGarage();
|
|
528
|
+
device.state['105'] = STATE_STOPPED;
|
|
802
529
|
|
|
803
|
-
// Simulate the user tapping the switch OFF — close cycle queued.
|
|
804
530
|
instance.setTargetDoorState(TDS.CLOSED);
|
|
805
|
-
await jest.advanceTimersByTimeAsync(SETTLE_MS);
|
|
806
|
-
emitReset(device, '2');
|
|
807
|
-
await jest.advanceTimersByTimeAsync(POST_RESET_DELAY_MS);
|
|
808
|
-
expect(device.update).toHaveBeenNthCalledWith(2, { '3': true });
|
|
809
|
-
emitReset(device, '3');
|
|
810
|
-
await jest.advanceTimersByTimeAsync(0);
|
|
811
531
|
|
|
812
|
-
expect(
|
|
813
|
-
expect(
|
|
532
|
+
expect(device.update).toHaveBeenCalledWith({ '102': true });
|
|
533
|
+
expect(accessory.context.cachedTargetDoorState).toBe(TDS.CLOSED);
|
|
814
534
|
});
|
|
815
535
|
});
|