Upload files to "/"

This commit is contained in:
2026-02-14 23:13:42 +00:00
commit 509102d57c
5 changed files with 952 additions and 0 deletions

70
config.h Normal file
View 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
View 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
View 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&#8595;</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
View 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
View 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