261 lines
12 KiB
C
261 lines
12 KiB
C
#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
|