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,555 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Dibby Wemo Manager</title>
<style>
:root {
--bg: #1a1a2e;
--bg2: #16213e;
--card: #0f3460;
--accent: #e94560;
--green: #4ade80;
--text: #e2e8f0;
--muted: #94a3b8;
--border: #2d3748;
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 16px;
}
h2 { font-size: 1.1rem; color: var(--text); margin-bottom: 12px; }
h3 { font-size: 0.95rem; color: var(--muted); margin-bottom: 8px; }
.tabs {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 2px solid var(--border);
padding-bottom: 2px;
}
.tab-btn {
background: none;
border: none;
color: var(--muted);
padding: 8px 16px;
cursor: pointer;
font-size: 0.9rem;
border-radius: var(--radius) var(--radius) 0 0;
transition: color 0.15s, background 0.15s;
}
.tab-btn.active {
color: var(--text);
background: var(--card);
font-weight: 600;
}
.tab-btn:hover:not(.active) { color: var(--text); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* Cards */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 10px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.card-title { font-weight: 600; font-size: 0.95rem; }
.card-subtitle { font-size: 0.78rem; color: var(--muted); margin-top: 2px; }
/* Buttons */
.btn {
padding: 6px 14px;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; }
.btn:disabled { opacity: 0.4; cursor: default; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-success { background: var(--green); color: #111; }
.btn-ghost { background: var(--bg2); color: var(--text); border: 1px solid var(--border); }
.btn-danger { background: #ef4444; color: #fff; }
.btn-sm { padding: 4px 10px; font-size: 0.78rem; }
/* Toggle switch */
.toggle-wrap { display: flex; align-items: center; gap: 8px; }
.toggle {
position: relative; width: 42px; height: 24px;
display: inline-block; cursor: pointer;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; inset: 0; background: #374151;
border-radius: 24px; transition: background 0.2s;
}
.slider:before {
content: ''; position: absolute;
width: 18px; height: 18px; left: 3px; bottom: 3px;
background: #fff; border-radius: 50%;
transition: transform 0.2s;
}
input:checked + .slider { background: var(--green); }
input:checked + .slider:before { transform: translateX(18px); }
/* Form */
.form-group { margin-bottom: 12px; }
label { display: block; font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; }
input[type=text], input[type=number], select {
width: 100%;
background: var(--bg2);
border: 1px solid var(--border);
color: var(--text);
padding: 7px 10px;
border-radius: var(--radius);
font-size: 0.875rem;
}
input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent; }
/* Day picker */
.day-picker { display: flex; gap: 6px; flex-wrap: wrap; }
.day-btn {
width: 36px; height: 36px; border-radius: 50%;
border: 1px solid var(--border); background: var(--bg2);
color: var(--muted); cursor: pointer; font-size: 0.75rem;
font-weight: 600; transition: all 0.15s;
}
.day-btn.selected { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Chips */
.chip {
display: inline-block; padding: 2px 8px; border-radius: 12px;
font-size: 0.72rem; font-weight: 600; margin-left: 8px;
}
.chip-on { background: #14532d; color: var(--green); }
.chip-off { background: #1f2937; color: var(--muted); }
.chip-dis { background: #422006; color: #fb923c; }
/* Status / alert */
.status-bar {
background: var(--bg2); border-left: 3px solid var(--accent);
padding: 10px 14px; border-radius: 0 var(--radius) var(--radius) 0;
font-size: 0.82rem; color: var(--muted); margin-bottom: 16px;
}
.alert {
padding: 10px 14px; border-radius: var(--radius);
font-size: 0.85rem; margin-bottom: 12px;
}
.alert-info { background: #1e3a5f; color: #93c5fd; }
.alert-success { background: #14532d; color: var(--green); }
.alert-error { background: #450a0a; color: #fca5a5; }
/* Inline form panel — no fixed/absolute positioning needed */
#dwm-form-panel .card { margin-bottom: 0; }
/* Row utils */
.flex-row { display: flex; align-items: center; gap: 8px; }
.flex-col { display: flex; flex-direction: column; gap: 4px; }
.spacer { flex: 1; }
/* Spinner */
.spin {
display: inline-block; width: 14px; height: 14px;
border: 2px solid var(--muted); border-top-color: var(--accent);
border-radius: 50%; animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Empty state */
.empty { text-align: center; color: var(--muted); padding: 32px 0; font-size: 0.9rem; }
/* Location autocomplete */
.autocomplete-list {
background: var(--bg2); border: 1px solid var(--border);
border-radius: var(--radius); max-height: 200px; overflow-y: auto;
margin-top: 4px;
}
.autocomplete-item {
padding: 8px 12px; cursor: pointer; font-size: 0.82rem; color: var(--text);
border-bottom: 1px solid var(--border);
}
.autocomplete-item:last-child { border-bottom: none; }
.autocomplete-item:hover { background: var(--card); }
</style>
</head>
<body>
<div class="tabs">
<button class="tab-btn active" data-tab="devices">📱 Devices</button>
<button class="tab-btn" data-tab="dwm-rules">⏰ DWM Rules</button>
<button class="tab-btn" data-tab="wemo-rules">🔌 Device Rules</button>
<button class="tab-btn" data-tab="settings">⚙️ Settings</button>
<button class="tab-btn" data-tab="help">❓ Help</button>
</div>
<!-- ── Devices Tab ──────────────────────────────────────────────────────── -->
<div id="tab-devices" class="tab-panel active">
<div class="flex-row" style="margin-bottom:16px">
<h2 style="margin:0">Wemo Devices</h2>
<div class="spacer"></div>
<button class="btn btn-primary" id="btn-discover">🔍 Discover</button>
</div>
<div id="devices-status"></div>
<div id="devices-list"><div class="empty">Click Discover to find Wemo devices on your network.</div></div>
</div>
<!-- ── DWM Rules Tab ────────────────────────────────────────────────────── -->
<div id="tab-dwm-rules" class="tab-panel">
<!-- List view -->
<div id="dwm-list-view">
<div class="flex-row" style="margin-bottom:12px">
<h2 style="margin:0">DWM Automation Rules</h2>
<div class="spacer"></div>
<button class="btn btn-primary" id="btn-add-dwm">+ Add Rule</button>
</div>
<!-- Scheduler heartbeat bar -->
<div id="dwm-heartbeat" style="display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:6px;margin-bottom:14px;font-size:0.8rem;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.18)">
<span id="hb-dot" style="width:10px;height:10px;border-radius:50%;background:#6b7280;flex-shrink:0"></span>
<span id="hb-text" style="color:#9ca3af">Checking scheduler…</span>
<div class="spacer"></div>
<span id="hb-next" style="color:#6b7280;font-size:0.75rem"></span>
</div>
<div id="dwm-rules-status"></div>
<div id="dwm-rules-list"><div class="empty">No DWM rules yet.</div></div>
</div>
<!-- Inline add/edit form (hidden until needed) -->
<div id="dwm-form-panel" style="display:none">
<div class="flex-row" style="margin-bottom:16px">
<h2 id="dwm-form-title" style="margin:0">Add DWM Rule</h2>
<div class="spacer"></div>
<button class="btn btn-ghost btn-sm" id="btn-dwm-form-cancel">✕ Cancel</button>
</div>
<div class="card">
<div class="form-group">
<label>Rule Name</label>
<input type="text" id="dwm-name" placeholder="e.g. Evening Lights" />
</div>
<div class="form-group">
<label>Type</label>
<select id="dwm-type">
<option value="Schedule">📅 Schedule (fixed on/off times)</option>
<option value="Countdown">⏱ Countdown (timer)</option>
<option value="Away">🏠 Away Mode (random)</option>
<option value="AlwaysOn">🔒 Always On (keep device on)</option>
<option value="Trigger">⚡ Trigger (IFTTT-style)</option>
</select>
</div>
<!-- Target devices (Schedule / Countdown / Away / AlwaysOn) -->
<div class="form-group" id="dwm-target-group">
<label>Target Devices</label>
<select id="dwm-target-devices" multiple size="4" style="height:90px"></select>
<div style="font-size:0.75rem;color:var(--muted);margin-top:4px">Hold Ctrl/Cmd to select multiple</div>
</div>
<!-- Trigger fields -->
<div id="dwm-trigger-fields" style="display:none">
<div class="form-group">
<label>Trigger Device (source)</label>
<select id="dwm-trigger-src"></select>
</div>
<div style="display:flex;gap:10px">
<div class="form-group" style="flex:1">
<label>When</label>
<select id="dwm-trigger-event">
<option value="any">Turns ON or OFF</option>
<option value="on">Turns ON</option>
<option value="off">Turns OFF</option>
</select>
</div>
<div class="form-group" style="flex:1">
<label>Then</label>
<select id="dwm-trigger-action">
<option value="on">Turn ON action devices</option>
<option value="off">Turn OFF action devices</option>
<option value="mirror">Mirror (same as trigger)</option>
<option value="opposite">Opposite (invert)</option>
</select>
</div>
</div>
<div class="form-group">
<label>Action Devices (targets)</label>
<select id="dwm-trigger-targets" multiple size="4" style="height:90px"></select>
<div style="font-size:0.75rem;color:var(--muted);margin-top:4px">Hold Ctrl/Cmd to select multiple</div>
</div>
</div>
<div class="form-group" id="dwm-days-group">
<label>Days</label>
<div class="day-picker" id="dwm-days">
<button class="day-btn" data-day="1">Mon</button>
<button class="day-btn" data-day="2">Tue</button>
<button class="day-btn" data-day="3">Wed</button>
<button class="day-btn" data-day="4">Thu</button>
<button class="day-btn" data-day="5">Fri</button>
<button class="day-btn" data-day="6">Sat</button>
<button class="day-btn" data-day="7">Sun</button>
</div>
</div>
<div id="dwm-schedule-fields">
<div class="flex-row">
<div class="form-group" style="flex:1">
<label>Start Time</label>
<input type="text" id="dwm-start-time" placeholder="e.g. 8:30 PM" />
</div>
<div class="form-group" style="flex:1">
<label>End Time (optional)</label>
<input type="text" id="dwm-end-time" placeholder="e.g. 11:00 PM" />
</div>
</div>
<div class="flex-row">
<div class="form-group" style="flex:1">
<label>Start Action</label>
<select id="dwm-start-action">
<option value="1">Turn ON</option>
<option value="0">Turn OFF</option>
</select>
</div>
<div class="form-group" style="flex:1">
<label>End Action</label>
<select id="dwm-end-action">
<option value="-1">None</option>
<option value="0">Turn OFF</option>
<option value="1">Turn ON</option>
</select>
</div>
</div>
</div>
<div id="dwm-countdown-fields" style="display:none">
<div class="form-group">
<label>Countdown Duration (minutes)</label>
<input type="number" id="dwm-countdown-mins" min="1" max="1440" placeholder="60" />
</div>
</div>
<div id="dwm-alwayson-info" style="display:none;margin-bottom:12px;padding:10px;background:rgba(48,209,88,.1);border:1px solid rgba(48,209,88,.3);border-radius:6px;font-size:0.82rem;color:#4ade80">
🔒 The scheduler polls this device every 10 seconds. If it is found OFF it will be turned back ON automatically. No schedule needed.
</div>
<div class="form-group">
<div class="toggle-wrap">
<label class="toggle">
<input type="checkbox" id="dwm-enabled" checked />
<span class="slider"></span>
</label>
<span style="font-size:0.88rem">Enabled</span>
</div>
</div>
<div id="dwm-form-error" class="alert alert-error" style="display:none"></div>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px">
<button class="btn btn-ghost" id="dwm-form-cancel-btn">Cancel</button>
<button class="btn btn-primary" id="dwm-form-save-btn">Save Rule</button>
</div>
</div>
</div>
</div>
<!-- ── Wemo Device Rules Tab ────────────────────────────────────────────── -->
<div id="tab-wemo-rules" class="tab-panel">
<div style="margin-bottom:12px">
<h2>Native Device Rules</h2>
<p style="font-size:0.82rem;color:var(--muted);margin-top:4px">
Manage on-device schedules stored in Wemo firmware. Select a device to view its rules.
</p>
</div>
<div class="form-group">
<label>Select Device</label>
<select id="wemo-rules-device-select"><option value="">— choose device —</option></select>
</div>
<div id="wemo-rules-status"></div>
<div id="wemo-rules-list"></div>
</div>
<!-- ── Settings Tab ─────────────────────────────────────────────────────── -->
<div id="tab-settings" class="tab-panel">
<h2 style="margin-bottom:16px">Settings</h2>
<div class="card">
<h3>Location (for sunrise/sunset rules)</h3>
<div id="location-current" style="margin-bottom:10px;font-size:0.83rem;color:var(--muted)">Not set</div>
<div class="form-group">
<label>Search for your city</label>
<input type="text" id="location-search-input" placeholder="e.g. London" autocomplete="off" />
<div id="location-autocomplete" class="autocomplete-list" style="display:none"></div>
</div>
<button class="btn btn-ghost btn-sm" id="btn-location-save" disabled>Save Location</button>
<span id="location-status" style="font-size:0.78rem;color:var(--green);margin-left:8px"></span>
</div>
</div>
<!-- ── Help Tab ──────────────────────────────────────────────────────────── -->
<div id="tab-help" class="tab-panel">
<h2 style="margin-bottom:4px">❓ Help &amp; Guide</h2>
<p style="font-size:0.82rem;color:var(--muted);margin-bottom:20px">How to use Dibby Wemo Manager in Homebridge</p>
<!-- Getting Started -->
<div class="card" style="margin-bottom:10px">
<h3 style="color:var(--accent);margin-bottom:10px">🚀 Getting Started</h3>
<ol style="font-size:0.85rem;line-height:1.9;padding-left:18px;color:var(--text)">
<li>Go to the <strong>📱 Devices</strong> tab and click <strong>Discover</strong> — your Wemo devices on the local network will appear.</li>
<li>Devices are automatically added to HomeKit as switches. Toggle them from the Home app on your iPhone/iPad.</li>
<li>To create automation rules, go to the <strong>⏰ DWM Rules</strong> tab and click <strong>+ Add Rule</strong>.</li>
<li>Rules run inside Homebridge — no internet or Belkin cloud required.</li>
</ol>
</div>
<!-- DWM Rules -->
<div class="card" style="margin-bottom:10px">
<h3 style="color:var(--accent);margin-bottom:10px">⏰ DWM Rules — How to Create a Rule</h3>
<p style="font-size:0.83rem;color:var(--muted);margin-bottom:10px">DWM (Dibby Wemo Manager) rules are stored locally and run in Homebridge.</p>
<ol style="font-size:0.85rem;line-height:1.9;padding-left:18px;color:var(--text)">
<li>Click the <strong>⏰ DWM Rules</strong> tab at the top.</li>
<li>Click <strong>+ Add Rule</strong> — the rule form opens inline on the same page (no pop-up).</li>
<li>Enter a <strong>Rule Name</strong> (e.g. "Evening Lights").</li>
<li>Choose a <strong>Rule Type</strong> (see types below).</li>
<li>Select <strong>target devices</strong> — which lights/switches the rule controls.</li>
<li>Fill in the schedule details and click <strong>Save Rule</strong>. Click <strong>Cancel</strong> or the <strong></strong> button to go back without saving.</li>
<li>The rule is active immediately — the toggle switch on the card enables/disables it without deleting it.</li>
</ol>
<div style="margin-top:14px;border-top:1px solid var(--border);padding-top:12px">
<p style="font-size:0.82rem;font-weight:600;color:var(--text);margin-bottom:8px">Rule Types:</p>
<table style="width:100%;font-size:0.82rem;border-collapse:collapse">
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 8px;white-space:nowrap">📅 <strong>Schedule</strong></td>
<td style="padding:7px 8px;color:var(--muted)">Turn on/off at fixed times on selected days. Enter times in 12-hour format (e.g. <em>8:30 PM</em>). Set a start time and optional end time, choose the action for each.</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 8px;white-space:nowrap"><strong>Countdown</strong></td>
<td style="padding:7px 8px;color:var(--muted)">Auto-off after a set number of minutes. Useful for things like a bathroom fan or porch light.</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 8px;white-space:nowrap">🏠 <strong>Away Mode</strong></td>
<td style="padding:7px 8px;color:var(--muted)">Randomly turns lights on and off within a time window to simulate occupancy while you're away.</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 8px;white-space:nowrap">🔒 <strong>Always On</strong></td>
<td style="padding:7px 8px;color:var(--muted)">Keeps a device permanently ON. If it's switched off by anyone, it will be turned back on within 10 seconds automatically. No time fields needed.</td>
</tr>
<tr>
<td style="padding:7px 8px;white-space:nowrap"><strong>Trigger</strong></td>
<td style="padding:7px 8px;color:var(--muted)">IFTTT-style: when one device turns on/off, automatically control another. E.g. "When the porch light turns ON, turn ON the driveway lights too."</td>
</tr>
</table>
</div>
<div style="margin-top:14px;border-top:1px solid var(--border);padding-top:12px">
<p style="font-size:0.82rem;font-weight:600;color:var(--text);margin-bottom:6px">⏰ Entering Times</p>
<p style="font-size:0.82rem;color:var(--muted);margin-bottom:6px">Times use 12-hour AM/PM format. All of these are valid:</p>
<table style="font-size:0.82rem;border-collapse:collapse">
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">8:30 PM</code></td><td style="color:var(--muted)">8:30 in the evening</td></tr>
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">8:30PM</code></td><td style="color:var(--muted)">same — space is optional</td></tr>
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">6:00 AM</code></td><td style="color:var(--muted)">6 o'clock in the morning</td></tr>
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">12:00 AM</code></td><td style="color:var(--muted)">midnight</td></tr>
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">12:00 PM</code></td><td style="color:var(--muted)">noon</td></tr>
<tr><td style="padding:3px 12px 3px 0;color:var(--text)"><code style="background:var(--bg2);padding:1px 5px;border-radius:3px">9 PM</code></td><td style="color:var(--muted)">9:00 PM — minutes are optional</td></tr>
</table>
</div>
</div>
<!-- Trigger rules detail -->
<div class="card" style="margin-bottom:10px">
<h3 style="color:var(--accent);margin-bottom:10px">⚡ Trigger Rules (IFTTT)</h3>
<p style="font-size:0.83rem;color:var(--muted);margin-bottom:10px">Trigger rules let one device control another automatically.</p>
<ol style="font-size:0.85rem;line-height:1.9;padding-left:18px;color:var(--text)">
<li>Click <strong>+ Add Rule</strong> and select type <strong>⚡ Trigger</strong>.</li>
<li>Under <strong>Trigger Device</strong> — pick the device whose state change starts the action.</li>
<li>Under <strong>When</strong> — choose "Turns ON", "Turns OFF", or "Turns ON or OFF".</li>
<li>Under <strong>Then</strong> — choose what to do to the action devices:<br>
<span style="color:var(--muted);display:block;padding-left:12px;margin-top:2px">
<strong>Turn ON</strong> — always turn action devices on<br>
<strong>Turn OFF</strong> — always turn action devices off<br>
<strong>Mirror</strong> — action devices copy the trigger (ON→ON, OFF→OFF)<br>
<strong>Opposite</strong> — action devices invert the trigger (ON→OFF, OFF→ON)
</span>
</li>
<li>Under <strong>Action Devices</strong> — select which devices to control (hold Ctrl/Cmd for multiple).</li>
<li>Click <strong>Save Rule</strong>. Homebridge polls devices every 10 s and fires the trigger on state change.</li>
</ol>
<p style="font-size:0.8rem;color:var(--muted);margin-top:8px;padding:8px;background:rgba(255,214,10,.07);border-radius:6px">
⚠️ The scheduler must be running for Trigger rules to work. If Homebridge restarts, rules resume automatically.
</p>
</div>
<!-- Device Rules -->
<div class="card" style="margin-bottom:10px">
<h3 style="color:var(--accent);margin-bottom:10px">🔌 Device Rules (Native Firmware)</h3>
<p style="font-size:0.83rem;color:var(--muted);margin-bottom:8px">These are rules stored directly on the Wemo device's own firmware — separate from DWM Rules.</p>
<ul style="font-size:0.85rem;line-height:1.8;padding-left:18px;color:var(--text)">
<li>Click <strong>🔌 Device Rules</strong> tab, then select a device from the dropdown.</li>
<li>Rules stored on the device are listed. You can enable/disable or delete them.</li>
<li>Note: Wemo Dimmer V2 devices with newer firmware do <strong>not</strong> support this feature.</li>
<li>DWM Rules are recommended over device rules as they support more features and work across multiple devices.</li>
</ul>
</div>
<!-- Settings -->
<div class="card" style="margin-bottom:10px">
<h3 style="color:var(--accent);margin-bottom:10px">⚙️ Settings — Location</h3>
<p style="font-size:0.83rem;color:var(--muted);margin-bottom:8px">Set your city for accurate sunrise/sunset times in Schedule rules.</p>
<ol style="font-size:0.85rem;line-height:1.9;padding-left:18px;color:var(--text)">
<li>Click the <strong>⚙️ Settings</strong> tab.</li>
<li>Type your city name in the search box (e.g. "London" or "New York").</li>
<li>Pick your city from the dropdown that appears.</li>
<li>Click <strong>Save Location</strong>.</li>
<li>You can now use 🌅 Sunrise and 🌇 Sunset as start/end times in Schedule rules.</li>
</ol>
</div>
<!-- Troubleshooting -->
<div class="card">
<h3 style="color:var(--accent);margin-bottom:10px">🔧 Troubleshooting</h3>
<table style="width:100%;font-size:0.82rem;border-collapse:collapse">
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 8px;white-space:nowrap;color:var(--text)"><strong>No devices found</strong></td>
<td style="padding:7px 8px;color:var(--muted)">Make sure your PC and Wemo devices are on the same WiFi network. Try clicking Discover again. Some routers block SSDP multicast — add a manual device entry via the Homebridge config.</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 8px;white-space:nowrap;color:var(--text)"><strong>HomeKit toggle not working</strong></td>
<td style="padding:7px 8px;color:var(--muted)">Restart Homebridge. Devices need to be discovered at least once before HomeKit can control them. Check the Homebridge logs for errors.</td>
</tr>
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:7px 8px;white-space:nowrap;color:var(--text)"><strong>Rules not firing</strong></td>
<td style="padding:7px 8px;color:var(--muted)">Check the <strong>⏰ DWM Rules</strong> tab status bar. 🟢 Green = scheduler running fine. 🟠 Amber = scheduler may have stopped — restart Homebridge. 🔴 Red = scheduler not running — check the DibbyWemo platform is in your Homebridge config. Times use 12-hour AM/PM (e.g. 8:30 PM).</td>
</tr>
<tr>
<td style="padding:7px 8px;white-space:nowrap;color:var(--text)"><strong>Settings panel blank</strong></td>
<td style="padding:7px 8px;color:var(--muted)">Run: <code style="background:var(--bg2);padding:1px 5px;border-radius:3px">npm install --prefix "%APPDATA%/npm/node_modules/homebridge-dibby-wemo"</code> then restart Homebridge.</td>
</tr>
</table>
</div>
</div>
<script src="index.js"></script>
</body>
</html>
@@ -0,0 +1,768 @@
/* Dibby Wemo Manager — Homebridge custom UI */
/* global homebridge */
'use strict';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let _devices = [];
let _dwmRules = [];
let _wemoRules = null; // { rules, ruleDevices, targets } for selected device
let _editingDwmId = null; // null = create, string = update
let _selectedDwmDays = new Set();
let _pendingLocation = null; // { lat, lng, label }
// ---------------------------------------------------------------------------
// Tabs
// ---------------------------------------------------------------------------
document.querySelectorAll('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach((p) => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(`tab-${btn.dataset.tab}`).classList.add('active');
});
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// Display seconds as 12-hour time: "8:30 AM" / "11:00 PM"
function secsToHHMM(secs) {
if (secs == null || secs < 0) return '';
const totalMins = Math.floor(secs / 60);
let h = Math.floor(totalMins / 60) % 24;
const m = totalMins % 60;
const ampm = h < 12 ? 'AM' : 'PM';
h = h % 12 || 12; // 0 → 12, 13 → 1, etc.
return `${h}:${String(m).padStart(2, '0')} ${ampm}`;
}
// Accept "8:30 AM", "8:30AM", "08:30 am", "8:30" (24-hr fallback), "8 AM"
function hhmmToSecs(str) {
if (!str) return -1;
str = str.trim().toUpperCase();
const match = str.match(/^(\d{1,2})(?::(\d{2}))?\s*(AM|PM)?$/);
if (!match) return -1;
let h = parseInt(match[1], 10);
const m = match[2] ? parseInt(match[2], 10) : 0;
const period = match[3];
if (isNaN(h) || isNaN(m) || m > 59) return -1;
if (period) {
// 12-hour mode
if (h < 1 || h > 12) return -1;
if (period === 'AM') h = h === 12 ? 0 : h;
else h = h === 12 ? 12 : h + 12;
} else {
// 24-hour fallback
if (h > 23) return -1;
}
return h * 3600 + m * 60;
}
const DAY_NAMES = ['', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
function dayLabel(dayIds) {
if (!dayIds?.length) return '—';
if (dayIds.length === 7) return 'Every day';
return dayIds.map((d) => DAY_NAMES[d] ?? d).join(', ');
}
function showStatus(containerId, msg, type = 'info') {
const el = document.getElementById(containerId);
if (!el) return;
el.innerHTML = msg
? `<div class="alert alert-${type}">${msg}</div>`
: '';
}
function spinner() { return '<span class="spin"></span>'; }
// ---------------------------------------------------------------------------
// Devices tab
// ---------------------------------------------------------------------------
async function loadDevices() {
showStatus('devices-status', spinner() + ' Loading…', 'info');
try {
_devices = await homebridge.request('/devices/list');
renderDevices();
showStatus('devices-status', '');
} catch (e) {
showStatus('devices-status', 'Failed to load devices: ' + e.message, 'error');
}
}
async function discoverDevices() {
const btn = document.getElementById('btn-discover');
btn.disabled = true;
showStatus('devices-status', spinner() + ' Scanning for devices (up to 10 s)…', 'info');
try {
_devices = await homebridge.request('/devices/discover', { timeout: 10000 });
renderDevices();
showStatus('devices-status', `Found ${_devices.length} device(s)`, 'success');
refreshWemoDeviceSelect();
} catch (e) {
showStatus('devices-status', 'Discovery failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
}
}
function renderDevices() {
const el = document.getElementById('devices-list');
if (!_devices.length) {
el.innerHTML = '<div class="empty">No devices found. Click Discover to scan your network.</div>';
return;
}
el.innerHTML = _devices.map((d, i) => `
<div class="card">
<div class="card-header">
<div>
<div class="card-title">${esc(d.friendlyName ?? d.host)}</div>
<div class="card-subtitle">${esc(d.host)}:${d.port}${esc(d.productModel ?? 'Wemo Device')}</div>
</div>
<div class="toggle-wrap">
<span id="dev-state-label-${i}" style="font-size:0.82rem;color:var(--muted)">…</span>
<label class="toggle">
<input type="checkbox" id="dev-toggle-${i}" onchange="setDeviceState(${i},this.checked)" />
<span class="slider"></span>
</label>
</div>
</div>
</div>
`).join('');
// Fetch state for each device
_devices.forEach((d, i) => fetchDeviceState(i, d));
}
async function fetchDeviceState(idx, device) {
try {
const on = await homebridge.request('/devices/state', { host: device.host, port: device.port });
const toggle = document.getElementById(`dev-toggle-${idx}`);
const label = document.getElementById(`dev-state-label-${idx}`);
if (toggle) toggle.checked = !!on;
if (label) label.textContent = on ? 'ON' : 'OFF';
} catch { /* device unreachable */ }
}
async function setDeviceState(idx, on) {
const d = _devices[idx];
if (!d) return;
const label = document.getElementById(`dev-state-label-${idx}`);
if (label) label.textContent = on ? 'ON' : 'OFF';
try {
await homebridge.request('/devices/setState', { host: d.host, port: d.port, on });
} catch (e) {
showStatus('devices-status', `Failed to set ${d.friendlyName}: ${e.message}`, 'error');
// Revert toggle
const toggle = document.getElementById(`dev-toggle-${idx}`);
if (toggle) toggle.checked = !on;
if (label) label.textContent = !on ? 'ON' : 'OFF';
}
}
document.getElementById('btn-discover').addEventListener('click', discoverDevices);
// ---------------------------------------------------------------------------
// DWM Rules tab
// ---------------------------------------------------------------------------
async function loadDwmRules() {
try {
_dwmRules = await homebridge.request('/rules/list');
renderDwmRules();
} catch (e) {
showStatus('dwm-rules-status', 'Failed to load rules: ' + e.message, 'error');
}
}
function dwmRuleSummary(r) {
if (r.type === 'AlwaysOn') {
const devs = (r.targetDevices ?? []).map((td) => esc(td.name ?? td.host)).join(', ') || 'no targets';
return `🔒 Enforced ON every 10 s · ${devs}`;
}
if (r.type === 'Trigger') {
const src = esc(r.triggerDevice?.name ?? r.triggerDevice?.host ?? '?');
const when = r.triggerEvent === 'on' ? 'ON' : r.triggerEvent === 'off' ? 'OFF' : 'ON/OFF';
const action = r.action === 'mirror' ? 'mirror' : r.action === 'opposite' ? 'opposite' : (r.action ?? 'on').toUpperCase();
const targets = (r.actionDevices ?? []).map((td) => esc(td.name ?? td.host)).join(', ') || '—';
return `⚡ If ${src}${when}, then ${action} (${targets})`;
}
if (r.type === 'Countdown') {
const mins = r.countdownTime ? Math.round(r.countdownTime / 60) : null;
return mins ? `${mins} min auto-off` : '—';
}
const days = dayLabel(r.days);
const devs = (r.targetDevices ?? []).map((td) => esc(td.name ?? td.host)).join(', ') || 'no targets';
const start = secsToHHMM(r.startTime) || '—';
const end = r.endTime > 0 ? ' ' + secsToHHMM(r.endTime) : '';
return `${days} · ${start}${end} · ${devs}`;
}
function renderDwmRules() {
const el = document.getElementById('dwm-rules-list');
if (!_dwmRules.length) {
el.innerHTML = '<div class="empty">No DWM rules yet. Click "+ Add Rule" to create one.</div>';
return;
}
const typeIcon = { Schedule: '📅', Away: '🏠', Countdown: '⏱', AlwaysOn: '🔒', Trigger: '⚡' };
el.innerHTML = _dwmRules.map((r) => `
<div class="card" data-rule-id="${r.id}">
<div class="card-header">
<div>
<div class="card-title">
${typeIcon[r.type] || '📅'} ${esc(r.name)}
<span class="chip ${r.enabled ? 'chip-on' : 'chip-dis'}">${r.enabled ? 'enabled' : 'disabled'}</span>
<span class="chip chip-off">${esc(r.type)}</span>
</div>
<div class="card-subtitle">${dwmRuleSummary(r)}</div>
</div>
<div class="flex-row">
<label class="toggle" title="${r.enabled ? 'Disable' : 'Enable'} rule">
<input type="checkbox" ${r.enabled ? 'checked' : ''} onchange="toggleDwmRule('${r.id}', this.checked)" />
<span class="slider"></span>
</label>
<button class="btn btn-ghost btn-sm" onclick="openDwmEdit('${r.id}')">Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteDwmRule('${r.id}')">Delete</button>
</div>
</div>
</div>
`).join('');
}
async function toggleDwmRule(id, enabled) {
try {
await homebridge.request('/rules/update', { id, updates: { enabled } });
await loadDwmRules();
} catch (e) {
showStatus('dwm-rules-status', 'Toggle failed: ' + e.message, 'error');
await loadDwmRules();
}
}
function deleteDwmRule(id) {
// confirm() is blocked in cross-origin iframes — use inline confirm row instead
const card = document.querySelector(`[data-rule-id="${id}"]`);
if (!card) return;
// If already showing confirm, execute delete
const existing = card.querySelector('.delete-confirm-row');
if (existing) {
existing.remove();
homebridge.request('/rules/delete', { id })
.then(() => loadDwmRules())
.catch((e) => showStatus('dwm-rules-status', 'Delete failed: ' + e.message, 'error'));
return;
}
// Show inline confirm bar
const row = document.createElement('div');
row.className = 'delete-confirm-row';
row.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:8px;padding:6px 10px;background:rgba(239,68,68,.12);border-radius:6px;font-size:0.8rem';
row.innerHTML = '<span style="color:#fca5a5;flex:1">Delete this rule?</span>'
+ `<button class="btn btn-danger btn-sm" onclick="deleteDwmRule('${id}')">Yes, delete</button>`
+ '<button class="btn btn-ghost btn-sm" onclick="this.closest(\'.delete-confirm-row\').remove()">Cancel</button>';
card.appendChild(row);
// Auto-dismiss after 5 seconds
setTimeout(() => row.remove(), 5000);
}
document.getElementById('btn-add-dwm').addEventListener('click', () => openDwmEdit(null));
// ── DWM Inline Form ───────────────────────────────────────────────────────────
function openDwmEdit(id) {
_editingDwmId = id;
_selectedDwmDays = new Set();
document.getElementById('dwm-form-error').style.display = 'none';
document.getElementById('dwm-form-title').textContent = id ? 'Edit DWM Rule' : 'Add DWM Rule';
const devOptions = _devices.map((d) =>
`<option value="${esc(d.host)}:${d.port}">${esc(d.friendlyName ?? d.host)}</option>`
).join('');
// Populate all device selects
document.getElementById('dwm-target-devices').innerHTML = devOptions;
document.getElementById('dwm-trigger-src').innerHTML = '<option value="">— select device —</option>' + devOptions;
document.getElementById('dwm-trigger-targets').innerHTML = devOptions;
if (id) {
const r = _dwmRules.find((x) => x.id === id);
if (!r) return;
document.getElementById('dwm-name').value = r.name ?? '';
document.getElementById('dwm-type').value = r.type ?? 'Schedule';
document.getElementById('dwm-enabled').checked = r.enabled !== false;
document.getElementById('dwm-start-time').value = secsToHHMM(r.startTime);
document.getElementById('dwm-end-time').value = secsToHHMM(r.endTime);
document.getElementById('dwm-start-action').value = String(r.startAction ?? 1);
document.getElementById('dwm-end-action').value = String(r.endAction ?? -1);
document.getElementById('dwm-countdown-mins').value =
r.countdownTime ? String(Math.round(r.countdownTime / 60)) : '';
_selectedDwmDays = new Set((r.days ?? []).map(Number));
// Select target devices
const targets = (r.targetDevices ?? []).map((td) => `${td.host}:${td.port}`);
Array.from(document.getElementById('dwm-target-devices').options).forEach((opt) => {
opt.selected = targets.includes(opt.value);
});
// Trigger-specific
if (r.type === 'Trigger') {
const srcKey = r.triggerDevice ? `${r.triggerDevice.host}:${r.triggerDevice.port}` : '';
document.getElementById('dwm-trigger-src').value = srcKey;
document.getElementById('dwm-trigger-event').value = r.triggerEvent ?? 'any';
document.getElementById('dwm-trigger-action').value = r.action ?? 'on';
const actKeys = (r.actionDevices ?? []).map((td) => `${td.host}:${td.port}`);
Array.from(document.getElementById('dwm-trigger-targets').options).forEach((opt) => {
opt.selected = actKeys.includes(opt.value);
});
}
} else {
document.getElementById('dwm-name').value = '';
document.getElementById('dwm-type').value = 'Schedule';
document.getElementById('dwm-enabled').checked = true;
document.getElementById('dwm-start-time').value = '';
document.getElementById('dwm-end-time').value = '';
document.getElementById('dwm-start-action').value = '1';
document.getElementById('dwm-end-action').value = '-1';
document.getElementById('dwm-countdown-mins').value = '';
document.getElementById('dwm-trigger-src').value = '';
document.getElementById('dwm-trigger-event').value = 'any';
document.getElementById('dwm-trigger-action').value = 'on';
Array.from(document.getElementById('dwm-target-devices').options).forEach((opt) => { opt.selected = false; });
Array.from(document.getElementById('dwm-trigger-targets').options).forEach((opt) => { opt.selected = false; });
}
updateDwmDayButtons();
updateDwmTypeFields();
document.getElementById('dwm-list-view').style.display = 'none';
document.getElementById('dwm-form-panel').style.display = '';
window.scrollTo(0, 0);
}
function updateDwmDayButtons() {
document.querySelectorAll('#dwm-days .day-btn').forEach((btn) => {
const d = Number(btn.dataset.day);
btn.classList.toggle('selected', _selectedDwmDays.has(d));
});
}
function updateDwmTypeFields() {
const type = document.getElementById('dwm-type').value;
const isSchedule = type === 'Schedule' || type === 'Away';
const isCountdown = type === 'Countdown';
const isAlwaysOn = type === 'AlwaysOn';
const isTrigger = type === 'Trigger';
const isTimeBased = isSchedule || isCountdown;
document.getElementById('dwm-target-group').style.display = isTrigger ? 'none' : '';
document.getElementById('dwm-days-group').style.display = isTrigger || isAlwaysOn ? 'none' : '';
document.getElementById('dwm-schedule-fields').style.display = isCountdown || isTrigger || isAlwaysOn ? 'none' : '';
document.getElementById('dwm-countdown-fields').style.display = isCountdown ? '' : 'none';
document.getElementById('dwm-trigger-fields').style.display = isTrigger ? '' : 'none';
document.getElementById('dwm-alwayson-info').style.display = isAlwaysOn ? '' : 'none';
}
document.querySelectorAll('#dwm-days .day-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const d = Number(btn.dataset.day);
if (_selectedDwmDays.has(d)) _selectedDwmDays.delete(d);
else _selectedDwmDays.add(d);
updateDwmDayButtons();
});
});
document.getElementById('dwm-type').addEventListener('change', updateDwmTypeFields);
function closeDwmModal() {
document.getElementById('dwm-form-panel').style.display = 'none';
document.getElementById('dwm-list-view').style.display = '';
}
document.getElementById('btn-dwm-form-cancel').addEventListener('click', closeDwmModal);
document.getElementById('dwm-form-cancel-btn').addEventListener('click', closeDwmModal);
document.getElementById('dwm-form-save-btn').addEventListener('click', async () => {
const errEl = document.getElementById('dwm-form-error');
errEl.style.display = 'none';
const name = document.getElementById('dwm-name').value.trim();
const type = document.getElementById('dwm-type').value;
const enabled = document.getElementById('dwm-enabled').checked;
if (!name) { showModalError('Rule name is required'); return; }
const devFromKey = (key) => {
const [host, port] = key.split(':');
const dev = _devices.find((d) => d.host === host && String(d.port) === port);
return { host, port: Number(port), name: dev?.friendlyName ?? host, udn: dev?.udn };
};
// ── AlwaysOn ──────────────────────────────────────────────────────────────
if (type === 'AlwaysOn') {
const selEl = document.getElementById('dwm-target-devices');
const selectedDevs = Array.from(selEl.selectedOptions).map((opt) => devFromKey(opt.value));
if (!selectedDevs.length) { showModalError('Select at least one device to keep on'); return; }
const rule = { name, type, enabled, targetDevices: selectedDevs };
try {
if (_editingDwmId) await homebridge.request('/rules/update', { id: _editingDwmId, updates: rule });
else await homebridge.request('/rules/create', rule);
closeDwmModal();
await loadDwmRules();
} catch (e) { showModalError('Save failed: ' + e.message); }
return;
}
// ── Trigger ───────────────────────────────────────────────────────────────
if (type === 'Trigger') {
const srcKey = document.getElementById('dwm-trigger-src').value;
if (!srcKey) { showModalError('Select a trigger (source) device'); return; }
const actTargets = Array.from(document.getElementById('dwm-trigger-targets').selectedOptions)
.map((opt) => devFromKey(opt.value));
if (!actTargets.length) { showModalError('Select at least one action device'); return; }
const rule = {
name, type, enabled,
triggerDevice: devFromKey(srcKey),
triggerEvent: document.getElementById('dwm-trigger-event').value,
action: document.getElementById('dwm-trigger-action').value,
actionDevices: actTargets,
};
try {
if (_editingDwmId) await homebridge.request('/rules/update', { id: _editingDwmId, updates: rule });
else await homebridge.request('/rules/create', rule);
closeDwmModal();
await loadDwmRules();
} catch (e) { showModalError('Save failed: ' + e.message); }
return;
}
// ── Schedule / Countdown / Away ───────────────────────────────────────────
if (_selectedDwmDays.size === 0 && type !== 'Countdown') {
showModalError('Select at least one day'); return;
}
const selEl = document.getElementById('dwm-target-devices');
const selectedDevs = Array.from(selEl.selectedOptions).map((opt) => devFromKey(opt.value));
if (!selectedDevs.length) { showModalError('Select at least one target device'); return; }
const rule = {
name, type, enabled,
days: Array.from(_selectedDwmDays).sort(),
targetDevices: selectedDevs,
};
if (type === 'Countdown') {
const mins = Number(document.getElementById('dwm-countdown-mins').value);
if (!mins || mins < 1) { showModalError('Enter countdown duration in minutes'); return; }
rule.countdownTime = mins * 60;
} else {
const startSecs = hhmmToSecs(document.getElementById('dwm-start-time').value);
if (startSecs < 0) { showModalError('Enter a valid start time (HH:MM)'); return; }
rule.startTime = startSecs;
rule.endTime = hhmmToSecs(document.getElementById('dwm-end-time').value);
rule.startAction = Number(document.getElementById('dwm-start-action').value);
rule.endAction = Number(document.getElementById('dwm-end-action').value);
}
try {
if (_editingDwmId) {
await homebridge.request('/rules/update', { id: _editingDwmId, updates: rule });
} else {
await homebridge.request('/rules/create', rule);
}
closeDwmModal();
await loadDwmRules();
} catch (e) {
showModalError('Save failed: ' + e.message);
}
});
function showModalError(msg) {
const el = document.getElementById('dwm-form-error');
el.textContent = msg;
el.style.display = 'block';
}
// ---------------------------------------------------------------------------
// Wemo Device Rules tab
// ---------------------------------------------------------------------------
function refreshWemoDeviceSelect() {
const sel = document.getElementById('wemo-rules-device-select');
const cur = sel.value;
sel.innerHTML = '<option value="">— choose device —</option>' +
_devices.map((d) =>
`<option value="${esc(d.host)}:${d.port}">${esc(d.friendlyName ?? d.host)} (${esc(d.host)})</option>`
).join('');
if (cur) sel.value = cur;
}
document.getElementById('wemo-rules-device-select').addEventListener('change', async function () {
const val = this.value;
if (!val) { document.getElementById('wemo-rules-list').innerHTML = ''; return; }
const [host, portStr] = val.split(':');
const port = Number(portStr);
showStatus('wemo-rules-status', spinner() + ' Fetching rules from device…', 'info');
document.getElementById('wemo-rules-list').innerHTML = '';
try {
_wemoRules = await homebridge.request('/rules/wemo/list', { host, port });
showStatus('wemo-rules-status', '');
renderWemoRules(host, port);
} catch (e) {
if (String(e.message).includes('FetchRules') || String(e.message).includes('rules1')) {
showStatus('wemo-rules-status',
'⚠️ This device does not support the Wemo Rules service (e.g. Dimmer V2 with newer firmware).', 'info');
} else {
showStatus('wemo-rules-status', 'Failed: ' + e.message, 'error');
}
}
});
function renderWemoRules(host, port) {
const el = document.getElementById('wemo-rules-list');
if (!_wemoRules?.rules?.length) {
el.innerHTML = '<div class="empty">No on-device rules found.</div>';
return;
}
el.innerHTML = _wemoRules.rules.map((r) => {
const devices = (_wemoRules.ruleDevices ?? []).filter((rd) => String(rd.RuleID) === String(r.RuleID));
const enabled = String(r.State) === '1';
const dayList = [...new Set(devices.map((d) => d.DayID))].sort().map((d) => DAY_NAMES[d] ?? d).join(', ') || '—';
const startTime = devices[0]?.StartTime >= 0 ? secsToHHMM(devices[0].StartTime) : '—';
return `<div class="card">
<div class="card-header">
<div>
<div class="card-title">
${esc(r.Name)}
<span class="chip ${enabled ? 'chip-on' : 'chip-dis'}">${enabled ? 'enabled' : 'disabled'}</span>
<span class="chip chip-off">${esc(r.Type)}</span>
</div>
<div class="card-subtitle">${dayList} · ${startTime}</div>
</div>
<div class="flex-row">
<label class="toggle" title="${enabled ? 'Disable' : 'Enable'} on device">
<input type="checkbox" ${enabled ? 'checked' : ''} onchange="toggleWemoRule('${esc(host)}',${port},'${r.RuleID}',this.checked)" />
<span class="slider"></span>
</label>
<button class="btn btn-danger btn-sm" onclick="deleteWemoRule('${esc(host)}',${port},'${r.RuleID}')">Delete</button>
</div>
</div>
</div>`;
}).join('');
}
async function toggleWemoRule(host, port, ruleId, enabled) {
showStatus('wemo-rules-status', spinner() + ' Updating device…', 'info');
try {
await homebridge.request('/rules/wemo/toggle', { host, port, ruleId, enabled });
showStatus('wemo-rules-status', 'Rule updated ✓', 'success');
// Refresh list
_wemoRules = await homebridge.request('/rules/wemo/list', { host, port });
renderWemoRules(host, port);
setTimeout(() => showStatus('wemo-rules-status', ''), 2500);
} catch (e) {
showStatus('wemo-rules-status', 'Toggle failed: ' + e.message, 'error');
_wemoRules = await homebridge.request('/rules/wemo/list', { host, port });
renderWemoRules(host, port);
}
}
async function deleteWemoRule(host, port, ruleId) {
if (!confirm('Delete this on-device rule? This cannot be undone.')) return;
showStatus('wemo-rules-status', spinner() + ' Deleting…', 'info');
try {
await homebridge.request('/rules/wemo/delete', { host, port, ruleId });
showStatus('wemo-rules-status', 'Rule deleted ✓', 'success');
_wemoRules = await homebridge.request('/rules/wemo/list', { host, port });
renderWemoRules(host, port);
setTimeout(() => showStatus('wemo-rules-status', ''), 2500);
} catch (e) {
showStatus('wemo-rules-status', 'Delete failed: ' + e.message, 'error');
}
}
// ---------------------------------------------------------------------------
// Settings — Location
// ---------------------------------------------------------------------------
async function loadLocation() {
try {
const loc = await homebridge.request('/location/get');
updateLocationDisplay(loc);
} catch { /* ignore */ }
}
function updateLocationDisplay(loc) {
const el = document.getElementById('location-current');
if (loc?.lat != null) {
el.textContent = `📍 ${loc.label ?? `${loc.lat}, ${loc.lng}`}`;
} else {
el.textContent = 'Not set';
}
}
let _locSearchTimer = null;
document.getElementById('location-search-input').addEventListener('input', function () {
clearTimeout(_locSearchTimer);
const q = this.value.trim();
if (q.length < 2) { hideAutocomplete(); return; }
_locSearchTimer = setTimeout(() => searchLocation(q), 400);
});
async function searchLocation(query) {
try {
const results = await homebridge.request('/location/search', { query });
showAutocomplete(results);
} catch { hideAutocomplete(); }
}
function showAutocomplete(results) {
const el = document.getElementById('location-autocomplete');
if (!results.length) { hideAutocomplete(); return; }
el.innerHTML = results.map((r, i) =>
`<div class="autocomplete-item" data-idx="${i}">${esc(r.label)}</div>`
).join('');
el.style.display = 'block';
el._results = results;
el.querySelectorAll('.autocomplete-item').forEach((item, i) => {
item.addEventListener('click', () => {
_pendingLocation = el._results[i];
document.getElementById('location-search-input').value = _pendingLocation.label;
hideAutocomplete();
document.getElementById('btn-location-save').disabled = false;
});
});
}
function hideAutocomplete() {
const el = document.getElementById('location-autocomplete');
el.style.display = 'none';
}
document.getElementById('btn-location-save').addEventListener('click', async () => {
if (!_pendingLocation) return;
try {
await homebridge.request('/location/set', _pendingLocation);
updateLocationDisplay(_pendingLocation);
document.getElementById('location-status').textContent = 'Saved ✓';
document.getElementById('btn-location-save').disabled = true;
_pendingLocation = null;
setTimeout(() => { document.getElementById('location-status').textContent = ''; }, 2500);
} catch (e) {
document.getElementById('location-status').textContent = 'Failed: ' + e.message;
}
});
// ---------------------------------------------------------------------------
// XSS-safe text escaping
// ---------------------------------------------------------------------------
function esc(str) {
return String(str ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ---------------------------------------------------------------------------
// Scheduler heartbeat
// ---------------------------------------------------------------------------
async function refreshHeartbeat() {
const dot = document.getElementById('hb-dot');
const text = document.getElementById('hb-text');
const next = document.getElementById('hb-next');
if (!dot) return;
try {
const hb = await homebridge.request('/scheduler/status');
if (!hb || !hb.running) {
dot.style.background = '#ef4444';
text.style.color = '#fca5a5';
text.textContent = hb?.ts
? '⚠ Scheduler stopped — restart Homebridge to recover'
: '⚠ Scheduler not running — check Homebridge config has DibbyWemo platform';
next.textContent = '';
return;
}
if (hb.stale) {
dot.style.background = '#f97316';
text.style.color = '#fdba74';
text.textContent = '⚠ Scheduler may be unresponsive (last heartbeat: ' + _relTime(hb.ts) + ')';
next.textContent = '';
return;
}
// Healthy
dot.style.background = '#22c55e';
text.style.color = '#4ade80';
text.textContent = '✓ Scheduler running · ' + hb.totalEntries + ' schedule entr' + (hb.totalEntries === 1 ? 'y' : 'ies');
// Last fired
if (hb.lastFire) {
const icon = hb.lastFire.success ? '✓' : '⚠';
next.textContent = 'Last: ' + icon + ' ' + hb.lastFire.msg.replace(/\s*[✓⚠]\s*$/, '') + ' · ' + _relTime(hb.lastFire.at);
next.style.color = hb.lastFire.success ? 'var(--muted)' : '#fca5a5';
} else if (hb.upcoming && hb.upcoming.length) {
const u = hb.upcoming[0];
next.textContent = 'Next: ' + u.ruleName + ' → ' + u.action + ' at ' + u.at;
next.style.color = 'var(--muted)';
} else {
next.textContent = 'No upcoming rules today';
next.style.color = 'var(--muted)';
}
} catch {
dot.style.background = 'var(--muted)';
text.style.color = 'var(--muted)';
text.textContent = 'Scheduler status unavailable';
next.textContent = '';
}
}
function _relTime(iso) {
const diff = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
return Math.floor(diff / 3600) + 'h ago';
}
// Poll heartbeat every 35 seconds while on the DWM tab
let _hbTimer = null;
function startHeartbeatPolling() {
refreshHeartbeat();
_hbTimer = setInterval(refreshHeartbeat, 35_000);
}
function stopHeartbeatPolling() {
if (_hbTimer) { clearInterval(_hbTimer); _hbTimer = null; }
}
// Start/stop polling when tab changes
document.querySelectorAll('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
if (btn.dataset.tab === 'dwm-rules') startHeartbeatPolling();
else stopHeartbeatPolling();
});
});
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
(async function init() {
await loadDevices();
await loadDwmRules();
await loadLocation();
refreshWemoDeviceSelect();
startHeartbeatPolling(); // start immediately (DWM tab is not default, but still useful)
})();
@@ -0,0 +1,151 @@
'use strict';
/**
* Homebridge custom UI server for homebridge-dibby-wemo.
*
* Runs as a child process managed by homebridge-config-ui-x.
* Communicates with the frontend via this.onRequest() / homebridge.request().
*
* Provides:
* - devices.list → saved device list (from plugin store)
* - devices.discover → trigger SSDP discovery
* - devices.state → get binary state of a device
* - devices.setState → set binary state of a device
* - rules.list → DWM rules from plugin store
* - rules.create → create a DWM rule
* - rules.update → update a DWM rule
* - rules.delete → delete a DWM rule
* - rules.wemo.list → fetch native device rules from a Wemo device
* - rules.wemo.toggle → enable / disable a native Wemo device rule
* - rules.wemo.delete → delete a native Wemo device rule
* - location.get → get stored location
* - location.search → geocode query via Nominatim
* - location.set → save location
*/
const { HomebridgePluginUiServer } = require('@homebridge/plugin-ui-utils');
const path = require('path');
const DwmStore = require('../lib/store');
const wemoClient = require('../lib/wemo-client');
const axios = require('axios');
class DibbyWemoUiServer extends HomebridgePluginUiServer {
constructor() {
super();
// Shared store instance — storagePath provided by homebridge-config-ui-x
this._store = new DwmStore(this.homebridgeStoragePath);
// ── Devices ─────────────────────────────────────────────────────────────
this.onRequest('/devices/list', async () => {
return this._store.getDevices();
});
this.onRequest('/devices/discover', async ({ timeout } = {}) => {
const ms = typeof timeout === 'number' ? timeout : 10_000;
const devices = await wemoClient.discoverDevices(ms);
// Persist updated list
this._store.saveDevices(devices.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,
})));
return devices;
});
this.onRequest('/devices/state', async ({ host, port }) => {
return await wemoClient.getBinaryState(host, Number(port));
});
this.onRequest('/devices/setState', async ({ host, port, on }) => {
await wemoClient.setBinaryState(host, Number(port), !!on);
return { ok: true };
});
// ── DWM Rules ────────────────────────────────────────────────────────────
this.onRequest('/rules/list', async () => {
return this._store.getDwmRules();
});
this.onRequest('/rules/create', async (rule) => {
return this._store.createDwmRule(rule);
});
this.onRequest('/rules/update', async ({ id, updates }) => {
return this._store.updateDwmRule(id, updates);
});
this.onRequest('/rules/delete', async ({ id }) => {
this._store.deleteDwmRule(id);
return { ok: true };
});
// ── Scheduler heartbeat ───────────────────────────────────────────────────
this.onRequest('/scheduler/status', async () => {
const hb = this._store.getHeartbeat();
if (!hb) return { running: false, stale: false, ts: null };
const ageMs = Date.now() - new Date(hb.ts).getTime();
// stale if no heartbeat for > 90 seconds (3 missed ticks)
return { ...hb, stale: ageMs > 90_000 };
});
// ── Native Wemo Device Rules ──────────────────────────────────────────────
this.onRequest('/rules/wemo/list', async ({ host, port }) => {
return await wemoClient.fetchRules(host, Number(port));
});
this.onRequest('/rules/wemo/toggle', async ({ host, port, ruleId, enabled }) => {
await wemoClient.toggleRule(host, Number(port), ruleId, !!enabled);
return { ok: true };
});
this.onRequest('/rules/wemo/delete', async ({ host, port, ruleId }) => {
await wemoClient.deleteRule(host, Number(port), ruleId);
return { ok: true };
});
this.onRequest('/rules/wemo/create', async ({ host, port, ruleData }) => {
const id = await wemoClient.createRule(host, Number(port), ruleData);
return { ok: true, id };
});
this.onRequest('/rules/wemo/update', async ({ host, port, ruleId, ruleData }) => {
await wemoClient.updateRule(host, Number(port), ruleId, ruleData);
return { ok: true };
});
// ── Location ──────────────────────────────────────────────────────────────
this.onRequest('/location/get', async () => {
return this._store.getLocation();
});
this.onRequest('/location/set', async (loc) => {
this._store.setLocation(loc);
return { ok: true };
});
this.onRequest('/location/search', async ({ query }) => {
try {
const res = await axios.get('https://nominatim.openstreetmap.org/search', {
params: { q: query, format: 'json', limit: 8, addressdetails: 1 },
headers: { 'User-Agent': 'homebrige-dibby-wemo/1.0' },
timeout: 8000,
});
return (res.data || []).map((r) => ({
lat: parseFloat(r.lat),
lng: parseFloat(r.lon),
label: r.display_name,
city: r.address?.city || r.address?.town || r.address?.village || '',
country: r.address?.country || '',
}));
} catch { return []; }
});
this.ready();
}
}
(() => new DibbyWemoUiServer())();