Files

770 lines
26 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SkyLogic AeroAlign</title>
<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;
color: #333;
}
.container {
background: white;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 30px;
}
h1 {
color: #667eea;
margin-bottom: 5px;
font-size: 2.5em;
}
.tagline {
color: #888;
font-style: italic;
margin-bottom: 20px;
}
.tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #eee;
}
.tab {
padding: 10px 20px;
background: none;
border: none;
color: #888;
cursor: pointer;
font-size: 1em;
font-weight: 600;
transition: all 0.3s;
border-bottom: 3px solid transparent;
}
.tab.active {
color: #667eea;
border-bottom-color: #667eea;
}
.tab:hover {
color: #667eea;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.status-bar {
background: #f0f4f8;
padding: 15px;
border-radius: 10px;
margin-bottom: 20px;
display: flex;
justify-content: space-around;
flex-wrap: wrap;
gap: 15px;
}
.status-item {
text-align: center;
}
.status-label {
font-size: 0.8em;
color: #888;
display: block;
}
.status-value {
font-size: 1.3em;
font-weight: 700;
color: #667eea;
display: block;
margin-top: 5px;
}
.nodes-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.node-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 25px;
border-radius: 15px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s, box-shadow 0.3s;
position: relative;
}
.node-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.2);
}
.node-card.disconnected {
background: linear-gradient(135deg, #999 0%, #666 100%);
opacity: 0.6;
}
.node-card.warning {
background: linear-gradient(135deg, #f39c12 0%, #e67e22 100%);
}
.node-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.node-label {
font-size: 1.2em;
font-weight: 600;
}
.node-id {
background: rgba(255, 255, 255, 0.3);
padding: 3px 10px;
border-radius: 5px;
font-size: 0.85em;
}
.connection-indicator {
position: absolute;
top: 15px;
right: 15px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #4caf50;
box-shadow: 0 0 10px rgba(76, 175, 80, 0.8);
animation: pulse 2s infinite;
}
.connection-indicator.disconnected {
background: #e74c3c;
box-shadow: 0 0 10px rgba(231, 76, 60, 0.8);
animation: none;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.angle-display {
text-align: center;
margin: 20px 0;
}
.angle-value {
font-size: 4em;
font-weight: 700;
line-height: 1;
}
.angle-label {
font-size: 1em;
opacity: 0.9;
margin-top: 5px;
}
.node-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid rgba(255, 255, 255, 0.3);
}
.metric {
text-align: center;
}
.metric-label {
font-size: 0.75em;
opacity: 0.9;
}
.metric-value {
font-size: 1.2em;
font-weight: 600;
margin-top: 3px;
}
.calibrate-btn {
width: 100%;
padding: 12px;
margin-top: 15px;
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.5);
color: white;
border-radius: 8px;
cursor: pointer;
font-size: 1em;
font-weight: 600;
transition: all 0.3s;
}
.calibrate-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: white;
}
.calibrate-btn:active {
transform: scale(0.98);
}
.differential-view {
background: #f0f4f8;
padding: 30px;
border-radius: 15px;
margin-bottom: 20px;
}
.diff-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.diff-selectors {
display: flex;
gap: 15px;
align-items: center;
}
.node-selector {
padding: 10px 15px;
border: 2px solid #667eea;
border-radius: 8px;
background: white;
color: #333;
font-size: 1em;
cursor: pointer;
}
.diff-result {
text-align: center;
padding: 40px;
background: white;
border-radius: 10px;
}
.diff-value {
font-size: 5em;
font-weight: 700;
color: #667eea;
margin-bottom: 10px;
}
.diff-value.good {
color: #4caf50;
}
.diff-value.warning {
color: #f39c12;
}
.diff-value.bad {
color: #e74c3c;
}
.diff-label {
font-size: 1.2em;
color: #888;
}
.error {
background: #ffebee;
color: #c62828;
padding: 15px;
border-radius: 10px;
margin: 20px 0;
text-align: center;
}
.loading {
text-align: center;
padding: 60px;
color: #888;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.footer {
text-align: center;
color: #888;
margin-top: 30px;
font-size: 0.9em;
padding-top: 20px;
border-top: 2px solid #eee;
}
.api-link {
color: #667eea;
text-decoration: none;
font-weight: 600;
}
.api-link:hover {
text-decoration: underline;
}
.toast {
position: fixed;
bottom: 30px;
right: 30px;
background: #4caf50;
color: white;
padding: 15px 25px;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
font-weight: 600;
opacity: 0;
transition: opacity 0.3s;
z-index: 1000;
}
.toast.show {
opacity: 1;
}
.toast.error {
background: #e74c3c;
}
@media (max-width: 768px) {
.container {
padding: 20px;
}
h1 {
font-size: 2em;
}
.nodes-grid {
grid-template-columns: 1fr;
}
.angle-value {
font-size: 3em;
}
.diff-value {
font-size: 3.5em;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>SkyLogic AeroAlign</h1>
<p class="tagline">Precision Grounded.</p>
</div>
<div class="tabs">
<button class="tab active" onclick="switchTab('sensors')">Sensors</button>
<button class="tab" onclick="switchTab('differential')">Differential</button>
<button class="tab" onclick="switchTab('status')">System</button>
</div>
<!-- Sensors Tab -->
<div id="sensors-tab" class="tab-content active">
<div id="loading" class="loading">
<div class="spinner"></div>
<p>Connecting to sensor nodes...</p>
</div>
<div id="error" class="error" style="display: none;"></div>
<div id="nodes" class="nodes-grid"></div>
</div>
<!-- Differential Tab -->
<div id="differential-tab" class="tab-content">
<div class="differential-view">
<div class="diff-header">
<h2>EWD / Differential Measurement</h2>
<div class="diff-selectors">
<select id="node1-select" class="node-selector" onchange="updateDifferential()">
<option value="">Select Node 1</option>
</select>
<span></span>
<select id="node2-select" class="node-selector" onchange="updateDifferential()">
<option value="">Select Node 2</option>
</select>
</div>
</div>
<div id="diff-result" class="diff-result">
<div class="diff-value" id="diff-value"></div>
<div class="diff-label">Median Differential (Pitch)</div>
<div style="display: flex; gap: 20px; margin-top: 15px; font-size: 14px;">
<div>
<div style="color: #666;">Current:</div>
<div id="diff-current" style="font-weight: 600; font-size: 16px;"></div>
</div>
<div>
<div style="color: #666;">Std Dev:</div>
<div id="diff-stddev" style="font-weight: 600; font-size: 16px;"></div>
</div>
<div>
<div style="color: #666;">Samples:</div>
<div id="diff-samples" style="font-weight: 600; font-size: 16px;"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Status Tab -->
<div id="status-tab" class="tab-content">
<div id="status-bar" class="status-bar"></div>
<div style="background: #f0f4f8; padding: 20px; border-radius: 10px; margin-top: 20px;">
<h3 style="margin-bottom: 15px;">API Endpoints</h3>
<p><a href="/api/nodes" class="api-link">/api/nodes</a> - Sensor data</p>
<p><a href="/api/status" class="api-link">/api/status</a> - System health</p>
<p><a href="/api/differential?node1=1&node2=2" class="api-link">/api/differential</a> - Differential calculation</p>
</div>
</div>
<div class="footer">
<p>SkyLogic AeroAlign v1.0.0 | AeroAlign active, CoG integration base in progress</p>
<p style="margin-top: 10px;">Open source hardware & firmware | <a href="https://github.com" class="api-link">GitHub</a></p>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
// Global state
let systemStatus = {};
let nodes = [];
let selectedNode1 = null;
let selectedNode2 = null;
// Tab switching
function switchTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(tabName + '-tab').classList.add('active');
}
// Show toast notification
function showToast(message, isError = false) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'toast show' + (isError ? ' error' : '');
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
// Fetch system status
async function fetchStatus() {
try {
const response = await fetch('/api/status');
systemStatus = await response.json();
updateStatusDisplay();
} catch (error) {
console.error('Error fetching status:', error);
}
}
// Fetch sensor nodes
async function fetchNodes() {
try {
const response = await fetch('/api/nodes');
nodes = await response.json();
document.getElementById('loading').style.display = 'none';
document.getElementById('error').style.display = 'none';
updateNodesDisplay();
updateNodeSelectors();
} catch (error) {
console.error('Error fetching nodes:', error);
document.getElementById('loading').style.display = 'none';
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent = 'Failed to connect. Check WiFi connection to "SkyLogic-AeroAlign".';
}
}
// Calibrate node
async function calibrateNode(nodeId) {
try {
const response = await fetch('/api/calibrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ node_id: nodeId })
});
const result = await response.json();
if (result.success) {
showToast(`Node ${nodeId} calibrated successfully!`);
} else {
showToast(`Calibration failed: ${result.error}`, true);
}
} catch (error) {
console.error('Error calibrating:', error);
showToast('Calibration failed. Check connection.', true);
}
}
// Update status display
function updateStatusDisplay() {
const statusDiv = document.getElementById('status-bar');
const batteryItem = systemStatus.master_battery_available ? `
<div class="status-item">
<span class="status-label">Master Battery</span>
<span class="status-value">${systemStatus.master_battery_percent}%</span>
</div>
` : '';
statusDiv.innerHTML = `
<div class="status-item">
<span class="status-label">Uptime</span>
<span class="status-value">${formatUptime(systemStatus.uptime_seconds)}</span>
</div>
<div class="status-item">
<span class="status-label">WiFi Clients</span>
<span class="status-value">${systemStatus.wifi_clients_connected}</span>
</div>
<div class="status-item">
<span class="status-label">Packets</span>
<span class="status-value">${systemStatus.esp_now_packets_received || 0}</span>
</div>
<div class="status-item">
<span class="status-label">Loss Rate</span>
<span class="status-value">${((systemStatus.esp_now_packet_loss_rate || 0) * 100).toFixed(1)}%</span>
</div>
<div class="status-item">
<span class="status-label">Free RAM</span>
<span class="status-value">${systemStatus.free_heap_kb} KB</span>
</div>
${batteryItem}
<div class="status-item">
<span class="status-label">Version</span>
<span class="status-value">${systemStatus.firmware_version}</span>
</div>
`;
}
// Update nodes display
function updateNodesDisplay() {
const nodesDiv = document.getElementById('nodes');
if (nodes.length === 0) {
nodesDiv.innerHTML = '<div style="text-align: center; color: #888; padding: 40px;">No sensor nodes connected. Power on Slave nodes.</div>';
return;
}
nodesDiv.innerHTML = nodes.map(node => {
const hasBattery = node.battery_available !== false;
const isWarning = hasBattery && node.battery_percent < 20;
const cardClass = !node.is_connected ? 'disconnected' : (isWarning ? 'warning' : '');
const connClass = node.is_connected ? '' : 'disconnected';
const isScaleNode = node.device_type === 2;
const batteryMetric = hasBattery ? `
<div class="metric">
<div class="metric-label">Battery</div>
<div class="metric-value">${node.battery_percent}%</div>
</div>
` : '';
const cardTitle = isScaleNode ? (node.label || 'CoG Scale ' + node.node_id) : (node.label || 'Sensor ' + node.node_id);
const mainValue = isScaleNode ? `${node.cog_position_mm.toFixed(1)} mm` : `${node.pitch.toFixed(2)}°`;
const mainLabel = isScaleNode ? 'CoG Position' : 'Pitch Angle';
const metricColumns = hasBattery ? 3 : 2;
const metricOneLabel = isScaleNode ? 'Front' : 'Roll';
const metricOneValue = isScaleNode ? `${node.front_weight_g.toFixed(0)} g` : `${node.roll.toFixed(2)}°`;
const metricTwoLabel = isScaleNode ? 'Rear' : 'Signal';
const metricTwoValue = isScaleNode ? `${node.rear_weight_g.toFixed(0)} g` : `${node.rssi} dBm`;
const signalMetric = isScaleNode ? '' : `
<div class="metric">
<div class="metric-label">Signal</div>
<div class="metric-value">${node.rssi} dBm</div>
</div>
`;
return `
<div class="node-card ${cardClass}">
<div class="connection-indicator ${connClass}"></div>
<div class="node-header">
<div class="node-label">${cardTitle}</div>
<div class="node-id">ID: ${node.node_id}</div>
</div>
<div class="angle-display">
<div class="angle-value">${mainValue}</div>
<div class="angle-label">${mainLabel}</div>
</div>
<div class="node-metrics" style="grid-template-columns: repeat(${isScaleNode ? metricColumns : metricColumns}, 1fr);">
<div class="metric">
<div class="metric-label">${metricOneLabel}</div>
<div class="metric-value">${metricOneValue}</div>
</div>
${batteryMetric}
<div class="metric">
<div class="metric-label">${metricTwoLabel}</div>
<div class="metric-value">${metricTwoValue}</div>
</div>
${signalMetric}
</div>
<button class="calibrate-btn" onclick="calibrateNode(${node.node_id})" ${!node.is_connected ? 'disabled' : ''}>
${!node.is_connected ? 'Disconnected' : (isScaleNode ? '⚙ Tare / Calibrate' : '⚙ Calibrate (Zero)')}
</button>
</div>
`;
}).join('');
}
// Update node selectors for differential
function updateNodeSelectors() {
const select1 = document.getElementById('node1-select');
const select2 = document.getElementById('node2-select');
const options = nodes.filter(n => n.is_connected && n.device_type !== 2).map(n =>
`<option value="${n.node_id}">${n.label || 'Node ' + n.node_id} (${n.node_id})</option>`
).join('');
select1.innerHTML = '<option value="">Select Node 1</option>' + options;
select2.innerHTML = '<option value="">Select Node 2</option>' + options;
}
// Update differential measurement
async function updateDifferential() {
const node1 = document.getElementById('node1-select').value;
const node2 = document.getElementById('node2-select').value;
if (!node1 || !node2) {
document.getElementById('diff-value').textContent = '—';
document.getElementById('diff-value').className = 'diff-value';
return;
}
try {
const response = await fetch(`/api/differential?node1=${node1}&node2=${node2}`);
const data = await response.json();
// Display median value (filtered)
const medianValue = data.median_diff;
const diffElem = document.getElementById('diff-value');
diffElem.textContent = medianValue.toFixed(2) + '°';
// Color code based on median value
if (Math.abs(medianValue) < 0.5) {
diffElem.className = 'diff-value good';
} else if (Math.abs(medianValue) < 2.0) {
diffElem.className = 'diff-value warning';
} else {
diffElem.className = 'diff-value bad';
}
// Display current reading (unfiltered)
document.getElementById('diff-current').textContent = data.angle_diff_pitch.toFixed(2) + '°';
// Display standard deviation (measurement stability)
const stdDev = data.std_dev;
const stdDevElem = document.getElementById('diff-stddev');
stdDevElem.textContent = stdDev.toFixed(3) + '°';
// Color code std dev (green if <0.1°, yellow if <0.3°, red if >=0.3°)
if (stdDev < 0.1) {
stdDevElem.style.color = '#28a745';
} else if (stdDev < 0.3) {
stdDevElem.style.color = '#ffc107';
} else {
stdDevElem.style.color = '#dc3545';
}
// Display sample count
document.getElementById('diff-samples').textContent = data.readings_count + '/10';
} catch (error) {
console.error('Error fetching differential:', error);
document.getElementById('diff-value').textContent = 'Error';
document.getElementById('diff-current').textContent = '—';
document.getElementById('diff-stddev').textContent = '—';
document.getElementById('diff-samples').textContent = '—';
}
}
// Format uptime
function formatUptime(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours}h ${minutes}m ${secs}s`;
}
// Poll for updates
function startPolling() {
fetchStatus();
fetchNodes();
// Update nodes every 100ms (10Hz)
setInterval(fetchNodes, 100);
// Update status every 2 seconds
setInterval(fetchStatus, 2000);
// Update differential if selected
setInterval(() => {
const node1 = document.getElementById('node1-select').value;
const node2 = document.getElementById('node2-select').value;
if (node1 && node2) {
updateDifferential();
}
}, 100);
}
// Start when page loads
window.addEventListener('load', startPolling);
</script>
</body>
</html>