Upload files to "/"
This commit is contained in:
70
config.h
Normal file
70
config.h
Normal file
@@ -0,0 +1,70 @@
|
||||
#ifndef CONFIG_H
|
||||
#define CONFIG_H
|
||||
|
||||
// ============================================================
|
||||
// ESP32 Serial Logger - Configuration
|
||||
// ESP-WROOM-32D DevKitC V4
|
||||
// ============================================================
|
||||
|
||||
// --- WiFi Configuration ---
|
||||
#define WIFI_SSID "YourSSID"
|
||||
#define WIFI_PASSWORD "YourPassword"
|
||||
#define WIFI_AP_SSID "ESP32_Logger"
|
||||
#define WIFI_AP_PASSWORD "12345678"
|
||||
#define WIFI_CONNECT_TIMEOUT 15000 // ms
|
||||
|
||||
// --- Serial2 (UART2) Pin Configuration ---
|
||||
#define SERIAL2_TX_PIN 17 // GPIO17
|
||||
#define SERIAL2_RX_PIN 16 // GPIO16
|
||||
|
||||
// --- Serial2 Default Settings ---
|
||||
#define DEFAULT_BAUD_RATE 115200
|
||||
#define DEFAULT_RX_BUFFER 4096
|
||||
|
||||
// --- HSPI SD Card Pin Configuration ---
|
||||
#define SD_HSPI_CLK 14
|
||||
#define SD_HSPI_MISO 26 // Remapped from GPIO12 (strapping pin)
|
||||
#define SD_HSPI_MOSI 13
|
||||
#define SD_HSPI_CS 15
|
||||
|
||||
// --- FreeRTOS Task Priorities ---
|
||||
#define TASK_PRIORITY_SERIAL 5 // Highest - must not miss data
|
||||
#define TASK_PRIORITY_SD_LOG 3 // Medium-high - buffered writes
|
||||
#define TASK_PRIORITY_WEB 2 // Medium - user interface
|
||||
#define TASK_PRIORITY_NTP 1 // Lowest - periodic sync
|
||||
|
||||
// --- FreeRTOS Task Stack Sizes ---
|
||||
#define TASK_STACK_SERIAL 4096
|
||||
#define TASK_STACK_SD_LOG 8192
|
||||
#define TASK_STACK_WEB 8192
|
||||
#define TASK_STACK_NTP 4096
|
||||
|
||||
// --- Queue Configuration ---
|
||||
#define QUEUE_SD_SIZE 512
|
||||
#define QUEUE_WEB_SIZE 256
|
||||
#define QUEUE_TX_SIZE 64
|
||||
|
||||
// --- Logging Configuration ---
|
||||
#define LOG_LINE_MAX_LEN 512
|
||||
#define SD_WRITE_INTERVAL 1000 // Flush to SD every N ms
|
||||
#define LOG_DIR "/logs"
|
||||
#define CSV_HEADER "Timestamp,Direction,Data\r\n"
|
||||
|
||||
// --- NTP Configuration ---
|
||||
#define NTP_SERVER "pool.ntp.org"
|
||||
#define NTP_GMT_OFFSET 32400 // KST = UTC+9 (9*3600)
|
||||
#define NTP_DAYLIGHT_OFFSET 0
|
||||
|
||||
// --- WebSocket Configuration ---
|
||||
#define WS_PORT 81
|
||||
#define WS_MAX_CLIENTS 4
|
||||
|
||||
// --- Log Entry Structure ---
|
||||
struct LogEntry {
|
||||
char timestamp[24]; // "2025-01-15 10:30:45.123"
|
||||
char direction; // 'R' for RX, 'T' for TX
|
||||
char data[LOG_LINE_MAX_LEN];
|
||||
uint16_t dataLen;
|
||||
};
|
||||
|
||||
#endif // CONFIG_H
|
||||
25
serial_task.h
Normal file
25
serial_task.h
Normal file
@@ -0,0 +1,25 @@
|
||||
#ifndef SERIAL_TASK_H
|
||||
#define SERIAL_TASK_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#include <freertos/queue.h>
|
||||
#include "config.h"
|
||||
|
||||
extern QueueHandle_t queueSD;
|
||||
extern QueueHandle_t queueWeb;
|
||||
extern QueueHandle_t queueTX;
|
||||
|
||||
extern volatile uint32_t serialBaud;
|
||||
extern volatile uint8_t serialDataBits;
|
||||
extern volatile char serialParity;
|
||||
extern volatile uint8_t serialStopBits;
|
||||
|
||||
void serialTaskInit();
|
||||
void serialRxTask(void *param);
|
||||
void serialTxTask(void *param);
|
||||
void reconfigureSerial(uint32_t baud, uint8_t dataBits, char parity, uint8_t stopBits);
|
||||
void getTimestamp(char *buf, size_t len);
|
||||
|
||||
#endif
|
||||
490
web_html.h
Normal file
490
web_html.h
Normal file
@@ -0,0 +1,490 @@
|
||||
#ifndef WEB_HTML_H
|
||||
#define WEB_HTML_H
|
||||
|
||||
const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<title>ESP32 Serial Logger</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
|
||||
:root{
|
||||
--bg:#1a1a2e;--panel:#16213e;--accent:#0f3460;
|
||||
--term-bg:#0a0a0a;--text:#e0e0e0;--rx:#00ff41;
|
||||
--tx:#ffd700;--border:#2a2a4a;--btn:#e94560;
|
||||
--btn-hover:#c81e45;--ok:#00c853;--warn:#ff9800;
|
||||
--radius:6px;
|
||||
}
|
||||
html,body{height:100%;overflow:hidden;}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);display:flex;flex-direction:column;}
|
||||
|
||||
/* ===== NAVBAR ===== */
|
||||
.nav{display:flex;align-items:center;background:var(--panel);padding:6px 12px;border-bottom:2px solid var(--accent);flex-shrink:0;gap:8px;min-height:42px;flex-wrap:wrap;}
|
||||
.logo{font-weight:700;font-size:14px;color:var(--btn);white-space:nowrap;}
|
||||
.conn{display:flex;align-items:center;gap:4px;font-size:11px;}
|
||||
.dot{width:8px;height:8px;border-radius:50%;background:#555;flex-shrink:0;}
|
||||
.dot.on{background:var(--ok);box-shadow:0 0 6px var(--ok);}
|
||||
.tabs{display:flex;gap:2px;margin-left:auto;}
|
||||
.tabs button{padding:6px 14px;background:var(--accent);border:none;color:var(--text);cursor:pointer;font-size:12px;font-weight:600;border-radius:4px 4px 0 0;touch-action:manipulation;}
|
||||
.tabs button.act{background:var(--btn);color:#fff;}
|
||||
|
||||
/* ===== MAIN AREA ===== */
|
||||
.main{flex:1;display:flex;overflow:hidden;position:relative;}
|
||||
.page{display:none;position:absolute;inset:0;flex-direction:column;overflow:hidden;}
|
||||
.page.act{display:flex;}
|
||||
|
||||
/* ===== TERMINAL ===== */
|
||||
.t-bar{display:flex;align-items:center;padding:4px 8px;background:var(--panel);gap:6px;border-bottom:1px solid var(--border);flex-shrink:0;flex-wrap:wrap;}
|
||||
.t-bar label{font-size:11px;color:#999;white-space:nowrap;}
|
||||
.t-bar select{background:var(--accent);color:var(--text);border:1px solid var(--border);padding:2px 4px;font-size:11px;border-radius:3px;}
|
||||
.t-bar input[type=checkbox]{width:16px;height:16px;accent-color:var(--btn);}
|
||||
.t-bar .sp{flex:1;min-width:4px;}
|
||||
.t-bar button,.ibtn{padding:4px 10px;background:var(--btn);color:#fff;border:none;border-radius:3px;cursor:pointer;font-size:11px;font-weight:600;touch-action:manipulation;}
|
||||
.t-bar button:active,.ibtn:active{opacity:0.7;}
|
||||
|
||||
#term{flex:1;background:var(--term-bg);padding:6px 8px;overflow-y:auto;overflow-x:hidden;font-family:'Courier New',Consolas,monospace;font-size:13px;line-height:1.4;white-space:pre-wrap;word-break:break-all;-webkit-overflow-scrolling:touch;}
|
||||
#term::-webkit-scrollbar{width:6px;}
|
||||
#term::-webkit-scrollbar-thumb{background:var(--accent);border-radius:3px;}
|
||||
.rx{color:var(--rx);}
|
||||
.tx{color:var(--tx);}
|
||||
.sys{color:#666;font-style:italic;}
|
||||
.ts{color:#555;font-size:10px;margin-right:6px;}
|
||||
|
||||
/* Input Bar */
|
||||
.ibar{display:flex;padding:4px 6px;background:var(--panel);border-top:1px solid var(--border);gap:4px;flex-shrink:0;align-items:center;flex-wrap:wrap;}
|
||||
#txIn{flex:1;min-width:0;background:var(--term-bg);color:var(--tx);border:1px solid var(--border);padding:8px;font-family:monospace;font-size:14px;border-radius:var(--radius);}
|
||||
#txIn:focus{border-color:var(--btn);outline:none;}
|
||||
.ibar .opts{display:flex;align-items:center;gap:4px;flex-shrink:0;}
|
||||
.ibar .opts label{font-size:10px;color:#888;white-space:nowrap;}
|
||||
.ibar .opts select{background:var(--accent);color:var(--text);border:1px solid var(--border);padding:2px;font-size:10px;border-radius:3px;}
|
||||
.sbtn{padding:8px 18px;background:var(--ok);color:#000;font-weight:700;border:none;border-radius:var(--radius);cursor:pointer;font-size:14px;touch-action:manipulation;flex-shrink:0;}
|
||||
.sbtn:active{background:#00e65b;}
|
||||
|
||||
/* ===== SETTINGS ===== */
|
||||
.spage{padding:12px;overflow-y:auto;-webkit-overflow-scrolling:touch;}
|
||||
.sgrp{background:var(--panel);border:1px solid var(--border);border-radius:var(--radius);padding:14px;margin-bottom:12px;}
|
||||
.sgrp h3{color:var(--btn);font-size:13px;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--border);}
|
||||
.fr{display:flex;align-items:center;margin-bottom:8px;gap:8px;}
|
||||
.fr label{width:110px;font-size:12px;color:#bbb;flex-shrink:0;}
|
||||
.fr select,.fr input{flex:1;background:var(--accent);color:var(--text);border:1px solid var(--border);padding:6px 8px;font-size:13px;border-radius:4px;max-width:220px;}
|
||||
.abtn{display:inline-block;padding:8px 24px;background:var(--btn);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:600;margin-top:6px;touch-action:manipulation;}
|
||||
.abtn:active{opacity:0.7;}
|
||||
|
||||
/* ===== FILES ===== */
|
||||
.fpage{display:flex;flex-direction:column;overflow:hidden;padding:8px;}
|
||||
.ftool{display:flex;align-items:center;gap:6px;padding:6px;flex-shrink:0;flex-wrap:wrap;}
|
||||
.ftool button{padding:6px 12px;border:none;border-radius:4px;cursor:pointer;font-size:11px;font-weight:600;touch-action:manipulation;}
|
||||
.ftool .r{background:var(--accent);color:var(--text);}
|
||||
.ftool .d{background:var(--btn);color:#fff;}
|
||||
.ftool .dl{background:var(--ok);color:#000;}
|
||||
.finfo{font-size:11px;color:#888;margin-left:auto;}
|
||||
.ftable{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--border);border-radius:4px;}
|
||||
table{width:100%;border-collapse:collapse;}
|
||||
thead{background:var(--accent);position:sticky;top:0;z-index:1;}
|
||||
th,td{padding:6px 8px;text-align:left;font-size:12px;border-bottom:1px solid var(--border);}
|
||||
th{color:var(--btn);font-weight:600;font-size:11px;text-transform:uppercase;}
|
||||
tr:active{background:rgba(233,69,96,0.1);}
|
||||
.cba,.cbf{width:18px;height:18px;accent-color:var(--btn);}
|
||||
.fl{color:var(--rx);text-decoration:none;}
|
||||
.fs{color:#888;}
|
||||
|
||||
/* ===== STATUS BAR ===== */
|
||||
.sbar{display:flex;align-items:center;padding:3px 10px;background:var(--panel);border-top:1px solid var(--border);font-size:10px;color:#777;gap:10px;flex-shrink:0;flex-wrap:wrap;}
|
||||
.sbar .ld{width:6px;height:6px;border-radius:50%;background:#555;flex-shrink:0;}
|
||||
.sbar .ld.rec{background:var(--btn);animation:bk 1s infinite;}
|
||||
@keyframes bk{50%{opacity:0.2;}}
|
||||
|
||||
/* ===== MOBILE RESPONSIVE ===== */
|
||||
@media(max-width:600px){
|
||||
.nav{padding:4px 8px;gap:4px;}
|
||||
.logo{font-size:12px;}
|
||||
.tabs button{padding:5px 10px;font-size:11px;}
|
||||
.t-bar{padding:3px 6px;gap:4px;}
|
||||
.t-bar label{font-size:10px;}
|
||||
#term{font-size:11px;padding:4px 6px;line-height:1.3;}
|
||||
.ts{font-size:9px;}
|
||||
.ibar{padding:4px;gap:3px;}
|
||||
#txIn{padding:6px;font-size:13px;}
|
||||
.sbtn{padding:6px 14px;font-size:13px;}
|
||||
.ibar .opts{width:100%;justify-content:flex-start;gap:6px;order:3;}
|
||||
.spage{padding:8px;}
|
||||
.sgrp{padding:10px;}
|
||||
.fr{flex-direction:column;align-items:flex-start;gap:4px;}
|
||||
.fr label{width:auto;}
|
||||
.fr select,.fr input{max-width:100%;width:100%;}
|
||||
.ftool{gap:4px;}
|
||||
.ftool button{padding:5px 10px;font-size:10px;}
|
||||
th,td{padding:5px 6px;font-size:11px;}
|
||||
.sbar{font-size:9px;gap:6px;padding:2px 6px;}
|
||||
}
|
||||
|
||||
/* Very small screens */
|
||||
@media(max-width:380px){
|
||||
.tabs button{padding:4px 8px;font-size:10px;}
|
||||
#term{font-size:10px;}
|
||||
#txIn{font-size:12px;}
|
||||
.conn{display:none;}
|
||||
}
|
||||
|
||||
/* Landscape phone */
|
||||
@media(max-height:500px){
|
||||
.nav{min-height:36px;padding:2px 8px;}
|
||||
.t-bar{padding:2px 6px;}
|
||||
.ibar{padding:2px 4px;}
|
||||
.sbar{padding:1px 6px;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- NAV -->
|
||||
<div class="nav">
|
||||
<span class="logo">ESP32 Logger</span>
|
||||
<div class="conn"><span class="dot" id="wsDot"></span><span id="wsSt">OFF</span></div>
|
||||
<div class="tabs">
|
||||
<button class="act" data-tab="terminal">Terminal</button>
|
||||
<button data-tab="settings">Settings</button>
|
||||
<button data-tab="files">Files</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MAIN -->
|
||||
<div class="main">
|
||||
|
||||
<!-- TERMINAL -->
|
||||
<div class="page act" id="p-terminal">
|
||||
<div class="t-bar">
|
||||
<label>View:</label>
|
||||
<select id="dMode"><option value="a">ASCII</option><option value="h">HEX</option><option value="b">Both</option></select>
|
||||
<label>Auto↓</label><input type="checkbox" id="aScr" checked>
|
||||
<label>Time</label><input type="checkbox" id="sTs" checked>
|
||||
<span class="sp"></span>
|
||||
<button onclick="clrTerm()">Clear</button>
|
||||
<button onclick="expTerm()">Export</button>
|
||||
</div>
|
||||
<div id="term"></div>
|
||||
<div class="ibar">
|
||||
<input type="text" id="txIn" placeholder="Send data..." enterkeyhint="send" inputmode="text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
|
||||
<button class="sbtn" id="sendBtn" onclick="txSend()">Send</button>
|
||||
<div class="opts">
|
||||
<label>End:</label>
|
||||
<select id="lEnd"><option value="crlf">CR+LF</option><option value="cr">CR</option><option value="lf">LF</option><option value="none">None</option></select>
|
||||
<label>Mode:</label>
|
||||
<select id="sMode"><option value="ascii">ASCII</option><option value="hex">HEX</option></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SETTINGS -->
|
||||
<div class="page" id="p-settings">
|
||||
<div class="spage">
|
||||
<div class="sgrp">
|
||||
<h3>Serial Port</h3>
|
||||
<div class="fr"><label>Baud Rate</label>
|
||||
<select id="baud"><option value="1200">1200</option><option value="2400">2400</option><option value="4800">4800</option><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><option value="1000000">1M</option><option value="2000000">2M</option></select>
|
||||
</div>
|
||||
<div class="fr"><label>Data Bits</label><select id="dBits"><option value="5">5</option><option value="6">6</option><option value="7">7</option><option value="8" selected>8</option></select></div>
|
||||
<div class="fr"><label>Parity</label><select id="par"><option value="N" selected>None</option><option value="E">Even</option><option value="O">Odd</option></select></div>
|
||||
<div class="fr"><label>Stop Bits</label><select id="sBits"><option value="1" selected>1</option><option value="2">2</option></select></div>
|
||||
<button class="abtn" onclick="applySerial()">Apply</button>
|
||||
</div>
|
||||
<div class="sgrp">
|
||||
<h3>System Info</h3>
|
||||
<div class="fr"><label>IP Address</label><input id="sIp" readonly></div>
|
||||
<div class="fr"><label>RSSI</label><input id="sRssi" readonly></div>
|
||||
<div class="fr"><label>Free Heap</label><input id="sHeap" readonly></div>
|
||||
<div class="fr"><label>Uptime</label><input id="sUp" readonly></div>
|
||||
<button class="abtn" onclick="sendC('sysinfo')" style="background:var(--accent);">Refresh</button>
|
||||
</div>
|
||||
<div class="sgrp">
|
||||
<h3>SD Logging</h3>
|
||||
<div class="fr"><label>Status</label><span id="logSt" style="color:var(--ok);font-size:13px;">Active</span></div>
|
||||
<div class="fr"><label>Log File</label><input id="logFile" readonly style="font-size:11px;"></div>
|
||||
<div style="display:flex;gap:8px;margin-top:6px;flex-wrap:wrap;">
|
||||
<button class="abtn" onclick="sendC('toggle_log')" id="logBtn">Stop Logging</button>
|
||||
<button class="abtn" onclick="sendC('new_log')" style="background:var(--warn);color:#000;">New File</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FILES -->
|
||||
<div class="page" id="p-files">
|
||||
<div class="fpage">
|
||||
<div class="ftool">
|
||||
<button class="r" onclick="loadFiles()">Refresh</button>
|
||||
<button class="d" onclick="delSel()">Delete</button>
|
||||
<button class="dl" onclick="dlSel()">Download</button>
|
||||
<span class="finfo" id="fInfo">0 files</span>
|
||||
</div>
|
||||
<div class="ftable">
|
||||
<table><thead><tr>
|
||||
<th style="width:32px;"><input type="checkbox" class="cba" onchange="togAll(this)"></th>
|
||||
<th>File</th><th>Size</th><th>Date</th>
|
||||
</tr></thead><tbody id="fList"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STATUS BAR -->
|
||||
<div class="sbar">
|
||||
<span class="ld rec" id="lDot"></span>
|
||||
<span id="lInfo">Logging...</span>
|
||||
<span id="serInfo">115200 8N1</span>
|
||||
<span id="rxC">RX:0</span>
|
||||
<span id="txC">TX:0</span>
|
||||
<span style="margin-left:auto;" id="tInfo">--</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ===== Globals =====
|
||||
let ws=null,rxB=0,txB=0,tLines=0;
|
||||
const ML=5000;
|
||||
|
||||
// ===== Tab Switching =====
|
||||
document.querySelectorAll('.tabs button').forEach(b=>{
|
||||
b.addEventListener('click',function(){
|
||||
document.querySelectorAll('.tabs button').forEach(x=>x.classList.remove('act'));
|
||||
document.querySelectorAll('.page').forEach(x=>x.classList.remove('act'));
|
||||
this.classList.add('act');
|
||||
document.getElementById('p-'+this.dataset.tab).classList.add('act');
|
||||
if(this.dataset.tab==='files') loadFiles();
|
||||
if(this.dataset.tab==='settings') sendC('sysinfo');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== WebSocket (port 81) =====
|
||||
function wsConn(){
|
||||
ws=new WebSocket('ws://'+location.hostname+':81');
|
||||
ws.onopen=function(){
|
||||
document.getElementById('wsDot').classList.add('on');
|
||||
document.getElementById('wsSt').textContent='ON';
|
||||
addSys('[Connected]');
|
||||
// Send browser time to ESP32 for clock sync
|
||||
let now=new Date();
|
||||
let epoch=Math.floor(now.getTime()/1000);
|
||||
let ms=now.getMilliseconds();
|
||||
ws.send(JSON.stringify({cmd:'set_time',epoch:epoch,ms:ms}));
|
||||
addSys('[Time sync: '+now.toLocaleString()+']');
|
||||
sendC('sysinfo');
|
||||
sendC('get_serial_config');
|
||||
};
|
||||
ws.onmessage=function(e){
|
||||
try{
|
||||
let m=JSON.parse(e.data);
|
||||
if(m.type==='rx'||m.type==='tx') addLine(m);
|
||||
else if(m.type==='sysinfo') updSys(m);
|
||||
else if(m.type==='serial_config') updSer(m);
|
||||
else if(m.type==='log_status') updLog(m);
|
||||
else if(m.type==='time_synced'&&m.ok) addSys('[ESP32 time synced OK]');
|
||||
}catch(x){}
|
||||
};
|
||||
ws.onclose=function(){
|
||||
document.getElementById('wsDot').classList.remove('on');
|
||||
document.getElementById('wsSt').textContent='OFF';
|
||||
setTimeout(wsConn,2000);
|
||||
};
|
||||
ws.onerror=function(){ws.close();};
|
||||
}
|
||||
|
||||
function sendC(c){if(ws&&ws.readyState===1)ws.send(JSON.stringify({cmd:c}));}
|
||||
|
||||
// ===== Terminal =====
|
||||
function addLine(m){
|
||||
const t=document.getElementById('term');
|
||||
const ts=document.getElementById('sTs').checked;
|
||||
const dm=document.getElementById('dMode').value;
|
||||
const dir=m.type==='rx'?'RX':'TX';
|
||||
const cls=m.type==='rx'?'rx':'tx';
|
||||
let txt=m.data||'';
|
||||
if(dm==='h') txt=hex(txt);
|
||||
else if(dm==='b') txt=txt+' ['+hex(txt)+']';
|
||||
|
||||
let d=document.createElement('div');
|
||||
d.className=cls;
|
||||
let h='';
|
||||
if(ts&&m.ts) h+='<span class="ts">'+esc(m.ts)+'</span>';
|
||||
h+='['+dir+'] '+esc(txt);
|
||||
d.innerHTML=h;
|
||||
t.appendChild(d);
|
||||
|
||||
if(m.type==='rx') rxB+=(m.data||'').length; else txB+=(m.data||'').length;
|
||||
document.getElementById('rxC').textContent='RX:'+fB(rxB);
|
||||
document.getElementById('txC').textContent='TX:'+fB(txB);
|
||||
|
||||
tLines++;
|
||||
while(tLines>ML){t.removeChild(t.firstChild);tLines--;}
|
||||
if(document.getElementById('aScr').checked) t.scrollTop=t.scrollHeight;
|
||||
}
|
||||
|
||||
function addSys(s){
|
||||
const t=document.getElementById('term');
|
||||
let d=document.createElement('div');
|
||||
d.className='sys';
|
||||
d.textContent=s;
|
||||
t.appendChild(d);
|
||||
t.scrollTop=t.scrollHeight;
|
||||
}
|
||||
|
||||
function clrTerm(){
|
||||
document.getElementById('term').innerHTML='';
|
||||
tLines=0;rxB=0;txB=0;
|
||||
document.getElementById('rxC').textContent='RX:0';
|
||||
document.getElementById('txC').textContent='TX:0';
|
||||
}
|
||||
|
||||
function expTerm(){
|
||||
const txt=document.getElementById('term').innerText;
|
||||
const b=new Blob([txt],{type:'text/plain'});
|
||||
const a=document.createElement('a');
|
||||
a.href=URL.createObjectURL(b);
|
||||
a.download='log_'+new Date().toISOString().slice(0,19).replace(/:/g,'')+'.txt';
|
||||
a.click();
|
||||
}
|
||||
|
||||
// ===== Send Data =====
|
||||
function txSend(){
|
||||
const inp=document.getElementById('txIn');
|
||||
const txt=inp.value;
|
||||
if(!txt||!ws||ws.readyState!==1) return;
|
||||
ws.send(JSON.stringify({
|
||||
cmd:'tx',
|
||||
data:txt,
|
||||
lineEnd:document.getElementById('lEnd').value,
|
||||
mode:document.getElementById('sMode').value
|
||||
}));
|
||||
inp.value='';
|
||||
inp.focus();
|
||||
}
|
||||
|
||||
// Enter key handling (works on both desktop & mobile)
|
||||
document.getElementById('txIn').addEventListener('keydown',function(e){
|
||||
if(e.key==='Enter'||e.keyCode===13){
|
||||
e.preventDefault();
|
||||
txSend();
|
||||
}
|
||||
});
|
||||
|
||||
// iOS "Go" / Android "Send" button on virtual keyboard
|
||||
document.getElementById('txIn').addEventListener('keypress',function(e){
|
||||
if(e.key==='Enter'||e.keyCode===13){
|
||||
e.preventDefault();
|
||||
txSend();
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback: form-like submit via compositionend (for IME)
|
||||
document.getElementById('txIn').addEventListener('compositionend',function(e){
|
||||
// Don't auto-send on composition end - user needs to press Enter
|
||||
});
|
||||
|
||||
// ===== Serial Settings =====
|
||||
function applySerial(){
|
||||
if(!ws||ws.readyState!==1){alert('Not connected');return;}
|
||||
ws.send(JSON.stringify({
|
||||
cmd:'serial_config',
|
||||
baud:parseInt(document.getElementById('baud').value),
|
||||
dataBits:parseInt(document.getElementById('dBits').value),
|
||||
parity:document.getElementById('par').value,
|
||||
stopBits:parseInt(document.getElementById('sBits').value)
|
||||
}));
|
||||
addSys('[Serial config applied]');
|
||||
}
|
||||
|
||||
function updSer(m){
|
||||
if(m.baud) document.getElementById('baud').value=m.baud;
|
||||
if(m.dataBits) document.getElementById('dBits').value=m.dataBits;
|
||||
if(m.parity) document.getElementById('par').value=m.parity;
|
||||
if(m.stopBits) document.getElementById('sBits').value=m.stopBits;
|
||||
document.getElementById('serInfo').textContent=m.baud+' '+m.dataBits+m.parity+m.stopBits;
|
||||
}
|
||||
|
||||
// ===== System Info =====
|
||||
function updSys(m){
|
||||
document.getElementById('sIp').value=m.ip||'';
|
||||
document.getElementById('sRssi').value=(m.rssi||'')+' dBm';
|
||||
document.getElementById('sHeap').value=fB(m.heap||0);
|
||||
document.getElementById('sUp').value=fUp(m.uptime||0);
|
||||
document.getElementById('tInfo').textContent=m.time||'--';
|
||||
}
|
||||
|
||||
// ===== Log Status =====
|
||||
function updLog(m){
|
||||
const dt=document.getElementById('lDot'),inf=document.getElementById('lInfo');
|
||||
const st=document.getElementById('logSt'),btn=document.getElementById('logBtn');
|
||||
if(m.active){
|
||||
dt.classList.add('rec');inf.textContent='Logging: '+(m.file||'');
|
||||
st.textContent='Active';st.style.color='var(--ok)';btn.textContent='Stop Logging';
|
||||
}else{
|
||||
dt.classList.remove('rec');inf.textContent='Stopped';
|
||||
st.textContent='Stopped';st.style.color='var(--btn)';btn.textContent='Start Logging';
|
||||
}
|
||||
document.getElementById('logFile').value=m.file||'';
|
||||
}
|
||||
|
||||
// ===== File Manager =====
|
||||
function loadFiles(){
|
||||
fetch('/api/files').then(r=>r.json()).then(d=>{
|
||||
const tb=document.getElementById('fList');tb.innerHTML='';
|
||||
if(!d.files||!d.files.length){
|
||||
tb.innerHTML='<tr><td colspan="4" style="text-align:center;color:#666;padding:20px;">No files</td></tr>';
|
||||
document.getElementById('fInfo').textContent='0 files';return;
|
||||
}
|
||||
let tot=0;
|
||||
d.files.forEach(f=>{
|
||||
tot+=f.size;
|
||||
let tr=document.createElement('tr');
|
||||
tr.innerHTML='<td><input type="checkbox" class="cbf" value="'+esc(f.name)+'"></td>'
|
||||
+'<td><a class="fl" href="/download?file='+encodeURIComponent(f.name)+'">'+esc(f.name)+'</a></td>'
|
||||
+'<td class="fs">'+fB(f.size)+'</td>'
|
||||
+'<td class="fs">'+(f.modified||'').slice(5)+'</td>';
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
document.getElementById('fInfo').textContent=d.files.length+' files ('+fB(tot)+')';
|
||||
}).catch(()=>addSys('[File list error]'));
|
||||
}
|
||||
|
||||
function togAll(cb){document.querySelectorAll('.cbf').forEach(c=>c.checked=cb.checked);}
|
||||
function getSel(){return Array.from(document.querySelectorAll('.cbf:checked')).map(c=>c.value);}
|
||||
|
||||
function delSel(){
|
||||
const f=getSel();
|
||||
if(!f.length){alert('Select files first');return;}
|
||||
if(!confirm('Delete '+f.length+' file(s)?'))return;
|
||||
fetch('/api/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({files:f})})
|
||||
.then(r=>r.json()).then(d=>{addSys('[Deleted '+(d.deleted||0)+' files]');loadFiles();})
|
||||
.catch(()=>alert('Delete failed'));
|
||||
}
|
||||
|
||||
function dlSel(){
|
||||
const f=getSel();
|
||||
if(!f.length){alert('Select files first');return;}
|
||||
f.forEach(n=>{let a=document.createElement('a');a.href='/download?file='+encodeURIComponent(n);a.download=n;a.click();});
|
||||
}
|
||||
|
||||
// ===== Utility =====
|
||||
function esc(s){let d=document.createElement('div');d.textContent=s;return d.innerHTML;}
|
||||
function hex(s){return Array.from(s).map(c=>c.charCodeAt(0).toString(16).padStart(2,'0').toUpperCase()).join(' ');}
|
||||
function fB(b){if(b<1024)return b+'B';if(b<1048576)return(b/1024).toFixed(1)+'K';return(b/1048576).toFixed(1)+'M';}
|
||||
function fUp(s){let d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60);return d+'d '+h+'h '+m+'m';}
|
||||
|
||||
// ===== Init =====
|
||||
wsConn();
|
||||
setInterval(()=>sendC('sysinfo'),10000);
|
||||
|
||||
// Prevent pull-to-refresh on mobile (interferes with scrolling terminal)
|
||||
document.body.addEventListener('touchmove',function(e){
|
||||
if(e.target.closest('#term,.ftable,.spage')) return;
|
||||
e.preventDefault();
|
||||
},{passive:false});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
)rawliteral";
|
||||
|
||||
#endif // WEB_HTML_H
|
||||
346
web_task.cpp
Normal file
346
web_task.cpp
Normal file
@@ -0,0 +1,346 @@
|
||||
#include "web_task.h"
|
||||
#include "web_html.h"
|
||||
#include "serial_task.h"
|
||||
#include "sdcard_task.h"
|
||||
#include <ArduinoJson.h>
|
||||
#include <time.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
// --- Web Server (port 80) & WebSocket (port 81) ---
|
||||
WebServer server(80);
|
||||
WebSocketsServer webSocket = WebSocketsServer(WS_PORT);
|
||||
|
||||
// ============================================================
|
||||
// WebSocket Event Handler (Links2004 pattern)
|
||||
// ============================================================
|
||||
void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length) {
|
||||
switch (type) {
|
||||
case WStype_CONNECTED: {
|
||||
Serial.printf("[WS] Client #%u connected\n", num);
|
||||
|
||||
// Send current status on connect
|
||||
StaticJsonDocument<256> doc;
|
||||
doc["type"] = "log_status";
|
||||
doc["active"] = sdLoggingActive;
|
||||
doc["file"] = currentLogFileName;
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
webSocket.sendTXT(num, json);
|
||||
break;
|
||||
}
|
||||
|
||||
case WStype_DISCONNECTED:
|
||||
Serial.printf("[WS] Client #%u disconnected\n", num);
|
||||
break;
|
||||
|
||||
case WStype_TEXT:
|
||||
handleWsMessage(num, (const char*)payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Process WebSocket Commands from Browser
|
||||
// ============================================================
|
||||
void handleWsMessage(uint8_t num, const char *message) {
|
||||
StaticJsonDocument<512> doc;
|
||||
DeserializationError err = deserializeJson(doc, message);
|
||||
if (err) return;
|
||||
|
||||
const char *cmd = doc["cmd"];
|
||||
if (!cmd) return;
|
||||
|
||||
// --- Set Time from browser ---
|
||||
if (strcmp(cmd, "set_time") == 0) {
|
||||
uint32_t epoch = doc["epoch"] | 0;
|
||||
uint16_t ms = doc["ms"] | 0;
|
||||
|
||||
if (epoch > 1700000000) { // Sanity check: after 2023
|
||||
struct timeval tv;
|
||||
tv.tv_sec = (time_t)epoch;
|
||||
tv.tv_usec = (suseconds_t)ms * 1000;
|
||||
settimeofday(&tv, NULL);
|
||||
|
||||
// Verify
|
||||
struct tm timeinfo;
|
||||
localtime_r(&tv.tv_sec, &timeinfo);
|
||||
Serial.printf("[Time] Synced from browser: %04d-%02d-%02d %02d:%02d:%02d\n",
|
||||
timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday,
|
||||
timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
|
||||
|
||||
// Broadcast confirmation
|
||||
StaticJsonDocument<128> resp;
|
||||
resp["type"] = "time_synced";
|
||||
resp["ok"] = true;
|
||||
String json;
|
||||
serializeJson(resp, json);
|
||||
webSocket.sendTXT(num, json);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- TX: Send data through serial ---
|
||||
if (strcmp(cmd, "tx") == 0) {
|
||||
const char *data = doc["data"];
|
||||
const char *lineEnd = doc["lineEnd"];
|
||||
const char *mode = doc["mode"];
|
||||
if (!data) return;
|
||||
|
||||
LogEntry *entry = (LogEntry*)pvPortMalloc(sizeof(LogEntry));
|
||||
if (!entry) return;
|
||||
|
||||
getTimestamp(entry->timestamp, sizeof(entry->timestamp));
|
||||
entry->direction = 'T';
|
||||
|
||||
if (mode && strcmp(mode, "hex") == 0) {
|
||||
String hexStr = data;
|
||||
hexStr.trim();
|
||||
int pos = 0;
|
||||
for (unsigned int i = 0; i < hexStr.length() && pos < LOG_LINE_MAX_LEN - 1; ) {
|
||||
while (i < hexStr.length() && hexStr[i] == ' ') i++;
|
||||
if (i + 1 < hexStr.length()) {
|
||||
char hex[3] = { hexStr[i], hexStr[i+1], 0 };
|
||||
entry->data[pos++] = (char)strtol(hex, NULL, 16);
|
||||
i += 2;
|
||||
} else break;
|
||||
}
|
||||
entry->data[pos] = '\0';
|
||||
entry->dataLen = pos;
|
||||
} else {
|
||||
int dLen = strlen(data);
|
||||
if (dLen > LOG_LINE_MAX_LEN - 4) dLen = LOG_LINE_MAX_LEN - 4;
|
||||
memcpy(entry->data, data, dLen);
|
||||
entry->dataLen = dLen;
|
||||
|
||||
// Append line ending
|
||||
if (lineEnd) {
|
||||
if (strcmp(lineEnd, "crlf") == 0) {
|
||||
entry->data[entry->dataLen++] = '\r';
|
||||
entry->data[entry->dataLen++] = '\n';
|
||||
} else if (strcmp(lineEnd, "cr") == 0) {
|
||||
entry->data[entry->dataLen++] = '\r';
|
||||
} else if (strcmp(lineEnd, "lf") == 0) {
|
||||
entry->data[entry->dataLen++] = '\n';
|
||||
}
|
||||
// "none" - no line ending
|
||||
}
|
||||
entry->data[entry->dataLen] = '\0';
|
||||
}
|
||||
|
||||
if (xQueueSend(queueTX, &entry, pdMS_TO_TICKS(100)) != pdTRUE) {
|
||||
vPortFree(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Serial Config ---
|
||||
else if (strcmp(cmd, "serial_config") == 0) {
|
||||
uint32_t baud = doc["baud"] | (uint32_t)DEFAULT_BAUD_RATE;
|
||||
uint8_t dataBits = doc["dataBits"] | 8;
|
||||
const char *p = doc["parity"] | "N";
|
||||
uint8_t stopBits = doc["stopBits"] | 1;
|
||||
|
||||
reconfigureSerial(baud, dataBits, p[0], stopBits);
|
||||
|
||||
// Broadcast new config to all clients
|
||||
StaticJsonDocument<256> resp;
|
||||
resp["type"] = "serial_config";
|
||||
resp["baud"] = baud;
|
||||
resp["dataBits"] = dataBits;
|
||||
resp["parity"] = String(p[0]);
|
||||
resp["stopBits"] = stopBits;
|
||||
String json;
|
||||
serializeJson(resp, json);
|
||||
webSocket.broadcastTXT(json);
|
||||
}
|
||||
|
||||
// --- Get Serial Config ---
|
||||
else if (strcmp(cmd, "get_serial_config") == 0) {
|
||||
StaticJsonDocument<256> resp;
|
||||
resp["type"] = "serial_config";
|
||||
resp["baud"] = (uint32_t)serialBaud;
|
||||
resp["dataBits"] = serialDataBits;
|
||||
resp["parity"] = String((char)serialParity);
|
||||
resp["stopBits"] = serialStopBits;
|
||||
String json;
|
||||
serializeJson(resp, json);
|
||||
webSocket.sendTXT(num, json);
|
||||
}
|
||||
|
||||
// --- System Info ---
|
||||
else if (strcmp(cmd, "sysinfo") == 0) {
|
||||
StaticJsonDocument<512> resp;
|
||||
resp["type"] = "sysinfo";
|
||||
resp["ip"] = WiFi.localIP().toString();
|
||||
resp["rssi"] = WiFi.RSSI();
|
||||
resp["heap"] = ESP.getFreeHeap();
|
||||
resp["uptime"] = millis() / 1000;
|
||||
char timeBuf[24];
|
||||
getTimestamp(timeBuf, sizeof(timeBuf));
|
||||
resp["time"] = timeBuf;
|
||||
String json;
|
||||
serializeJson(resp, json);
|
||||
webSocket.sendTXT(num, json);
|
||||
}
|
||||
|
||||
// --- Toggle Logging ---
|
||||
else if (strcmp(cmd, "toggle_log") == 0) {
|
||||
if (sdLoggingActive) sdStopLogging();
|
||||
else sdStartLogging();
|
||||
|
||||
StaticJsonDocument<256> resp;
|
||||
resp["type"] = "log_status";
|
||||
resp["active"] = sdLoggingActive;
|
||||
resp["file"] = currentLogFileName;
|
||||
String json;
|
||||
serializeJson(resp, json);
|
||||
webSocket.broadcastTXT(json);
|
||||
}
|
||||
|
||||
// --- New Log File ---
|
||||
else if (strcmp(cmd, "new_log") == 0) {
|
||||
sdCreateNewLogFile();
|
||||
sdLoggingActive = true;
|
||||
|
||||
StaticJsonDocument<256> resp;
|
||||
resp["type"] = "log_status";
|
||||
resp["active"] = true;
|
||||
resp["file"] = currentLogFileName;
|
||||
String json;
|
||||
serializeJson(resp, json);
|
||||
webSocket.broadcastTXT(json);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Setup Web Server Routes
|
||||
// ============================================================
|
||||
void setupWebRoutes() {
|
||||
// Main page
|
||||
server.on("/", HTTP_GET, []() {
|
||||
server.send_P(200, "text/html", INDEX_HTML);
|
||||
});
|
||||
|
||||
// API: File List
|
||||
server.on("/api/files", HTTP_GET, []() {
|
||||
String json = sdGetFileList();
|
||||
server.send(200, "application/json", json);
|
||||
});
|
||||
|
||||
// API: Delete Files (POST with JSON body)
|
||||
server.on("/api/delete", HTTP_POST, []() {
|
||||
if (!server.hasArg("plain")) {
|
||||
server.send(400, "application/json", "{\"error\":\"no body\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
StaticJsonDocument<1024> doc;
|
||||
DeserializationError err = deserializeJson(doc, server.arg("plain"));
|
||||
if (err) {
|
||||
server.send(400, "application/json", "{\"error\":\"invalid json\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
JsonArray files = doc["files"];
|
||||
int deleted = 0;
|
||||
for (JsonVariant f : files) {
|
||||
const char *fname = f.as<const char*>();
|
||||
if (fname && sdDeleteFile(fname)) deleted++;
|
||||
}
|
||||
|
||||
String resp = "{\"deleted\":" + String(deleted) + "}";
|
||||
server.send(200, "application/json", resp);
|
||||
});
|
||||
|
||||
// API: Download File
|
||||
server.on("/download", HTTP_GET, []() {
|
||||
if (!server.hasArg("file")) {
|
||||
server.send(400, "text/plain", "Missing file parameter");
|
||||
return;
|
||||
}
|
||||
String filename = server.arg("file");
|
||||
|
||||
// Security: prevent path traversal
|
||||
if (filename.indexOf("..") >= 0) {
|
||||
server.send(403, "text/plain", "Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
String path = String(LOG_DIR) + "/" + filename;
|
||||
if (!SD.exists(path)) {
|
||||
server.send(404, "text/plain", "File not found");
|
||||
return;
|
||||
}
|
||||
|
||||
File file = SD.open(path, FILE_READ);
|
||||
if (!file) {
|
||||
server.send(500, "text/plain", "Cannot open file");
|
||||
return;
|
||||
}
|
||||
|
||||
size_t fileSize = file.size();
|
||||
server.sendHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
|
||||
server.sendHeader("Content-Length", String(fileSize));
|
||||
server.streamFile(file, "text/csv");
|
||||
file.close();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Web Broadcast Task - Sends serial data to WebSocket clients
|
||||
// Runs webSocket.loop() and broadcasts queued data
|
||||
// ============================================================
|
||||
void webBroadcastTask(void *param) {
|
||||
Serial.println("[Task] WebBroadcast started on core " + String(xPortGetCoreID()));
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(500)); // Initial stabilization
|
||||
|
||||
while (true) {
|
||||
// Process WebSocket events (REQUIRED for Links2004 library)
|
||||
webSocket.loop();
|
||||
|
||||
// Process web queue entries and broadcast
|
||||
if (webSocket.connectedClients() > 0) {
|
||||
LogEntry *entry;
|
||||
// Process up to 10 entries per cycle for responsiveness
|
||||
int processed = 0;
|
||||
while (processed < 10 && xQueueReceive(queueWeb, &entry, 0) == pdTRUE) {
|
||||
StaticJsonDocument<768> doc;
|
||||
doc["type"] = (entry->direction == 'T') ? "tx" : "rx";
|
||||
doc["ts"] = entry->timestamp;
|
||||
doc["data"] = entry->data;
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
webSocket.broadcastTXT(json);
|
||||
vPortFree(entry);
|
||||
processed++;
|
||||
}
|
||||
} else {
|
||||
// No clients - drain web queue to prevent memory buildup
|
||||
LogEntry *entry;
|
||||
while (xQueueReceive(queueWeb, &entry, 0) == pdTRUE) {
|
||||
vPortFree(entry);
|
||||
}
|
||||
}
|
||||
|
||||
vTaskDelay(pdMS_TO_TICKS(10));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Initialize Web Server & WebSocket
|
||||
// ============================================================
|
||||
void webTaskInit() {
|
||||
setupWebRoutes();
|
||||
|
||||
webSocket.begin();
|
||||
webSocket.onEvent(webSocketEvent);
|
||||
Serial.printf("[Web] WebSocket started on port %d\n", WS_PORT);
|
||||
|
||||
server.begin();
|
||||
Serial.println("[Web] HTTP server started on port 80");
|
||||
|
||||
// Create broadcast task on core 0
|
||||
xTaskCreatePinnedToCore(webBroadcastTask, "WebBC", TASK_STACK_WEB,
|
||||
NULL, TASK_PRIORITY_WEB, NULL, 0);
|
||||
}
|
||||
21
web_task.h
Normal file
21
web_task.h
Normal file
@@ -0,0 +1,21 @@
|
||||
#ifndef WEB_TASK_H
|
||||
#define WEB_TASK_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <WiFi.h>
|
||||
#include <WebServer.h>
|
||||
#include <WebSocketsServer.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#include "config.h"
|
||||
|
||||
extern WebServer server;
|
||||
extern WebSocketsServer webSocket;
|
||||
|
||||
void webTaskInit();
|
||||
void webBroadcastTask(void *param);
|
||||
void setupWebRoutes();
|
||||
void webSocketEvent(uint8_t num, WStype_t type, uint8_t *payload, size_t length);
|
||||
void handleWsMessage(uint8_t num, const char *message);
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user