Skip to content

Commit 496221a

Browse files
Tiwasclaude
andcommitted
feat(circadian): onoff actually turns members on/off; deprecate redundant cards
CLG's onoff capability now physically turns all group members on (with current circadian target) or off, instead of just gating the scheduler. This lets state-capture and other apps interoperate naturally. The clg_pause/clg_resume cards remain for "stop execution without touching the lights" (use clg_pause with amount 0 for indefinite). Marks clg_turn_on/_off/_toggle and clg_turn_on_all_members/_off_all_members as deprecated — they duplicate the standard on/off cards generated from the onoff capability. Handlers stay in place so existing flows keep working. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3e1b9d7 commit 496221a

6 files changed

Lines changed: 161 additions & 25 deletions

File tree

no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_toggle.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"id": "clg_toggle",
3+
"deprecated": true,
34
"title": {
45
"en": "Toggle",
56
"no": "Veksle",

no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_turn_off.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"id": "clg_turn_off",
3+
"deprecated": true,
34
"title": {
45
"en": "Turn off",
56
"no": "Slå av",

no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_turn_off_all_members.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"id": "clg_turn_off_all_members",
3+
"deprecated": true,
34
"title": {
45
"en": "Turn off all lights in the group",
56
"no": "Slå av alle lys i gruppen"

no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_turn_on.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"id": "clg_turn_on",
3+
"deprecated": true,
34
"title": {
45
"en": "Turn on",
56
"no": "Slå på",

no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_turn_on_all_members.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"id": "clg_turn_on_all_members",
3+
"deprecated": true,
34
"title": {
45
"en": "Turn on all lights at current circadian level",
56
"no": "Slå på alle lys med gjeldende dognrytmeniva"

no.tiwas.booleantoolbox/drivers/circadian-light-group/device.js

Lines changed: 156 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ const DEVICE_TASK_CONCURRENCY = 5;
1313
const DEVICE_TASK_MAX_RETRIES = 1;
1414
const CLG_ONOFF_STORE_KEY = 'clg_onoff_state';
1515
const CLG_ONOFF_LEGACY_STORE_KEY = 'clg_onoff';
16+
const CLG_PAUSE_STORE_KEY = 'clg_pause_state';
17+
const MAX_TIMEOUT_MS = 2147483647;
1618

1719
const TRANSIENT_ERROR_PATTERNS = [
1820
/TRANSMIT_COMPLETE_NO_ACK/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

Comments
 (0)