1230 lines
39 KiB
HTML
1230 lines
39 KiB
HTML
<!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;
|
||
}
|
||
}
|
||
|
||
/* OTA Update Styles */
|
||
.ota-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 15px;
|
||
}
|
||
|
||
.ota-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.ota-form label {
|
||
font-weight: 600;
|
||
color: #555;
|
||
}
|
||
|
||
.ota-form input {
|
||
padding: 10px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.ota-form input:focus {
|
||
outline: none;
|
||
border-color: #2196F3;
|
||
}
|
||
|
||
.ota-progress {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.progress-bar {
|
||
flex: 1;
|
||
height: 20px;
|
||
background: #e0e0e0;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.progress-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, #4CAF50, #8BC34A);
|
||
border-radius: 10px;
|
||
transition: width 0.3s ease;
|
||
width: 0%;
|
||
}
|
||
|
||
#ota-progress-text {
|
||
font-weight: 600;
|
||
min-width: 45px;
|
||
}
|
||
|
||
.ota-info {
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
/* OTA Upload Section */
|
||
.ota-upload-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.drop-zone {
|
||
border: 2px dashed #ccc;
|
||
border-radius: 8px;
|
||
padding: 30px 20px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
background: #fafafa;
|
||
}
|
||
|
||
.drop-zone:hover, .drop-zone.drag-over {
|
||
border-color: #2196F3;
|
||
background: #e3f2fd;
|
||
}
|
||
|
||
.drop-zone.drag-over {
|
||
transform: scale(1.02);
|
||
}
|
||
|
||
.drop-zone-icon {
|
||
font-size: 48px;
|
||
display: block;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.drop-zone p {
|
||
margin: 0;
|
||
font-weight: 600;
|
||
color: #333;
|
||
}
|
||
|
||
.drop-zone-hint {
|
||
font-size: 12px;
|
||
color: #666;
|
||
margin-top: 5px;
|
||
display: block;
|
||
}
|
||
|
||
.file-info {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 10px;
|
||
background: #e8f5e9;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.file-info #file-name {
|
||
font-weight: 600;
|
||
color: #2e7d32;
|
||
}
|
||
|
||
.file-info #file-size {
|
||
color: #666;
|
||
}
|
||
|
||
.ota-divider {
|
||
display: flex;
|
||
align-items: center;
|
||
text-align: center;
|
||
margin: 15px 0;
|
||
}
|
||
|
||
.ota-divider::before,
|
||
.ota-divider::after {
|
||
content: '';
|
||
flex: 1;
|
||
border-bottom: 1px solid #ddd;
|
||
}
|
||
|
||
.ota-divider span {
|
||
padding: 0 15px;
|
||
color: #999;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 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">
|
||
<!-- OTA Update Card -->
|
||
<div class="card">
|
||
<h2><span class="icon">🔄</span> OTA Update</h2>
|
||
<div class="ota-container">
|
||
<div class="ota-info">
|
||
<div class="info-item">
|
||
<div class="info-label">Running Partition</div>
|
||
<div class="info-value" id="ota-partition">-</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- File Upload Section -->
|
||
<div class="ota-upload-section">
|
||
<div class="drop-zone" id="drop-zone">
|
||
<div class="drop-zone-content">
|
||
<span class="drop-zone-icon">📁</span>
|
||
<p>Drag & drop firmware file here</p>
|
||
<span class="drop-zone-hint">or click to browse</span>
|
||
</div>
|
||
<input type="file" id="ota-file-input" accept=".bin" style="display: none;" />
|
||
</div>
|
||
<div class="file-info" id="file-info" style="display: none;">
|
||
<span id="file-name">-</span>
|
||
<span id="file-size">-</span>
|
||
</div>
|
||
<button class="btn btn-primary" id="ota-upload-btn" onclick="uploadFirmware()" disabled>Upload Firmware</button>
|
||
</div>
|
||
|
||
<div class="ota-divider">
|
||
<span>OR</span>
|
||
</div>
|
||
|
||
<div class="ota-form">
|
||
<label for="ota-url">Firmware URL:</label>
|
||
<input type="text" id="ota-url" placeholder="http://server/firmware.bin" />
|
||
<button class="btn btn-secondary" id="ota-btn" onclick="startOTAUpdate()">Download from URL</button>
|
||
</div>
|
||
<div class="ota-progress" id="ota-progress-container" style="display: none;">
|
||
<div class="progress-bar">
|
||
<div class="progress-fill" id="ota-progress-bar"></div>
|
||
</div>
|
||
<span id="ota-progress-text">0%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- System Info Card (Additional) -->
|
||
<div class="card">
|
||
<h2><span class="icon">📊</span> System Status</h2>
|
||
<div class="info-grid">
|
||
<div class="info-item">
|
||
<div class="info-label">Free Heap</div>
|
||
<div class="info-value" id="free-heap">-</div>
|
||
</div>
|
||
<div class="info-item">
|
||
<div class="info-label">Uptime</div>
|
||
<div class="info-value" id="uptime">-</div>
|
||
</div>
|
||
<div class="info-item">
|
||
<div class="info-label">WiFi Mode</div>
|
||
<div class="info-value" id="wifi-mode">Ethernet</div>
|
||
</div>
|
||
<div class="info-item">
|
||
<div class="info-label">IP Address</div>
|
||
<div class="info-value" id="ip-address">-</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);
|
||
}
|
||
}
|
||
|
||
// OTA Update Functions
|
||
let otaPollInterval = null;
|
||
|
||
async function updateOTAStatus() {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/api/ota/status`);
|
||
const result = await response.json();
|
||
|
||
if (result.success && result.data) {
|
||
document.getElementById('ota-partition').textContent = result.data.partition || '-';
|
||
|
||
if (result.data.updating) {
|
||
document.getElementById('ota-progress-container').style.display = 'flex';
|
||
const progress = result.data.progress || 0;
|
||
document.getElementById('ota-progress-bar').style.width = `${progress}%`;
|
||
document.getElementById('ota-progress-text').textContent = `${progress}%`;
|
||
document.getElementById('ota-btn').disabled = true;
|
||
document.getElementById('ota-btn').textContent = 'Updating...';
|
||
} else {
|
||
document.getElementById('ota-progress-container').style.display = 'none';
|
||
document.getElementById('ota-btn').disabled = false;
|
||
document.getElementById('ota-btn').textContent = 'Start Update';
|
||
|
||
if (otaPollInterval) {
|
||
clearInterval(otaPollInterval);
|
||
otaPollInterval = null;
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to get OTA status:', error);
|
||
}
|
||
}
|
||
|
||
async function startOTAUpdate() {
|
||
const url = document.getElementById('ota-url').value.trim();
|
||
|
||
if (!url) {
|
||
showToast('Please enter a firmware URL', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${API_BASE}/api/ota/update`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ url: url })
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showToast('OTA update started', 'success');
|
||
document.getElementById('ota-btn').disabled = true;
|
||
document.getElementById('ota-btn').textContent = 'Updating...';
|
||
document.getElementById('ota-progress-container').style.display = 'flex';
|
||
|
||
// Start polling for progress
|
||
otaPollInterval = setInterval(updateOTAStatus, 1000);
|
||
} else {
|
||
showToast(result.error || 'Failed to start OTA update', 'error');
|
||
}
|
||
} catch (error) {
|
||
console.error('OTA update error:', error);
|
||
showToast('Failed to start OTA update', 'error');
|
||
}
|
||
}
|
||
|
||
// File Upload Functions
|
||
let selectedFile = null;
|
||
|
||
function formatFileSize(bytes) {
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||
}
|
||
|
||
function handleFileSelect(file) {
|
||
if (!file) return;
|
||
|
||
if (!file.name.endsWith('.bin')) {
|
||
showToast('Please select a .bin firmware file', 'error');
|
||
return;
|
||
}
|
||
|
||
selectedFile = file;
|
||
document.getElementById('file-info').style.display = 'flex';
|
||
document.getElementById('file-name').textContent = file.name;
|
||
document.getElementById('file-size').textContent = formatFileSize(file.size);
|
||
document.getElementById('ota-upload-btn').disabled = false;
|
||
}
|
||
|
||
async function uploadFirmware() {
|
||
if (!selectedFile) {
|
||
showToast('Please select a firmware file first', 'error');
|
||
return;
|
||
}
|
||
|
||
const uploadBtn = document.getElementById('ota-upload-btn');
|
||
uploadBtn.disabled = true;
|
||
uploadBtn.textContent = 'Uploading...';
|
||
|
||
document.getElementById('ota-progress-container').style.display = 'flex';
|
||
document.getElementById('ota-progress-bar').style.width = '0%';
|
||
document.getElementById('ota-progress-text').textContent = '0%';
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('firmware', selectedFile);
|
||
|
||
const xhr = new XMLHttpRequest();
|
||
|
||
xhr.upload.onprogress = (e) => {
|
||
if (e.lengthComputable) {
|
||
const percent = Math.round((e.loaded / e.total) * 100);
|
||
document.getElementById('ota-progress-bar').style.width = percent + '%';
|
||
document.getElementById('ota-progress-text').textContent = percent + '%';
|
||
}
|
||
};
|
||
|
||
xhr.onload = () => {
|
||
if (xhr.status === 200) {
|
||
showToast('Firmware uploaded successfully! Device will restart...', 'success');
|
||
document.getElementById('ota-progress-bar').style.width = '100%';
|
||
document.getElementById('ota-progress-text').textContent = '100%';
|
||
} else {
|
||
let errorMsg = 'Upload failed';
|
||
try {
|
||
const result = JSON.parse(xhr.responseText);
|
||
errorMsg = result.error || errorMsg;
|
||
} catch (e) {}
|
||
showToast(errorMsg, 'error');
|
||
uploadBtn.disabled = false;
|
||
uploadBtn.textContent = 'Upload Firmware';
|
||
}
|
||
};
|
||
|
||
xhr.onerror = () => {
|
||
showToast('Upload failed - network error', 'error');
|
||
uploadBtn.disabled = false;
|
||
uploadBtn.textContent = 'Upload Firmware';
|
||
};
|
||
|
||
xhr.open('POST', `${API_BASE}/api/ota/upload`);
|
||
xhr.send(formData);
|
||
|
||
} catch (error) {
|
||
console.error('Upload error:', error);
|
||
showToast('Failed to upload firmware', 'error');
|
||
uploadBtn.disabled = false;
|
||
uploadBtn.textContent = 'Upload Firmware';
|
||
}
|
||
}
|
||
|
||
// Initialize
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
updateSystemInfo();
|
||
updateGPIOStates();
|
||
updatePowerStatus();
|
||
updateOTAStatus();
|
||
connectWebSocket();
|
||
|
||
// Setup drag and drop
|
||
const dropZone = document.getElementById('drop-zone');
|
||
const fileInput = document.getElementById('ota-file-input');
|
||
|
||
dropZone.addEventListener('click', () => fileInput.click());
|
||
|
||
dropZone.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
dropZone.classList.add('drag-over');
|
||
});
|
||
|
||
dropZone.addEventListener('dragleave', () => {
|
||
dropZone.classList.remove('drag-over');
|
||
});
|
||
|
||
dropZone.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
dropZone.classList.remove('drag-over');
|
||
const files = e.dataTransfer.files;
|
||
if (files.length > 0) {
|
||
handleFileSelect(files[0]);
|
||
}
|
||
});
|
||
|
||
fileInput.addEventListener('change', (e) => {
|
||
if (e.target.files.length > 0) {
|
||
handleFileSelect(e.target.files[0]);
|
||
}
|
||
});
|
||
|
||
// Periodic updates
|
||
setInterval(updateSystemInfo, 5000);
|
||
setInterval(updateGPIOStates, 2000);
|
||
setInterval(updatePowerStatus, 2000);
|
||
setInterval(updateOTAStatus, 5000);
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|