433 lines
22 KiB
HTML
433 lines
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Home Automation</title>
|
|
|
|
<link rel="stylesheet" href="/assets/css/style.css">
|
|
|
|
<!-- MQTT.js (browser) -->
|
|
<script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>My Home Automation</h1>
|
|
<div class="sub">MQTT control panel (LAN/VPN)</div>
|
|
</header>
|
|
|
|
<div class="container">
|
|
<!-- Connection Card -->
|
|
<div class="card" id="conn-card">
|
|
<h2>MQTT Connection</h2>
|
|
<div class="stack">
|
|
<div class="row">
|
|
<div class="pill">
|
|
<strong>Status:</strong>
|
|
<span id="conn-status" class="mono">disconnected</span>
|
|
<span id="auto-badge" class="auto-chip hidden">AUTO</span>
|
|
</div>
|
|
<div class="spacer"></div>
|
|
<button class="btn" id="connect-btn">Connect</button>
|
|
<button class="btn ghost" id="disconnect-btn" disabled>Disconnect</button>
|
|
</div>
|
|
<div class="row" style="gap:16px; align-items:flex-end; flex-wrap:wrap;">
|
|
<div style="min-width:220px; flex:1;">
|
|
<div class="field">
|
|
<label>Broker WebSocket URL <span class="mono notice">ws://192.168.1.237:9001</span></label>
|
|
<input id="broker-url" type="text" value="ws://192.168.1.237:9001" style="width:100%; padding:8px; border:1px solid #e5e7eb; border-radius:8px;" />
|
|
</div>
|
|
</div>
|
|
<div style="min-width:160px;">
|
|
<div class="field">
|
|
<label>Username</label>
|
|
<input id="broker-user" type="text" value="17ChurchWalk" style="width:100%; padding:8px; border:1px solid #e5e7eb; border-radius:8px;" />
|
|
</div>
|
|
</div>
|
|
<div style="min-width:220px;">
|
|
<div class="field">
|
|
<label>Password</label>
|
|
<input id="broker-pass" type="password" value="PantomimeFrequentedHouse" style="width:100%; padding:8px; border:1px solid #e5e7eb; border-radius:8px;" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="row" style="align-items:center; gap:10px;">
|
|
<input type="checkbox" id="auto-connect" checked /> <label for="auto-connect">Auto-connect on load</label>
|
|
</div>
|
|
<div id="conn-hint" class="notice muted"></div>
|
|
<div id="conn-error" class="notice error"></div>
|
|
<div class="notice">Credentials are pre-filled for convenience and stored locally in your browser (localStorage). For stronger security, consider a tiny server-side MQTT proxy so the page never sees raw creds.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid">
|
|
<!-- Link to Control Panel (existing) -->
|
|
<div class="card">
|
|
<h2>Control</h2>
|
|
<div class="row">
|
|
<a class="btn" href="http://control.richardjolley.co.uk">Open Control Panel</a>
|
|
<span class="chip">LAN/VPN only</span>
|
|
</div>
|
|
<p class="notice">VPN and service management.</p>
|
|
</div>
|
|
|
|
<!-- Office Light Card (Shelly color bulb) -->
|
|
<div class="card" id="office-light">
|
|
<div class="row" style="justify-content:space-between; align-items:center; gap:10px;">
|
|
<h2 style="margin:0;">Office Light</h2>
|
|
<div class="row">
|
|
<div id="ol-color" class="color-box" title="Current color"></div>
|
|
<button id="ol-toggle" class="btn">Toggle</button>
|
|
</div>
|
|
</div>
|
|
<div class="notice mono" style="margin-top:6px;">Topic base: <span id="ol-base-topic">shellies/office-bulb/color/0</span></div>
|
|
|
|
<div class="field">
|
|
<label>Mode</label>
|
|
<div class="seg" id="ol-mode">
|
|
<button data-mode="color" class="active" type="button">Color</button>
|
|
<button data-mode="white" type="button">White</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="ol-color-controls">
|
|
<div class="field">
|
|
<label>Brightness (gain) <span class="mono" id="ol-bright-val">100</span></label>
|
|
<input type="range" id="ol-bright" min="0" max="100" value="100" />
|
|
</div>
|
|
|
|
<div class="row" style="gap:16px;">
|
|
<div class="field" style="flex:1;">
|
|
<label>Red <span class="mono" id="ol-r-val">255</span></label>
|
|
<input type="range" id="ol-r" min="0" max="255" value="255" />
|
|
</div>
|
|
<div class="field" style="flex:1;">
|
|
<label>Green <span class="mono" id="ol-g-val">255</span></label>
|
|
<input type="range" id="ol-g" min="0" max="255" value="255" />
|
|
</div>
|
|
<div class="field" style="flex:1;">
|
|
<label>Blue <span class="mono" id="ol-b-val">255</span></label>
|
|
<input type="range" id="ol-b" min="0" max="255" value="255" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>White channel (RGBW) <span class="mono" id="ol-w-val">0</span></label>
|
|
<input type="range" id="ol-w" min="0" max="255" value="0" />
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Effect (0=off) <span class="mono" id="ol-effect-val">0</span></label>
|
|
<input type="range" id="ol-effect" min="0" max="10" value="0" />
|
|
</div>
|
|
</div>
|
|
|
|
<div id="ol-white-controls" class="hidden">
|
|
<div class="field">
|
|
<label>Brightness <span class="mono" id="ol-wbright-val">100</span></label>
|
|
<input type="range" id="ol-wbright" min="0" max="100" value="100" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Color Temperature (K) <span class="mono" id="ol-temp-val">3000</span></label>
|
|
<input type="range" id="ol-temp" min="2700" max="6500" step="10" value="3000" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Effect (0=off) <span class="mono" id="ol-effectw-val">0</span></label>
|
|
<input type="range" id="ol-effectw" min="0" max="10" value="0" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="notice">Shelly expects updates on <span class="mono">{base}/set</span> as JSON; on/off on <span class="mono">{base}/command</span>. We also listen to <span class="mono">{base}/status</span>.</div>
|
|
<div id="ol-feedback" class="notice"></div>
|
|
</div>
|
|
|
|
<div class="card" id="office-plug-card">
|
|
<div class="row" style="justify-content:space-between; align-items:center;">
|
|
<h2 style="margin:0;">Office Plug Lamp</h2>
|
|
<div class="row off" id="office-plug-state">
|
|
<span class="status-dot"></span>
|
|
<span class="mono" id="office-plug-text">off</span>
|
|
</div>
|
|
</div>
|
|
<div class="notice mono" style="margin-top:6px;">Prefix: <span>office-plug-lamp</span> · RPC topic: <span>office-plug-lamp/rpc</span></div>
|
|
<div class="row" style="margin-top:10px; gap:8px;">
|
|
<button class="btn small" id="office-plug-on">On</button>
|
|
<button class="btn small" id="office-plug-off">Off</button>
|
|
<button class="btn small" id="office-plug-toggle">Toggle</button>
|
|
</div>
|
|
<div class="notice" id="office-plug-fb"></div>
|
|
</div>
|
|
|
|
<div class="card" id="living-plug-card">
|
|
<div class="row" style="justify-content:space-between; align-items:center;">
|
|
<h2 style="margin:0;">Living Room Plug Lamp</h2>
|
|
<div class="row off" id="living-plug-state">
|
|
<span class="status-dot"></span>
|
|
<span class="mono" id="living-plug-text">off</span>
|
|
</div>
|
|
</div>
|
|
<div class="notice mono" style="margin-top:6px;">Prefix: <span>livingroom-plug-lamp</span> · RPC topic: <span>livingroom-plug-lamp/rpc</span></div>
|
|
<div class="row" style="margin-top:10px; gap:8px;">
|
|
<button class="btn small" id="living-plug-on">On</button>
|
|
<button class="btn small" id="living-plug-off">Off</button>
|
|
<button class="btn small" id="living-plug-toggle">Toggle</button>
|
|
</div>
|
|
<div class="notice" id="living-plug-fb"></div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const qs = s => document.querySelector(s);
|
|
const byId = id => document.getElementById(id);
|
|
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
|
|
const debounce = (fn, ms=200) => { let t; return (...a) => { clearTimeout(t); t=setTimeout(()=>fn(...a), ms); }; };
|
|
|
|
const storage = {
|
|
get() {
|
|
return {
|
|
url: localStorage.getItem('mqtt_url') || 'ws://192.168.1.237:9001',
|
|
user: localStorage.getItem('mqtt_user') || '17ChurchWalk',
|
|
pass: localStorage.getItem('mqtt_pass') || 'PantomimeFrequentedHouse',
|
|
auto: localStorage.getItem('mqtt_auto') !== '0'
|
|
};
|
|
},
|
|
set({url,user,pass,auto}) {
|
|
localStorage.setItem('mqtt_url', url);
|
|
localStorage.setItem('mqtt_user', user || '');
|
|
localStorage.setItem('mqtt_pass', pass || '');
|
|
localStorage.setItem('mqtt_auto', auto ? '1':'0');
|
|
}
|
|
};
|
|
|
|
const elConnStatus = byId('conn-status');
|
|
const elConnect = byId('connect-btn');
|
|
const elDisconnect = byId('disconnect-btn');
|
|
const elUrl = byId('broker-url');
|
|
const elUser = byId('broker-user');
|
|
const elPass = byId('broker-pass');
|
|
const elAuto = byId('auto-connect');
|
|
const elAutoBadge = byId('auto-badge');
|
|
const elHint = byId('conn-hint');
|
|
const elErr = byId('conn-error');
|
|
|
|
const devices = [
|
|
{ kind:'bulb', name:'Office Light', base:'shellies/office-bulb/color/0',
|
|
ids:{ toggle:'ol-toggle', color:'ol-color', modeSeg:'ol-mode', R:'ol-r', G:'ol-g', B:'ol-b', W:'ol-w', Rv:'ol-r-val', Gv:'ol-g-val', Bv:'ol-b-val', Wv:'ol-w-val', bright:'ol-bright', brightv:'ol-bright-val', effect:'ol-effect', effectv:'ol-effect-val', wbright:'ol-wbright', wbrightv:'ol-wbright-val', temp:'ol-temp', tempv:'ol-temp-val', effectw:'ol-effectw', effectwv:'ol-effectw-val', colorControls:'ol-color-controls', whiteControls:'ol-white-controls', feedback:'ol-feedback' },
|
|
state:{ on:false, mode:'color', r:255,g:255,b:255,w:0,gain:100,effect:0, brightness:100,temp:3000,effectw:0 }
|
|
},
|
|
{ kind:'plug', prefix:'office-plug-lamp', switchId:0, els:{ stateWrap:'office-plug-state', stateText:'office-plug-text', on:'office-plug-on', off:'office-plug-off', toggle:'office-plug-toggle', fb:'office-plug-fb' }, state:{ on:false } },
|
|
{ kind:'plug', prefix:'livingroom-plug-lamp', switchId:0, els:{ stateWrap:'living-plug-state', stateText:'living-plug-text', on:'living-plug-on', off:'living-plug-off', toggle:'living-plug-toggle', fb:'living-plug-fb' }, state:{ on:false } }
|
|
];
|
|
|
|
(function initConnForm(){
|
|
const {url,user,pass,auto} = storage.get();
|
|
elUrl.value = url; elUser.value = user; elPass.value = pass; elAuto.checked = auto;
|
|
elAutoBadge.classList.toggle('hidden', !auto);
|
|
// Show helpful hint if https + ws
|
|
if (location.protocol === 'https:' && url.startsWith('ws://')) {
|
|
elHint.innerHTML = 'This page is served over <code class="inline">https</code> but the broker URL is <code class="inline">ws://</code>. Browsers block mixed content. Either open this page via <code class="inline">http://</code> on your LAN, or change the broker to <code class="inline">wss://</code> and enable a TLS WebSocket listener in Mosquitto.';
|
|
} else {
|
|
elHint.textContent = '';
|
|
}
|
|
})();
|
|
|
|
let client = null;
|
|
|
|
function setConnStatus(text, ok=false) {
|
|
elConnStatus.textContent = text;
|
|
elConnStatus.className = 'mono ' + (ok ? 'success' : (text==='disconnected'?'':'warn'));
|
|
elConnect.disabled = !!ok;
|
|
elDisconnect.disabled = !ok;
|
|
}
|
|
|
|
function showError(msg){ elErr.textContent = msg || ''; }
|
|
|
|
function connectMQTT(){
|
|
showError('');
|
|
const url = elUrl.value.trim();
|
|
const username = elUser.value.trim() || undefined;
|
|
const password = elPass.value || undefined;
|
|
storage.set({url, user: username||'', pass: password||'', auto: elAuto.checked});
|
|
|
|
if (typeof mqtt === 'undefined') {
|
|
showError('MQTT library failed to load. Check network/CSP and the CDN.');
|
|
return;
|
|
}
|
|
if (location.protocol === 'https:' && url.startsWith('ws://')) {
|
|
showError('Mixed content blocked: change broker URL to wss:// or open this page via http://');
|
|
setConnStatus('blocked');
|
|
return;
|
|
}
|
|
|
|
try { if (client) { client.end(true); client = null; } } catch(e) {}
|
|
|
|
setConnStatus('connecting…');
|
|
client = mqtt.connect(url, { username, password, reconnectPeriod: 2000 });
|
|
|
|
client.on('connect', () => {
|
|
setConnStatus('connected', true);
|
|
devices.forEach(d => {
|
|
if (d.kind === 'bulb') {
|
|
try { client.subscribe(`${d.base}/status`); } catch(e){}
|
|
try { client.subscribe(`${d.base}`); } catch(e){}
|
|
} else if (d.kind === 'plug') {
|
|
try { client.subscribe(`${d.prefix}/events/rpc`); } catch(e){}
|
|
}
|
|
});
|
|
requestStatuses();
|
|
});
|
|
|
|
client.on('reconnect', () => setConnStatus('reconnecting…'));
|
|
client.on('close', () => setConnStatus('disconnected'));
|
|
client.on('error', (err) => {
|
|
setConnStatus('error');
|
|
showError('Connection error: ' + (err && err.message ? err.message : String(err)));
|
|
console.error(err);
|
|
});
|
|
|
|
client.on('message', (topic, payloadBuf) => {
|
|
const payload = payloadBuf.toString();
|
|
const bulb = devices[0];
|
|
if (topic === bulb.base) {
|
|
bulb.state.on = (payload.trim().toLowerCase() === 'on');
|
|
updateBulbUI(bulb);
|
|
return;
|
|
}
|
|
if (topic === `${bulb.base}/status`) {
|
|
try {
|
|
const js = JSON.parse(payload);
|
|
if (typeof js.ison === 'boolean') bulb.state.on = js.ison;
|
|
if (js.mode === 'color' || js.mode === 'white') bulb.state.mode = js.mode;
|
|
if (Number.isFinite(js.red)) bulb.state.r = clamp(js.red,0,255);
|
|
if (Number.isFinite(js.green)) bulb.state.g = clamp(js.green,0,255);
|
|
if (Number.isFinite(js.blue)) bulb.state.b = clamp(js.blue,0,255);
|
|
if (Number.isFinite(js.white)) bulb.state.w = clamp(js.white,0,255);
|
|
if (Number.isFinite(js.gain)) bulb.state.gain = clamp(js.gain,0,100);
|
|
if (Number.isFinite(js.effect)) bulb.state.effect = clamp(js.effect,0,10);
|
|
if (Number.isFinite(js.brightness)) bulb.state.brightness = clamp(js.brightness,0,100);
|
|
if (Number.isFinite(js.temp)) bulb.state.temp = clamp(js.temp,2700,6500);
|
|
updateBulbUI(bulb);
|
|
} catch(e) { console.warn('Bad JSON on status', e, payload); }
|
|
return;
|
|
}
|
|
|
|
const plug = devices.find(d => d.kind==='plug' && topic === `${d.prefix}/events/rpc`);
|
|
if (plug) {
|
|
try {
|
|
const js = JSON.parse(payload);
|
|
if (js.method === 'NotifyStatus' && js.params && js.params[`switch:${plug.switchId}`]) {
|
|
const out = js.params[`switch:${plug.switchId}`].output;
|
|
if (typeof out === 'boolean') { plug.state.on = out; updatePlugUI(plug); }
|
|
}
|
|
} catch(e) { console.warn('Bad RPC JSON', e, payload); }
|
|
return;
|
|
}
|
|
});
|
|
}
|
|
|
|
function requestStatuses(){
|
|
devices.filter(d=>d.kind==='plug').forEach((plug,i) => {
|
|
const req = { id: 100+i, src: 'ui', method: 'Shelly.GetStatus' };
|
|
publishRPC(plug, req);
|
|
});
|
|
}
|
|
|
|
function publish(dev, subPath, message) {
|
|
if (!client || client.disconnected) return false;
|
|
const topic = `${dev.base}${subPath ? '/' + subPath : ''}`;
|
|
try { client.publish(topic, message, { qos:0, retain:false }); return true; } catch(e) { console.error(e); return false; }
|
|
}
|
|
|
|
function publishRPC(plug, obj){
|
|
if (!client || client.disconnected) return false;
|
|
const topic = `${plug.prefix}/rpc`;
|
|
try { client.publish(topic, JSON.stringify(obj), { qos:0, retain:false }); return true; } catch(e) { console.error(e); return false; }
|
|
}
|
|
|
|
function updateBulbUI(dev){
|
|
const ids = dev.ids;
|
|
const seg = byId(ids.modeSeg);
|
|
seg.querySelectorAll('button').forEach(btn => btn.classList.toggle('active', btn.dataset.mode === dev.state.mode));
|
|
byId(ids.colorControls).classList.toggle('hidden', dev.state.mode !== 'color');
|
|
byId(ids.whiteControls).classList.toggle('hidden', dev.state.mode !== 'white');
|
|
byId(ids.color).style.background = `rgb(${dev.state.r} ${dev.state.g} ${dev.state.b})`;
|
|
byId(ids.toggle).textContent = dev.state.on ? 'Turn Off' : 'Turn On';
|
|
byId(ids.R).value = dev.state.r; byId(ids.Rv).textContent = dev.state.r;
|
|
byId(ids.G).value = dev.state.g; byId(ids.Gv).textContent = dev.state.g;
|
|
byId(ids.B).value = dev.state.b; byId(ids.Bv).textContent = dev.state.b;
|
|
byId(ids.W).value = dev.state.w; byId(ids.Wv).textContent = dev.state.w;
|
|
byId(ids.bright).value = dev.state.gain; byId(ids.brightv).textContent = dev.state.gain;
|
|
byId(ids.effect).value = dev.state.effect; byId(ids.effectv).textContent = dev.state.effect;
|
|
byId(ids.wbright).value = dev.state.brightness; byId(ids.wbrightv).textContent = dev.state.brightness;
|
|
byId(ids.temp).value = dev.state.temp; byId(ids.tempv).textContent = dev.state.temp;
|
|
byId(ids.effectw).value = dev.state.effectw; byId(ids.effectwv).textContent = dev.state.effectw;
|
|
}
|
|
|
|
function sendBulbUpdate(dev){
|
|
const s = dev.state; let body;
|
|
if (s.mode === 'color') { body = { mode:'color', red:s.r, green:s.g, blue:s.b, white:s.w, gain:s.gain, effect:s.effect, turn:'on' }; }
|
|
else { body = { mode:'white', brightness:s.brightness, temp:s.temp, effect:s.effectw, turn:'on' }; }
|
|
const ok = publish(dev, 'set', JSON.stringify(body));
|
|
byId(dev.ids.feedback).textContent = ok ? `Set → ${JSON.stringify(body)}` : 'Not connected';
|
|
}
|
|
|
|
function toggleBulbPower(dev){
|
|
const target = dev.state.on ? 'off' : 'on';
|
|
const ok = publish(dev, 'command', target);
|
|
byId(dev.ids.feedback).textContent = ok ? `Power ${target}` : 'Not connected';
|
|
}
|
|
|
|
function updatePlugUI(plug){
|
|
const wrap = byId(plug.els.stateWrap); const text = byId(plug.els.stateText);
|
|
wrap.classList.toggle('on', plug.state.on); wrap.classList.toggle('off', !plug.state.on);
|
|
text.textContent = plug.state.on ? 'on' : 'off';
|
|
}
|
|
|
|
function plugOn(plug){ const ok = publishRPC(plug, { id: Date.now(), src:'ui', method:'Switch.Set', params:{ id: plug.switchId, on:true } }); byId(plug.els.fb).textContent = ok ? 'Switch.Set → on' : 'Not connected'; }
|
|
function plugOff(plug){ const ok = publishRPC(plug, { id: Date.now(), src:'ui', method:'Switch.Set', params:{ id: plug.switchId, on:false } }); byId(plug.els.fb).textContent = ok ? 'Switch.Set → off' : 'Not connected'; }
|
|
function plugToggle(plug){ const ok = publishRPC(plug, { id: Date.now(), src:'ui', method:'Switch.Toggle', params:{ id: plug.switchId } }); byId(plug.els.fb).textContent = ok ? 'Switch.Toggle' : 'Not connected'; }
|
|
|
|
(function initOfficeLight(){
|
|
const d = devices[0]; const ids = d.ids; const apply = debounce(() => sendBulbUpdate(d), 180);
|
|
byId(ids.modeSeg).querySelectorAll('button').forEach(btn => { btn.addEventListener('click', () => { d.state.mode = btn.dataset.mode; updateBulbUI(d); apply(); }); });
|
|
byId(ids.R).addEventListener('input', e => { d.state.r = +e.target.value; byId(ids.Rv).textContent = d.state.r; updateBulbUI(d); apply(); });
|
|
byId(ids.G).addEventListener('input', e => { d.state.g = +e.target.value; byId(ids.Gv).textContent = d.state.g; updateBulbUI(d); apply(); });
|
|
byId(ids.B).addEventListener('input', e => { d.state.b = +e.target.value; byId(ids.Bv).textContent = d.state.b; updateBulbUI(d); apply(); });
|
|
byId(ids.W).addEventListener('input', e => { d.state.w = +e.target.value; byId(ids.Wv).textContent = d.state.w; updateBulbUI(d); apply(); });
|
|
byId(ids.bright).addEventListener('input', e => { d.state.gain = +e.target.value; byId(ids.brightv).textContent = d.state.gain; updateBulbUI(d); apply(); });
|
|
byId(ids.effect).addEventListener('input', e => { d.state.effect = +e.target.value; byId(ids.effectv).textContent = d.state.effect; updateBulbUI(d); apply(); });
|
|
byId(ids.wbright).addEventListener('input', e => { d.state.brightness = +e.target.value; byId(ids.wbrightv).textContent = d.state.brightness; updateBulbUI(d); apply(); });
|
|
byId(ids.temp).addEventListener('input', e => { d.state.temp = +e.target.value; byId(ids.tempv).textContent = d.state.temp; updateBulbUI(d); apply(); });
|
|
byId(ids.effectw).addEventListener('input', e => { d.state.effectw = +e.target.value; byId(ids.effectwv).textContent = d.state.effectw; updateBulbUI(d); apply(); });
|
|
byId(ids.toggle).addEventListener('click', () => { toggleBulbPower(d); }); updateBulbUI(d);
|
|
})();
|
|
|
|
(function initPlugs(){
|
|
[{id:'office'}, {id:'living'}].forEach(k=>{}); // noop placeholder to keep structure tidy
|
|
devices.filter(d=>d.kind==='plug').forEach(plug => {
|
|
byId(plug.els.on).addEventListener('click', ()=>plugOn(plug));
|
|
byId(plug.els.off).addEventListener('click', ()=>plugOff(plug));
|
|
byId(plug.els.toggle).addEventListener('click', ()=>plugToggle(plug));
|
|
updatePlugUI(plug);
|
|
});
|
|
})();
|
|
|
|
elConnect.addEventListener('click', connectMQTT);
|
|
elDisconnect.addEventListener('click', () => { if (client) { try { client.end(true); } catch(e){} } setConnStatus('disconnected'); });
|
|
|
|
window.addEventListener('load', () => {
|
|
const {auto} = storage.get();
|
|
elAuto.checked = auto; elAutoBadge.classList.toggle('hidden', !auto);
|
|
if (auto) { try { connectMQTT(); } catch (e) { console.warn('Auto-connect failed', e); showError('Auto-connect failed: ' + e.message); } }
|
|
});
|
|
|
|
elAuto.addEventListener('change', () => {
|
|
storage.set({ url: elUrl.value, user: elUser.value, pass: elPass.value, auto: elAuto.checked });
|
|
elAutoBadge.classList.toggle('hidden', !elAuto.checked);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|