Files
ThaMunsta 905b54803d
Build Images and Deploy / Update-PROD-Stack (push) Successful in 18s
maybe now
2026-04-06 21:01:48 -04:00

1491 lines
66 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>DWM Remote</title>
<style>
:root {
--bg: #f0f4f8;
--card: #ffffff;
--border: #dde3ea;
--text: #1a2533;
--text2: #546878;
--accent: #00a9d5;
--on: #22c55e;
--off: #94a3b8;
--danger: #ef4444;
--nav-bg: #ffffff;
--radius: 14px;
--shadow: 0 2px 12px rgba(0,0,0,.08);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1b27;
--card: #1a2e40;
--border: #263647;
--text: #e2eaf2;
--text2: #7fa8c8;
--nav-bg: #142030;
--shadow: 0 2px 12px rgba(0,0,0,.3);
}
}
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg); color: var(--text); min-height: 100vh;
display: flex; flex-direction: column; }
/* ── Nav bar ── */
#nav { position: fixed; bottom: 0; left: 0; right: 0;
background: var(--nav-bg); border-top: 1px solid var(--border);
display: flex; z-index: 100; }
.nav-btn { flex: 1; padding: 10px 4px 14px; border: none; background: none;
cursor: pointer; color: var(--text2); font-size: 10px; font-weight: 600;
display: flex; flex-direction: column; align-items: center; gap: 3px;
letter-spacing: .04em; text-transform: uppercase; transition: color .15s; }
.nav-btn .icon { font-size: 22px; line-height: 1; }
.nav-btn.active { color: var(--accent); }
/* ── Pages ── */
#main { flex: 1; overflow-y: auto; padding: 16px 14px 90px; }
.page { display: none; }
.page.active { display: block; }
h2 { font-size: 20px; font-weight: 700; margin-bottom: 14px; }
/* ── Cards ── */
.card { background: var(--card); border-radius: var(--radius);
box-shadow: var(--shadow); margin-bottom: 10px;
padding: 14px 16px; display: flex; align-items: center; gap: 12px; }
.card-body { flex: 1; min-width: 0; }
.card-name { font-weight: 600; font-size: 15px; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis; }
.card-meta { font-size: 12px; color: var(--text2); margin-top: 2px; }
/* ── Toggle switch ── */
.toggle { position: relative; width: 52px; height: 30px; flex-shrink: 0; cursor: pointer; }
.toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
.track { position: absolute; inset: 0; border-radius: 15px;
background: var(--off); transition: background .2s; }
.thumb { position: absolute; top: 3px; left: 3px; width: 24px; height: 24px;
border-radius: 50%; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.2);
transition: transform .2s; }
.toggle input:checked + .track { background: var(--on); }
.toggle input:checked ~ .thumb { transform: translateX(22px); }
.toggle.busy .thumb::after {
content: ''; position: absolute; inset: 3px; border-radius: 50%;
border: 2px solid transparent; border-top-color: var(--accent);
animation: spin .6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Status badge ── */
.badge { display: inline-block; font-size: 11px; font-weight: 700; padding: 2px 8px;
border-radius: 5px; letter-spacing: .04em; }
.badge-on { background: rgba(34,197,94,.15); color: #16a34a; }
.badge-off { background: rgba(148,163,184,.15); color: var(--text2); }
.badge-disabled { background: rgba(239,68,68,.1); color: var(--danger); }
.badge-away { background: rgba(245,158,11,.15); color: #b45309; }
/* ── Toolbar ── */
.toolbar { display: flex; gap: 8px; margin-bottom: 14px; align-items: center; flex-wrap: wrap; }
.btn { border: none; border-radius: 10px; padding: 9px 16px; font-size: 13px;
font-weight: 600; cursor: pointer; transition: opacity .15s; }
.btn:active { opacity: .7; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-ghost { background: transparent; color: var(--accent); border: 1px solid var(--border); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-sm { padding: 5px 10px; font-size: 12px; border-radius: 8px; }
.btn:disabled { opacity: .4; cursor: default; }
/* ── Empty state ── */
.empty { text-align: center; padding: 60px 20px; color: var(--text2); }
.empty .icon { font-size: 48px; margin-bottom: 12px; }
.empty p { font-size: 14px; line-height: 1.6; }
/* ── Toast ── */
#toasts { position: fixed; top: 16px; right: 16px; left: 16px; z-index: 999;
display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
.toast { background: var(--card); border: 1px solid var(--border);
border-radius: 10px; padding: 10px 14px; font-size: 13px; font-weight: 500;
box-shadow: var(--shadow); animation: slideIn .2s ease; pointer-events: auto; }
.toast-success { border-left: 3px solid var(--on); }
.toast-error { border-left: 3px solid var(--danger); }
.toast-info { border-left: 3px solid var(--accent); }
@keyframes slideIn { from { transform: translateY(-8px); opacity: 0; } to { opacity: 1; } }
/* ── Status page ── */
#status-indicator { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--off); }
.dot.running { background: var(--on); box-shadow: 0 0 6px var(--on); }
#log { background: var(--card); border-radius: var(--radius); padding: 12px;
font-family: monospace; font-size: 12px; color: var(--text2);
max-height: 55vh; overflow-y: auto; }
.log-entry { padding: 4px 0; border-bottom: 1px solid var(--border); line-height: 1.5; }
.log-entry:last-child { border-bottom: none; }
.log-ok { color: var(--on); }
.log-fail { color: var(--danger); }
/* ── Header banner ── */
.page-header { font-size: 13px; color: var(--text2); margin-bottom: 12px;
display: flex; align-items: center; gap: 6px; }
.page-header .ws-dot { width: 7px; height: 7px; border-radius: 50%;
background: var(--off); display: inline-block; transition: background .3s; }
.page-header .ws-dot.connected { background: var(--on); }
/* ── Rule card ── */
.rule-card { flex-wrap: wrap; align-items: flex-start; }
.rule-card .card-body { min-width: 180px; }
.rule-devices { margin-top: 6px; display: flex; gap: 4px; flex-wrap: wrap; }
.dev-chip { font-size: 11px; background: var(--bg); border: 1px solid var(--border);
border-radius: 5px; padding: 2px 7px; color: var(--text2); }
.rule-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
.icon-btn { background: none; border: 1px solid var(--border); border-radius: 8px;
padding: 5px 8px; font-size: 15px; cursor: pointer; color: var(--text2);
transition: all .15s; line-height: 1; }
.icon-btn:hover { border-color: var(--accent); color: var(--accent); }
.icon-btn.del:hover { border-color: var(--danger); color: var(--danger); }
/* ── Brightness Slider ── */
.brightness-control {
background: var(--bg);
border-radius: 8px;
padding: 8px;
border: 1px solid var(--border);
}
.brightness-slider {
flex: 1;
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, var(--off) 0%, var(--on) 100%);
outline: none;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.brightness-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--card);
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
transition: all .15s;
}
.brightness-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0,0,0,0.3);
}
.brightness-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
border: 2px solid var(--card);
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
transition: all .15s;
}
.brightness-slider::-moz-range-thumb:hover {
transform: scale(1.1);
box-shadow: 0 3px 6px rgba(0,0,0,0.3);
}
.brightness-slider:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.brightness-value {
font-weight: 600;
color: var(--accent);
}
/* ── Modal ── */
.modal-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55);
z-index: 200; align-items: flex-end; justify-content: center; }
.modal-backdrop.open { display: flex; }
.modal { background: var(--card); border-radius: var(--radius) var(--radius) 0 0;
width: 100%; max-width: 540px; max-height: 92vh; overflow-y: auto;
padding: 20px 18px 32px; animation: slideUp .22s ease; }
@keyframes slideUp { from { transform: translateY(40px); opacity: 0; } to { opacity: 1; } }
.modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
.modal-title { font-size: 17px; font-weight: 700; }
.modal-close { background: none; border: none; font-size: 22px; color: var(--text2);
cursor: pointer; padding: 2px 6px; line-height: 1; }
.form-group { margin-bottom: 14px; }
.form-label { display: block; font-size: 12px; font-weight: 600; color: var(--text2);
text-transform: uppercase; letter-spacing: .05em; margin-bottom: 6px; }
.form-input, .form-select { width: 100%; background: var(--bg); border: 1px solid var(--border);
color: var(--text); padding: 10px 12px; border-radius: 10px; font-size: 14px;
appearance: none; -webkit-appearance: none; }
.form-input:focus, .form-select:focus { outline: 2px solid var(--accent); border-color: transparent; }
.form-row { display: flex; gap: 10px; }
.form-row .form-group { flex: 1; }
/* Day picker */
.day-picker { display: flex; gap: 6px; flex-wrap: wrap; }
.day-pill { padding: 6px 11px; border-radius: 20px; border: 1.5px solid var(--border);
background: var(--bg); color: var(--text2); font-size: 13px; font-weight: 600;
cursor: pointer; transition: all .15s; user-select: none; }
.day-pill.on { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Device checkboxes */
.dev-check-list { display: flex; flex-direction: column; gap: 6px; max-height: 160px;
overflow-y: auto; border: 1px solid var(--border); border-radius: 10px;
padding: 8px 10px; background: var(--bg); }
.dev-check-item { display: flex; align-items: center; gap: 8px; font-size: 14px;
cursor: pointer; padding: 2px 0; }
.dev-check-item input { width: 16px; height: 16px; cursor: pointer; accent-color: var(--accent); }
/* Modal footer */
.modal-footer { display: flex; gap: 10px; margin-top: 20px; }
.modal-footer .btn { flex: 1; padding: 13px; font-size: 15px; }
/* Delete confirm */
.confirm-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.55);
z-index: 300; align-items: center; justify-content: center; padding: 20px; }
.confirm-backdrop.open { display: flex; }
.confirm-box { background: var(--card); border-radius: var(--radius); padding: 22px 20px;
width: 100%; max-width: 340px; text-align: center; }
.confirm-title { font-size: 16px; font-weight: 700; margin-bottom: 8px; }
.confirm-msg { font-size: 13px; color: var(--text2); margin-bottom: 20px; line-height: 1.5; }
.confirm-btns { display: flex; gap: 10px; }
.confirm-btns .btn { flex: 1; padding: 11px; }
</style>
</head>
<body>
<div id="toasts"></div>
<!-- Pages -->
<div id="main">
<div id="page-devices" class="page active">
<div class="toolbar">
<h2>Devices</h2>
<div style="flex:1"></div>
<button class="btn btn-ghost btn-sm" onclick="refreshDeviceStates()">⟳ Refresh</button>
<button class="btn btn-ghost btn-sm" onclick="openAddDeviceModal()"> Add IP</button>
<button class="btn btn-primary" id="btn-discover" onclick="discoverDevices()">⟳ Scan</button>
</div>
<div id="page-header-devices" class="page-header">
<span class="ws-dot" id="ws-dot-d"></span>
<span id="ws-label-d">Connecting…</span>
</div>
<div id="devices-list"><div class="empty"><div class="icon">📡</div><p>Tap <strong>Scan</strong> to discover devices.</p></div></div>
</div>
<div id="page-rules" class="page">
<div class="toolbar">
<h2>DWM Rules</h2>
<div style="flex:1"></div>
<button class="btn btn-ghost btn-sm" onclick="loadRules()"></button>
<button class="btn btn-primary btn-sm" onclick="openRuleModal(null)"> Add</button>
</div>
<div id="rules-list"><div class="empty"><div class="icon">📅</div><p>Loading rules…</p></div></div>
</div>
<div id="page-wemo-rules" class="page">
<div class="toolbar">
<h2>Wemo Rules</h2>
<button class="btn btn-ghost" onclick="loadWemoRules()"></button>
</div>
<div id="wemo-rules-list"><div class="empty"><div class="icon"></div><p>Select a device above to view its rules.</p></div></div>
</div>
<div id="page-more" class="page">
<h2 style="margin-bottom:16px">More</h2>
<div style="background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:18px;margin-bottom:12px;display:flex;align-items:center;gap:14px;">
<img src="/icon.png" alt="DWM" style="width:54px;height:54px;border-radius:10px;flex-shrink:0;" onerror="this.style.display='none'">
<div>
<div style="font-size:16px;font-weight:700;">Dibby Wemo Manager</div>
<div style="font-size:12px;color:var(--text2);margin-top:2px;">Version 2.0 &nbsp;·&nbsp; Developed by SRS IT</div>
<div style="font-size:12px;color:var(--text2);margin-top:2px;">Local control only. No cloud required.</div>
<div style="font-size:12px;color:var(--text2);margin-top:2px;">Dedicated to Dibby ❤️</div>
</div>
</div>
<a href="/help" target="_blank" style="text-decoration:none;">
<div style="background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:16px 18px;margin-bottom:10px;display:flex;align-items:center;gap:12px;cursor:pointer;">
<span style="font-size:26px;">📖</span>
<div style="flex:1">
<div style="font-weight:600;font-size:15px;">Help &amp; Documentation</div>
<div style="font-size:12px;color:var(--text2);margin-top:2px;">Guides, troubleshooting, REST API reference</div>
</div>
<span style="color:var(--text2);font-size:18px;"></span>
</div>
</a>
<div style="background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:16px 18px;margin-bottom:10px;">
<div style="font-weight:600;font-size:14px;margin-bottom:10px;">🌐 Web Server</div>
<table style="font-size:12px;color:var(--text2);width:100%;border-collapse:collapse;line-height:2;">
<tr><td style="width:80px;color:var(--text)">URL</td><td id="srv-url" style="font-family:monospace;color:var(--accent)"></td></tr>
<tr><td style="color:var(--text)">Port</td><td id="srv-port" style="font-family:monospace;"></td></tr>
<tr><td style="color:var(--text)">Socket</td><td id="srv-ws"></td></tr>
</table>
</div>
<div style="background:var(--card);border-radius:var(--radius);box-shadow:var(--shadow);padding:16px 18px;margin-bottom:10px;">
<div style="font-weight:600;font-size:14px;margin-bottom:10px;">📡 REST API Quick Reference</div>
<table style="font-size:12px;color:var(--text2);width:100%;border-collapse:collapse;line-height:1.8;">
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/devices</td><td>List devices</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/devices/discover</td><td>Scan network</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/devices/add</td><td>Add device manually <code style="background:var(--bg);padding:1px 4px;border-radius:3px;">{"host":"192.168.1.100","port":49153}</code></td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/devices/:ip/:port/info</td><td>Get device information</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/devices/:ip/:port/state</td><td>Toggle power <code style="background:var(--bg);padding:1px 4px;border-radius:3px;">{"on":true}</code></td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/devices/:ip/:port/brightness</td><td>Get brightness (dimmer only)</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/devices/:ip/:port/brightness</td><td>Set brightness <code style="background:var(--bg);padding:1px 4px;border-radius:3px;">{"brightness":50}</code></td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/devices/:ip/:port/rules</td><td>Get Wemo device rules</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">PUT /api/devices/:ip/:port/rules/:id</td><td>Update Wemo rule</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/dwm-rules</td><td>List DWM rules</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">POST /api/dwm-rules</td><td>Create rule</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">PUT /api/dwm-rules/:id</td><td>Update rule</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">DELETE /api/dwm-rules/:id</td><td>Delete rule</td></tr>
<tr><td style="padding-right:8px;white-space:nowrap;color:var(--accent);font-family:monospace;">GET /api/scheduler/status</td><td>Scheduler state</td></tr>
</table>
</div>
<div style="text-align:center;font-size:12px;color:var(--text2);padding:12px 0 4px;">
Web remote served by the DWM desktop app on your PC.
</div>
</div>
<div id="page-status" class="page">
<h2>Scheduler</h2>
<div id="status-indicator">
<span class="dot" id="sched-dot"></span>
<span id="sched-label" style="font-size:14px;font-weight:600;">Unknown</span>
</div>
<div style="font-size:13px;color:var(--text2);margin-bottom:10px;">Live event log (via WebSocket)</div>
<div id="log"><div style="color:var(--text2);font-size:12px;">Waiting for events…</div></div>
</div>
</div>
<!-- Bottom nav -->
<nav id="nav">
<button class="nav-btn active" onclick="showPage('devices',this)">
<span class="icon">💡</span>Devices
</button>
<button class="nav-btn" onclick="showPage('rules',this)">
<span class="icon">📅</span>DWM Rules
</button>
<button class="nav-btn" onclick="showPage('wemo-rules',this)">
<span class="icon"></span>Wemo Rules
</button>
<button class="nav-btn" onclick="showPage('status',this)">
<span class="icon">📊</span>Status
</button>
<button class="nav-btn" onclick="showPage('more',this)">
<span class="icon"></span>More
</button>
</nav>
<!-- ── Rule Modal ── -->
<div class="modal-backdrop" id="modal-rule" onclick="closeRuleModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<span class="modal-title" id="modal-rule-title">Add DWM Rule</span>
<button class="modal-close" onclick="closeRuleModal()"></button>
</div>
<div class="form-group">
<label class="form-label">Rule Name</label>
<input class="form-input" id="f-name" type="text" placeholder="e.g. Evening Lights" />
</div>
<div class="form-group">
<label class="form-label">Type</label>
<select class="form-select" id="f-type" onchange="onTypeChange()">
<option value="Schedule">Schedule — fixed on/off times</option>
<option value="Away">Away Mode — random intervals</option>
<option value="Countdown">Countdown — auto-off timer</option>
<option value="AlwaysOn">Always On — keep device on at all times</option>
<option value="Trigger">Trigger — if device A changes, act on device B</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Target Devices</label>
<div class="dev-check-list" id="f-devices">
<div style="font-size:13px;color:var(--text2)">Loading devices…</div>
</div>
</div>
<div class="form-group" id="fg-days">
<label class="form-label">Days</label>
<div class="day-picker" id="f-days">
<span class="day-pill" data-d="1">Mon</span>
<span class="day-pill" data-d="2">Tue</span>
<span class="day-pill" data-d="3">Wed</span>
<span class="day-pill" data-d="4">Thu</span>
<span class="day-pill" data-d="5">Fri</span>
<span class="day-pill" data-d="6">Sat</span>
<span class="day-pill" data-d="7">Sun</span>
</div>
</div>
<!-- Schedule / Away fields -->
<div id="fg-times">
<div class="form-row">
<div class="form-group">
<label class="form-label">Start Time</label>
<input class="form-input" id="f-start" type="time" />
</div>
<div class="form-group" id="fg-end" style="display:none">
<label class="form-label">End Time</label>
<input class="form-input" id="f-end" type="time" />
</div>
</div>
<div class="form-row" id="fg-actions">
<div class="form-group">
<label class="form-label">Start Action</label>
<select class="form-select" id="f-start-action">
<option value="1">Turn ON</option>
<option value="0">Turn OFF</option>
</select>
</div>
<div class="form-group">
<label class="form-label">End Action</label>
<select class="form-select" id="f-end-action">
<option value="-1">None</option>
<option value="0">Turn OFF</option>
<option value="1">Turn ON</option>
</select>
</div>
</div>
</div>
<!-- Countdown fields -->
<div id="fg-countdown" style="display:none">
<div class="form-group">
<label class="form-label">Countdown Duration (minutes)</label>
<input class="form-input" id="f-countdown" type="number" min="1" max="1440" placeholder="60" />
</div>
</div>
<!-- Trigger fields -->
<div id="fg-trigger" style="display:none">
<div class="form-group">
<label class="form-label">Trigger Device (source)</label>
<select class="form-select" id="f-trigger-device">
<option value="">— select device —</option>
</select>
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label class="form-label">When it…</label>
<select class="form-select" id="f-trigger-event">
<option value="on">Turns ON</option>
<option value="off">Turns OFF</option>
<option value="any">Changes (either way)</option>
</select>
</div>
<div class="form-group" style="flex:1">
<label class="form-label">Then…</label>
<select class="form-select" id="f-trigger-action">
<option value="on">Turn ON</option>
<option value="off">Turn OFF</option>
<option value="mirror">Mirror (same state)</option>
<option value="opposite">Opposite state</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Action Devices (targets)</label>
<div class="dev-check-list" id="f-action-devices">
<div style="font-size:13px;color:var(--text2)">Loading devices…</div>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label">Enabled</label>
<label class="toggle" style="width:52px">
<input type="checkbox" id="f-enabled" checked>
<span class="track"></span>
<span class="thumb"></span>
</label>
</div>
<div id="modal-rule-error" style="display:none;color:var(--danger);font-size:13px;margin-bottom:10px;"></div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeRuleModal()">Cancel</button>
<button class="btn btn-primary" id="btn-save-rule" onclick="saveRule()">Save Rule</button>
</div>
</div>
</div>
<!-- ── Add Device Modal ── -->
<div class="modal-backdrop" id="modal-add-device" onclick="closeAddDeviceModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<span class="modal-title">Add Wemo Device Manually</span>
<button class="modal-close" onclick="closeAddDeviceModal()">×</button>
</div>
<div class="form-group">
<label class="form-label">IP Address</label>
<input class="form-input" id="add-device-host" type="text" placeholder="192.168.1.100" />
</div>
<div class="form-group">
<label class="form-label">Port (optional)</label>
<input class="form-input" id="add-device-port" type="number" placeholder="49153" min="1" max="65535" />
<div style="font-size:12px;color:var(--text2);margin-top:4px;">Default: 49153 (try 49152-49156 if needed)</div>
</div>
<div id="add-device-error" style="display:none;color:var(--danger);font-size:13px;margin-bottom:10px;"></div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeAddDeviceModal()">Cancel</button>
<button class="btn btn-primary" id="btn-add-device" onclick="addDeviceManually()">Add Device</button>
</div>
</div>
</div>
<!-- ── Device Info Modal ── -->
<div class="modal-backdrop" id="modal-device-info" onclick="closeDeviceInfoModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<span class="modal-title">Device Information</span>
<button class="modal-close" onclick="closeDeviceInfoModal()">×</button>
</div>
<div id="device-info-content">
<div style="text-align:center;color:var(--text2);padding:20px;">Loading device information…</div>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="closeDeviceInfoModal()">Close</button>
</div>
</div>
</div>
<!-- ── Delete Confirm ── -->
<div class="confirm-backdrop" id="confirm-delete">
<div class="confirm-box">
<div class="confirm-title">Delete Rule?</div>
<div class="confirm-msg" id="confirm-delete-msg">This cannot be undone.</div>
<div class="confirm-btns">
<button class="btn btn-ghost" onclick="closeConfirm()">Cancel</button>
<button class="btn btn-danger" id="btn-confirm-del">Delete</button>
</div>
</div>
</div>
<script>
// ── State ──────────────────────────────────────────────────────────────────
const BASE = '';
let ws = null;
let devices = [];
let rules = [];
let wemoRules = [];
let wemoDevIdx = 0;
let _editingRuleId = null; // null = create, string = update
// ── Routing ────────────────────────────────────────────────────────────────
function showPage(name, btn) {
document.querySelectorAll('.page').forEach((p) => p.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach((b) => b.classList.remove('active'));
document.getElementById('page-' + name).classList.add('active');
btn.classList.add('active');
if (name === 'rules') loadRules();
if (name === 'wemo-rules') loadWemoRules();
if (name === 'status') updateSchedStatus();
if (name === 'more') updateServerCard();
}
// ── Toast ──────────────────────────────────────────────────────────────────
function toast(msg, type = 'info') {
const el = document.createElement('div');
el.className = `toast toast-${type}`;
el.textContent = msg;
document.getElementById('toasts').appendChild(el);
setTimeout(() => el.remove(), 3200);
}
// ── API ────────────────────────────────────────────────────────────────────
async function api(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(BASE + path, opts);
if (!res.ok) {
const txt = await res.text().catch(() => res.statusText);
throw new Error(txt || `HTTP ${res.status}`);
}
const ct = res.headers.get('content-type') || '';
return ct.includes('json') ? res.json() : res.text();
}
// ── WebSocket ──────────────────────────────────────────────────────────────
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}`;
console.log(`[DWM] Connecting WebSocket to: ${wsUrl}`);
try {
ws = new WebSocket(wsUrl);
} catch (err) {
console.error('[DWM] WebSocket creation failed:', err);
return;
}
ws.onopen = () => {
console.log('[DWM] WebSocket connected');
document.querySelectorAll('.ws-dot').forEach((d) => d.classList.add('connected'));
document.querySelectorAll('[id^="ws-label"]').forEach((l) => l.textContent = 'Connected');
};
ws.onclose = () => {
console.log('[DWM] WebSocket disconnected, reconnecting...');
document.querySelectorAll('.ws-dot').forEach((d) => d.classList.remove('connected'));
document.querySelectorAll('[id^="ws-label"]').forEach((l) => l.textContent = 'Reconnecting…');
setTimeout(connectWS, 4000);
};
ws.onerror = (error) => {
console.error('[DWM] WebSocket error:', error);
document.querySelectorAll('.ws-dot').forEach((d) => d.classList.remove('connected'));
document.querySelectorAll('[id^="ws-label"]').forEach((l) => l.textContent = 'Connection Error');
// Show user-friendly error message
if (error.type === 'error' && error.message.includes('SSL')) {
toast('WebSocket SSL Error: Try accessing via domain name or check certificate', 'error');
} else if (error.message) {
toast(`WebSocket Error: ${error.message}`, 'error');
} else {
toast('WebSocket connection failed - try refreshing the page', 'error');
}
};
ws.onmessage = (e) => {
try {
const { type, data } = JSON.parse(e.data);
if (type === 'scheduler-fired') appendLog(data);
if (type === 'scheduler-status') applySchedStatus(data);
if (type === 'scheduler-health') appendLog({ success: data.online, msg: `[health] ${data.msg}` });
} catch (err) {
console.error('[DWM] WebSocket message parse error:', err);
}
};
}
// ── More / Server card ─────────────────────────────────────────────────────
async function updateServerCard() {
try {
const info = await api('GET', '/api/server-info');
if (info?.port) {
document.getElementById('srv-port').textContent = info.port;
document.getElementById('srv-url').textContent = `http://${location.hostname}:${info.port}`;
document.getElementById('srv-ws').textContent = `ws://${location.hostname}:${info.port}`;
}
} catch {}
}
// ── Devices ────────────────────────────────────────────────────────────────
async function loadDevices() {
try {
devices = await api('GET', '/api/devices');
renderDevices();
} catch (e) {
toast('Could not load devices: ' + e.message, 'error');
}
}
async function discoverDevices() {
const btn = document.getElementById('btn-discover');
btn.disabled = true; btn.textContent = '⟳ Scanning…';
const list = document.getElementById('devices-list');
list.innerHTML = `<div class="empty"><div class="icon">📡</div><p>Scanning network…</p></div>`;
try {
devices = await api('POST', '/api/devices/discover');
renderDevices();
toast(`Found ${devices.length} device(s)`, 'success');
} catch (e) {
toast('Scan failed: ' + e.message, 'error');
} finally {
btn.disabled = false; btn.textContent = '⟳ Scan';
}
}
function renderDevices() {
const el = document.getElementById('devices-list');
if (!devices.length) {
el.innerHTML = `<div class="empty"><div class="icon">📡</div><p>No devices found.<br>Tap <strong>Scan</strong> to search.</p></div>`;
return;
}
// Check if there are WebSocket connection issues
const wsConnected = ws && ws.readyState === WebSocket.OPEN;
if (!wsConnected) {
el.innerHTML = `<div class="empty"><div class="icon">⚠️</div><p>WebSocket connection issue detected.<br>Device states may not be accurate.<br>Try refreshing the page.</p></div>`;
return;
}
el.innerHTML = devices.map((d, i) => {
const name = d.friendlyName || d.name || d.host;
const isDimmer = d.isDimmer || false;
const icon = isDimmer ? '🔆' : '💡';
return `
<div class="card" id="dev-${i}">
<div style="font-size:24px;flex-shrink:0">${icon}</div>
<div class="card-body">
<div class="card-name">${esc(name)}</div>
<div class="card-meta">${esc(d.host)}:${d.port}
${d.productModel ? ' · ' + esc(d.productModel) : ''}
${isDimmer ? ' · Dimmer' : ''}
</div>
${isDimmer ? `
<div class="brightness-control" style="margin-top:8px;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:12px;color:var(--text2);">🔅</span>
<input type="range" class="brightness-slider" id="bright-${i}"
min="0" max="100" value="50"
oninput="updateBrightnessPreview(${i}, this.value)"
onchange="setBrightness(${i}, this.value)">
<span class="brightness-value" id="bright-val-${i}" style="font-size:12px;color:var(--text2);min-width:30px;">50%</span>
</div>
</div>
` : ''}
</div>
<div class="rule-actions">
<label class="toggle" id="dtog-${i}" onclick="toggleDevice(${i},event)">
<input type="checkbox" id="dchk-${i}">
<span class="track"></span>
<span class="thumb"></span>
</label>
<button class="icon-btn" onclick="openDeviceInfoModal(${i})" title="Device Info">️</button>
</div>
</div>`;
}).join('');
// fetch current state for each device
devices.forEach(async (d, i) => {
// Skip fetching state if WebSocket is not connected
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.log(`[DWM] Skipping device state fetch due to WebSocket connection issues`);
return;
}
try {
// For dimmer devices, fetch both brightness and state
if (d.isDimmer) {
// Fetch both brightness and state in parallel to avoid race conditions
const [brightnessData, stateData] = await Promise.allSettled([
api('GET', `/api/devices/${d.host}/${d.port}/brightness`),
api('GET', `/api/devices/${d.host}/${d.port}/state`)
]);
// Handle brightness data
if (brightnessData.status === 'fulfilled' && brightnessData.value.brightness !== undefined) {
const slider = document.getElementById('bright-'+i);
const value = document.getElementById('bright-val-'+i);
if (slider && value) {
slider.value = brightnessData.value.brightness;
value.textContent = Math.round(brightnessData.value.brightness) + '%';
}
}
// Handle state data (this should set the toggle correctly)
if (stateData.status === 'fulfilled') {
const checkbox = document.getElementById('dchk-'+i);
if (checkbox) {
// stateData.value is an object {on: false}, need to access the 'on' property
const actualState = !!stateData.value.on;
checkbox.checked = actualState;
console.log(`[DWM] Device ${d.host}:${d.port} - BinaryState: ${actualState ? 'ON' : 'OFF'}, Last brightness: ${brightnessData.value?.brightness || 'N/A'}%`);
console.log(`[DWM] Setting checkbox.checked to: ${actualState}`);
console.log(`[DWM] Raw stateData.value:`, stateData.value);
// Force a DOM update to ensure the change takes effect
checkbox.dispatchEvent(new Event('change'));
}
}
} else {
// For non-dimmer devices, just fetch binary state
const stateResult = await api('GET', `/api/devices/${d.host}/${d.port}/state`);
const checkbox = document.getElementById('dchk-'+i);
if (checkbox) {
const shouldBeChecked = !!stateResult;
checkbox.checked = shouldBeChecked;
console.log(`[DWM] Non-dimmer device ${d.host}:${d.port} - State: ${stateResult ? 'ON' : 'OFF'}, Setting checkbox to: ${shouldBeChecked}`);
// Force a DOM update to ensure the change takes effect
checkbox.dispatchEvent(new Event('change'));
}
}
} catch (err) {
console.error(`[DWM] Error fetching state for device ${d.host}:${d.port}:`, err);
}
});
}
// ── Refresh Device States ───────────────────────────────────────────────────
async function refreshDeviceStates() {
if (!devices.length) return;
// Show loading state
toast('Refreshing device states...', 'info');
// Re-render devices to refresh their states
renderDevices();
toast('Device states refreshed', 'success');
}
async function toggleDevice(i, e) {
e.preventDefault();
const dev = devices[i];
const chk = document.getElementById('dchk-' + i);
const tog = document.getElementById('dtog-' + i);
if (!dev || !chk || !tog) return;
const newState = !chk.checked;
tog.classList.add('busy');
try {
await api('POST', `/api/devices/${dev.host}/${dev.port}/state`, { on: newState });
chk.checked = newState;
} catch (err) {
toast(`Failed: ${err.message}`, 'error');
} finally {
tog.classList.remove('busy');
}
}
// ── Brightness control ───────────────────────────────────────────────────────
function updateBrightnessPreview(i, value) {
const valueEl = document.getElementById('bright-val-' + i);
if (valueEl) {
valueEl.textContent = Math.round(value) + '%';
}
}
async function setBrightness(i, value) {
const dev = devices[i];
const slider = document.getElementById('bright-' + i);
if (!dev || !slider) return;
slider.disabled = true;
try {
await api('POST', `/api/devices/${dev.host}/${dev.port}/brightness`, { brightness: Number(value) });
// Update power toggle state based on brightness
const chk = document.getElementById('dchk-' + i);
if (chk) {
chk.checked = Number(value) > 0;
}
toast(`Brightness set to ${Math.round(value)}%`, 'success');
} catch (err) {
toast(`Failed to set brightness: ${err.message}`, 'error');
} finally {
slider.disabled = false;
}
}
// ── Rules list ─────────────────────────────────────────────────────────────
async function loadRules() {
try {
rules = await api('GET', '/api/dwm-rules');
renderRules();
} catch (e) {
toast('Could not load rules: ' + e.message, 'error');
}
}
function ruleSummary(r) {
const DAY = {1:'Mon',2:'Tue',3:'Wed',4:'Thu',5:'Fri',6:'Sat',7:'Sun'};
const days = (r.days || []).map((d) => DAY[d]).join(' ') || '—';
const secs = (s) => {
if (s === -2) return '🌅';
if (s === -3) return '🌇';
if (!s && s !== 0) return '—';
return `${String(Math.floor(Math.abs(s)/3600)%24).padStart(2,'0')}:${String(Math.floor((Math.abs(s)%3600)/60)).padStart(2,'0')}`;
};
if (r.type === 'AlwaysOn') return 'Enforced ON every 10 s';
if (r.type === 'Trigger') {
const evtLabel = {on:'turns ON', off:'turns OFF', any:'changes'}[r.triggerEvent] ?? r.triggerEvent;
const actLabel = {on:'→ ON', off:'→ OFF', mirror:'→ mirror', opposite:'→ opposite'}[r.action] ?? r.action;
const srcName = r.triggerDevice?.name ?? r.triggerDevice?.host ?? '?';
return `If ${esc(srcName)} ${evtLabel} ${actLabel}`;
}
if (r.type === 'Countdown') {
const m = r.countdownTime ? Math.round(r.countdownTime / 60) : 0;
return m >= 60 ? `${Math.floor(m/60)}h${m%60||''}m auto-off` : `${m}m auto-off`;
}
if (r.type === 'Away') return `${days} · ${secs(r.startTime)}${secs(r.endTime)}`;
const end = r.endTime > 0 || r.endTime === -2 || r.endTime === -3;
return end ? `${days} · ${secs(r.startTime)}${secs(r.endTime)}` : `${days} · ${secs(r.startTime)}`;
}
function renderRules() {
const el = document.getElementById('rules-list');
if (!rules.length) {
el.innerHTML = `<div class="empty"><div class="icon">📅</div><p>No DWM rules yet.<br>Tap <strong> Add</strong> to create one.</p></div>`;
return;
}
el.innerHTML = rules.map((r, i) => {
const icon = {Schedule:'📅',Away:'🏠',Countdown:'⏱',AlwaysOn:'🔒',Trigger:'⚡'}[r.type] || '📅';
const devChips = (r.targetDevices || []).map((td) =>
`<span class="dev-chip">📍 ${esc(td.name || td.host)}</span>`).join('');
return `
<div class="card rule-card" id="rule-${i}">
<div style="font-size:22px;flex-shrink:0">${icon}</div>
<div class="card-body">
<div class="card-name">${esc(r.name)}</div>
<div class="card-meta">
<span class="badge badge-${r.type === 'Away' ? 'away' : 'off'}" style="margin-right:5px">${r.type}</span>
${esc(ruleSummary(r))}
${!r.enabled ? '<span class="badge badge-disabled" style="margin-left:5px">Disabled</span>' : ''}
</div>
${devChips ? `<div class="rule-devices">${devChips}</div>` : ''}
</div>
<div class="rule-actions">
<label class="toggle" id="rtog-${i}" onclick="toggleRule(${i},event)">
<input type="checkbox" id="rchk-${i}" ${r.enabled ? 'checked' : ''}>
<span class="track"></span>
<span class="thumb"></span>
</label>
<button class="icon-btn" onclick="openRuleModal(${i})" title="Edit">✏️</button>
<button class="icon-btn del" onclick="confirmDeleteRule(${i})" title="Delete">🗑</button>
</div>
</div>`;
}).join('');
}
async function toggleRule(i, e) {
e.preventDefault();
const rule = rules[i];
const chk = document.getElementById('rchk-' + i);
const tog = document.getElementById('rtog-' + i);
if (!rule || !chk || !tog) return;
const enabled = !chk.checked;
tog.classList.add('busy');
try {
await api('PUT', `/api/dwm-rules/${rule.id}`, { ...rule, enabled });
rules[i] = { ...rule, enabled };
chk.checked = enabled;
toast(`"${rule.name}" ${enabled ? 'enabled' : 'disabled'}`, 'info');
renderRules();
} catch (err) {
toast(`Failed: ${err.message}`, 'error');
tog.classList.remove('busy');
}
}
// ── Rule Modal ─────────────────────────────────────────────────────────────
function secsToHHMM(s) {
if (!s && s !== 0) return '';
const h = Math.floor(Math.abs(s) / 3600) % 24;
const m = Math.floor((Math.abs(s) % 3600) / 60);
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;
}
function hhmmToSecs(str) {
if (!str) return -1;
const [h, m] = str.split(':').map(Number);
if (isNaN(h) || isNaN(m)) return -1;
return h * 3600 + m * 60;
}
function onTypeChange() {
const t = document.getElementById('f-type').value;
const isTrigger = t === 'Trigger';
const isAlwaysOn = t === 'AlwaysOn';
const isCountdown = t === 'Countdown';
const isSchedule = t === 'Schedule';
const noTime = isCountdown || isAlwaysOn || isTrigger;
document.getElementById('fg-countdown').style.display = isCountdown ? '' : 'none';
document.getElementById('fg-trigger').style.display = isTrigger ? '' : 'none';
document.getElementById('fg-times').style.display = noTime ? 'none' : '';
document.getElementById('fg-end').style.display = !isSchedule ? '' : '';
document.getElementById('fg-actions').style.display = isSchedule ? '' : 'none';
document.getElementById('fg-days').style.display = noTime ? 'none' : '';
// For Trigger rules the standard "Target Devices" section is replaced by fg-trigger
document.getElementById('f-devices').closest('.form-group').style.display =
isTrigger ? 'none' : '';
// Populate trigger device dropdown if switching to Trigger
if (isTrigger) populateTriggerDeviceSelect();
}
function populateDeviceChecks(selectedUdns) {
const container = document.getElementById('f-devices');
const list = devices.filter((d) => d.host);
if (!list.length) {
container.innerHTML = `<div style="font-size:13px;color:var(--text2)">No devices found — go to Devices tab and scan first.</div>`;
return;
}
container.innerHTML = list.map((d) => {
const key = d.udn || `${d.host}:${d.port}`;
const name = d.friendlyName || d.name || d.host;
const chk = selectedUdns.includes(key) ? 'checked' : '';
return `<label class="dev-check-item">
<input type="checkbox" value="${esc(key)}" data-host="${esc(d.host)}" data-port="${d.port}" data-name="${esc(name)}" ${chk}>
${esc(name)} <span style="font-size:11px;color:var(--text2);margin-left:4px">${esc(d.host)}</span>
</label>`;
}).join('');
}
function populateTriggerDeviceSelect(selectedKey) {
const sel = document.getElementById('f-trigger-device');
const list = devices.filter((d) => d.host);
sel.innerHTML = '<option value="">— select device —</option>' +
list.map((d) => {
const key = d.udn || `${d.host}:${d.port}`;
const name = d.friendlyName || d.name || d.host;
const sel = key === selectedKey ? 'selected' : '';
return `<option value="${esc(key)}" data-host="${esc(d.host)}" data-port="${d.port}" data-name="${esc(name)}" ${sel}>${esc(name)}</option>`;
}).join('');
}
function populateActionDeviceChecks(selectedKeys) {
const container = document.getElementById('f-action-devices');
const list = devices.filter((d) => d.host);
if (!list.length) {
container.innerHTML = `<div style="font-size:13px;color:var(--text2)">No devices — scan first.</div>`;
return;
}
container.innerHTML = list.map((d) => {
const key = d.udn || `${d.host}:${d.port}`;
const name = d.friendlyName || d.name || d.host;
const chk = selectedKeys.includes(key) ? 'checked' : '';
return `<label class="dev-check-item">
<input type="checkbox" value="${esc(key)}" data-host="${esc(d.host)}" data-port="${d.port}" data-name="${esc(name)}" ${chk}>
${esc(name)} <span style="font-size:11px;color:var(--text2);margin-left:4px">${esc(d.host)}</span>
</label>`;
}).join('');
}
function openRuleModal(idx) {
_editingRuleId = null;
const rule = idx !== null ? rules[idx] : null;
// Title
document.getElementById('modal-rule-title').textContent = rule ? 'Edit Rule' : 'Add DWM Rule';
document.getElementById('modal-rule-error').style.display = 'none';
// Prefill fields
document.getElementById('f-name').value = rule?.name || '';
document.getElementById('f-type').value = rule?.type || 'Schedule';
document.getElementById('f-enabled').checked = rule ? !!rule.enabled : true;
// Days
const days = rule?.days || [];
document.querySelectorAll('.day-pill').forEach((p) => {
p.classList.toggle('on', days.includes(Number(p.dataset.d)));
p.onclick = () => p.classList.toggle('on');
});
// Times
document.getElementById('f-start').value = secsToHHMM(rule?.startTime);
document.getElementById('f-end').value = secsToHHMM(rule?.endTime > 0 ? rule.endTime : null);
document.getElementById('f-start-action').value = String(rule?.startAction ?? 1);
document.getElementById('f-end-action').value = String(rule?.endAction ?? -1);
// Countdown
if (rule?.type === 'Countdown' && rule.countdownTime) {
document.getElementById('f-countdown').value = Math.round(rule.countdownTime / 60);
} else {
document.getElementById('f-countdown').value = '';
}
// Standard target devices (non-Trigger types)
const selUdns = (rule?.targetDevices || []).map((td) => td.udn || `${td.host}:${td.port}`);
populateDeviceChecks(selUdns);
// Trigger-specific fields
if (rule?.type === 'Trigger') {
const srcKey = rule.triggerDevice
? (rule.triggerDevice.udn || `${rule.triggerDevice.host}:${rule.triggerDevice.port}`)
: '';
populateTriggerDeviceSelect(srcKey);
document.getElementById('f-trigger-event').value = rule.triggerEvent ?? 'on';
document.getElementById('f-trigger-action').value = rule.action ?? 'on';
const actionKeys = (rule.actionDevices || []).map((d) => d.udn || `${d.host}:${d.port}`);
populateActionDeviceChecks(actionKeys);
} else {
populateTriggerDeviceSelect('');
populateActionDeviceChecks([]);
}
onTypeChange();
if (rule) _editingRuleId = rule.id;
document.getElementById('modal-rule').classList.add('open');
setTimeout(() => document.getElementById('f-name').focus(), 100);
}
function closeRuleModal(e) {
if (e && e.target !== document.getElementById('modal-rule')) return;
document.getElementById('modal-rule').classList.remove('open');
}
// ── Add Device Modal ───────────────────────────────────────────────────────
function openAddDeviceModal() {
document.getElementById('modal-add-device').classList.add('open');
document.getElementById('add-device-error').style.display = 'none';
document.getElementById('add-device-host').value = '';
document.getElementById('add-device-port').value = '';
setTimeout(() => document.getElementById('add-device-host').focus(), 100);
}
function closeAddDeviceModal(e) {
if (e && e.target !== document.getElementById('modal-add-device')) return;
document.getElementById('modal-add-device').classList.remove('open');
}
async function addDeviceManually() {
const errEl = document.getElementById('add-device-error');
const btn = document.getElementById('btn-add-device');
errEl.style.display = 'none';
const host = document.getElementById('add-device-host').value.trim();
const port = document.getElementById('add-device-port').value.trim();
if (!host) {
errEl.textContent = 'IP address is required';
errEl.style.display = 'block';
return;
}
// Basic IP validation
const ipPattern = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (!ipPattern.test(host)) {
errEl.textContent = 'Please enter a valid IP address';
errEl.style.display = 'block';
return;
}
const portNum = port ? parseInt(port, 10) : 49153;
if (portNum < 1 || portNum > 65535) {
errEl.textContent = 'Port must be between 1 and 65535';
errEl.style.display = 'block';
return;
}
btn.disabled = true;
btn.textContent = 'Adding…';
try {
const device = await api('POST', '/api/devices/add', { host, port: portNum });
toast(`Successfully added ${device.friendlyName || device.host}`, 'success');
closeAddDeviceModal();
await loadDevices(); // Refresh device list
} catch (err) {
errEl.textContent = err.message || 'Failed to add device';
errEl.style.display = 'block';
} finally {
btn.disabled = false;
btn.textContent = 'Add Device';
}
}
// ── Device Info Modal ───────────────────────────────────────────────────────
let currentDeviceInfo = null;
async function openDeviceInfoModal(i) {
const dev = devices[i];
if (!dev) return;
currentDeviceInfo = dev;
document.getElementById('modal-device-info').classList.add('open');
const content = document.getElementById('device-info-content');
content.innerHTML = '<div style="text-align:center;color:var(--text2);padding:20px;">Loading device information…</div>';
try {
const info = await api('GET', `/api/devices/${dev.host}/${dev.port}/info`);
renderDeviceInfo(info, dev);
} catch (err) {
content.innerHTML = `
<div style="text-align:center;color:var(--danger);padding:20px;">
<div style="font-size:24px;margin-bottom:10px;">⚠️</div>
<div>Failed to load device information</div>
<div style="font-size:12px;margin-top:8px;">${err.message}</div>
</div>
`;
}
}
function closeDeviceInfoModal(e) {
if (e && e.target !== document.getElementById('modal-device-info')) return;
document.getElementById('modal-device-info').classList.remove('open');
currentDeviceInfo = null;
}
function renderDeviceInfo(info, dev) {
const content = document.getElementById('device-info-content');
const fields = [
{ label: 'Device Name', value: info.friendlyName || dev.friendlyName || 'Unknown' },
{ label: 'IP Address', value: dev.host },
{ label: 'Port', value: dev.port },
{ label: 'Product Model', value: info.productModel || 'Unknown' },
{ label: 'Model Description', value: info.modelDescription || 'Unknown' },
{ label: 'Firmware Version', value: info.firmwareVersion || 'Unknown' },
{ label: 'UDN', value: info.udn || 'Unknown' },
{ label: 'Device Type', value: info.deviceType || 'Unknown' },
{ label: 'Manufacturer', value: info.manufacturer || 'Unknown' },
{ label: 'Is Dimmer', value: dev.isDimmer ? 'Yes' : 'No' },
{ label: 'Serial Number', value: info.serialNumber || 'Unknown' },
{ label: 'MAC Address', value: info.macAddress || 'Unknown' }
];
content.innerHTML = `
<div style="display:grid;gap:12px;">
${fields.map(field => `
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border);">
<span style="font-size:13px;color:var(--text2);font-weight:600;">${field.label}</span>
<span style="font-size:13px;color:var(--text);font-family:monospace;word-break:break-all;">${field.value}</span>
</div>
`).join('')}
</div>
`;
}
async function saveRule() {
const errEl = document.getElementById('modal-rule-error');
errEl.style.display = 'none';
const name = document.getElementById('f-name').value.trim();
if (!name) { showModalErr('Rule name is required'); return; }
const type = document.getElementById('f-type').value;
const enabled = document.getElementById('f-enabled').checked;
// Collect selected days
const days = [];
document.querySelectorAll('.day-pill.on').forEach((p) => days.push(Number(p.dataset.d)));
// Collect selected devices
const targetDevices = [];
document.querySelectorAll('#f-devices input[type=checkbox]:checked').forEach((cb) => {
targetDevices.push({ udn: cb.value, host: cb.dataset.host, port: Number(cb.dataset.port), name: cb.dataset.name });
});
if (!targetDevices.length) { showModalErr('Select at least one target device'); return; }
let body = { name, type, enabled, targetDevices };
if (type === 'Trigger') {
// Trigger rules use triggerDevice + actionDevices instead of targetDevices
const srcOpt = document.getElementById('f-trigger-device');
const selOpt = srcOpt.options[srcOpt.selectedIndex];
if (!selOpt?.value) { showModalErr('Select a trigger device'); return; }
body.triggerDevice = {
udn: selOpt.value,
host: selOpt.dataset.host,
port: Number(selOpt.dataset.port),
name: selOpt.dataset.name,
};
body.triggerEvent = document.getElementById('f-trigger-event').value;
body.action = document.getElementById('f-trigger-action').value;
body.actionDevices = [];
document.querySelectorAll('#f-action-devices input[type=checkbox]:checked').forEach((cb) => {
body.actionDevices.push({ udn: cb.value, host: cb.dataset.host, port: Number(cb.dataset.port), name: cb.dataset.name });
});
if (!body.actionDevices.length) { showModalErr('Select at least one action device'); return; }
delete body.targetDevices; // not used for Trigger
} else if (type === 'AlwaysOn') {
// No schedule fields — health monitor polls every 10 s and turns ON if off
} else if (type === 'Countdown') {
const mins = Number(document.getElementById('f-countdown').value);
if (!mins || mins < 1) { showModalErr('Enter a countdown duration in minutes'); return; }
body.countdownTime = mins * 60;
} else {
// Schedule or Away
const startStr = document.getElementById('f-start').value;
if (!startStr) { showModalErr('Start time is required'); return; }
if (!days.length) { showModalErr('Select at least one day'); return; }
body.days = days;
body.startTime = hhmmToSecs(startStr);
body.endTime = hhmmToSecs(document.getElementById('f-end').value);
if (type === 'Schedule') {
body.startAction = Number(document.getElementById('f-start-action').value);
body.endAction = Number(document.getElementById('f-end-action').value);
}
}
const btn = document.getElementById('btn-save-rule');
btn.disabled = true; btn.textContent = 'Saving…';
try {
if (_editingRuleId) {
await api('PUT', `/api/dwm-rules/${_editingRuleId}`, body);
toast(`"${name}" updated`, 'success');
} else {
await api('POST', '/api/dwm-rules', body);
toast(`"${name}" created`, 'success');
}
document.getElementById('modal-rule').classList.remove('open');
await loadRules();
} catch (e) {
showModalErr(e.message);
} finally {
btn.disabled = false; btn.textContent = 'Save Rule';
}
}
function showModalErr(msg) {
const el = document.getElementById('modal-rule-error');
el.textContent = '⚠ ' + msg;
el.style.display = 'block';
}
// ── Delete Confirm ─────────────────────────────────────────────────────────
let _deleteIdx = null;
function confirmDeleteRule(i) {
_deleteIdx = i;
const rule = rules[i];
document.getElementById('confirm-delete-msg').textContent =
`Delete "${rule?.name || 'this rule'}"? This cannot be undone.`;
document.getElementById('btn-confirm-del').onclick = doDeleteRule;
document.getElementById('confirm-delete').classList.add('open');
}
function closeConfirm() {
document.getElementById('confirm-delete').classList.remove('open');
_deleteIdx = null;
}
async function doDeleteRule() {
const rule = rules[_deleteIdx];
if (!rule) return closeConfirm();
try {
await api('DELETE', `/api/dwm-rules/${rule.id}`);
toast(`"${rule.name}" deleted`, 'success');
closeConfirm();
await loadRules();
} catch (e) {
toast('Delete failed: ' + e.message, 'error');
}
}
// ── Wemo Device Rules ──────────────────────────────────────────────────────
async function loadWemoRules() {
const el = document.getElementById('wemo-rules-list');
if (!devices.length) {
try { devices = await api('GET', '/api/devices'); } catch {}
}
const savedDevices = devices.filter((d) => d.host);
if (!savedDevices.length) {
el.innerHTML = `<div class="empty"><div class="icon">⚡</div><p>No devices found.<br>Go to Devices tab and tap Scan first.</p></div>`;
return;
}
const pickerHTML = `
<div style="display:flex;gap:8px;overflow-x:auto;padding-bottom:8px;margin-bottom:12px;">
${savedDevices.map((d, i) => `
<button onclick="selectWemoDev(${i})" id="wdev-btn-${i}"
style="flex-shrink:0;padding:6px 14px;border-radius:20px;border:1.5px solid var(--border);
background:${i===wemoDevIdx?'var(--accent)':'var(--card)'};
color:${i===wemoDevIdx?'#fff':'var(--text)'};font-size:13px;cursor:pointer;white-space:nowrap;">
${esc(d.friendlyName || d.name || d.host)}
</button>`).join('')}
</div>
<div id="wemo-rules-inner"></div>`;
el.innerHTML = pickerHTML;
loadWemoDevRules(savedDevices[wemoDevIdx]);
}
function selectWemoDev(idx) {
wemoDevIdx = idx;
const savedDevices = devices.filter((d) => d.host);
document.querySelectorAll('[id^="wdev-btn-"]').forEach((b, i) => {
b.style.background = i === idx ? 'var(--accent)' : 'var(--card)';
b.style.color = i === idx ? '#fff' : 'var(--text)';
});
loadWemoDevRules(savedDevices[idx]);
}
async function loadWemoDevRules(device) {
const inner = document.getElementById('wemo-rules-inner');
if (!inner) return;
inner.innerHTML = `<div class="empty"><div class="icon">⏳</div><p>Loading rules from device…</p></div>`;
try {
const result = await api('GET', `/api/devices/${device.host}/${device.port}/rules`);
wemoRules = result.rules || result || [];
renderWemoRules(device);
} catch (e) {
inner.innerHTML = `<div class="empty"><div class="icon">⚠️</div><p>Could not load rules:<br>${esc(e.message)}</p></div>`;
}
}
function renderWemoRules(device) {
const inner = document.getElementById('wemo-rules-inner');
if (!inner) return;
if (!wemoRules.length) {
inner.innerHTML = `<div class="empty"><div class="icon">⚡</div><p>No rules on this device.</p></div>`;
return;
}
inner.innerHTML = wemoRules.map((r, i) => `
<div class="card rule-card" id="wrule-${i}">
<div style="font-size:22px;flex-shrink:0">${{Schedule:'📅',Away:'🏠',Countdown:'⏱'}[r.type]||'⚡'}</div>
<div class="card-body">
<div class="card-name">${esc(r.name)}</div>
<div class="card-meta">
<span class="badge badge-off" style="margin-right:5px">${esc(r.type)}</span>
${!r.enabled ? '<span class="badge badge-disabled">Disabled</span>' : ''}
</div>
</div>
<label class="toggle" id="wtog-${i}" onclick="toggleWemoRule(${i},'${device.host}',${device.port},event)">
<input type="checkbox" id="wchk-${i}" ${r.enabled ? 'checked' : ''}>
<span class="track"></span>
<span class="thumb"></span>
</label>
</div>
`).join('');
}
async function toggleWemoRule(i, host, port, e) {
e.preventDefault();
const rule = wemoRules[i];
const chk = document.getElementById('wchk-' + i);
const tog = document.getElementById('wtog-' + i);
if (!rule || !chk || !tog) return;
const enabled = !chk.checked;
tog.classList.add('busy');
try {
await api('PUT', `/api/devices/${host}/${port}/rules/${rule.ruleId}`, { enabled });
wemoRules[i] = { ...rule, enabled };
chk.checked = enabled;
toast(`"${rule.name}" ${enabled ? 'enabled' : 'disabled'}`, 'info');
renderWemoRules({ host, port });
} catch (err) {
toast(`Failed: ${err.message}`, 'error');
} finally {
tog.classList.remove('busy');
}
}
// ── Scheduler status ───────────────────────────────────────────────────────
async function updateSchedStatus() {
try {
const status = await api('GET', '/api/scheduler/status');
applySchedStatus(status);
} catch {}
}
function applySchedStatus(status) {
const dot = document.getElementById('sched-dot');
const label = document.getElementById('sched-label');
if (!dot || !label) return;
const running = status?.running ?? false;
dot.classList.toggle('running', running);
label.textContent = running ? '🟢 Scheduler Running' : '⚫ Scheduler Stopped';
}
function appendLog(event) {
const log = document.getElementById('log');
if (!log) return;
if (log.querySelector('div[style]')) log.innerHTML = '';
const time = new Date().toLocaleTimeString();
const ok = event?.success !== false;
const msg = event?.msg || JSON.stringify(event);
const div = document.createElement('div');
div.className = `log-entry ${ok ? 'log-ok' : 'log-fail'}`;
div.textContent = `[${time}] ${msg}`;
log.insertBefore(div, log.firstChild);
while (log.children.length > 100) log.removeChild(log.lastChild);
}
// ── Util ───────────────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── Init ───────────────────────────────────────────────────────────────────
connectWS();
loadDevices();
updateServerCard();
</script>
</body>
</html>