Skip to content

Commit 862a3c4

Browse files
Tiwasclaude
andcommitted
fix(circadian): prewarm safety, light_mode probe, group on/off cards
- Detect unsafe prewarm via realtime onoff listener with 4s wait (poll-with-early-exit), replacing the cached single-fetch check. - Probe light_mode for prewarm safety; gate light_mode and dim writes strictly on per-cap test results so untested caps stay suppressed. - Asymmetric probe timeouts: bail early when onoff trips, otherwise wait the full 4s for slow Z-Wave reports. - Swallow transient Z-Wave errors (TRANSMIT_COMPLETE_NO_ACK et al.) in apply and turn-on/off loops. - Verbose per-device prewarm diagnostics for the first few members per apply pass. - Filter pair candidates to require dim/temp/hue/sat; hide test UI, prewarm checkbox and badges for caps the device lacks. - Add clg_turn_on_all_members and clg_turn_off_all_members flow actions; refactor turn-on into shared helpers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d6a8498 commit 862a3c4

10 files changed

Lines changed: 290 additions & 62 deletions

File tree

.claude/settings.local.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,18 @@
88
"Bash(git add:*)",
99
"Bash(git commit:*)",
1010
"Bash(git push:*)",
11-
"Bash(homey app build:*)"
11+
"Bash(homey app build:*)",
12+
"Bash(homey app *)",
13+
"Bash(npx jest *)",
14+
"Bash(node -e \"for \\(const f of ['en','no','da','de','es','fi','fr','it','nl','pl','sv']\\) { JSON.parse\\(require\\('fs'\\).readFileSync\\('locales/'+f+'.json','utf8'\\)\\); console.log\\(f,'OK'\\); }\")",
15+
"Bash(node -e \"JSON.parse\\(require\\('fs'\\).readFileSync\\('drivers/circadian-light-group/driver.compose.json','utf8'\\)\\); console.log\\('compose OK'\\);\")",
16+
"Bash(node -c drivers/circadian-light-group/driver.js)",
17+
"Bash(node -c drivers/circadian-light-group/device.js)",
18+
"Bash(node -e \"for \\(const f of ['en','no','da','de','es','fi','fr','it','nl','pl','sv']\\) { JSON.parse\\(require\\('fs'\\).readFileSync\\('locales/'+f+'.json','utf8'\\)\\); } console.log\\('all locales OK'\\);\")",
19+
"Bash(node *)",
20+
"Bash(gh api *)",
21+
"Bash(gh search *)",
22+
"WebFetch(domain:github.com)"
1223
]
1324
}
1425
}

no.tiwas.booleantoolbox/.homeychangelog.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,8 @@
5555
},
5656
"1.10.2": {
5757
"en": "More user config options + test capabilities"
58+
},
59+
"1.10.3": {
60+
"en": "Fiddled with capability detection and some code. Also removed devices with only on_off from list as...there's nothing to do with them."
5861
}
5962
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"id": "clg_turn_off_all_members",
3+
"title": {
4+
"en": "Turn off all lights in the group",
5+
"no": "Slå av alle lys i gruppen"
6+
},
7+
"args": [
8+
{
9+
"type": "device",
10+
"name": "device",
11+
"filter": "driver_id=circadian-light-group"
12+
}
13+
],
14+
"titleFormatted": {
15+
"en": "Turn off all lights in the group",
16+
"no": "Slå av alle lys i gruppen"
17+
}
18+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"id": "clg_turn_on_all_members",
3+
"title": {
4+
"en": "Turn on all lights at current circadian level",
5+
"no": "Slå på alle lys med gjeldende dognrytmeniva"
6+
},
7+
"hint": {
8+
"en": "Sets temperature/color and dim on every enabled member, then turns each on. Avoids the brief flash at the previous brightness.",
9+
"no": "Setter temperatur/farge og dim på hvert aktive medlem, deretter slås det paa. Unngaar kort blink ved forrige lysstyrke."
10+
},
11+
"args": [
12+
{
13+
"type": "device",
14+
"name": "device",
15+
"filter": "driver_id=circadian-light-group"
16+
}
17+
],
18+
"titleFormatted": {
19+
"en": "Turn on all lights in the group at current circadian level",
20+
"no": "Slå på alle lys i gruppen med gjeldende dognrytmeniva"
21+
}
22+
}

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

Lines changed: 165 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ const { OutdoorLightProvider } = require('../../lib/OutdoorLightProvider');
66
const { todayKey, defaultDirectionFor, detectCrossing, dateToMinutesOfDay } = require('../../lib/AnchorResolver');
77

88
const 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+
/TRANSMIT_COMPLETE_NO_ACK/i,
14+
/did not respond/i,
15+
/timeout/i,
16+
/TRANSMIT_COMPLETE_FAIL/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

1024
class 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 ----

no.tiwas.booleantoolbox/drivers/circadian-light-group/driver.compose.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@
8787
{ "id": "clg_set_red_threshold" },
8888
{ "id": "clg_apply_state" },
8989
{ "id": "clg_force_red_mode" },
90-
{ "id": "clg_turn_on_member" }
90+
{ "id": "clg_turn_on_member" },
91+
{ "id": "clg_turn_on_all_members" },
92+
{ "id": "clg_turn_off_all_members" }
9193
]
9294
}
9395
}

0 commit comments

Comments
 (0)