Initial release — Dibby Wemo Manager v2.0.0
Desktop (Electron/Windows): device dashboard, DWM scheduling engine, native firmware rules editor, Windows background service, web remote, sunrise/sunset support. Homebridge plugin (homebridge-dibby-wemo v1.0.0): HomeKit switches for all local Wemo devices, custom UI with DWM rules, device rules, scheduler heartbeat, and location-based sunrise/sunset scheduling. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,721 @@
|
||||
'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();
|
||||
*/
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Wemo DayID: 1=Mon … 7=Sun. JS getDay(): 0=Sun … 6=Sat. */
|
||||
function jsToWemoDayId(jsDay) { return jsDay === 0 ? 7 : jsDay; }
|
||||
|
||||
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._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();
|
||||
|
||||
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 = Number(rule.startTime ?? -1);
|
||||
const endSecs = Number(rule.endTime ?? -1);
|
||||
if (startSecs < 0) continue;
|
||||
|
||||
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: 1, isAwayStart: true,
|
||||
});
|
||||
if (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: 0, isAwayEnd: true, awayRuleId: rule.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Schedule / time-based
|
||||
const startSecs = Number(rule.startTime ?? -1);
|
||||
const endSecs = Number(rule.endTime ?? -1);
|
||||
const startAction = Number(rule.startAction ?? 1);
|
||||
const endAction = Number(rule.endAction ?? -1);
|
||||
if (startSecs < 0) 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 > 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();
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!rule.enabled || rule.type !== 'Away') continue;
|
||||
if (this._awayLoops.has(rule.id)) continue;
|
||||
|
||||
const startSecs = Number(rule.startTime ?? -1);
|
||||
const endSecs = Number(rule.endTime ?? -1);
|
||||
if (startSecs < 0) continue;
|
||||
if (!(rule.days ?? []).includes(todayId)) continue;
|
||||
|
||||
const inWindow = 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 loop = { rule, devices, endSecs: Number(rule.endTime ?? -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) {
|
||||
for (const td of loop.devices) {
|
||||
this._wemo.setBinaryState(td.host, td.port, false).catch(() => {});
|
||||
}
|
||||
this._emit({ success: true,
|
||||
msg: `"${loop.rule.name}" Away Mode window ended — all devices OFF`,
|
||||
entry: { action: 0 } });
|
||||
}
|
||||
}
|
||||
|
||||
_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; }
|
||||
}
|
||||
|
||||
_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 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 && rule.type === 'AlwaysOn') alwaysOnSet.add(k);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
} 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;
|
||||
Reference in New Issue
Block a user