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
@@ -0,0 +1,95 @@
'use strict';
/**
* WemoSwitchAccessory
*
* Represents a single Wemo device as a HomeKit Switch.
* State is polled on the configured interval and pushed to HomeKit.
*/
class WemoSwitchAccessory {
/**
* @param {object} params
* @param {object} params.platform - WemoPlatform instance
* @param {object} params.accessory - PlatformAccessory from Homebridge
* @param {object} params.device - { host, port, udn, friendlyName, ... }
* @param {object} params.wemoClient - wemo-client module
* @param {number} params.pollInterval - poll interval in seconds
*/
constructor({ platform, accessory, device, wemoClient, pollInterval = 30 }) {
this.platform = platform;
this.accessory = accessory;
this.device = device;
this.wemo = wemoClient;
this.pollInterval = pollInterval;
this.log = platform.log;
const { Service, Characteristic } = platform.api.hap;
// ── Accessory information ───────────────────────────────────────────────
this.accessory.getService(Service.AccessoryInformation)
?.setCharacteristic(Characteristic.Manufacturer, 'Belkin')
.setCharacteristic(Characteristic.Model, device.productModel ?? 'Wemo Switch')
.setCharacteristic(Characteristic.SerialNumber, device.udn ?? device.host);
// ── Switch service ──────────────────────────────────────────────────────
this.switchService = this.accessory.getService(Service.Switch)
|| this.accessory.addService(Service.Switch, device.friendlyName ?? device.host);
this.switchService.getCharacteristic(Characteristic.On)
.onGet(this._getOn.bind(this))
.onSet(this._setOn.bind(this));
// ── Initial state + poll ────────────────────────────────────────────────
this._currentState = false;
this._pollTimer = null;
this._startPolling();
}
// ── HomeKit handlers ──────────────────────────────────────────────────────
async _getOn() {
try {
this._currentState = await this.wemo.getBinaryState(this.device.host, this.device.port);
} catch (e) {
this.log.warn(`[${this.device.friendlyName}] getBinaryState failed: ${e.message}`);
}
return this._currentState;
}
async _setOn(value) {
try {
await this.wemo.setBinaryState(this.device.host, this.device.port, !!value);
this._currentState = !!value;
} catch (e) {
this.log.error(`[${this.device.friendlyName}] setBinaryState failed: ${e.message}`);
throw new this.platform.api.hap.HapStatusError(
this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
);
}
}
// ── Polling ───────────────────────────────────────────────────────────────
_startPolling() {
this._pollTimer = setInterval(async () => {
try {
const newState = await this.wemo.getBinaryState(this.device.host, this.device.port);
if (newState !== this._currentState) {
this._currentState = newState;
const { Characteristic } = this.platform.api.hap;
this.switchService.updateCharacteristic(Characteristic.On, newState);
}
} catch { /* device unreachable — keep last state */ }
}, this.pollInterval * 1000);
}
stopPolling() {
if (this._pollTimer) {
clearInterval(this._pollTimer);
this._pollTimer = null;
}
}
}
module.exports = WemoSwitchAccessory;
+188
View File
@@ -0,0 +1,188 @@
'use strict';
/**
* WemoPlatform
*
* Homebridge platform plugin. Discovers Wemo devices via SSDP (and any
* manually-configured hosts), registers each as a Switch accessory, and
* runs the DWM local scheduler for time-based automation rules.
*/
const DwmStore = require('./store');
const wemoClient = require('./wemo-client');
const DwmScheduler = require('./scheduler');
const WemoSwitchAccessory = require('./accessory');
const PLUGIN_NAME = 'homebridge-dibby-wemo';
const PLATFORM_NAME = 'DibbyWemo';
class WemoPlatform {
/**
* @param {object} log - Homebridge logger
* @param {object} config - Platform config from config.json
* @param {object} api - Homebridge API
*/
constructor(log, config, api) {
this.log = log;
this.config = config ?? {};
this.api = api;
this._accessories = new Map(); // uuid → PlatformAccessory
this._handlers = new Map(); // uuid → WemoSwitchAccessory
// Store in Homebridge's user storage directory
this._store = new DwmStore(api.user.storagePath());
// Location is set via the custom UI settings panel (city search) and stored
// in the plugin's DwmStore — no raw lat/lng in config.json needed.
// DWM Scheduler
this._scheduler = new DwmScheduler({
store: this._store,
wemoClient,
log,
});
this._scheduler.onFire(({ success, msg }) => {
if (success) log.info('[DWM] ' + msg);
else log.warn('[DWM] ' + msg);
});
// Homebridge calls didFinishLaunching once the restore cache is ready
api.on('didFinishLaunching', () => {
this._discoverDevices();
this._scheduler.start().catch((e) => log.error('[DWM Scheduler] Start failed: ' + e.message));
});
log.info('DibbyWemo platform initialised');
}
// ── Homebridge lifecycle ──────────────────────────────────────────────────
/**
* Called for each accessory restored from cache on startup.
* We immediately attach handlers using the device context stored in the
* accessory so HomeKit requests don't time out during the SSDP window.
*/
configureAccessory(accessory) {
this.log.info('Restoring cached accessory: ' + accessory.displayName);
this._accessories.set(accessory.UUID, accessory);
// Re-attach handlers right away if we have saved device context
const device = accessory.context?.device;
if (device?.host && device?.port) {
const pollInterval = this.config.pollInterval ?? 30;
this._handlers.get(accessory.UUID)?.stopPolling();
const handler = new WemoSwitchAccessory({
platform: this,
accessory,
device,
wemoClient,
pollInterval,
});
this._handlers.set(accessory.UUID, handler);
}
}
// ── Discovery ─────────────────────────────────────────────────────────────
async _discoverDevices() {
const timeout = this.config.discoveryTimeout ?? 10_000;
const pollInterval = this.config.pollInterval ?? 30;
this.log.info('Starting Wemo device discovery…');
let discovered = [];
try {
discovered = await wemoClient.discoverDevices(timeout);
} catch (e) {
this.log.error('SSDP discovery failed: ' + e.message);
}
// Merge in manually-configured devices
const manual = (this.config.manualDevices ?? []).map(({ host, port }) => ({
host, port: port ?? 49153,
}));
for (const m of manual) {
if (!discovered.find((d) => d.host === m.host && d.port === m.port)) {
try {
const info = await wemoClient.getDeviceInfo(m.host, m.port);
discovered.push({ ...m, ...info });
} catch {
discovered.push(m);
}
}
}
this.log.info(`Found ${discovered.length} Wemo device(s)`);
// Save discovered device list for the custom UI
this._store.saveDevices(discovered.map((d) => ({
host: d.host,
port: d.port,
udn: d.udn ?? `${d.host}:${d.port}`,
friendlyName: d.friendlyName ?? d.host,
productModel: d.productModel ?? 'Wemo Device',
firmwareVersion: d.firmwareVersion ?? null,
})));
for (const device of discovered) {
this._registerDevice(device, pollInterval);
}
// Remove stale accessories (devices no longer discovered)
const activeUUIDs = new Set(discovered.map((d) => this._uuidForDevice(d)));
for (const [uuid, acc] of this._accessories) {
if (!activeUUIDs.has(uuid)) {
this.log.info('Removing stale accessory: ' + acc.displayName);
this._handlers.get(uuid)?.stopPolling();
this._handlers.delete(uuid);
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [acc]);
this._accessories.delete(uuid);
}
}
}
_uuidForDevice(device) {
const id = device.udn ?? `${device.host}:${device.port}`;
return this.api.hap.uuid.generate(id);
}
_registerDevice(device, pollInterval) {
const uuid = this._uuidForDevice(device);
const name = device.friendlyName ?? device.host;
let accessory = this._accessories.get(uuid);
if (!accessory) {
this.log.info('Adding new accessory: ' + name);
accessory = new this.api.platformAccessory(name, uuid);
this._accessories.set(uuid, accessory);
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
} else {
accessory.displayName = name;
}
// Persist device connection info so configureAccessory can restore it on
// the next restart without waiting for SSDP to complete.
accessory.context.device = {
host: device.host,
port: device.port,
udn: device.udn ?? `${device.host}:${device.port}`,
friendlyName: device.friendlyName ?? device.host,
productModel: device.productModel ?? 'Wemo Device',
firmwareVersion: device.firmwareVersion ?? null,
};
// (Re)create handler so device info is up to date
this._handlers.get(uuid)?.stopPolling();
const handler = new WemoSwitchAccessory({
platform: this,
accessory,
device,
wemoClient,
pollInterval,
});
this._handlers.set(uuid, handler);
}
}
module.exports = { WemoPlatform, PLUGIN_NAME, PLATFORM_NAME };
+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;
+118
View File
@@ -0,0 +1,118 @@
'use strict';
/**
* DWM Store — Homebridge edition.
*
* Stores devices, DWM rules, and location in a single JSON file inside
* Homebridge's storagePath (passed in at construction time, not via Electron).
*
* Schema mirrors the desktop store exactly so DWM rules created in the desktop
* app can be imported / shared.
*/
const path = require('path');
const fs = require('fs');
const DEFAULTS = {
location: null,
devices: [],
deviceGroups: [],
deviceOrder: [],
disabledRules: {},
dwmRules: [],
schedulerHeartbeat: null,
};
class DwmStore {
constructor(storagePath) {
this._filePath = path.join(storagePath, 'dibby-wemo.json');
}
// ── Internal I/O ──────────────────────────────────────────────────────────
_load() {
try {
return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(this._filePath, 'utf8')) };
} catch {
return { ...DEFAULTS };
}
}
_save(data) {
fs.writeFileSync(this._filePath, JSON.stringify(data, null, 2), 'utf8');
}
// ── Location ──────────────────────────────────────────────────────────────
getLocation() { return this._load().location; }
setLocation(loc) { const d = this._load(); d.location = loc; this._save(d); }
// ── Devices ───────────────────────────────────────────────────────────────
getDevices() { return this._load().devices ?? []; }
saveDevices(list) { const d = this._load(); d.devices = list; this._save(d); }
getDeviceOrder() { return this._load().deviceOrder ?? []; }
saveDeviceOrder(order) { const d = this._load(); d.deviceOrder = order; this._save(d); }
getDeviceGroups() { return this._load().deviceGroups ?? []; }
saveDeviceGroups(groups) { const d = this._load(); d.deviceGroups = groups; this._save(d); }
// ── Disabled-rule backups ─────────────────────────────────────────────────
getDisabledRules() { return this._load().disabledRules ?? {}; }
setDisabledRule(key, ruleDevicesRows) {
const d = this._load();
if (!d.disabledRules) d.disabledRules = {};
d.disabledRules[key] = ruleDevicesRows;
this._save(d);
}
clearDisabledRule(key) {
const d = this._load();
if (!d.disabledRules) return;
delete d.disabledRules[key];
this._save(d);
}
// ── DWM Rules ─────────────────────────────────────────────────────────────
getDwmRules() { return this._load().dwmRules ?? []; }
createDwmRule(rule) {
const d = this._load();
if (!d.dwmRules) d.dwmRules = [];
const id = `dwm-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const now = new Date().toISOString();
const newRule = { ...rule, id, createdAt: now, updatedAt: now };
d.dwmRules.push(newRule);
this._save(d);
return newRule;
}
updateDwmRule(id, updates) {
const d = this._load();
if (!d.dwmRules) d.dwmRules = [];
const idx = d.dwmRules.findIndex((r) => r.id === id);
if (idx === -1) throw new Error(`DWM rule not found: ${id}`);
d.dwmRules[idx] = { ...d.dwmRules[idx], ...updates, id, updatedAt: new Date().toISOString() };
this._save(d);
return d.dwmRules[idx];
}
deleteDwmRule(id) {
const d = this._load();
if (!d.dwmRules) return;
d.dwmRules = d.dwmRules.filter((r) => r.id !== id);
this._save(d);
}
// ── Scheduler heartbeat ───────────────────────────────────────────────────
getHeartbeat() { return this._load().schedulerHeartbeat ?? null; }
saveHeartbeat(hb) {
const d = this._load();
d.schedulerHeartbeat = { ...hb, ts: new Date().toISOString() };
this._save(d);
}
}
module.exports = DwmStore;
+81
View File
@@ -0,0 +1,81 @@
'use strict';
/**
* Calculate sunrise and sunset times for a given location and date.
* Pure JS no external dependencies.
*
* Algorithm: NOAA Solar Calculator (Jean Meeus, Astronomical Algorithms).
*
* @param {number} lat Latitude in decimal degrees (positive = North)
* @param {number} lng Longitude in decimal degrees (positive = East)
* @param {Date} date Date to calculate for (default: today)
* @returns {{ sunrise: number|null, sunset: number|null }}
* Times as integer seconds from LOCAL midnight.
* null for each value if polar day or polar night.
*/
function sunTimes(lat, lng, date = new Date()) {
const D2R = Math.PI / 180;
const R2D = 180 / Math.PI;
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const A = Math.floor((14 - month) / 12);
const Y = year + 4800 - A;
const M = month + 12 * A - 3;
const JDN = day + Math.floor((153 * M + 2) / 5) + 365 * Y
+ Math.floor(Y / 4) - Math.floor(Y / 100) + Math.floor(Y / 400) - 32045;
const JD = JDN - 0.5;
const T = (JD - 2451545.0) / 36525.0;
let L0 = 280.46646 + T * (36000.76983 + T * 0.0003032);
L0 = ((L0 % 360) + 360) % 360;
let Mdeg = 357.52911 + T * (35999.05029 - 0.0001537 * T);
Mdeg = ((Mdeg % 360) + 360) % 360;
const Mrad = Mdeg * D2R;
const C = (1.914602 - T * (0.004817 + 0.000014 * T)) * Math.sin(Mrad)
+ (0.019993 - 0.000101 * T) * Math.sin(2 * Mrad)
+ 0.000289 * Math.sin(3 * Mrad);
const omega = 125.04 - 1934.136 * T;
const lambda = (L0 + C) - 0.00569 - 0.00478 * Math.sin(omega * D2R);
const eps0 = 23.0
+ (26.0 + (21.448 - T * (46.8150 + T * (0.00059 - T * 0.001813))) / 60.0) / 60.0;
const eps = (eps0 + 0.00256 * Math.cos(omega * D2R)) * D2R;
const sinDec = Math.sin(eps) * Math.sin(lambda * D2R);
const decl = Math.asin(sinDec);
const e = 0.016708634 - T * (0.000042037 + 0.0000001267 * T);
const y = Math.pow(Math.tan(eps / 2), 2);
const EqT = 4 * R2D * (
y * Math.sin(2 * L0 * D2R)
- 2 * e * Math.sin(Mrad)
+ 4 * e * y * Math.sin(Mrad) * Math.cos(2 * L0 * D2R)
- 0.5 * y * y * Math.sin(4 * L0 * D2R)
- 1.25 * e * e * Math.sin(2 * Mrad)
);
const cosHA = (Math.cos(90.833 * D2R) - Math.sin(lat * D2R) * sinDec)
/ (Math.cos(lat * D2R) * Math.cos(decl));
if (cosHA < -1 || cosHA > 1) {
return { sunrise: null, sunset: null };
}
const HA = Math.acos(cosHA) * R2D;
const tzOffsetMin = -date.getTimezoneOffset();
const solarNoon = 720.0 - 4.0 * lng - EqT + tzOffsetMin;
return {
sunrise: Math.round((solarNoon - HA * 4.0) * 60),
sunset: Math.round((solarNoon + HA * 4.0) * 60),
};
}
module.exports = { sunTimes };
+76
View File
@@ -0,0 +1,76 @@
'use strict';
/** Day numbers: 1=Monday ... 7=Sunday (Wemo convention) */
const DAY_NUMBERS = { Monday:1, Tuesday:2, Wednesday:3, Thursday:4, Friday:5, Saturday:6, Sunday:7 };
const DAY_NAMES = { 1:'Monday', 2:'Tuesday', 3:'Wednesday', 4:'Thursday', 5:'Friday', 6:'Saturday', 7:'Sunday' };
const DAY_SHORT = { 1:'Mon', 2:'Tue', 3:'Wed', 4:'Thu', 5:'Fri', 6:'Sat', 7:'Sun' };
/** Rule types stored in RULES.Type */
const RULE_TYPES = {
SCHEDULE: 'Schedule',
AWAY: 'Away',
COUNTDOWN: 'Countdown',
LONG_PRESS: 'Long Press',
};
/** Start/End action values */
const ACTIONS = { ON: 1.0, OFF: 0.0, TOGGLE: 2.0, NONE: -1.0 };
/** Network status codes returned by GetNetworkStatus */
const NETWORK_STATUS = { FAILED: '0', SUCCESS: '1', WRONG_PASSWORD: '2', CONNECTING: '3' };
/** Wemo device reset codes for ReSetup action */
const RESET_CODES = { CLEAR_DATA: 1, FACTORY_RESET: 2, CLEAR_WIFI: 5 };
/** Default RULEDEVICES field values */
const RD_DEFAULTS = {
GroupID: 0,
RuleDuration: 0,
StartAction: 1.0,
EndAction: -1.0,
SensorDuration: 2,
Type: -1,
Value: -1,
Level: -1,
ZBCapabilityStart: '',
ZBCapabilityEnd: '',
OnModeOffset: -1,
OffModeOffset: -1,
CountdownTime: 0,
EndTime: -1,
};
/** Sun time sentinel codes stored in RULEDEVICES.StartTime / EndTime */
const SUN_CODES = { SUNRISE: -2, SUNSET: -3 };
function namesToDayNumbers(names) {
return names.map((n) => DAY_NUMBERS[n]).filter(Boolean).sort((a, b) => a - b);
}
function dayNumbersToNames(numbers) {
return numbers.map((n) => DAY_NAMES[n]).filter(Boolean);
}
function dayNumbersToShort(numbers) {
return numbers.map((n) => DAY_SHORT[n]).filter(Boolean);
}
function timeToSecs(hhmm) {
if (!hhmm || !hhmm.includes(':')) return 0;
const [h, m] = hhmm.split(':').map(Number);
return h * 3600 + m * 60;
}
function secsToHHMM(secs) {
if (secs === undefined || secs === null || secs < 0) return '00:00';
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')}`;
}
module.exports = {
DAY_NUMBERS, DAY_NAMES, DAY_SHORT,
RULE_TYPES, ACTIONS, NETWORK_STATUS, RESET_CODES, RD_DEFAULTS, SUN_CODES,
namesToDayNumbers, dayNumbersToNames, dayNumbersToShort,
timeToSecs, secsToHHMM,
};
@@ -0,0 +1,489 @@
'use strict';
/**
* Wemo SOAP client + SSDP discovery + rules CRUD.
*
* Self-contained: no Electron, no store dependency.
* Adapted from apps/desktop/src/main/wemo.js — same protocol, same SQL schema.
*/
const dgram = require('dgram');
const path = require('path');
const http = require('http');
const axios = require('axios');
const AdmZip = require('adm-zip');
const { parseStringPromise } = require('xml2js');
const { create } = require('xmlbuilder2');
// Core helpers — bundled locally so the plugin is self-contained
const { namesToDayNumbers, timeToSecs } = require('./types');
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const NO_KEEPALIVE = new http.Agent({ keepAlive: false });
const WEMO_PORTS = [49153, 49152, 49154, 49155, 49156];
const BE_SVC = 'urn:Belkin:service:basicevent:1';
const BE_URL = '/upnp/control/basicevent1';
const TS_SVC = 'urn:Belkin:service:timesync:1';
const TS_URL = '/upnp/control/timesync1';
const RULES_SVC = 'urn:Belkin:service:rules:1';
const RULES_URL = '/upnp/control/rules1';
const RULE_TYPE_TO_DEVICE = {
'Schedule': 'Time Interval',
'Countdown': 'Countdown Rule',
'Away': 'Away Mode',
};
// ---------------------------------------------------------------------------
// sql.js (WASM SQLite)
// ---------------------------------------------------------------------------
let SQL = null;
async function getSql(log) {
if (!SQL) {
const fs = require('fs');
const initSqlJs = require('sql.js');
const candidates = [
path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'),
path.join(__dirname, '..', '..', '..', 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'),
path.join(__dirname, 'sql-wasm.wasm'),
];
let wasmBinary = null;
for (const p of candidates) {
try { wasmBinary = fs.readFileSync(p); break; } catch { /* try next */ }
}
if (!wasmBinary) {
throw new Error(`sql-wasm.wasm not found. Tried:\n${candidates.join('\n')}`);
}
SQL = await initSqlJs({ wasmBinary });
}
return SQL;
}
// ---------------------------------------------------------------------------
// SOAP helpers
// ---------------------------------------------------------------------------
async function soapRequest(host, port, controlURL, serviceType, action, args = {}, timeoutMs = 10_000) {
const url = `http://${host}:${port}${controlURL}`;
const root = create({ version: '1.0', encoding: 'utf-8' })
.ele('s:Envelope', { 'xmlns:s': 'http://schemas.xmlsoap.org/soap/envelope/', 's:encodingStyle': 'http://schemas.xmlsoap.org/soap/encoding/' })
.ele('s:Body')
.ele(`u:${action}`, { [`xmlns:u`]: serviceType });
for (const [k, v] of Object.entries(args)) root.ele(k).txt(v);
const xml = root.doc().end({ headless: false });
const res = await axios.post(url, xml, {
headers: {
'Content-Type': 'text/xml; charset="utf-8"',
'SOAPACTION': `"${serviceType}#${action}"`,
'Connection': 'close',
},
httpAgent: NO_KEEPALIVE,
timeout: timeoutMs,
});
const parsed = await parseStringPromise(res.data, { explicitArray: false, ignoreAttrs: true });
const body = parsed['s:Envelope']['s:Body'];
return body[`u:${action}Response`] ?? body;
}
async function soapWithFallback(host, port, controlURL, serviceType, action, args = {}) {
const portsToTry = [port, ...WEMO_PORTS.filter((p) => p !== port)];
let lastErr = null;
for (const tryPort of portsToTry) {
try {
return await soapRequest(host, tryPort, controlURL, serviceType, action, args);
} catch (err) {
lastErr = err;
const isConn = err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT';
if (!isConn) throw err;
}
}
throw lastErr || new Error(`${host}: all ports failed for ${action}`);
}
// ---------------------------------------------------------------------------
// Device control
// ---------------------------------------------------------------------------
async function getBinaryState(host, port) {
const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'GetBinaryState');
const raw = String(res['BinaryState'] ?? '0');
return raw === '1' || raw === '8';
}
async function setBinaryState(host, port, on) {
await soapWithFallback(host, port, BE_URL, BE_SVC, 'SetBinaryState', { BinaryState: on ? '1' : '0' });
}
// ---------------------------------------------------------------------------
// Device info
// ---------------------------------------------------------------------------
function resolveProductModel(udn, deviceType, firmwareSuffix) {
const udnBase = String(udn || '').replace(/^uuid:/i, '');
const parts = udnBase.split('-');
const udnPrefix = parts.slice(0, 2).join('-').toLowerCase();
const udnType = parts[0].toLowerCase();
const fwSuffix = String(firmwareSuffix || '').toUpperCase();
const dt = String(deviceType || '').toLowerCase();
if (udnPrefix === 'lightswitch-3_0') return 'Wemo 3-Way Smart Switch (WLS0403)';
if (udnPrefix === 'lightswitch-2_0') return 'Wemo Light Switch (WLS040)';
if (udnPrefix === 'lightswitch-1_0') {
if (fwSuffix.includes('OWRT-LS')) return 'Wemo Light Switch (F7C030)';
return 'Wemo Light Switch (WLS040)';
}
if (udnType === 'dimmer' || dt.includes('dimmer') || fwSuffix.includes('WDS'))
return 'Wemo WiFi Smart Dimmer (WDS060)';
if (udnType === 'insight' || dt.includes('insight')) return 'Wemo Insight Smart Plug (F7C029)';
if (udnPrefix === 'socket-2_0') return 'Wemo Mini Smart Plug (F7C063)';
if (udnPrefix === 'socket-1_0') {
if (fwSuffix.includes('OWRT-SNS')) return 'Wemo Switch (F7C027)';
return 'Wemo Smart Plug';
}
if (udnType === 'socket') return 'Wemo Smart Plug';
return null;
}
async function getDeviceInfo(host, port) {
const results = {};
try {
const res = await soapWithFallback(host, port, BE_URL, BE_SVC, 'GetFriendlyName');
results.friendlyName = String(res['FriendlyName'] ?? '').trim();
} catch { results.friendlyName = null; }
try {
const sx = await axios.get(`http://${host}:${port}/setup.xml`, { timeout: 5000, httpAgent: NO_KEEPALIVE });
const fwMatch = sx.data.match(/<firmwareVersion>([^<]+)<\/firmwareVersion>/i);
const udnMatch = sx.data.match(/<UDN>([^<]+)<\/UDN>/i);
const dtMatch = sx.data.match(/<deviceType>([^<]+)<\/deviceType>/i);
const mdMatch = sx.data.match(/<modelDescription>([^<]+)<\/modelDescription>/i);
results.firmwareVersion = fwMatch ? fwMatch[1].trim() : null;
results.modelDescription = mdMatch ? mdMatch[1].trim() : null;
if (udnMatch) {
results.udn = udnMatch[1].trim();
const fw = results.firmwareVersion || '';
const fwSuffix = fw.split('PVT-').pop() || '';
results.productModel = resolveProductModel(results.udn, dtMatch ? dtMatch[1] : '', fwSuffix);
}
} catch { /* non-fatal */ }
return results;
}
// ---------------------------------------------------------------------------
// SSDP Discovery
// ---------------------------------------------------------------------------
function discoverDevices(timeoutMs = 10_000) {
return new Promise((resolve) => {
const SSDP_ADDR = '239.255.255.250';
const SSDP_PORT = 1900;
const M_SEARCH = [
'M-SEARCH * HTTP/1.1',
`HOST: ${SSDP_ADDR}:${SSDP_PORT}`,
'MAN: "ssdp:discover"',
'MX: 3',
'ST: urn:Belkin:device:**',
'', '',
].join('\r\n');
const found = new Map();
const sock = dgram.createSocket({ type: 'udp4', reuseAddr: true });
sock.on('message', async (msg) => {
const text = msg.toString();
const locMatch = text.match(/LOCATION:\s*(http:\/\/([^:]+):(\d+)\/setup\.xml)/i);
if (!locMatch) return;
const [, , ip, portStr] = locMatch;
const port = parseInt(portStr, 10);
const key = `${ip}:${port}`;
if (found.has(key)) return;
found.set(key, { host: ip, port, discovering: true });
try {
const info = await getDeviceInfo(ip, port);
found.set(key, { host: ip, port, ...info });
} catch { /* keep partial entry */ }
});
sock.bind(() => {
const buf = Buffer.from(M_SEARCH);
sock.send(buf, 0, buf.length, SSDP_PORT, SSDP_ADDR);
});
setTimeout(() => {
try { sock.close(); } catch { /* ignore */ }
resolve(Array.from(found.values()));
}, timeoutMs);
});
}
// ---------------------------------------------------------------------------
// Rules — fetch (ZIP + SQLite)
// ---------------------------------------------------------------------------
async function fetchRules(host, port) {
const res = await soapWithFallback(host, port, RULES_URL, RULES_SVC, 'FetchRules');
const version = String(res['ruleDbVersion'] ?? '0');
const dbUrl = String(res['ruleDbPath'] ?? '');
if (!dbUrl) throw new Error('FetchRules returned no ruleDbPath');
const dlRes = await axios.get(dbUrl, { responseType: 'arraybuffer', timeout: 15_000 });
const zip = new AdmZip(Buffer.from(dlRes.data));
const entry = zip.getEntries().find((e) => e.entryName.endsWith('.db'));
if (!entry) throw new Error('No .db file in rules ZIP');
const SQL = await getSql();
const db = new SQL.Database(entry.getData());
const rules = _dbQuery(db, 'SELECT * FROM RULES');
const ruleDevices = _dbQuery(db, 'SELECT * FROM RULEDEVICES');
const targets = _dbQuery(db, 'SELECT * FROM TARGETDEVICES');
db.close();
return { version, rules, ruleDevices, targets };
}
function _dbQuery(db, sql) {
const rows = [];
const stmt = db.prepare(sql);
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
return rows;
}
// ---------------------------------------------------------------------------
// Rules — store (ZIP + CDATA encode)
// ---------------------------------------------------------------------------
async function storeRules(host, port, version, dbBuffer) {
const zip = new AdmZip();
zip.addFile('temppluginRules.db', dbBuffer);
const b64 = zip.toBuffer().toString('base64');
// CRITICAL: body must be entity-encoded CDATA — hand-crafted XML only
const soapXml = `<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:StoreRules xmlns:u="urn:Belkin:service:rules:1">
<ruleDbVersion>${version}</ruleDbVersion>
<StartSync>NOSYNC</StartSync>
<ruleDbBody>&lt;![CDATA[${b64}]]&gt;</ruleDbBody>
</u:StoreRules>
</s:Body>
</s:Envelope>`;
const url = `http://${host}:${port}${RULES_URL}`;
const res = await axios.post(url, soapXml, {
headers: {
'Content-Type': 'text/xml; charset="utf-8"',
'SOAPACTION': `"${RULES_SVC}#StoreRules"`,
'Connection': 'close',
},
httpAgent: NO_KEEPALIVE,
timeout: 20_000,
});
if (String(res.data).includes('failed')) throw new Error('StoreRules: device returned failure');
}
// ---------------------------------------------------------------------------
// Rules — create / update / delete / toggle
// ---------------------------------------------------------------------------
async function createRule(host, port, ruleData) {
const SQL = await getSql();
const { version, rules, ruleDevices, targets } = await fetchRules(host, port);
const db = new SQL.Database();
_createSchema(db);
for (const r of rules) _insertRule(db, r);
for (const r of ruleDevices) _insertRuleDevice(db, r);
for (const r of targets) _insertTargetDevice(db, r);
const newId = _nextRuleId(db);
_insertNewRule(db, newId, ruleData);
const buf = Buffer.from(db.export());
db.close();
await storeRules(host, port, String(parseInt(version, 10) + 2), buf);
return newId;
}
async function updateRule(host, port, ruleId, ruleData) {
const SQL = await getSql();
const { version, rules, ruleDevices, targets } = await fetchRules(host, port);
const db = new SQL.Database();
_createSchema(db);
for (const r of rules) _insertRule(db, r);
for (const r of ruleDevices) _insertRuleDevice(db, r);
for (const r of targets) _insertTargetDevice(db, r);
db.run('DELETE FROM RULES WHERE RuleID = ?', [String(ruleId)]);
db.run('DELETE FROM RULEDEVICES WHERE RuleID = ?', [String(ruleId)]);
db.run('DELETE FROM TARGETDEVICES WHERE RuleID = ?', [String(ruleId)]);
_insertNewRule(db, ruleId, ruleData);
const buf = Buffer.from(db.export());
db.close();
await storeRules(host, port, String(parseInt(version, 10) + 2), buf);
}
async function deleteRule(host, port, ruleId) {
const SQL = await getSql();
const { version, rules, ruleDevices, targets } = await fetchRules(host, port);
const db = new SQL.Database();
_createSchema(db);
for (const r of rules) _insertRule(db, r);
for (const r of ruleDevices) _insertRuleDevice(db, r);
for (const r of targets) _insertTargetDevice(db, r);
db.run('DELETE FROM RULES WHERE RuleID = ?', [String(ruleId)]);
db.run('DELETE FROM RULEDEVICES WHERE RuleID = ?', [String(ruleId)]);
db.run('DELETE FROM TARGETDEVICES WHERE RuleID = ?', [String(ruleId)]);
const buf = Buffer.from(db.export());
db.close();
await storeRules(host, port, String(parseInt(version, 10) + 2), buf);
}
async function toggleRule(host, port, ruleId, enabled) {
const SQL = await getSql();
const { version, rules, ruleDevices, targets } = await fetchRules(host, port);
const db = new SQL.Database();
_createSchema(db);
for (const r of rules) _insertRule(db, r);
for (const r of ruleDevices) _insertRuleDevice(db, r);
for (const r of targets) _insertTargetDevice(db, r);
db.run('UPDATE RULES SET State = ? WHERE RuleID = ?', [enabled ? '1' : '0', String(ruleId)]);
const buf = Buffer.from(db.export());
db.close();
await storeRules(host, port, String(parseInt(version, 10) + 2), buf);
}
// ---------------------------------------------------------------------------
// SQLite helpers (schema + insert helpers)
// ---------------------------------------------------------------------------
function _createSchema(db) {
db.run(`CREATE TABLE IF NOT EXISTS RULES (
RuleID TEXT, Name TEXT, Type TEXT, RuleOrder INTEGER,
StartDate TEXT DEFAULT '12201982', EndDate TEXT DEFAULT '07301982',
State TEXT DEFAULT '1', Sync TEXT DEFAULT 'NOSYNC'
)`);
db.run(`CREATE TABLE IF NOT EXISTS RULEDEVICES (
RuleDevicePK INTEGER PRIMARY KEY AUTOINCREMENT,
RuleID TEXT, DeviceID TEXT, GroupID INTEGER, DayID INTEGER,
StartTime INTEGER, RuleDuration INTEGER, StartAction INTEGER, EndAction INTEGER,
SensorDuration INTEGER, Type INTEGER, Value INTEGER, Level INTEGER,
ZBCapabilityStart TEXT, ZBCapabilityEnd TEXT,
OnModeOffset INTEGER, OffModeOffset INTEGER, CountdownTime INTEGER, EndTime INTEGER
)`);
db.run(`CREATE TABLE IF NOT EXISTS TARGETDEVICES (
TargetDevicesPK INTEGER PRIMARY KEY AUTOINCREMENT,
RuleID TEXT, DeviceID TEXT, DeviceIndex INTEGER
)`);
}
function _insertRule(db, r) {
db.run(
'INSERT INTO RULES (RuleID,Name,Type,RuleOrder,StartDate,EndDate,State,Sync) VALUES (?,?,?,?,?,?,?,?)',
[r.RuleID, r.Name, r.Type, r.RuleOrder, r.StartDate ?? '12201982', r.EndDate ?? '07301982', r.State ?? '1', r.Sync ?? 'NOSYNC']
);
}
function _insertRuleDevice(db, r) {
db.run(
`INSERT INTO RULEDEVICES (RuleID,DeviceID,GroupID,DayID,StartTime,RuleDuration,StartAction,EndAction,
SensorDuration,Type,Value,Level,ZBCapabilityStart,ZBCapabilityEnd,
OnModeOffset,OffModeOffset,CountdownTime,EndTime)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[r.RuleID, r.DeviceID, r.GroupID ?? 0, r.DayID, r.StartTime, r.RuleDuration ?? 0,
r.StartAction, r.EndAction ?? -1, r.SensorDuration ?? 0, r.Type ?? 0, r.Value ?? 0,
r.Level ?? 0, r.ZBCapabilityStart ?? '', r.ZBCapabilityEnd ?? '',
r.OnModeOffset ?? 0, r.OffModeOffset ?? 0, r.CountdownTime ?? 0, r.EndTime ?? -1]
);
}
function _insertTargetDevice(db, r) {
db.run(
'INSERT INTO TARGETDEVICES (RuleID,DeviceID,DeviceIndex) VALUES (?,?,?)',
[r.RuleID, r.DeviceID, r.DeviceIndex ?? 0]
);
}
function _nextRuleId(db) {
const stmt = db.prepare('SELECT CAST(MAX(CAST(RuleID AS INTEGER)) AS INTEGER) AS mx FROM RULES');
let mx = 0;
if (stmt.step()) { mx = stmt.getAsObject().mx ?? 0; }
stmt.free();
return mx + 1;
}
function _insertNewRule(db, ruleId, ruleData) {
// namesToDayNumbers + timeToSecs already required at top of file
const days = ruleData.days ?? [];
const dayNums = typeof days[0] === 'string' ? namesToDayNumbers(days) : days.map(Number);
const devId = ruleData.deviceId ?? ruleData.udn ?? '';
const ruleType = RULE_TYPE_TO_DEVICE[ruleData.type] ?? ruleData.type ?? 'Time Interval';
let startSecs, endSecs;
if (ruleData.startTime != null) {
startSecs = typeof ruleData.startTime === 'string'
? timeToSecs(ruleData.startTime) : Number(ruleData.startTime);
} else startSecs = 0;
if (ruleData.endTime != null && ruleData.endTime !== '') {
endSecs = typeof ruleData.endTime === 'string'
? timeToSecs(ruleData.endTime) : Number(ruleData.endTime);
} else endSecs = -1;
const startAction = ruleData.startAction ?? 1;
const endAction = ruleData.endAction ?? -1;
db.run(
'INSERT INTO RULES (RuleID,Name,Type,RuleOrder,StartDate,EndDate,State,Sync) VALUES (?,?,?,?,?,?,?,?)',
[String(ruleId), ruleData.name ?? 'Rule', ruleType, ruleId,
'12201982', '07301982', ruleData.enabled !== false ? '1' : '0', 'NOSYNC']
);
for (const dayId of dayNums) {
db.run(
`INSERT INTO RULEDEVICES (RuleID,DeviceID,GroupID,DayID,StartTime,RuleDuration,StartAction,EndAction,
SensorDuration,Type,Value,Level,ZBCapabilityStart,ZBCapabilityEnd,
OnModeOffset,OffModeOffset,CountdownTime,EndTime)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[String(ruleId), devId, 0, dayId, startSecs, 0,
startAction, endAction, 0, 0, 0, 0, '', '',
0, 0, ruleData.countdownTime ?? 0, endSecs]
);
}
db.run(
'INSERT INTO TARGETDEVICES (RuleID,DeviceID,DeviceIndex) VALUES (?,?,?)',
[String(ruleId), devId, 0]
);
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
module.exports = {
getBinaryState,
setBinaryState,
getDeviceInfo,
discoverDevices,
fetchRules,
storeRules,
createRule,
updateRule,
deleteRule,
toggleRule,
};