@@ -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