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:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user