Skip to content

Commit 7781e68

Browse files
committed
Fix circadian group member onoff handling
1 parent 9b68144 commit 7781e68

2 files changed

Lines changed: 531 additions & 35 deletions

File tree

no.tiwas.booleantoolbox/CircadianLightGroupDevice.test.js

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,290 @@ describe('CircadianLightGroupDevice capability selection', () => {
122122
['light_temperature', 0.25],
123123
]);
124124
});
125+
126+
test('only uses tested prewarm capabilities while off', () => {
127+
const device = createDeviceHarness();
128+
const writes = device.getCapabilitiesToSet(
129+
{
130+
prewarmSupport: {
131+
dim: true,
132+
light_temperature: true,
133+
light_hue: false,
134+
light_saturation: true,
135+
light_mode: false,
136+
},
137+
},
138+
{ mode: 'temperature', hue: null, saturation: null, temperature: 0.25, dim: 0.5 },
139+
{
140+
light_mode: setable('color'),
141+
light_temperature: setable(0.8),
142+
light_hue: setable(0.7),
143+
light_saturation: setable(0.4),
144+
dim: setable(0.2),
145+
},
146+
false
147+
);
148+
149+
expect(writes).toEqual([
150+
['light_temperature', 0.75],
151+
['dim', 0.5],
152+
]);
153+
});
154+
155+
test('does not prewarm untested capabilities while off', () => {
156+
const device = createDeviceHarness();
157+
const writes = device.getCapabilitiesToSet(
158+
{
159+
prewarmSupport: {
160+
dim: null,
161+
light_temperature: null,
162+
light_hue: null,
163+
light_saturation: null,
164+
light_mode: null,
165+
},
166+
},
167+
{ mode: 'color', hue: 0, saturation: 1, temperature: 0.1, dim: 0.2 },
168+
{
169+
light_mode: setable('temperature'),
170+
light_temperature: setable(0.8),
171+
light_hue: setable(0.7),
172+
light_saturation: setable(0.4),
173+
dim: setable(0.5),
174+
},
175+
false
176+
);
177+
178+
expect(writes).toEqual([]);
179+
});
180+
});
181+
182+
describe('CircadianLightGroupDevice light application', () => {
183+
test('prewarms supported capabilities while member light is off', async () => {
184+
const device = createDeviceHarness();
185+
device.waitForPrewarmTrip = jest.fn().mockResolvedValue(false);
186+
const apiDevice = {
187+
capabilitiesObj: {
188+
onoff: { setable: true, value: false },
189+
light_temperature: setable(0.8),
190+
dim: setable(0.5),
191+
},
192+
setCapabilityValue: jest.fn(),
193+
makeCapabilityInstance: jest.fn(() => ({ destroy: jest.fn() })),
194+
};
195+
device.homey = {
196+
app: {
197+
api: {
198+
devices: {
199+
getDevice: jest.fn().mockResolvedValue(apiDevice),
200+
},
201+
},
202+
},
203+
};
204+
205+
await device.applyTargetToDevice(
206+
{
207+
id: 'light-1',
208+
name: 'Kitchen',
209+
prewarmBeforeOn: true,
210+
prewarmSupport: { light_temperature: true, dim: true },
211+
},
212+
{ mode: 'temperature', hue: null, saturation: null, temperature: 0.25, dim: 0.5 },
213+
{ remaining: 0 }
214+
);
215+
216+
expect(apiDevice.setCapabilityValue).toHaveBeenCalledWith('light_temperature', 0.75);
217+
expect(apiDevice.setCapabilityValue).toHaveBeenCalledWith('dim', 0.5);
218+
});
219+
220+
test('uses live onoff watcher instead of stale cached onoff value', async () => {
221+
const device = createDeviceHarness();
222+
device.waitForPrewarmTrip = jest.fn().mockResolvedValue(false);
223+
device.memberOnoffWatchers = new Map([
224+
['light-1', { value: false }],
225+
]);
226+
const apiDevice = {
227+
capabilitiesObj: {
228+
onoff: { setable: true, value: true },
229+
light_temperature: setable(0.8),
230+
dim: setable(0.5),
231+
},
232+
setCapabilityValue: jest.fn(),
233+
makeCapabilityInstance: jest.fn(() => ({ destroy: jest.fn() })),
234+
};
235+
device.homey = {
236+
app: {
237+
api: {
238+
devices: {
239+
getDevice: jest.fn().mockResolvedValue(apiDevice),
240+
},
241+
},
242+
},
243+
};
244+
245+
await device.applyTargetToDevice(
246+
{
247+
id: 'light-1',
248+
name: 'Kitchen',
249+
prewarmBeforeOn: true,
250+
prewarmSupport: { light_temperature: false, dim: false },
251+
},
252+
{ mode: 'temperature', hue: null, saturation: null, temperature: 0.25, dim: 0.5 },
253+
{ remaining: 0 }
254+
);
255+
256+
expect(apiDevice.setCapabilityValue).not.toHaveBeenCalled();
257+
});
258+
259+
test('marks prewarm capability unsupported when it turns the light on', async () => {
260+
const device = createDeviceHarness();
261+
const config = {
262+
devices: [{
263+
id: 'light-1',
264+
name: 'Kitchen',
265+
prewarmSupport: { light_temperature: true, dim: true },
266+
}],
267+
};
268+
let onoffListener = null;
269+
const apiDevice = {
270+
capabilitiesObj: {
271+
onoff: { setable: true, value: false },
272+
light_temperature: setable(0.8),
273+
dim: setable(0.5),
274+
},
275+
setCapabilityValue: jest.fn(async (capability) => {
276+
if (capability === 'light_temperature') onoffListener(true);
277+
}),
278+
makeCapabilityInstance: jest.fn((capability, listener) => {
279+
if (capability === 'onoff') onoffListener = listener;
280+
return { destroy: jest.fn() };
281+
}),
282+
};
283+
device.homey = {
284+
app: {
285+
api: {
286+
devices: {
287+
getDevice: jest.fn().mockResolvedValue(apiDevice),
288+
},
289+
},
290+
},
291+
};
292+
device.getConfig = jest.fn(() => config);
293+
device.setSettings = jest.fn();
294+
device.triggerError = jest.fn();
295+
296+
await device.applyTargetToDevice(
297+
{
298+
id: 'light-1',
299+
name: 'Kitchen',
300+
prewarmBeforeOn: true,
301+
prewarmSupport: { light_temperature: true, dim: true },
302+
},
303+
{ mode: 'temperature', hue: null, saturation: null, temperature: 0.25, dim: 0.5 },
304+
{ remaining: 0 }
305+
);
306+
307+
expect(config.devices[0].prewarmSupport.light_temperature).toBe(false);
308+
expect(apiDevice.setCapabilityValue).toHaveBeenCalledWith('onoff', false);
309+
expect(device.setSettings).toHaveBeenCalledWith({
310+
config_json: JSON.stringify(config, null, 2),
311+
});
312+
});
313+
314+
test('reverts delayed re-on after a recent CLG write and user off', async () => {
315+
const device = createDeviceHarness();
316+
const apiDevice = {
317+
setCapabilityValue: jest.fn(),
318+
};
319+
const watcher = {
320+
apiDevice,
321+
value: false,
322+
onoffSetable: true,
323+
lastOffAt: Date.now() - 1000,
324+
lastClgWriteAt: Date.now() - 2000,
325+
lastClgWriteCapability: 'dim',
326+
};
327+
328+
await device.onMemberOnoffChange({ id: 'light-1', name: 'Kitchen' }, watcher, true);
329+
330+
expect(apiDevice.setCapabilityValue).toHaveBeenCalledWith('onoff', false);
331+
expect(watcher.value).toBe(false);
332+
});
333+
334+
test('turns member on before applying capabilities that are not safe to prewarm', async () => {
335+
const device = createDeviceHarness();
336+
device.getCapabilityValue = jest.fn((capability) => capability === 'onoff');
337+
const watcher = {
338+
value: false,
339+
onoffSetable: true,
340+
lastOffAt: Date.now() - 1000,
341+
lastClgWriteAt: null,
342+
lastClgWriteCapability: null,
343+
allowOnUntil: null,
344+
};
345+
device.memberOnoffWatchers = new Map([['light-1', watcher]]);
346+
const apiDevice = {
347+
capabilitiesObj: {
348+
onoff: { setable: true, value: false },
349+
dim: setable(0),
350+
},
351+
setCapabilityValue: jest.fn().mockResolvedValue(undefined),
352+
};
353+
device.homey = {
354+
app: {
355+
api: {
356+
devices: {
357+
getDevice: jest.fn().mockResolvedValue(apiDevice),
358+
},
359+
},
360+
},
361+
};
362+
363+
await device.turnOnMemberToTarget(
364+
{
365+
id: 'light-1',
366+
name: 'Kitchen',
367+
prewarmBeforeOn: true,
368+
prewarmSupport: { dim: false },
369+
},
370+
{ mode: 'temperature', hue: null, saturation: null, temperature: 0.25, dim: 0.5 }
371+
);
372+
373+
const calls = apiDevice.setCapabilityValue.mock.calls;
374+
expect(calls[0]).toEqual(['onoff', true]);
375+
expect(calls).toContainEqual(['dim', 0.5]);
376+
expect(watcher.value).toBe(true);
377+
});
378+
});
379+
380+
describe('CircadianLightGroupDevice onoff persistence', () => {
381+
test('defaults CLG on when no structured persisted state exists', async () => {
382+
const device = createDeviceHarness();
383+
device.getStoreValue = jest.fn(async (key) => (key === 'clg_onoff' ? false : null));
384+
device.setStoreValue = jest.fn().mockResolvedValue(undefined);
385+
device.getCapabilityValue = jest.fn(() => false);
386+
device.setCapabilityValue = jest.fn().mockResolvedValue(undefined);
387+
device.error = jest.fn();
388+
389+
await device.restorePersistedOnoffState();
390+
391+
expect(device.setCapabilityValue).toHaveBeenCalledWith('onoff', true);
392+
expect(device.setStoreValue).toHaveBeenCalledWith('clg_onoff_state', expect.objectContaining({ value: true }));
393+
});
394+
395+
test('restores an explicit structured CLG off state', async () => {
396+
const device = createDeviceHarness();
397+
device.getStoreValue = jest.fn(async (key) => {
398+
if (key === 'clg_onoff_state') return { value: false, updatedAt: '2026-05-08T00:00:00.000Z' };
399+
return true;
400+
});
401+
device.setStoreValue = jest.fn().mockResolvedValue(undefined);
402+
device.getCapabilityValue = jest.fn(() => true);
403+
device.setCapabilityValue = jest.fn().mockResolvedValue(undefined);
404+
device.error = jest.fn();
405+
406+
await device.restorePersistedOnoffState();
407+
408+
expect(device.setCapabilityValue).toHaveBeenCalledWith('onoff', false);
409+
expect(device.setStoreValue).toHaveBeenCalledWith('clg_onoff_state', expect.objectContaining({ value: false }));
410+
});
125411
});

0 commit comments

Comments
 (0)