Files
esp32s3_canlogger_mcp2515/transmit.h
2026-03-12 21:14:40 +00:00

679 lines
30 KiB
C++
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#ifndef TRANSMIT_H
#define TRANSMIT_H
const char transmit_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CAN Transmit - Sequence Editor</title>
<style>
:root {
--bg: #0e1117;
--panel: #161b24;
--card: #1c2230;
--border: #2d3748;
--accent: #43cea2;
--green: #3fb950;
--blue: #58a6ff;
--red: #f85149;
--yellow: #e3b341;
--purple: #bc8cff;
--text: #e6edf3;
--muted: #8b949e;
--r: 8px;
}
* { margin:0; padding:0; box-sizing:border-box; }
html, body { min-height:100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg); color: var(--text);
overflow-x: hidden; font-size: 14px;
}
/* Header */
.header {
background: linear-gradient(135deg,#1a2744 0%,#1e1a3a 100%);
padding: 11px 16px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
}
.header h1 {
font-size: 1.0em; font-weight: 700; color: var(--accent);
display: flex; align-items: center; gap: 8px;
}
.header p { font-size: 0.78em; color: var(--muted); margin:0; }
.badge {
background: rgba(67,206,162,.15); color: var(--accent);
padding: 2px 8px; border-radius: 12px; font-size: 0.65em;
font-weight: 600; border: 1px solid rgba(67,206,162,.3);
white-space: nowrap;
}
.header-spacer { flex:1; }
/* Nav */
.nav {
background: var(--panel); border-bottom: 1px solid var(--border);
display: flex; overflow-x: auto; -webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.nav::-webkit-scrollbar { display:none; }
.nav a {
display: inline-flex; align-items: center;
padding: 10px 13px; text-decoration: none; color: var(--muted);
font-size: 0.78em; font-weight: 500;
border-bottom: 2px solid transparent; white-space: nowrap;
transition: color .2s, border-color .2s;
}
.nav a:hover { color: var(--text); }
.nav a.active { color: var(--accent); border-bottom-color: var(--accent); }
/* Content */
.content { padding: 12px; }
/* Headings */
h2 {
color: var(--accent); margin: 14px 0 10px;
font-size: 0.82em; font-weight: 700; text-transform: uppercase;
letter-spacing: .5px; padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
h3 {
color: var(--text); font-size: 0.85em; font-weight: 600;
margin-bottom: 10px; padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
/* Buttons */
.btn, button {
padding: 6px 13px; border: 1px solid var(--border);
border-radius: var(--r); background: var(--bg); color: var(--muted);
font-size: 0.8em; font-weight: 600; cursor: pointer;
font-family: inherit; transition: all .15s; white-space: nowrap;
-webkit-tap-highlight-color: transparent; touch-action: manipulation;
}
.btn:hover, button:hover { border-color: var(--accent); color: var(--accent); }
.btn:active, button:active { transform: scale(.97); }
.btn-primary { border-color:var(--blue); color:var(--blue); }
.btn-success { border-color:var(--accent); color:var(--accent); }
.btn-danger { border-color:var(--red); color:var(--red); }
.btn-warning { border-color:var(--yellow); color:var(--yellow); }
.btn-secondary{ border-color:var(--muted); color:var(--muted); }
.btn-primary:hover { background:rgba(88,166,255,.10); }
.btn-success:hover { background:rgba(67,206,162,.10); }
.btn-danger:hover { background:rgba(248,81,73,.10); }
.btn-warning:hover { background:rgba(227,179,65,.10); }
.btn-secondary:hover{ background:rgba(139,148,158,.10); }
.btn-small { padding:4px 9px; font-size:.75em; }
.button-group, .btn-group { display:flex; gap:6px; flex-wrap:wrap; margin-top:10px; }
/* Forms */
label {
display:block; font-weight:600; color:var(--muted);
margin-bottom:4px; font-size:.78em;
text-transform:uppercase; letter-spacing:.3px;
}
input[type="text"], input[type="password"], input[type="number"],
select, textarea {
width:100%; padding:7px 10px;
border:1px solid var(--border); border-radius:var(--r);
font-size:.85em; font-family:inherit;
background:var(--bg); color:var(--text);
transition:border-color .2s;
}
input:focus, select:focus, textarea:focus {
outline:none; border-color:var(--accent);
box-shadow:0 0 0 2px rgba(67,206,162,.10);
}
input[type="checkbox"], input[type="radio"] {
width:15px; height:15px; cursor:pointer; accent-color:var(--accent);
}
select option { background:var(--panel); color:var(--text); }
/* Panels */
.section, .settings-section, .send-panel, .stats,
.control-panel, .auto-trigger-section {
background:var(--panel); border:1px solid var(--border);
border-radius:var(--r); padding:13px; margin-bottom:10px;
}
.form-group { margin-bottom:12px; }
.form-group:last-child { margin-bottom:0; }
.help-text { font-size:.76em; color:var(--muted); margin-top:4px; line-height:1.4; }
.form-row {
display:grid;
grid-template-columns:repeat(auto-fit,minmax(min(100%,190px),1fr));
gap:10px; margin-bottom:10px;
}
.form-row label { text-transform:none; font-size:.78em; }
.checkbox-group, .checkbox-row {
display:flex; align-items:center; gap:8px;
margin-bottom:8px; padding:7px 10px;
background:var(--card); border-radius:6px; border:1px solid var(--border);
}
.checkbox-group label, .checkbox-row label {
text-transform:none; cursor:pointer; margin-bottom:0;
color:var(--text); font-size:.85em; letter-spacing:0;
}
/* Alerts */
.alert {
padding:9px 13px; border-radius:var(--r);
margin-bottom:10px; display:none;
align-items:center; gap:9px;
font-size:.83em; font-weight:600;
border:1px solid var(--border); background:var(--panel); color:var(--text);
}
.alert.show { display:flex; }
.alert-success { border-color:rgba(67,206,162,.4); color:var(--accent); background:rgba(67,206,162,.07); }
.alert-info { border-color:rgba(88,166,255,.4); color:var(--blue); background:rgba(88,166,255,.07); }
.alert-warning { border-color:rgba(227,179,65,.4); color:var(--yellow); background:rgba(227,179,65,.07); }
.alert-icon { font-size:1.15em; }
.alert-text { flex:1; }
@keyframes slideDown { from{opacity:0;transform:translateY(-6px);}to{opacity:1;transform:translateY(0);} }
/* Connection status */
.connection-status {
position:fixed; top:10px; right:10px;
padding:4px 11px; border-radius:20px;
font-size:.75em; font-weight:600; z-index:1000;
border:1px solid var(--border); background:var(--panel); color:var(--muted);
}
.connection-status.connected { border-color:rgba(67,206,162,.5); color:var(--accent); background:rgba(67,206,162,.08); }
.connection-status.disconnected { border-color:rgba(248,81,73,.5); color:var(--red); background:rgba(248,81,73,.08); }
@media (max-width:480px) { .content{padding:8px;} h2{font-size:.78em;} }
/* transmit.h specific */
.steps-list {
background:var(--bg); border:1px solid var(--border);
border-radius:var(--r); padding:8px; max-height:360px; overflow-y:auto;
}
.steps-list::-webkit-scrollbar { width:5px; }
.steps-list::-webkit-scrollbar-track { background:var(--panel); }
.steps-list::-webkit-scrollbar-thumb { background:var(--border); border-radius:3px; }
.step-item {
background:var(--card); padding:9px 11px; margin-bottom:6px;
border-radius:6px; border:1px solid var(--border); border-left:3px solid var(--blue);
display:flex; justify-content:space-between; align-items:center;
}
.step-item:hover { border-color:var(--blue); }
.step-info { flex:1; font-family:'SF Mono','Fira Code','Courier New',monospace; font-size:.80em; color:var(--text); }
.step-actions { display:flex; gap:4px; }
.sequence-list {
display:grid;
grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr));
gap:8px;
}
.sequence-card {
background:var(--card); padding:12px; border-radius:var(--r);
border:1px solid var(--border); transition:border-color .15s;
}
.sequence-card:hover { border-color:var(--blue); }
.sequence-card h3 { color:var(--blue); margin-bottom:7px; font-size:.92em; border-bottom:1px solid var(--border); padding-bottom:5px; }
.sequence-info { font-size:.80em; color:var(--muted); margin-bottom:7px; }
.status-badge { display:inline-block; padding:2px 7px; border-radius:4px; font-size:.73em; font-weight:600; margin-right:4px; }
.badge-once { background:rgba(88,166,255,.12); color:var(--blue); border:1px solid rgba(88,166,255,.2); }
.badge-count { background:rgba(227,179,65,.12); color:var(--yellow); border:1px solid rgba(227,179,65,.2); }
.badge-infinite { background:rgba(188,140,255,.12);color:var(--purple); border:1px solid rgba(188,140,255,.2);}
.badge-running { background:rgba(67,206,162,.12); color:var(--accent); border:1px solid rgba(67,206,162,.2); }
</style>
</head>
<body>
<div id="connection-status" class="connection-status disconnected">🔴 Disconnected</div>
<div class="header">
<h1>🚀 CAN Sequence Transmitter</h1>
<p>Create and Execute CAN Message Sequences</p>
</div>
<div class="nav">
<a href="/">📊 Monitor</a>
<a href="/transmit" class="active">📤 Transmit</a>
<a href="/graph">📈 Graph</a>
<a href="/graph-view">📊 Graph View</a>
<a href="/settings"> Settings</a>
<a href="/serial">📟 Serial1</a>
<a href="/serial2">📟 Serial2</a>
</div>
<div class="content">
<div class="section">
<h2>Create New Sequence</h2>
<div class="form-row">
<div class="form-group">
<label>Sequence Name</label>
<input type="text" id="seq-name" placeholder="My Sequence" maxlength="31">
</div>
<div class="form-group">
<label>Repeat Mode</label>
<select id="repeat-mode">
<option value="0">Once</option>
<option value="1">Count</option>
<option value="2">Infinite</option>
</select>
</div>
<div class="form-group">
<label>Repeat Count</label>
<input type="number" id="repeat-count" value="1" min="1" max="1000">
</div>
</div>
<h3 style="margin-top: 20px; margin-bottom: 10px; color: #555;">Add Step</h3>
<div class="form-row">
<div class="form-group">
<label>CAN ID (Hex)</label>
<input type="text" id="step-id" placeholder="0x123" maxlength="8">
</div>
<div class="form-group">
<label>ID Type</label>
<select id="step-extended">
<option value="0">Standard</option>
<option value="1">Extended</option>
</select>
</div>
<div class="form-group">
<label>DLC</label>
<select id="step-dlc">
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8" selected>8</option>
</select>
</div>
<div class="form-group">
<label>Delay (ms)</label>
<input type="number" id="step-delay" value="100" min="0" max="60000">
</div>
</div>
<div class="form-group" style="margin-bottom: 15px;">
<label>Data (8 bytes, Hex)</label>
<input type="text" id="step-data" placeholder="00 11 22 33 44 55 66 77" maxlength="23">
</div>
<div class="button-group">
<button class="btn-primary" onclick="addStep()"> Add Step</button>
<button class="btn-success" onclick="saveSequence()">💾 Save Sequence</button>
<button class="btn-danger" onclick="clearSteps()">🗑️ Clear All</button>
</div>
<h3 style="margin-top: 20px; margin-bottom: 10px; color: #555;">Steps (<span id="step-count">0</span>)</h3>
<div class="steps-list" id="steps-list">
<p style="text-align: center; color: #999;">No steps added yet</p>
</div>
</div>
<div class="section">
<h2>Saved Sequences</h2>
<div class="button-group" style="margin-bottom: 15px;">
<button class="btn-primary" onclick="loadSequences()">🔄 Refresh</button>
</div>
<div class="sequence-list" id="sequence-list">
<p style="text-align: center; color: #999;">Loading...</p>
</div>
</div>
</div>
</div>
<script>
let ws;
let currentSteps = [];
let sequences = [];
function updateConnectionStatus(connected) {
const status = document.getElementById('connection-status');
if (connected) {
status.className = 'connection-status connected';
status.innerHTML = '🟢 Connected';
} else {
status.className = 'connection-status disconnected';
status.innerHTML = '🔴 Disconnected';
}
}
function initWebSocket() {
ws = new WebSocket('ws://' + window.location.hostname + ':81/');
ws.onopen = function() {
console.log('WebSocket connected');
updateConnectionStatus(true);
setTimeout(loadSequences, 500);
};
ws.onclose = function() {
console.log('WebSocket disconnected');
updateConnectionStatus(false);
setTimeout(initWebSocket, 3000);
};
ws.onerror = function(error) {
console.error('WebSocket error:', error);
updateConnectionStatus(false);
};
ws.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
console.log('Received:', data.type, data);
if (data.type === 'sequences') {
sequences = data.sequences || data.list || [];
sequences = sequences.map((seq, idx) => ({
...seq,
index: seq.index !== undefined ? seq.index : idx,
mode: seq.mode !== undefined ? seq.mode : seq.repeatMode,
count: seq.count !== undefined ? seq.count : seq.repeatCount
}));
updateSequenceList();
} else if (data.type === 'sequenceDetail') {
handleSequenceDetail(data);
} else if (data.type === 'sequenceSaved') {
if (data.success) {
alert('Sequence saved successfully!');
clearSteps();
loadSequences();
} else {
alert('Failed to save sequence (max 10 sequences)');
}
} else if (data.type === 'sequenceDeleted') {
if (data.success) {
alert('Sequence deleted!');
loadSequences();
}
} else if (data.type === 'sequenceStarted') {
alert('Sequence started!');
} else if (data.type === 'sequenceStopped') {
alert('Sequence stopped!');
}
} catch (e) {
console.error('Parse error:', e);
}
};
}
function loadSequences() {
if (ws && ws.readyState === WebSocket.OPEN) {
console.log('Requesting sequences...');
ws.send(JSON.stringify({cmd: 'getSequences'}));
} else {
console.log('WebSocket not connected, retrying...');
setTimeout(loadSequences, 1000);
}
}
function addStep() {
const id = document.getElementById('step-id').value.trim();
const extended = parseInt(document.getElementById('step-extended').value);
const dlc = parseInt(document.getElementById('step-dlc').value);
const dataStr = document.getElementById('step-data').value.trim();
const delay = parseInt(document.getElementById('step-delay').value);
let canId = 0;
if (id.startsWith('0x') || id.startsWith('0X')) {
canId = parseInt(id.substring(2), 16);
} else {
canId = parseInt(id, 16);
}
if (isNaN(canId) || id === '') {
alert('Invalid CAN ID');
return;
}
let dataBytes = [];
if (dataStr) {
dataBytes = dataStr.split(/[\s,]+/).map(b => parseInt(b, 16));
}
while (dataBytes.length < 8) {
dataBytes.push(0);
}
const step = {
canId: canId,
extended: extended === 1,
dlc: dlc,
data: dataBytes.slice(0, 8),
delayMs: delay
};
currentSteps.push(step);
updateStepsList();
document.getElementById('step-id').value = '';
document.getElementById('step-data').value = '';
}
function removeStep(index) {
currentSteps.splice(index, 1);
updateStepsList();
}
function clearSteps() {
currentSteps = [];
updateStepsList();
document.getElementById('seq-name').value = '';
}
function updateStepsList() {
const list = document.getElementById('steps-list');
document.getElementById('step-count').textContent = currentSteps.length;
if (currentSteps.length === 0) {
list.innerHTML = '<p style="text-align: center; color: #999;">No steps added yet</p>';
return;
}
list.innerHTML = '';
currentSteps.forEach((step, index) => {
const item = document.createElement('div');
item.className = 'step-item';
const dataStr = step.data.map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
item.innerHTML =
'<div class="step-info">' +
'<strong>Step ' + (index + 1) + ':</strong> ' +
'ID=0x' + step.canId.toString(16).toUpperCase() + ' ' +
(step.extended ? '(Ext)' : '(Std)') + ' | ' +
'DLC=' + step.dlc + ' | ' +
'Data=[' + dataStr + '] | ' +
'Delay=' + step.delayMs + 'ms' +
'</div>' +
'<div class="step-actions">' +
'<button class="btn-danger btn-small" onclick="removeStep(' + index + ')">Delete</button>' +
'</div>';
list.appendChild(item);
});
}
function saveSequence() {
const name = document.getElementById('seq-name').value.trim();
const mode = parseInt(document.getElementById('repeat-mode').value);
const count = parseInt(document.getElementById('repeat-count').value);
if (name === '') {
alert('Please enter sequence name');
return;
}
if (currentSteps.length === 0) {
alert('Please add at least one step');
return;
}
const sequence = {
cmd: 'addSequence',
name: name,
repeatMode: mode,
repeatCount: count,
steps: currentSteps.map(step => ({
id: '0x' + step.canId.toString(16).toUpperCase(),
ext: step.extended,
dlc: step.dlc,
data: step.data,
delay: step.delayMs
}))
};
console.log(' Saving sequence:', sequence);
console.log('📊 Total steps:', currentSteps.length);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(sequence));
console.log(' Sequence save command sent to server');
// ⭐⭐⭐ 저장 확인 메시지 (3초 후 자동 사라짐)
alert('Sequence "' + name + '" saved successfully!\n' +
'Steps: ' + currentSteps.length + '\n' +
'Repeat: ' + (mode === 0 ? 'Once' : mode === 1 ? count + ' times' : 'Forever'));
// 입력 필드 초기화 (선택 사항)
// document.getElementById('seq-name').value = '';
} else {
console.error(' WebSocket not connected!');
alert('Error: Not connected to server');
}
}
function updateSequenceList() {
const list = document.getElementById('sequence-list');
console.log('Updating sequence list:', sequences);
if (!sequences || sequences.length === 0) {
list.innerHTML = '<p style="text-align: center; color: #999;">No saved sequences</p>';
return;
}
list.innerHTML = '';
sequences.forEach((seq, idx) => {
const card = document.createElement('div');
card.className = 'sequence-card';
const mode = seq.mode !== undefined ? seq.mode : seq.repeatMode;
const count = seq.count !== undefined ? seq.count : seq.repeatCount;
const index = seq.index !== undefined ? seq.index : idx;
let modeBadge = '';
if (mode === 0) {
modeBadge = '<span class="status-badge badge-once">Once</span>';
} else if (mode === 1) {
modeBadge = '<span class="status-badge badge-count">Count: ' + count + '</span>';
} else if (mode === 2) {
modeBadge = '<span class="status-badge badge-infinite">Infinite</span>';
}
const stepCount = typeof seq.steps === 'number' ? seq.steps :
(Array.isArray(seq.steps) ? seq.steps.length : seq.stepCount || 0);
card.innerHTML =
'<h3>' + seq.name + '</h3>' +
'<div class="sequence-info">' +
modeBadge +
'<span class="status-badge" style="background: #e0e0e0; color: #555;">Steps: ' + stepCount + '</span>' +
'</div>' +
'<div class="button-group">' +
'<button class="btn-primary btn-small" onclick="loadSequenceDetail(' + index + ')">📝 Edit</button>' +
'<button class="btn-success btn-small" onclick="startSequence(' + index + ')">▶️ Run</button>' +
'<button class="btn-warning btn-small" onclick="stopSequence()">⏹️ Stop</button>' +
'<button class="btn-danger btn-small" onclick="deleteSequence(' + index + ')">🗑️ Delete</button>' +
'</div>';
list.appendChild(card);
});
}
function loadSequenceDetail(index) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({cmd: 'getSequenceDetail', index: index}));
console.log(' getSequenceDetail :', index);
}
}
function handleSequenceDetail(data) {
console.log(' Sequence Detail :', data);
// ESP32에서 보낸 형식에 맞춰 처리
document.getElementById('seq-name').value = data.name || '';
document.getElementById('repeat-mode').value = data.repeatMode !== undefined ? data.repeatMode : 0;
document.getElementById('repeat-count').value = data.repeatCount !== undefined ? data.repeatCount : 1;
currentSteps = [];
if (data.steps && Array.isArray(data.steps)) {
data.steps.forEach(step => {
let dataBytes = [];
if (typeof step.data === 'string') {
dataBytes = step.data.split(' ').map(b => parseInt(b, 16));
} else if (Array.isArray(step.data)) {
dataBytes = step.data;
}
currentSteps.push({
canId: typeof step.id === 'string' ? parseInt(step.id.replace('0x', ''), 16) : step.id,
extended: step.ext || step.extended || false,
dlc: step.dlc,
data: dataBytes,
delayMs: step.delay || step.delayMs || 0
});
});
}
updateStepsList();
document.querySelector('.section').scrollIntoView({ behavior: 'smooth' });
alert('Sequence loaded! You can now edit and save it.');
console.log(' Loaded steps:', currentSteps);
}
function startSequence(index) {
if (ws && ws.readyState === WebSocket.OPEN) {
console.log('Starting sequence:', index);
ws.send(JSON.stringify({cmd: 'startSequence', index: index}));
}
}
function stopSequence() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({cmd: 'stopSequence'}));
}
}
// ★★★ 핵심 수정: Arduino는 'removeSequence' 명령어 사용
function deleteSequence(index) {
if (!confirm('Are you sure you want to delete this sequence?')) {
return;
}
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({cmd: 'removeSequence', index: index}));
console.log('Delete sequence command sent (removeSequence):', index);
// 삭제 후 목록 새로고침
setTimeout(loadSequences, 500);
}
}
document.getElementById('repeat-mode').addEventListener('change', function() {
const countInput = document.getElementById('repeat-count');
if (this.value === '1') {
countInput.disabled = false;
} else {
countInput.disabled = true;
}
});
window.addEventListener('load', function() {
initWebSocket();
});
</script>
</body>
</html>
)rawliteral";
#endif