WebPanelServer.cpp 55 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294
  1. #include "WebPanelServer.h"
  2. #if defined(ESP_PLATFORM) && WITH_WEB_PANEL
  3. #include <Arduino.h>
  4. #include <esp_heap_caps.h>
  5. #include <esp_idf_version.h>
  6. #include <esp_system.h>
  7. #include <string.h>
  8. #include "../mqtt/generated/WebPanelCert.h"
  9. namespace {
  10. constexpr size_t kWebServerStackSize = 8192;
  11. constexpr size_t kWebPasswordBufferSize = 80;
  12. constexpr size_t kWebCommandBufferSize = 192;
  13. constexpr size_t kWebReplyBufferSize = 256;
  14. constexpr size_t kWebJsonBufferSize = 2048;
  15. #if defined(MQTT_DEBUG) && MQTT_DEBUG
  16. #define WEB_PANEL_LOG(fmt, ...) Serial.printf("[WEB] " fmt "\n", ##__VA_ARGS__)
  17. #else
  18. #define WEB_PANEL_LOG(...) do { } while (0)
  19. #endif
  20. char* allocScratchBuffer(size_t size) {
  21. void* ptr = heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
  22. if (ptr == nullptr) {
  23. ptr = heap_caps_malloc(size, MALLOC_CAP_8BIT);
  24. }
  25. return static_cast<char*>(ptr);
  26. }
  27. void freeScratchBuffer(void* ptr) {
  28. if (ptr != nullptr) {
  29. heap_caps_free(ptr);
  30. }
  31. }
  32. void bytesToHexUpper(const uint8_t* src, size_t len, char* dst, size_t dst_size) {
  33. if (dst_size == 0) {
  34. return;
  35. }
  36. size_t di = 0;
  37. for (size_t i = 0; i < len && di + 2 < dst_size; ++i) {
  38. snprintf(&dst[di], dst_size - di, "%02X", src[i]);
  39. di += 2;
  40. }
  41. dst[(di < dst_size) ? di : (dst_size - 1)] = 0;
  42. }
  43. size_t appendJsonEscaped(char* dst, size_t dst_size, size_t offset, const char* src) {
  44. if (dst == nullptr || dst_size == 0) {
  45. return offset;
  46. }
  47. for (size_t i = 0; src != nullptr && src[i] != 0 && offset + 2 < dst_size; ++i) {
  48. char c = src[i];
  49. if (c == '\\' || c == '"') {
  50. if (offset + 2 >= dst_size) break;
  51. dst[offset++] = '\\';
  52. dst[offset++] = c;
  53. } else if (c == '\n') {
  54. if (offset + 2 >= dst_size) break;
  55. dst[offset++] = '\\';
  56. dst[offset++] = 'n';
  57. } else if (c == '\r') {
  58. if (offset + 2 >= dst_size) break;
  59. dst[offset++] = '\\';
  60. dst[offset++] = 'r';
  61. } else if (c == '\t') {
  62. if (offset + 2 >= dst_size) break;
  63. dst[offset++] = '\\';
  64. dst[offset++] = 't';
  65. } else {
  66. dst[offset++] = c;
  67. }
  68. }
  69. dst[(offset < dst_size) ? offset : (dst_size - 1)] = 0;
  70. return offset;
  71. }
  72. bool appendJsonField(char* dst, size_t dst_size, size_t& offset, const char* key, const char* value, bool comma) {
  73. int written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s\"%s\":\"", comma ? "," : "", key);
  74. if (written < 0 || offset + static_cast<size_t>(written) >= dst_size) {
  75. return false;
  76. }
  77. offset += static_cast<size_t>(written);
  78. offset = appendJsonEscaped(dst, dst_size, offset, value != nullptr ? value : "");
  79. if (offset + 2 >= dst_size) {
  80. return false;
  81. }
  82. dst[offset++] = '"';
  83. dst[offset] = 0;
  84. return true;
  85. }
  86. bool appendJsonFieldRaw(char* dst, size_t dst_size, size_t& offset, const char* key, const char* value, bool comma) {
  87. int written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s\"%s\":", comma ? "," : "", key);
  88. if (written < 0 || offset + static_cast<size_t>(written) >= dst_size) {
  89. return false;
  90. }
  91. offset += static_cast<size_t>(written);
  92. if (value == nullptr) {
  93. value = "null";
  94. }
  95. written = snprintf(&dst[offset], (offset < dst_size) ? (dst_size - offset) : 0, "%s", value);
  96. if (written < 0 || offset + static_cast<size_t>(written) >= dst_size) {
  97. return false;
  98. }
  99. offset += static_cast<size_t>(written);
  100. return true;
  101. }
  102. const char kWebPanelHtml[] PROGMEM = R"HTML(
  103. <!doctype html>
  104. <html>
  105. <head>
  106. <meta charset="utf-8">
  107. <meta name="viewport" content="width=device-width,initial-scale=1">
  108. <title>Repeater Config</title>
  109. <style>
  110. :root {
  111. color-scheme: light;
  112. --accent:#2f8f4e;
  113. --accent-hover:#3fae61;
  114. --background:#f4f6f9;
  115. --text:#1f2937;
  116. --text-muted:#4b5563;
  117. --border:rgba(0, 0, 0, 0.08);
  118. --surface1:#ffffff;
  119. --surface2:#f0f3f7;
  120. --card-bg:#ffffff;
  121. --input-bg:#ffffff;
  122. --terminal-bg:#f0f3f7;
  123. --terminal-border:rgba(0, 0, 0, 0.08);
  124. --terminal-cmd:#2f8f4e;
  125. --status-red:#c94a4a;
  126. --button-text:#ffffff;
  127. --button-secondary-text:#1f2937;
  128. }
  129. :root[data-theme="dark"] {
  130. color-scheme: dark;
  131. --accent:#36a167;
  132. --accent-hover:#49c27d;
  133. --background:#222222;
  134. --text:#e6eaf0;
  135. --text-muted:#9aa4b2;
  136. --border:rgba(255, 255, 255, 0.08);
  137. --surface1:#303030;
  138. --surface2:#343434;
  139. --card-bg:#303030;
  140. --input-bg:#343434;
  141. --terminal-bg:#222222;
  142. --terminal-border:rgba(255, 255, 255, 0.08);
  143. --terminal-cmd:#8fd3ff;
  144. --status-red:#d45a5a;
  145. --button-text:#ffffff;
  146. --button-secondary-text:#e6eaf0;
  147. }
  148. html { min-height:100%; background:linear-gradient(180deg,var(--background),var(--surface2)); background-repeat:no-repeat; background-attachment:fixed; }
  149. 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; }
  150. main { max-width:920px; margin:0 auto; padding:24px; }
  151. .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; }
  152. .card { background:var(--card-bg); border:1px solid var(--border); border-radius:14px; padding:18px; margin-bottom:18px; }
  153. h1,h2,h3 { margin:0 0 12px; font-size:18px; }
  154. p { color:var(--text-muted); margin:8px 0 0; }
  155. 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; }
  156. textarea { min-height:100px; resize:vertical; }
  157. 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; }
  158. button:hover { background:var(--accent-hover); }
  159. .row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
  160. .row-command { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:center; }
  161. .row3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
  162. .quick { display:flex; flex-wrap:wrap; gap:10px; }
  163. .quick button, .iconbtn, .themebtn { background:var(--surface2); color:var(--button-secondary-text); border:1px solid var(--border); }
  164. .quick button:hover, .iconbtn:hover, .themebtn:hover { background:var(--surface1); }
  165. button.action-advert { background:linear-gradient(135deg,#d97706,#f59e0b); color:#fff7ed; border:none; }
  166. button.action-advert:hover { background:linear-gradient(135deg,#ea8f17,#ffb938); }
  167. button.action-caution { background:linear-gradient(135deg,#b94747,#d66a5f); color:#fff5f5; border:none; }
  168. button.action-caution:hover { background:linear-gradient(135deg,#c75656,#e57b70); }
  169. button.action-dreamy { background:linear-gradient(135deg,#4e7ac7,#8b7cf6); color:#f7f7ff; border:none; }
  170. button.action-dreamy:hover { background:linear-gradient(135deg,#5c89d4,#9a8cff); }
  171. .stack { display:grid; gap:12px; }
  172. .field-card { display:grid; gap:10px; }
  173. .inline-actions { display:grid; grid-template-columns:minmax(0,1fr) auto auto; gap:8px; align-items:center; }
  174. .label { font-size:12px; color:var(--text-muted); margin-bottom:6px; display:block; }
  175. .fieldline { display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center; }
  176. .iconbtn { width:44px; padding:12px 0; }
  177. .savebtn { width:100%; background:var(--accent); color:var(--button-text); border:none; }
  178. .savebtn:hover { background:var(--accent-hover); }
  179. .themebtn { padding:10px 14px; }
  180. #app { display:none; }
  181. #status { white-space:pre-wrap; color:var(--text-muted); min-height:1.4em; }
  182. .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; }
  183. .term-entry { margin:0 0 12px; }
  184. .term-cmd { color:var(--terminal-cmd); }
  185. .term-out { white-space:pre-wrap; color:var(--text); }
  186. .term-out.err { color:var(--status-red); }
  187. .stats-shell { display:grid; gap:12px; }
  188. .stats-empty, .stats-error { background:var(--surface2); border:1px dashed var(--border); border-radius:12px; padding:16px; color:var(--text-muted); }
  189. .stats-error { color:var(--status-red); }
  190. .actions-bar { display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:12px; }
  191. .actions-group { display:flex; gap:10px; flex-wrap:wrap; }
  192. .actions-group.left { justify-self:start; }
  193. .actions-group.center { justify-self:center; }
  194. .actions-group.right { justify-self:end; }
  195. .hud-grid-1 { display:grid; grid-template-columns:1fr; gap:12px; }
  196. .hud-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
  197. .hud-grid-2 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
  198. .hud-grid-3 { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
  199. .hud-card { background:linear-gradient(180deg,var(--surface2),var(--surface1)); border:1px solid var(--border); border-radius:14px; padding:14px; display:grid; gap:12px; }
  200. .hud-card h3 { margin:0; font-size:13px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.08em; }
  201. .hud-row { display:grid; gap:10px; }
  202. .hud-kpi { display:flex; align-items:flex-end; justify-content:space-between; gap:10px; }
  203. .hud-value { font-size:28px; font-weight:700; line-height:1; color:var(--text); }
  204. .hud-sub { font-size:12px; color:var(--text-muted); }
  205. .meter { height:10px; background:var(--background); border:1px solid var(--border); border-radius:999px; overflow:hidden; }
  206. .meter-fill { height:100%; border-radius:999px; background:linear-gradient(90deg,var(--accent),var(--accent-hover)); }
  207. .meter-fill.warn { background:linear-gradient(90deg,#d7a531,#e9bf52); }
  208. .meter-fill.bad { background:linear-gradient(90deg,#bf4b4b,#dd6a6a); }
  209. .metric-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
  210. .core-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
  211. .core-grid .hud-row { align-content:start; }
  212. .core-metrics { grid-template-columns:repeat(4,minmax(0,1fr)); }
  213. .metric { background:rgba(255,255,255,.45); border:1px solid var(--border); border-radius:12px; padding:10px; }
  214. :root[data-theme="dark"] .metric { background:rgba(0,0,0,.16); }
  215. .metric-label { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.06em; }
  216. .metric-value { margin-top:4px; font-size:16px; font-weight:700; color:var(--text); }
  217. @media (max-width:760px) {
  218. body { font-size:15px; }
  219. main { padding:16px; }
  220. .card { padding:16px; margin-bottom:14px; }
  221. .row, .row3, .row-command, .metric-grid, .hud-grid-1, .hud-grid-2, .hud-grid-3, .core-grid, .core-metrics { grid-template-columns:1fr; }
  222. .inline-actions { grid-template-columns:minmax(0,1fr) auto auto; }
  223. .fieldline { grid-template-columns:minmax(0,1fr) auto; align-items:center; }
  224. .row-command button { width:100%; }
  225. .fieldline .iconbtn { width:44px; }
  226. .row-command button { justify-self:stretch; }
  227. .hud-kpi { align-items:flex-start; flex-direction:column; }
  228. .quick { gap:8px; }
  229. #quickCommandsPanel .quick { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
  230. .quick button, .themebtn { width:100%; }
  231. .actions-bar { display:grid; grid-template-columns:1fr; gap:10px; }
  232. .actions-group { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
  233. .actions-group.left, .actions-group.center, .actions-group.right { justify-self:stretch; }
  234. .actions-group.center { grid-template-columns:1fr; justify-items:center; }
  235. #actionsPanel .actions-group button { width:100%; }
  236. #actionsPanel .actions-group.center button { width:auto; min-width:56px; }
  237. .row > div + div, .row3 > div + div { margin-top:4px; }
  238. .row > div[style*="align-self:end"] { padding-top:4px; }
  239. }
  240. </style>
  241. </head>
  242. <body>
  243. <main>
  244. <section class="card" id="login">
  245. <h1>Repeater Config</h1>
  246. <p>Use the repeater admin password to unlock the command console. Accept the self-signed certificate warning in your browser first.</p>
  247. <div class="row" style="margin-top:14px">
  248. <input id="password" type="password" placeholder="Admin password">
  249. <button id="loginBtn">Unlock</button>
  250. </div>
  251. <div id="status"></div>
  252. </section>
  253. <section class="card" id="actionsPanel" style="display:none">
  254. <h2>Actions</h2>
  255. <div class="actions-bar">
  256. <div class="actions-group left">
  257. <button id="advertBtn" class="action-advert">Advert</button>
  258. <button id="otaBtn" class="action-dreamy">Start OTA</button>
  259. </div>
  260. <div class="actions-group center">
  261. <button id="themeToggle" class="themebtn" aria-label="Toggle theme" title="Toggle theme">☾</button>
  262. </div>
  263. <div class="actions-group right">
  264. <button id="rebootBtn" class="action-caution">Reboot</button>
  265. <button id="logoutBtn" class="themebtn action-caution">Logout</button>
  266. </div>
  267. </div>
  268. </section>
  269. <section class="card" id="quickCommandsPanel" style="display:none">
  270. <h2>Quick "get" Commands</h2>
  271. <div class="stack">
  272. <div>
  273. <span class="label">WiFi</span>
  274. <div class="quick">
  275. <button data-cmd="get wifi.status">wifi.status</button>
  276. <button data-cmd="get wifi.powersaving">wifi.powersaving</button>
  277. </div>
  278. </div>
  279. <div>
  280. <span class="label">MQTT</span>
  281. <div class="quick">
  282. <button data-cmd="get mqtt.status">mqtt.status</button>
  283. <button data-cmd="get mqtt.iata">mqtt.iata</button>
  284. <button data-cmd="get mqtt.owner">mqtt.owner</button>
  285. <button data-cmd="get mqtt.email">mqtt.email</button>
  286. </div>
  287. </div>
  288. </div>
  289. </section>
  290. <section class="card" id="cliPanel">
  291. <h2>Run CLI Command</h2>
  292. <div class="row-command">
  293. <input id="command" placeholder="get mqtt.status">
  294. <button id="runBtn">Run</button>
  295. </div>
  296. <p>Only the allowlisted commands exposed by this panel will run here.</p>
  297. <div id="reply" class="terminal"></div>
  298. </section>
  299. <section class="card" id="repeaterSettingsPanel" style="display:none">
  300. <h2>Repeater Settings</h2>
  301. <div class="stack">
  302. <div class="field-card">
  303. <label class="label" for="nodeName">Device Name</label>
  304. <div class="inline-actions">
  305. <input id="nodeName" placeholder="MeshCore-HOWL">
  306. <button class="iconbtn" data-load-cmd="get name" data-load-input="nodeName" title="Refresh device name">&#8635;</button>
  307. <button class="savebtn" data-prefix="set name " data-input="nodeName">Save</button>
  308. </div>
  309. </div>
  310. <div class="row">
  311. <div class="field-card">
  312. <div>
  313. <label class="label" for="nodeLat">Latitude</label>
  314. <div class="fieldline">
  315. <input id="nodeLat" placeholder="0.0">
  316. <button class="iconbtn" data-load-cmd="get lat" data-load-input="nodeLat" title="Refresh latitude">&#8635;</button>
  317. </div>
  318. </div>
  319. <button class="savebtn" data-prefix="set lat " data-input="nodeLat">Save latitude</button>
  320. </div>
  321. <div class="field-card">
  322. <div>
  323. <label class="label" for="nodeLon">Longitude</label>
  324. <div class="fieldline">
  325. <input id="nodeLon" placeholder="0.0">
  326. <button class="iconbtn" data-load-cmd="get lon" data-load-input="nodeLon" title="Refresh longitude">&#8635;</button>
  327. </div>
  328. </div>
  329. <button class="savebtn" data-prefix="set lon " data-input="nodeLon">Save longitude</button>
  330. </div>
  331. </div>
  332. <div class="field-card">
  333. <label class="label" for="guestPassword">Guest Password</label>
  334. <div class="inline-actions">
  335. <input id="guestPassword" type="password" placeholder="new guest password">
  336. <span></span>
  337. <button class="savebtn" data-prefix="set guest.password " data-input="guestPassword">Save</button>
  338. </div>
  339. </div>
  340. <div class="field-card">
  341. <label class="label" for="privateKey">Private Key</label>
  342. <div class="inline-actions">
  343. <input id="privateKey" placeholder="64-hex-char private key">
  344. <span></span>
  345. <button class="savebtn" data-prefix="set prv.key " data-input="privateKey">Save</button>
  346. </div>
  347. <p>Changing the private key requires a reboot to apply.</p>
  348. </div>
  349. <div class="row3">
  350. <div class="field-card">
  351. <div>
  352. <label class="label" for="advertInterval">Advert Interval (minutes)</label>
  353. <div class="fieldline">
  354. <input id="advertInterval" placeholder="2">
  355. <button class="iconbtn" data-load-cmd="get advert.interval" data-load-input="advertInterval" title="Refresh advert interval">&#8635;</button>
  356. </div>
  357. </div>
  358. <button class="savebtn" data-prefix="set advert.interval " data-input="advertInterval">Save advert interval</button>
  359. </div>
  360. <div class="field-card">
  361. <div>
  362. <label class="label" for="floodInterval">Flood Interval (hours)</label>
  363. <div class="fieldline">
  364. <input id="floodInterval" placeholder="12">
  365. <button class="iconbtn" data-load-cmd="get flood.advert.interval" data-load-input="floodInterval" title="Refresh flood interval">&#8635;</button>
  366. </div>
  367. </div>
  368. <button class="savebtn" data-prefix="set flood.advert.interval " data-input="floodInterval">Save flood interval</button>
  369. </div>
  370. <div class="field-card">
  371. <div>
  372. <label class="label" for="floodMax">Flood Max</label>
  373. <div class="fieldline">
  374. <input id="floodMax" placeholder="64">
  375. <button class="iconbtn" data-load-cmd="get flood.max" data-load-input="floodMax" title="Refresh flood max">&#8635;</button>
  376. </div>
  377. </div>
  378. <button class="savebtn" data-prefix="set flood.max " data-input="floodMax">Save flood max</button>
  379. </div>
  380. </div>
  381. <div class="field-card">
  382. <label class="label" for="ownerInfo">Owner Info</label>
  383. <div class="inline-actions">
  384. <textarea id="ownerInfo" placeholder="Free text shown in owner info"></textarea>
  385. <button class="iconbtn" data-load-cmd="get owner.info" data-load-input="ownerInfo" data-load-format="multiline" title="Refresh owner info">&#8635;</button>
  386. <button id="saveOwnerInfo" class="savebtn">Save</button>
  387. </div>
  388. </div>
  389. </div>
  390. </section>
  391. <section class="card" id="mqttSettingsPanel" style="display:none">
  392. <h2>MQTT Settings</h2>
  393. <div class="stack">
  394. <div class="field-card">
  395. <label class="label" for="mqttIata">MQTT IATA</label>
  396. <div class="inline-actions">
  397. <select id="mqttIata">
  398. <optgroup label="ACT">
  399. <option value="CBR">CBR - Canberra</option>
  400. </optgroup>
  401. <optgroup label="New South Wales">
  402. <option value="ABX">ABX - Albury</option>
  403. <option value="ARM">ARM - Armidale</option>
  404. <option value="BHQ">BHQ - Broken Hill</option>
  405. <option value="BNK">BNK - Ballina</option>
  406. <option value="CFS">CFS - Coffs Harbour</option>
  407. <option value="DBO">DBO - Dubbo</option>
  408. <option value="GFF">GFF - Griffith</option>
  409. <option value="GFN">GFN - Grafton</option>
  410. <option value="LDH">LDH - Lord Howe Island</option>
  411. <option value="LSY">LSY - Lismore</option>
  412. <option value="MIM">MIM - Merimbula</option>
  413. <option value="MRZ">MRZ - Moree</option>
  414. <option value="MYA">MYA - Moruya</option>
  415. <option value="NTL">NTL - Newcastle</option>
  416. <option value="OAG">OAG - Orange</option>
  417. <option value="PQQ">PQQ - Port Macquarie</option>
  418. <option value="SYD">SYD - Sydney</option>
  419. <option value="WGA">WGA - Wagga Wagga</option>
  420. </optgroup>
  421. <optgroup label="Queensland">
  422. <option value="ABM">ABM - Bamaga</option>
  423. <option value="BNE">BNE - Brisbane</option>
  424. <option value="CNS">CNS - Cairns</option>
  425. <option value="HTI">HTI - Hamilton Island</option>
  426. <option value="HVB">HVB - Hervey Bay</option>
  427. <option value="ISA">ISA - Mount Isa</option>
  428. <option value="LRE">LRE - Longreach</option>
  429. <option value="MCY">MCY - Sunshine Coast</option>
  430. <option value="MKY">MKY - Mackay</option>
  431. <option value="OOL">OOL - Gold Coast</option>
  432. <option value="PPP">PPP - Proserpine</option>
  433. <option value="ROK">ROK - Rockhampton</option>
  434. <option value="TSV">TSV - Townsville</option>
  435. <option value="WEI">WEI - Weipa</option>
  436. <option value="WTB">WTB - Toowoomba Wellcamp</option>
  437. </optgroup>
  438. <optgroup label="South Australia">
  439. <option value="ADL">ADL - Adelaide</option>
  440. <option value="KGC">KGC - Kingscote</option>
  441. <option value="MGB">MGB - Mount Gambier</option>
  442. <option value="PLO">PLO - Port Lincoln</option>
  443. <option value="WYA">WYA - Whyalla</option>
  444. </optgroup>
  445. <optgroup label="Tasmania">
  446. <option value="BWT">BWT - Burnie</option>
  447. <option value="DPO">DPO - Devonport</option>
  448. <option value="FLS">FLS - Flinders Island</option>
  449. <option value="HBA">HBA - Hobart</option>
  450. <option value="KNS">KNS - King Island</option>
  451. <option value="LST">LST - Launceston</option>
  452. </optgroup>
  453. <optgroup label="Victoria">
  454. <option value="AVV">AVV - Avalon</option>
  455. <option value="GEX">GEX - Geelong West</option>
  456. <option value="MEB">MEB - Essendon Fields</option>
  457. <option value="MEL" selected>MEL - Melbourne</option>
  458. <option value="MQL">MQL - Mildura</option>
  459. </optgroup>
  460. </select>
  461. <button class="iconbtn" data-load-cmd="get mqtt.iata" data-load-input="mqttIata" title="Refresh MQTT IATA">&#8635;</button>
  462. <button class="savebtn" data-prefix="set mqtt.iata " data-input="mqttIata">Save</button>
  463. </div>
  464. </div>
  465. <div class="field-card">
  466. <label class="label" for="mqttOwner">MQTT Owner</label>
  467. <div class="inline-actions">
  468. <input id="mqttOwner" placeholder="64-hex-char owner key">
  469. <button class="iconbtn" data-load-cmd="get mqtt.owner" data-load-input="mqttOwner" title="Refresh MQTT owner">&#8635;</button>
  470. <button class="savebtn" data-prefix="set mqtt.owner " data-input="mqttOwner">Save</button>
  471. </div>
  472. </div>
  473. <div class="field-card">
  474. <label class="label" for="mqttEmail">MQTT Email</label>
  475. <div class="inline-actions">
  476. <input id="mqttEmail" type="email" placeholder="owner@example.com">
  477. <button class="iconbtn" data-load-cmd="get mqtt.email" data-load-input="mqttEmail" title="Refresh MQTT email">&#8635;</button>
  478. <button class="savebtn" data-prefix="set mqtt.email " data-input="mqttEmail">Save</button>
  479. </div>
  480. </div>
  481. </div>
  482. </section>
  483. <section class="card" id="statsPanel" style="display:none">
  484. <h2>Stats</h2>
  485. <div class="quick" style="margin-bottom:12px">
  486. <button id="getStatsBtn">Get Stats</button>
  487. </div>
  488. <div id="statsDashboard" class="stats-shell">
  489. <div class="stats-empty">Press Get Stats to load the dashboard.</div>
  490. </div>
  491. </section>
  492. </main>
  493. <script>
  494. let token = "";
  495. let commandQueue = Promise.resolve();
  496. const statusEl = document.getElementById("status");
  497. const replyEl = document.getElementById("reply");
  498. const themeToggleEl = document.getElementById("themeToggle");
  499. const rootEl = document.documentElement;
  500. function getPreferredTheme() {
  501. const saved = localStorage.getItem("repeater-theme");
  502. if (saved === "light" || saved === "dark") return saved;
  503. return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
  504. }
  505. function applyTheme(theme) {
  506. rootEl.dataset.theme = theme;
  507. themeToggleEl.textContent = theme === "dark" ? "☀" : "☾";
  508. themeToggleEl.title = theme === "dark" ? "Switch to light mode" : "Switch to dark mode";
  509. themeToggleEl.setAttribute("aria-label", themeToggleEl.title);
  510. }
  511. function toggleTheme() {
  512. const next = rootEl.dataset.theme === "dark" ? "light" : "dark";
  513. localStorage.setItem("repeater-theme", next);
  514. applyTheme(next);
  515. }
  516. applyTheme(getPreferredTheme());
  517. themeToggleEl.onclick = toggleTheme;
  518. function appendHistory(cmd, text, ok) {
  519. const entry = document.createElement("div");
  520. entry.className = "term-entry";
  521. const cmdLine = document.createElement("div");
  522. cmdLine.className = "term-cmd";
  523. cmdLine.textContent = "> " + cmd;
  524. const outLine = document.createElement("div");
  525. outLine.className = "term-out" + (ok ? "" : " err");
  526. outLine.textContent = text && text.length ? text : "OK";
  527. entry.appendChild(cmdLine);
  528. entry.appendChild(outLine);
  529. replyEl.appendChild(entry);
  530. replyEl.scrollTop = replyEl.scrollHeight;
  531. }
  532. function parseReplyValue(text) {
  533. return (text || "").replace(/^>\s*/, "").trim();
  534. }
  535. function clamp(value, min, max) {
  536. return Math.min(max, Math.max(min, value));
  537. }
  538. function pctRange(value, min, max) {
  539. if (!Number.isFinite(value) || max <= min) return 0;
  540. return clamp(((value - min) * 100) / (max - min), 0, 100);
  541. }
  542. function pctRatio(value, total) {
  543. if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return 0;
  544. return clamp((value * 100) / total, 0, 100);
  545. }
  546. function escapeHtml(value) {
  547. return String(value == null ? "" : value)
  548. .replace(/&/g, "&amp;")
  549. .replace(/</g, "&lt;")
  550. .replace(/>/g, "&gt;")
  551. .replace(/\"/g, "&quot;")
  552. .replace(/'/g, "&#39;");
  553. }
  554. function formatDuration(seconds) {
  555. if (!Number.isFinite(seconds)) return "--";
  556. const secs = Math.max(0, Math.round(seconds));
  557. if (secs < 3600) {
  558. return Math.floor(secs / 60) + "m " + (secs % 60) + "s";
  559. }
  560. if (secs < 86400) {
  561. return Math.floor(secs / 3600) + "h " + Math.floor((secs % 3600) / 60) + "m";
  562. }
  563. return Math.floor(secs / 86400) + "d " + Math.floor((secs % 86400) / 3600) + "h";
  564. }
  565. function formatBytes(value) {
  566. if (!Number.isFinite(value)) return "--";
  567. const abs = Math.abs(value);
  568. if (abs >= 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + " MB";
  569. if (abs >= 1024) return (value / 1024).toFixed(1) + " KB";
  570. return Math.round(value) + " B";
  571. }
  572. function toneForPercent(percent, invert) {
  573. if (invert) {
  574. if (percent >= 70) return "bad";
  575. if (percent >= 35) return "warn";
  576. return "";
  577. }
  578. if (percent >= 75) return "";
  579. if (percent >= 40) return "warn";
  580. return "bad";
  581. }
  582. function renderMeter(label, value, percent, note, invert) {
  583. const pct = clamp(Math.round(percent), 0, 100);
  584. const tone = toneForPercent(pct, invert);
  585. return `<div class="hud-row">
  586. <div class="hud-kpi">
  587. <div>
  588. <div class="metric-label">${escapeHtml(label)}</div>
  589. <div class="hud-value">${escapeHtml(value)}</div>
  590. </div>
  591. <div class="hud-sub">${escapeHtml(note)}</div>
  592. </div>
  593. <div class="meter"><div class="meter-fill${tone ? " " + tone : ""}" style="width:${pct}%"></div></div>
  594. </div>`;
  595. }
  596. function renderMetric(label, value) {
  597. return `<div class="metric">
  598. <div class="metric-label">${escapeHtml(label)}</div>
  599. <div class="metric-value">${escapeHtml(value)}</div>
  600. </div>`;
  601. }
  602. function renderMissingCard(title, message) {
  603. return `<section class="hud-card">
  604. <h3>${escapeHtml(title)}</h3>
  605. <div class="stats-empty">${escapeHtml(message)}</div>
  606. </section>`;
  607. }
  608. function parseJsonReply(text) {
  609. const value = parseReplyValue(text);
  610. if (!value) return null;
  611. try {
  612. return JSON.parse(value);
  613. } catch (_) {
  614. return null;
  615. }
  616. }
  617. function parseKeyedReply(text, keys) {
  618. const value = parseReplyValue(text);
  619. if (!value) return null;
  620. const found = [];
  621. for (const key of keys) {
  622. const marker = key + ":";
  623. const index = value.indexOf(marker);
  624. if (index >= 0) {
  625. found.push({ key, index });
  626. }
  627. }
  628. if (!found.length) return null;
  629. found.sort((a, b) => a.index - b.index);
  630. const parsed = {};
  631. for (let i = 0; i < found.length; i++) {
  632. const current = found[i];
  633. const start = current.index + current.key.length + 1;
  634. const end = i + 1 < found.length ? found[i + 1].index : value.length;
  635. parsed[current.key] = value.slice(start, end).trim();
  636. }
  637. return parsed;
  638. }
  639. function parseWifiStatusReply(text) {
  640. const parsed = parseKeyedReply(text, ["ssid", "status", "code", "state", "ip", "rssi", "quality", "signal"]);
  641. if (!parsed) return null;
  642. const rssi = Number.parseInt(parsed.rssi, 10);
  643. const quality = Number.parseInt(String(parsed.quality || "").replace("%", ""), 10);
  644. const code = Number.parseInt(parsed.code, 10);
  645. return {
  646. ssid: parsed.ssid || "-",
  647. status: parsed.status || "unknown",
  648. code: Number.isFinite(code) ? code : null,
  649. state: parsed.state || "unknown",
  650. ip: parsed.ip || "--",
  651. rssi: Number.isFinite(rssi) ? rssi : null,
  652. quality: Number.isFinite(quality) ? quality : null,
  653. signal: parsed.signal || "--"
  654. };
  655. }
  656. function renderCoreCard(core) {
  657. const batteryPct = pctRange(core.battery_mv, 3000, 4200);
  658. const queuePct = pctRange(core.queue_len, 0, 12);
  659. const errorsPct = core.errors > 0 ? 100 : 0;
  660. return `<section class="hud-card">
  661. <h3>Core</h3>
  662. <div class="core-grid">
  663. ${renderMeter("Battery", Math.round(batteryPct) + "%", batteryPct, (core.battery_mv || 0) + " mV", false)}
  664. ${renderMeter("Queue", String(core.queue_len ?? 0), queuePct, "outbound packets", true)}
  665. ${renderMeter("Errors", String(core.errors ?? 0), errorsPct, "sticky error flags", true)}
  666. </div>
  667. <div class="metric-grid core-metrics">
  668. ${renderMetric("Uptime", formatDuration(core.uptime_secs))}
  669. ${renderMetric("Battery", (core.battery_mv || 0) + " mV")}
  670. ${renderMetric("Queue", String(core.queue_len ?? 0))}
  671. ${renderMetric("Errors", String(core.errors ?? 0))}
  672. </div>
  673. </section>`;
  674. }
  675. function renderWifiCard(wifi, powersave) {
  676. const qualityPct = Number.isFinite(wifi.quality) ? clamp(wifi.quality, 0, 100) : pctRange(wifi.rssi, -100, -50);
  677. const rssiPct = pctRange(wifi.rssi, -100, -50);
  678. const statusNote = wifi.status === "connected" ? (wifi.signal || "linked") : (wifi.state || "idle");
  679. return `<section class="hud-card">
  680. <h3>Wi-Fi</h3>
  681. ${renderMeter("Signal Quality", Number.isFinite(wifi.quality) ? wifi.quality + "%" : "--", qualityPct, statusNote, false)}
  682. ${renderMeter("RSSI", Number.isFinite(wifi.rssi) ? wifi.rssi + " dBm" : "--", rssiPct, wifi.ssid || "-", false)}
  683. <div class="metric-grid">
  684. ${renderMetric("Status", wifi.status || "--")}
  685. ${renderMetric("State", wifi.state || "--")}
  686. ${renderMetric("SSID", wifi.ssid || "-")}
  687. ${renderMetric("IP", wifi.ip || "--")}
  688. ${renderMetric("Power Save", powersave || "--")}
  689. ${renderMetric("Code", wifi.code == null ? "--" : wifi.code)}
  690. </div>
  691. </section>`;
  692. }
  693. function renderRadioCard(radio) {
  694. const rssiPct = pctRange(radio.last_rssi, -120, -20);
  695. const snrPct = pctRange(radio.last_snr, -20, 20);
  696. const noisePct = pctRange(radio.noise_floor, -130, -60);
  697. const totalAir = (radio.tx_air_secs || 0) + (radio.rx_air_secs || 0);
  698. const txShare = pctRatio(radio.tx_air_secs || 0, totalAir);
  699. return `<section class="hud-card">
  700. <h3>Radio</h3>
  701. ${renderMeter("RSSI", (radio.last_rssi ?? "--") + " dBm", rssiPct, "signal strength", false)}
  702. ${renderMeter("SNR", Number.isFinite(radio.last_snr) ? radio.last_snr.toFixed(1) + " dB" : "--", snrPct, "link quality", false)}
  703. ${renderMeter("Noise Floor", (radio.noise_floor ?? "--") + " dBm", noisePct, "ambient RF", false)}
  704. <div class="metric-grid">
  705. ${renderMetric("TX Air", String(radio.tx_air_secs ?? 0) + " s")}
  706. ${renderMetric("RX Air", String(radio.rx_air_secs ?? 0) + " s")}
  707. ${renderMetric("TX Share", Math.round(txShare) + "%")}
  708. ${renderMetric("Total Air", String(totalAir) + " s")}
  709. </div>
  710. </section>`;
  711. }
  712. function renderPacketsCard(packets) {
  713. const sent = packets.sent || 0;
  714. const recv = packets.recv || 0;
  715. const floodTx = packets.flood_tx || 0;
  716. const directTx = packets.direct_tx || 0;
  717. const floodRx = packets.flood_rx || 0;
  718. const directRx = packets.direct_rx || 0;
  719. return `<section class="hud-card">
  720. <h3>Packets</h3>
  721. ${renderMeter("TX Flood Share", Math.round(pctRatio(floodTx, sent)) + "%", pctRatio(floodTx, sent), floodTx + " flood / " + directTx + " direct", false)}
  722. ${renderMeter("RX Flood Share", Math.round(pctRatio(floodRx, recv)) + "%", pctRatio(floodRx, recv), floodRx + " flood / " + directRx + " direct", false)}
  723. <div class="metric-grid">
  724. ${renderMetric("Sent", sent)}
  725. ${renderMetric("Recv", recv)}
  726. ${renderMetric("TX Direct", directTx)}
  727. ${renderMetric("RX Direct", directRx)}
  728. ${renderMetric("Recv Errors", packets.recv_errors || 0)}
  729. ${renderMetric("Balance", recv - sent)}
  730. </div>
  731. </section>`;
  732. }
  733. function renderMemoryCard(memory) {
  734. const heapFree = memory.heap_free || 0;
  735. const heapMax = memory.heap_max || 0;
  736. const psramFree = memory.psram_free || 0;
  737. const psramMax = memory.psram_max || 0;
  738. return `<section class="hud-card">
  739. <h3>Memory</h3>
  740. ${renderMeter("Heap Largest Block", formatBytes(heapMax), pctRatio(heapMax, heapFree), "largest alloc vs free", false)}
  741. ${renderMeter("PSRAM Largest Block", formatBytes(psramMax), pctRatio(psramMax, psramFree), "largest alloc vs free", false)}
  742. <div class="metric-grid">
  743. ${renderMetric("Heap Free", formatBytes(heapFree))}
  744. ${renderMetric("Heap Min", formatBytes(memory.heap_min || 0))}
  745. ${renderMetric("PSRAM Free", formatBytes(psramFree))}
  746. ${renderMetric("PSRAM Min", formatBytes(memory.psram_min || 0))}
  747. </div>
  748. </section>`;
  749. }
  750. function renderStatsDashboard(results, errors) {
  751. const dashboardEl = document.getElementById("statsDashboard");
  752. const notices = errors.length ? `<div class="stats-error">${errors.map((item) => escapeHtml(item)).join("<br>")}</div>` : "";
  753. const coreCards = [
  754. results.core ? renderCoreCard(results.core) : renderMissingCard("Core", "stats-core unavailable")
  755. ];
  756. const middleCards = [
  757. results.radio ? renderRadioCard(results.radio) : renderMissingCard("Radio", "stats-radio unavailable"),
  758. results.memory ? renderMemoryCard(results.memory) : renderMissingCard("Memory", "memory unavailable")
  759. ];
  760. const lowerCards = [
  761. results.wifi ? renderWifiCard(results.wifi, results.wifi_powersave) : renderMissingCard("Wi-Fi", "wifi.status unavailable"),
  762. results.packets ? renderPacketsCard(results.packets) : renderMissingCard("Packets", "stats-packets unavailable")
  763. ];
  764. dashboardEl.innerHTML = notices +
  765. `<div class="hud-grid-1">${coreCards.join("")}</div>` +
  766. `<div class="hud-grid-2">${middleCards.join("")}</div>` +
  767. `<div class="hud-grid-2">${lowerCards.join("")}</div>`;
  768. }
  769. function showAuthedUi(show) {
  770. document.getElementById("login").style.display = show ? "none" : "block";
  771. document.getElementById("cliPanel").style.display = show ? "block" : "none";
  772. document.getElementById("quickCommandsPanel").style.display = show ? "block" : "none";
  773. document.getElementById("mqttSettingsPanel").style.display = show ? "block" : "none";
  774. document.getElementById("statsPanel").style.display = show ? "block" : "none";
  775. document.getElementById("actionsPanel").style.display = show ? "block" : "none";
  776. document.getElementById("repeaterSettingsPanel").style.display = show ? "block" : "none";
  777. if (!show) {
  778. commandQueue = Promise.resolve();
  779. document.getElementById("password").value = "";
  780. statusEl.textContent = "";
  781. document.getElementById("statsDashboard").innerHTML = '<div class="stats-empty">Press Get Stats to load the dashboard.</div>';
  782. }
  783. }
  784. function queueCommand(task) {
  785. const next = commandQueue.then(task, task);
  786. commandQueue = next.catch(() => {});
  787. return next;
  788. }
  789. async function runCommand(cmd, options = {}) {
  790. if (!token) return { ok:false, text:"" };
  791. const recordHistory = options.recordHistory !== false;
  792. const updateInput = options.updateInput !== false;
  793. return queueCommand(async () => {
  794. if (updateInput) {
  795. document.getElementById("command").value = cmd;
  796. }
  797. const res = await fetch("/api/command", { method:"POST", headers:{ "X-Auth-Token": token }, body: cmd });
  798. const text = await res.text();
  799. if (recordHistory) {
  800. appendHistory(cmd, text, res.ok);
  801. }
  802. return { ok:res.ok, text };
  803. });
  804. }
  805. function runPrefixed(prefix, inputId) {
  806. const value = document.getElementById(inputId).value;
  807. runCommand(prefix + value);
  808. }
  809. async function loadField(cmd, inputId, format) {
  810. const result = await runCommand(cmd);
  811. if (!result.ok) return;
  812. let value = parseReplyValue(result.text);
  813. if (format === "multiline") {
  814. value = value.replace(/\|/g, "\n");
  815. }
  816. document.getElementById(inputId).value = value;
  817. }
  818. async function fetchJson(path) {
  819. if (!token) return null;
  820. return queueCommand(async () => {
  821. const res = await fetch(path, { headers:{ "X-Auth-Token": token } });
  822. const text = await res.text();
  823. if (!res.ok) {
  824. throw new Error(text || "request failed");
  825. }
  826. return JSON.parse(text);
  827. });
  828. }
  829. function applyBootstrapData(data) {
  830. if (!data) return;
  831. if (typeof data.name === "string") document.getElementById("nodeName").value = data.name;
  832. if (typeof data.mqtt_iata === "string" && data.mqtt_iata.length) document.getElementById("mqttIata").value = data.mqtt_iata;
  833. if (typeof data.mqtt_owner === "string") document.getElementById("mqttOwner").value = data.mqtt_owner;
  834. if (typeof data.mqtt_email === "string") document.getElementById("mqttEmail").value = data.mqtt_email;
  835. if (typeof data.advert_interval === "string") document.getElementById("advertInterval").value = data.advert_interval;
  836. if (typeof data.flood_interval === "string") document.getElementById("floodInterval").value = data.flood_interval;
  837. if (typeof data.flood_max === "string") document.getElementById("floodMax").value = data.flood_max;
  838. }
  839. async function refreshStats() {
  840. const getStatsBtn = document.getElementById("getStatsBtn");
  841. getStatsBtn.disabled = true;
  842. getStatsBtn.textContent = "Loading...";
  843. document.getElementById("statsDashboard").innerHTML = '<div class="stats-empty">Loading dashboard...</div>';
  844. const results = {};
  845. const errors = [];
  846. try {
  847. const payload = await fetchJson("/api/stats");
  848. if (!payload) throw new Error("no stats payload");
  849. if (typeof payload.wifi === "string") {
  850. const parsed = parseWifiStatusReply(payload.wifi);
  851. if (parsed != null) results.wifi = parsed;
  852. else errors.push("get wifi.status: invalid reply");
  853. }
  854. if (typeof payload.wifi_powersave === "string") {
  855. results.wifi_powersave = parseReplyValue(payload.wifi_powersave) || "--";
  856. }
  857. for (const key of ["core", "radio", "packets", "memory"]) {
  858. if (typeof payload[key] === "string") {
  859. const parsed = parseJsonReply(payload[key]);
  860. if (parsed != null) results[key] = parsed;
  861. else errors.push(key + ": invalid reply");
  862. }
  863. }
  864. } catch (error) {
  865. errors.push(error && error.message ? error.message : "stats request failed");
  866. }
  867. renderStatsDashboard(results, errors);
  868. getStatsBtn.disabled = false;
  869. getStatsBtn.textContent = "Get Stats";
  870. }
  871. document.getElementById("loginBtn").onclick = async () => {
  872. const pwd = document.getElementById("password").value;
  873. const res = await fetch("/login", { method:"POST", body: pwd });
  874. const text = await res.text();
  875. if (!res.ok) {
  876. statusEl.textContent = text || "Access denied";
  877. return;
  878. }
  879. token = text.trim();
  880. showAuthedUi(true);
  881. try {
  882. applyBootstrapData(await fetchJson("/api/bootstrap"));
  883. } catch (_) {
  884. await Promise.all([
  885. loadField("get name", "nodeName"),
  886. loadField("get mqtt.iata", "mqttIata"),
  887. loadField("get mqtt.owner", "mqttOwner"),
  888. loadField("get mqtt.email", "mqttEmail"),
  889. loadField("get advert.interval", "advertInterval"),
  890. loadField("get flood.advert.interval", "floodInterval"),
  891. loadField("get flood.max", "floodMax")
  892. ]);
  893. }
  894. };
  895. document.getElementById("password").addEventListener("keydown", (event) => {
  896. if (event.key === "Enter") {
  897. event.preventDefault();
  898. document.getElementById("loginBtn").click();
  899. }
  900. });
  901. document.getElementById("runBtn").onclick = () => runCommand(document.getElementById("command").value);
  902. document.getElementById("getStatsBtn").onclick = () => refreshStats();
  903. document.getElementById("command").addEventListener("keydown", (event) => {
  904. if (event.key === "Enter") {
  905. event.preventDefault();
  906. document.getElementById("runBtn").click();
  907. }
  908. });
  909. document.querySelectorAll("[data-cmd]").forEach((btn) => btn.onclick = () => runCommand(btn.dataset.cmd));
  910. document.querySelectorAll("[data-prefix]").forEach((btn) => btn.onclick = () => runPrefixed(btn.dataset.prefix, btn.dataset.input));
  911. document.querySelectorAll("[data-load-cmd]").forEach((btn) => btn.onclick = () => loadField(btn.dataset.loadCmd, btn.dataset.loadInput, btn.dataset.loadFormat));
  912. document.getElementById("saveOwnerInfo").onclick = () => {
  913. const value = document.getElementById("ownerInfo").value.replace(/\n/g, "|");
  914. runCommand("set owner.info " + value);
  915. };
  916. document.getElementById("rebootBtn").onclick = async () => {
  917. if (confirm("Reboot the repeater now?")) {
  918. await runCommand("reboot");
  919. }
  920. };
  921. document.getElementById("advertBtn").onclick = async () => {
  922. if (confirm("Send an advert now?")) {
  923. await runCommand("advert");
  924. }
  925. };
  926. document.getElementById("otaBtn").onclick = async () => {
  927. if (confirm("Start OTA mode now?")) {
  928. await runCommand("start ota");
  929. }
  930. };
  931. document.getElementById("logoutBtn").onclick = () => {
  932. token = "";
  933. showAuthedUi(false);
  934. };
  935. showAuthedUi(false);
  936. </script>
  937. </body>
  938. </html>
  939. )HTML";
  940. } // namespace
  941. WebPanelServer::WebPanelServer()
  942. : _runner(nullptr), _server(nullptr), _token{0}, _route_context{this} {
  943. }
  944. void WebPanelServer::setCommandRunner(WebPanelCommandRunner* runner) {
  945. _runner = runner;
  946. }
  947. bool WebPanelServer::start() {
  948. if (_server != nullptr || _runner == nullptr) {
  949. return _server != nullptr;
  950. }
  951. if (_token[0] == 0) {
  952. refreshToken();
  953. }
  954. httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT();
  955. config.httpd.max_open_sockets = 2;
  956. config.httpd.max_uri_handlers = 5;
  957. config.httpd.max_resp_headers = 4;
  958. config.httpd.backlog_conn = 2;
  959. config.httpd.recv_wait_timeout = 2;
  960. config.httpd.send_wait_timeout = 2;
  961. config.httpd.stack_size = kWebServerStackSize;
  962. #if defined(ESP_IDF_VERSION_MAJOR) && ESP_IDF_VERSION_MAJOR >= 5
  963. config.servercert = reinterpret_cast<const uint8_t*>(mqtt_web_panel_cert::kServerCertPem);
  964. config.servercert_len = sizeof(mqtt_web_panel_cert::kServerCertPem);
  965. #else
  966. // IDF 4.x uses the misnamed CA slot for the server certificate.
  967. config.cacert_pem = reinterpret_cast<const uint8_t*>(mqtt_web_panel_cert::kServerCertPem);
  968. config.cacert_len = sizeof(mqtt_web_panel_cert::kServerCertPem);
  969. #endif
  970. config.prvtkey_pem = reinterpret_cast<const uint8_t*>(mqtt_web_panel_cert::kServerKeyPem);
  971. config.prvtkey_len = sizeof(mqtt_web_panel_cert::kServerKeyPem);
  972. esp_err_t rc = httpd_ssl_start(&_server, &config);
  973. if (rc != ESP_OK) {
  974. _server = nullptr;
  975. WEB_PANEL_LOG("server start failed rc=0x%x", static_cast<unsigned>(rc));
  976. return false;
  977. }
  978. httpd_uri_t index_uri = {.uri = "/", .method = HTTP_GET, .handler = &WebPanelServer::handleIndex, .user_ctx = &_route_context};
  979. httpd_uri_t login_uri = {.uri = "/login", .method = HTTP_POST, .handler = &WebPanelServer::handleLogin, .user_ctx = &_route_context};
  980. httpd_uri_t command_uri = {.uri = "/api/command", .method = HTTP_POST, .handler = &WebPanelServer::handleCommand, .user_ctx = &_route_context};
  981. httpd_uri_t bootstrap_uri = {.uri = "/api/bootstrap", .method = HTTP_GET, .handler = &WebPanelServer::handleBootstrap, .user_ctx = &_route_context};
  982. httpd_uri_t stats_uri = {.uri = "/api/stats", .method = HTTP_GET, .handler = &WebPanelServer::handleStats, .user_ctx = &_route_context};
  983. httpd_register_uri_handler(_server, &index_uri);
  984. httpd_register_uri_handler(_server, &login_uri);
  985. httpd_register_uri_handler(_server, &command_uri);
  986. httpd_register_uri_handler(_server, &bootstrap_uri);
  987. httpd_register_uri_handler(_server, &stats_uri);
  988. WEB_PANEL_LOG("server started on https://%s/", WiFi.localIP().toString().c_str());
  989. return true;
  990. }
  991. void WebPanelServer::stop() {
  992. if (_server != nullptr) {
  993. WEB_PANEL_LOG("server stopped");
  994. httpd_ssl_stop(_server);
  995. _server = nullptr;
  996. }
  997. _token[0] = 0;
  998. }
  999. bool WebPanelServer::isRunning() const {
  1000. return _server != nullptr;
  1001. }
  1002. bool WebPanelServer::hasSessionToken() const {
  1003. return _token[0] != 0;
  1004. }
  1005. esp_err_t WebPanelServer::handleIndex(httpd_req_t* req) {
  1006. auto* ctx = static_cast<RouteContext*>(req->user_ctx);
  1007. if (ctx == nullptr || ctx->self == nullptr) {
  1008. return httpd_resp_send_500(req);
  1009. }
  1010. httpd_resp_set_type(req, "text/html; charset=utf-8");
  1011. httpd_resp_set_hdr(req, "Cache-Control", "no-store");
  1012. return httpd_resp_send(req, kWebPanelHtml, HTTPD_RESP_USE_STRLEN);
  1013. }
  1014. esp_err_t WebPanelServer::handleLogin(httpd_req_t* req) {
  1015. auto* ctx = static_cast<RouteContext*>(req->user_ctx);
  1016. if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) {
  1017. return httpd_resp_send_500(req);
  1018. }
  1019. char* password = allocScratchBuffer(kWebPasswordBufferSize);
  1020. if (password == nullptr) {
  1021. return httpd_resp_send_500(req);
  1022. }
  1023. if (!ctx->self->readRequestBody(req, password, kWebPasswordBufferSize)) {
  1024. freeScratchBuffer(password);
  1025. return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request");
  1026. }
  1027. if (strcmp(password, ctx->self->_runner->getWebAdminPassword()) != 0) {
  1028. freeScratchBuffer(password);
  1029. WEB_PANEL_LOG("login denied");
  1030. return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Bad password");
  1031. }
  1032. freeScratchBuffer(password);
  1033. ctx->self->refreshToken();
  1034. WEB_PANEL_LOG("login accepted");
  1035. httpd_resp_set_type(req, "text/plain; charset=utf-8");
  1036. httpd_resp_set_hdr(req, "Cache-Control", "no-store");
  1037. return httpd_resp_sendstr(req, ctx->self->_token);
  1038. }
  1039. esp_err_t WebPanelServer::handleCommand(httpd_req_t* req) {
  1040. auto* ctx = static_cast<RouteContext*>(req->user_ctx);
  1041. if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) {
  1042. return httpd_resp_send_500(req);
  1043. }
  1044. if (!ctx->self->isAuthorized(req)) {
  1045. return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized");
  1046. }
  1047. char* command = allocScratchBuffer(kWebCommandBufferSize);
  1048. char* reply = allocScratchBuffer(kWebReplyBufferSize);
  1049. if (command == nullptr || reply == nullptr) {
  1050. freeScratchBuffer(command);
  1051. freeScratchBuffer(reply);
  1052. return httpd_resp_send_500(req);
  1053. }
  1054. if (!ctx->self->readRequestBody(req, command, kWebCommandBufferSize)) {
  1055. freeScratchBuffer(command);
  1056. freeScratchBuffer(reply);
  1057. return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Bad request");
  1058. }
  1059. memset(reply, 0, kWebReplyBufferSize);
  1060. ctx->self->_runner->runWebCommand(command, reply, kWebReplyBufferSize);
  1061. httpd_resp_set_type(req, "text/plain; charset=utf-8");
  1062. httpd_resp_set_hdr(req, "Cache-Control", "no-store");
  1063. esp_err_t rc = httpd_resp_send(req, reply[0] ? reply : "OK", HTTPD_RESP_USE_STRLEN);
  1064. freeScratchBuffer(command);
  1065. freeScratchBuffer(reply);
  1066. return rc;
  1067. }
  1068. esp_err_t WebPanelServer::handleBootstrap(httpd_req_t* req) {
  1069. auto* ctx = static_cast<RouteContext*>(req->user_ctx);
  1070. if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) {
  1071. return httpd_resp_send_500(req);
  1072. }
  1073. if (!ctx->self->isAuthorized(req)) {
  1074. return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized");
  1075. }
  1076. char* reply = allocScratchBuffer(kWebReplyBufferSize);
  1077. char* json = allocScratchBuffer(kWebJsonBufferSize);
  1078. if (reply == nullptr || json == nullptr) {
  1079. freeScratchBuffer(reply);
  1080. freeScratchBuffer(json);
  1081. return httpd_resp_send_500(req);
  1082. }
  1083. const struct {
  1084. const char* key;
  1085. const char* command;
  1086. } fields[] = {
  1087. {"name", "get name"},
  1088. {"mqtt_iata", "get mqtt.iata"},
  1089. {"mqtt_owner", "get mqtt.owner"},
  1090. {"mqtt_email", "get mqtt.email"},
  1091. {"advert_interval", "get advert.interval"},
  1092. {"flood_interval", "get flood.advert.interval"},
  1093. {"flood_max", "get flood.max"},
  1094. };
  1095. size_t offset = 0;
  1096. json[offset++] = '{';
  1097. json[offset] = 0;
  1098. for (size_t i = 0; i < (sizeof(fields) / sizeof(fields[0])); ++i) {
  1099. memset(reply, 0, kWebReplyBufferSize);
  1100. ctx->self->_runner->runWebCommand(fields[i].command, reply, kWebReplyBufferSize);
  1101. const char* value = reply;
  1102. if (value[0] == '>' && value[1] == ' ') {
  1103. value += 2;
  1104. }
  1105. if (strcmp(value, "-") == 0) {
  1106. value = "";
  1107. }
  1108. if (!appendJsonField(json, kWebJsonBufferSize, offset, fields[i].key, value, i != 0)) {
  1109. freeScratchBuffer(reply);
  1110. freeScratchBuffer(json);
  1111. return httpd_resp_send_500(req);
  1112. }
  1113. }
  1114. if (offset + 2 >= kWebJsonBufferSize) {
  1115. freeScratchBuffer(reply);
  1116. freeScratchBuffer(json);
  1117. return httpd_resp_send_500(req);
  1118. }
  1119. json[offset++] = '}';
  1120. json[offset] = 0;
  1121. httpd_resp_set_type(req, "application/json; charset=utf-8");
  1122. httpd_resp_set_hdr(req, "Cache-Control", "no-store");
  1123. esp_err_t rc = httpd_resp_send(req, json, HTTPD_RESP_USE_STRLEN);
  1124. freeScratchBuffer(reply);
  1125. freeScratchBuffer(json);
  1126. return rc;
  1127. }
  1128. esp_err_t WebPanelServer::handleStats(httpd_req_t* req) {
  1129. auto* ctx = static_cast<RouteContext*>(req->user_ctx);
  1130. if (ctx == nullptr || ctx->self == nullptr || ctx->self->_runner == nullptr) {
  1131. return httpd_resp_send_500(req);
  1132. }
  1133. if (!ctx->self->isAuthorized(req)) {
  1134. return httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Unauthorized");
  1135. }
  1136. char* reply = allocScratchBuffer(kWebReplyBufferSize);
  1137. char* json = allocScratchBuffer(kWebJsonBufferSize);
  1138. if (reply == nullptr || json == nullptr) {
  1139. freeScratchBuffer(reply);
  1140. freeScratchBuffer(json);
  1141. return httpd_resp_send_500(req);
  1142. }
  1143. const struct {
  1144. const char* key;
  1145. const char* command;
  1146. } fields[] = {
  1147. {"wifi", "get wifi.status"},
  1148. {"wifi_powersave", "get wifi.powersaving"},
  1149. {"core", "stats-core"},
  1150. {"radio", "stats-radio"},
  1151. {"packets", "stats-packets"},
  1152. {"memory", "memory"},
  1153. };
  1154. size_t offset = 0;
  1155. json[offset++] = '{';
  1156. json[offset] = 0;
  1157. for (size_t i = 0; i < (sizeof(fields) / sizeof(fields[0])); ++i) {
  1158. memset(reply, 0, kWebReplyBufferSize);
  1159. ctx->self->_runner->runWebCommand(fields[i].command, reply, kWebReplyBufferSize);
  1160. if (!appendJsonField(json, kWebJsonBufferSize, offset, fields[i].key, reply, i != 0)) {
  1161. freeScratchBuffer(reply);
  1162. freeScratchBuffer(json);
  1163. return httpd_resp_send_500(req);
  1164. }
  1165. }
  1166. if (offset + 2 >= kWebJsonBufferSize) {
  1167. freeScratchBuffer(reply);
  1168. freeScratchBuffer(json);
  1169. return httpd_resp_send_500(req);
  1170. }
  1171. json[offset++] = '}';
  1172. json[offset] = 0;
  1173. httpd_resp_set_type(req, "application/json; charset=utf-8");
  1174. httpd_resp_set_hdr(req, "Cache-Control", "no-store");
  1175. esp_err_t rc = httpd_resp_send(req, json, HTTPD_RESP_USE_STRLEN);
  1176. freeScratchBuffer(reply);
  1177. freeScratchBuffer(json);
  1178. return rc;
  1179. }
  1180. bool WebPanelServer::readRequestBody(httpd_req_t* req, char* buffer, size_t buffer_size) const {
  1181. if (req == nullptr || buffer == nullptr || buffer_size == 0 || req->content_len <= 0 ||
  1182. req->content_len >= static_cast<int>(buffer_size)) {
  1183. return false;
  1184. }
  1185. int remaining = req->content_len;
  1186. int offset = 0;
  1187. while (remaining > 0) {
  1188. int read = httpd_req_recv(req, &buffer[offset], remaining);
  1189. if (read <= 0) {
  1190. return false;
  1191. }
  1192. offset += read;
  1193. remaining -= read;
  1194. }
  1195. buffer[offset] = 0;
  1196. return true;
  1197. }
  1198. void WebPanelServer::refreshToken() {
  1199. uint8_t token[16];
  1200. esp_fill_random(token, sizeof(token));
  1201. bytesToHexUpper(token, sizeof(token), _token, sizeof(_token));
  1202. }
  1203. bool WebPanelServer::isAuthorized(httpd_req_t* req) const {
  1204. if (_token[0] == 0) {
  1205. return false;
  1206. }
  1207. char token[40];
  1208. if (httpd_req_get_hdr_value_str(req, "X-Auth-Token", token, sizeof(token)) != ESP_OK) {
  1209. return false;
  1210. }
  1211. return strcmp(token, _token) == 0;
  1212. }
  1213. #else
  1214. WebPanelServer::WebPanelServer()
  1215. : _runner(nullptr) {
  1216. }
  1217. void WebPanelServer::setCommandRunner(WebPanelCommandRunner* runner) {
  1218. _runner = runner;
  1219. }
  1220. bool WebPanelServer::start() {
  1221. return false;
  1222. }
  1223. void WebPanelServer::stop() {
  1224. }
  1225. bool WebPanelServer::isRunning() const {
  1226. return false;
  1227. }
  1228. bool WebPanelServer::hasSessionToken() const {
  1229. return false;
  1230. }
  1231. #endif