DeterministicESPAsyncWebServer 1.2.0
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
sysadmin.ino
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file sysadmin.ino
6 * @brief System Administration example featuring a dark-themed HTML/CSS dashboard, stats API, and reboot sequencer.
7 *
8 * This example showcases:
9 * 1. Serving a single-page web dashboard using PROGMEM static resources.
10 * 2. Responsive CSS Grid styling, sleek dark-mode aesthetics, and micro-animations.
11 * 3. Hardware statistics endpoint (/api/sysinfo) tracking CPU, memory heap, and WiFi RSSI.
12 * 4. Clean reboot sequence: responding to the client first, then restarting the device
13 * after a 1-second delay in loop() to allow lwIP buffers to flush cleanly.
14 * 5. Input validation and config updates via POST endpoints.
15 * 6. Restricting admin APIs with a custom headers check (X-Admin-Token).
16 *
17 * To run this example:
18 * - Configure SSID/PASSWORD, and upload to an ESP32.
19 * - Find the ESP32 IP in the Serial Monitor, open it in any web browser.
20 * - Interact with the dashboard to view real-time diagnostics or reboot the chip.
21 */
22
23#include "DeterministicESPAsyncWebServer.h"
24#include "network_drivers/physical.h"
25#include <WiFi.h>
26
27static const char *SSID = "YOUR_SSID";
28static const char *PASSWORD = "YOUR_PASSWORD";
29
30DetWebServer server;
31
32// Simple authentication token for AJAX API queries
33static const char *ADMIN_TOKEN = "admin123";
34
35// System control flags for asynchronous reboot scheduling
36static bool pending_reboot = false;
37static unsigned long reboot_trigger_ms = 0;
38
39// --- Embedded Dashboard HTML (Premium Dark Theme) ---
40static const char ADMIN_HTML[] PROGMEM = R"rawhtml(
41<!DOCTYPE html>
42<html lang="en">
43<head>
44 <meta charset="UTF-8">
45 <meta name="viewport" content="width=device-width, initial-scale=1.0">
46 <title>ESP32 Admin Node Console</title>
47 <style>
48 :root {
49 --bg-color: #0b0f19;
50 --card-bg: #161b2a;
51 --text-color: #e2e8f0;
52 --text-muted: #94a3b8;
53 --accent: #6366f1;
54 --accent-glow: rgba(99, 102, 241, 0.4);
55 --success: #10b981;
56 --danger: #ef4444;
57 --border: #2e354f;
58 }
59 * {
60 box-sizing: border-box;
61 margin: 0;
62 padding: 0;
63 }
64 body {
65 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
66 background-color: var(--bg-color);
67 color: var(--text-color);
68 padding: 2rem 1.5rem;
69 min-height: 100vh;
70 display: flex;
71 flex-direction: column;
72 align-items: center;
73 }
74 .container {
75 width: 100%;
76 max-width: 900px;
77 display: flex;
78 flex-direction: column;
79 gap: 2rem;
80 }
81 header {
82 display: flex;
83 justify-content: space-between;
84 align-items: center;
85 border-bottom: 1px solid var(--border);
86 padding-bottom: 1.5rem;
87 }
88 .logo-area {
89 display: flex;
90 align-items: center;
91 gap: 0.75rem;
92 }
93 h1 {
94 font-size: 1.5rem;
95 font-weight: 700;
96 background: linear-gradient(135deg, #a5b4fc, #6366f1);
97 -webkit-background-clip: text;
98 -webkit-text-fill-color: transparent;
99 }
100 .status-dot {
101 width: 10px;
102 height: 10px;
103 background-color: var(--success);
104 border-radius: 50%;
105 box-shadow: 0 0 10px var(--success);
106 animation: pulse 2s infinite;
107 }
108 @keyframes pulse {
109 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); }
110 70% { box-shadow: 0 0 0 8px rgba(16, 185, 129, 0); }
111 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
112 }
113 .token-input-card {
114 background-color: var(--card-bg);
115 border: 1px solid var(--border);
116 border-radius: 12px;
117 padding: 1.25rem;
118 display: flex;
119 gap: 1rem;
120 align-items: center;
121 }
122 .token-input-card label {
123 font-weight: 600;
124 color: var(--text-muted);
125 white-space: nowrap;
126 }
127 .token-input-card input {
128 background-color: var(--bg-color);
129 border: 1px solid var(--border);
130 border-radius: 6px;
131 color: var(--text-color);
132 padding: 0.5rem 0.75rem;
133 flex-grow: 1;
134 font-family: monospace;
135 outline: none;
136 }
137 .token-input-card input:focus {
138 border-color: var(--accent);
139 box-shadow: 0 0 5px var(--accent-glow);
140 }
141 .grid {
142 display: grid;
143 grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
144 gap: 1.5rem;
145 }
146 .card {
147 background-color: var(--card-bg);
148 border: 1px solid var(--border);
149 border-radius: 12px;
150 padding: 1.5rem;
151 display: flex;
152 flex-direction: column;
153 gap: 1rem;
154 transition: transform 0.2s, border-color 0.2s;
155 }
156 .card:hover {
157 transform: translateY(-2px);
158 border-color: var(--accent);
159 }
160 .card-title {
161 font-size: 0.875rem;
162 text-transform: uppercase;
163 letter-spacing: 0.05em;
164 color: var(--text-muted);
165 font-weight: 600;
166 }
167 .card-value {
168 font-size: 1.8rem;
169 font-weight: 700;
170 color: #ffffff;
171 }
172 .data-list {
173 list-style: none;
174 display: flex;
175 flex-direction: column;
176 gap: 0.5rem;
177 }
178 .data-item {
179 display: flex;
180 justify-content: space-between;
181 font-size: 0.9rem;
182 }
183 .data-label {
184 color: var(--text-muted);
185 }
186 .data-value {
187 font-weight: 500;
188 font-family: monospace;
189 }
190 .actions {
191 display: flex;
192 gap: 1rem;
193 margin-top: 1rem;
194 }
195 button {
196 padding: 0.75rem 1.5rem;
197 border: none;
198 border-radius: 8px;
199 cursor: pointer;
200 font-weight: 600;
201 font-size: 0.95rem;
202 transition: background 0.2s, transform 0.1s;
203 display: flex;
204 align-items: center;
205 justify-content: center;
206 gap: 0.5rem;
207 }
208 button:active {
209 transform: scale(0.98);
210 }
211 .btn-primary {
212 background-color: var(--accent);
213 color: white;
214 }
215 .btn-primary:hover {
216 background-color: #5558e6;
217 }
218 .btn-danger {
219 background-color: var(--danger);
220 color: white;
221 }
222 .btn-danger:hover {
223 background-color: #dc2626;
224 }
225 .modal {
226 display: none;
227 position: fixed;
228 top: 0; left: 0; right: 0; bottom: 0;
229 background-color: rgba(11, 15, 25, 0.85);
230 align-items: center;
231 justify-content: center;
232 z-index: 100;
233 backdrop-filter: blur(4px);
234 }
235 .modal-content {
236 background-color: var(--card-bg);
237 border: 1px solid var(--border);
238 border-radius: 16px;
239 padding: 2rem;
240 max-width: 400px;
241 width: 90%;
242 text-align: center;
243 display: flex;
244 flex-direction: column;
245 gap: 1.5rem;
246 box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5);
247 }
248 </style>
249</head>
250<body>
251 <div class="container">
252 <header>
253 <div class="logo-area">
254 <div class="status-dot"></div>
255 <h1>ESP32 Admin Console</h1>
256 </div>
257 <span style="font-family: monospace; font-size: 0.9rem;" id="ip-addr">Node Active</span>
258 </header>
259
260 <div class="token-input-card">
261 <label for="token">Access Token:</label>
262 <input type="password" id="token" value="admin123" placeholder="Enter X-Admin-Token">
263 </div>
264
265 <div class="grid">
266 <div class="card">
267 <div class="card-title">Memory Allocation</div>
268 <div class="card-value" id="free-heap">-- KB</div>
269 <ul class="data-list">
270 <li class="data-item">
271 <span class="data-label">Min Free Heap</span>
272 <span class="data-value" id="min-heap">-- KB</span>
273 </li>
274 <li class="data-item">
275 <span class="data-label">Max Alloc Block</span>
276 <span class="data-value" id="max-alloc">-- KB</span>
277 </li>
278 </ul>
279 </div>
280
281 <div class="card">
282 <div class="card-title">System Metrics</div>
283 <ul class="data-list" style="margin-top: 0.5rem;">
284 <li class="data-item">
285 <span class="data-label">Uptime</span>
286 <span class="data-value" id="uptime">-- s</span>
287 </li>
288 <li class="data-item">
289 <span class="data-label">Reset Reason</span>
290 <span class="data-value" id="reset-reason">--</span>
291 </li>
292 <li class="data-item">
293 <span class="data-label">Chip Revision</span>
294 <span class="data-value" id="chip-rev">--</span>
295 </li>
296 <li class="data-item">
297 <span class="data-label">CPU Freq</span>
298 <span class="data-value" id="cpu-freq">-- MHz</span>
299 </li>
300 </ul>
301 </div>
302
303 <div class="card">
304 <div class="card-title">Network Info</div>
305 <div class="card-value" id="wifi-rssi">-- dBm</div>
306 <ul class="data-list">
307 <li class="data-item">
308 <span class="data-label">SSID</span>
309 <span class="data-value" id="wifi-ssid">--</span>
310 </li>
311 <li class="data-item">
312 <span class="data-label">Channel</span>
313 <span class="data-value" id="wifi-channel">--</span>
314 </li>
315 </ul>
316 </div>
317 </div>
318
319 <div class="actions">
320 <button class="btn-primary" onclick="loadStats()">Refresh Stats</button>
321 <button class="btn-danger" onclick="confirmReboot()">Restart Device</button>
322 </div>
323 </div>
324
325 <!-- Reboot Modal -->
326 <div class="modal" id="reboot-modal">
327 <div class="modal-content">
328 <h2>Restart ESP32?</h2>
329 <p style="color: var(--text-muted);">This will temporarily disconnect all socket sessions and reboot the node firmware.</p>
330 <div class="actions" style="justify-content: center;">
331 <button class="btn-primary" onclick="closeRebootModal()">Cancel</button>
332 <button class="btn-danger" onclick="triggerReboot()">Confirm Restart</button>
333 </div>
334 </div>
335 </div>
336
337 <script>
338 const getHeaders = () => {
339 return {
340 'X-Admin-Token': document.getElementById('token').value
341 };
342 };
343
344 async function loadStats() {
345 try {
346 const res = await fetch('/api/sysinfo', { headers: getHeaders() });
347 if (res.status === 401) {
348 alert('Unauthorized: Invalid Access Token!');
349 return;
350 }
351 const data = await res.json();
352
353 document.getElementById('free-heap').innerText = Math.round(data.free_heap / 1024) + ' KB';
354 document.getElementById('min-heap').innerText = Math.round(data.min_free_heap / 1024) + ' KB';
355 document.getElementById('max-alloc').innerText = Math.round(data.max_alloc_heap / 1024) + ' KB';
356 document.getElementById('uptime').innerText = Math.round(data.uptime_ms / 1000) + ' s';
357 document.getElementById('reset-reason').innerText = data.reset_reason;
358 document.getElementById('chip-rev').innerText = 'Rev ' + data.chip_revision;
359 document.getElementById('cpu-freq').innerText = data.cpu_freq_mhz + ' MHz';
360 document.getElementById('wifi-rssi').innerText = data.wifi_rssi + ' dBm';
361 document.getElementById('wifi-ssid').innerText = data.wifi_ssid;
362 document.getElementById('wifi-channel').innerText = data.wifi_channel;
363 document.getElementById('ip-addr').innerText = data.ip_address;
364 } catch (err) {
365 console.error('Failed to load system stats', err);
366 }
367 }
368
369 function confirmReboot() {
370 document.getElementById('reboot-modal').style.display = 'flex';
371 }
372
373 function closeRebootModal() {
374 document.getElementById('reboot-modal').style.display = 'none';
375 }
376
377 async function triggerReboot() {
378 closeRebootModal();
379 try {
380 const res = await fetch('/api/restart', {
381 method: 'POST',
382 headers: getHeaders()
383 });
384 if (res.status === 200) {
385 alert('Reboot signal accepted. Reconnecting in 5 seconds...');
386 setTimeout(() => window.location.reload(), 5000);
387 } else {
388 alert('Error: Reboot rejected by node.');
389 }
390 } catch (err) {
391 alert('Connection lost. Node is rebooting...');
392 }
393 }
394
395 // Init load
396 window.onload = loadStats;
397 </script>
398</body>
399</html>
400)rawhtml";
401
402// --- Helper Functions ---
403
404/**
405 * @brief Translates ESP32 reset reason enum into user-friendly text string.
406 */
407const char *get_reset_reason_string(esp_reset_reason_t reason)
408{
409 switch (reason)
410 {
411 case ESP_RST_POWERON:
412 return "Power On";
413 case ESP_RST_EXT:
414 return "External Pin Reset";
415 case ESP_RST_SW:
416 return "Software Reboot";
417 case ESP_RST_PANIC:
418 return "Software Panic / Crash";
419 case ESP_RST_INT_WDT:
420 return "Interrupt Watchdog";
421 case ESP_RST_TASK_WDT:
422 return "Task Watchdog";
423 case ESP_RST_WDT:
424 return "Generic Watchdog";
425 case ESP_RST_DEEPSLEEP:
426 return "Deep Sleep Exit";
427 case ESP_RST_BROWNOUT:
428 return "Brownout Event";
429 case ESP_RST_SDIO:
430 return "SDIO Interface Reset";
431 default:
432 return "Unknown";
433 }
434}
435
436/**
437 * @brief Verifies the custom administration security token header.
438 */
439bool verify_admin_privileges(const HttpReq *req)
440{
441 const char *token = http_get_header(req, "X-Admin-Token");
442 return (token && strcmp(token, ADMIN_TOKEN) == 0);
443}
444
445// --- Route Handlers ---
446
447/**
448 * @brief GET /
449 * Serves the embedded single-page HTML console dashboard.
450 */
451void handle_serve_dashboard(uint8_t slot_id, HttpReq *req)
452{
453 server.send(slot_id, 200, "text/html", ADMIN_HTML);
454}
455
456/**
457 * @brief GET /api/sysinfo
458 * Collects and serializes hardware telemetry to JSON. Requires valid auth token header.
459 */
460void handle_get_sysinfo(uint8_t slot_id, HttpReq *req)
461{
462 if (!verify_admin_privileges(req))
463 {
464 server.send(slot_id, 401, "application/json", "{\"error\":\"Unauthorized\"}");
465 return;
466 }
467
468 char response_buf[384];
469 snprintf(response_buf, sizeof(response_buf),
470 "{"
471 "\"free_heap\":%u,"
472 "\"min_free_heap\":%u,"
473 "\"max_alloc_heap\":%u,"
474 "\"uptime_ms\":%lu,"
475 "\"reset_reason\":\"%s\","
476 "\"chip_revision\":%d,"
477 "\"cpu_freq_mhz\":%d,"
478 "\"wifi_rssi\":%d,"
479 "\"wifi_ssid\":\"%s\","
480 "\"wifi_channel\":%d,"
481 "\"ip_address\":\"%s\""
482 "}",
483 ESP.getFreeHeap(), ESP.getMinFreeHeap(), ESP.getMaxAllocHeap(), millis(),
484 get_reset_reason_string(esp_reset_reason()), ESP.getChipRevision(), ESP.getCpuFreqMHz(), WiFi.RSSI(),
485 WiFi.SSID().c_str(), WiFi.channel(), WiFi.localIP().toString().c_str());
486
487 server.send(slot_id, 200, "application/json", response_buf);
488}
489
490/**
491 * @brief POST /api/restart
492 * Receives reboot command and arms reboot timer.
493 */
494void handle_post_restart(uint8_t slot_id, HttpReq *req)
495{
496 if (!verify_admin_privileges(req))
497 {
498 server.send(slot_id, 401, "application/json", "{\"error\":\"Unauthorized\"}");
499 return;
500 }
501
502 // Acknowledge request immediately to send response headers back to client
503 server.send(slot_id, 200, "application/json", "{\"status\":\"rebooting\"}");
504
505 // Schedule the physical restart after response dispatch completes
506 pending_reboot = true;
507 reboot_trigger_ms = millis();
508}
509
510void setup()
511{
512 Serial.begin(115200);
513 delay(1000);
514 Serial.println("\n--- DetWebServer SysAdmin Control Console ---");
515
516 init_wifi_physical(SSID, PASSWORD);
517 while (!wifi_ready())
518 {
519 delay(500);
520 Serial.print(".");
521 }
522 Serial.println("\nWiFi Online!");
523 Serial.print("Access the Admin Dashboard via: http://");
524 Serial.println(WiFi.localIP());
525
526 server.set_cors("*");
527
528 // Register UI & REST services
529 server.on("/", HTTP_GET, handle_serve_dashboard);
530 server.on("/api/sysinfo", HTTP_GET, handle_get_sysinfo);
531 server.on("/api/restart", HTTP_POST, handle_post_restart);
532
533 if (server.begin(80))
534 {
535 Serial.println("SysAdmin Node Console successfully initialized.");
536 }
537}
538
539void loop()
540{
541 server.handle();
542
543 // Perform scheduled reboot sequence
544 if (pending_reboot && (millis() - reboot_trigger_ms >= 1000))
545 {
546 Serial.println("Reboot sequence triggered. Rebooting hardware now!");
547 delay(100);
548 ESP.restart();
549 }
550}