diff --git a/packages/homebridge-plugin/homebridge-ui/public/index.html b/packages/homebridge-plugin/homebridge-ui/public/index.html
index 5c130d3..3fb7f20 100644
--- a/packages/homebridge-plugin/homebridge-ui/public/index.html
+++ b/packages/homebridge-plugin/homebridge-ui/public/index.html
@@ -399,10 +399,10 @@
-
+
diff --git a/packages/homebridge-plugin/homebridge-ui/public/index.js b/packages/homebridge-plugin/homebridge-ui/public/index.js
index 2ec11fa..05178ac 100644
--- a/packages/homebridge-plugin/homebridge-ui/public/index.js
+++ b/packages/homebridge-plugin/homebridge-ui/public/index.js
@@ -196,8 +196,8 @@ function dwmRuleSummary(r) {
}
if (r.type === 'Countdown') {
const mins = r.countdownTime ? Math.round(r.countdownTime / 60) : null;
- const action = r.countdownAction === 0 ? 'Turn OFF' : 'Turn ON';
- return mins ? `⏱ ${mins} min · ${action}` : '—';
+ const cond = r.countdownAction === 'off_to_on' ? 'OFF→ON' : 'ON→OFF';
+ return mins ? `⏱ ${mins} min · ${cond}` : '—';
}
const days = dayLabel(r.days);
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-countdown-mins').value =
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));
@@ -477,7 +477,7 @@ function openDwmEdit(id) {
document.getElementById('dwm-start-action').value = '1';
document.getElementById('dwm-end-action').value = '-1';
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-event').value = 'any';
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);
if (!mins || mins < 1) { showModalError('Enter countdown duration in minutes'); return; }
rule.countdownTime = mins * 60;
- rule.countdownAction = Number(document.getElementById('dwm-countdown-action').value);
+ rule.countdownAction = document.getElementById('dwm-countdown-action').value;
} else {
const startType = document.getElementById('dwm-start-type').value;
const startOffset = parseInt(document.getElementById('dwm-start-offset').value ?? '0', 10) || 0;
diff --git a/packages/homebridge-plugin/lib/scheduler.js b/packages/homebridge-plugin/lib/scheduler.js
index 0f851f8..0c38d12 100644
--- a/packages/homebridge-plugin/lib/scheduler.js
+++ b/packages/homebridge-plugin/lib/scheduler.js
@@ -98,9 +98,11 @@ class DwmScheduler {
this._lastFireMsg = null; // last fire event for heartbeat
this._onStatus = null; // (statusObj) status callback
this._onHealth = null; // ({host, port, name, online, msg}) health event callback
- this._deviceHealth = new Map(); // 'host:port' → true | false
- this._triggerStates = new Map(); // 'host:port' → last known boolean state (for Trigger rules)
- this._healthTimer = null;
+ this._deviceHealth = new Map(); // 'host:port' → true | false
+ 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._startedAt = null;
}
@@ -212,23 +214,8 @@ class DwmScheduler {
continue;
}
- // Countdown with active window
- if (rule.type === 'Countdown') {
- 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;
- }
+ // Countdown — handled entirely by the health-monitor state-change poll
+ if (rule.type === 'Countdown') continue;
// Schedule / time-based
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);
this._timers = [];
if (this._tickTimer) { clearTimeout(this._tickTimer); this._tickTimer = null; }
+ for (const { timer } of this._countdownTimers.values()) clearTimeout(timer);
+ this._countdownTimers.clear();
}
_scheduleUpcoming() {
@@ -527,8 +516,9 @@ class DwmScheduler {
// Build device map: all targets + trigger source devices
const deviceMap = new Map(); // 'host:port' → { host, port, name }
const allRules = this._store.getDwmRules();
- const alwaysOnSet = new Set(); // keys with an active AlwaysOn rule
- const triggerSrcSet = new Set(); // keys that are trigger source devices
+ const alwaysOnSet = new Set(); // keys with an active AlwaysOn rule
+ const triggerSrcSet = new Set(); // keys that are trigger source devices
+ const countdownDevMap = new Map(); // deviceKey → [{rule, td}]
const addDev = (td) => {
if (!td?.host || !td?.port) return;
@@ -548,7 +538,12 @@ class DwmScheduler {
}
for (const td of (rule.targetDevices ?? [])) {
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) {
this._deviceHealth.set(key, false);
if (wasOnline !== false) {