Files

361 lines
16 KiB
C

#include "web_server_pages.h"
#include "sys_uart.h"
#include "esp_log.h"
#include "esp_http_server.h"
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
static const char *TAG = "SYS_WEB_SERIAL";
static const char SERIAL_HTML[] =
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\">"
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\">"
"<title>ABrobot Web Serial Terminal</title>"
"<style>"
":root{--bg:#0b0f17;--card:#141b26;--border:#243042;--text:#e6edf3;--accent:#00d2ff;--accent2:#3a7bd5;--green:#00e676;--amber:#ffb300;--term-bg:#080b10;}"
"*{box-sizing:border-box;margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;}"
"body{background:var(--bg);color:var(--text);padding:14px;display:flex;justify-content:center;min-height:100vh;}"
".container{width:100%;max-width:920px;display:flex;flex-direction:column;gap:12px;}"
".header{background:linear-gradient(135deg,rgba(58,123,213,0.18),rgba(0,210,255,0.08));border:1px solid var(--border);border-radius:12px;padding:12px 18px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;}"
".title-group{display:flex;align-items:center;gap:10px;}"
".status-dot{width:10px;height:10px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);transition:background 0.3s;}"
".title{font-size:1.15rem;font-weight:700;letter-spacing:0.5px;background:linear-gradient(90deg,#00d2ff,#3a7bd5);-webkit-background-clip:text;-webkit-text-fill-color:transparent;}"
".nav-links{display:flex;gap:8px;align-items:center;}"
".nav-links a{color:#8b949e;text-decoration:none;font-size:0.85rem;padding:6px 12px;border-radius:6px;transition:all 0.2s;}"
".nav-links a:hover{color:var(--text);background:rgba(255,255,255,0.05);}"
".nav-links a.active{color:var(--accent);font-weight:600;background:rgba(0,210,255,0.1);border:1px solid rgba(0,210,255,0.25);}"
".toolbar{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:10px 14px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;}"
".tool-group{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}"
"label{font-size:0.82rem;color:#8b949e;}"
"select,button,input{background:#0a0e14;color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 10px;font-size:0.86rem;outline:none;transition:border-color 0.2s;}"
"select:focus,input:focus{border-color:var(--accent);}"
"button{cursor:pointer;background:linear-gradient(180deg,#21262d,#161b22);border-color:#30363d;font-weight:500;}"
"button:hover{background:#30363d;border-color:#8b949e;}"
"button.primary{background:linear-gradient(135deg,var(--accent2),var(--accent));border:none;color:#0a0d14;font-weight:700;}"
"button.primary:hover{opacity:0.9;}"
".badge{background:#1a2332;border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:0.75rem;font-family:monospace;color:#58a6ff;}"
"#terminal{background:var(--term-bg);border:1px solid var(--border);border-radius:10px;height:420px;overflow-y:auto;padding:12px;font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;font-size:0.86rem;line-height:1.45;white-space:pre-wrap;word-break:break-all;box-shadow:inset 0 2px 8px rgba(0,0,0,0.6);}"
".log-rx{color:#7ee787;}"
".log-tx{color:#f6d365;}"
".log-sys{color:#79c0ff;font-style:italic;}"
".log-hex{color:#d2a8ff;}"
".input-bar{display:flex;gap:8px;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:10px;flex-wrap:wrap;}"
"#cmdInput{flex:1;min-width:200px;font-family:monospace;}"
".macros{display:flex;gap:6px;flex-wrap:wrap;align-items:center;}"
".macros button{font-size:0.75rem;padding:4px 8px;}"
"@media(max-width:600px){#terminal{height:300px;}}"
"</style></head><body>"
"<div class=\"container\">"
"<div class=\"header\">"
" <div class=\"title-group\">"
" <div class=\"status-dot\" id=\"statusDot\"></div>"
" <div class=\"title\">ESP32-C3 Web Serial Terminal</div>"
" </div>"
" <div class=\"nav-links\">"
" <a href=\"/\">Wi-Fi Setup</a>"
" <a href=\"/serial\" class=\"active\">Serial Terminal</a>"
" <a href=\"/ota\">OTA Update</a>"
" </div>"
"</div>"
"<div class=\"toolbar\">"
" <div class=\"tool-group\">"
" <label for=\"baudSelect\">Baud:</label>"
" <select id=\"baudSelect\" onchange=\"changeBaud()\">"
" <option value=\"9600\">9600</option>"
" <option value=\"19200\">19200</option>"
" <option value=\"38400\">38400</option>"
" <option value=\"57600\">57600</option>"
" <option value=\"115200\" selected>115200</option>"
" <option value=\"230400\">230400</option>"
" <option value=\"460800\">460800</option>"
" <option value=\"921600\">921600</option>"
" </select>"
" <label for=\"viewMode\">View:</label>"
" <select id=\"viewMode\" onchange=\"clearTerminal()\">"
" <option value=\"ascii\" selected>ASCII Text</option>"
" <option value=\"hex\">HEX Dump</option>"
" </select>"
" <button onclick=\"clearTerminal()\">Clear</button>"
" <button id=\"scrollBtn\" onclick=\"toggleAutoScroll()\">Scroll: ON</button>"
" </div>"
" <div class=\"tool-group\">"
" <span class=\"badge\" id=\"rxStat\">RX: 0 B</span>"
" <span class=\"badge\" id=\"txStat\">TX: 0 B</span>"
" <button onclick=\"exportLog()\">Save Log</button>"
" </div>"
"</div>"
"<div id=\"terminal\"></div>"
"<div class=\"input-bar\">"
" <select id=\"sendFormat\">"
" <option value=\"text\" selected>Text</option>"
" <option value=\"hex\">HEX (e.g. 41 42 FF)</option>"
" </select>"
" <select id=\"lineEnding\">"
" <option value=\"\\r\\n\" selected>CRLF (\\r\\n)</option>"
" <option value=\"\\n\">LF (\\n)</option>"
" <option value=\"\\r\">CR (\\r)</option>"
" <option value=\"\">None</option>"
" </select>"
" <input type=\"text\" id=\"cmdInput\" placeholder=\"Type command to send over TX (GPIO 21)...\" autocomplete=\"off\" autofocus>"
" <button class=\"primary\" onclick=\"sendCmd()\">Send</button>"
"</div>"
"<div class=\"macros\">"
" <span style=\"font-size:0.75rem;color:#8b949e;\">Quick:</span>"
" <button onclick=\"sendMacro('PING')\">PING</button>"
" <button onclick=\"sendMacro('AT')\">AT</button>"
" <button onclick=\"sendMacro('HELP')\">HELP</button>"
" <button onclick=\"sendMacro('STATUS')\">STATUS</button>"
" <button onclick=\"sendMacro('RESET')\">RESET</button>"
"</div>"
"</div>"
"<script>"
"let seq = 0, autoScroll = true, history = [], histIdx = -1;"
"const term = document.getElementById('terminal');"
"const cmdIn = document.getElementById('cmdInput');"
"const rxStat = document.getElementById('rxStat');"
"const txStat = document.getElementById('txStat');"
"const baudSel = document.getElementById('baudSelect');"
"const viewMode = document.getElementById('viewMode');"
"const sendFmt = document.getElementById('sendFormat');"
"const statusDot = document.getElementById('statusDot');"
"function log(text, cls) {"
" const span = document.createElement('span');"
" span.className = cls || '';"
" span.textContent = text;"
" term.appendChild(span);"
" if (autoScroll) term.scrollTop = term.scrollHeight;"
"}"
"function toggleAutoScroll() {"
" autoScroll = !autoScroll;"
" document.getElementById('scrollBtn').textContent = 'Scroll: ' + (autoScroll ? 'ON' : 'OFF');"
"}"
"async function clearTerminal() {"
" term.innerHTML = '';"
" try { await fetch('/serial/clear', {method: 'POST'}); } catch(e){}"
" log('[Terminal buffer cleared]\\n', 'log-sys');"
"}"
"function exportLog() {"
" const blob = new Blob([term.textContent], {type: 'text/plain'});"
" const a = document.createElement('a');"
" a.href = URL.createObjectURL(blob);"
" a.download = 'serial_log_' + new Date().toISOString().replace(/[:.]/g,'-') + '.txt';"
" a.click();"
"}"
"async function pollData() {"
" try {"
" const res = await fetch('/serial/data?seq=' + seq);"
" if (res.ok) {"
" statusDot.style.background = '#00e676';"
" statusDot.style.boxShadow = '0 0 8px #00e676';"
" const data = await res.json();"
" if (data.seq !== undefined) seq = data.seq;"
" if (data.rx !== undefined) rxStat.textContent = 'RX: ' + formatBytes(data.rx);"
" if (data.tx !== undefined) txStat.textContent = 'TX: ' + formatBytes(data.tx);"
" if (data.baud !== undefined && baudSel.value != data.baud) baudSel.value = data.baud;"
" if (data.hex && data.hex.length > 0) {"
" if (viewMode.value === 'hex') {"
" let hexStr = '';"
" for (let i = 0; i < data.hex.length; i++) {"
" hexStr += data.hex[i].toString(16).padStart(2, '0').toUpperCase() + ' ';"
" if ((i + 1) % 16 === 0) hexStr += '\\n';"
" }"
" log(hexStr, 'log-hex');"
" } else {"
" if (data.text) log(data.text, 'log-rx');"
" }"
" }"
" } else {"
" statusDot.style.background = '#ff5252';"
" statusDot.style.boxShadow = '0 0 8px #ff5252';"
" }"
" } catch (e) {"
" statusDot.style.background = '#ff5252';"
" statusDot.style.boxShadow = '0 0 8px #ff5252';"
" }"
" setTimeout(pollData, 120);"
"}"
"function formatBytes(bytes) {"
" if (bytes < 1024) return bytes + ' B';"
" if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';"
" return (bytes / 1048576).toFixed(2) + ' MB';"
"}"
"async function sendCmd() {"
" let val = cmdIn.value;"
" if (val === '') return;"
" history.push(val);"
" histIdx = history.length;"
" cmdIn.value = '';"
" let payload = '';"
" if (sendFmt.value === 'hex') {"
" let bytes = val.trim().split(/[\\s,]+/).map(h => parseInt(h, 16)).filter(n => !isNaN(n));"
" let binStr = String.fromCharCode(...bytes);"
" payload = binStr;"
" log('-> [HEX: ' + val + ']\\n', 'log-tx');"
" } else {"
" const le = document.getElementById('lineEnding').value;"
" payload = val + le.replace(/\\\\r/g, '\\r').replace(/\\\\n/g, '\\n');"
" log('-> ' + val + '\\n', 'log-tx');"
" }"
" try {"
" await fetch('/serial/tx', {method: 'POST', body: payload});"
" } catch (e) {"
" log('[TX Error: ' + e.message + ']\\n', 'log-sys');"
" }"
"}"
"function sendMacro(cmd) {"
" cmdIn.value = cmd;"
" sendCmd();"
"}"
"async function changeBaud() {"
" const b = baudSel.value;"
" log('[Switching Baud to ' + b + '...]\\n', 'log-sys');"
" try {"
" await fetch('/serial/baud', {method: 'POST', body: b});"
" await clearTerminal();"
" } catch (e) {"
" log('[Failed to set baud: ' + e.message + ']\\n', 'log-sys');"
" }"
"}"
"cmdIn.addEventListener('keydown', e => {"
" if (e.key === 'Enter') { sendCmd(); }"
" else if (e.key === 'ArrowUp') {"
" if (histIdx > 0) { histIdx--; cmdIn.value = history[histIdx]; }"
" } else if (e.key === 'ArrowDown') {"
" if (histIdx < history.length - 1) { histIdx++; cmdIn.value = history[histIdx]; }"
" else { histIdx = history.length; cmdIn.value = ''; }"
" }"
"});"
"log('[ABrobot Web Serial Connected on TX:GPIO21 / RX:GPIO20 (Internal Pull-Up Active)]\\n', 'log-sys');"
"try{fetch('/time/sync?epoch='+Math.floor(Date.now()/1000));}catch(e){}pollData();"
"</script></body></html>";
static esp_err_t serial_page_get(httpd_req_t *request)
{
httpd_resp_set_type(request, "text/html");
return httpd_resp_send(request, SERIAL_HTML, HTTPD_RESP_USE_STRLEN);
}
static esp_err_t serial_data_get(httpd_req_t *request)
{
char query[64] = {0};
uint32_t from_seq = 0;
if (httpd_req_get_url_query_str(request, query, sizeof(query)) == ESP_OK) {
char val[16] = {0};
if (httpd_query_key_value(query, "seq", val, sizeof(val)) == ESP_OK) {
from_seq = (uint32_t)strtoul(val, NULL, 10);
}
}
uint8_t rx_delta[1024];
uint32_t new_seq = from_seq;
size_t len = sys_uart_read_delta(from_seq, rx_delta, sizeof(rx_delta) - 1, &new_seq);
rx_delta[len] = '\0';
uint32_t total_rx = 0, total_tx = 0;
sys_uart_get_stats(&total_rx, &total_tx);
uint32_t baud = sys_uart_get_baud_rate();
httpd_resp_set_type(request, "application/json");
/* Begin JSON response */
char prefix[128];
int p_len = snprintf(prefix, sizeof(prefix), "{\"seq\":%lu,\"rx\":%lu,\"tx\":%lu,\"baud\":%lu,\"text\":\"",
(unsigned long)new_seq, (unsigned long)total_rx, (unsigned long)total_tx, (unsigned long)baud);
httpd_resp_send_chunk(request, prefix, p_len);
/* Stream escaped JSON text */
for (size_t i = 0; i < len; i++) {
uint8_t c = rx_delta[i];
if (c == '"') {
httpd_resp_send_chunk(request, "\\\"", 2);
} else if (c == '\\') {
httpd_resp_send_chunk(request, "\\\\", 2);
} else if (c == '\n') {
httpd_resp_send_chunk(request, "\\n", 2);
} else if (c == '\r') {
httpd_resp_send_chunk(request, "\\r", 2);
} else if (c == '\t') {
httpd_resp_send_chunk(request, "\\t", 2);
} else if (c < 32 || c > 126) {
/* Replace non-printable ASCII in text view with dot or hex escape */
char hex_esc[8];
int h_len = snprintf(hex_esc, sizeof(hex_esc), "\\u%04x", c);
httpd_resp_send_chunk(request, hex_esc, h_len);
} else {
char ch[2] = {(char)c, '\0'};
httpd_resp_send_chunk(request, ch, 1);
}
}
/* Stream raw byte array for HEX dump view */
httpd_resp_send_chunk(request, "\",\"hex\":[", 9);
for (size_t i = 0; i < len; i++) {
char num[8];
int n_len = snprintf(num, sizeof(num), "%s%u", (i > 0 ? "," : ""), (unsigned int)rx_delta[i]);
httpd_resp_send_chunk(request, num, n_len);
}
httpd_resp_send_chunk(request, "]}", 2);
return httpd_resp_send_chunk(request, NULL, 0);
}
static esp_err_t serial_tx_post(httpd_req_t *request)
{
char body[512] = {0};
int total_len = request->content_len;
if (total_len >= sizeof(body)) {
total_len = sizeof(body) - 1;
}
int received = httpd_req_recv(request, body, total_len);
if (received > 0) {
sys_uart_write((const uint8_t *)body, (size_t)received);
httpd_resp_sendstr(request, "OK");
return ESP_OK;
}
return httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "No TX data received");
}
static esp_err_t serial_baud_post(httpd_req_t *request)
{
char body[32] = {0};
int received = httpd_req_recv(request, body, sizeof(body) - 1);
if (received > 0) {
body[received] = '\0';
uint32_t baud = (uint32_t)strtoul(body, NULL, 10);
if (baud > 0) {
esp_err_t err = sys_uart_set_baud_rate(baud);
if (err == ESP_OK) {
httpd_resp_sendstr(request, "OK");
return ESP_OK;
}
}
}
return httpd_resp_send_err(request, HTTPD_400_BAD_REQUEST, "Invalid baud rate");
}
static esp_err_t serial_clear_post(httpd_req_t *request)
{
sys_uart_clear_rx_buffer();
httpd_resp_sendstr(request, "OK");
return ESP_OK;
}
void web_server_register_serial_pages(httpd_handle_t server)
{
const httpd_uri_t page = {.uri = "/serial", .method = HTTP_GET, .handler = serial_page_get};
const httpd_uri_t data = {.uri = "/serial/data", .method = HTTP_GET, .handler = serial_data_get};
const httpd_uri_t tx = {.uri = "/serial/tx", .method = HTTP_POST, .handler = serial_tx_post};
const httpd_uri_t baud = {.uri = "/serial/baud", .method = HTTP_POST, .handler = serial_baud_post};
const httpd_uri_t clear = {.uri = "/serial/clear", .method = HTTP_POST, .handler = serial_clear_post};
httpd_register_uri_handler(server, &page);
httpd_register_uri_handler(server, &data);
httpd_register_uri_handler(server, &tx);
httpd_register_uri_handler(server, &baud);
httpd_register_uri_handler(server, &clear);
ESP_LOGI(TAG, "Web Serial Terminal endpoints registered at /serial");
}