552 lines
18 KiB
HTML
552 lines
18 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>ESP32 Firmware Flasher</title>
|
|
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
padding: 20px;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.container {
|
|
background: white;
|
|
border-radius: 12px;
|
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
max-width: 700px;
|
|
width: 100%;
|
|
padding: 40px;
|
|
}
|
|
|
|
h1 {
|
|
color: #667eea;
|
|
margin-bottom: 10px;
|
|
font-size: 28px;
|
|
}
|
|
|
|
.subtitle {
|
|
color: #666;
|
|
margin-bottom: 30px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
label {
|
|
display: block;
|
|
margin-bottom: 8px;
|
|
color: #333;
|
|
font-weight: 500;
|
|
font-size: 14px;
|
|
}
|
|
|
|
input[type="text"],
|
|
select {
|
|
width: 100%;
|
|
padding: 12px;
|
|
border: 2px solid #e0e0e0;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
transition: border-color 0.3s;
|
|
}
|
|
|
|
input[type="text"]:focus,
|
|
select:focus {
|
|
outline: none;
|
|
border-color: #667eea;
|
|
}
|
|
|
|
.file-input-wrapper {
|
|
position: relative;
|
|
overflow: hidden;
|
|
display: inline-block;
|
|
width: 100%;
|
|
}
|
|
|
|
.file-input-wrapper input[type="file"] {
|
|
position: absolute;
|
|
left: -9999px;
|
|
}
|
|
|
|
.file-input-label {
|
|
display: block;
|
|
padding: 12px;
|
|
border: 2px dashed #e0e0e0;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
color: #666;
|
|
}
|
|
|
|
.file-input-label:hover {
|
|
border-color: #667eea;
|
|
background: #f8f9ff;
|
|
}
|
|
|
|
.file-selected {
|
|
border-color: #667eea;
|
|
border-style: solid;
|
|
background: #f8f9ff;
|
|
color: #667eea;
|
|
}
|
|
|
|
.btn {
|
|
width: 100%;
|
|
padding: 14px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
}
|
|
|
|
.btn-primary:hover:not(:disabled) {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
|
|
}
|
|
|
|
.btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.output-section {
|
|
margin-top: 30px;
|
|
}
|
|
|
|
.output-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.output-toggle {
|
|
background: #f0f0f0;
|
|
border: none;
|
|
border-radius: 6px;
|
|
padding: 8px 16px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
color: #333;
|
|
transition: background 0.3s;
|
|
}
|
|
|
|
.output-toggle:hover {
|
|
background: #e0e0e0;
|
|
}
|
|
|
|
.output-box {
|
|
background: #1e1e1e;
|
|
color: #d4d4d4;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 13px;
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
line-height: 1.6;
|
|
white-space: pre-wrap;
|
|
word-wrap: break-word;
|
|
display: none;
|
|
}
|
|
|
|
.output-box.expanded {
|
|
display: block;
|
|
}
|
|
|
|
.output-box:empty::before {
|
|
content: 'Output will appear here...';
|
|
color: #666;
|
|
}
|
|
|
|
.progress-bar {
|
|
width: 100%;
|
|
height: 8px;
|
|
background: #e0e0e0;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
margin: 20px 0;
|
|
display: none;
|
|
}
|
|
|
|
.progress-bar.active {
|
|
display: block;
|
|
}
|
|
|
|
.progress-bar-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #667eea, #764ba2);
|
|
width: 0%;
|
|
transition: width 0.3s ease-out;
|
|
}
|
|
|
|
.progress-bar-fill.indeterminate {
|
|
animation: progress 2s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes progress {
|
|
0% { width: 0%; }
|
|
50% { width: 100%; }
|
|
100% { width: 0%; }
|
|
}
|
|
|
|
.progress-text {
|
|
text-align: center;
|
|
font-size: 13px;
|
|
color: #667eea;
|
|
font-weight: 600;
|
|
margin-top: 5px;
|
|
display: none;
|
|
}
|
|
|
|
.progress-text.active {
|
|
display: block;
|
|
}
|
|
|
|
.status-message {
|
|
padding: 12px;
|
|
border-radius: 8px;
|
|
margin: 15px 0;
|
|
font-weight: 500;
|
|
display: none;
|
|
}
|
|
|
|
.status-message.success {
|
|
background: #d4edda;
|
|
color: #155724;
|
|
border: 1px solid #c3e6cb;
|
|
display: block;
|
|
}
|
|
|
|
.status-message.error {
|
|
background: #f8d7da;
|
|
color: #721c24;
|
|
border: 1px solid #f5c6cb;
|
|
display: block;
|
|
}
|
|
|
|
.refresh-btn {
|
|
padding: 8px 16px;
|
|
background: #f0f0f0;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
color: #333;
|
|
margin-left: 10px;
|
|
transition: background 0.3s;
|
|
}
|
|
|
|
.refresh-btn:hover {
|
|
background: #e0e0e0;
|
|
}
|
|
|
|
.port-section {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 10px;
|
|
}
|
|
|
|
.port-section .form-group {
|
|
flex: 1;
|
|
margin-bottom: 0;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>ESP32 Firmware Flasher</h1>
|
|
<p class="subtitle">Flash firmware packages to ESP32 devices</p>
|
|
|
|
<form id="flashForm">
|
|
<div class="port-section">
|
|
<div class="form-group">
|
|
<label for="port">Serial Port</label>
|
|
<select id="port" name="port" required>
|
|
<option value="">Select a port...</option>
|
|
</select>
|
|
</div>
|
|
<button type="button" class="refresh-btn" onclick="refreshPorts()">🔄 Refresh</button>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="firmware">Firmware Package (.zip)</label>
|
|
<div class="file-input-wrapper">
|
|
<input type="file" id="firmware" name="firmware" accept=".zip" required>
|
|
<label for="firmware" class="file-input-label" id="fileLabel">
|
|
📦 Click to select firmware package
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hidden fields with default values -->
|
|
<input type="hidden" id="chip" name="chip" value="esp32">
|
|
<input type="hidden" id="baud" name="baud" value="460800">
|
|
<input type="hidden" id="flash_mode" name="flash_mode" value="dio">
|
|
<input type="hidden" id="flash_freq" name="flash_freq" value="40m">
|
|
<input type="hidden" id="flash_size" name="flash_size" value="2MB">
|
|
|
|
<button type="submit" class="btn btn-primary" id="flashBtn">
|
|
⚡ Flash Firmware
|
|
</button>
|
|
|
|
<div class="progress-bar" id="progressBar">
|
|
<div class="progress-bar-fill" id="progressBarFill"></div>
|
|
</div>
|
|
<div class="progress-text" id="progressText">0%</div>
|
|
|
|
<div class="status-message" id="statusMessage"></div>
|
|
</form>
|
|
|
|
<div class="output-section">
|
|
<div class="output-header">
|
|
<label>Output Log</label>
|
|
<button type="button" class="output-toggle" id="outputToggle" onclick="toggleOutput()">▼ Show Details</button>
|
|
</div>
|
|
<div class="output-box" id="output"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let statusCheckInterval = null;
|
|
let socket = null;
|
|
let heartbeatInterval = null;
|
|
|
|
// Toggle output visibility
|
|
function toggleOutput() {
|
|
const output = document.getElementById('output');
|
|
const toggle = document.getElementById('outputToggle');
|
|
|
|
if (output.classList.contains('expanded')) {
|
|
output.classList.remove('expanded');
|
|
toggle.textContent = '▼ Show Details';
|
|
} else {
|
|
output.classList.add('expanded');
|
|
toggle.textContent = '▲ Hide Details';
|
|
}
|
|
}
|
|
|
|
// Initialize Socket.IO connection
|
|
function initWebSocket() {
|
|
socket = io();
|
|
|
|
socket.on('connect', () => {
|
|
console.log('WebSocket connected');
|
|
|
|
// Start sending heartbeats every 2 seconds
|
|
heartbeatInterval = setInterval(() => {
|
|
socket.emit('heartbeat');
|
|
}, 2000);
|
|
});
|
|
|
|
socket.on('disconnect', () => {
|
|
console.log('WebSocket disconnected');
|
|
if (heartbeatInterval) {
|
|
clearInterval(heartbeatInterval);
|
|
heartbeatInterval = null;
|
|
}
|
|
});
|
|
|
|
socket.on('heartbeat_ack', (data) => {
|
|
// Heartbeat acknowledged
|
|
});
|
|
}
|
|
|
|
// Load ports on page load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initWebSocket();
|
|
refreshPorts();
|
|
});
|
|
|
|
// Clean up on page unload
|
|
window.addEventListener('beforeunload', () => {
|
|
if (socket) {
|
|
socket.disconnect();
|
|
}
|
|
});
|
|
|
|
// Handle file selection
|
|
document.getElementById('firmware').addEventListener('change', function(e) {
|
|
const fileLabel = document.getElementById('fileLabel');
|
|
if (e.target.files.length > 0) {
|
|
fileLabel.textContent = '✓ ' + e.target.files[0].name;
|
|
fileLabel.classList.add('file-selected');
|
|
} else {
|
|
fileLabel.textContent = '📦 Click to select firmware package';
|
|
fileLabel.classList.remove('file-selected');
|
|
}
|
|
});
|
|
|
|
async function refreshPorts() {
|
|
const portSelect = document.getElementById('port');
|
|
portSelect.innerHTML = '<option value="">Loading ports...</option>';
|
|
|
|
try {
|
|
const response = await fetch('/api/ports');
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
portSelect.innerHTML = '<option value="">Select a port...</option>';
|
|
data.ports.forEach(port => {
|
|
const option = document.createElement('option');
|
|
option.value = port.device;
|
|
option.textContent = `${port.device} - ${port.description}`;
|
|
portSelect.appendChild(option);
|
|
});
|
|
} else {
|
|
portSelect.innerHTML = '<option value="">Error loading ports</option>';
|
|
}
|
|
} catch (error) {
|
|
portSelect.innerHTML = '<option value="">Error loading ports</option>';
|
|
console.error('Error:', error);
|
|
}
|
|
}
|
|
|
|
document.getElementById('flashForm').addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const flashBtn = document.getElementById('flashBtn');
|
|
const progressBar = document.getElementById('progressBar');
|
|
const progressBarFill = document.getElementById('progressBarFill');
|
|
const progressText = document.getElementById('progressText');
|
|
const output = document.getElementById('output');
|
|
const outputToggle = document.getElementById('outputToggle');
|
|
const statusMessage = document.getElementById('statusMessage');
|
|
|
|
// Disable form
|
|
flashBtn.disabled = true;
|
|
flashBtn.textContent = '⚡ Flashing...';
|
|
progressBar.classList.add('active');
|
|
progressBarFill.classList.add('indeterminate');
|
|
progressBarFill.style.width = '0%';
|
|
progressText.classList.add('active');
|
|
progressText.textContent = '0%';
|
|
output.textContent = '';
|
|
output.classList.remove('expanded');
|
|
outputToggle.textContent = '▼ Show Details';
|
|
statusMessage.style.display = 'none';
|
|
statusMessage.className = 'status-message';
|
|
|
|
// Create FormData
|
|
const formData = new FormData(e.target);
|
|
|
|
try {
|
|
const response = await fetch('/api/flash', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
// Start checking status
|
|
statusCheckInterval = setInterval(checkStatus, 500);
|
|
} else {
|
|
showError(data.error);
|
|
resetForm();
|
|
}
|
|
} catch (error) {
|
|
showError('Failed to start flash: ' + error.message);
|
|
resetForm();
|
|
}
|
|
});
|
|
|
|
async function checkStatus() {
|
|
try {
|
|
const response = await fetch('/api/status');
|
|
const data = await response.json();
|
|
|
|
// Update output
|
|
const output = document.getElementById('output');
|
|
output.textContent = data.output.join('\n');
|
|
output.scrollTop = output.scrollHeight;
|
|
|
|
// Update progress bar
|
|
const progressBarFill = document.getElementById('progressBarFill');
|
|
const progressText = document.getElementById('progressText');
|
|
if (data.progress !== undefined && data.progress > 0) {
|
|
// Switch from indeterminate to determinate
|
|
progressBarFill.classList.remove('indeterminate');
|
|
progressBarFill.style.width = data.progress + '%';
|
|
progressText.textContent = data.progress.toFixed(1) + '%';
|
|
}
|
|
|
|
// Check if complete
|
|
if (!data.running && statusCheckInterval) {
|
|
clearInterval(statusCheckInterval);
|
|
statusCheckInterval = null;
|
|
|
|
const statusMessage = document.getElementById('statusMessage');
|
|
if (data.success === true) {
|
|
progressBarFill.style.width = '100%';
|
|
progressText.textContent = '100%';
|
|
statusMessage.textContent = '✓ Flash completed successfully!';
|
|
statusMessage.className = 'status-message success';
|
|
statusMessage.style.display = 'block';
|
|
} else if (data.success === false) {
|
|
statusMessage.textContent = '✗ Flash operation failed. Check the output log for details.';
|
|
statusMessage.className = 'status-message error';
|
|
statusMessage.style.display = 'block';
|
|
}
|
|
|
|
resetForm();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error checking status:', error);
|
|
clearInterval(statusCheckInterval);
|
|
statusCheckInterval = null;
|
|
resetForm();
|
|
}
|
|
}
|
|
|
|
function showError(message) {
|
|
const statusMessage = document.getElementById('statusMessage');
|
|
statusMessage.textContent = '✗ ' + message;
|
|
statusMessage.className = 'status-message error';
|
|
statusMessage.style.display = 'block';
|
|
}
|
|
|
|
function resetForm() {
|
|
const flashBtn = document.getElementById('flashBtn');
|
|
const progressBar = document.getElementById('progressBar');
|
|
const progressBarFill = document.getElementById('progressBarFill');
|
|
const progressText = document.getElementById('progressText');
|
|
|
|
flashBtn.disabled = false;
|
|
flashBtn.textContent = '⚡ Flash Firmware';
|
|
|
|
// Delay hiding progress to let user see completion
|
|
setTimeout(() => {
|
|
progressBar.classList.remove('active');
|
|
progressText.classList.remove('active');
|
|
progressBarFill.classList.remove('indeterminate');
|
|
progressBarFill.style.width = '0%';
|
|
}, 2000);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|