| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294 |
- #include "WebPanelServer.h"
- #if defined(ESP_PLATFORM) && WITH_WEB_PANEL
- #include <Arduino.h>
- #include <esp_heap_caps.h>
- #include <esp_idf_version.h>
- #include <esp_system.h>
- #include <string.h>
- #include "../mqtt/generated/WebPanelCert.h"
- namespace {
- constexpr size_t kWebServerStackSize = 8192;
- constexpr size_t kWebPasswordBufferSize = 80;
- constexpr size_t kWebCommandBufferSize = 192;
- constexpr size_t kWebReplyBufferSize = 256;
- constexpr size_t kWebJsonBufferSize = 2048;
- #if defined(MQTT_DEBUG) && MQTT_DEBUG
- #define WEB_PANEL_LOG(fmt, ...) Serial.printf("[WEB] " fmt "\n", ##__VA_ARGS__)
- #else
- #define WEB_PANEL_LOG(...) do { } while (0)
- #endif
- char* allocScratchBuffer(size_t size) {
- void* ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
- if (ptr == nullptr) {
- ptr = heap_caps_malloc(size, MALLOC_CAP_8BIT);
- }
- return static_cast<char*>(ptr);
- }
- void freeScratchBuffer(void* ptr) {
- if (ptr != nullptr) {
- heap_caps_free(ptr);
- }
- }
- void bytesToHexUpper(const uint8_t* src, size_t len, char* dst, size_t dst_size) {
- if (dst_size == 0) {
- return;
- }
- size_t di = 0;
- for (size_t i = 0; i < len && di + 2 < dst_size; ++i) {
- snprintf(&dst[di], dst_size - di, "%02X", src[i]);
- di += 2;
- }
- dst[(di < dst_size) ? di : (dst_size - 1)] = 0;
- }
- size_t appendJsonEscaped(char* dst, size_t dst_size, size_t offset, const char* src) {
- if (dst == nullptr || dst_size == 0) {
- return offset;
- }
- for (size_t i = 0; src != nullptr && src[i] != 0 && offset + 2 < dst_size; ++i) {
- char c = src[i];
- if (c == '\\' || c == '"') {
- if (offset + 2 >= dst_size) break;
- dst[offset++] = '\\';
- dst[offset++] = c;
- } else if (c == '\n') {
- if (offset + 2 >= dst_size) break;
- dst[offset++] = '\\';
- dst[offset++] = 'n';
- } else if (c == '\r') {
- if (offset + 2 >= dst_size) break;
- dst[offset++] = '\\';
- dst[offset++] = 'r';
- } else if (c == '\t') {
- if (offset + 2 >= dst_size) break;
- dst[offset++] = '\\';
- dst[offset++] = 't';
- } else {
- dst[offset++] = c;
- }
- }
- dst[(offset < dst_size) ? offset : (dst_size - 1)] = 0;
- return offset;
- }
- bool appendJsonField(char* dst, size_t dst_size, size_t& offset, const char* key, const char* value, bool comma) {
- int written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s\"%s\":\"", comma ? "," : "", key);
- if (written < 0 || offset + static_cast<size_t>(written) >= dst_size) {
- return false;
- }
- offset += static_cast<size_t>(written);
- offset = appendJsonEscaped(dst, dst_size, offset, value != nullptr ? value : "");
- if (offset + 2 >= dst_size) {
- return false;
- }
- dst[offset++] = '"';
- dst[offset] = 0;
- return true;
- }
- bool appendJsonFieldRaw(char* dst, size_t dst_size, size_t& offset, const char* key, const char* value, bool comma) {
- int written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s\"%s\":", comma ? "," : "", key);
- if (written < 0 || offset + static_cast<size_t>(written) >= dst_size) {
- return false;
- }
- offset += static_cast<size_t>(written);
- if (value == nullptr) {
- value = "null";
- }
- written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s", value);
- if (written < 0 || offset + static_cast<size_t>(written) >= dst_size) {
- return false;
- }
- offset += static_cast<size_t>(written);
- return true;
- }
- const char kWebPanelHtml[] PROGMEM = R"HTML(
- <!doctype html>
- <html>
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width,initial-scale=1">
- <title>Repeater Config</title>
- <style>
- :root {
- color-scheme: light;
- --accent:#2f8f4e;
- --accent-hover:#3fae61;
- --background:#f4f6f9;
- --text:#1f2937;
- --text-muted:#4b5563;
- --border:rgba(0, 0, 0, 0.08);
- --surface1:#ffffff;
- --surface2:#f0f3f7;
- --card-bg:#ffffff;
- --input-bg:#ffffff;
- --terminal-bg:#f0f3f7;
- --terminal-border:rgba(0, 0, 0, 0.08);
- --terminal-cmd:#2f8f4e;
- --status-red:#c94a4a;
- --button-text:#ffffff;
- --button-secondary-text:#1f2937;
- }
- :root[data-theme="dark"] {
- color-scheme: dark;
- --accent:#36a167;
- --accent-hover:#49c27d;
- --background:#222222;
- --text:#e6eaf0;
- --text-muted:#9aa4b2;
- --border:rgba(255, 255, 255, 0.08);
- --surface1:#303030;
- --surface2:#343434;
- --card-bg:#303030;
- --input-bg:#343434;
- --terminal-bg:#222222;
- --terminal-border:rgba(255, 255, 255, 0.08);
- --terminal-cmd:#8fd3ff;
- --status-red:#d45a5a;
- --button-text:#ffffff;
- --button-secondary-text:#e6eaf0;
- }
- html { min-height:100%; background:linear-gradient(180deg,var(--background),var(--surface2)); background-repeat:no-repeat; background-attachment:fixed; }
- body { min-height:100vh; margin:0; font:16px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; background:transparent; color:var(--text); transition:background .2s ease,color .2s ease; }
- main { max-width:920px; margin:0 auto; padding:24px; }
- .theme-fab { position:fixed; top:18px; right:18px; width:48px; height:48px; border-radius:999px; display:flex; align-items:center; justify-content:center; z-index:20; box-shadow:0 12px 28px rgba(0,0,0,.18); font-size:22px; line-height:1; }
- .card { background:var(--card-bg); border:1px solid var(--border); border-radius:14px; padding:18px; margin-bottom:18px; }
- h1,h2,h3 { margin:0 0 12px; font-size:18px; }
- p { color:var(--text-muted); margin:8px 0 0; }
- input, textarea, button, select { width:100%; box-sizing:border-box; border-radius:10px; border:1px solid var(--border); background:var(--input-bg); color:var(--text); padding:12px; font:inherit; }
- textarea { min-height:100px; resize:vertical; }
- button { width:auto; cursor:pointer; background:var(--accent); color:var(--button-text); border:none; font-weight:700; transition:background .2s ease,color .2s ease,border-color .2s ease; }
- button:hover { background:var(--accent-hover); }
- .row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
- .row-command { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; }
- .row3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
- .quick { display:flex; flex-wrap:wrap; gap:10px; }
- .quick button, .iconbtn, .themebtn { background:var(--surface2); color:var(--button-secondary-text); border:1px solid var(--border); }
- .quick button:hover, .iconbtn:hover, .themebtn:hover { background:var(--surface1); }
- button.action-advert { background:linear-gradient(135deg,#d97706,#f59e0b); color:#fff7ed; border:none; }
- button.action-advert:hover { background:linear-gradient(135deg,#ea8f17,#ffb938); }
- button.action-caution { background:linear-gradient(135deg,#b94747,#d66a5f); color:#fff5f5; border:none; }
- button.action-caution:hover { background:linear-gradient(135deg,#c75656,#e57b70); }
- button.action-dreamy { background:linear-gradient(135deg,#4e7ac7,#8b7cf6); color:#f7f7ff; border:none; }
- button.action-dreamy:hover { background:linear-gradient(135deg,#5c89d4,#9a8cff); }
- .stack { display:grid; gap:12px; }
- .field-card { display:grid; gap:10px; }
- .inline-actions { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; align-items:center; }
- .label { font-size:12px; color:var(--text-muted); margin-bottom:6px; display:block; }
- .fieldline { display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center; }
- .iconbtn { width:44px; padding:12px 0; }
- .savebtn { width:100%; background:var(--accent); color:var(--button-text); border:none; }
- .savebtn:hover { background:var(--accent-hover); }
- .themebtn { padding:10px 14px; }
- #app { display:none; }
- #status { white-space:pre-wrap; color:var(--text-muted); min-height:1.4em; }
- .terminal { background:var(--terminal-bg); border:1px solid var(--terminal-border); border-radius:12px; padding:14px; min-height:180px; max-height:320px; overflow:auto; font:14px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; }
- .term-entry { margin:0 0 12px; }
- .term-cmd { color:var(--terminal-cmd); }
- .term-out { white-space:pre-wrap; color:var(--text); }
- .term-out.err { color:var(--status-red); }
- .stats-shell { display:grid; gap:12px; }
- .stats-empty, .stats-error { background:var(--surface2); border:1px dashed var(--border); border-radius:12px; padding:16px; color:var(--text-muted); }
- .stats-error { color:var(--status-red); }
- .actions-bar { display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:12px; }
- .actions-group { display:flex; gap:10px; flex-wrap:wrap; }
- .actions-group.left { justify-self:start; }
- .actions-group.center { justify-self:center; }
- .actions-group.right { justify-self:end; }
- .hud-grid-1 { display:grid; grid-template-columns:1fr; gap:12px; }
- .hud-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
- .hud-grid-2 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
- .hud-grid-3 { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
- .hud-card { background:linear-gradient(180deg,var(--surface2),var(--surface1)); border:1px solid var(--border); border-radius:14px; padding:14px; display:grid; gap:12px; }
- .hud-card h3 { margin:0; font-size:13px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.08em; }
- .hud-row { display:grid; gap:10px; }
- .hud-kpi { display:flex; align-items:flex-end; justify-content:space-between; gap:10px; }
- .hud-value { font-size:28px; font-weight:700; line-height:1; color:var(--text); }
- .hud-sub { font-size:12px; color:var(--text-muted); }
- .meter { height:10px; background:var(--background); border:1px solid var(--border); border-radius:999px; overflow:hidden; }
- .meter-fill { height:100%; border-radius:999px; background:linear-gradient(90deg,var(--accent),var(--accent-hover)); }
- .meter-fill.warn { background:linear-gradient(90deg,#d7a531,#e9bf52); }
- .meter-fill.bad { background:linear-gradient(90deg,#bf4b4b,#dd6a6a); }
- .metric-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
- .core-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
- .core-grid .hud-row { align-content:start; }
- .core-metrics { grid-template-columns:repeat(4,minmax(0,1fr)); }
- .metric { background:rgba(255,255,255,.45); border:1px solid var(--border); border-radius:12px; padding:10px; }
- :root[data-theme="dark"] .metric { background:rgba(0,0,0,.16); }
- .metric-label { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; }
- .metric-value { margin-top:4px; font-size:16px; font-weight:700; color:var(--text); }
- @media (max-width:760px) {
- body { font-size:15px; }
- main { padding:16px; }
- .card { padding:16px; margin-bottom:14px; }
- .row, .row3, .row-command, .metric-grid, .hud-grid-1, .hud-grid-2, .hud-grid-3, .core-grid, .core-metrics { grid-template-columns:1fr; }
- .inline-actions { grid-template-columns:minmax(0,1fr) auto auto; }
- .fieldline { grid-template-columns:minmax(0,1fr) auto; align-items:center; }
- .row-command button { width:100%; }
- .fieldline .iconbtn { width:44px; }
- .row-command button { justify-self:stretch; }
- .hud-kpi { align-items:flex-start; flex-direction:column; }
- .quick { gap:8px; }
- #quickCommandsPanel .quick { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
- .quick button, .themebtn { width:100%; }
- .actions-bar { display:grid; grid-template-columns:1fr; gap:10px; }
- .actions-group { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
- .actions-group.left, .actions-group.center, .actions-group.right { justify-self:stretch; }
- .actions-group.center { grid-template-columns:1fr; justify-items:center; }
- #actionsPanel .actions-group button { width:100%; }
- #actionsPanel .actions-group.center button { width:auto; min-width:56px; }
- .row > div + div, .row3 > div + div { margin-top:4px; }
- .row > div[style*="align-self:end"] { padding-top:4px; }
- }
- </style>
- </head>
- <body>
- <main>
- <section class="card" id="login">
- <h1>Repeater Config</h1>
- <p>Use the repeater admin password to unlock the command console. Accept the self-signed certificate warning in your browser first.</p>
- <div class="row" style="margin-top:14px">
- <input id="password" type="password" placeholder="Admin password">
- <button id="loginBtn">Unlock</button>
- </div>
- <div id="status"></div>
- </section>
- <section class="card" id="actionsPanel" style="display:none">
- <h2>Actions</h2>
- <div class="actions-bar">
- <div class="actions-group left">
- <button id="advertBtn" class="action-advert">Advert</button>
- <button id="otaBtn" class="action-dreamy">Start OTA</button>
- </div>
- <div class="actions-group center">
- <button id="themeToggle" class="themebtn" aria-label="Toggle theme" title="Toggle theme">☾</button>
- </div>
- <div class="actions-group right">
- <button id="rebootBtn" class="action-caution">Reboot</button>
- <button id="logoutBtn" class="themebtn action-caution">Logout</button>
- </div>
- </div>
- </section>
- <section class="card" id="quickCommandsPanel" style="display:none">
- <h2>Quick "get" Commands</h2>
- <div class="stack">
- <div>
- <span class="label">WiFi</span>
- <div class="quick">
- <button data-cmd="get wifi.status">wifi.status</button>
- <button data-cmd="get wifi.powersaving">wifi.powersaving</button>
- </div>
- </div>
- <div>
- <span class="label">MQTT</span>
- <div class="quick">
- <button data-cmd="get mqtt.status">mqtt.status</button>
- <button data-cmd="get mqtt.iata">mqtt.iata</button>
- <button data-cmd="get mqtt.owner">mqtt.owner</button>
- <button data-cmd="get mqtt.email">mqtt.email</button>
- </div>
- </div>
- </div>
- </section>
- <section class="card" id="cliPanel">
- <h2>Run CLI Command</h2>
- <div class="row-command">
- <input id="command" placeholder="get mqtt.status">
- <button id="runBtn">Run</button>
- </div>
- <p>Only the allowlisted commands exposed by this panel will run here.</p>
- <div id="reply" class="terminal"></div>
- </section>
- <section class="card" id="repeaterSettingsPanel" style="display:none">
- <h2>Repeater Settings</h2>
- <div class="stack">
- <div class="field-card">
- <label class="label" for="nodeName">Device Name</label>
- <div class="inline-actions">
- <input id="nodeName" placeholder="MeshCore-HOWL">
- <button class="iconbtn" data-load-cmd="get name" data-load-input="nodeName" title="Refresh device name">↻</button>
- <button class="savebtn" data-prefix="set name " data-input="nodeName">Save</button>
- </div>
- </div>
- <div class="row">
- <div class="field-card">
- <div>
- <label class="label" for="nodeLat">Latitude</label>
- <div class="fieldline">
- <input id="nodeLat" placeholder="0.0">
- <button class="iconbtn" data-load-cmd="get lat" data-load-input="nodeLat" title="Refresh latitude">↻</button>
- </div>
- </div>
- <button class="savebtn" data-prefix="set lat " data-input="nodeLat">Save latitude</button>
- </div>
- <div class="field-card">
- <div>
- <label class="label" for="nodeLon">Longitude</label>
- <div class="fieldline">
- <input id="nodeLon" placeholder="0.0">
- <button class="iconbtn" data-load-cmd="get lon" data-load-input="nodeLon" title="Refresh longitude">↻</button>
- </div>
- </div>
- <button class="savebtn" data-prefix="set lon " data-input="nodeLon">Save longitude</button>
- </div>
- </div>
- <div class="field-card">
- <label class="label" for="guestPassword">Guest Password</label>
- <div class="inline-actions">
- <input id="guestPassword" type="password" placeholder="new guest password">
- <span></span>
- <button class="savebtn" data-prefix="set guest.password " data-input="guestPassword">Save</button>
- </div>
- </div>
- <div class="field-card">
- <label class="label" for="privateKey">Private Key</label>
- <div class="inline-actions">
- <input id="privateKey" placeholder="64-hex-char private key">
- <span></span>
- <button class="savebtn" data-prefix="set prv.key " data-input="privateKey">Save</button>
- </div>
- <p>Changing the private key requires a reboot to apply.</p>
- </div>
- <div class="row3">
- <div class="field-card">
- <div>
- <label class="label" for="advertInterval">Advert Interval (minutes)</label>
- <div class="fieldline">
- <input id="advertInterval" placeholder="2">
- <button class="iconbtn" data-load-cmd="get advert.interval" data-load-input="advertInterval" title="Refresh advert interval">↻</button>
- </div>
- </div>
- <button class="savebtn" data-prefix="set advert.interval " data-input="advertInterval">Save advert interval</button>
- </div>
- <div class="field-card">
- <div>
- <label class="label" for="floodInterval">Flood Interval (hours)</label>
- <div class="fieldline">
- <input id="floodInterval" placeholder="12">
- <button class="iconbtn" data-load-cmd="get flood.advert.interval" data-load-input="floodInterval" title="Refresh flood interval">↻</button>
- </div>
- </div>
- <button class="savebtn" data-prefix="set flood.advert.interval " data-input="floodInterval">Save flood interval</button>
- </div>
- <div class="field-card">
- <div>
- <label class="label" for="floodMax">Flood Max</label>
- <div class="fieldline">
- <input id="floodMax" placeholder="64">
- <button class="iconbtn" data-load-cmd="get flood.max" data-load-input="floodMax" title="Refresh flood max">↻</button>
- </div>
- </div>
- <button class="savebtn" data-prefix="set flood.max " data-input="floodMax">Save flood max</button>
- </div>
- </div>
- <div class="field-card">
- <label class="label" for="ownerInfo">Owner Info</label>
- <div class="inline-actions">
- <textarea id="ownerInfo" placeholder="Free text shown in owner info"></textarea>
- <button class="iconbtn" data-load-cmd="get owner.info" data-load-input="ownerInfo" data-load-format="multiline" title="Refresh owner info">↻</button>
- <button id="saveOwnerInfo" class="savebtn">Save</button>
- </div>
- </div>
- </div>
- </section>
- <section class="card" id="mqttSettingsPanel" style="display:none">
- <h2>MQTT Settings</h2>
- <div class="stack">
- <div class="field-card">
- <label class="label" for="mqttIata">MQTT IATA</label>
- <div class="inline-actions">
- <select id="mqttIata">
- <optgroup label="ACT">
- <option value="CBR">CBR - Canberra</option>
- </optgroup>
- <optgroup label="New South Wales">
- <option value="ABX">ABX - Albury</option>
- <option value="ARM">ARM - Armidale</option>
- <option value="BHQ">BHQ - Broken Hill</option>
- <option value="BNK">BNK - Ballina</option>
- <option value="CFS">CFS - Coffs Harbour</option>
- <option value="DBO">DBO - Dubbo</option>
- <option value="GFF">GFF - Griffith</option>
- <option value="GFN">GFN - Grafton</option>
- <option value="LDH">LDH - Lord Howe Island</option>
- <option value="LSY">LSY - Lismore</option>
- <option value="MIM">MIM - Merimbula</option>
- <option value="MRZ">MRZ - Moree</option>
- <option value="MYA">MYA - Moruya</option>
- <option value="NTL">NTL - Newcastle</option>
- <option value="OAG">OAG - Orange</option>
- <option value="PQQ">PQQ - Port Macquarie</option>
- <option value="SYD">SYD - Sydney</option>
- <option value="WGA">WGA - Wagga Wagga</option>
- </optgroup>
- <optgroup label="Queensland">
- <option value="ABM">ABM - Bamaga</option>
- <option value="BNE">BNE - Brisbane</option>
- <option value="CNS">CNS - Cairns</option>
- <option value="HTI">HTI - Hamilton Island</option>
- <option value="HVB">HVB - Hervey Bay</option>
- <option value="ISA">ISA - Mount Isa</option>
- <option value="LRE">LRE - Longreach</option>
- <option value="MCY">MCY - Sunshine Coast</option>
- <option value="MKY">MKY - Mackay</option>
- <option value="OOL">OOL - Gold Coast</option>
- <option value="PPP">PPP - Proserpine</option>
- <option value="ROK">ROK - Rockhampton</option>
- <option value="TSV">TSV - Townsville</option>
- <option value="WEI">WEI - Weipa</option>
- <option value="WTB">WTB - Toowoomba Wellcamp</option>
- </optgroup>
- <optgroup label="South Australia">
- <option value="ADL">ADL - Adelaide</option>
- <option value="KGC">KGC - Kingscote</option>
- <option value="MGB">MGB - Mount Gambier</option>
- <option value="PLO">PLO - Port Lincoln</option>
- <option value="WYA">WYA - Whyalla</option>
- </optgroup>
- <optgroup label="Tasmania">
- <option value="BWT">BWT - Burnie</option>
- <option value="DPO">DPO - Devonport</option>
- <option value="FLS">FLS - Flinders Island</option>
- <option value="HBA">HBA - Hobart</option>
- <option value="KNS">KNS - King Island</option>
- <option value="LST">LST - Launceston</option>
- </optgroup>
- <optgroup label="Victoria">
- <option value="AVV">AVV - Avalon</option>
- <option value="GEX">GEX - Geelong West</option>
- <option value="MEB">MEB - Essendon Fields</option>
- <option value="MEL" selected>MEL - Melbourne</option>
- <option value="MQL">MQL - Mildura</option>
- </optgroup>
- </select>
- <button class="iconbtn" data-load-cmd="get mqtt.iata" data-load-input="mqttIata" title="Refresh MQTT IATA">↻</button>
- <button class="savebtn" data-prefix="set mqtt.iata " data-input="mqttIata">Save</button>
- </div>
- </div>
- <div class="field-card">
- <label class="label" for="mqttOwner">MQTT Owner</label>
- <div class="inline-actions">
- <input id="mqttOwner" placeholder="64-hex-char owner key">
- <button class="iconbtn" data-load-cmd="get mqtt.owner" data-load-input="mqttOwner" title="Refresh MQTT owner">↻</button>
- <button class="savebtn" data-prefix="set mqtt.owner " data-input="mqttOwner">Save</button>
- </div>
- </div>
- <div class="field-card">
- <label class="label" for="mqttEmail">MQTT Email</label>
- <div class="inline-actions">
- <input id="mqttEmail" type="email" placeholder="owner@example.com">
- <button class="iconbtn" data-load-cmd="get mqtt.email" data-load-input="mqttEmail" title="Refresh MQTT email">↻</button>
- <button class="savebtn" data-prefix="set mqtt.email " data-input="mqttEmail">Save</button>
- </div>
- </div>
- </div>
- </section>
- <section class="card" id="statsPanel" style="display:none">
- <h2>Stats</h2>
- <div class="quick" style="margin-bottom:12px">
- <button id="getStatsBtn">Get Stats</button>
- </div>
- <div id="statsDashboard" class="stats-shell">
- <div class="stats-empty">Press Get Stats to load the dashboard.</div>
- </div>
- </section>
- </main>
- <script>
- let token = "";
- let commandQueue = Promise.resolve();
- const statusEl = document.getElementById("status");
- const replyEl = document.getElementById("reply");
- const themeToggleEl = document.getElementById("themeToggle");
- const rootEl = document.documentElement;
- function getPreferredTheme() {
- const saved = localStorage.getItem("repeater-theme");
- if (saved === "light" || saved === "dark") return saved;
- return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
- }
- function applyTheme(theme) {
- rootEl.dataset.theme = theme;
- themeToggleEl.textContent = theme === "dark" ? "☀" : "☾";
- themeToggleEl.title = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
- themeToggleEl.setAttribute("aria-label", themeToggleEl.title);
- }
- function toggleTheme() {
- const next = rootEl.dataset.theme === "dark" ? "light" : "dark";
- localStorage.setItem("repeater-theme", next);
- applyTheme(next);
- }
- applyTheme(getPreferredTheme());
- themeToggleEl.onclick = toggleTheme;
- function appendHistory(cmd, text, ok) {
- const entry = document.createElement("div");
- entry.className = "term-entry";
- const cmdLine = document.createElement("div");
- cmdLine.className = "term-cmd";
- cmdLine.textContent = "> " + cmd;
- const outLine = document.createElement("div");
- outLine.className = "term-out" + (ok ? "" : " err");
- outLine.textContent = text && text.length ? text : "OK";
- entry.appendChild(cmdLine);
- entry.appendChild(outLine);
- replyEl.appendChild(entry);
- replyEl.scrollTop = replyEl.scrollHeight;
- }
- function parseReplyValue(text) {
- return (text || "").replace(/^>\s*/, "").trim();
- }
- function clamp(value, min, max) {
- return Math.min(max, Math.max(min, value));
- }
- function pctRange(value, min, max) {
- if (!Number.isFinite(value) || max <= min) return 0;
- return clamp(((value - min) * 100) / (max - min), 0, 100);
- }
- function pctRatio(value, total) {
- if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return 0;
- return clamp((value * 100) / total, 0, 100);
- }
- function escapeHtml(value) {
- return String(value == null ? "" : value)
- .replace(/&/g, "&")
- .replace(/</g, "<")
- .replace(/>/g, ">")
- .replace(/\"/g, """)
- .replace(/'/g, "'");
- }
- function formatDuration(seconds) {
- if (!Number.isFinite(seconds)) return "--";
- const secs = Math.max(0, Math.round(seconds));
- if (secs < 3600) {
- return Math.floor(secs / 60) + "m " + (secs % 60) + "s";
- }
- if (secs < 86400) {
- return Math.floor(secs / 3600) + "h " + Math.floor((secs % 3600) / 60) + "m";
- }
- return Math.floor(secs / 86400) + "d " + Math.floor((secs % 86400) / 3600) + "h";
- }
- function formatBytes(value) {
- if (!Number.isFinite(value)) return "--";
- const abs = Math.abs(value);
- if (abs >= 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + " MB";
- if (abs >= 1024) return (value / 1024).toFixed(1) + " KB";
- return Math.round(value) + " B";
- }
- function toneForPercent(percent, invert) {
- if (invert) {
- if (percent >= 70) return "bad";
- if (percent >= 35) return "warn";
- return "";
- }
- if (percent >= 75) return "";
- if (percent >= 40) return "warn";
- return "bad";
- }
- function renderMeter(label, value, percent, note, invert) {
- const pct = clamp(Math.round(percent), 0, 100);
- const tone = toneForPercent(pct, invert);
- return `<div class="hud-row">
- <div class="hud-kpi">
- <div>
- <div class="metric-label">${escapeHtml(label)}</div>
- <div class="hud-value">${escapeHtml(value)}</div>
- </div>
- <div class="hud-sub">${escapeHtml(note)}</div>
- </div>
- <div class="meter"><div class="meter-fill${tone ? " " + tone : ""}" style="width:${pct}%"></div></div>
- </div>`;
- }
- function renderMetric(label, value) {
- return `<div class="metric">
- <div class="metric-label">${escapeHtml(label)}</div>
- <div class="metric-value">${escapeHtml(value)}</div>
- </div>`;
- }
- function renderMissingCard(title, message) {
- return `<section class="hud-card">
- <h3>${escapeHtml(title)}</h3>
- <div class="stats-empty">${escapeHtml(message)}</div>
- </section>`;
- }
- function parseJsonReply(text) {
- const value = parseReplyValue(text);
- if (!value) return null;
- try {
- return JSON.parse(value);
- } catch (_) {
- return null;
- }
- }
- function parseKeyedReply(text, keys) {
- const value = parseReplyValue(text);
- if (!value) return null;
- const found = [];
- for (const key of keys) {
- const marker = key + ":";
- const index = value.indexOf(marker);
- if (index >= 0) {
- found.push({ key, index });
- }
- }
- if (!found.length) return null;
- found.sort((a, b) => a.index - b.index);
- const parsed = {};
- for (let i = 0; i < found.length; i++) {
- const current = found[i];
- const start = current.index + current.key.length + 1;
- const end = i + 1 < found.length ? found[i + 1].index : value.length;
- parsed[current.key] = value.slice(start, end).trim();
- }
- return parsed;
- }
- function parseWifiStatusReply(text) {
- const parsed = parseKeyedReply(text, ["ssid", "status", "code", "state", "ip", "rssi", "quality", "signal"]);
- if (!parsed) return null;
- const rssi = Number.parseInt(parsed.rssi, 10);
- const quality = Number.parseInt(String(parsed.quality || "").replace("%", ""), 10);
- const code = Number.parseInt(parsed.code, 10);
- return {
- ssid: parsed.ssid || "-",
- status: parsed.status || "unknown",
- code: Number.isFinite(code) ? code : null,
- state: parsed.state || "unknown",
- ip: parsed.ip || "--",
- rssi: Number.isFinite(rssi) ? rssi : null,
- quality: Number.isFinite(quality) ? quality : null,
- signal: parsed.signal || "--"
- };
- }
- function renderCoreCard(core) {
- const batteryPct = pctRange(core.battery_mv, 3000, 4200);
- const queuePct = pctRange(core.queue_len, 0, 12);
- const errorsPct = core.errors > 0 ? 100 : 0;
- return `<section class="hud-card">
- <h3>Core</h3>
- <div class="core-grid">
- ${renderMeter("Battery", Math.round(batteryPct) + "%", batteryPct, (core.battery_mv || 0) + " mV", false)}
- ${renderMeter("Queue", String(core.queue_len ?? 0), queuePct, "outbound packets", true)}
- ${renderMeter("Errors", String(core.errors ?? 0), errorsPct, "sticky error flags", true)}
- </div>
- <div class="metric-grid core-metrics">
- ${renderMetric("Uptime", formatDuration(core.uptime_secs))}
- ${renderMetric("Battery", (core.battery_mv || 0) + " mV")}
- ${renderMetric("Queue", String(core.queue_len ?? 0))}
- ${renderMetric("Errors", String(core.errors ?? 0))}
- </div>
- </section>`;
- }
- function renderWifiCard(wifi, powersave) {
- const qualityPct = Number.isFinite(wifi.quality) ? clamp(wifi.quality, 0, 100) : pctRange(wifi.rssi, -100, -50);
- const rssiPct = pctRange(wifi.rssi, -100, -50);
- const statusNote = wifi.status === "connected" ? (wifi.signal || "linked") : (wifi.state || "idle");
- return `<section class="hud-card">
- <h3>Wi-Fi</h3>
- ${renderMeter("Signal Quality", Number.isFinite(wifi.quality) ? wifi.quality + "%" : "--", qualityPct, statusNote, false)}
- ${renderMeter("RSSI", Number.isFinite(wifi.rssi) ? wifi.rssi + " dBm" : "--", rssiPct, wifi.ssid || "-", false)}
- <div class="metric-grid">
- ${renderMetric("Status", wifi.status || "--")}
- ${renderMetric("State", wifi.state || "--")}
- ${renderMetric("SSID", wifi.ssid || "-")}
- ${renderMetric("IP", wifi.ip || "--")}
- ${renderMetric("Power Save", powersave || "--")}
- ${renderMetric("Code", wifi.code == null ? "--" : wifi.code)}
- </div>
- </section>`;
- }
- function renderRadioCard(radio) {
- const rssiPct = pctRange(radio.last_rssi, -120, -20);
- const snrPct = pctRange(radio.last_snr, -20, 20);
- const noisePct = pctRange(radio.noise_floor, -130, -60);
- const totalAir = (radio.tx_air_secs || 0) + (radio.rx_air_secs || 0);
- const txShare = pctRatio(radio.tx_air_secs || 0, totalAir);
- return `<section class="hud-card">
- <h3>Radio</h3>
- ${renderMeter("RSSI", (radio.last_rssi ?? "--") + " dBm", rssiPct, "signal strength", false)}
- ${renderMeter("SNR", Number.isFinite(radio.last_snr) ? radio.last_snr.toFixed(1) + " dB" : "--", snrPct, "link quality", false)}
- ${renderMeter("Noise Floor", (radio.noise_floor ?? "--") + " dBm", noisePct, "ambient RF", false)}
- <div class="metric-grid">
- ${renderMetric("TX Air", String(radio.tx_air_secs ?? 0) + " s")}
- ${renderMetric("RX Air", String(radio.rx_air_secs ?? 0) + " s")}
- ${renderMetric("TX Share", Math.round(txShare) + "%")}
- ${renderMetric("Total Air", String(totalAir) + " s")}
- </div>
- </section>`;
- }
- function renderPacketsCard(packets) {
- const sent = packets.sent || 0;
- const recv = packets.recv || 0;
- const floodTx = packets.flood_tx || 0;
- const directTx = packets.direct_tx || 0;
- const floodRx = packets.flood_rx || 0;
- const directRx = packets.direct_rx || 0;
- return `<section class="hud-card">
- <h3>Packets</h3>
- ${renderMeter("TX Flood Share", Math.round(pctRatio(floodTx, sent)) + "%", pctRatio(floodTx, sent), floodTx + " flood / " + directTx + " direct", false)}
- ${renderMeter("RX Flood Share", Math.round(pctRatio(floodRx, recv)) + "%", pctRatio(floodRx, recv), floodRx + " flood / " + directRx + " direct", false)}
- <div class="metric-grid">
- ${renderMetric("Sent", sent)}
- ${renderMetric("Recv", recv)}
- ${renderMetric("TX Direct", directTx)}
- ${renderMetric("RX Direct", directRx)}
- ${renderMetric("Recv Errors", packets.recv_errors || 0)}
- ${renderMetric("Balance", recv - sent)}
- </div>
- </section>`;
- }
- function renderMemoryCard(memory) {
- const heapFree = memory.heap_free || 0;
- const heapMax = memory.heap_max || 0;
- const psramFree = memory.psram_free || 0;
- const psramMax = memory.psram_max || 0;
- return `<section class="hud-card">
- <h3>Memory</h3>
- ${renderMeter("Heap Largest Block", formatBytes(heapMax), pctRatio(heapMax, heapFree), "largest alloc vs free", false)}
- ${renderMeter("PSRAM Largest Block", formatBytes(psramMax), pctRatio(psramMax, psramFree), "largest alloc vs free", false)}
- <div class="metric-grid">
- ${renderMetric("Heap Free", formatBytes(heapFree))}
- ${renderMetric("Heap Min", formatBytes(memory.heap_min || 0))}
- ${renderMetric("PSRAM Free", formatBytes(psramFree))}
- ${renderMetric("PSRAM Min", formatBytes(memory.psram_min || 0))}
- </div>
- </section>`;
- }
- function renderStatsDashboard(results, errors) {
- const dashboardEl = document.getElementById("statsDashboard");
- const notices = errors.length ? `<div class="stats-error">${errors.map((item) => escapeHtml(item)).join("<br>")}</div>` : "";
- const coreCards = [
- results.core ? renderCoreCard(results.core) : renderMissingCard("Core", "stats-core unavailable")
- ];
- const middleCards = [
- results.radio ? renderRadioCard(results.radio) : renderMissingCard("Radio", "stats-radio unavailable"),
- results.memory ? renderMemoryCard(results.memory) : renderMissingCard("Memory", "memory unavailable")
- ];
- const lowerCards = [
- results.wifi ? renderWifiCard(results.wifi, results.wifi_powersave) : renderMissingCard("Wi-Fi", "wifi.status unavailable"),
- results.packets ? renderPacketsCard(results.packets) : renderMissingCard("Packets", "stats-packets unavailable")
- ];
- dashboardEl.innerHTML = notices +
- `<div class="hud-grid-1">${coreCards.join("")}</div>` +
- `<div class="hud-grid-2">${middleCards.join("")}</div>` +
- `<div class="hud-grid-2">${lowerCards.join("")}</div>`;
- }
- function showAuthedUi(show) {
- document.getElementById("login").style.display = show ? "none" : "block";
- document.getElementById("cliPanel").style.display = show ? "block" : "none";
- document.getElementById("quickCommandsPanel").style.display = show ? "block" : "none";
- document.getElementById("mqttSettingsPanel").style.display = show ? "block" : "none";
- document.getElementById("statsPanel").style.display = show ? "block" : "none";
- document.getElementById("actionsPanel").style.display = show ? "block" : "none";
- document.getElementById("repeaterSettingsPanel").style.display = show ? "block" : "none";
- if (!show) {
- commandQueue = Promise.resolve();
- document.getElementById("password").value = "";
- statusEl.textContent = "";
- document.getElementById("statsDashboard").innerHTML = '<div class="stats-empty">Press Get Stats to load the dashboard.</div>';
- }
- }
- function queueCommand(task) {
- const next = commandQueue.then(task, task);
- commandQueue = next.catch(() => {});
- return next;
- }
- async function runCommand(cmd, options = {}) {
- if (!token) return { ok:false, text:"" };
- const recordHistory = options.recordHistory !== false;
- const updateInput = options.updateInput !== false;
- return queueCommand(async () => {
- if (updateInput) {
- document.getElementById("command").value = cmd;
- }
- const res = await fetch("/api/command", { method:"POST", headers:{ "X-Auth-Token": token }, body: cmd });
- const text = await res.text();
- if (recordHistory) {
- appendHistory(cmd, text, res.ok);
- }
- return { ok:res.ok, text };
- });
- }
- function runPrefixed(prefix, inputId) {
- const value = document.getElementById(inputId).value;
- runCommand(prefix + value);
- }
- async function loadField(cmd, inputId, format) {
- const result = await runCommand(cmd);
- if (!result.ok) return;
- let value = parseReplyValue(result.text);
- if (format === "multiline") {
- value = value.replace(/\|/g, "\n");
- }
- document.getElementById(inputId).value = value;
- }
- async function fetchJson(path) {
- if (!token) return null;
- return queueCommand(async () => {
- const res = await fetch(path, { headers:{ "X-Auth-Token": token } });
- const text = await res.text();
- if (!res.ok) {
- throw new Error(text || "request failed");
- }
- return JSON.parse(text);
- });
- }
- function applyBootstrapData(data) {
- if (!data) return;
- if (typeof data.name === "string") document.getElementById("nodeName").value = data.name;
- if (typeof data.mqtt_iata === "string" && data.mqtt_iata.length) document.getElementById("mqttIata").value = data.mqtt_iata;
- if (typeof data.mqtt_owner === "string") document.getElementById("mqttOwner").value = data.mqtt_owner;
- if (typeof data.mqtt_email === "string") document.getElementById("mqttEmail").value = data.mqtt_email;
- if (typeof data.advert_interval === "string") document.getElementById("advertInterval").value = data.advert_interval;
- if (typeof data.flood_interval === "string") document.getElementById("floodInterval").value = data.flood_interval;
- if (typeof data.flood_max === "string") document.getElementById("floodMax").value = data.flood_max;
- }
- async function refreshStats() {
- const getStatsBtn = document.getElementById("getStatsBtn");
- getStatsBtn.disabled = true;
- getStatsBtn.textContent = "Loading...";
- document.getElementById("statsDashboard").innerHTML = '<div class="stats-empty">Loading dashboard...</div>';
- const results = {};
- const errors = [];
- try {
- const payload = await fetchJson("/api/stats");
- if (!payload) throw new Error("no stats payload");
- if (typeof payload.wifi === "string") {
- const parsed = parseWifiStatusReply(payload.wifi);
- if (parsed != null) results.wifi = parsed;
- else errors.push("get wifi.status: invalid reply");
- }
- if (typeof payload.wifi_powersave === "string") {
- results.wifi_powersave = parseReplyValue(payload.wifi_powersave) || "--";
- }
- for (const key of ["core", "radio", "packets", "memory"]) {
- if (typeof payload[key] === "string") {
- const parsed = parseJsonReply(payload[key]);
- if (parsed != null) results[key] = parsed;
- else errors.push(key + ": invalid reply");
- }
- }
- } catch (error) {
- errors.push(error && error.message ? error.message : "stats request failed");
- }
- renderStatsDashboard(results, errors);
- getStatsBtn.disabled = false;
- getStatsBtn.textContent = "Get Stats";
- }
- document.getElementById("loginBtn").onclick = async () => {
- const pwd = document.getElementById("password").value;
- const res = await fetch("/login", { method:"POST", body: pwd });
- const text = await res.text();
- if (!res.ok) {
- statusEl.textContent = text || "Access denied";
- return;
- }
- token = text.trim();
- showAuthedUi(true);
- try {
- applyBootstrapData(await fetchJson("/api/bootstrap"));
- } catch (_) {
- await Promise.all([
- loadField("get name", "nodeName"),
- loadField("get mqtt.iata", "mqttIata"),
- loadField("get mqtt.owner", "mqttOwner"),
- loadField("get mqtt.email", "mqttEmail"),
- loadField("get advert.interval", "advertInterval"),
- loadField("get flood.advert.interval", "floodInterval"),
- loadField("get flood.max", "floodMax")
- ]);
- }
- };
- document.getElementById("password").addEventListener("keydown", (event) => {
- if (event.key === "Enter") {
- event.preventDefault();
- document.getElementById("loginBtn").click();
- }
- });
- document.getElementById("runBtn").onclick = () => runCommand(document.getElementById("command").value);
- document.getElementById("getStatsBtn").onclick = () => refreshStats();
- document.getElementById("command").addEventListener("keydown", (event) => {
- if (event.key === "Enter") {
- event.preventDefault();
- document.getElementById("runBtn").click();
- }
- });
- document.querySelectorAll("[data-cmd]").forEach((btn) => btn.onclick = () => runCommand(btn.dataset.cmd));
- document.querySelectorAll("[data-prefix]").forEach((btn) => btn.onclick = () => runPrefixed(btn.dataset.prefix, btn.dataset.input));
- document.querySelectorAll("[data-load-cmd]").forEach((btn) => btn.onclick = () => loadField(btn.dataset.loadCmd, btn.dataset.loadInput, btn.dataset.loadFormat));
- document.getElementById("saveOwnerInfo").onclick = () => {
- const value = document.getElementById("ownerInfo").value.replace(/\n/g, "|");
- runCommand("set owner.info " + value);
- };
- document.getElementById("rebootBtn").onclick = async () => {
- if (confirm("Reboot the repeater now?")) {
- await runCommand("reboot");
- }
- };
- document.getElementById("advertBtn").onclick = async () => {
- if (confirm("Send an advert now?")) {
- await runCommand("advert");
- }
- };
- document.getElementById("otaBtn").onclick = async () => {
- if (confirm("Start OTA mode now?")) {
- await runCommand("start ota");
- }
- };
- document.getElementById("logoutBtn").onclick = () => {
- token = "";
- showAuthedUi(false);
- };
- showAuthedUi(false);
- </script>
- </body>
- </html>
- )HTML";
- } // namespace
- WebPanelServer::WebPanelServer()
- : _runner(nullptr), _server(nullptr), _token{0}, _route_context{this} {
- }
- void WebPanelServer::setCommandRunner(WebPanelCommandRunner* runner) {
- _runner = runner;
- }
- bool WebPanelServer::start() {
- if (_server != nullptr || _runner == nullptr) {
- return _server != nullptr;
- }
- if (_token[0] == 0) {
- refreshToken();
- }
- httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT();
- config.httpd.max_open_sockets = 2;
- config.httpd.max_uri_handlers = 5;
- config.httpd.max_resp_headers = 4;
- config.httpd.backlog_conn = 2;
- config.httpd.recv_wait_timeout = 2;
- config.httpd.send_wait_timeout = 2;
- config.httpd.stack_size = kWebServerStackSize;
- #if defined(ESP_IDF_VERSION_MAJOR) && ESP_IDF_VERSION_MAJOR >= 5
- config.servercert = reinterpret_cast<const uint8_t*>(mqtt_web_panel_cert::kServerCertPem);
- config.servercert_len = sizeof(mqtt_web_panel_cert::kServerCertPem);
- #else
- // IDF 4.x uses the misnamed CA slot for the server certificate.
- config.cacert_pem = reinterpret_cast<const uint8_t*>(mqtt_web_panel_cert::kServerCertPem);
- config.cacert_len = sizeof(mqtt_web_panel_cert::kServerCertPem);
- #endif
- config.prvtkey_pem = reinterpret_cast<const uint8_t*>(mqtt_web_panel_cert::kServerKeyPem);
- config.prvtkey_len = sizeof(mqtt_web_panel_cert::kServerKeyPem);
- esp_err_t rc = httpd_ssl_start(&_server, &config);
- if (rc != ESP_OK) {
- _server = nullptr;
- WEB_PANEL_LOG("server start failed rc=0x%x", static_cast<unsigned>(rc));
- return false;
- }
- httpd_uri_t index_uri = {.uri = "/", .method = HTTP_GET, .handler = &WebPanelServer::handleIndex, .user_ctx = &_route_context};
- httpd_uri_t login_uri = {.uri = "/login", .method = HTTP_POST, .handler = &WebPanelServer::handleLogin, .user_ctx = &_route_context};
- httpd_uri_t command_uri = {.uri = "/api/command", .method = HTTP_POST, .handler = &WebPanelServer::handleCommand, .user_ctx = &_route_context};
- httpd_uri_t bootstrap_uri = {.uri = "/api/bootstrap", .method = HTTP_GET, .handler = &WebPanelServer::handleBootstrap, .user_ctx = &_route_context};
- httpd_uri_t stats_uri = {.uri = "/api/stats", .method = HTTP_GET, .handler = &WebPanelServer::handleStats, .user_ctx = &_route_context};
- httpd_register_uri_handler(_server, &index_uri);
- httpd_register_uri_handler(_server, &login_uri);
- httpd_register_uri_handler(_server, &command_uri);
- httpd_register_uri_handler(_server, &bootstrap_uri);
- httpd_register_uri_handler(_server, &stats_uri);
- WEB_PANEL_LOG("server started on https://%s/", WiFi.localIP().toString().c_str());
- return true;
- }
- void WebPanelServer::stop() {
- if (_server != nullptr) {
- WEB_PANEL_LOG("server stopped");
- httpd_ssl_stop(_server);
- _server = nullptr;
- }
- _token[0] = 0;
- }
- bool WebPanelServer::isRunning() const {
- return _server != nullptr;
- }
- bool WebPanelServer::hasSessionToken() const {
- return _token[0] != 0;
- }
- esp_err_t WebPanelServer::handleIndex(httpd_req_t* req) {
- auto* ctx = static_cast<RouteContext*>(req->user_ctx);
- if (ctx == nullptr || ctx->self == nullptr) {
- return httpd_resp_send_500(req);
- }
- httpd_resp_set_type(req, "text/html; charset=utf-8");
- httpd_resp_set_hdr(req, "Cache-Control", "no-store");
- return httpd_resp_send(req, kWebPanelHtml, HTTPD_RESP_USE_STRLEN);
- }
- esp_err_t WebPanelServer::handleLogin(httpd_req_t* req) {
- auto* ctx = static_cast<RouteContext*>(req->user_ctx);
- if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) {
- return httpd_resp_send_500(req);
- }
- char* password = allocScratchBuffer(kWebPasswordBufferSize);
- if (password == nullptr) {
- return httpd_resp_send_500(req);
- }
- if (!ctx->self->readRequestBody(req, password, kWebPasswordBufferSize)) {
- freeScratchBuffer(password);
- return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request");
- }
- if (strcmp(password, ctx->self->_runner->getWebAdminPassword()) != 0) {
- freeScratchBuffer(password);
- WEB_PANEL_LOG("login denied");
- return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Bad password");
- }
- freeScratchBuffer(password);
- ctx->self->refreshToken();
- WEB_PANEL_LOG("login accepted");
- httpd_resp_set_type(req, "text/plain; charset=utf-8");
- httpd_resp_set_hdr(req, "Cache-Control", "no-store");
- return httpd_resp_sendstr(req, ctx->self->_token);
- }
- esp_err_t WebPanelServer::handleCommand(httpd_req_t* req) {
- auto* ctx = static_cast<RouteContext*>(req->user_ctx);
- if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) {
- return httpd_resp_send_500(req);
- }
- if (!ctx->self->isAuthorized(req)) {
- return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized");
- }
- char* command = allocScratchBuffer(kWebCommandBufferSize);
- char* reply = allocScratchBuffer(kWebReplyBufferSize);
- if (command == nullptr || reply == nullptr) {
- freeScratchBuffer(command);
- freeScratchBuffer(reply);
- return httpd_resp_send_500(req);
- }
- if (!ctx->self->readRequestBody(req, command, kWebCommandBufferSize)) {
- freeScratchBuffer(command);
- freeScratchBuffer(reply);
- return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request");
- }
- memset(reply, 0, kWebReplyBufferSize);
- ctx->self->_runner->runWebCommand(command, reply, kWebReplyBufferSize);
- httpd_resp_set_type(req, "text/plain; charset=utf-8");
- httpd_resp_set_hdr(req, "Cache-Control", "no-store");
- esp_err_t rc = httpd_resp_send(req, reply[0] ? reply : "OK", HTTPD_RESP_USE_STRLEN);
- freeScratchBuffer(command);
- freeScratchBuffer(reply);
- return rc;
- }
- esp_err_t WebPanelServer::handleBootstrap(httpd_req_t* req) {
- auto* ctx = static_cast<RouteContext*>(req->user_ctx);
- if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) {
- return httpd_resp_send_500(req);
- }
- if (!ctx->self->isAuthorized(req)) {
- return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized");
- }
- char* reply = allocScratchBuffer(kWebReplyBufferSize);
- char* json = allocScratchBuffer(kWebJsonBufferSize);
- if (reply == nullptr || json == nullptr) {
- freeScratchBuffer(reply);
- freeScratchBuffer(json);
- return httpd_resp_send_500(req);
- }
- const struct {
- const char* key;
- const char* command;
- } fields[] = {
- {"name", "get name"},
- {"mqtt_iata", "get mqtt.iata"},
- {"mqtt_owner", "get mqtt.owner"},
- {"mqtt_email", "get mqtt.email"},
- {"advert_interval", "get advert.interval"},
- {"flood_interval", "get flood.advert.interval"},
- {"flood_max", "get flood.max"},
- };
- size_t offset = 0;
- json[offset++] = '{';
- json[offset] = 0;
- for (size_t i = 0; i < (sizeof(fields) / sizeof(fields[0])); ++i) {
- memset(reply, 0, kWebReplyBufferSize);
- ctx->self->_runner->runWebCommand(fields[i].command, reply, kWebReplyBufferSize);
- const char* value = reply;
- if (value[0] == '>' && value[1] == ' ') {
- value += 2;
- }
- if (strcmp(value, "-") == 0) {
- value = "";
- }
- if (!appendJsonField(json, kWebJsonBufferSize, offset, fields[i].key, value, i != 0)) {
- freeScratchBuffer(reply);
- freeScratchBuffer(json);
- return httpd_resp_send_500(req);
- }
- }
- if (offset + 2 >= kWebJsonBufferSize) {
- freeScratchBuffer(reply);
- freeScratchBuffer(json);
- return httpd_resp_send_500(req);
- }
- json[offset++] = '}';
- json[offset] = 0;
- httpd_resp_set_type(req, "application/json; charset=utf-8");
- httpd_resp_set_hdr(req, "Cache-Control", "no-store");
- esp_err_t rc = httpd_resp_send(req, json, HTTPD_RESP_USE_STRLEN);
- freeScratchBuffer(reply);
- freeScratchBuffer(json);
- return rc;
- }
- esp_err_t WebPanelServer::handleStats(httpd_req_t* req) {
- auto* ctx = static_cast<RouteContext*>(req->user_ctx);
- if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) {
- return httpd_resp_send_500(req);
- }
- if (!ctx->self->isAuthorized(req)) {
- return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized");
- }
- char* reply = allocScratchBuffer(kWebReplyBufferSize);
- char* json = allocScratchBuffer(kWebJsonBufferSize);
- if (reply == nullptr || json == nullptr) {
- freeScratchBuffer(reply);
- freeScratchBuffer(json);
- return httpd_resp_send_500(req);
- }
- const struct {
- const char* key;
- const char* command;
- } fields[] = {
- {"wifi", "get wifi.status"},
- {"wifi_powersave", "get wifi.powersaving"},
- {"core", "stats-core"},
- {"radio", "stats-radio"},
- {"packets", "stats-packets"},
- {"memory", "memory"},
- };
- size_t offset = 0;
- json[offset++] = '{';
- json[offset] = 0;
- for (size_t i = 0; i < (sizeof(fields) / sizeof(fields[0])); ++i) {
- memset(reply, 0, kWebReplyBufferSize);
- ctx->self->_runner->runWebCommand(fields[i].command, reply, kWebReplyBufferSize);
- if (!appendJsonField(json, kWebJsonBufferSize, offset, fields[i].key, reply, i != 0)) {
- freeScratchBuffer(reply);
- freeScratchBuffer(json);
- return httpd_resp_send_500(req);
- }
- }
- if (offset + 2 >= kWebJsonBufferSize) {
- freeScratchBuffer(reply);
- freeScratchBuffer(json);
- return httpd_resp_send_500(req);
- }
- json[offset++] = '}';
- json[offset] = 0;
- httpd_resp_set_type(req, "application/json; charset=utf-8");
- httpd_resp_set_hdr(req, "Cache-Control", "no-store");
- esp_err_t rc = httpd_resp_send(req, json, HTTPD_RESP_USE_STRLEN);
- freeScratchBuffer(reply);
- freeScratchBuffer(json);
- return rc;
- }
- bool WebPanelServer::readRequestBody(httpd_req_t* req, char* buffer, size_t buffer_size) const {
- if (req == nullptr || buffer == nullptr || buffer_size == 0 || req->content_len <= 0 ||
- req->content_len >= static_cast<int>(buffer_size)) {
- return false;
- }
- int remaining = req->content_len;
- int offset = 0;
- while (remaining > 0) {
- int read = httpd_req_recv(req, &buffer[offset], remaining);
- if (read <= 0) {
- return false;
- }
- offset += read;
- remaining -= read;
- }
- buffer[offset] = 0;
- return true;
- }
- void WebPanelServer::refreshToken() {
- uint8_t token[16];
- esp_fill_random(token, sizeof(token));
- bytesToHexUpper(token, sizeof(token), _token, sizeof(_token));
- }
- bool WebPanelServer::isAuthorized(httpd_req_t* req) const {
- if (_token[0] == 0) {
- return false;
- }
- char token[40];
- if (httpd_req_get_hdr_value_str(req, "X-Auth-Token", token, sizeof(token)) != ESP_OK) {
- return false;
- }
- return strcmp(token, _token) == 0;
- }
- #else
- WebPanelServer::WebPanelServer()
- : _runner(nullptr) {
- }
- void WebPanelServer::setCommandRunner(WebPanelCommandRunner* runner) {
- _runner = runner;
- }
- bool WebPanelServer::start() {
- return false;
- }
- void WebPanelServer::stop() {
- }
- bool WebPanelServer::isRunning() const {
- return false;
- }
- bool WebPanelServer::hasSessionToken() const {
- return false;
- }
- #endif
|