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
370 lines
11 KiB
C++
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
|