기본 동작 확인

This commit is contained in:
2026-04-30 17:40:07 +00:00
parent fe9874eb5a
commit 4dc7c5f0f6
6 changed files with 2389 additions and 0 deletions

1248
CANFD_Logger.ino Normal file

File diff suppressed because it is too large Load Diff

157
graph.h Normal file
View File

@@ -0,0 +1,157 @@
#ifndef GRAPH_H
#define GRAPH_H
const char graph_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html lang="ko"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title> - CANFD Logger</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;font-size:13px}
nav{background:#161b22;border-bottom:1px solid #30363d;padding:8px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
nav a{color:#58a6ff;text-decoration:none;padding:4px 10px;border-radius:6px;font-size:12px}
nav a:hover{background:#21262d} nav a.active{background:#1f6feb;color:#fff}
.title{color:#e6edf3;font-weight:700;font-size:15px}
.container{padding:12px}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;margin-bottom:10px}
.card h3{font-size:12px;color:#8b949e;margin-bottom:12px;text-transform:uppercase}
input[type=text],select{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:5px 8px;border-radius:6px;font-size:12px}
.btn{padding:5px 12px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600}
.btn-blue{background:#1f6feb;color:#fff} .btn-green{background:#238636;color:#fff}
.btn-gray{background:#21262d;color:#c9d1d9;border:1px solid #30363d}
.row{display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;margin-bottom:8px}
.field{margin-bottom:4px} label{display:block;font-size:11px;color:#8b949e;margin-bottom:3px}
canvas{width:100%;height:300px;background:#0d1117;border:1px solid #30363d;border-radius:6px}
.sig-list{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}
.sig-chip{background:#21262d;border:1px solid #30363d;border-radius:12px;padding:3px 10px;font-size:11px;display:flex;align-items:center;gap:6px}
.sig-color{width:10px;height:10px;border-radius:50%;display:inline-block}
</style>
</head><body>
<nav>
<span class="title"> CANFD Logger</span>
<a href="/"></a><a href="/transmit"></a>
<a href="/graph" class="active"></a><a href="/settings"></a>
</nav>
<div class="container">
<div class="card">
<h3> (CAN FD)</h3>
<div class="row">
<div class="field"><label>CAN ID (hex)</label><input type="text" id="sigId" value="0x123" style="width:90px"></div>
<div class="field"><label> </label><input type="text" id="sigStart" value="0" style="width:60px"></div>
<div class="field"><label> </label><input type="text" id="sigLen" value="8" style="width:60px"></div>
<div class="field"><label></label><input type="text" id="sigScale" value="1" style="width:60px"></div>
<div class="field"><label></label><input type="text" id="sigName" value="Signal 1" style="width:90px"></div>
<div class="field" style="margin-bottom:4px"><button class="btn btn-blue" onclick="addSignal()">+ </button></div>
</div>
<div class="sig-list" id="sigList"></div>
<canvas id="chart"></canvas>
<div style="margin-top:8px;display:flex;gap:8px">
<button class="btn btn-gray" onclick="clearSignals()"> </button>
<span style="font-size:11px;color:#8b949e;margin-top:8px"> 8 , 500 </span>
</div>
</div>
</div>
<script>
const COLORS=['#58a6ff','#3fb950','#d29922','#f85149','#a371f7','#ffa657','#79c0ff','#56d364'];
const DLC_LEN=[0,1,2,3,4,5,6,7,8,12,16,20,24,32,48,64];
let signals=[], ws, reconnTimer;
const MAX_PTS=500;
function addSignal(){
if(signals.length>=8) return alert(' 8');
let id=parseInt(document.getElementById('sigId').value,16);
let s=parseInt(document.getElementById('sigStart').value);
let l=parseInt(document.getElementById('sigLen').value);
let sc=parseFloat(document.getElementById('sigScale').value)||1;
let nm=document.getElementById('sigName').value||('Signal '+(signals.length+1));
let color=COLORS[signals.length%COLORS.length];
signals.push({id,startBit:s,bitLen:l,scale:sc,name:nm,color,pts:[],lastVal:null});
renderChips(); draw();
}
function clearSignals(){ signals=[]; renderChips(); draw(); }
function renderChips(){
let html='';
signals.forEach((s,i)=>{ html+=`<div class="sig-chip"><span class="sig-color" style="background:${s.color}"></span>${s.name} (0x${s.id.toString(16).toUpperCase()})<span onclick="removeSignal(${i})" style="cursor:pointer;color:#8b949e"></span></div>`; });
document.getElementById('sigList').innerHTML=html;
}
function removeSignal(i){ signals.splice(i,1); renderChips(); draw(); }
function extractBits(data,start,len){
let v=0;
for(let i=0;i<len;i++){
let bit=start+i; let b=Math.floor(bit/8); let bit2=7-(bit%8);
if(b<data.length&&(data[b]&(1<<bit2))) v|=(1<<(len-1-i));
}
return v;
}
function processMsg(m){
signals.forEach(s=>{
if(s.id===m.id){
let v=extractBits(m.data||[],s.startBit,s.bitLen)*s.scale;
let ts=Date.now();
s.pts.push({x:ts,y:v}); s.lastVal=v;
if(s.pts.length>MAX_PTS) s.pts.shift();
}
});
draw();
}
const cv=document.getElementById('chart');
const ctx=cv.getContext('2d');
function draw(){
let W=cv.offsetWidth, H=cv.offsetHeight;
cv.width=W; cv.height=H;
ctx.fillStyle='#0d1117'; ctx.fillRect(0,0,W,H);
if(signals.length===0){
ctx.fillStyle='#8b949e'; ctx.font='13px sans-serif'; ctx.textAlign='center';
ctx.fillText(' .',W/2,H/2); return;
}
let allPts=signals.flatMap(s=>s.pts);
if(allPts.length<2) return;
let now=Date.now();
let minX=now-30000, maxX=now;
let allY=allPts.map(p=>p.y);
let minY=Math.min(...allY), maxY=Math.max(...allY);
if(minY===maxY){minY-=1;maxY+=1;}
let pad=30;
function toX(x){return pad+(x-minX)/(maxX-minX)*(W-pad*2);}
function toY(y){return H-pad-(y-minY)/(maxY-minY)*(H-pad*2);}
// 격자
ctx.strokeStyle='#21262d'; ctx.lineWidth=1;
for(let i=0;i<=4;i++){let y=pad+i*(H-pad*2)/4;ctx.beginPath();ctx.moveTo(pad,y);ctx.lineTo(W-pad,y);ctx.stroke();}
// 축 레이블
ctx.fillStyle='#8b949e'; ctx.font='10px sans-serif'; ctx.textAlign='right';
for(let i=0;i<=4;i++){let y=pad+i*(H-pad*2)/4;let v=(maxY-(maxY-minY)*i/4).toFixed(2);ctx.fillText(v,pad-4,y+4);}
// 신호
signals.forEach(s=>{
if(s.pts.length<2) return;
ctx.strokeStyle=s.color; ctx.lineWidth=1.5; ctx.beginPath();
s.pts.forEach((p,i)=>{let x=toX(p.x),y=toY(p.y);i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);});
ctx.stroke();
// 현재값
let last=s.pts[s.pts.length-1];
ctx.fillStyle=s.color; ctx.font='11px monospace'; ctx.textAlign='left';
ctx.fillText(s.name+': '+(s.lastVal!==null?s.lastVal.toFixed(2):'--'), toX(last.x)+4, toY(last.y)-4);
});
}
function connect(){
ws=new WebSocket('ws://'+location.hostname+':81/');
ws.onopen=()=>clearTimeout(reconnTimer);
ws.onclose=()=>reconnTimer=setTimeout(connect,3000);
ws.onmessage=e=>{
try{
let d=JSON.parse(e.data);
if(d.type==='update'&&d.messages) d.messages.forEach(m=>processMsg(m));
}catch(ex){}
};
}
connect();
setInterval(draw,100);
</script>
</body></html>
)rawliteral";
#endif

222
graph_viewer.h Normal file
View File

@@ -0,0 +1,222 @@
#ifndef GRAPH_VIEWER_H
#define GRAPH_VIEWER_H
const char graph_viewer_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html lang="ko"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title> - CANFD Logger</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;font-size:13px}
nav{background:#161b22;border-bottom:1px solid #30363d;padding:8px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
nav a{color:#58a6ff;text-decoration:none;padding:4px 10px;border-radius:6px;font-size:12px}
nav a:hover{background:#21262d} nav a.active{background:#1f6feb;color:#fff}
.title{color:#e6edf3;font-weight:700;font-size:15px}
.container{padding:12px;max-width:1000px}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;margin-bottom:10px}
.card h3{font-size:12px;color:#8b949e;margin-bottom:12px;text-transform:uppercase}
.btn{padding:5px 12px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600}
.btn-blue{background:#1f6feb;color:#fff} .btn-gray{background:#21262d;color:#c9d1d9;border:1px solid #30363d}
input[type=text],input[type=file]{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:5px 8px;border-radius:6px;font-size:12px}
.row{display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;margin-bottom:8px}
.field{margin-bottom:4px} label{display:block;font-size:11px;color:#8b949e;margin-bottom:3px}
canvas{width:100%;height:300px;background:#0d1117;border:1px solid #30363d;border-radius:6px}
.info{font-size:11px;color:#8b949e;margin-top:6px}
.stat{display:inline-block;margin-right:16px;color:#8b949e}
.stat b{color:#e6edf3}
</style>
</head><body>
<nav>
<span class="title"> CANFD Logger</span>
<a href="/"></a><a href="/transmit"></a>
<a href="/graph"></a><a href="/graph-view" class="active"></a>
<a href="/settings"></a>
</nav>
<div class="container">
<div class="card">
<h3>BIN (CAN FD)</h3>
<div class="row">
<div class="field">
<label>BIN ()</label>
<input type="file" id="binFile" accept=".bin" onchange="loadBin()">
</div>
<div class="field" style="margin-bottom:4px">
<button class="btn btn-gray" onclick="document.getElementById('dlUrl').style.display='block'">URL로 </button>
</div>
</div>
<div id="dlUrl" style="display:none;margin-bottom:8px">
<label> URL (SD )</label>
<div class="row">
<input type="text" id="urlInput" placeholder="예: CANFD_20250430_120000.bin" style="width:260px">
<button class="btn btn-blue" onclick="loadUrl()"></button>
</div>
</div>
<div id="fileInfo" class="info"></div>
</div>
<div class="card" id="analyzeCard" style="display:none">
<h3> </h3>
<div class="row">
<div class="field"><label>CAN ID (hex)</label><input type="text" id="vSigId" value="0x123" style="width:90px"></div>
<div class="field"><label> </label><input type="text" id="vSigStart" value="0" style="width:60px"></div>
<div class="field"><label> </label><input type="text" id="vSigLen" value="8" style="width:60px"></div>
<div class="field"><label></label><input type="text" id="vSigScale" value="1" style="width:60px"></div>
<div class="field"><label></label><input type="text" id="vSigName" value="Signal 1" style="width:90px"></div>
<div class="field" style="margin-bottom:4px"><button class="btn btn-blue" onclick="addSig()">+ </button></div>
</div>
<div id="vSigList" style="display:flex;gap:8px;flex-wrap:wrap;margin:8px 0"></div>
<canvas id="vChart"></canvas>
<div style="margin-top:8px">
<span class="stat"> : <b id="timeRange">-</b></span>
<span class="stat"> : <b id="totalMsgs">0</b></span>
<span class="stat">FD : <b id="fdMsgs">0</b></span>
</div>
<div style="margin-top:8px;display:flex;gap:8px">
<button class="btn btn-gray" onclick="clearSigs()"> </button>
<button class="btn btn-blue" onclick="exportCSV()">CSV </button>
</div>
</div>
</div>
<script>
const COLORS=['#58a6ff','#3fb950','#d29922','#f85149','#a371f7','#ffa657','#79c0ff','#56d364'];
const RECORD_SIZE=79; // sizeof(CANFDLog): 8+4+1+1+1+64
let records=[], signals=[];
function parseBin(buf){
records=[];
let n=Math.floor(buf.byteLength/RECORD_SIZE);
let dv=new DataView(buf);
for(let i=0;i<n;i++){
let off=i*RECORD_SIZE;
let tsLo=dv.getUint32(off,true), tsHi=dv.getUint32(off+4,true);
let ts=tsHi*0x100000000+tsLo; // uint64 → JS Number (μs, precision ok for logging)
let id=dv.getUint32(off+8,true);
let dlc=dv.getUint8(off+12);
let len=dv.getUint8(off+13);
let flags=dv.getUint8(off+14);
let data=new Uint8Array(buf,off+15,Math.min(len,64));
records.push({ts,id,dlc,len,flags,data:Array.from(data)});
}
let total=records.length;
let fdCnt=records.filter(r=>(r.flags&0x20)!==0).length;
document.getElementById('totalMsgs').textContent=total.toLocaleString();
document.getElementById('fdMsgs').textContent=fdCnt.toLocaleString();
if(total>0){
let t0=records[0].ts, t1=records[total-1].ts;
document.getElementById('timeRange').textContent=((t1-t0)/1e6).toFixed(3)+'s';
document.getElementById('fileInfo').textContent=
` ${total.toLocaleString()} | FD: ${fdCnt.toLocaleString()} | : ${((t1-t0)/1e6).toFixed(3)}s`;
}
document.getElementById('analyzeCard').style.display='block';
draw();
}
function loadBin(){
let f=document.getElementById('binFile').files[0]; if(!f) return;
document.getElementById('fileInfo').textContent=' ...';
let r=new FileReader();
r.onload=e=>parseBin(e.target.result);
r.readAsArrayBuffer(f);
}
function loadUrl(){
let fn=document.getElementById('urlInput').value.trim(); if(!fn) return;
document.getElementById('fileInfo').textContent=' ...';
fetch('/download?file='+fn).then(r=>r.arrayBuffer()).then(parseBin)
.catch(e=>{ document.getElementById('fileInfo').textContent=' : '+e; });
}
function extractBits(data,start,len){
let v=0;
for(let i=0;i<len;i++){
let bit=start+i,b=Math.floor(bit/8),bit2=7-(bit%8);
if(b<data.length&&(data[b]&(1<<bit2))) v|=(1<<(len-1-i));
}
return v;
}
function addSig(){
if(signals.length>=8) return alert(' 8');
let id=parseInt(document.getElementById('vSigId').value,16);
signals.push({id,startBit:parseInt(document.getElementById('vSigStart').value)||0,
bitLen:parseInt(document.getElementById('vSigLen').value)||8,
scale:parseFloat(document.getElementById('vSigScale').value)||1,
name:document.getElementById('vSigName').value||'Signal',
color:COLORS[signals.length%COLORS.length]});
renderSigList(); draw();
}
function clearSigs(){signals=[];renderSigList();draw();}
function removeSig(i){signals.splice(i,1);renderSigList();draw();}
function renderSigList(){
let h='';
signals.forEach((s,i)=>h+=`<span style="background:#21262d;border:1px solid #30363d;border-radius:12px;padding:3px 10px;font-size:11px;display:inline-flex;align-items:center;gap:6px"><span style="width:10px;height:10px;border-radius:50%;background:${s.color};display:inline-block"></span>${s.name}<span onclick="removeSig(${i})" style="cursor:pointer;color:#8b949e"></span></span>`);
document.getElementById('vSigList').innerHTML=h;
}
const cv=document.getElementById('vChart');
const ctx=cv.getContext('2d');
function draw(){
let W=cv.offsetWidth, H=cv.offsetHeight;
cv.width=W; cv.height=H;
ctx.fillStyle='#0d1117';ctx.fillRect(0,0,W,H);
if(!records.length||!signals.length){
ctx.fillStyle='#8b949e';ctx.font='13px sans-serif';ctx.textAlign='center';
ctx.fillText(records.length?' .':'BIN .',W/2,H/2); return;
}
// 각 신호별 포인트 계산
let seriesList=signals.map(s=>{
let pts=records.filter(r=>r.id===s.id).map(r=>({x:r.ts,y:extractBits(r.data,s.startBit,s.bitLen)*s.scale}));
return {s,pts};
}).filter(s=>s.pts.length>0);
if(!seriesList.length) return;
let allPts=seriesList.flatMap(s=>s.pts);
let minX=Math.min(...allPts.map(p=>p.x)), maxX=Math.max(...allPts.map(p=>p.x));
let minY=Math.min(...allPts.map(p=>p.y)), maxY=Math.max(...allPts.map(p=>p.y));
if(minY===maxY){minY-=1;maxY+=1;}
let pad=35;
function toX(x){return pad+(x-minX)/(maxX-minX||1)*(W-pad*2);}
function toY(y){return H-pad-(y-minY)/(maxY-minY)*(H-pad*2);}
// 격자
ctx.strokeStyle='#21262d';ctx.lineWidth=1;
for(let i=0;i<=4;i++){let y=pad+i*(H-pad*2)/4;ctx.beginPath();ctx.moveTo(pad,y);ctx.lineTo(W-pad,y);ctx.stroke();}
ctx.fillStyle='#8b949e';ctx.font='10px sans-serif';ctx.textAlign='right';
for(let i=0;i<=4;i++){let y=pad+i*(H-pad*2)/4;let v=(maxY-(maxY-minY)*i/4).toFixed(2);ctx.fillText(v,pad-4,y+4);}
// X축 레이블
ctx.textAlign='center';
for(let i=0;i<=4;i++){let x=pad+i*(W-pad*2)/4;let t=((minX+(maxX-minX)*i/4-minX)/1e6).toFixed(2)+'s';ctx.fillText(t,x,H-4);}
// 신호 그리기
seriesList.forEach(({s,pts})=>{
ctx.strokeStyle=s.color;ctx.lineWidth=1.5;ctx.beginPath();
pts.forEach((p,i)=>{let x=toX(p.x),y=toY(p.y);i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);});
ctx.stroke();
ctx.fillStyle=s.color;ctx.font='11px monospace';ctx.textAlign='left';
let last=pts[pts.length-1];
ctx.fillText(s.name+': '+last.y.toFixed(2),toX(last.x)+4,toY(last.y)-4);
});
}
function exportCSV(){
if(!records.length) return;
let cols=['Time_us','ID','DLC','Len','FD','BRS','EXT'];
signals.forEach(s=>cols.push(s.name));
let rows=[cols.join(',')];
records.forEach(r=>{
let row=[r.ts,(r.id>>>0).toString(16).toUpperCase(),r.dlc,r.len,
(r.flags&0x20)?1:0,(r.flags&0x10)?1:0,(r.flags&0x80)?1:0];
signals.forEach(s=>{
if(s.id===r.id) row.push((extractBits(r.data,s.startBit,s.bitLen)*s.scale).toFixed(4));
else row.push('');
});
rows.push(row.join(','));
});
let blob=new Blob([rows.join('\n')],{type:'text/csv'});
let a=document.createElement('a');a.href=URL.createObjectURL(blob);
a.download='canfd_export.csv';a.click();
}
</script>
</body></html>
)rawliteral";
#endif

271
index.h Normal file
View File

@@ -0,0 +1,271 @@
#ifndef INDEX_H
#define INDEX_H_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">
<title>CANFD Logger</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;font-size:13px}
nav{background:#161b22;border-bottom:1px solid #30363d;padding:8px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
nav a{color:#58a6ff;text-decoration:none;padding:4px 10px;border-radius:6px;font-size:12px}
nav a:hover{background:#21262d} nav a.active{background:#1f6feb;color:#fff}
.title{color:#e6edf3;font-weight:700;font-size:15px;margin-right:8px}
.container{padding:12px;display:grid;gap:10px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:12px}
.card h3{font-size:12px;color:#8b949e;margin-bottom:10px;text-transform:uppercase;letter-spacing:.5px}
.stat{display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid #21262d}
.stat:last-child{border:none} .stat-label{color:#8b949e} .stat-val{color:#e6edf3;font-weight:600}
.stat-val.ok{color:#3fb950} .stat-val.warn{color:#d29922} .stat-val.err{color:#f85149}
.badge{display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600}
.badge-green{background:#033a16;color:#3fb950;border:1px solid #238636}
.badge-red{background:#3d0000;color:#f85149;border:1px solid #da3633}
.badge-blue{background:#051d4d;color:#58a6ff;border:1px solid #1f6feb}
.badge-orange{background:#3d2200;color:#d29922;border:1px solid #9e6a03}
.badge-purple{background:#2d1b69;color:#a371f7;border:1px solid #6e40c9}
.btn{padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;transition:.2s}
.btn-green{background:#238636;color:#fff} .btn-green:hover{background:#2ea043}
.btn-red{background:#da3633;color:#fff} .btn-red:hover{background:#f85149}
.btn-blue{background:#1f6feb;color:#fff} .btn-blue:hover{background:#388bfd}
.btn-gray{background:#21262d;color:#c9d1d9;border:1px solid #30363d} .btn-gray:hover{background:#30363d}
.btn:disabled{opacity:.4;cursor:not-allowed}
.log-ctrl{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px}
select{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:5px 8px;border-radius:6px;font-size:12px}
table{width:100%;border-collapse:collapse;font-size:11px}
th{background:#21262d;color:#8b949e;padding:6px 8px;text-align:left;font-weight:600;position:sticky;top:0}
td{padding:5px 8px;border-bottom:1px solid #21262d;font-family:monospace}
tr:hover td{background:#1c2128}
.tbl-wrap{max-height:380px;overflow-y:auto;border-radius:6px;border:1px solid #30363d}
.fd-tag{display:inline-block;padding:1px 5px;border-radius:3px;font-size:10px;font-weight:700;margin-left:3px}
.fd-tag.fd{background:#2d1b69;color:#a371f7} .fd-tag.brs{background:#033a16;color:#3fb950}
.fd-tag.ext{background:#051d4d;color:#58a6ff}
.progress-bar{background:#21262d;border-radius:4px;height:8px;overflow:hidden}
.progress-fill{height:100%;background:#1f6feb;transition:.5s;border-radius:4px}
.progress-fill.warn{background:#d29922} .progress-fill.err{background:#f85149}
.ws-status{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px}
.ws-ok{background:#3fb950} .ws-err{background:#f85149}
input[type=text]{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:4px 8px;border-radius:6px;font-size:12px;width:100%}
@media(max-width:700px){.grid2,.grid3{grid-template-columns:1fr}}
</style>
</head><body>
<nav>
<span class="title"> CANFD Logger</span>
<a href="/" class="active"></a>
<a href="/transmit"></a>
<a href="/graph"></a>
<a href="/settings"></a>
<span style="margin-left:auto;display:flex;align-items:center">
<span class="ws-status ws-err" id="wsLed"></span>
<span id="wsLabel" style="font-size:11px;color:#8b949e"> ...</span>
</span>
</nav>
<div class="container">
<!-- -->
<div class="grid3">
<div class="card">
<h3>CAN FD </h3>
<div class="stat"><span class="stat-label"> </span><span class="stat-val" id="speedName">-</span></div>
<div class="stat"><span class="stat-label"> </span><span id="listenBadge"><span class="badge badge-blue">-</span></span></div>
<div class="stat"><span class="stat-label"> </span><span class="stat-val" id="totalMsg">0</span></div>
<div class="stat"><span class="stat-label">Msg/s</span><span class="stat-val ok" id="msgPerSec">0</span></div>
<div class="stat"><span class="stat-label"> </span><span class="stat-val" id="busLoad">0%</span></div>
<div class="stat"><span class="stat-label"> </span><span class="stat-val" id="totalTx">0</span></div>
</div>
<div class="card">
<h3> / SD</h3>
<div class="stat"><span class="stat-label">SD </span><span id="sdBadge"></span></div>
<div class="stat"><span class="stat-label"> </span><span id="logBadge"></span></div>
<div class="stat"><span class="stat-label"> </span><span class="stat-val" id="fileSize">-</span></div>
<div class="stat"><span class="stat-label"> </span><span class="stat-val" id="curFile" style="font-size:10px;word-break:break-all">-</span></div>
<div style="margin-top:8px">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:3px">
<span style="color:#8b949e"></span><span id="ringLabel" style="color:#8b949e">0/4096</span>
</div>
<div class="progress-bar"><div class="progress-fill" id="ringFill" style="width:0%"></div></div>
</div>
<div class="stat" style="margin-top:8px"><span class="stat-label">Drop</span><span class="stat-val err" id="dropped">0</span></div>
</div>
<div class="card">
<h3> / </h3>
<div class="stat"><span class="stat-label"> </span><span id="timeBadge"></span></div>
<div class="stat"><span class="stat-label">RTC</span><span id="rtcBadge"></span></div>
<div class="stat"><span class="stat-label">NTP </span><span class="stat-val" id="syncCount">0</span></div>
<div class="stat"><span class="stat-label">RTC </span><span class="stat-val" id="rtcSyncCount">0</span></div>
<div class="stat"><span class="stat-label"> </span><span class="stat-val" id="curTime" style="font-size:11px">-</span></div>
<div class="stat"><span class="stat-label">AP IP ( )</span><span class="stat-val ok" id="apIP" style="font-size:11px">-</span></div>
<div class="stat"><span class="stat-label">STA IP</span><span class="stat-val" id="staIP" style="font-size:11px;color:#8b949e">-</span></div>
<div style="margin-top:8px;display:flex;gap:6px">
<button class="btn btn-gray" onclick="syncTime()">📱 </button>
<button class="btn btn-gray" onclick="clearStats()">🔄 </button>
</div>
</div>
</div>
<!-- -->
<div class="card">
<h3> </h3>
<div class="log-ctrl">
<select id="fmtSel">
<option value="bin">BIN (/ )</option>
<option value="csv">CSV ()</option>
<option value="pcap">PCAP (Wireshark)</option>
</select>
<button class="btn btn-green" id="startBtn" onclick="startLog()"> </button>
<button class="btn btn-red" id="stopBtn" onclick="stopLog()" disabled> </button>
<button class="btn btn-blue" onclick="getFiles()">📂 </button>
<button class="btn btn-gray" onclick="hwReset()">🔌 </button>
</div>
<div id="logInfo" style="font-size:11px;color:#8b949e;margin-top:4px"></div>
</div>
<!-- -->
<div class="card">
<h3>CAN FD ( 30 ID)</h3>
<div class="tbl-wrap">
<table id="msgTable">
<thead><tr>
<th>CAN ID</th><th></th><th>DLC</th><th></th><th> </th><th> (hex)</th>
</tr></thead>
<tbody id="msgBody"></tbody>
</table>
</div>
</div>
<!-- -->
<div class="card" id="fileCard" style="display:none">
<h3>SD </h3>
<div class="tbl-wrap">
<table id="fileTable">
<thead><tr><th></th><th></th><th></th><th></th></tr></thead>
<tbody id="fileBody"></tbody>
</table>
</div>
</div>
</div>
<script>
let ws, reconnTimer;
function fmtBytes(b){if(b<1024)return b+'B';if(b<1048576)return(b/1024).toFixed(1)+'KB';return(b/1048576).toFixed(2)+'MB';}
function fmtNum(n){return n>=1000000?(n/1000000).toFixed(2)+'M':n>=1000?(n/1000).toFixed(1)+'K':n;}
function connect(){
ws=new WebSocket('ws://'+location.hostname+':81/');
ws.onopen=()=>{
document.getElementById('wsLed').className='ws-status ws-ok';
document.getElementById('wsLabel').textContent='';
clearTimeout(reconnTimer);
};
ws.onclose=()=>{
document.getElementById('wsLed').className='ws-status ws-err';
document.getElementById('wsLabel').textContent=' ';
reconnTimer=setTimeout(connect,3000);
};
ws.onmessage=e=>{
try{ handleMsg(JSON.parse(e.data)); }catch(ex){}
};
}
function handleMsg(d){
if(d.type==='update'){
// 속도/모드
document.getElementById('speedName').textContent=d.speedName||'-';
document.getElementById('listenBadge').innerHTML=d.listenOnly?
'<span class="badge badge-orange">Listen Only</span>':
'<span class="badge badge-green">Normal</span>';
// 통계
document.getElementById('totalMsg').textContent=fmtNum(d.totalMsg||0);
document.getElementById('msgPerSec').textContent=(d.msgPerSec||0).toLocaleString();
document.getElementById('busLoad').textContent=(d.busLoad||0)+'%';
document.getElementById('totalTx').textContent=fmtNum(d.totalTx||0);
// SD
document.getElementById('sdBadge').innerHTML=d.sdReady?
'<span class="badge badge-green"></span>':'<span class="badge badge-red"></span>';
document.getElementById('logBadge').innerHTML=d.logging?
'<span class="badge badge-green"> </span>':'<span class="badge badge-red"></span>';
document.getElementById('fileSize').textContent=fmtBytes(d.fileSize||0);
document.getElementById('curFile').textContent=d.currentFile||'-';
document.getElementById('logInfo').textContent=d.logging?
': '+d.logFormat+' | '+fmtBytes(d.fileSize||0):'';
// 링버퍼
let used=d.ringUsed||0, size=d.ringSize||4096;
let pct=Math.round(used/size*100);
document.getElementById('ringLabel').textContent=used+'/'+size;
let fill=document.getElementById('ringFill');
fill.style.width=pct+'%';
fill.className='progress-fill'+(pct>80?' err':pct>50?' warn':'');
document.getElementById('dropped').textContent=(d.dropped||0).toLocaleString();
// 시간
document.getElementById('timeBadge').innerHTML=d.timeSync?
'<span class="badge badge-green"></span>':'<span class="badge badge-red"></span>';
document.getElementById('rtcBadge').innerHTML=d.rtcAvail?
'<span class="badge badge-blue"></span>':'<span class="badge badge-red"></span>';
document.getElementById('syncCount').textContent=d.syncCount||0;
document.getElementById('rtcSyncCount').textContent=d.rtcSyncCnt||0;
document.getElementById('curTime').textContent=d.timestamp?new Date(d.timestamp*1000).toLocaleString('ko-KR'):'';
if(d.apIP) document.getElementById('apIP').textContent=d.apIP;
if(d.staIP) document.getElementById('staIP').textContent=d.staConnected?d.staIP:'';
// 버튼
document.getElementById('startBtn').disabled=!!d.logging;
document.getElementById('stopBtn').disabled=!d.logging;
// 메시지 테이블
if(d.messages) updateTable(d.messages);
}
else if(d.type==='files') renderFiles(d.files||[]);
}
let msgMap={};
function updateTable(msgs){
msgs.forEach(m=>{msgMap[m.id]=m;});
let ids=Object.keys(msgMap).sort((a,b)=>parseInt(a)-parseInt(b));
let html='';
ids.forEach(idKey=>{
let m=msgMap[idKey];
let idStr=m.ext?'<span class="fd-tag ext">EXT</span>0x'+(m.id>>>0).toString(16).toUpperCase().padStart(8,'0'):
'0x'+(m.id>>>0).toString(16).toUpperCase().padStart(3,'0');
let type=(m.fd?'<span class="fd-tag fd">FD</span>':'CAN')+
(m.brs?'<span class="fd-tag brs">BRS</span>':'');
let dat=(m.data||[]).map(b=>b.toString(16).toUpperCase().padStart(2,'0')).join(' ');
if(m.len>16) dat+=' ...';
html+=`<tr><td>${idStr}</td><td>${type}</td><td>${m.dlc}</td><td>${m.len}B</td><td>${(m.count||0).toLocaleString()}</td><td style="font-family:monospace;font-size:11px">${dat}</td></tr>`;
});
document.getElementById('msgBody').innerHTML=html;
}
function renderFiles(files){
let card=document.getElementById('fileCard');
card.style.display='block';
let html='';
files.sort((a,b)=>a.name>b.name?-1:1);
files.forEach(f=>{
let ext=f.name.split('.').pop().toLowerCase();
html+=`<tr>
<td><a href="/download?file=${f.name}" style="color:#58a6ff">${f.name}</a></td>
<td>${fmtBytes(f.size)}</td>
<td><input type="text" value="${f.comment||''}" placeholder="코멘트..." onchange="saveComment('${f.name}',this.value)" style="width:120px"></td>
<td><button class="btn btn-red" onclick="delFile('${f.name}')" style="padding:2px 8px;font-size:11px"></button></td>
</tr>`;
});
document.getElementById('fileBody').innerHTML=html;
}
function startLog(){ws.send(JSON.stringify({cmd:'startLogging',format:document.getElementById('fmtSel').value}));}
function stopLog(){ws.send(JSON.stringify({cmd:'stopLogging'}));}
function getFiles(){ws.send(JSON.stringify({cmd:'getFiles'}));}
function hwReset(){if(confirm(' ?'))ws.send(JSON.stringify({cmd:'hwReset'}));}
function clearStats(){ws.send(JSON.stringify({cmd:'clearStats'}));}
function delFile(n){if(confirm(n+' ?'))ws.send(JSON.stringify({cmd:'deleteFile',filename:n}));}
function saveComment(n,c){ws.send(JSON.stringify({cmd:'addComment',filename:n,comment:c}));}
function syncTime(){
let now=new Date();
ws.send(JSON.stringify({cmd:'syncTime',year:now.getFullYear(),month:now.getMonth()+1,
day:now.getDate(),hour:now.getHours(),minute:now.getMinutes(),second:now.getSeconds()}));
}
connect();
</script>
</body></html>
)rawliteral";
#endif

231
settings.h Normal file
View File

@@ -0,0 +1,231 @@
#ifndef SETTINGS_H
#define SETTINGS_H
const char settings_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html lang="ko"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title> - CANFD Logger</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;font-size:13px}
nav{background:#161b22;border-bottom:1px solid #30363d;padding:8px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
nav a{color:#58a6ff;text-decoration:none;padding:4px 10px;border-radius:6px;font-size:12px}
nav a:hover{background:#21262d} nav a.active{background:#1f6feb;color:#fff}
.title{color:#e6edf3;font-weight:700;font-size:15px;margin-right:8px}
.container{padding:16px;max-width:700px}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;margin-bottom:12px}
.card h3{font-size:12px;color:#8b949e;margin-bottom:14px;text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid #30363d;padding-bottom:8px}
.field{margin-bottom:12px}
label{display:block;font-size:12px;color:#8b949e;margin-bottom:4px}
input[type=text],input[type=password],select{width:100%;background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:7px 10px;border-radius:6px;font-size:13px}
input[type=checkbox]{width:16px;height:16px;margin-right:6px;accent-color:#1f6feb}
.row{display:flex;gap:10px;align-items:flex-end}
.row .field{flex:1}
.btn{padding:7px 16px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;transition:.2s}
.btn-green{background:#238636;color:#fff} .btn-green:hover{background:#2ea043}
.btn-blue{background:#1f6feb;color:#fff} .btn-blue:hover{background:#388bfd}
.btn-gray{background:#21262d;color:#c9d1d9;border:1px solid #30363d}
.info{background:#051d4d;border:1px solid #1f6feb;border-radius:6px;padding:10px;font-size:12px;color:#79c0ff;margin-top:8px}
.warn{background:#3d2200;border:1px solid #9e6a03;border-radius:6px;padding:10px;font-size:12px;color:#ffa657;margin-top:8px}
.speed-table{width:100%;border-collapse:collapse;font-size:12px;margin-top:8px}
.speed-table th{background:#21262d;color:#8b949e;padding:6px 8px;text-align:left}
.speed-table td{padding:5px 8px;border-bottom:1px solid #21262d}
.speed-table tr.selected td{background:#051d4d;color:#79c0ff}
.speed-table tr:hover td{background:#1c2128;cursor:pointer}
.badge{display:inline-block;padding:2px 7px;border-radius:10px;font-size:10px;font-weight:700}
.badge-fd{background:#2d1b69;color:#a371f7} .badge-cl{background:#033a16;color:#3fb950}
#toast{position:fixed;bottom:20px;right:20px;background:#238636;color:#fff;padding:10px 16px;border-radius:8px;display:none;font-size:13px;z-index:999}
</style>
</head><body>
<nav>
<span class="title"> CANFD Logger</span>
<a href="/"></a><a href="/transmit"></a><a href="/graph"></a>
<a href="/settings" class="active"></a>
</nav>
<div class="container">
<!-- CAN FD -->
<div class="card">
<h3>CAN FD / </h3>
<div class="field">
<label> ( )</label>
<table class="speed-table" id="speedTable">
<thead><tr><th>#</th><th></th><th> </th><th> </th><th></th></tr></thead>
<tbody id="speedBody"></tbody>
</table>
</div>
<div class="field" style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="listenOnly"><label for="listenOnly" style="display:inline;color:#c9d1d9">Listen Only ( , )</label>
</div>
<div class="info" id="speedInfo"> .</div>
<div class="warn"> "CAN 재초기화" . SD .</div>
<div style="margin-top:12px;display:flex;gap:8px">
<button class="btn btn-green" onclick="reinitCAN()"> CAN </button>
<button class="btn btn-gray" onclick="saveAndRestart()">💾 </button>
</div>
</div>
<!-- WiFi -->
<div class="card">
<h3>WiFi (AP )</h3>
<div class="row">
<div class="field"><label>AP SSID</label><input type="text" id="ssid" placeholder="CANFD_Logger"></div>
<div class="field"><label>AP </label><input type="password" id="password" placeholder="12345678"></div>
</div>
<div class="field" style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="staEnable" onchange="toggleSTA()"><label for="staEnable" style="display:inline;color:#c9d1d9">STA (WiFi + NTP )</label>
</div>
<div id="staFields" style="display:none">
<div class="row">
<div class="field"><label> WiFi SSID</label><input type="text" id="staSSID"></div>
<div class="field"><label>WiFi </label><input type="password" id="staPass"></div>
</div>
<div class="info" id="staInfo"></div>
</div>
<div style="margin-top:12px">
<button class="btn btn-blue" onclick="saveWifi()">💾 WiFi ( )</button>
</div>
</div>
<!-- -->
<div class="card">
<h3></h3>
<div style="font-size:12px;color:#8b949e;line-height:2">
<b style="color:#c9d1d9"> ()</b><br>
CAN SPI: SCK=12, MOSI=11, MISO=13, CS=10 | INT: (255)<br>
SD SDIO: CLK=39, CMD=38, D0=40, D1=41, D2=42, D3=2<br>
RTC I2C: SDA=8, SCL=18 (DS3231)<br><br>
<b style="color:#c9d1d9"></b>: OSC_20MHz ( MCP2518FD )<br>
<b style="color:#c9d1d9"></b>: ACAN2517FD by Pierre Molinaro<br>
</div>
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-gray" onclick="hwReset()">🔌 </button>
</div>
</div>
</div>
<div id="toast"></div>
<script>
const PRESETS=[
{name:'Classic 125K', arb:'125 kbps', data:'125 kbps', fd:false},
{name:'Classic 250K', arb:'250 kbps', data:'250 kbps', fd:false},
{name:'Classic 500K', arb:'500 kbps', data:'500 kbps', fd:false},
{name:'Classic 1M', arb:'1 Mbps', data:'1 Mbps', fd:false},
{name:'FD 500K/2M', arb:'500 kbps', data:'2 Mbps', fd:true},
{name:'FD 500K/4M', arb:'500 kbps', data:'4 Mbps', fd:true},
{name:'FD 1M/4M', arb:'1 Mbps', data:'4 Mbps', fd:true},
{name:'FD 1M/8M', arb:'1 Mbps', data:'8 Mbps', fd:true},
];
let selIdx=4;
let ws, reconnTimer;
function renderPresets(){
let html='';
PRESETS.forEach((p,i)=>{
let cls=i===selIdx?'class="selected"':'';
let badge=p.fd?'<span class="badge badge-fd">CAN FD</span>':'<span class="badge badge-cl">Classic</span>';
html+=`<tr ${cls} onclick="selectPreset(${i})"><td>${i}</td><td>${p.name}</td><td>${p.arb}</td><td>${p.data}</td><td>${badge}</td></tr>`;
});
document.getElementById('speedBody').innerHTML=html;
updateSpeedInfo();
}
function selectPreset(i){
selIdx=i; renderPresets();
}
function updateSpeedInfo(){
let p=PRESETS[selIdx];
document.getElementById('speedInfo').innerHTML=
`: <b>${p.name}</b> | : ${p.arb} | : ${p.data} | `+
(p.fd?'<b style="color:#a371f7">CAN FD </b>':'<b style="color:#3fb950">Classic CAN </b>');
}
function connect(){
ws=new WebSocket('ws://'+location.hostname+':81/');
ws.onopen=()=>{
clearTimeout(reconnTimer);
ws.send(JSON.stringify({cmd:'getSettings'}));
};
ws.onclose=()=>{ reconnTimer=setTimeout(connect,3000); };
ws.onmessage=e=>{
try{ let d=JSON.parse(e.data); handleMsg(d); }catch(ex){}
};
}
function handleMsg(d){
if(d.type==='settings'){
document.getElementById('ssid').value=d.ssid||'';
document.getElementById('password').value=d.password||'';
document.getElementById('staEnable').checked=!!d.staEnable;
document.getElementById('staSSID').value=d.staSSID||'';
if(d.staConnected){
document.getElementById('staInfo').textContent=': '+d.staIP;
}
if(d.speedIdx!==undefined){ selIdx=d.speedIdx; renderPresets(); }
document.getElementById('listenOnly').checked=!!d.listenOnly;
toggleSTA();
}
else if(d.type==='reinitResult'){
toast(d.success?' CAN : '+d.preset:' ','#238636');
}
else if(d.type==='settingsSaved'){
toast(' ','#238636');
}
}
function toggleSTA(){
document.getElementById('staFields').style.display=
document.getElementById('staEnable').checked?'block':'none';
}
function reinitCAN(){
ws.send(JSON.stringify({
cmd:'reinitCAN',
speedIdx:selIdx,
listenOnly:document.getElementById('listenOnly').checked
}));
}
function saveWifi(){
ws.send(JSON.stringify({
cmd:'saveSettings',
ssid:document.getElementById('ssid').value,
password:document.getElementById('password').value,
staEnable:document.getElementById('staEnable').checked,
staSSID:document.getElementById('staSSID').value,
staPassword:document.getElementById('staPass').value,
speedIdx:selIdx,
listenOnly:document.getElementById('listenOnly').checked
}));
}
function saveAndRestart(){
if(!confirm(' .')) return;
ws.send(JSON.stringify({
cmd:'saveSettings',
ssid:document.getElementById('ssid').value,
password:document.getElementById('password').value,
staEnable:document.getElementById('staEnable').checked,
staSSID:document.getElementById('staSSID').value,
staPassword:document.getElementById('staPass').value,
speedIdx:selIdx,
listenOnly:document.getElementById('listenOnly').checked
}));
setTimeout(()=>ws.send(JSON.stringify({cmd:'hwReset'})),500);
}
function hwReset(){ if(confirm('?')) ws.send(JSON.stringify({cmd:'hwReset'})); }
function toast(msg,col='#238636'){
let t=document.getElementById('toast');
t.textContent=msg; t.style.background=col; t.style.display='block';
setTimeout(()=>t.style.display='none',3000);
}
renderPresets();
connect();
</script>
</body></html>
)rawliteral";
#endif

260
transmit.h Normal file
View File

@@ -0,0 +1,260 @@
#ifndef TRANSMIT_H
#define TRANSMIT_H
const char transmit_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html lang="ko"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title> - CANFD Logger</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;font-size:13px}
nav{background:#161b22;border-bottom:1px solid #30363d;padding:8px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
nav a{color:#58a6ff;text-decoration:none;padding:4px 10px;border-radius:6px;font-size:12px}
nav a:hover{background:#21262d} nav a.active{background:#1f6feb;color:#fff}
.title{color:#e6edf3;font-weight:700;font-size:15px;margin-right:8px}
.container{padding:12px;display:grid;gap:10px;max-width:900px}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px}
.card h3{font-size:12px;color:#8b949e;margin-bottom:12px;text-transform:uppercase;letter-spacing:.5px}
.row{display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap}
.field{margin-bottom:8px}
label{display:block;font-size:11px;color:#8b949e;margin-bottom:3px}
input[type=text],select{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:5px 8px;border-radius:6px;font-size:12px}
input[type=checkbox]{accent-color:#1f6feb}
.w80{width:80px} .w100{width:100px} .w60{width:60px} .w120{width:120px}
.btn{padding:5px 12px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;transition:.2s}
.btn-green{background:#238636;color:#fff} .btn-blue{background:#1f6feb;color:#fff}
.btn-red{background:#da3633;color:#fff} .btn-gray{background:#21262d;color:#c9d1d9;border:1px solid #30363d}
.data-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(52px,1fr));gap:4px;margin:8px 0}
.data-cell{background:#21262d;border:1px solid #30363d;border-radius:4px;padding:4px 6px;text-align:center;font-family:monospace;font-size:13px;color:#e6edf3;cursor:pointer}
.data-cell:focus{outline:none;border-color:#1f6feb;background:#1c2128}
.dlc-info{font-size:11px;color:#8b949e;margin-top:4px}
table{width:100%;border-collapse:collapse;font-size:11px}
th{background:#21262d;color:#8b949e;padding:6px 8px;text-align:left;font-weight:600}
td{padding:5px 8px;border-bottom:1px solid #21262d}
.badge{padding:2px 7px;border-radius:10px;font-size:10px;font-weight:700;display:inline-block}
.badge-fd{background:#2d1b69;color:#a371f7} .badge-cl{background:#033a16;color:#3fb950}
.badge-on{background:#033a16;color:#3fb950} .badge-off{background:#3d0000;color:#f85149}
#log{background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:8px;height:120px;overflow-y:auto;font-family:monospace;font-size:11px;color:#3fb950}
</style>
</head><body>
<nav>
<span class="title"> CANFD Logger</span>
<a href="/"></a><a href="/transmit" class="active"></a>
<a href="/graph"></a><a href="/settings"></a>
</nav>
<div class="container">
<!-- -->
<div class="card">
<h3>CAN / CAN FD </h3>
<div class="row">
<div class="field">
<label>CAN ID (hex)</label>
<input type="text" id="txId" value="0x123" class="w100">
</div>
<div class="field">
<label>DLC</label>
<select id="txDlc" onchange="onDlcChange()" class="w80">
<option>0</option><option>1</option><option>2</option><option>3</option>
<option>4</option><option>5</option><option>6</option><option>7</option>
<option selected>8</option><option>9</option><option>10</option><option>11</option>
<option>12</option><option>13</option><option>14</option><option>15</option>
</select>
</div>
<div class="field" style="display:flex;align-items:center;gap:6px;margin-bottom:8px">
<input type="checkbox" id="txExt"><label style="display:inline">EXT (29bit)</label>
<input type="checkbox" id="txFD" onchange="onFDChange()"><label style="display:inline">FD</label>
<input type="checkbox" id="txBRS" disabled><label style="display:inline;color:#8b949e" id="brsLabel">BRS</label>
</div>
</div>
<label> (hex, )</label>
<div class="data-grid" id="dataGrid"></div>
<div class="dlc-info" id="dlcInfo">DLC 8 = 8 bytes</div>
<div style="margin-top:10px;display:flex;gap:8px">
<button class="btn btn-green" onclick="sendOnce()"> </button>
<button class="btn btn-gray" onclick="clearData()"></button>
</div>
</div>
<!-- -->
<div class="card">
<h3> ( 16)</h3>
<div class="row" style="margin-bottom:10px">
<div class="field"><label> #</label><select id="slotSel" class="w60" onchange="loadSlot()">
<script>for(let i=0;i<16;i++)document.write('<option>'+i+'</option>')</script>
</select></div>
<div class="field"><label>CAN ID</label><input type="text" id="pId" value="0x200" class="w100"></div>
<div class="field"><label>DLC</label><select id="pDlc" onchange="onPDlcChange()" class="w80">
<script>for(let i=0;i<=15;i++)document.write('<option'+(i===8?' selected':'')+'>'+i+'</option>')</script>
</select></div>
<div class="field"><label>(ms)</label><input type="text" id="pInterval" value="100" class="w80"></div>
</div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<input type="checkbox" id="pExt"><label>EXT</label>
<input type="checkbox" id="pFD" onchange="onPFDChange()"><label>FD</label>
<input type="checkbox" id="pBRS" disabled><label style="color:#8b949e" id="pBrsLabel">BRS</label>
<input type="checkbox" id="pActive"><label style="color:#3fb950"></label>
</div>
<label></label>
<div class="data-grid" id="pDataGrid"></div>
<div style="margin-top:10px;display:flex;gap:8px">
<button class="btn btn-blue" onclick="saveSlot()">💾 </button>
</div>
<div style="margin-top:12px">
<table id="slotTable">
<thead><tr><th>#</th><th>ID</th><th></th><th>DLC</th><th></th><th></th></tr></thead>
<tbody id="slotBody"></tbody>
</table>
</div>
</div>
<!-- -->
<div class="card">
<h3> </h3>
<div id="log"></div>
<button class="btn btn-gray" onclick="document.getElementById('log').innerHTML=''" style="margin-top:6px"></button>
</div>
</div>
<script>
const DLC_LEN=[0,1,2,3,4,5,6,7,8,12,16,20,24,32,48,64];
let ws, reconnTimer;
let slots=Array(16).fill(null).map(()=>({id:'0x200',ext:false,fd:false,brs:false,dlc:8,data:new Array(64).fill(0),interval:100,active:false}));
// ── 단발 데이터 그리드 ────────────────────────
function buildGrid(id,len){
let g=document.getElementById(id); g.innerHTML='';
for(let i=0;i<len;i++){
let c=document.createElement('div');
c.className='data-cell'; c.contentEditable='true';
c.textContent='00'; c.dataset.idx=i;
c.onkeydown=e=>{
if(e.key==='Tab'){e.preventDefault();let n=g.children[i+1];if(n)n.focus();}
if(!/[0-9a-fA-F]|Backspace|Tab|ArrowLeft|ArrowRight/.test(e.key)) e.preventDefault();
};
c.oninput=()=>{let v=c.textContent.replace(/[^0-9a-fA-F]/g,'');if(v.length>2)v=v.slice(-2);c.textContent=v||'00';};
g.appendChild(c);
}
}
function getGridData(id,len){
let g=document.getElementById(id);
let d=[];
for(let i=0;i<len;i++) d.push(parseInt(g.children[i]?.textContent||'0',16)||0);
return d;
}
function setGridData(id,data,len){
let g=document.getElementById(id);
for(let i=0;i<len;i++){
if(g.children[i]) g.children[i].textContent=(data[i]||0).toString(16).toUpperCase().padStart(2,'0');
}
}
function onDlcChange(){
let dlc=parseInt(document.getElementById('txDlc').value);
let len=DLC_LEN[dlc]||0;
document.getElementById('dlcInfo').textContent='DLC '+dlc+' = '+len+' bytes';
buildGrid('dataGrid',len);
}
function onFDChange(){
let fd=document.getElementById('txFD').checked;
document.getElementById('txBRS').disabled=!fd;
document.getElementById('brsLabel').style.color=fd?'#c9d1d9':'#8b949e';
}
function onPDlcChange(){
let dlc=parseInt(document.getElementById('pDlc').value);
buildGrid('pDataGrid',DLC_LEN[dlc]||0);
}
function onPFDChange(){
let fd=document.getElementById('pFD').checked;
document.getElementById('pBRS').disabled=!fd;
document.getElementById('pBrsLabel').style.color=fd?'#c9d1d9':'#8b949e';
}
function clearData(){ let g=document.getElementById('dataGrid'); for(let c of g.children) c.textContent='00'; }
function sendOnce(){
let dlc=parseInt(document.getElementById('txDlc').value);
let len=DLC_LEN[dlc]||0;
let d=getGridData('dataGrid',len);
let id=document.getElementById('txId').value;
let msg={cmd:'sendOnce',id:id,ext:document.getElementById('txExt').checked,
fd:document.getElementById('txFD').checked,brs:document.getElementById('txBRS').checked,
dlc:dlc,data:d};
ws.send(JSON.stringify(msg));
addLog('TX: ID='+id+' DLC='+dlc+(document.getElementById('txFD').checked?' [FD]':'')+
' Data:'+d.slice(0,len).map(b=>b.toString(16).toUpperCase().padStart(2,'0')).join(' '));
}
function loadSlot(){
let i=parseInt(document.getElementById('slotSel').value);
let s=slots[i];
document.getElementById('pId').value=s.id;
document.getElementById('pExt').checked=s.ext;
document.getElementById('pFD').checked=s.fd;
document.getElementById('pBRS').checked=s.brs;
document.getElementById('pBRS').disabled=!s.fd;
document.getElementById('pDlc').value=s.dlc;
document.getElementById('pInterval').value=s.interval;
document.getElementById('pActive').checked=s.active;
let len=DLC_LEN[s.dlc]||0;
buildGrid('pDataGrid',len);
setGridData('pDataGrid',s.data,len);
onPFDChange();
}
function saveSlot(){
let i=parseInt(document.getElementById('slotSel').value);
let dlc=parseInt(document.getElementById('pDlc').value);
let len=DLC_LEN[dlc]||0;
let d=getGridData('pDataGrid',len);
let full=new Array(64).fill(0); for(let j=0;j<len;j++) full[j]=d[j];
slots[i]={id:document.getElementById('pId').value,ext:document.getElementById('pExt').checked,
fd:document.getElementById('pFD').checked,brs:document.getElementById('pBRS').checked,
dlc,data:full,interval:parseInt(document.getElementById('pInterval').value)||0,
active:document.getElementById('pActive').checked};
ws.send(JSON.stringify({cmd:'setTxMsg',idx:i,...slots[i]}));
renderSlots();
addLog(' '+i+' : ID='+slots[i].id+(slots[i].active?' []':' []'));
}
function renderSlots(){
let html='';
slots.forEach((s,i)=>{
if(s.dlc>0||s.active){
html+=`<tr>
<td>${i}</td>
<td>${s.id}</td>
<td>${s.fd?'<span class="badge badge-fd">FD</span>':'<span class="badge badge-cl">CL</span>'}</td>
<td>${s.dlc}</td><td>${s.interval}ms</td>
<td><span class="badge ${s.active?'badge-on':'badge-off'}">${s.active?'ON':'OFF'}</span></td>
</tr>`;
}
});
document.getElementById('slotBody').innerHTML=html||'<tr><td colspan="6" style="color:#8b949e;text-align:center"> </td></tr>';
}
function addLog(msg){
let l=document.getElementById('log');
let t=new Date().toLocaleTimeString('ko-KR',{hour12:false});
l.innerHTML+=`<div style="color:#8b949e">[${t}]</div><div>${msg}</div>`;
l.scrollTop=l.scrollHeight;
}
function connect(){
ws=new WebSocket('ws://'+location.hostname+':81/');
ws.onopen=()=>{ clearTimeout(reconnTimer); };
ws.onclose=()=>{ reconnTimer=setTimeout(connect,3000); };
ws.onmessage=e=>{ try{ handleMsg(JSON.parse(e.data)); }catch(ex){} };
}
function handleMsg(d){ } // 필요시 확장
// 초기화
buildGrid('dataGrid',8);
buildGrid('pDataGrid',8);
document.getElementById('dlcInfo').textContent='DLC 8 = 8 bytes';
connect();
</script>
</body></html>
)rawliteral";
#endif