@@ -13,6 +13,8 @@ const DEVICE_TASK_CONCURRENCY = 5;
1313const DEVICE_TASK_MAX_RETRIES = 1 ;
1414const CLG_ONOFF_STORE_KEY = 'clg_onoff_state' ;
1515const CLG_ONOFF_LEGACY_STORE_KEY = 'clg_onoff' ;
16+ const CLG_PAUSE_STORE_KEY = 'clg_pause_state' ;
17+ const MAX_TIMEOUT_MS = 2147483647 ;
1618
1719const TRANSIENT_ERROR_PATTERNS = [
1820 / T R A N S M I T _ C O M P L E T E _ N O _ A C K / i,
@@ -56,14 +58,26 @@ class CircadianLightGroupDevice extends Homey.Device {
5658 this . tempOverride = null ; // { dim?, temperature?, saturation?, forceRed?, forceColor?, expiresAt? }
5759 this . redModeOverride = null ; // { value: true|false, expiresAt? }
5860 this . redOverrideTimer = null ;
61+ this . pauseTimer = null ;
5962
6063 this . registerCapabilityListener ( 'onoff' , async ( value ) => {
6164 await this . setOnoffState ( value ) ;
6265 await this . fireOnoffTrigger ( value ) ;
63- await this . applyCurrentProfile ( { reason : 'onoff-change' } ) ;
66+ if ( value === true ) {
67+ await this . onFlowTurnOnAllMembers ( ) ;
68+ } else {
69+ await this . onFlowTurnOffAllMembers ( ) ;
70+ }
6471 } ) ;
6572
6673 this . registerCapabilityListener ( 'clg_paused' , async ( value ) => {
74+ this . pauseDebug ( `capability changed value=${ value } ` ) ;
75+ this . clearPauseTimer ( ) ;
76+ if ( value === true ) {
77+ await this . persistPauseState ( null ) ;
78+ } else {
79+ await this . clearPersistedPauseState ( ) ;
80+ }
6781 await this . firePauseTrigger ( value ) ;
6882 await this . applyCurrentProfile ( { reason : 'paused-change' } ) ;
6983 } ) ;
@@ -73,6 +87,7 @@ class CircadianLightGroupDevice extends Homey.Device {
7387
7488 await this . ensureDefaultCapabilityValues ( ) ;
7589 await this . restorePersistedOnoffState ( ) ;
90+ await this . restorePersistedPauseState ( ) ;
7691
7792 await this . setupLuxWatchers ( ) ;
7893 await this . setupMemberOnoffWatchers ( ) ;
@@ -287,6 +302,10 @@ class CircadianLightGroupDevice extends Homey.Device {
287302 }
288303 }
289304
305+ pauseDebug ( message ) {
306+ this . debug ( '[CLG pause]' , message ) ;
307+ }
308+
290309 getGeo ( ) {
291310 try {
292311 const geo = this . homey . geolocation ;
@@ -359,6 +378,121 @@ class CircadianLightGroupDevice extends Homey.Device {
359378 await this . setOnoffState ( desired ) ;
360379 }
361380
381+ normalizePauseUnit ( unit ) {
382+ const value = typeof unit === 'object' && unit !== null ? unit . id : unit ;
383+ const normalized = String ( value || 'minutes' ) . trim ( ) . toLowerCase ( ) ;
384+ if ( [ 'second' , 'seconds' , 's' , 'sec' , 'secs' , 'sekund' , 'sekunder' ] . includes ( normalized ) ) return 'seconds' ;
385+ if ( [ 'hour' , 'hours' , 'h' , 'hr' , 'hrs' , 'time' , 'timer' ] . includes ( normalized ) ) return 'hours' ;
386+ return 'minutes' ;
387+ }
388+
389+ getPauseDurationMs ( args = { } ) {
390+ if ( args . amount !== undefined || args . unit !== undefined ) {
391+ const amount = Math . max ( 0 , Number ( args . amount ) || 0 ) ;
392+ const unit = this . normalizePauseUnit ( args . unit ) ;
393+ const factor = unit === 'seconds' ? 1000 : unit === 'hours' ? 3600000 : 60000 ;
394+ return amount * factor ;
395+ }
396+
397+ return Math . max ( 0 , Number ( args . minutes ) || 0 ) * 60000 ;
398+ }
399+
400+ describePauseArgs ( args = { } ) {
401+ const unit = typeof args . unit === 'object' && args . unit !== null ? args . unit . id : args . unit ;
402+ return JSON . stringify ( {
403+ amount : args . amount ,
404+ unit,
405+ minutes : args . minutes ,
406+ } ) ;
407+ }
408+
409+ clearPauseTimer ( ) {
410+ if ( this . pauseTimer ) {
411+ clearTimeout ( this . pauseTimer ) ;
412+ this . pauseTimer = null ;
413+ }
414+ }
415+
416+ async persistPauseState ( expiresAt ) {
417+ this . pauseDebug ( `persist paused=true expiresAt=${ Number . isFinite ( expiresAt ) ? new Date ( expiresAt ) . toISOString ( ) : 'manual' } ` ) ;
418+ await this . setStoreValue ( CLG_PAUSE_STORE_KEY , {
419+ paused : true ,
420+ expiresAt : Number . isFinite ( expiresAt ) ? expiresAt : null ,
421+ updatedAt : new Date ( ) . toISOString ( ) ,
422+ } ) . catch ( this . error ) ;
423+ }
424+
425+ async clearPersistedPauseState ( ) {
426+ this . pauseDebug ( 'persist paused=false' ) ;
427+ await this . setStoreValue ( CLG_PAUSE_STORE_KEY , {
428+ paused : false ,
429+ expiresAt : null ,
430+ updatedAt : new Date ( ) . toISOString ( ) ,
431+ } ) . catch ( this . error ) ;
432+ }
433+
434+ schedulePauseResume ( expiresAt ) {
435+ this . clearPauseTimer ( ) ;
436+
437+ const remainingMs = Number ( expiresAt ) - Date . now ( ) ;
438+ if ( ! Number . isFinite ( remainingMs ) || remainingMs <= 0 ) return ;
439+
440+ const delay = Math . min ( remainingMs , MAX_TIMEOUT_MS ) ;
441+ this . pauseDebug ( `schedule resume in ${ Math . round ( remainingMs / 1000 ) } s at ${ new Date ( Number ( expiresAt ) ) . toISOString ( ) } ` ) ;
442+ this . pauseTimer = setTimeout ( ( ) => {
443+ this . pauseTimer = null ;
444+ if ( remainingMs > MAX_TIMEOUT_MS ) {
445+ this . pauseDebug ( 'timer chunk elapsed; rescheduling remaining pause' ) ;
446+ this . restorePersistedPauseState ( ) . catch ( this . error ) ;
447+ return ;
448+ }
449+ this . pauseDebug ( 'timer elapsed; resuming' ) ;
450+ this . onFlowResume ( { } ) . catch ( this . error ) ;
451+ } , delay ) ;
452+ }
453+
454+ async restorePersistedPauseState ( ) {
455+ let storedState = null ;
456+ try {
457+ storedState = await this . getStoreValue ( CLG_PAUSE_STORE_KEY ) ;
458+ } catch ( error ) {
459+ storedState = null ;
460+ }
461+
462+ const isPaused = this . getCapabilityValue ( 'clg_paused' ) === true ;
463+ this . pauseDebug ( `restore stored=${ JSON . stringify ( storedState ) } capability=${ isPaused } ` ) ;
464+ if ( ! storedState || storedState . paused !== true ) {
465+ this . clearPauseTimer ( ) ;
466+ if ( isPaused ) {
467+ this . pauseDebug ( 'restore found paused capability without stored expiry; treating as manual pause' ) ;
468+ await this . persistPauseState ( null ) ;
469+ }
470+ return ;
471+ }
472+
473+ const expiresAt = Number ( storedState . expiresAt ) ;
474+ if ( Number . isFinite ( expiresAt ) ) {
475+ if ( expiresAt <= Date . now ( ) ) {
476+ this . pauseDebug ( `restore found expired pause (${ new Date ( expiresAt ) . toISOString ( ) } ); resuming now` ) ;
477+ await this . onFlowResume ( ) ;
478+ return ;
479+ }
480+ if ( ! isPaused ) {
481+ this . pauseDebug ( 'restore found future pause while capability was false; setting paused=true' ) ;
482+ await this . setCapabilityValue ( 'clg_paused' , true ) ;
483+ }
484+ this . schedulePauseResume ( expiresAt ) ;
485+ return ;
486+ }
487+
488+ if ( ! isPaused ) {
489+ this . pauseDebug ( 'restore found manual pause while capability was false; setting paused=true' ) ;
490+ await this . setCapabilityValue ( 'clg_paused' , true ) ;
491+ }
492+ this . clearPauseTimer ( ) ;
493+ await this . persistPauseState ( null ) ;
494+ }
495+
362496 getConfig ( ) {
363497 const json = this . getSetting ( 'config_json' ) ;
364498 if ( ! json ) return { profile : { } , outdoorLight : { } , devices : [ ] } ;
@@ -1071,22 +1205,24 @@ class CircadianLightGroupDevice extends Homey.Device {
10711205 }
10721206
10731207 async onFlowTurnOn ( ) {
1074- if ( this . getCapabilityValue ( 'onoff' ) !== true ) {
1208+ const wasOn = this . getCapabilityValue ( 'onoff' ) === true ;
1209+ if ( ! wasOn ) {
10751210 await this . setCapabilityValue ( 'onoff' , true ) ;
10761211 await this . persistOnoffState ( true ) ;
10771212 await this . fireOnoffTrigger ( true ) ;
1078- await this . applyCurrentProfile ( { reason : 'flow-turn-on' } ) ;
10791213 }
1214+ await this . onFlowTurnOnAllMembers ( ) ;
10801215 return true ;
10811216 }
10821217
10831218 async onFlowTurnOff ( ) {
1084- if ( this . getCapabilityValue ( 'onoff' ) !== false ) {
1219+ const wasOn = this . getCapabilityValue ( 'onoff' ) === true ;
1220+ if ( wasOn ) {
10851221 await this . setCapabilityValue ( 'onoff' , false ) ;
10861222 await this . persistOnoffState ( false ) ;
10871223 await this . fireOnoffTrigger ( false ) ;
1088- await this . applyCurrentProfile ( { reason : 'flow-turn-off' } ) ;
10891224 }
1225+ await this . onFlowTurnOffAllMembers ( ) ;
10901226 return true ;
10911227 }
10921228
@@ -1095,7 +1231,11 @@ class CircadianLightGroupDevice extends Homey.Device {
10951231 await this . setCapabilityValue ( 'onoff' , next ) ;
10961232 await this . persistOnoffState ( next ) ;
10971233 await this . fireOnoffTrigger ( next ) ;
1098- await this . applyCurrentProfile ( { reason : 'flow-toggle' } ) ;
1234+ if ( next ) {
1235+ await this . onFlowTurnOnAllMembers ( ) ;
1236+ } else {
1237+ await this . onFlowTurnOffAllMembers ( ) ;
1238+ }
10991239 return true ;
11001240 }
11011241
@@ -1191,39 +1331,30 @@ class CircadianLightGroupDevice extends Homey.Device {
11911331 }
11921332
11931333 async onFlowPause ( args ) {
1194- // Backwards compatibility: old card used { minutes }, new card uses { amount, unit }.
1195- let ms = 0 ;
1196- if ( args && ( args . amount !== undefined || args . unit !== undefined ) ) {
1197- const amount = Number ( args . amount ) || 0 ;
1198- const unit = args . unit || 'minutes' ;
1199- const factor = unit === 'seconds' ? 1000 : unit === 'hours' ? 3600000 : 60000 ;
1200- ms = amount * factor ;
1201- } else {
1202- ms = ( Number ( args . minutes ) || 0 ) * 60000 ;
1203- }
1334+ const ms = this . getPauseDurationMs ( args ) ;
1335+ const expiresAt = ms > 0 ? Date . now ( ) + ms : null ;
12041336
12051337 const wasPaused = this . getCapabilityValue ( 'clg_paused' ) === true ;
1338+ this . pauseDebug ( `flow pause args=${ this . describePauseArgs ( args ) } durationMs=${ ms } expiresAt=${ expiresAt ? new Date ( expiresAt ) . toISOString ( ) : 'manual' } wasPaused=${ wasPaused } ` ) ;
1339+ this . clearPauseTimer ( ) ;
12061340 await this . setCapabilityValue ( 'clg_paused' , true ) ;
1341+ await this . persistPauseState ( expiresAt ) ;
12071342 if ( ! wasPaused ) await this . firePauseTrigger ( true ) ;
12081343
12091344 if ( ms > 0 ) {
1210- if ( this . pauseTimer ) clearTimeout ( this . pauseTimer ) ;
1211- this . pauseTimer = setTimeout ( ( ) => {
1212- this . onFlowResume ( { } ) . catch ( this . error ) ;
1213- } , ms ) ;
1345+ this . schedulePauseResume ( expiresAt ) ;
12141346 }
12151347
12161348 return true ;
12171349 }
12181350
12191351 async onFlowResume ( ) {
1220- if ( this . pauseTimer ) {
1221- clearTimeout ( this . pauseTimer ) ;
1222- this . pauseTimer = null ;
1223- }
1352+ this . clearPauseTimer ( ) ;
12241353
12251354 const wasPaused = this . getCapabilityValue ( 'clg_paused' ) === true ;
1355+ this . pauseDebug ( `flow resume wasPaused=${ wasPaused } ` ) ;
12261356 await this . setCapabilityValue ( 'clg_paused' , false ) ;
1357+ await this . clearPersistedPauseState ( ) ;
12271358 if ( wasPaused ) await this . firePauseTrigger ( false ) ;
12281359 await this . applyCurrentProfile ( { reason : 'flow-resume' } ) ;
12291360 return true ;
@@ -1260,7 +1391,7 @@ class CircadianLightGroupDevice extends Homey.Device {
12601391 async onDeleted ( ) {
12611392 this . deleted = true ;
12621393 this . stopScheduler ( ) ;
1263- if ( this . pauseTimer ) clearTimeout ( this . pauseTimer ) ;
1394+ this . clearPauseTimer ( ) ;
12641395 await this . teardownLuxWatchers ( ) ;
12651396 await this . teardownMemberOnoffWatchers ( ) ;
12661397 }
0 commit comments