기본 동작 확인
This commit is contained in:
1248
CANFD_Logger.ino
Normal file
1248
CANFD_Logger.ino
Normal file
File diff suppressed because it is too large
Load Diff
157
graph.h
Normal file
157
graph.h
Normal 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
222
graph_viewer.h
Normal 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
271
index.h
Normal 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
231
settings.h
Normal 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
260
transmit.h
Normal 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
|
||||||
Reference in New Issue
Block a user