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:
@@ -62,6 +62,8 @@ class LocalScheduler {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -455,6 +439,7 @@ class LocalScheduler {
|
|||||||
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() {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ function dwmRuleToForm(rule, defaultDeviceUdn) {
|
|||||||
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) : '',
|
||||||
@@ -445,6 +446,7 @@ export default function RuleEditor({ rule, device, isDwm = false, onSave, onClos
|
|||||||
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>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 countdownAction = form.countdownAction ?? 'on_to_off';
|
||||||
const windowEnabled = form.windowEnabled ?? false;
|
const windowEnabled = form.windowEnabled ?? false;
|
||||||
const windowStartTime = form.windowStartTime ?? '';
|
const windowStartTime = form.windowStartTime ?? '';
|
||||||
const windowEndTime = form.windowEndTime ?? '';
|
const windowEndTime = form.windowEndTime ?? '';
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user