feat: port countdown/away action features to Windows desktop app

- CountdownEditor: new Condition dropdown (ON->OFF / OFF->ON)
- AwayModeEditor: Window Start/End Action dropdowns
- RuleEditor: persist countdownAction field
- scheduler: countdown now state-change-driven with window check;
  away mode respects startAction/endAction; _stopAwayLoop uses endAction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SRS IT
2026-03-28 23:19:16 -04:00
parent 3c155f7cfd
commit 7bd3a81bda
4 changed files with 131 additions and 48 deletions
+81 -35
View File
@@ -61,8 +61,10 @@ class LocalScheduler {
this._onStatus = null; // (statusObj) status callback this._onStatus = null; // (statusObj) status callback
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._healthTimer = null; 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; this._startedAt = null;
} }
@@ -162,6 +164,9 @@ class LocalScheduler {
const endSecs = resolveSecs(Number(rule.endTime ?? -1), rule.endType, rule.endOffset); const endSecs = resolveSecs(Number(rule.endTime ?? -1), rule.endType, rule.endOffset);
if (startSecs === null) continue; // no location set or polar day/night if (startSecs === null) continue; // no location set or polar day/night
const awayStartAction = Number(rule.startAction ?? 1);
const awayEndAction = Number(rule.endAction ?? 0);
for (const dayId of (rule.days ?? [])) { for (const dayId of (rule.days ?? [])) {
const td0 = rule.targetDevices?.[0]; // for status display only const td0 = rule.targetDevices?.[0]; // for status display only
// Window-start entry: fires the away loop // Window-start entry: fires the away loop
@@ -172,7 +177,7 @@ class LocalScheduler {
targetPort: td0?.port ?? 0, targetPort: td0?.port ?? 0,
dayId: Number(dayId), dayId: Number(dayId),
targetSecs: startSecs, targetSecs: startSecs,
action: 1, action: awayStartAction,
isAwayStart: true, isAwayStart: true,
}); });
// Window-end entry: stops the away loop // Window-end entry: stops the away loop
@@ -184,7 +189,7 @@ class LocalScheduler {
targetPort: td0?.port ?? 0, targetPort: td0?.port ?? 0,
dayId: Number(dayId), dayId: Number(dayId),
targetSecs: endSecs, targetSecs: endSecs,
action: 0, action: awayEndAction,
isAwayEnd: true, isAwayEnd: true,
awayRuleId: rule.id, awayRuleId: rule.id,
}); });
@@ -193,31 +198,8 @@ class LocalScheduler {
continue; continue;
} }
// ── Countdown with active window ───────────────────────────────────── // ── Countdown — handled 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 crossesMidnight = windowEnd >= 0 && windowEnd < windowStart;
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: 1 });
if (windowEnd >= 0) {
const offDayId = crossesMidnight ? (Number(dayId) % 7) + 1 : Number(dayId);
schedule.push({ ruleId: rule.id + '-wend', ruleName: rule.name,
targetHost: td.host, targetPort: td.port,
dayId: offDayId, targetSecs: windowEnd, action: 0 });
}
}
}
continue;
}
// ── Schedule / other time-based rules ──────────────────────────────── // ── Schedule / other time-based rules ────────────────────────────────
const startSecs = resolveSecs(Number(rule.startTime ?? -1), rule.startType, rule.startOffset); const startSecs = resolveSecs(Number(rule.startTime ?? -1), rule.startType, rule.startOffset);
@@ -354,13 +336,15 @@ class LocalScheduler {
this._awayLoops.delete(ruleId); this._awayLoops.delete(ruleId);
if (forceOff) { if (forceOff) {
const endAction = Number(loop.rule.endAction ?? 0);
const turnOn = endAction === 1;
for (const td of loop.devices) { for (const td of loop.devices) {
wemo.setBinaryState(td.host, td.port, false).catch(() => {}); wemo.setBinaryState(td.host, td.port, turnOn).catch(() => {});
} }
this._onFire?.({ this._onFire?.({
success: true, success: true,
msg: `"${loop.rule.name}" Away Mode window ended — all devices OFF`, msg: `"${loop.rule.name}" Away Mode window ended — all devices ${turnOn ? 'ON' : 'OFF'}`,
entry: { action: 0 }, entry: { action: endAction },
}); });
} }
} }
@@ -453,8 +437,9 @@ class LocalScheduler {
// Build device map: all targets + trigger source devices // Build device map: all targets + trigger source devices
const deviceMap = new Map(); // 'host:port' → { host, port, name } const deviceMap = new Map(); // 'host:port' → { host, port, name }
const allRules = store.getDwmRules(); const allRules = 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;
@@ -474,7 +459,12 @@ class LocalScheduler {
} }
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 });
}
} }
} }
@@ -518,6 +508,60 @@ class LocalScheduler {
} }
} }
// ── Countdown — fire only when state matches condition and within window ──
if (countdownDevMap.has(key)) {
const prevState = this._countdownStates.get(key);
this._countdownStates.set(key, isOn);
if (prevState !== undefined && prevState !== isOn) {
const nowSecs = secondsFromMidnight(new Date());
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;
// Check active window (if defined)
const winStart = Number(rule.windowStart ?? -1);
const winEnd = Number(rule.windowEnd ?? -1);
if (winStart >= 0 && winEnd >= 0) {
const crossesMidnight = winEnd < winStart;
const inWindow = crossesMidnight
? (nowSecs >= winStart || nowSecs <= winEnd)
: (nowSecs >= winStart && nowSecs <= winEnd);
if (!inWindow) continue;
} else if (winStart >= 0) {
if (nowSecs < winStart) continue;
}
const timerKey = `${key}-${rule.id}`;
const existing = this._countdownTimers.get(timerKey);
if (existing) { clearTimeout(existing.timer); this._countdownTimers.delete(timerKey); }
const wantOn = condition === 'off_to_on';
const durationMs = (Number(rule.countdownTime) || 60) * 1000;
const label = wantOn ? 'ON' : 'OFF';
const mins = Math.round(durationMs / 60000);
this._onFire?.({ 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 wemo.setBinaryState(td.host, td.port, wantOn);
this._onFire?.({ success: true,
msg: `"${rule.name}" countdown elapsed → ${label} (${td.host}) ✓`,
entry: { action: wantOn ? 1 : 0 } });
} catch (e2) {
this._onFire?.({ 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) {
@@ -626,6 +670,8 @@ class LocalScheduler {
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() {
@@ -76,8 +76,9 @@ function dwmRuleToForm(rule, defaultDeviceUdn) {
endTime: rule.endType === 'fixed' && rule.endTime > 0 ? secsToHHMM(rule.endTime) : '', endTime: rule.endType === 'fixed' && rule.endTime > 0 ? secsToHHMM(rule.endTime) : '',
endOffset: rule.endOffset ?? 0, endOffset: rule.endOffset ?? 0,
endAction: rule.endAction ?? -1, endAction: rule.endAction ?? -1,
countdownMins: rule.countdownTime ? Math.round(rule.countdownTime / 60) : 60, countdownMins: rule.countdownTime ? Math.round(rule.countdownTime / 60) : 60,
countdownTime: rule.countdownTime ?? 3600, countdownTime: rule.countdownTime ?? 3600,
countdownAction: rule.countdownAction ?? 'on_to_off',
// Countdown active window // Countdown active window
windowEnabled: rule.windowStart >= 0 && rule.windowStart != null, windowEnabled: rule.windowStart >= 0 && rule.windowStart != null,
windowStartTime: rule.windowStart >= 0 ? secsToHHMM(rule.windowStart) : '', windowStartTime: rule.windowStart >= 0 ? secsToHHMM(rule.windowStart) : '',
@@ -444,7 +445,8 @@ export default function RuleEditor({ rule, device, isDwm = false, onSave, onClos
endType: form.endType || 'fixed', endType: form.endType || 'fixed',
startOffset: form.startOffset ?? 0, startOffset: form.startOffset ?? 0,
endOffset: form.endOffset ?? 0, endOffset: form.endOffset ?? 0,
countdownTime: form.countdownTime ?? 3600, countdownTime: form.countdownTime ?? 3600,
countdownAction: form.countdownAction ?? 'on_to_off',
windowStart, windowStart,
windowEnd, windowEnd,
windowDays, windowDays,
@@ -126,6 +126,30 @@ export default function AwayModeEditor({ form, onChange, sunTimes }) {
configured window. The DWM scheduler handles all randomisation while the app is running. configured window. The DWM scheduler handles all randomisation while the app is running.
</div> </div>
{/* Start / End action */}
<div style={{ display: 'flex', gap: 16, marginBottom: 4 }}>
<div className="form-group" style={{ flex: 1 }}>
<label>Window Start Action</label>
<select
value={form.startAction ?? 1}
onChange={(e) => onChange({ ...form, startAction: Number(e.target.value) })}
>
<option value={1}>Turn ON</option>
<option value={0}>Turn OFF</option>
</select>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label>Window End Action</label>
<select
value={form.endAction ?? 0}
onChange={(e) => onChange({ ...form, endAction: Number(e.target.value) })}
>
<option value={0}>Turn OFF</option>
<option value={1}>Turn ON</option>
</select>
</div>
</div>
{/* Active days */} {/* Active days */}
<div className="form-group"> <div className="form-group">
<label>Active Days</label> <label>Active Days</label>
@@ -2,8 +2,9 @@ import React from 'react';
import DayPicker from '../DayPicker'; import DayPicker from '../DayPicker';
export default function CountdownEditor({ form, onChange }) { export default function CountdownEditor({ form, onChange }) {
const mins = form.countdownMins ?? 60; const mins = form.countdownMins ?? 60;
const windowEnabled = form.windowEnabled ?? false; const countdownAction = form.countdownAction ?? 'on_to_off';
const windowEnabled = form.windowEnabled ?? false;
const windowStartTime = form.windowStartTime ?? ''; const windowStartTime = form.windowStartTime ?? '';
const windowEndTime = form.windowEndTime ?? ''; const windowEndTime = form.windowEndTime ?? '';
const windowDays = form.windowDays ?? ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday']; const windowDays = form.windowDays ?? ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'];
@@ -18,14 +19,26 @@ export default function CountdownEditor({ form, onChange }) {
return ( return (
<> <>
<div className="notice notice-info"> {/* Condition */}
The device automatically turns off after the countdown completes. <div className="form-group">
The countdown starts when the device is manually turned on (or at window start, if an active window is set below). <label>Condition</label>
<select
value={countdownAction}
onChange={(e) => onChange({ ...form, countdownAction: e.target.value })}
>
<option value="on_to_off">If device turns ON auto-OFF after duration</option>
<option value="off_to_on">If device turns OFF auto-ON after duration</option>
</select>
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>
{countdownAction === 'on_to_off'
? 'When the device is turned ON, it will automatically turn OFF after the countdown.'
: 'When the device is turned OFF, it will automatically turn ON after the countdown.'}
</div>
</div> </div>
{/* Countdown duration */} {/* Countdown duration */}
<div className="form-group"> <div className="form-group">
<label>Turn off after (minutes)</label> <label>Duration (minutes)</label>
<input <input
type="number" min="1" max="1440" type="number" min="1" max="1440"
value={mins} value={mins}
@@ -59,9 +72,8 @@ export default function CountdownEditor({ form, onChange }) {
{windowEnabled && ( {windowEnabled && (
<> <>
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12 }}> <p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12 }}>
The scheduler will turn the device <strong>ON</strong> at the window start and The countdown only activates when the device state changes within this time window.
<strong> OFF</strong> at the window end. The countdown auto-off fires in between. State changes outside the window are ignored.
Use this to prevent the timer rule conflicting with other rules outside these hours.
</p> </p>
{/* Window times */} {/* Window times */}
@@ -88,7 +100,6 @@ export default function CountdownEditor({ form, onChange }) {
{windowStartTime && windowEndTime && crossesMidnight() && ( {windowStartTime && windowEndTime && crossesMidnight() && (
<div className="notice notice-info" style={{ marginBottom: 12, fontSize: 12 }}> <div className="notice notice-info" style={{ marginBottom: 12, fontSize: 12 }}>
🌙 Window crosses midnight ends at <strong>{windowEndTime}</strong> the <strong>next day</strong>. 🌙 Window crosses midnight ends at <strong>{windowEndTime}</strong> the <strong>next day</strong>.
The OFF command fires on the following calendar day.
</div> </div>
)} )}