feat: countdown rule fires only when device matches configured condition
Countdown is now state-change-driven (no scheduled window): - 'If turns ON → auto-OFF after duration' (on_to_off) - 'If turns OFF → auto-ON after duration' (off_to_on) Scheduler polls device state; timer only starts when state matches the chosen condition. Cancels any pending timer if state changes again. Away Mode startAction/endAction already wired; _stopAwayLoop uses endAction. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -399,10 +399,10 @@
|
|||||||
<input type="number" id="dwm-countdown-mins" min="1" max="1440" placeholder="60" />
|
<input type="number" id="dwm-countdown-mins" min="1" max="1440" placeholder="60" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Action when timer fires</label>
|
<label>Condition</label>
|
||||||
<select id="dwm-countdown-action">
|
<select id="dwm-countdown-action">
|
||||||
<option value="1">Turn ON</option>
|
<option value="on_to_off">If device turns ON → auto-OFF after duration</option>
|
||||||
<option value="0">Turn OFF</option>
|
<option value="off_to_on">If device turns OFF → auto-ON after duration</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -196,8 +196,8 @@ function dwmRuleSummary(r) {
|
|||||||
}
|
}
|
||||||
if (r.type === 'Countdown') {
|
if (r.type === 'Countdown') {
|
||||||
const mins = r.countdownTime ? Math.round(r.countdownTime / 60) : null;
|
const mins = r.countdownTime ? Math.round(r.countdownTime / 60) : null;
|
||||||
const action = r.countdownAction === 0 ? 'Turn OFF' : 'Turn ON';
|
const cond = r.countdownAction === 'off_to_on' ? 'OFF→ON' : 'ON→OFF';
|
||||||
return mins ? `⏱ ${mins} min · ${action}` : '—';
|
return mins ? `⏱ ${mins} min · ${cond}` : '—';
|
||||||
}
|
}
|
||||||
const days = dayLabel(r.days);
|
const days = dayLabel(r.days);
|
||||||
const devs = (r.targetDevices ?? []).map((td) => esc(td.name ?? td.host)).join(', ') || 'no targets';
|
const devs = (r.targetDevices ?? []).map((td) => esc(td.name ?? td.host)).join(', ') || 'no targets';
|
||||||
@@ -443,7 +443,7 @@ function openDwmEdit(id) {
|
|||||||
document.getElementById('dwm-end-action').value = String(r.endAction ?? -1);
|
document.getElementById('dwm-end-action').value = String(r.endAction ?? -1);
|
||||||
document.getElementById('dwm-countdown-mins').value =
|
document.getElementById('dwm-countdown-mins').value =
|
||||||
r.countdownTime ? String(Math.round(r.countdownTime / 60)) : '';
|
r.countdownTime ? String(Math.round(r.countdownTime / 60)) : '';
|
||||||
document.getElementById('dwm-countdown-action').value = String(r.countdownAction ?? 1);
|
document.getElementById('dwm-countdown-action').value = r.countdownAction ?? 'on_to_off';
|
||||||
|
|
||||||
_selectedDwmDays = new Set((r.days ?? []).map(Number));
|
_selectedDwmDays = new Set((r.days ?? []).map(Number));
|
||||||
|
|
||||||
@@ -477,7 +477,7 @@ function openDwmEdit(id) {
|
|||||||
document.getElementById('dwm-start-action').value = '1';
|
document.getElementById('dwm-start-action').value = '1';
|
||||||
document.getElementById('dwm-end-action').value = '-1';
|
document.getElementById('dwm-end-action').value = '-1';
|
||||||
document.getElementById('dwm-countdown-mins').value = '';
|
document.getElementById('dwm-countdown-mins').value = '';
|
||||||
document.getElementById('dwm-countdown-action').value = '1';
|
document.getElementById('dwm-countdown-action').value = 'on_to_off';
|
||||||
document.getElementById('dwm-trigger-src').value = '';
|
document.getElementById('dwm-trigger-src').value = '';
|
||||||
document.getElementById('dwm-trigger-event').value = 'any';
|
document.getElementById('dwm-trigger-event').value = 'any';
|
||||||
document.getElementById('dwm-trigger-action').value = 'on';
|
document.getElementById('dwm-trigger-action').value = 'on';
|
||||||
@@ -608,7 +608,7 @@ document.getElementById('dwm-form-save-btn').addEventListener('click', async ()
|
|||||||
const mins = Number(document.getElementById('dwm-countdown-mins').value);
|
const mins = Number(document.getElementById('dwm-countdown-mins').value);
|
||||||
if (!mins || mins < 1) { showModalError('Enter countdown duration in minutes'); return; }
|
if (!mins || mins < 1) { showModalError('Enter countdown duration in minutes'); return; }
|
||||||
rule.countdownTime = mins * 60;
|
rule.countdownTime = mins * 60;
|
||||||
rule.countdownAction = Number(document.getElementById('dwm-countdown-action').value);
|
rule.countdownAction = document.getElementById('dwm-countdown-action').value;
|
||||||
} else {
|
} else {
|
||||||
const startType = document.getElementById('dwm-start-type').value;
|
const startType = document.getElementById('dwm-start-type').value;
|
||||||
const startOffset = parseInt(document.getElementById('dwm-start-offset').value ?? '0', 10) || 0;
|
const startOffset = parseInt(document.getElementById('dwm-start-offset').value ?? '0', 10) || 0;
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ class DwmScheduler {
|
|||||||
this._onHealth = null; // ({host, port, name, online, msg}) health event callback
|
this._onHealth = null; // ({host, port, name, online, msg}) health event callback
|
||||||
this._deviceHealth = new Map(); // 'host:port' → true | false
|
this._deviceHealth = new Map(); // 'host:port' → true | false
|
||||||
this._triggerStates = new Map(); // 'host:port' → last known boolean state (for Trigger rules)
|
this._triggerStates = new Map(); // 'host:port' → last known boolean state (for Trigger rules)
|
||||||
|
this._countdownStates = new Map(); // 'host:port' → last known boolean state (for Countdown rules)
|
||||||
|
this._countdownTimers = new Map(); // 'deviceKey-ruleId' → {timer, wantOn}
|
||||||
this._healthTimer = null;
|
this._healthTimer = null;
|
||||||
this._startedAt = null;
|
this._startedAt = null;
|
||||||
}
|
}
|
||||||
@@ -212,23 +214,8 @@ class DwmScheduler {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Countdown with active window
|
// Countdown — handled entirely by the health-monitor state-change poll
|
||||||
if (rule.type === 'Countdown') {
|
if (rule.type === 'Countdown') continue;
|
||||||
const windowStart = Number(rule.windowStart ?? -1);
|
|
||||||
const windowEnd = Number(rule.windowEnd ?? -1);
|
|
||||||
if (windowStart < 0 || !(rule.windowDays?.length)) continue;
|
|
||||||
|
|
||||||
const countdownAction = Number(rule.countdownAction ?? 1);
|
|
||||||
for (const dayId of rule.windowDays) {
|
|
||||||
for (const td of (rule.targetDevices ?? [])) {
|
|
||||||
if (!td.host || !td.port) continue;
|
|
||||||
schedule.push({ ruleId: rule.id, ruleName: rule.name,
|
|
||||||
targetHost: td.host, targetPort: td.port,
|
|
||||||
dayId: Number(dayId), targetSecs: windowStart, action: countdownAction });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule / time-based
|
// Schedule / time-based
|
||||||
const startSecs = resolveSecs(Number(rule.startTime ?? -1), rule.startType, rule.startOffset, todaySun);
|
const startSecs = resolveSecs(Number(rule.startTime ?? -1), rule.startType, rule.startOffset, todaySun);
|
||||||
@@ -393,6 +380,8 @@ class DwmScheduler {
|
|||||||
for (const t of this._timers) clearTimeout(t);
|
for (const t of this._timers) clearTimeout(t);
|
||||||
this._timers = [];
|
this._timers = [];
|
||||||
if (this._tickTimer) { clearTimeout(this._tickTimer); this._tickTimer = null; }
|
if (this._tickTimer) { clearTimeout(this._tickTimer); this._tickTimer = null; }
|
||||||
|
for (const { timer } of this._countdownTimers.values()) clearTimeout(timer);
|
||||||
|
this._countdownTimers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
_scheduleUpcoming() {
|
_scheduleUpcoming() {
|
||||||
@@ -529,6 +518,7 @@ class DwmScheduler {
|
|||||||
const allRules = this._store.getDwmRules();
|
const allRules = this._store.getDwmRules();
|
||||||
const alwaysOnSet = new Set(); // keys with an active AlwaysOn rule
|
const alwaysOnSet = new Set(); // keys with an active AlwaysOn rule
|
||||||
const triggerSrcSet = new Set(); // keys that are trigger source devices
|
const triggerSrcSet = new Set(); // keys that are trigger source devices
|
||||||
|
const countdownDevMap = new Map(); // deviceKey → [{rule, td}]
|
||||||
|
|
||||||
const addDev = (td) => {
|
const addDev = (td) => {
|
||||||
if (!td?.host || !td?.port) return;
|
if (!td?.host || !td?.port) return;
|
||||||
@@ -548,7 +538,12 @@ class DwmScheduler {
|
|||||||
}
|
}
|
||||||
for (const td of (rule.targetDevices ?? [])) {
|
for (const td of (rule.targetDevices ?? [])) {
|
||||||
const k = addDev(td);
|
const k = addDev(td);
|
||||||
if (k && rule.type === 'AlwaysOn') alwaysOnSet.add(k);
|
if (!k) continue;
|
||||||
|
if (rule.type === 'AlwaysOn') alwaysOnSet.add(k);
|
||||||
|
if (rule.type === 'Countdown') {
|
||||||
|
if (!countdownDevMap.has(k)) countdownDevMap.set(k, []);
|
||||||
|
countdownDevMap.get(k).push({ rule, td });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,6 +587,47 @@ class DwmScheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Countdown — fire only when state matches configured condition ──
|
||||||
|
if (countdownDevMap.has(key)) {
|
||||||
|
const prevState = this._countdownStates.get(key);
|
||||||
|
this._countdownStates.set(key, isOn);
|
||||||
|
if (prevState !== undefined && prevState !== isOn) {
|
||||||
|
for (const { rule, td } of countdownDevMap.get(key)) {
|
||||||
|
const condition = rule.countdownAction ?? 'on_to_off';
|
||||||
|
const triggered = condition === 'on_to_off' ? isOn : !isOn;
|
||||||
|
if (!triggered) continue; // state doesn't match this rule's condition
|
||||||
|
|
||||||
|
const timerKey = `${key}-${rule.id}`;
|
||||||
|
// Cancel any pending timer for this device+rule
|
||||||
|
const existing = this._countdownTimers.get(timerKey);
|
||||||
|
if (existing) { clearTimeout(existing.timer); this._countdownTimers.delete(timerKey); }
|
||||||
|
|
||||||
|
const wantOn = condition === 'off_to_on'; // on_to_off → turn OFF; off_to_on → turn ON
|
||||||
|
const durationMs = (Number(rule.countdownTime) || 60) * 1000;
|
||||||
|
const label = wantOn ? 'ON' : 'OFF';
|
||||||
|
const mins = Math.round(durationMs / 60000);
|
||||||
|
this._emit({ success: true,
|
||||||
|
msg: `"${rule.name}" countdown started — will turn ${label} in ${mins} min (${td.host})`,
|
||||||
|
entry: { action: wantOn ? 1 : 0 } });
|
||||||
|
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
this._countdownTimers.delete(timerKey);
|
||||||
|
try {
|
||||||
|
await this._wemo.setBinaryState(td.host, td.port, wantOn);
|
||||||
|
this._emit({ success: true,
|
||||||
|
msg: `"${rule.name}" countdown elapsed → ${label} (${td.host}) ✓`,
|
||||||
|
entry: { action: wantOn ? 1 : 0 } });
|
||||||
|
} catch (e2) {
|
||||||
|
this._emit({ success: false,
|
||||||
|
msg: `"${rule.name}" countdown elapsed → ${label} FAILED: ${e2.message}`,
|
||||||
|
entry: { action: wantOn ? 1 : 0 } });
|
||||||
|
}
|
||||||
|
}, durationMs);
|
||||||
|
this._countdownTimers.set(timerKey, { timer, wantOn });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._deviceHealth.set(key, false);
|
this._deviceHealth.set(key, false);
|
||||||
if (wasOnline !== false) {
|
if (wasOnline !== false) {
|
||||||
|
|||||||
Reference in New Issue
Block a user