3c155f7cfd
UI: Active Window Start/End time inputs on countdown form. Leave blank = runs any time. End before start = crosses midnight. Scheduler: checks current time against window before starting timer; supports cross-midnight windows (e.g. 9:00 AM to 4:00 AM next day). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
800 lines
31 KiB
JavaScript
800 lines
31 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* DWM Scheduler — Homebridge edition.
|
||
*
|
||
* Identical logic to the desktop LocalScheduler but takes store + wemoClient
|
||
* as constructor dependencies instead of top-level requires.
|
||
*
|
||
* Rule types handled:
|
||
* - Schedule / Away (fixed times) → pre-computed {dayId, targetSecs, action} entries
|
||
* - Countdown with active window → ON at windowStart, OFF at windowEnd (cross-midnight aware)
|
||
* - Away Mode → randomisation loop: ON 30–90 min, OFF 1–15 min within window
|
||
* - AlwaysOn → health monitor enforces ON every 10 s; no schedule entry
|
||
* - Trigger → if device A changes state, fire action on device B
|
||
*
|
||
* Usage:
|
||
* const scheduler = new DwmScheduler({ store, wemoClient, log });
|
||
* scheduler.onFire(({ success, msg }) => log.info(msg));
|
||
* await scheduler.start();
|
||
*/
|
||
|
||
const { sunTimes: calcSunTimes } = require('./sun');
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
/** Wemo DayID: 1=Mon … 7=Sun. JS getDay(): 0=Sun … 6=Sat. */
|
||
function jsToWemoDayId(jsDay) { return jsDay === 0 ? 7 : jsDay; }
|
||
|
||
/**
|
||
* Resolve a stored startTime/endTime value to actual seconds-from-midnight.
|
||
* -2 = sunrise sentinel, -3 = sunset sentinel.
|
||
* offsetMins is added to the sun time (negative = before).
|
||
* Returns null if unresolvable (no location, polar day/night, or no time set).
|
||
*/
|
||
function resolveSecs(rawSecs, type, offsetMins, todaySun) {
|
||
const offsetSecs = (offsetMins ?? 0) * 60;
|
||
if (type === 'sunset' || rawSecs === -3) {
|
||
return todaySun?.sunset != null ? todaySun.sunset + offsetSecs : null;
|
||
}
|
||
if (type === 'sunrise' || rawSecs === -2) {
|
||
return todaySun?.sunrise != null ? todaySun.sunrise + offsetSecs : null;
|
||
}
|
||
return rawSecs >= 0 ? rawSecs : null;
|
||
}
|
||
|
||
/** Compute today's sunrise/sunset from the store's saved location. Returns null if not set. */
|
||
function getTodaySun(store) {
|
||
const loc = store.getLocation?.();
|
||
if (!loc?.lat || !loc?.lng) return null;
|
||
try { return calcSunTimes(loc.lat, loc.lng); } catch { return null; }
|
||
}
|
||
|
||
function secondsFromMidnight(date) {
|
||
return date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds();
|
||
}
|
||
|
||
function secsToHHMM(secs) {
|
||
const h = Math.floor(secs / 3600) % 24;
|
||
const m = Math.floor((secs % 3600) / 60);
|
||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||
}
|
||
|
||
function actionLabel(a) {
|
||
return a === 1 ? 'ON' : a === 0 ? 'OFF' : `action(${a})`;
|
||
}
|
||
|
||
function randBetween(min, max) {
|
||
return min + Math.floor(Math.random() * (max - min + 1));
|
||
}
|
||
|
||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||
|
||
const HEALTH_POLL_MS = 10_000; // poll devices every 10 seconds
|
||
const CATCHUP_WINDOW_S = 10 * 60; // catch up rules missed within last 10 minutes
|
||
|
||
// ── DwmScheduler ─────────────────────────────────────────────────────────────
|
||
|
||
class DwmScheduler {
|
||
/**
|
||
* @param {object} deps
|
||
* @param {import('./store')} deps.store - DwmStore instance
|
||
* @param {object} deps.wemoClient - wemo-client module
|
||
* @param {{ info, warn, error }} deps.log - Homebridge log object
|
||
*/
|
||
constructor({ store, wemoClient, log }) {
|
||
this._store = store;
|
||
this._wemo = wemoClient;
|
||
this._log = log ?? console;
|
||
|
||
this._schedule = []; // pre-computed time entries for Schedule/Countdown rules
|
||
this._awayLoops = new Map(); // ruleId → away-loop state for active Away Mode rules
|
||
this._firedToday = new Set(); // prevent double-firing within a tick window
|
||
this._timers = [];
|
||
this._tickTimer = null;
|
||
this._running = false;
|
||
this._lastDate = null;
|
||
this._onFire = null; // ({success, msg, entry}) notification callback
|
||
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._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;
|
||
}
|
||
|
||
// ── Public API ────────────────────────────────────────────────────────────
|
||
|
||
isRunning() { return this._running; }
|
||
|
||
// Internal helper — records every fire event then forwards to caller
|
||
_emit(event) {
|
||
this._lastFireMsg = { msg: event.msg, success: event.success, at: new Date().toISOString() };
|
||
this._onFire?.(event);
|
||
}
|
||
|
||
onFire(cb) { this._onFire = cb; }
|
||
onStatus(cb) { this._onStatus = cb; }
|
||
onHealth(cb) { this._onHealth = cb; }
|
||
|
||
getHealthStatus() {
|
||
const out = {};
|
||
for (const [key, online] of this._deviceHealth) out[key] = online;
|
||
return out;
|
||
}
|
||
|
||
async start() {
|
||
if (this._running) this._clearTimers();
|
||
this._running = true;
|
||
this._startedAt = new Date();
|
||
this._firedToday = new Set();
|
||
|
||
this._loadSchedule();
|
||
this._resumeAwayLoops();
|
||
this._catchUpMissedRules();
|
||
this._tick();
|
||
this._startHealthMonitor();
|
||
|
||
const status = this._buildStatus();
|
||
this._onStatus?.(status);
|
||
this._log.info?.('[DWM Scheduler] Started — ' + this._schedule.length + ' schedule entries loaded');
|
||
return status;
|
||
}
|
||
|
||
stop() {
|
||
this._running = false;
|
||
this._clearTimers();
|
||
this._stopAllAwayLoops(false);
|
||
this._stopHealthMonitor();
|
||
this._schedule = [];
|
||
this._firedToday = new Set();
|
||
this._lastDate = null;
|
||
this._deviceHealth = new Map();
|
||
this._triggerStates = new Map();
|
||
this._log.info?.('[DWM Scheduler] Stopped');
|
||
return { running: false };
|
||
}
|
||
|
||
reload() {
|
||
if (!this._running) return;
|
||
this._stopAllAwayLoops(false);
|
||
this._loadSchedule();
|
||
this._catchUpMissedRules();
|
||
this._scheduleUpcoming();
|
||
this._resumeAwayLoops();
|
||
const status = this._buildStatus();
|
||
this._onStatus?.(status);
|
||
this._log.info?.('[DWM Scheduler] Reloaded — ' + this._schedule.length + ' schedule entries');
|
||
return status;
|
||
}
|
||
|
||
getStatus() { return this._buildStatus(); }
|
||
|
||
// ── Schedule loading ──────────────────────────────────────────────────────
|
||
|
||
_loadSchedule() {
|
||
const schedule = [];
|
||
const rules = this._store.getDwmRules();
|
||
const todaySun = getTodaySun(this._store);
|
||
|
||
for (const rule of rules) {
|
||
if (!rule.enabled) continue;
|
||
|
||
// ── AlwaysOn / Trigger — handled entirely by the health-monitor poll ──
|
||
if (rule.type === 'AlwaysOn' || rule.type === 'Trigger') continue;
|
||
|
||
// Away Mode
|
||
if (rule.type === 'Away') {
|
||
const startSecs = resolveSecs(Number(rule.startTime ?? -1), rule.startType, rule.startOffset, todaySun);
|
||
const endSecs = resolveSecs(Number(rule.endTime ?? -1), rule.endType, rule.endOffset, todaySun);
|
||
if (startSecs === null) continue;
|
||
const awayStartAction = Number(rule.startAction ?? 1);
|
||
const awayEndAction = Number(rule.endAction ?? 0);
|
||
|
||
for (const dayId of (rule.days ?? [])) {
|
||
const td0 = rule.targetDevices?.[0];
|
||
schedule.push({
|
||
ruleId: rule.id, ruleName: rule.name,
|
||
targetHost: td0?.host ?? '', targetPort: td0?.port ?? 0,
|
||
dayId: Number(dayId), targetSecs: startSecs,
|
||
action: awayStartAction, isAwayStart: true,
|
||
});
|
||
if (endSecs !== null && endSecs >= 0) {
|
||
schedule.push({
|
||
ruleId: rule.id + '-away-end', ruleName: rule.name,
|
||
targetHost: td0?.host ?? '', targetPort: td0?.port ?? 0,
|
||
dayId: Number(dayId), targetSecs: endSecs,
|
||
action: awayEndAction, isAwayEnd: true, awayRuleId: rule.id,
|
||
});
|
||
}
|
||
}
|
||
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);
|
||
const endSecs = resolveSecs(Number(rule.endTime ?? -1), rule.endType, rule.endOffset, todaySun);
|
||
const startAction = Number(rule.startAction ?? 1);
|
||
const endAction = Number(rule.endAction ?? -1);
|
||
if (startSecs === null) continue;
|
||
|
||
for (const dayId of (rule.days ?? [])) {
|
||
for (const td of (rule.targetDevices ?? [])) {
|
||
if (!td.host || !td.port) continue;
|
||
if (startAction >= 0) {
|
||
schedule.push({ ruleId: rule.id, ruleName: rule.name,
|
||
targetHost: td.host, targetPort: td.port,
|
||
dayId: Number(dayId), targetSecs: startSecs, action: startAction });
|
||
}
|
||
if (endSecs !== null && endSecs > 0 && endAction >= 0) {
|
||
schedule.push({ ruleId: rule.id, ruleName: rule.name,
|
||
targetHost: td.host, targetPort: td.port,
|
||
dayId: Number(dayId), targetSecs: endSecs, action: endAction });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
this._schedule = schedule;
|
||
this._lastDate = new Date().toDateString();
|
||
}
|
||
|
||
// ── Away Mode loop ────────────────────────────────────────────────────────
|
||
|
||
_resumeAwayLoops() {
|
||
if (!this._running) return;
|
||
const now = new Date();
|
||
const nowSecs = secondsFromMidnight(now);
|
||
const todayId = jsToWemoDayId(now.getDay());
|
||
const rules = this._store.getDwmRules();
|
||
const todaySun = getTodaySun(this._store);
|
||
|
||
for (const rule of rules) {
|
||
if (!rule.enabled || rule.type !== 'Away') continue;
|
||
if (this._awayLoops.has(rule.id)) continue;
|
||
|
||
const startSecs = resolveSecs(Number(rule.startTime ?? -1), rule.startType, rule.startOffset, todaySun);
|
||
const endSecs = resolveSecs(Number(rule.endTime ?? -1), rule.endType, rule.endOffset, todaySun);
|
||
if (startSecs === null) continue;
|
||
if (!(rule.days ?? []).includes(todayId)) continue;
|
||
|
||
const inWindow = endSecs !== null && endSecs >= 0
|
||
? (startSecs <= endSecs ? (nowSecs >= startSecs && nowSecs < endSecs)
|
||
: (nowSecs >= startSecs || nowSecs < endSecs))
|
||
: nowSecs >= startSecs;
|
||
|
||
if (inWindow) this._startAwayLoop(rule);
|
||
}
|
||
}
|
||
|
||
_startAwayLoop(rule) {
|
||
const existing = this._awayLoops.get(rule.id);
|
||
if (existing?.timer) clearTimeout(existing.timer);
|
||
|
||
const devices = (rule.targetDevices ?? []).filter(td => td.host && td.port);
|
||
if (!devices.length) return;
|
||
|
||
const todaySun = getTodaySun(this._store);
|
||
const resolvedEnd = resolveSecs(Number(rule.endTime ?? -1), rule.endType, rule.endOffset, todaySun);
|
||
const loop = { rule, devices, endSecs: resolvedEnd ?? -1, timer: null, isOn: false };
|
||
this._awayLoops.set(rule.id, loop);
|
||
this._awayStep(rule.id, true);
|
||
}
|
||
|
||
_awayStep(ruleId, turnOn) {
|
||
if (!this._running) return;
|
||
const loop = this._awayLoops.get(ruleId);
|
||
if (!loop) return;
|
||
|
||
const nowSecs = secondsFromMidnight(new Date());
|
||
if (loop.endSecs >= 0 && nowSecs >= loop.endSecs) {
|
||
this._stopAwayLoop(ruleId, true);
|
||
return;
|
||
}
|
||
|
||
loop.isOn = turnOn;
|
||
for (const td of loop.devices) {
|
||
this._wemo.setBinaryState(td.host, td.port, turnOn)
|
||
.then(() => {
|
||
this._emit({ success: true,
|
||
msg: `"${loop.rule.name}" Away → ${turnOn ? 'ON' : 'OFF'} (${td.host}) ✓`,
|
||
entry: { action: turnOn ? 1 : 0 } });
|
||
})
|
||
.catch((e) => {
|
||
this._emit({ success: false,
|
||
msg: `"${loop.rule.name}" Away → ${turnOn ? 'ON' : 'OFF'} FAILED (${td.host}): ${e.message}`,
|
||
entry: { action: turnOn ? 1 : 0 } });
|
||
});
|
||
}
|
||
|
||
const delaySecs = turnOn ? randBetween(30, 90) * 60 : randBetween(1, 15) * 60;
|
||
if (loop.endSecs >= 0) {
|
||
const remaining = loop.endSecs - nowSecs;
|
||
if (delaySecs >= remaining) return;
|
||
}
|
||
loop.timer = setTimeout(() => this._awayStep(ruleId, !turnOn), delaySecs * 1000);
|
||
}
|
||
|
||
_stopAwayLoop(ruleId, forceOff) {
|
||
const loop = this._awayLoops.get(ruleId);
|
||
if (!loop) return;
|
||
if (loop.timer) clearTimeout(loop.timer);
|
||
this._awayLoops.delete(ruleId);
|
||
if (forceOff) {
|
||
const endAction = Number(loop.rule.endAction ?? 0);
|
||
const turnOn = endAction === 1;
|
||
for (const td of loop.devices) {
|
||
this._wemo.setBinaryState(td.host, td.port, turnOn).catch(() => {});
|
||
}
|
||
this._emit({ success: true,
|
||
msg: `"${loop.rule.name}" Away Mode window ended — all devices ${turnOn ? 'ON' : 'OFF'}`,
|
||
entry: { action: endAction } });
|
||
}
|
||
}
|
||
|
||
_stopAllAwayLoops(forceOff) {
|
||
for (const [id] of this._awayLoops) this._stopAwayLoop(id, forceOff);
|
||
}
|
||
|
||
// ── Tick / scheduling ─────────────────────────────────────────────────────
|
||
|
||
_tick() {
|
||
if (!this._running) return;
|
||
|
||
// Always reschedule FIRST — even if something below throws, the next tick
|
||
// still runs. Clears any previous timer so we don't double-fire.
|
||
if (this._tickTimer) clearTimeout(this._tickTimer);
|
||
this._tickTimer = setTimeout(() => this._tick(), 30_000);
|
||
|
||
try {
|
||
const now = new Date();
|
||
const today = now.toDateString();
|
||
|
||
if (today !== this._lastDate) {
|
||
// Day rolled over — full reset
|
||
this._firedToday = new Set();
|
||
this._stopAllAwayLoops(false);
|
||
this._loadSchedule();
|
||
this._resumeAwayLoops();
|
||
this._onStatus?.(this._buildStatus());
|
||
} else {
|
||
// Reload rules on every tick so newly created/edited rules are picked up
|
||
// without requiring a Homebridge restart. _firedToday prevents double-firing.
|
||
this._loadSchedule();
|
||
}
|
||
|
||
this._scheduleUpcoming();
|
||
this._writeHeartbeat();
|
||
} catch (e) {
|
||
this._log.error?.('[DWM Scheduler] Tick error (scheduler still running): ' + e.message);
|
||
}
|
||
}
|
||
|
||
_clearTimers() {
|
||
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() {
|
||
if (!this._running) return;
|
||
const now = new Date();
|
||
const nowSecs = secondsFromMidnight(now);
|
||
const todayId = jsToWemoDayId(now.getDay());
|
||
const dayStart = new Date(now); dayStart.setHours(0, 0, 0, 0);
|
||
const windowEnd = nowSecs + 65;
|
||
|
||
for (const entry of this._schedule) {
|
||
if (entry.dayId !== todayId) continue;
|
||
if (entry.targetSecs < nowSecs - 5) continue;
|
||
if (entry.targetSecs > windowEnd) continue;
|
||
|
||
const key = `${entry.ruleId}-${entry.dayId}-${entry.targetSecs}-${entry.targetHost}`;
|
||
if (this._firedToday.has(key)) continue;
|
||
this._firedToday.add(key);
|
||
|
||
const fireAt = dayStart.getTime() + entry.targetSecs * 1000;
|
||
const delay = Math.max(0, fireAt - Date.now());
|
||
const t = setTimeout(() => this._fire(entry), delay);
|
||
this._timers.push(t);
|
||
}
|
||
}
|
||
|
||
async _fire(entry) {
|
||
if (entry.isAwayStart) {
|
||
const rule = this._store.getDwmRules().find(r => r.id === entry.ruleId);
|
||
if (rule && rule.enabled) {
|
||
this._startAwayLoop(rule);
|
||
this._emit({ success: true, msg: `"${entry.ruleName}" Away Mode started`, entry });
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (entry.isAwayEnd) {
|
||
this._stopAwayLoop(entry.awayRuleId, true);
|
||
return;
|
||
}
|
||
|
||
const label = actionLabel(entry.action);
|
||
const wantOn = entry.action === 1;
|
||
try {
|
||
await this._wemo.setBinaryState(entry.targetHost, entry.targetPort, wantOn);
|
||
|
||
await new Promise((r) => setTimeout(r, 3000));
|
||
let confirmed = true;
|
||
try {
|
||
const state = await this._wemo.getBinaryState(entry.targetHost, entry.targetPort);
|
||
confirmed = (!!state) === wantOn;
|
||
} catch { confirmed = null; }
|
||
|
||
const suffix = confirmed === null ? ' (unverified)' : confirmed ? ' ✓' : ' ⚠ retrying';
|
||
this._emit({ success: true,
|
||
msg: `"${entry.ruleName}" → ${label} (${entry.targetHost})${suffix}`, entry });
|
||
|
||
if (confirmed === false) {
|
||
await new Promise((r) => setTimeout(r, 5000));
|
||
try {
|
||
await this._wemo.setBinaryState(entry.targetHost, entry.targetPort, wantOn);
|
||
this._emit({ success: true, msg: `"${entry.ruleName}" → ${label} retry OK`, entry });
|
||
} catch { /* silent */ }
|
||
}
|
||
} catch (e) {
|
||
this._emit({ success: false,
|
||
msg: `"${entry.ruleName}" → ${label} FAILED: ${e.message}`, entry });
|
||
}
|
||
}
|
||
|
||
// ── Missed-rule catch-up ──────────────────────────────────────────────────
|
||
|
||
/**
|
||
* On start, fire any Schedule/Countdown entries whose time fell within the
|
||
* last CATCHUP_WINDOW_S seconds (i.e. Homebridge was restarting when they
|
||
* were supposed to run). Away Mode windows are handled by _resumeAwayLoops.
|
||
*/
|
||
_catchUpMissedRules() {
|
||
if (!this._schedule.length) return;
|
||
|
||
const now = new Date();
|
||
const nowSecs = secondsFromMidnight(now);
|
||
const todayId = jsToWemoDayId(now.getDay());
|
||
const missed = [];
|
||
|
||
for (const entry of this._schedule) {
|
||
if (entry.isAwayStart || entry.isAwayEnd) continue;
|
||
if (entry.dayId !== todayId) continue;
|
||
|
||
const age = nowSecs - entry.targetSecs;
|
||
if (age <= 0 || age > CATCHUP_WINDOW_S) continue;
|
||
|
||
const key = `${entry.ruleId}-${entry.dayId}-${entry.targetSecs}-${entry.targetHost}`;
|
||
if (this._firedToday.has(key)) continue;
|
||
|
||
missed.push({ entry, key });
|
||
}
|
||
|
||
for (const { entry, key } of missed) {
|
||
this._firedToday.add(key);
|
||
this._emit({ success: true,
|
||
msg: `[catch-up] "${entry.ruleName}" → ${actionLabel(entry.action)} (${entry.targetHost})`, entry });
|
||
this._fire(entry);
|
||
}
|
||
|
||
if (missed.length) {
|
||
this._onStatus?.(this._buildStatus());
|
||
}
|
||
}
|
||
|
||
// ── Health monitor ────────────────────────────────────────────────────────
|
||
|
||
_startHealthMonitor() {
|
||
if (this._healthTimer) return;
|
||
// Small initial delay so start() returns quickly before first poll
|
||
this._healthTimer = setTimeout(() => this._pollDeviceHealth(), 15_000);
|
||
}
|
||
|
||
_stopHealthMonitor() {
|
||
if (this._healthTimer) { clearTimeout(this._healthTimer); this._healthTimer = null; }
|
||
}
|
||
|
||
/**
|
||
* Collect every unique host:port referenced in enabled DWM rules,
|
||
* probe each one, track online/offline state, and emit _onHealth events
|
||
* on transitions. When a device comes back online, enforce the state
|
||
* it should currently be in according to the active schedule.
|
||
*/
|
||
async _pollDeviceHealth() {
|
||
if (!this._running) return;
|
||
|
||
// 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 countdownDevMap = new Map(); // deviceKey → [{rule, td}]
|
||
|
||
const addDev = (td) => {
|
||
if (!td?.host || !td?.port) return;
|
||
const key = `${td.host}:${td.port}`;
|
||
if (!deviceMap.has(key))
|
||
deviceMap.set(key, { host: td.host, port: Number(td.port), name: td.name ?? td.host });
|
||
return key;
|
||
};
|
||
|
||
for (const rule of allRules) {
|
||
if (!rule.enabled) continue;
|
||
if (rule.type === 'Trigger') {
|
||
const k = addDev(rule.triggerDevice);
|
||
if (k) triggerSrcSet.add(k);
|
||
for (const td of (rule.actionDevices ?? [])) addDev(td);
|
||
continue;
|
||
}
|
||
for (const td of (rule.targetDevices ?? [])) {
|
||
const k = addDev(td);
|
||
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 });
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const [key, dev] of deviceMap) {
|
||
const wasOnline = this._deviceHealth.get(key); // undefined = first check
|
||
|
||
try {
|
||
const isOn = await this._wemo.getBinaryState(dev.host, dev.port);
|
||
|
||
if (wasOnline === false) {
|
||
// ── Just came back online ──────────────────────────────────────
|
||
this._deviceHealth.set(key, true);
|
||
this._onHealth?.({ ...dev, online: true,
|
||
msg: `${dev.name} came back online` });
|
||
await this._enforceCurrentState(dev);
|
||
} else {
|
||
this._deviceHealth.set(key, true);
|
||
if (wasOnline === undefined) {
|
||
this._onHealth?.({ ...dev, online: true, msg: `${dev.name} online` });
|
||
}
|
||
}
|
||
|
||
// ── AlwaysOn enforcement ──────────────────────────────────────────
|
||
if (alwaysOnSet.has(key) && !isOn) {
|
||
try {
|
||
await this._wemo.setBinaryState(dev.host, dev.port, true);
|
||
this._emit({ success: true,
|
||
msg: `[always-on] ${dev.name} was OFF — turned ON ✓` });
|
||
} catch (e) {
|
||
this._emit({ success: false,
|
||
msg: `[always-on] ${dev.name} turn-ON failed: ${e.message}` });
|
||
}
|
||
}
|
||
|
||
// ── Trigger detection — fire rules if this device changed state ──
|
||
if (triggerSrcSet.has(key)) {
|
||
const prevState = this._triggerStates.get(key);
|
||
this._triggerStates.set(key, isOn);
|
||
if (prevState !== undefined && prevState !== isOn) {
|
||
await this._fireTriggerRules(key, isOn);
|
||
}
|
||
}
|
||
|
||
// ── 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; // state doesn't match this rule's condition
|
||
|
||
// 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; // outside active window
|
||
} else if (winStart >= 0) {
|
||
if (nowSecs < winStart) continue;
|
||
}
|
||
|
||
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) {
|
||
this._onHealth?.({ ...dev, online: false,
|
||
msg: `${dev.name} unreachable: ${e.message}` });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Schedule next poll
|
||
if (this._running) {
|
||
this._healthTimer = setTimeout(() => this._pollDeviceHealth(), HEALTH_POLL_MS);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* For a device that just came back online, find the most recent Schedule
|
||
* entry that should have fired today and push that state to the device.
|
||
*/
|
||
async _enforceCurrentState(dev) {
|
||
const now = new Date();
|
||
const nowSecs = secondsFromMidnight(now);
|
||
const todayId = jsToWemoDayId(now.getDay());
|
||
|
||
let best = null;
|
||
for (const entry of this._schedule) {
|
||
if (entry.isAwayStart || entry.isAwayEnd) continue;
|
||
if (entry.targetHost !== dev.host) continue;
|
||
if (entry.dayId !== todayId) continue;
|
||
if (entry.targetSecs > nowSecs) continue;
|
||
|
||
if (!best || entry.targetSecs > best.targetSecs) best = entry;
|
||
}
|
||
|
||
if (!best) return;
|
||
|
||
const wantOn = best.action === 1;
|
||
try {
|
||
await this._wemo.setBinaryState(dev.host, dev.port, wantOn);
|
||
this._emit({
|
||
success: true,
|
||
msg: `[enforce] "${best.ruleName}" → ${actionLabel(best.action)} restored on ${dev.name} ✓`,
|
||
entry: best,
|
||
});
|
||
} catch (e) {
|
||
this._emit({
|
||
success: false,
|
||
msg: `[enforce] "${best.ruleName}" → ${actionLabel(best.action)} FAILED on ${dev.name}: ${e.message}`,
|
||
entry: best,
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── Trigger rules ─────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* A trigger device changed state. Find every enabled Trigger rule whose
|
||
* triggerDevice matches sourceKey and whose triggerEvent matches, then
|
||
* fire the action on each actionDevice.
|
||
*
|
||
* triggerEvent: 'on' | 'off' | 'any'
|
||
* action: 'on' | 'off' | 'mirror' | 'opposite'
|
||
*/
|
||
async _fireTriggerRules(sourceKey, isOn) {
|
||
const rules = this._store.getDwmRules().filter((r) =>
|
||
r.enabled &&
|
||
r.type === 'Trigger' &&
|
||
r.triggerDevice?.host &&
|
||
`${r.triggerDevice.host}:${r.triggerDevice.port}` === sourceKey
|
||
);
|
||
|
||
for (const rule of rules) {
|
||
const matches =
|
||
rule.triggerEvent === 'any' ||
|
||
(rule.triggerEvent === 'on' && isOn) ||
|
||
(rule.triggerEvent === 'off' && !isOn);
|
||
if (!matches) continue;
|
||
|
||
let targetOn;
|
||
if (rule.action === 'on') targetOn = true;
|
||
else if (rule.action === 'off') targetOn = false;
|
||
else if (rule.action === 'mirror') targetOn = isOn;
|
||
else if (rule.action === 'opposite') targetOn = !isOn;
|
||
else continue;
|
||
|
||
for (const dev of (rule.actionDevices ?? [])) {
|
||
if (!dev.host || !dev.port) continue;
|
||
try {
|
||
await this._wemo.setBinaryState(dev.host, Number(dev.port), targetOn);
|
||
this._emit({ success: true,
|
||
msg: `[trigger] "${rule.name}" → ${dev.name ?? dev.host} ${targetOn ? 'ON' : 'OFF'} ✓` });
|
||
} catch (e) {
|
||
this._emit({ success: false,
|
||
msg: `[trigger] "${rule.name}" → ${dev.name ?? dev.host} FAILED: ${e.message}` });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Heartbeat ─────────────────────────────────────────────────────────────
|
||
|
||
_writeHeartbeat() {
|
||
try {
|
||
const status = this._buildStatus();
|
||
const lastFire = this._lastFireMsg ?? null;
|
||
this._store.saveHeartbeat({
|
||
running: true,
|
||
startedAt: this._startedAt?.toISOString() ?? null,
|
||
totalEntries: status.totalEntries,
|
||
upcoming: status.upcoming.slice(0, 3),
|
||
lastFire,
|
||
});
|
||
} catch { /* non-critical */ }
|
||
}
|
||
|
||
// ── Status ────────────────────────────────────────────────────────────────
|
||
|
||
_buildStatus() {
|
||
const now = new Date();
|
||
const nowSecs = secondsFromMidnight(now);
|
||
const todayId = jsToWemoDayId(now.getDay());
|
||
|
||
const awayActive = [];
|
||
for (const [, loop] of this._awayLoops) {
|
||
awayActive.push({ ruleName: loop.rule.name, action: loop.isOn ? 'ON (Away)' : 'OFF (Away)', at: 'now' });
|
||
}
|
||
|
||
const seen = new Set();
|
||
const upcoming = this._schedule
|
||
.filter(e => e.dayId === todayId && e.targetSecs > nowSecs && !e.isAwayEnd)
|
||
.sort((a, b) => a.targetSecs - b.targetSecs)
|
||
.reduce((acc, e) => {
|
||
const key = `${e.ruleId}|${e.targetSecs}|${e.action}|${e.targetHost}`;
|
||
if (!seen.has(key)) {
|
||
seen.add(key);
|
||
acc.push({
|
||
ruleName: e.ruleName, targetHost: e.targetHost,
|
||
action: e.isAwayStart ? 'Away Mode start' : actionLabel(e.action),
|
||
at: secsToHHMM(e.targetSecs),
|
||
});
|
||
}
|
||
return acc;
|
||
}, [])
|
||
.slice(0, 8);
|
||
|
||
return {
|
||
running: this._running,
|
||
totalEntries: this._schedule.length,
|
||
awayActive,
|
||
upcoming: [...awayActive, ...upcoming].slice(0, 8),
|
||
};
|
||
}
|
||
}
|
||
|
||
module.exports = DwmScheduler;
|