Ryan Malloy 04667f5161 Add optional MQTT gateway with web configuration UI
Feature-flagged WiFi/MQTT capability (WITH_MQTT) that bridges
LoRa mesh packets to MQTT topics over TLS (port 443). Includes:

- WiFiManager: connection handling with AP fallback mode
- MQTTBridge: TLS-secured pub/sub with FNV-1a deduplication
- WebConfig: REST API for WiFi/MQTT settings
- Embedded web UI dashboard for configuration

Default broker: meshqt.l.supported.systems:443 (MQTTS)
Build with: pio run -e heltec_v3_mqtt
2026-01-25 22:44:16 -07:00

370 lines
11 KiB
C++

#pragma once
#ifdef WITH_MQTT
// Embedded HTML/CSS/JS for configuration web interface
// Stored in PROGMEM to save RAM
const char WEB_UI_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MeshCore Gateway</title>
<style>
:root {
--bg: #1a1a2e;
--card: #16213e;
--accent: #0f3460;
--text: #e8e8e8;
--text-dim: #a0a0a0;
--success: #4ade80;
--warning: #fbbf24;
--error: #f87171;
--border: #2a3a5a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 1rem;
}
.container { max-width: 600px; margin: 0 auto; }
h1 {
font-size: 1.5rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
h1::before { content: "📡"; }
.card {
background: var(--card);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
border: 1px solid var(--border);
}
.card h2 {
font-size: 1rem;
color: var(--text-dim);
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.status-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.status-item {
display: flex;
justify-content: space-between;
padding: 0.5rem;
background: var(--accent);
border-radius: 4px;
}
.status-item .label { color: var(--text-dim); font-size: 0.875rem; }
.status-item .value { font-weight: 500; font-family: monospace; }
.indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.5rem;
}
.indicator.online { background: var(--success); }
.indicator.offline { background: var(--error); }
.indicator.warning { background: var(--warning); }
form { display: flex; flex-direction: column; gap: 0.75rem; }
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--text-dim);
}
input, select {
padding: 0.625rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--accent);
color: var(--text);
font-size: 1rem;
}
input:focus, select:focus {
outline: none;
border-color: var(--success);
}
.checkbox-label {
flex-direction: row;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input {
width: auto;
accent-color: var(--success);
}
button {
padding: 0.75rem 1rem;
border: none;
border-radius: 4px;
background: var(--success);
color: #000;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
button:hover { opacity: 0.9; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
button.secondary {
background: var(--accent);
color: var(--text);
border: 1px solid var(--border);
}
button.danger { background: var(--error); }
.btn-row { display: flex; gap: 0.5rem; }
.msg {
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
display: none;
}
.msg.success { background: rgba(74, 222, 128, 0.2); border: 1px solid var(--success); display: block; }
.msg.error { background: rgba(248, 113, 113, 0.2); border: 1px solid var(--error); display: block; }
.hidden { display: none; }
@media (max-width: 480px) {
.status-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<h1>MeshCore Gateway</h1>
<div id="msg" class="msg"></div>
<div class="card">
<h2>Status</h2>
<div class="status-grid">
<div class="status-item">
<span class="label">Node</span>
<span class="value" id="node-name">-</span>
</div>
<div class="status-item">
<span class="label">Node ID</span>
<span class="value" id="node-id">-</span>
</div>
<div class="status-item">
<span class="label">WiFi</span>
<span class="value"><span id="wifi-ind" class="indicator offline"></span><span id="wifi-status">-</span></span>
</div>
<div class="status-item">
<span class="label">MQTT</span>
<span class="value"><span id="mqtt-ind" class="indicator offline"></span><span id="mqtt-status">-</span></span>
</div>
<div class="status-item">
<span class="label">IP Address</span>
<span class="value" id="ip-addr">-</span>
</div>
<div class="status-item">
<span class="label">WiFi RSSI</span>
<span class="value" id="wifi-rssi">-</span>
</div>
<div class="status-item">
<span class="label">Packets RX/TX</span>
<span class="value" id="packets">-</span>
</div>
<div class="status-item">
<span class="label">Free Heap</span>
<span class="value" id="heap">-</span>
</div>
</div>
</div>
<div class="card">
<h2>WiFi Configuration</h2>
<form id="wifi-form">
<label>
Network SSID
<input type="text" id="wifi-ssid" maxlength="32" required>
</label>
<label>
Password
<input type="password" id="wifi-pass" maxlength="64" placeholder="Leave blank to keep current">
</label>
<div class="btn-row">
<button type="submit">Save WiFi</button>
<button type="button" class="secondary" onclick="loadWiFi()">Reset</button>
</div>
</form>
</div>
<div class="card">
<h2>MQTT Configuration</h2>
<form id="mqtt-form">
<label class="checkbox-label">
<input type="checkbox" id="mqtt-enabled">
MQTT Enabled
</label>
<label>
Broker Address
<input type="text" id="mqtt-broker" maxlength="63" placeholder="mqtt.example.com">
</label>
<label>
Port
<input type="number" id="mqtt-port" value="1883" min="1" max="65535">
</label>
<label>
Username (optional)
<input type="text" id="mqtt-user" maxlength="31">
</label>
<label>
Password (optional)
<input type="password" id="mqtt-pass" maxlength="63" placeholder="Leave blank to keep current">
</label>
<label>
Topic Prefix
<input type="text" id="mqtt-prefix" maxlength="31" placeholder="meshcore/repeater">
</label>
<div class="btn-row">
<button type="submit">Save MQTT</button>
<button type="button" class="secondary" onclick="loadMQTT()">Reset</button>
</div>
</form>
</div>
<div class="card">
<h2>System</h2>
<div class="btn-row">
<button type="button" class="danger" onclick="reboot()">Reboot Device</button>
</div>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
let refreshTimer;
function showMsg(text, isError) {
const msg = $('msg');
msg.textContent = text;
msg.className = 'msg ' + (isError ? 'error' : 'success');
setTimeout(() => msg.className = 'msg', 5000);
}
async function api(endpoint, method = 'GET', data = null) {
const opts = { method, headers: {} };
if (data) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(data);
}
const res = await fetch('/api/' + endpoint, opts);
return res.json();
}
async function loadStatus() {
try {
const s = await api('status');
$('node-name').textContent = s.node_name || '-';
$('node-id').textContent = s.node_id ? s.node_id.substring(0, 8) : '-';
$('wifi-ind').className = 'indicator ' + (s.wifi?.connected ? 'online' : 'offline');
$('wifi-status').textContent = s.wifi?.connected ? s.wifi.ssid : (s.wifi?.ap_mode ? 'AP Mode' : 'Disconnected');
$('ip-addr').textContent = s.wifi?.ip || '-';
$('wifi-rssi').textContent = s.wifi?.connected ? s.wifi.rssi + ' dBm' : '-';
$('mqtt-ind').className = 'indicator ' + (s.mqtt?.connected ? 'online' : (s.mqtt?.enabled ? 'warning' : 'offline'));
$('mqtt-status').textContent = s.mqtt?.connected ? 'Connected' : (s.mqtt?.enabled ? 'Connecting...' : 'Disabled');
$('packets').textContent = (s.mesh?.packets_rx || 0) + ' / ' + (s.mesh?.packets_tx || 0);
$('heap').textContent = s.free_heap ? Math.round(s.free_heap / 1024) + ' KB' : '-';
} catch (e) {
console.error('Status error:', e);
}
}
async function loadWiFi() {
try {
const w = await api('wifi');
$('wifi-ssid').value = w.ssid || '';
$('wifi-pass').value = '';
$('wifi-pass').placeholder = w.password_set ? 'Leave blank to keep current' : 'Enter password';
} catch (e) {
showMsg('Failed to load WiFi config', true);
}
}
async function loadMQTT() {
try {
const m = await api('mqtt');
$('mqtt-enabled').checked = m.enabled;
$('mqtt-broker').value = m.broker || '';
$('mqtt-port').value = m.port || 1883;
$('mqtt-user').value = m.user || '';
$('mqtt-pass').value = '';
$('mqtt-prefix').value = m.topic_prefix || 'meshcore/repeater';
} catch (e) {
showMsg('Failed to load MQTT config', true);
}
}
$('wifi-form').onsubmit = async (e) => {
e.preventDefault();
try {
const data = { ssid: $('wifi-ssid').value };
if ($('wifi-pass').value) data.password = $('wifi-pass').value;
const res = await api('wifi', 'POST', data);
showMsg(res.message || 'WiFi config saved');
} catch (e) {
showMsg('Failed to save WiFi config', true);
}
};
$('mqtt-form').onsubmit = async (e) => {
e.preventDefault();
try {
const data = {
enabled: $('mqtt-enabled').checked,
broker: $('mqtt-broker').value,
port: parseInt($('mqtt-port').value),
user: $('mqtt-user').value,
topic_prefix: $('mqtt-prefix').value
};
if ($('mqtt-pass').value) data.password = $('mqtt-pass').value;
const res = await api('mqtt', 'POST', data);
showMsg(res.message || 'MQTT config saved');
} catch (e) {
showMsg('Failed to save MQTT config', true);
}
};
async function reboot() {
if (!confirm('Are you sure you want to reboot the device?')) return;
try {
await api('reboot', 'POST');
showMsg('Rebooting...');
clearInterval(refreshTimer);
} catch (e) {
showMsg('Reboot command sent');
}
}
// Initial load
loadStatus();
loadWiFi();
loadMQTT();
// Auto-refresh status every 5 seconds
refreshTimer = setInterval(loadStatus, 5000);
</script>
</body>
</html>
)rawliteral";
#endif // WITH_MQTT