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:
SRS IT
2026-03-28 16:30:43 -04:00
commit 27be1892ed
75 changed files with 14322 additions and 0 deletions
+721
View File
@@ -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 3090 min, OFF 115 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;