691 lines
25 KiB
C++
691 lines
25 KiB
C++
#ifndef SERIAL_H
|
||
#define SERIAL_H
|
||
|
||
const char serial_html[] PROGMEM = R"rawliteral(
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<title>Serial Monitor - Byun CAN Logger</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, 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;
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: calc(100vh - 20px);
|
||
}
|
||
.header {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
padding: 20px;
|
||
text-align: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.header h1 { font-size: 1.8em; margin-bottom: 5px; }
|
||
.header p { opacity: 0.9; font-size: 0.9em; }
|
||
.nav {
|
||
background: #2c3e50;
|
||
padding: 10px;
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.nav a {
|
||
color: white;
|
||
text-decoration: none;
|
||
padding: 10px 15px;
|
||
border-radius: 5px;
|
||
transition: all 0.3s;
|
||
font-size: 0.9em;
|
||
white-space: nowrap;
|
||
}
|
||
.nav a:hover { background: #34495e; }
|
||
.nav a.active { background: #3498db; }
|
||
|
||
.content {
|
||
padding: 15px;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.config-panel {
|
||
background: #f8f9fa;
|
||
padding: 15px;
|
||
border-radius: 10px;
|
||
margin-bottom: 15px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||
flex-shrink: 0;
|
||
}
|
||
.config-row {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.config-row:last-child { margin-bottom: 0; }
|
||
.config-row label {
|
||
font-weight: 600;
|
||
color: #333;
|
||
white-space: nowrap;
|
||
min-width: 80px;
|
||
}
|
||
.config-row select, .config-row input {
|
||
padding: 8px 12px;
|
||
border: 2px solid #ddd;
|
||
border-radius: 5px;
|
||
font-size: 0.95em;
|
||
transition: all 0.3s;
|
||
background: white;
|
||
}
|
||
.config-row select:focus, .config-row input:focus {
|
||
outline: none;
|
||
border-color: #667eea;
|
||
}
|
||
.btn {
|
||
padding: 8px 16px;
|
||
border: none;
|
||
border-radius: 5px;
|
||
font-size: 0.95em;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
white-space: nowrap;
|
||
}
|
||
.btn-primary {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
}
|
||
.btn-success {
|
||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||
color: white;
|
||
}
|
||
.btn-danger {
|
||
background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);
|
||
color: white;
|
||
}
|
||
.btn-warning {
|
||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||
color: white;
|
||
}
|
||
.btn:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||
}
|
||
.btn:disabled {
|
||
background: #cccccc;
|
||
cursor: not-allowed;
|
||
opacity: 0.6;
|
||
transform: none;
|
||
}
|
||
|
||
.status-bar {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
padding: 10px 15px;
|
||
border-radius: 8px;
|
||
margin-bottom: 15px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
.status-bar.connected {
|
||
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||
}
|
||
.status-bar.disconnected {
|
||
background: linear-gradient(135deg, #eb3349 0%, #f45c43 100%);
|
||
}
|
||
.status-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 0.9em;
|
||
}
|
||
.status-label {
|
||
font-weight: 600;
|
||
opacity: 0.9;
|
||
}
|
||
.status-value {
|
||
font-family: 'Courier New', monospace;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.terminal-container {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
background: #1e1e1e;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||
}
|
||
.terminal-header {
|
||
background: #2d2d2d;
|
||
padding: 10px 15px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
border-bottom: 1px solid #3d3d3d;
|
||
}
|
||
.terminal-title {
|
||
color: #e0e0e0;
|
||
font-size: 0.9em;
|
||
font-weight: 600;
|
||
}
|
||
.terminal-controls {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
.terminal-btn {
|
||
padding: 5px 12px;
|
||
font-size: 0.85em;
|
||
border-radius: 4px;
|
||
}
|
||
.terminal-output {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 15px;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 0.9em;
|
||
color: #e0e0e0;
|
||
line-height: 1.5;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
}
|
||
.terminal-output::-webkit-scrollbar {
|
||
width: 8px;
|
||
}
|
||
.terminal-output::-webkit-scrollbar-track {
|
||
background: #2d2d2d;
|
||
}
|
||
.terminal-output::-webkit-scrollbar-thumb {
|
||
background: #4d4d4d;
|
||
border-radius: 4px;
|
||
}
|
||
.terminal-output::-webkit-scrollbar-thumb:hover {
|
||
background: #5d5d5d;
|
||
}
|
||
.terminal-input-container {
|
||
background: #2d2d2d;
|
||
padding: 10px 15px;
|
||
display: flex;
|
||
gap: 10px;
|
||
border-top: 1px solid #3d3d3d;
|
||
}
|
||
.terminal-input {
|
||
flex: 1;
|
||
padding: 8px 12px;
|
||
border: 2px solid #4d4d4d;
|
||
border-radius: 5px;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 0.9em;
|
||
background: #1e1e1e;
|
||
color: #e0e0e0;
|
||
}
|
||
.terminal-input:focus {
|
||
outline: none;
|
||
border-color: #667eea;
|
||
}
|
||
|
||
.log-line {
|
||
margin-bottom: 2px;
|
||
}
|
||
.log-line.tx {
|
||
color: #4fc3f7;
|
||
}
|
||
.log-line.rx {
|
||
color: #81c784;
|
||
}
|
||
.log-line.error {
|
||
color: #e57373;
|
||
}
|
||
.log-line.info {
|
||
color: #ffd54f;
|
||
}
|
||
.timestamp {
|
||
color: #9e9e9e;
|
||
font-size: 0.85em;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.header h1 { font-size: 1.5em; }
|
||
.header p { font-size: 0.85em; }
|
||
.content { padding: 10px; }
|
||
.config-row {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
.config-row label {
|
||
min-width: auto;
|
||
}
|
||
.status-bar {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
}
|
||
.terminal-output {
|
||
font-size: 0.8em;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header">
|
||
<h1>📡 Serial Monitor</h1>
|
||
<p>Real-time Serial Communication Interface</p>
|
||
</div>
|
||
|
||
<div class="nav">
|
||
<a href="/">📊 Monitor</a>
|
||
<a href="/transmit">📤 Transmit</a>
|
||
<a href="/graph">📈 Graph</a>
|
||
<a href="/graph-view">📊 Graph View</a>
|
||
<a href="/serial" class="active">📡 Serial</a>
|
||
<a href="/settings">⚙️ Settings</a>
|
||
</div>
|
||
|
||
<div class="content">
|
||
<div class="config-panel">
|
||
<div class="config-row">
|
||
<label>Baud Rate:</label>
|
||
<select id="baudrate">
|
||
<option value="1200">1200</option>
|
||
<option value="2400">2400</option>
|
||
<option value="4800">4800</option>
|
||
<option value="9600" selected>9600</option>
|
||
<option value="19200">19200</option>
|
||
<option value="38400">38400</option>
|
||
<option value="57600">57600</option>
|
||
<option value="115200">115200</option>
|
||
<option value="230400">230400</option>
|
||
<option value="460800">460800</option>
|
||
<option value="921600">921600</option>
|
||
</select>
|
||
|
||
<label style="margin-left: 10px;">Data Bits:</label>
|
||
<select id="databits">
|
||
<option value="5">5</option>
|
||
<option value="6">6</option>
|
||
<option value="7">7</option>
|
||
<option value="8" selected>8</option>
|
||
</select>
|
||
|
||
<label style="margin-left: 10px;">Parity:</label>
|
||
<select id="parity">
|
||
<option value="0" selected>None</option>
|
||
<option value="2">Even</option>
|
||
<option value="3">Odd</option>
|
||
</select>
|
||
|
||
<label style="margin-left: 10px;">Stop Bits:</label>
|
||
<select id="stopbits">
|
||
<option value="1" selected>1</option>
|
||
<option value="2">2</option>
|
||
</select>
|
||
|
||
<button class="btn btn-primary" onclick="applySerialConfig()">Apply</button>
|
||
</div>
|
||
|
||
<div class="config-row">
|
||
<label>Line Ending:</label>
|
||
<select id="line-ending">
|
||
<option value="none">No line ending</option>
|
||
<option value="lf">LF (\n)</option>
|
||
<option value="cr">CR (\r)</option>
|
||
<option value="crlf" selected>CR+LF (\r\n)</option>
|
||
</select>
|
||
|
||
<label style="margin-left: 10px;">Display:</label>
|
||
<select id="display-mode">
|
||
<option value="text" selected>Text</option>
|
||
<option value="hex">HEX</option>
|
||
<option value="both">Both</option>
|
||
</select>
|
||
|
||
<button class="btn btn-success" id="connect-btn" onclick="toggleConnection()">Connect</button>
|
||
<button class="btn btn-warning" onclick="clearTerminal()">Clear</button>
|
||
<button class="btn btn-primary" onclick="saveLog()">Save Log</button>
|
||
<button class="btn btn-danger" onclick="stopLogging()">Stop Log</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="status-bar disconnected" id="status-bar">
|
||
<div class="status-item">
|
||
<span class="status-label">Status:</span>
|
||
<span class="status-value" id="connection-status">Disconnected</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">TX:</span>
|
||
<span class="status-value" id="tx-count">0</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">RX:</span>
|
||
<span class="status-value" id="rx-count">0</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">Errors:</span>
|
||
<span class="status-value" id="error-count">0</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">Logging:</span>
|
||
<span class="status-value" id="logging-status">OFF</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="terminal-container">
|
||
<div class="terminal-header">
|
||
<div class="terminal-title">Serial Terminal</div>
|
||
<div class="terminal-controls">
|
||
<button class="btn terminal-btn btn-warning" onclick="clearTerminal()">Clear</button>
|
||
<button class="btn terminal-btn btn-primary" onclick="scrollToBottom()">Bottom</button>
|
||
</div>
|
||
</div>
|
||
<div class="terminal-output" id="terminal-output"></div>
|
||
<div class="terminal-input-container">
|
||
<input type="text" class="terminal-input" id="terminal-input"
|
||
placeholder="Type command and press Enter..."
|
||
onkeypress="handleInputKeypress(event)">
|
||
<button class="btn btn-primary" onclick="sendCommand()">Send</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let ws;
|
||
let isConnected = false;
|
||
let isLogging = false;
|
||
let txCount = 0;
|
||
let rxCount = 0;
|
||
let errorCount = 0;
|
||
let logData = [];
|
||
|
||
function initWebSocket() {
|
||
ws = new WebSocket('ws://' + window.location.hostname + ':81/');
|
||
|
||
ws.onopen = function() {
|
||
console.log('WebSocket connected');
|
||
};
|
||
|
||
ws.onclose = function() {
|
||
console.log('WebSocket disconnected');
|
||
setTimeout(initWebSocket, 3000);
|
||
};
|
||
|
||
ws.onerror = function(error) {
|
||
console.error('WebSocket error:', error);
|
||
};
|
||
|
||
ws.onmessage = function(event) {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
|
||
if (data.type === 'serialStatus') {
|
||
updateSerialStatus(data);
|
||
} else if (data.type === 'serialData') {
|
||
displaySerialData(data);
|
||
} else if (data.type === 'serialConfig') {
|
||
updateSerialConfig(data);
|
||
}
|
||
} catch (e) {
|
||
console.error('Parse error:', e);
|
||
}
|
||
};
|
||
}
|
||
|
||
function toggleConnection() {
|
||
const btn = document.getElementById('connect-btn');
|
||
|
||
if (isConnected) {
|
||
// Disconnect
|
||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify({cmd: 'serialDisconnect'}));
|
||
}
|
||
isConnected = false;
|
||
btn.textContent = 'Connect';
|
||
btn.className = 'btn btn-success';
|
||
document.getElementById('status-bar').className = 'status-bar disconnected';
|
||
document.getElementById('connection-status').textContent = 'Disconnected';
|
||
} else {
|
||
// Connect
|
||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify({cmd: 'serialConnect'}));
|
||
}
|
||
isConnected = true;
|
||
btn.textContent = 'Disconnect';
|
||
btn.className = 'btn btn-danger';
|
||
document.getElementById('status-bar').className = 'status-bar connected';
|
||
document.getElementById('connection-status').textContent = 'Connected';
|
||
}
|
||
}
|
||
|
||
function applySerialConfig() {
|
||
const config = {
|
||
cmd: 'serialConfig',
|
||
baudrate: parseInt(document.getElementById('baudrate').value),
|
||
databits: parseInt(document.getElementById('databits').value),
|
||
parity: parseInt(document.getElementById('parity').value),
|
||
stopbits: parseInt(document.getElementById('stopbits').value)
|
||
};
|
||
|
||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify(config));
|
||
console.log('Serial config applied:', config);
|
||
}
|
||
}
|
||
|
||
function sendCommand() {
|
||
const input = document.getElementById('terminal-input');
|
||
const text = input.value;
|
||
|
||
if (!text || !isConnected) return;
|
||
|
||
const lineEnding = document.getElementById('line-ending').value;
|
||
let commandToSend = text;
|
||
|
||
if (lineEnding === 'lf') commandToSend += '\n';
|
||
else if (lineEnding === 'cr') commandToSend += '\r';
|
||
else if (lineEnding === 'crlf') commandToSend += '\r\n';
|
||
|
||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify({
|
||
cmd: 'serialSend',
|
||
data: commandToSend
|
||
}));
|
||
|
||
// Display sent data
|
||
displaySerialData({
|
||
direction: 'tx',
|
||
data: text,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
txCount++;
|
||
document.getElementById('tx-count').textContent = txCount;
|
||
}
|
||
|
||
input.value = '';
|
||
}
|
||
|
||
function handleInputKeypress(event) {
|
||
if (event.key === 'Enter') {
|
||
sendCommand();
|
||
}
|
||
}
|
||
|
||
function displaySerialData(data) {
|
||
const output = document.getElementById('terminal-output');
|
||
const displayMode = document.getElementById('display-mode').value;
|
||
const timestamp = new Date(data.timestamp).toLocaleTimeString('ko-KR', {hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit'}) + '.' + (data.timestamp % 1000).toString().padStart(3, '0');
|
||
|
||
let displayText = '';
|
||
|
||
if (displayMode === 'text' || displayMode === 'both') {
|
||
displayText = data.data;
|
||
}
|
||
|
||
if (displayMode === 'hex' || displayMode === 'both') {
|
||
let hexText = '';
|
||
for (let i = 0; i < data.data.length; i++) {
|
||
const hex = data.data.charCodeAt(i).toString(16).toUpperCase().padStart(2, '0');
|
||
hexText += hex + ' ';
|
||
}
|
||
if (displayMode === 'both') {
|
||
displayText += ' [' + hexText.trim() + ']';
|
||
} else {
|
||
displayText = hexText.trim();
|
||
}
|
||
}
|
||
|
||
const line = document.createElement('div');
|
||
line.className = 'log-line ' + data.direction;
|
||
line.innerHTML = '<span class="timestamp">[' + timestamp + ']</span> ' +
|
||
'<span style="font-weight: 700;">' + (data.direction === 'tx' ? 'TX' : 'RX') + ':</span> ' +
|
||
displayText;
|
||
|
||
output.appendChild(line);
|
||
|
||
// Auto scroll to bottom
|
||
if (output.scrollHeight - output.scrollTop <= output.clientHeight + 100) {
|
||
output.scrollTop = output.scrollHeight;
|
||
}
|
||
|
||
// Update counters
|
||
if (data.direction === 'rx') {
|
||
rxCount++;
|
||
document.getElementById('rx-count').textContent = rxCount;
|
||
}
|
||
|
||
// Save to log if logging is enabled
|
||
if (isLogging) {
|
||
logData.push({
|
||
timestamp: timestamp,
|
||
direction: data.direction,
|
||
data: data.data
|
||
});
|
||
}
|
||
}
|
||
|
||
function updateSerialStatus(data) {
|
||
if (data.connected !== undefined) {
|
||
isConnected = data.connected;
|
||
const btn = document.getElementById('connect-btn');
|
||
if (isConnected) {
|
||
btn.textContent = 'Disconnect';
|
||
btn.className = 'btn btn-danger';
|
||
document.getElementById('status-bar').className = 'status-bar connected';
|
||
document.getElementById('connection-status').textContent = 'Connected';
|
||
} else {
|
||
btn.textContent = 'Connect';
|
||
btn.className = 'btn btn-success';
|
||
document.getElementById('status-bar').className = 'status-bar disconnected';
|
||
document.getElementById('connection-status').textContent = 'Disconnected';
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateSerialConfig(data) {
|
||
if (data.baudrate) document.getElementById('baudrate').value = data.baudrate;
|
||
if (data.databits) document.getElementById('databits').value = data.databits;
|
||
if (data.parity !== undefined) document.getElementById('parity').value = data.parity;
|
||
if (data.stopbits) document.getElementById('stopbits').value = data.stopbits;
|
||
}
|
||
|
||
function clearTerminal() {
|
||
document.getElementById('terminal-output').innerHTML = '';
|
||
txCount = 0;
|
||
rxCount = 0;
|
||
errorCount = 0;
|
||
document.getElementById('tx-count').textContent = '0';
|
||
document.getElementById('rx-count').textContent = '0';
|
||
document.getElementById('error-count').textContent = '0';
|
||
}
|
||
|
||
function scrollToBottom() {
|
||
const output = document.getElementById('terminal-output');
|
||
output.scrollTop = output.scrollHeight;
|
||
}
|
||
|
||
function saveLog() {
|
||
if (isLogging) {
|
||
alert('Logging is already active. Stop logging first to save.');
|
||
return;
|
||
}
|
||
|
||
isLogging = true;
|
||
logData = [];
|
||
document.getElementById('logging-status').textContent = 'ON';
|
||
document.getElementById('logging-status').style.color = '#38ef7d';
|
||
|
||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify({cmd: 'serialStartLog'}));
|
||
}
|
||
|
||
console.log('Serial logging started');
|
||
}
|
||
|
||
function stopLogging() {
|
||
if (!isLogging) {
|
||
alert('Logging is not active.');
|
||
return;
|
||
}
|
||
|
||
isLogging = false;
|
||
document.getElementById('logging-status').textContent = 'OFF';
|
||
document.getElementById('logging-status').style.color = '#e0e0e0';
|
||
|
||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify({cmd: 'serialStopLog'}));
|
||
}
|
||
|
||
// Generate log file
|
||
if (logData.length > 0) {
|
||
let logText = 'Serial Communication Log\n';
|
||
logText += 'Generated: ' + new Date().toLocaleString() + '\n';
|
||
logText += '='.repeat(80) + '\n\n';
|
||
|
||
logData.forEach(entry => {
|
||
logText += '[' + entry.timestamp + '] ' + entry.direction.toUpperCase() + ': ' + entry.data + '\n';
|
||
});
|
||
|
||
const blob = new Blob([logText], {type: 'text/plain'});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'serial_log_' + new Date().toISOString().replace(/[:.]/g, '-') + '.txt';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
|
||
console.log('Serial log saved:', logData.length, 'entries');
|
||
}
|
||
|
||
logData = [];
|
||
}
|
||
|
||
initWebSocket();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
)rawliteral";
|
||
|
||
#endif |