582 lines
20 KiB
C
582 lines
20 KiB
C
#ifndef CANFD_INDEX_H
|
||
#define CANFD_INDEX_H
|
||
|
||
const char canfd_index_html[] PROGMEM = R"rawliteral(
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>ESP32-S3 CAN FD Logger</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
min-height: 100vh;
|
||
padding: 10px;
|
||
}
|
||
.container {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
background: white;
|
||
border-radius: 15px;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||
overflow: hidden;
|
||
}
|
||
.header {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
padding: 20px;
|
||
text-align: center;
|
||
}
|
||
.header h1 { font-size: 28px; margin-bottom: 5px; }
|
||
.header p { opacity: 0.9; font-size: 14px; }
|
||
|
||
/* Navigation */
|
||
.nav {
|
||
background: #f8f9fa;
|
||
padding: 0;
|
||
border-bottom: 2px solid #e0e0e0;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
}
|
||
.nav a {
|
||
padding: 15px 20px;
|
||
text-decoration: none;
|
||
color: #666;
|
||
font-weight: 500;
|
||
font-size: 14px;
|
||
border-bottom: 3px solid transparent;
|
||
transition: all 0.3s;
|
||
}
|
||
.nav a:hover {
|
||
background: #e9ecef;
|
||
color: #667eea;
|
||
}
|
||
.nav a.active {
|
||
color: #764ba2;
|
||
border-bottom-color: #764ba2;
|
||
background: white;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.content { padding: 20px; }
|
||
|
||
/* Status Cards */
|
||
.status-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 15px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.status-card {
|
||
background: #f8f9fa;
|
||
padding: 20px;
|
||
border-radius: 10px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||
}
|
||
.status-card h3 {
|
||
color: #666;
|
||
font-size: 13px;
|
||
margin-bottom: 10px;
|
||
text-transform: uppercase;
|
||
}
|
||
.status-card .value {
|
||
color: #333;
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
}
|
||
.status-badge {
|
||
display: inline-block;
|
||
padding: 4px 12px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
.status-ok { background: #10b981; color: white; }
|
||
.status-error { background: #ef4444; color: white; }
|
||
.status-warn { background: #f59e0b; color: white; }
|
||
|
||
/* Controls */
|
||
.controls {
|
||
background: #f8f9fa;
|
||
padding: 20px;
|
||
border-radius: 10px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.controls h2 {
|
||
color: #333;
|
||
font-size: 18px;
|
||
margin-bottom: 15px;
|
||
}
|
||
.btn-group {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||
gap: 10px;
|
||
}
|
||
.btn {
|
||
padding: 12px 20px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
}
|
||
.btn-start { background: #10b981; color: white; }
|
||
.btn-start:hover { background: #059669; }
|
||
.btn-stop { background: #ef4444; color: white; }
|
||
.btn-stop:hover { background: #dc2626; }
|
||
.btn-refresh { background: #667eea; color: white; }
|
||
.btn-refresh:hover { background: #5568d3; }
|
||
|
||
/* Settings */
|
||
.settings-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||
gap: 15px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.setting-group {
|
||
background: #f8f9fa;
|
||
padding: 15px;
|
||
border-radius: 10px;
|
||
}
|
||
.setting-label {
|
||
color: #666;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
margin-bottom: 8px;
|
||
display: block;
|
||
}
|
||
select, input {
|
||
width: 100%;
|
||
padding: 10px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* File List */
|
||
.file-section {
|
||
background: #f8f9fa;
|
||
padding: 20px;
|
||
border-radius: 10px;
|
||
margin-top: 20px;
|
||
}
|
||
.file-section h2 {
|
||
color: #333;
|
||
font-size: 18px;
|
||
margin-bottom: 15px;
|
||
}
|
||
.file-list {
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
}
|
||
.file-item {
|
||
background: white;
|
||
padding: 12px 15px;
|
||
margin-bottom: 8px;
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||
}
|
||
.file-item:hover {
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||
}
|
||
.file-checkbox {
|
||
width: 18px;
|
||
height: 18px;
|
||
}
|
||
.file-info {
|
||
flex: 1;
|
||
}
|
||
.file-name {
|
||
font-weight: 600;
|
||
color: #333;
|
||
font-size: 14px;
|
||
}
|
||
.file-size {
|
||
color: #999;
|
||
font-size: 12px;
|
||
margin-left: 10px;
|
||
}
|
||
.file-actions {
|
||
display: flex;
|
||
gap: 5px;
|
||
}
|
||
.file-btn {
|
||
padding: 6px 12px;
|
||
border: none;
|
||
border-radius: 5px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
}
|
||
.btn-download { background: #10b981; color: white; }
|
||
.btn-download:hover { background: #059669; }
|
||
.btn-delete { background: #ef4444; color: white; }
|
||
.btn-delete:hover { background: #dc2626; }
|
||
|
||
/* Real-time Monitor */
|
||
.monitor {
|
||
background: #1a1a1a;
|
||
color: #00ff00;
|
||
padding: 15px;
|
||
border-radius: 8px;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 12px;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
margin-top: 20px;
|
||
}
|
||
.monitor-line {
|
||
margin-bottom: 2px;
|
||
white-space: nowrap;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header">
|
||
<h1>🚗 ESP32-S3 CAN FD Logger</h1>
|
||
<p>MCP2517FD High-Speed Data Logger with Web Interface</p>
|
||
</div>
|
||
|
||
<div class="nav">
|
||
<a href="/" class="active">📊 Dashboard</a>
|
||
<a href="/settings">⚙️ Settings</a>
|
||
<a href="/graph">📈 Graph</a>
|
||
</div>
|
||
|
||
<div class="content">
|
||
<!-- System Status -->
|
||
<div class="status-grid">
|
||
<div class="status-card">
|
||
<h3>CAN Controller</h3>
|
||
<div class="value">
|
||
<span class="status-badge" id="canStatus">Loading...</span>
|
||
</div>
|
||
</div>
|
||
<div class="status-card">
|
||
<h3>SD Card</h3>
|
||
<div class="value">
|
||
<span class="status-badge" id="sdStatus">Loading...</span>
|
||
</div>
|
||
</div>
|
||
<div class="status-card">
|
||
<h3>Total Frames</h3>
|
||
<div class="value" id="canFrames">0</div>
|
||
</div>
|
||
<div class="status-card">
|
||
<h3>Queue Usage</h3>
|
||
<div class="value" id="queueUsage">0 / 10000</div>
|
||
</div>
|
||
<div class="status-card">
|
||
<h3>Errors</h3>
|
||
<div class="value" id="canErrors">0</div>
|
||
</div>
|
||
<div class="status-card">
|
||
<h3>Free PSRAM</h3>
|
||
<div class="value" id="psram">- KB</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CAN Settings -->
|
||
<div class="settings-grid">
|
||
<div class="setting-group">
|
||
<label class="setting-label">CAN Mode</label>
|
||
<select id="canMode">
|
||
<option value="fd">CAN FD Mode</option>
|
||
<option value="classic">Classic CAN Mode</option>
|
||
</select>
|
||
</div>
|
||
<div class="setting-group">
|
||
<label class="setting-label">Bit Rate</label>
|
||
<select id="bitRate">
|
||
<option value="125000">125 kbps</option>
|
||
<option value="250000">250 kbps</option>
|
||
<option value="500000" selected>500 kbps</option>
|
||
<option value="1000000">1 Mbps</option>
|
||
</select>
|
||
</div>
|
||
<div class="setting-group" id="dataRateGroup">
|
||
<label class="setting-label">Data Rate (CAN FD)</label>
|
||
<select id="dataRate">
|
||
<option value="1">x1</option>
|
||
<option value="2">x2</option>
|
||
<option value="4" selected>x4 (2 Mbps)</option>
|
||
<option value="8">x8</option>
|
||
</select>
|
||
</div>
|
||
<div class="setting-group">
|
||
<label class="setting-label">Controller Mode</label>
|
||
<select id="controllerMode">
|
||
<option value="0" selected>Normal</option>
|
||
<option value="1">Listen Only</option>
|
||
<option value="2">Loopback</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Logging Controls -->
|
||
<div class="controls">
|
||
<h2>🎬 Logging Control</h2>
|
||
<div class="btn-group">
|
||
<button class="btn btn-start" onclick="startLogging()">▶ Start Logging</button>
|
||
<button class="btn btn-stop" onclick="stopLogging()">⏹ Stop Logging</button>
|
||
<button class="btn btn-refresh" onclick="applySettings()">💾 Apply Settings</button>
|
||
<button class="btn btn-refresh" onclick="loadFileList()">🔄 Refresh Files</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- File List -->
|
||
<div class="file-section">
|
||
<h2>📁 Log Files on SD Card</h2>
|
||
<div style="margin-bottom: 10px;">
|
||
<button class="btn btn-start" onclick="downloadSelected()" style="padding: 8px 16px;">⬇ Download Selected</button>
|
||
<button class="btn btn-delete" onclick="deleteSelected()" style="padding: 8px 16px;">🗑 Delete Selected</button>
|
||
</div>
|
||
<div class="file-list" id="fileList">
|
||
<p style="text-align: center; color: #999; padding: 20px;">Loading files...</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Real-time Monitor -->
|
||
<div class="controls">
|
||
<h2>📺 Real-time CAN Monitor</h2>
|
||
<div class="monitor" id="monitor">
|
||
<div class="monitor-line">Waiting for CAN messages...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let ws;
|
||
let monitorLines = [];
|
||
const MAX_MONITOR_LINES = 50;
|
||
|
||
// WebSocket connection
|
||
function initWebSocket() {
|
||
ws = new WebSocket('ws://' + window.location.hostname + ':81');
|
||
|
||
ws.onopen = function() {
|
||
console.log('WebSocket connected');
|
||
updateStatus();
|
||
loadFileList();
|
||
};
|
||
|
||
ws.onmessage = function(event) {
|
||
const data = JSON.parse(event.data);
|
||
|
||
if (data.type === 'status') {
|
||
updateStatusDisplay(data);
|
||
} else if (data.type === 'canMessage') {
|
||
addMonitorLine(data);
|
||
} else if (data.type === 'files') {
|
||
displayFiles(data.files);
|
||
}
|
||
};
|
||
|
||
ws.onclose = function() {
|
||
console.log('WebSocket disconnected');
|
||
setTimeout(initWebSocket, 3000);
|
||
};
|
||
}
|
||
|
||
function updateStatus() {
|
||
fetch('/status')
|
||
.then(r => r.json())
|
||
.then(data => updateStatusDisplay(data))
|
||
.catch(err => console.error('Status update failed:', err));
|
||
}
|
||
|
||
function updateStatusDisplay(data) {
|
||
// CAN Status
|
||
const canStatus = document.getElementById('canStatus');
|
||
if (data.canReady) {
|
||
canStatus.className = 'status-badge status-ok';
|
||
canStatus.textContent = 'Ready';
|
||
} else {
|
||
canStatus.className = 'status-badge status-error';
|
||
canStatus.textContent = 'Offline';
|
||
}
|
||
|
||
// SD Status
|
||
const sdStatus = document.getElementById('sdStatus');
|
||
if (data.sdReady) {
|
||
sdStatus.className = 'status-badge status-ok';
|
||
sdStatus.textContent = 'Ready';
|
||
} else {
|
||
sdStatus.className = 'status-badge status-error';
|
||
sdStatus.textContent = 'Error';
|
||
}
|
||
|
||
// Statistics
|
||
document.getElementById('canFrames').textContent = data.canFrames.toLocaleString();
|
||
document.getElementById('queueUsage').textContent = data.queueUsage + ' / 10000';
|
||
document.getElementById('canErrors').textContent = data.canErrors;
|
||
document.getElementById('psram').textContent = data.freePsram + ' KB';
|
||
}
|
||
|
||
function addMonitorLine(msg) {
|
||
const line = `[${msg.timestamp}] ID: 0x${msg.id} DLC: ${msg.len} ${msg.fd ? 'FD' : 'CAN'} ${msg.brs ? 'BRS' : ''} Data: ${msg.data}`;
|
||
|
||
monitorLines.push(line);
|
||
if (monitorLines.length > MAX_MONITOR_LINES) {
|
||
monitorLines.shift();
|
||
}
|
||
|
||
const monitor = document.getElementById('monitor');
|
||
monitor.innerHTML = monitorLines.map(l => `<div class="monitor-line">${l}</div>`).join('');
|
||
monitor.scrollTop = monitor.scrollHeight;
|
||
}
|
||
|
||
function startLogging() {
|
||
fetch('/logging/start', {method: 'POST'})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
alert(data.message);
|
||
updateStatus();
|
||
loadFileList();
|
||
});
|
||
}
|
||
|
||
function stopLogging() {
|
||
fetch('/logging/stop', {method: 'POST'})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
alert(data.message);
|
||
updateStatus();
|
||
loadFileList();
|
||
});
|
||
}
|
||
|
||
function applySettings() {
|
||
const settings = {
|
||
mode: document.getElementById('canMode').value,
|
||
bitRate: parseInt(document.getElementById('bitRate').value),
|
||
dataRate: parseInt(document.getElementById('dataRate').value),
|
||
controllerMode: parseInt(document.getElementById('controllerMode').value)
|
||
};
|
||
|
||
fetch('/settings/apply', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(settings)
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
alert(data.message + '\n\nPlease restart ESP32 to apply changes.');
|
||
});
|
||
}
|
||
|
||
function loadFileList() {
|
||
fetch('/files/list')
|
||
.then(r => r.json())
|
||
.then(data => displayFiles(data.files))
|
||
.catch(err => console.error('File list failed:', err));
|
||
}
|
||
|
||
function displayFiles(files) {
|
||
const fileList = document.getElementById('fileList');
|
||
|
||
if (!files || files.length === 0) {
|
||
fileList.innerHTML = '<p style="text-align: center; color: #999; padding: 20px;">No files found</p>';
|
||
return;
|
||
}
|
||
|
||
fileList.innerHTML = files.map(file => `
|
||
<div class="file-item">
|
||
<input type="checkbox" class="file-checkbox" value="${file.name}">
|
||
<div class="file-info">
|
||
<span class="file-name">${file.name}</span>
|
||
<span class="file-size">${formatBytes(file.size)}</span>
|
||
</div>
|
||
<div class="file-actions">
|
||
<button class="file-btn btn-download" onclick="downloadFile('${file.name}')">Download</button>
|
||
<button class="file-btn btn-delete" onclick="deleteFile('${file.name}')">Delete</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function downloadFile(filename) {
|
||
window.location.href = '/download?file=' + encodeURIComponent(filename);
|
||
}
|
||
|
||
function deleteFile(filename) {
|
||
if (!confirm(`Delete "${filename}"?\n\nThis cannot be undone.`)) return;
|
||
|
||
fetch('/files/delete', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({filename: filename})
|
||
})
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
alert(data.message);
|
||
loadFileList();
|
||
});
|
||
}
|
||
|
||
function downloadSelected() {
|
||
const selected = Array.from(document.querySelectorAll('.file-checkbox:checked'))
|
||
.map(cb => cb.value);
|
||
|
||
if (selected.length === 0) {
|
||
alert('Please select files to download');
|
||
return;
|
||
}
|
||
|
||
selected.forEach(filename => {
|
||
setTimeout(() => downloadFile(filename), 100);
|
||
});
|
||
}
|
||
|
||
function deleteSelected() {
|
||
const selected = Array.from(document.querySelectorAll('.file-checkbox:checked'))
|
||
.map(cb => cb.value);
|
||
|
||
if (selected.length === 0) {
|
||
alert('Please select files to delete');
|
||
return;
|
||
}
|
||
|
||
if (!confirm(`Delete ${selected.length} file(s)?\n\nThis cannot be undone.`)) return;
|
||
|
||
selected.forEach(filename => deleteFile(filename));
|
||
}
|
||
|
||
function formatBytes(bytes) {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||
}
|
||
|
||
// CAN Mode change handler
|
||
document.getElementById('canMode').addEventListener('change', function() {
|
||
const dataRateGroup = document.getElementById('dataRateGroup');
|
||
if (this.value === 'classic') {
|
||
dataRateGroup.style.display = 'none';
|
||
} else {
|
||
dataRateGroup.style.display = 'block';
|
||
}
|
||
});
|
||
|
||
// Initialize
|
||
initWebSocket();
|
||
setInterval(updateStatus, 2000);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
)rawliteral";
|
||
|
||
#endif |