Initial commit

ESP32-S3 firmware acting as BMC for another board, controlling an ATX power supply and providing access to UART.

Co-authored with GLM-5
This commit is contained in:
2026-03-11 17:39:43 +01:00
commit 064e8812a4
21 changed files with 4235 additions and 0 deletions

825
main/html/index.html Normal file
View File

@@ -0,0 +1,825 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VisionFive2 BMC Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #eee;
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 30px;
padding: 20px;
background: rgba(255, 255, 255, 0.05);
border-radius: 15px;
backdrop-filter: blur(10px);
}
h1 {
font-size: 2.5em;
margin-bottom: 10px;
background: linear-gradient(90deg, #00d4ff, #00ff88);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.status-bar {
display: flex;
justify-content: center;
gap: 30px;
flex-wrap: wrap;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #ff4444;
animation: pulse 2s infinite;
}
.status-dot.connected {
background: #00ff88;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.card {
background: rgba(255, 255, 255, 0.05);
border-radius: 15px;
padding: 20px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.card h2 {
font-size: 1.3em;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
gap: 10px;
}
.card h2 .icon {
font-size: 1.5em;
}
/* Power Control */
.power-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 1em;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 600;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
}
.btn:active {
transform: translateY(0);
}
.btn-power-on {
background: linear-gradient(135deg, #00c853, #00e676);
color: #000;
}
.btn-power-off {
background: linear-gradient(135deg, #ff1744, #ff5252);
color: #fff;
}
.btn-reset {
background: linear-gradient(135deg, #ff9100, #ffab40);
color: #000;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.power-status {
margin-top: 15px;
padding: 15px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
display: flex;
align-items: center;
gap: 10px;
}
.power-indicator {
width: 20px;
height: 20px;
border-radius: 50%;
background: #ff4444;
}
.power-indicator.on {
background: #00ff88;
box-shadow: 0 0 20px rgba(0, 255, 136, 0.5);
}
/* GPIO Monitor */
.gpio-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.gpio-item {
background: rgba(0, 0, 0, 0.2);
padding: 10px;
border-radius: 8px;
text-align: center;
}
.gpio-name {
font-size: 0.8em;
color: #888;
margin-bottom: 5px;
}
.gpio-pin {
font-size: 0.9em;
color: #00d4ff;
margin-bottom: 5px;
}
.gpio-value {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
}
.gpio-led {
width: 16px;
height: 16px;
border-radius: 50%;
background: #333;
border: 2px solid #555;
}
.gpio-led.high {
background: #00ff88;
border-color: #00ff88;
box-shadow: 0 0 10px rgba(0, 255, 136, 0.5);
}
.gpio-toggle {
padding: 4px 8px;
font-size: 0.8em;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
border-radius: 4px;
cursor: pointer;
}
.gpio-toggle:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Serial Console */
.console-container {
display: flex;
flex-direction: column;
height: 400px;
}
.console-output {
flex: 1;
background: #0a0a0a;
border-radius: 8px;
padding: 15px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
overflow-y: auto;
margin-bottom: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
cursor: text;
}
.console-output:focus {
outline: none;
border-color: #00d4ff;
}
.console-output pre {
white-space: pre-wrap;
word-wrap: break-word;
}
.console-output .input-line {
color: #00ff88;
}
.console-output .cursor {
display: inline-block;
width: 8px;
height: 1em;
background: #00ff88;
animation: blink 1s step-end infinite;
vertical-align: middle;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.console-controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.console-controls select {
padding: 8px 12px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
}
/* System Info */
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.info-item {
padding: 10px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
}
.info-label {
font-size: 0.8em;
color: #888;
margin-bottom: 3px;
}
.info-value {
font-size: 1em;
color: #00d4ff;
font-family: 'Courier New', monospace;
}
/* Toast Notifications */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
}
.toast {
padding: 15px 20px;
border-radius: 8px;
margin-bottom: 10px;
animation: slideIn 0.3s ease;
max-width: 300px;
}
.toast.success {
background: rgba(0, 255, 136, 0.2);
border: 1px solid #00ff88;
}
.toast.error {
background: rgba(255, 68, 68, 0.2);
border: 1px solid #ff4444;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Responsive */
@media (max-width: 600px) {
h1 {
font-size: 1.8em;
}
.status-bar {
flex-direction: column;
align-items: center;
}
.power-buttons {
flex-direction: column;
}
.btn {
width: 100%;
}
.info-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🖥️ VisionFive2 BMC Dashboard</h1>
<div class="status-bar">
<div class="status-item">
<div class="status-dot" id="eth-status"></div>
<span>Ethernet: <span id="eth-text">Disconnected</span></span>
</div>
<div class="status-item">
<span>IP: <span id="ip-address">-</span></span>
</div>
<div class="status-item">
<span>UART: <span id="uart-baud">115200</span> baud</span>
</div>
</div>
</header>
<div class="grid">
<!-- Power Control Card -->
<div class="card">
<h2><span class="icon"></span> Power Control</h2>
<div class="power-buttons">
<button class="btn btn-power-on" onclick="powerOn()">Power On</button>
<button class="btn btn-power-off" onclick="powerOff()">Power Off</button>
<button class="btn btn-reset" onclick="reset()">Reset</button>
</div>
<div class="power-status">
<div class="power-indicator" id="power-indicator"></div>
<span>Power Status: <strong id="power-status">Unknown</strong></span>
</div>
</div>
<!-- System Info Card -->
<div class="card">
<h2><span class="icon"></span> System Info</h2>
<div class="info-grid">
<div class="info-item">
<div class="info-label">MAC Address</div>
<div class="info-value" id="mac-address">-</div>
</div>
<div class="info-item">
<div class="info-label">Netmask</div>
<div class="info-value" id="netmask">-</div>
</div>
<div class="info-item">
<div class="info-label">Gateway</div>
<div class="info-value" id="gateway">-</div>
</div>
<div class="info-item">
<div class="info-label">Link Speed</div>
<div class="info-value" id="link-speed">-</div>
</div>
</div>
</div>
</div>
<div class="grid">
<!-- GPIO Monitor Card -->
<div class="card">
<h2><span class="icon">🔌</span> GPIO Monitor</h2>
<div class="gpio-grid" id="gpio-grid">
<!-- GPIO items will be populated by JavaScript -->
</div>
</div>
<!-- Serial Console Card -->
<div class="card">
<h2><span class="icon">💻</span> Serial Console</h2>
<div class="console-container">
<div class="console-output" id="console-output" tabindex="0">
<pre>Connecting to serial console...</pre>
</div>
<div class="console-controls">
<label>Baud Rate:</label>
<select id="baud-select" onchange="setBaudRate()">
<option value="9600">9600</option>
<option value="19200">19200</option>
<option value="38400">38400</option>
<option value="57600">57600</option>
<option value="115200" selected>115200</option>
<option value="230400">230400</option>
<option value="460800">460800</option>
<option value="921600">921600</option>
</select>
<button class="btn btn-secondary" onclick="clearConsole()">Clear</button>
<button class="btn btn-secondary" onclick="connectWebSocket()">Reconnect</button>
</div>
</div>
</div>
</div>
</div>
<div class="toast-container" id="toast-container"></div>
<script>
// WebSocket connection
let ws = null;
let wsConnected = false;
// API base URL
const API_BASE = '';
// Toast notification
function showToast(message, type = 'success') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
// API helper
async function apiCall(endpoint, method = 'GET', data = null) {
const options = {
method: method,
headers: {
'Content-Type': 'application/json'
}
};
if (data) {
options.body = JSON.stringify(data);
}
try {
const response = await fetch(`${API_BASE}${endpoint}`, options);
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Unknown error');
}
return result;
} catch (error) {
showToast(error.message, 'error');
throw error;
}
}
// Power control functions
async function powerOn() {
try {
await apiCall('/api/power/on', 'POST');
showToast('Power on sequence initiated');
updatePowerStatus();
} catch (error) {
console.error('Power on failed:', error);
}
}
async function powerOff() {
try {
await apiCall('/api/power/off', 'POST');
showToast('Power off sequence initiated');
updatePowerStatus();
} catch (error) {
console.error('Power off failed:', error);
}
}
async function reset() {
try {
await apiCall('/api/power/reset', 'POST');
showToast('Reset sequence initiated');
} catch (error) {
console.error('Reset failed:', error);
}
}
async function updatePowerStatus() {
try {
const result = await apiCall('/api/power/status');
const indicator = document.getElementById('power-indicator');
const status = document.getElementById('power-status');
if (result.data.power_good) {
indicator.classList.add('on');
status.textContent = 'ON';
} else {
indicator.classList.remove('on');
status.textContent = 'OFF';
}
} catch (error) {
console.error('Failed to get power status:', error);
}
}
// GPIO functions
async function updateGPIOStates() {
try {
const result = await apiCall('/api/gpio');
const grid = document.getElementById('gpio-grid');
grid.innerHTML = '';
result.gpios.forEach(gpio => {
const item = document.createElement('div');
item.className = 'gpio-item';
const isOutput = gpio.mode === 'output';
item.innerHTML = `
<div class="gpio-name">${gpio.name}</div>
<div class="gpio-pin">GPIO ${gpio.pin}</div>
<div class="gpio-value">
<div class="gpio-led ${gpio.value ? 'high' : ''}"></div>
<span>${gpio.value}</span>
${isOutput ? `<button class="gpio-toggle" onclick="toggleGPIO(${gpio.pin})">Toggle</button>` : ''}
</div>
`;
grid.appendChild(item);
});
} catch (error) {
console.error('Failed to get GPIO states:', error);
}
}
async function toggleGPIO(pin) {
try {
// Get current state
const result = await apiCall('/api/gpio');
const gpio = result.gpios.find(g => g.pin === pin);
if (gpio) {
const newValue = gpio.value ? 0 : 1;
await apiCall(`/api/gpio/set?pin=${pin}`, 'POST', { value: newValue });
updateGPIOStates();
}
} catch (error) {
console.error('Failed to toggle GPIO:', error);
}
}
// Serial console functions
let inputBuffer = '';
let cursorVisible = true;
function connectWebSocket() {
const protocol = window.location.protocol === 'https' ? 'wss' : 'ws';
const wsUrl = `${protocol}://${window.location.host}/api/serial/ws`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
wsConnected = true;
clearConsole();
appendConsole('Connected to serial console\n');
updateCursor();
};
ws.onmessage = (event) => {
appendConsole(event.data);
};
ws.onclose = () => {
wsConnected = false;
appendConsole('\nDisconnected from serial console\n');
// Try to reconnect after 3 seconds
setTimeout(connectWebSocket, 3000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
appendConsole('WebSocket error\n');
};
}
// Strip ANSI escape sequences from text
function stripAnsi(text) {
// Remove ANSI escape sequences (cursor movement, colors, etc.)
return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
.replace(/\x1b\][^\x07]*\x07/g, '')
.replace(/\x1b[()][AB012]/g, '')
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
}
function appendConsole(data) {
const output = document.getElementById('console-output');
const pre = output.querySelector('pre') || document.createElement('pre');
if (!output.contains(pre)) {
output.innerHTML = '';
output.appendChild(pre);
}
// Remove cursor before appending, then re-add
const cursor = pre.querySelector('.cursor');
if (cursor) cursor.remove();
// Strip ANSI escape sequences for cleaner display
const cleanData = stripAnsi(data);
pre.textContent += cleanData;
updateCursor();
output.scrollTop = output.scrollHeight;
}
function updateCursor() {
const output = document.getElementById('console-output');
const pre = output.querySelector('pre');
if (!pre) return;
// Remove existing cursor
const existingCursor = pre.querySelector('.cursor');
if (existingCursor) existingCursor.remove();
// Add cursor at end
if (wsConnected) {
const cursor = document.createElement('span');
cursor.className = 'cursor';
pre.appendChild(cursor);
}
}
function sendChar(char) {
if (wsConnected) {
ws.send(char);
}
}
function sendBuffer() {
if (inputBuffer && wsConnected) {
ws.send(inputBuffer + '\n');
inputBuffer = '';
updateCursor();
}
}
function handleConsoleKeyDown(event) {
if (!wsConnected) return;
const output = document.getElementById('console-output');
// Handle special keys
if (event.key === 'Enter') {
event.preventDefault();
sendChar('\n');
inputBuffer = '';
} else if (event.key === 'Backspace') {
event.preventDefault();
if (inputBuffer.length > 0) {
inputBuffer = inputBuffer.slice(0, -1);
sendChar('\b'); // Send backspace to remote
}
} else if (event.key === 'Tab') {
event.preventDefault();
sendChar('\t');
inputBuffer += '\t';
} else if (event.key === 'Escape') {
event.preventDefault();
sendChar('\x1b');
} else if (event.ctrlKey && event.key.length === 1) {
// Handle Ctrl+C, Ctrl+D, etc.
event.preventDefault();
const ctrlChar = String.fromCharCode(event.key.charCodeAt(0) - 96);
sendChar(ctrlChar);
} else if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
// Regular character
event.preventDefault();
sendChar(event.key);
inputBuffer += event.key;
}
}
function clearConsole() {
const output = document.getElementById('console-output');
output.innerHTML = '<pre></pre>';
inputBuffer = '';
updateCursor();
}
// Initialize console keyboard handling
document.addEventListener('DOMContentLoaded', () => {
const output = document.getElementById('console-output');
output.addEventListener('keydown', handleConsoleKeyDown);
// Focus console on click
output.addEventListener('click', () => {
output.focus();
});
});
async function setBaudRate() {
const baud = document.getElementById('baud-select').value;
try {
await apiCall('/api/serial/config', 'PUT', { baud_rate: parseInt(baud) });
document.getElementById('uart-baud').textContent = baud;
showToast(`Baud rate set to ${baud}`);
} catch (error) {
console.error('Failed to set baud rate:', error);
}
}
// System info functions
async function updateSystemInfo() {
try {
const result = await apiCall('/api/system/info');
document.getElementById('ip-address').textContent = result.data.ip || '-';
document.getElementById('mac-address').textContent = result.data.mac || '-';
document.getElementById('netmask').textContent = result.data.netmask || '-';
document.getElementById('gateway').textContent = result.data.gateway || '-';
document.getElementById('link-speed').textContent = result.data.link_speed ? `${result.data.link_speed} Mbps` : '-';
// Update Ethernet status
const ethStatus = document.getElementById('eth-status');
const ethText = document.getElementById('eth-text');
if (result.data.ethernet_connected) {
ethStatus.classList.add('connected');
ethText.textContent = 'Connected';
} else {
ethStatus.classList.remove('connected');
ethText.textContent = 'Disconnected';
}
document.getElementById('uart-baud').textContent = result.data.uart_baud_rate;
} catch (error) {
console.error('Failed to get system info:', error);
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
updateSystemInfo();
updateGPIOStates();
updatePowerStatus();
connectWebSocket();
// Periodic updates
setInterval(updateSystemInfo, 5000);
setInterval(updateGPIOStates, 2000);
setInterval(updatePowerStatus, 2000);
});
</script>
</body>
</html>