@@ -6,6 +6,20 @@ const { OutdoorLightProvider } = require('../../lib/OutdoorLightProvider');
66const { todayKey, defaultDirectionFor, detectCrossing, dateToMinutesOfDay } = require ( '../../lib/AnchorResolver' ) ;
77
88const APPLY_CAPABILITY_DELAY = 150 ;
9+ const PREWARM_ONOFF_WAIT_MS = 4000 ;
10+ const PREWARM_ONOFF_POLL_MS = 100 ;
11+
12+ const TRANSIENT_ERROR_PATTERNS = [
13+ / T R A N S M I T _ C O M P L E T E _ N O _ A C K / i,
14+ / d i d n o t r e s p o n d / i,
15+ / t i m e o u t / i,
16+ / T R A N S M I T _ C O M P L E T E _ F A I L / i,
17+ ] ;
18+
19+ function isTransientDeviceError ( error ) {
20+ const message = `${ error ?. message || '' } ${ error ?. cause ?. error || '' } ${ error ?. cause ?. error_description || '' } ` ;
21+ return TRANSIENT_ERROR_PATTERNS . some ( re => re . test ( message ) ) ;
22+ }
923
1024class CircadianLightGroupDevice extends Homey . Device {
1125 async onInit ( ) {
@@ -289,10 +303,15 @@ class CircadianLightGroupDevice extends Homey.Device {
289303 await this . requestExternalOutdoorLightIfNeeded ( config ) ;
290304
291305 const errors = [ ] ;
306+ const debugCtx = { remaining : 3 , reason } ;
292307 for ( const item of devices ) {
293308 try {
294- await this . applyTargetToDevice ( item , target ) ;
309+ await this . applyTargetToDevice ( item , target , debugCtx ) ;
295310 } catch ( error ) {
311+ if ( isTransientDeviceError ( error ) ) {
312+ this . debug ( `applyTargetToDevice[${ item . name || item . id } ]: transient error ignored — ${ error . message } ` ) ;
313+ continue ;
314+ }
296315 errors . push ( { device : item . name || item . id , error : error . message } ) ;
297316 this . error ( `Failed to apply target to ${ item . name || item . id } :` , error ) ;
298317 }
@@ -324,7 +343,7 @@ class CircadianLightGroupDevice extends Homey.Device {
324343 . catch ( this . error ) ;
325344 }
326345
327- async applyTargetToDevice ( item , target ) {
346+ async applyTargetToDevice ( item , target , debugCtx ) {
328347 if ( ! item . id ) {
329348 this . debug ( `applyTargetToDevice: skipped (no id) for ${ item . name || '<unnamed>' } ` ) ;
330349 return ;
@@ -335,32 +354,95 @@ class CircadianLightGroupDevice extends Homey.Device {
335354 const caps = apiDevice . capabilitiesObj || { } ;
336355 const isOn = caps . onoff ?. value === true ;
337356 const unsafePrewarmDevices = await this . getStoreValue ( 'unsafe_prewarm_devices' ) || { } ;
338- const prewarmBeforeOn = item . prewarmBeforeOn !== false && ! unsafePrewarmDevices [ item . id ] ;
357+ const unsafeEntry = unsafePrewarmDevices [ item . id ] ;
358+ const prewarmBeforeOn = item . prewarmBeforeOn !== false && ! unsafeEntry ;
359+
360+ const verbose = debugCtx && debugCtx . remaining > 0 ;
361+ if ( verbose ) {
362+ debugCtx . remaining -= 1 ;
363+ const capSummary = {
364+ onoff : { value : caps . onoff ?. value , setable : caps . onoff ?. setable } ,
365+ light_mode : { value : caps . light_mode ?. value , setable : caps . light_mode ?. setable } ,
366+ light_temperature : { value : caps . light_temperature ?. value , setable : caps . light_temperature ?. setable } ,
367+ light_hue : { setable : caps . light_hue ?. setable } ,
368+ light_saturation : { setable : caps . light_saturation ?. setable } ,
369+ dim : { value : caps . dim ?. value , setable : caps . dim ?. setable } ,
370+ } ;
371+ this . debug ( `applyTargetToDevice[${ label } ] DIAG: isOn=${ isOn } item.prewarmBeforeOn=${ item . prewarmBeforeOn } unsafeEntry=${ unsafeEntry ? JSON . stringify ( unsafeEntry ) : 'none' } -> prewarmBeforeOn=${ prewarmBeforeOn } ` ) ;
372+ this . debug ( `applyTargetToDevice[${ label } ] DIAG: prewarmSupport=${ JSON . stringify ( item . prewarmSupport || { } ) } redModeAllowed=${ item . redModeAllowed } invertTemperature=${ item . invertTemperature } minDim=${ item . minDim } maxDim=${ item . maxDim } ` ) ;
373+ this . debug ( `applyTargetToDevice[${ label } ] DIAG: caps=${ JSON . stringify ( capSummary ) } ` ) ;
374+ this . debug ( `applyTargetToDevice[${ label } ] DIAG: target={mode:${ target . mode } , dim:${ target . dim } , temp:${ target . temperature } , hue:${ target . hue } , sat:${ target . saturation } }` ) ;
375+ }
339376
340377 if ( ! isOn && ! prewarmBeforeOn ) {
341- this . debug ( `applyTargetToDevice[${ label } ]: skipped (off + prewarm disabled)` ) ;
378+ this . debug ( `applyTargetToDevice[${ label } ]: skipped (off + prewarm disabled) [item.prewarmBeforeOn= ${ item . prewarmBeforeOn } unsafe= ${ ! ! unsafeEntry } ] ` ) ;
342379 return ;
343380 }
344381
345- const capabilitiesToSet = this . getCapabilitiesToSet ( item , target , caps , isOn ) ;
382+ const capabilitiesToSet = this . getCapabilitiesToSet ( item , target , caps , isOn , verbose ? label : null ) ;
346383 if ( capabilitiesToSet . length === 0 ) {
347384 this . debug ( `applyTargetToDevice[${ label } ]: nothing to set (isOn=${ isOn } )` ) ;
348385 return ;
349386 }
350387
351- this . debug ( `applyTargetToDevice[${ label } ]: isOn=${ isOn } writes=${ JSON . stringify ( capabilitiesToSet ) } ` ) ;
388+ const decision = isOn ? 'apply-live' : ( prewarmBeforeOn ? 'prewarm-while-off' : 'unexpected' ) ;
389+ this . debug ( `applyTargetToDevice[${ label } ]: decision=${ decision } isOn=${ isOn } writes=${ JSON . stringify ( capabilitiesToSet ) } ` ) ;
352390
353- for ( const [ capability , value ] of capabilitiesToSet ) {
354- await apiDevice . setCapabilityValue ( capability , value ) ;
355- await new Promise ( resolve => setTimeout ( resolve , APPLY_CAPABILITY_DELAY ) ) ;
391+ let liveOnoff = isOn ;
392+ let onoffInstance = null ;
393+ if ( ! isOn && prewarmBeforeOn ) {
394+ try {
395+ onoffInstance = apiDevice . makeCapabilityInstance ( 'onoff' , ( value ) => {
396+ liveOnoff = value === true ;
397+ } ) ;
398+ } catch ( error ) {
399+ this . debug ( `applyTargetToDevice[${ label } ]: makeCapabilityInstance(onoff) failed: ${ error . message } ` ) ;
400+ }
356401 }
357402
358- if ( ! isOn && prewarmBeforeOn ) {
359- await this . detectUnsafePrewarm ( item ) ;
403+ try {
404+ for ( const [ capability , value ] of capabilitiesToSet ) {
405+ await apiDevice . setCapabilityValue ( capability , value ) ;
406+ await new Promise ( resolve => setTimeout ( resolve , APPLY_CAPABILITY_DELAY ) ) ;
407+ if ( ! isOn && prewarmBeforeOn && liveOnoff === true ) break ;
408+ }
409+
410+ if ( ! isOn && prewarmBeforeOn ) {
411+ const tripped = await this . waitForPrewarmTrip ( ( ) => liveOnoff , PREWARM_ONOFF_WAIT_MS ) ;
412+ if ( tripped ) {
413+ await this . markUnsafePrewarm ( item , label ) ;
414+ } else {
415+ this . debug ( `applyTargetToDevice[${ label } ]: prewarm OK (onoff stayed false ${ PREWARM_ONOFF_WAIT_MS } ms)` ) ;
416+ }
417+ }
418+ } finally {
419+ if ( onoffInstance ) {
420+ try { onoffInstance . destroy ( ) ; } catch ( error ) { /* ignore */ }
421+ }
422+ }
423+ }
424+
425+ async waitForPrewarmTrip ( getOnoff , timeoutMs ) {
426+ const deadline = Date . now ( ) + timeoutMs ;
427+ while ( Date . now ( ) < deadline ) {
428+ if ( getOnoff ( ) === true ) return true ;
429+ await new Promise ( resolve => setTimeout ( resolve , Math . min ( PREWARM_ONOFF_POLL_MS , Math . max ( 0 , deadline - Date . now ( ) ) ) ) ) ;
360430 }
431+ return getOnoff ( ) === true ;
361432 }
362433
363- getCapabilitiesToSet ( item , target , caps , isOn ) {
434+ async markUnsafePrewarm ( item , label ) {
435+ const unsafe = await this . getStoreValue ( 'unsafe_prewarm_devices' ) || { } ;
436+ unsafe [ item . id ] = {
437+ name : item . name ,
438+ detectedAt : new Date ( ) . toISOString ( ) ,
439+ } ;
440+ await this . setStoreValue ( 'unsafe_prewarm_devices' , unsafe ) ;
441+ this . debug ( `markUnsafePrewarm[${ label } ]: marked UNSAFE; future passes will skip prewarm` ) ;
442+ await this . triggerError ( `Prewarm turned on ${ item . name || item . id } . Disable prewarm for this light.` ) ;
443+ }
444+
445+ getCapabilitiesToSet ( item , target , caps , isOn , debugLabel ) {
364446 const result = [ ] ;
365447 const minDim = Number . isFinite ( Number ( item . minDim ) ) ? Number ( item . minDim ) : 0.05 ;
366448 const maxDim = Number . isFinite ( Number ( item . maxDim ) ) ? Number ( item . maxDim ) : 1 ;
@@ -369,38 +451,36 @@ class CircadianLightGroupDevice extends Homey.Device {
369451 const prewarm = item . prewarmSupport || { } ;
370452 const prewarmAllowed = ( capId ) => isOn || prewarm [ capId ] !== false ;
371453
372- if ( caps . light_mode ?. setable && caps . light_mode . value !== mode ) {
454+ const lightModeAllowed = isOn || prewarm . light_mode === true ;
455+ if ( caps . light_mode ?. setable && caps . light_mode . value !== mode && lightModeAllowed ) {
373456 result . push ( [ 'light_mode' , mode ] ) ;
457+ if ( debugLabel ) this . debug ( `getCapabilitiesToSet[${ debugLabel } ]: light_mode ${ caps . light_mode . value } -> ${ mode } (isOn=${ isOn } prewarm.light_mode=${ prewarm . light_mode } )` ) ;
458+ } else if ( debugLabel && caps . light_mode ?. setable && caps . light_mode . value !== mode ) {
459+ this . debug ( `getCapabilitiesToSet[${ debugLabel } ]: light_mode write SUPPRESSED (isOn=${ isOn } prewarm.light_mode=${ prewarm . light_mode } ) — strict opt-in` ) ;
374460 }
375461
376462 if ( mode === 'color' && caps . light_hue ?. setable && caps . light_saturation ?. setable ) {
377463 if ( prewarmAllowed ( 'light_hue' ) ) result . push ( [ 'light_hue' , target . hue ] ) ;
378464 if ( prewarmAllowed ( 'light_saturation' ) ) result . push ( [ 'light_saturation' , target . saturation ] ) ;
465+ if ( debugLabel ) this . debug ( `getCapabilitiesToSet[${ debugLabel } ]: color path, prewarmAllowed(hue)=${ prewarmAllowed ( 'light_hue' ) } prewarmAllowed(sat)=${ prewarmAllowed ( 'light_saturation' ) } ` ) ;
379466 } else if ( caps . light_temperature ?. setable && prewarmAllowed ( 'light_temperature' ) ) {
380467 const temperature = item . invertTemperature === true ? 1 - target . temperature : target . temperature ;
381468 result . push ( [ 'light_temperature' , clamp ( temperature ) ] ) ;
469+ if ( debugLabel ) this . debug ( `getCapabilitiesToSet[${ debugLabel } ]: temperature path, prewarmAllowed(temp)=${ prewarmAllowed ( 'light_temperature' ) } (prewarm.light_temperature=${ prewarm . light_temperature } )` ) ;
470+ } else if ( debugLabel ) {
471+ this . debug ( `getCapabilitiesToSet[${ debugLabel } ]: no color/temperature write — mode=${ mode } hue.setable=${ caps . light_hue ?. setable } sat.setable=${ caps . light_saturation ?. setable } temp.setable=${ caps . light_temperature ?. setable } prewarmAllowed(temp)=${ prewarmAllowed ( 'light_temperature' ) } ` ) ;
382472 }
383473
384- if ( isOn && caps . dim ?. setable ) {
474+ if ( caps . dim ?. setable && ( isOn || prewarm . dim === true ) ) {
385475 result . push ( [ 'dim' , clamp ( target . dim , minDim , maxDim ) ] ) ;
476+ if ( debugLabel ) this . debug ( `getCapabilitiesToSet[${ debugLabel } ]: dim included (isOn=${ isOn } prewarm.dim=${ prewarm . dim } )` ) ;
477+ } else if ( debugLabel && caps . dim ?. setable ) {
478+ this . debug ( `getCapabilitiesToSet[${ debugLabel } ]: dim suppressed (isOn=${ isOn } prewarm.dim=${ prewarm . dim } )` ) ;
386479 }
387480
388481 return result ;
389482 }
390483
391- async detectUnsafePrewarm ( item ) {
392- const refreshed = await this . homey . app . api . devices . getDevice ( { id : item . id } ) ;
393- if ( refreshed . capabilitiesObj ?. onoff ?. value !== true ) return ;
394-
395- const unsafe = await this . getStoreValue ( 'unsafe_prewarm_devices' ) || { } ;
396- unsafe [ item . id ] = {
397- name : item . name ,
398- detectedAt : new Date ( ) . toISOString ( ) ,
399- } ;
400- await this . setStoreValue ( 'unsafe_prewarm_devices' , unsafe ) ;
401- await this . triggerError ( `Prewarm turned on ${ item . name || item . id } . Disable prewarm for this light.` ) ;
402- }
403-
404484 async triggerError ( error ) {
405485 if ( this . getSetting ( 'log_errors' ) !== false ) {
406486 await this . homey . flow . getDeviceTriggerCard ( 'clg_error_occurred' )
@@ -516,16 +596,54 @@ class CircadianLightGroupDevice extends Homey.Device {
516596 const item = ( Array . isArray ( config . devices ) ? config . devices : [ ] ) . find ( d => d . id === memberId ) ;
517597 if ( ! item ) throw new Error ( 'Light is not a member of this group' ) ;
518598
519- const clgActive = this . getCapabilityValue ( 'onoff' ) === true && this . getCapabilityValue ( 'clg_paused' ) !== true ;
520- if ( ! clgActive ) {
521- const apiDevice = await this . homey . app . api . devices . getDevice ( { id : item . id } ) ;
522- if ( apiDevice . capabilitiesObj ?. onoff ?. setable && apiDevice . capabilitiesObj . onoff . value !== true ) {
523- await apiDevice . setCapabilityValue ( 'onoff' , true ) ;
599+ const target = await this . computeCurrentTarget ( config ) ;
600+ await this . turnOnMemberToTarget ( item , target ) ;
601+ return true ;
602+ }
603+
604+ async onFlowTurnOnAllMembers ( ) {
605+ const config = this . getConfig ( ) ;
606+ const members = ( Array . isArray ( config . devices ) ? config . devices : [ ] ) . filter ( d => d . enabled !== false ) ;
607+ if ( members . length === 0 ) return true ;
608+
609+ const target = await this . computeCurrentTarget ( config ) ;
610+ for ( const item of members ) {
611+ try {
612+ await this . turnOnMemberToTarget ( item , target ) ;
613+ } catch ( error ) {
614+ if ( isTransientDeviceError ( error ) ) {
615+ this . debug ( `turn_on_all_members[${ item . name || item . id } ]: transient error ignored — ${ error . message } ` ) ;
616+ continue ;
617+ }
618+ this . error ( `turn_on_all_members[${ item . name || item . id } ] failed:` , error ) ;
524619 }
525- this . debug ( `turn_on_member[${ item . name || item . id } ]: CLG inactive, only set onoff=true` ) ;
526- return true ;
527620 }
621+ return true ;
622+ }
623+
624+ async onFlowTurnOffAllMembers ( ) {
625+ const config = this . getConfig ( ) ;
626+ const members = ( Array . isArray ( config . devices ) ? config . devices : [ ] ) . filter ( d => d . enabled !== false ) ;
627+ if ( members . length === 0 ) return true ;
528628
629+ for ( const item of members ) {
630+ try {
631+ const apiDevice = await this . homey . app . api . devices . getDevice ( { id : item . id } ) ;
632+ if ( apiDevice . capabilitiesObj ?. onoff ?. setable && apiDevice . capabilitiesObj . onoff . value !== false ) {
633+ await apiDevice . setCapabilityValue ( 'onoff' , false ) ;
634+ }
635+ } catch ( error ) {
636+ if ( isTransientDeviceError ( error ) ) {
637+ this . debug ( `turn_off_all_members[${ item . name || item . id } ]: transient error ignored — ${ error . message } ` ) ;
638+ continue ;
639+ }
640+ this . error ( `turn_off_all_members[${ item . name || item . id } ] failed:` , error ) ;
641+ }
642+ }
643+ return true ;
644+ }
645+
646+ async computeCurrentTarget ( config ) {
529647 let outdoor ;
530648 try {
531649 outdoor = await this . outdoorProvider . getOutdoorLight ( config . outdoorLight || { } ) ;
@@ -536,6 +654,19 @@ class CircadianLightGroupDevice extends Homey.Device {
536654 const luxCrossings = await this . getStoreValue ( 'luxCrossings' ) || { } ;
537655 const target = calculateTarget ( config . profile || { } , outdoor , new Date ( ) , { ...geo , luxCrossings } ) ;
538656 this . applyOverridesToTarget ( target ) ;
657+ return target ;
658+ }
659+
660+ async turnOnMemberToTarget ( item , target ) {
661+ const clgActive = this . getCapabilityValue ( 'onoff' ) === true && this . getCapabilityValue ( 'clg_paused' ) !== true ;
662+ if ( ! clgActive ) {
663+ const apiDevice = await this . homey . app . api . devices . getDevice ( { id : item . id } ) ;
664+ if ( apiDevice . capabilitiesObj ?. onoff ?. setable && apiDevice . capabilitiesObj . onoff . value !== true ) {
665+ await apiDevice . setCapabilityValue ( 'onoff' , true ) ;
666+ }
667+ this . debug ( `turn_on_member[${ item . name || item . id } ]: CLG inactive, only set onoff=true` ) ;
668+ return ;
669+ }
539670
540671 const apiDevice = await this . homey . app . api . devices . getDevice ( { id : item . id } ) ;
541672 const caps = apiDevice . capabilitiesObj || { } ;
@@ -568,7 +699,6 @@ class CircadianLightGroupDevice extends Homey.Device {
568699 if ( caps . onoff ?. setable && caps . onoff . value !== true ) {
569700 await apiDevice . setCapabilityValue ( 'onoff' , true ) ;
570701 }
571- return true ;
572702 }
573703
574704 // ---- Flow action handlers ----
0 commit comments