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._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._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;
}
@@ -162,6 +164,9 @@ class LocalScheduler {
const endSecs = resolveSecs(Number(rule.endTime ?? -1), rule.endType, rule.endOffset);
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 ?? [])) {
const td0 = rule.targetDevices?.[0]; // for status display only
// Window-start entry: fires the away loop
@@ -172,7 +177,7 @@ class LocalScheduler {
targetPort: td0?.port ?? 0,
dayId: Number(dayId),
targetSecs: startSecs,
action: 1,
action: awayStartAction,
isAwayStart: true,
});
// Window-end entry: stops the away loop
@@ -184,7 +189,7 @@ class LocalScheduler {
targetPort: td0?.port ?? 0,
dayId: Number(dayId),
targetSecs: endSecs,
action: 0,
action: awayEndAction,
isAwayEnd: true,
awayRuleId: rule.id,
});
@@ -193,31 +198,8 @@ class LocalScheduler {
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 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;
}
// ── Countdown — handled by the health-monitor state-change poll ─────
if (rule.type === 'Countdown') continue;
// ── Schedule / other time-based rules ────────────────────────────────
const startSecs = resolveSecs(Number(rule.startTime ?? -1), rule.startType, rule.startOffset);
@@ -354,13 +336,15 @@ class LocalScheduler {
this._awayLoops.delete(ruleId);
if (forceOff) {
const endAction = Number(loop.rule.endAction ?? 0);
const turnOn = endAction === 1;
for (const td of loop.devices) {
wemo.setBinaryState(td.host, td.port, false).catch(() => {});
wemo.setBinaryState(td.host, td.port, turnOn).catch(() => {});
}
this._onFire?.({
success: true,
msg: `"${loop.rule.name}" Away Mode window ended — all devices OFF`,
entry: { action: 0 },
msg: `"${loop.rule.name}" Away Mode window ended — all devices ${turnOn ? 'ON' : 'OFF'}`,
entry: { action: endAction },
});
}
}
@@ -453,8 +437,9 @@ class LocalScheduler {
// Build device map: all targets + trigger source devices
const deviceMap = new Map(); // 'host:port' → { host, port, name }
const allRules = 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;
@@ -474,7 +459,12 @@ class LocalScheduler {
}
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 });
}
}
}
@@ -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) {
this._deviceHealth.set(key, false);
if (wasOnline !== false) {
@@ -626,6 +670,8 @@ class LocalScheduler {
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() {
@@ -76,8 +76,9 @@ function dwmRuleToForm(rule, defaultDeviceUdn) {
endTime: rule.endType === 'fixed' && rule.endTime > 0 ? secsToHHMM(rule.endTime) : '',
endOffset: rule.endOffset ?? 0,
endAction: rule.endAction ?? -1,
countdownMins: rule.countdownTime ? Math.round(rule.countdownTime / 60) : 60,
countdownTime: rule.countdownTime ?? 3600,
countdownMins: rule.countdownTime ? Math.round(rule.countdownTime / 60) : 60,
countdownTime: rule.countdownTime ?? 3600,
countdownAction: rule.countdownAction ?? 'on_to_off',
// Countdown active window
windowEnabled: rule.windowStart >= 0 && rule.windowStart != null,
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',
startOffset: form.startOffset ?? 0,
endOffset: form.endOffset ?? 0,
countdownTime: form.countdownTime ?? 3600,
countdownTime: form.countdownTime ?? 3600,
countdownAction: form.countdownAction ?? 'on_to_off',
windowStart,
windowEnd,
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.
</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 */}
<div className="form-group">
<label>Active Days</label>
@@ -2,8 +2,9 @@ import React from 'react';
import DayPicker from '../DayPicker';
export default function CountdownEditor({ form, onChange }) {
const mins = form.countdownMins ?? 60;
const windowEnabled = form.windowEnabled ?? false;
const mins = form.countdownMins ?? 60;
const countdownAction = form.countdownAction ?? 'on_to_off';
const windowEnabled = form.windowEnabled ?? false;
const windowStartTime = form.windowStartTime ?? '';
const windowEndTime = form.windowEndTime ?? '';
const windowDays = form.windowDays ?? ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'];
@@ -18,14 +19,26 @@ export default function CountdownEditor({ form, onChange }) {
return (
<>
<div className="notice notice-info">
The device automatically turns off after the countdown completes.
The countdown starts when the device is manually turned on (or at window start, if an active window is set below).
{/* Condition */}
<div className="form-group">
<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>
{/* Countdown duration */}
<div className="form-group">
<label>Turn off after (minutes)</label>
<label>Duration (minutes)</label>
<input
type="number" min="1" max="1440"
value={mins}
@@ -59,9 +72,8 @@ export default function CountdownEditor({ form, onChange }) {
{windowEnabled && (
<>
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12 }}>
The scheduler will turn the device <strong>ON</strong> at the window start and
<strong> OFF</strong> at the window end. The countdown auto-off fires in between.
Use this to prevent the timer rule conflicting with other rules outside these hours.
The countdown only activates when the device state changes within this time window.
State changes outside the window are ignored.
</p>
{/* Window times */}
@@ -88,7 +100,6 @@ export default function CountdownEditor({ form, onChange }) {
{windowStartTime && windowEndTime && crossesMidnight() && (
<div className="notice notice-info" style={{ marginBottom: 12, fontSize: 12 }}>
🌙 Window crosses midnight ends at <strong>{windowEndTime}</strong> the <strong>next day</strong>.
The OFF command fires on the following calendar day.
</div>
)}