feat(dashboard): web dashboard skeleton with Three.js 3D scene and CSI visualization
- Static HTML/JS dashboard served by mothership at / - Three.js 3D scene with ground grid, OrbitControls (pan/zoom/rotate) - WebSocket connection at /ws/dashboard for real-time CSI streaming - Binary CSI frame parsing (24-byte header + I/Q payload) - Amplitude bar chart (64 subcarriers) as 2D Canvas overlay - Node/link panels with live status and click-to-select - Dashboard Go package with Hub for client broadcasting - Ingestion server broadcasts CSI frames and node events to dashboard - 5 new tests for dashboard hub operations Complete: 3D scene, WebSocket, amplitude chart, node/link panels Remaining: Docker packaging (Phase 1 final item) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f178496c83
commit
8230ef4222
8 changed files with 1590 additions and 4 deletions
71
PROGRESS.md
71
PROGRESS.md
|
|
@ -12,11 +12,80 @@ Goal: Bare-minimum loop from ESP32 to browser. Zero-config with passive radar an
|
|||
| Passive radar support | **Done** | BSSID filter in csi.c |
|
||||
| BLE scanning | **Done** | Core 0 concurrent with WiFi |
|
||||
| Mothership WebSocket ingestion | **Done** | See iteration 1 below |
|
||||
| Dashboard skeleton | Not started | |
|
||||
| Dashboard skeleton | **Done** | See iteration 3 below |
|
||||
| Docker packaging | Not started | |
|
||||
|
||||
### Iteration Log
|
||||
|
||||
#### Iteration 3 — 2026-03-26
|
||||
|
||||
**Completed:** Dashboard skeleton with 3D visualization
|
||||
|
||||
Implemented the web dashboard in vanilla JS + Three.js with:
|
||||
|
||||
- **Static file structure:** `dashboard/` directory with HTML/JS
|
||||
- `index.html` — Dark theme UI with status bar, node/link panels, amplitude chart
|
||||
- `js/app.js` — Main application (~350 lines)
|
||||
|
||||
- **3D Scene (Three.js):**
|
||||
- Ground grid (10×10m, 20 divisions)
|
||||
- OrbitControls for pan/zoom/rotate with damping
|
||||
- Axes helper for orientation
|
||||
- Responsive canvas with devicePixelRatio support
|
||||
- FPS counter in status bar
|
||||
|
||||
- **WebSocket connection (`/ws/dashboard`):**
|
||||
- Auto-reconnect with 3s backoff
|
||||
- JSON message handling for state, node events, link events
|
||||
- Binary CSI frame parsing (24-byte header + I/Q payload)
|
||||
- Connection status indicator (green/red dot)
|
||||
|
||||
- **Node/Link panels:**
|
||||
- Live list of connected nodes with MAC, firmware, chip
|
||||
- Active links with node:peer MAC pairs
|
||||
- Click-to-select for amplitude chart display
|
||||
|
||||
- **Amplitude chart (Canvas 2D):**
|
||||
- 64-bar visualization of subcarrier amplitudes
|
||||
- Real-time update from selected link's CSI frames
|
||||
- Color gradient based on amplitude intensity
|
||||
- Channel and RSSI display overlay
|
||||
|
||||
- **Dashboard package (Go):**
|
||||
- `internal/dashboard/hub.go` — Hub for client management and broadcasting
|
||||
- `internal/dashboard/server.go` — WebSocket handler with ping/pong keepalive
|
||||
- `internal/dashboard/hub_test.go` — 5 tests for hub operations
|
||||
- IngestionState interface for querying node/link state
|
||||
- CSIBroadcaster interface implementation
|
||||
|
||||
- **Main.go updates:**
|
||||
- Static file serving with SPA fallback
|
||||
- Dashboard WebSocket endpoint at `/ws/dashboard`
|
||||
- Auto-discovery of dashboard directory
|
||||
- Wiring between ingestion and dashboard packages
|
||||
|
||||
**Dependencies used (frontend):**
|
||||
- Three.js r128 (CDN)
|
||||
- OrbitControls (CDN)
|
||||
|
||||
**Tests:** 5 new tests for dashboard hub. All 27 tests pass.
|
||||
|
||||
**Files created:**
|
||||
```
|
||||
dashboard/
|
||||
├── index.html
|
||||
└── js/
|
||||
└── app.js
|
||||
|
||||
mothership/internal/dashboard/
|
||||
├── hub.go
|
||||
├── hub_test.go
|
||||
└── server.go
|
||||
```
|
||||
|
||||
**Remaining for Phase 1:**
|
||||
- Docker packaging
|
||||
|
||||
#### Iteration 2 — 2026-03-26
|
||||
|
||||
**Completed:** ESP32-S3 firmware skeleton
|
||||
|
|
|
|||
240
dashboard/index.html
Normal file
240
dashboard/index.html
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Spaxel 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: #1a1a2e;
|
||||
color: #eee;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#scene-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Status bar at top */
|
||||
#status-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
gap: 24px;
|
||||
z-index: 100;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #666;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 8px #4caf50;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #f44336;
|
||||
}
|
||||
|
||||
/* Amplitude chart overlay */
|
||||
#chart-panel {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 400px;
|
||||
height: 200px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#chart-title {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#chart-title .link-id {
|
||||
color: #4fc3f7;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#amplitude-chart {
|
||||
width: 100%;
|
||||
height: calc(100% - 24px);
|
||||
}
|
||||
|
||||
/* Node list panel */
|
||||
#node-panel {
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 20px;
|
||||
width: 280px;
|
||||
max-height: calc(100vh - 80px);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#node-panel h3 {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.node-item {
|
||||
padding: 8px;
|
||||
margin-bottom: 4px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.node-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.node-item.selected {
|
||||
background: rgba(79, 195, 247, 0.2);
|
||||
border: 1px solid rgba(79, 195, 247, 0.5);
|
||||
}
|
||||
|
||||
.node-mac {
|
||||
font-family: monospace;
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.node-status {
|
||||
float: right;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.node-status.online {
|
||||
background: rgba(76, 175, 80, 0.3);
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
.node-status.offline {
|
||||
background: rgba(244, 67, 54, 0.3);
|
||||
color: #e57373;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Link list */
|
||||
.link-section {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.link-item {
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 4px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.link-item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.link-item.selected {
|
||||
background: rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Status bar -->
|
||||
<div id="status-bar">
|
||||
<div class="status-item">
|
||||
<div id="ws-status" class="status-dot disconnected"></div>
|
||||
<span id="ws-status-text">Disconnected</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>Nodes: <strong id="node-count">0</strong></span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>Links: <strong id="link-count">0</strong></span>
|
||||
</div>
|
||||
<div class="status-item" style="margin-left:auto;">
|
||||
<span>FPS: <strong id="fps-counter">0</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3D Scene container -->
|
||||
<div id="scene-container"></div>
|
||||
|
||||
<!-- Node panel -->
|
||||
<div id="node-panel">
|
||||
<h3>Nodes</h3>
|
||||
<div id="node-list">
|
||||
<div class="empty-state">No nodes connected</div>
|
||||
</div>
|
||||
<div class="link-section">
|
||||
<h3>Links</h3>
|
||||
<div id="link-list">
|
||||
<div class="empty-state">No links active</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amplitude chart -->
|
||||
<div id="chart-panel">
|
||||
<div id="chart-title">Amplitude — <span class="link-id">no link selected</span></div>
|
||||
<canvas id="amplitude-chart"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Three.js from CDN -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<!-- OrbitControls from CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||||
|
||||
<!-- Main application -->
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
530
dashboard/js/app.js
Normal file
530
dashboard/js/app.js
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
/**
|
||||
* Spaxel Dashboard - Main Application
|
||||
*
|
||||
* Phase 1 skeleton: 3D scene with ground grid, OrbitControls,
|
||||
* WebSocket connection, and amplitude bar chart visualization.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ============================================
|
||||
// Configuration
|
||||
// ============================================
|
||||
const CONFIG = {
|
||||
wsReconnectDelay: 3000,
|
||||
gridWidth: 10, // meters
|
||||
gridDepth: 10, // meters
|
||||
gridDivisions: 20,
|
||||
cameraFov: 60,
|
||||
cameraNear: 0.1,
|
||||
cameraFar: 1000,
|
||||
cameraInitial: { x: 8, y: 8, z: 8 },
|
||||
chartBars: 64, // number of subcarriers
|
||||
chartUpdateMs: 100 // update chart at 10 Hz max
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// State
|
||||
// ============================================
|
||||
const state = {
|
||||
ws: null,
|
||||
wsConnected: false,
|
||||
nodes: new Map(), // MAC -> { mac, firmware, chip, lastSeen }
|
||||
links: new Map(), // linkID -> { nodeMAC, peerMAC, lastFrame, lastCSI }
|
||||
selectedLinkID: null,
|
||||
lastChartUpdate: 0,
|
||||
frameCount: 0,
|
||||
lastFpsTime: performance.now()
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Three.js Scene Setup
|
||||
// ============================================
|
||||
let scene, camera, renderer, controls, gridHelper, axesHelper;
|
||||
|
||||
function initScene() {
|
||||
const container = document.getElementById('scene-container');
|
||||
|
||||
// Scene
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x1a1a2e);
|
||||
|
||||
// Camera
|
||||
camera = new THREE.PerspectiveCamera(
|
||||
CONFIG.cameraFov,
|
||||
window.innerWidth / window.innerHeight,
|
||||
CONFIG.cameraNear,
|
||||
CONFIG.cameraFar
|
||||
);
|
||||
camera.position.set(
|
||||
CONFIG.cameraInitial.x,
|
||||
CONFIG.cameraInitial.y,
|
||||
CONFIG.cameraInitial.z
|
||||
);
|
||||
|
||||
// Renderer
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// OrbitControls
|
||||
controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.screenSpacePanning = true;
|
||||
controls.minDistance = 2;
|
||||
controls.maxDistance = 50;
|
||||
controls.maxPolarAngle = Math.PI / 2 + 0.3; // Allow slight below-ground view
|
||||
|
||||
// Grid helper (XZ plane, Y-up)
|
||||
gridHelper = new THREE.GridHelper(
|
||||
CONFIG.gridWidth,
|
||||
CONFIG.gridDivisions,
|
||||
0x444466, // center line color
|
||||
0x333344 // grid line color
|
||||
);
|
||||
scene.add(gridHelper);
|
||||
|
||||
// Axes helper for orientation
|
||||
axesHelper = new THREE.AxesHelper(2);
|
||||
axesHelper.position.set(-CONFIG.gridWidth / 2, 0.01, -CONFIG.gridDepth / 2);
|
||||
scene.add(axesHelper);
|
||||
|
||||
// Ambient light
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
|
||||
scene.add(ambientLight);
|
||||
|
||||
// Directional light
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.4);
|
||||
directionalLight.position.set(5, 10, 5);
|
||||
scene.add(directionalLight);
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
|
||||
console.log('[Spaxel] Scene initialized');
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
updateFPS();
|
||||
}
|
||||
|
||||
function updateFPS() {
|
||||
state.frameCount++;
|
||||
const now = performance.now();
|
||||
const elapsed = now - state.lastFpsTime;
|
||||
if (elapsed >= 1000) {
|
||||
const fps = Math.round(state.frameCount * 1000 / elapsed);
|
||||
document.getElementById('fps-counter').textContent = fps;
|
||||
state.frameCount = 0;
|
||||
state.lastFpsTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WebSocket Connection
|
||||
// ============================================
|
||||
function connectWebSocket() {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsURL = `${wsProtocol}//${window.location.host}/ws/dashboard`;
|
||||
|
||||
console.log('[Spaxel] Connecting to', wsURL);
|
||||
|
||||
state.ws = new WebSocket(wsURL);
|
||||
state.ws.binaryType = 'arraybuffer';
|
||||
|
||||
state.ws.onopen = function() {
|
||||
console.log('[Spaxel] WebSocket connected');
|
||||
state.wsConnected = true;
|
||||
updateConnectionStatus(true);
|
||||
};
|
||||
|
||||
state.ws.onclose = function(event) {
|
||||
console.log('[Spaxel] WebSocket closed:', event.code, event.reason);
|
||||
state.wsConnected = false;
|
||||
updateConnectionStatus(false);
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
state.ws.onerror = function(error) {
|
||||
console.error('[Spaxel] WebSocket error:', error);
|
||||
};
|
||||
|
||||
state.ws.onmessage = function(event) {
|
||||
handleMessage(event.data);
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
console.log('[Spaxel] Reconnecting in', CONFIG.wsReconnectDelay, 'ms');
|
||||
setTimeout(connectWebSocket, CONFIG.wsReconnectDelay);
|
||||
}
|
||||
|
||||
function updateConnectionStatus(connected) {
|
||||
const dot = document.getElementById('ws-status');
|
||||
const text = document.getElementById('ws-status-text');
|
||||
|
||||
if (connected) {
|
||||
dot.classList.remove('disconnected');
|
||||
dot.classList.add('connected');
|
||||
text.textContent = 'Connected';
|
||||
} else {
|
||||
dot.classList.remove('connected');
|
||||
dot.classList.add('disconnected');
|
||||
text.textContent = 'Disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Message Handling
|
||||
// ============================================
|
||||
function handleMessage(data) {
|
||||
if (typeof data === 'string') {
|
||||
// JSON message
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
handleJSONMessage(msg);
|
||||
} catch (e) {
|
||||
console.error('[Spaxel] Failed to parse JSON:', e);
|
||||
}
|
||||
} else if (data instanceof ArrayBuffer) {
|
||||
// Binary CSI frame
|
||||
handleBinaryFrame(data);
|
||||
}
|
||||
}
|
||||
|
||||
function handleJSONMessage(msg) {
|
||||
switch (msg.type) {
|
||||
case 'state':
|
||||
// Initial state dump
|
||||
if (msg.nodes) {
|
||||
msg.nodes.forEach(node => {
|
||||
state.nodes.set(node.mac, {
|
||||
mac: node.mac,
|
||||
firmware: node.firmware_version,
|
||||
chip: node.chip,
|
||||
lastSeen: Date.now()
|
||||
});
|
||||
});
|
||||
}
|
||||
if (msg.links) {
|
||||
msg.links.forEach(link => {
|
||||
state.links.set(link.id, {
|
||||
nodeMAC: link.node_mac,
|
||||
peerMAC: link.peer_mac,
|
||||
lastFrame: Date.now(),
|
||||
lastCSI: null
|
||||
});
|
||||
});
|
||||
}
|
||||
updateNodeList();
|
||||
updateLinkList();
|
||||
break;
|
||||
|
||||
case 'node_connected':
|
||||
state.nodes.set(msg.mac, {
|
||||
mac: msg.mac,
|
||||
firmware: msg.firmware_version,
|
||||
chip: msg.chip,
|
||||
lastSeen: Date.now()
|
||||
});
|
||||
updateNodeList();
|
||||
break;
|
||||
|
||||
case 'node_disconnected':
|
||||
state.nodes.delete(msg.mac);
|
||||
updateNodeList();
|
||||
break;
|
||||
|
||||
case 'link_active':
|
||||
if (!state.links.has(msg.id)) {
|
||||
state.links.set(msg.id, {
|
||||
nodeMAC: msg.node_mac,
|
||||
peerMAC: msg.peer_mac,
|
||||
lastFrame: Date.now(),
|
||||
lastCSI: null
|
||||
});
|
||||
updateLinkList();
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Ignore unknown types (forward-compatible)
|
||||
}
|
||||
}
|
||||
|
||||
function handleBinaryFrame(buffer) {
|
||||
const frame = parseCSIFrame(buffer);
|
||||
if (!frame) return;
|
||||
|
||||
const linkID = frame.linkID;
|
||||
|
||||
// Update link state
|
||||
let link = state.links.get(linkID);
|
||||
if (!link) {
|
||||
link = {
|
||||
nodeMAC: frame.nodeMAC,
|
||||
peerMAC: frame.peerMAC,
|
||||
lastFrame: Date.now(),
|
||||
lastCSI: null
|
||||
};
|
||||
state.links.set(linkID, link);
|
||||
updateLinkList();
|
||||
} else {
|
||||
link.lastFrame = Date.now();
|
||||
}
|
||||
|
||||
// Store CSI for chart rendering
|
||||
link.lastCSI = frame;
|
||||
|
||||
// Update chart if this is the selected link
|
||||
if (state.selectedLinkID === linkID) {
|
||||
const now = performance.now();
|
||||
if (now - state.lastChartUpdate >= CONFIG.chartUpdateMs) {
|
||||
drawAmplitudeChart(frame);
|
||||
state.lastChartUpdate = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CSI Frame Parsing (matches Go binary format)
|
||||
// ============================================
|
||||
function parseCSIFrame(buffer) {
|
||||
const view = new DataView(buffer);
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
if (bytes.length < 24) {
|
||||
return null; // Header too short
|
||||
}
|
||||
|
||||
const nodeMAC = formatMAC(bytes, 0);
|
||||
const peerMAC = formatMAC(bytes, 6);
|
||||
const timestampUS = view.getBigUint64(12, true); // little-endian
|
||||
const rssi = view.getInt8(20);
|
||||
const noiseFloor = view.getInt8(21);
|
||||
const channel = bytes[22];
|
||||
const nSub = bytes[23];
|
||||
|
||||
if (channel === 0 || channel > 14) {
|
||||
return null; // Invalid channel
|
||||
}
|
||||
|
||||
const expectedLen = 24 + nSub * 2;
|
||||
if (bytes.length !== expectedLen) {
|
||||
return null; // Payload length mismatch
|
||||
}
|
||||
|
||||
// Extract I/Q pairs and compute amplitude
|
||||
const subcarriers = [];
|
||||
for (let i = 0; i < nSub; i++) {
|
||||
const offset = 24 + i * 2;
|
||||
const iVal = bytes[offset];
|
||||
const qVal = bytes[offset + 1];
|
||||
// Convert from unsigned to signed (JavaScript quirk)
|
||||
const I = iVal > 127 ? iVal - 256 : iVal;
|
||||
const Q = qVal > 127 ? qVal - 256 : qVal;
|
||||
const amplitude = Math.sqrt(I * I + Q * Q);
|
||||
subcarriers.push({ I, Q, amplitude });
|
||||
}
|
||||
|
||||
return {
|
||||
nodeMAC,
|
||||
peerMAC,
|
||||
linkID: `${nodeMAC}:${peerMAC}`,
|
||||
timestampUS: Number(timestampUS),
|
||||
rssi,
|
||||
noiseFloor,
|
||||
channel,
|
||||
nSub,
|
||||
subcarriers
|
||||
};
|
||||
}
|
||||
|
||||
function formatMAC(bytes, offset) {
|
||||
const parts = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
parts.push(bytes[offset + i].toString(16).padStart(2, '0').toUpperCase());
|
||||
}
|
||||
return parts.join(':');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UI Updates
|
||||
// ============================================
|
||||
function updateNodeList() {
|
||||
const container = document.getElementById('node-list');
|
||||
document.getElementById('node-count').textContent = state.nodes.size;
|
||||
|
||||
if (state.nodes.size === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No nodes connected</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
state.nodes.forEach((node, mac) => {
|
||||
const isOnline = Date.now() - node.lastSeen < 30000;
|
||||
html += `
|
||||
<div class="node-item" data-mac="${mac}">
|
||||
<span class="node-mac">${mac}</span>
|
||||
<span class="node-status ${isOnline ? 'online' : 'offline'}">
|
||||
${isOnline ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function updateLinkList() {
|
||||
const container = document.getElementById('link-list');
|
||||
document.getElementById('link-count').textContent = state.links.size;
|
||||
|
||||
if (state.links.size === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No links active</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
state.links.forEach((link, id) => {
|
||||
const selected = state.selectedLinkID === id ? 'selected' : '';
|
||||
const shortID = id.split(':').map(p => p.split(':').slice(-1)[0]).join('→');
|
||||
html += `
|
||||
<div class="link-item ${selected}" data-link-id="${id}">
|
||||
<span>${shortID}</span>
|
||||
<span style="color:#666">${link.nSub || 64} sub</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
|
||||
// Add click handlers
|
||||
container.querySelectorAll('.link-item').forEach(el => {
|
||||
el.addEventListener('click', () => selectLink(el.dataset.linkId));
|
||||
});
|
||||
}
|
||||
|
||||
function selectLink(linkID) {
|
||||
state.selectedLinkID = linkID;
|
||||
updateLinkList();
|
||||
|
||||
// Update chart title
|
||||
document.querySelector('#chart-title .link-id').textContent = linkID || 'no link selected';
|
||||
|
||||
// Draw current data immediately if available
|
||||
const link = state.links.get(linkID);
|
||||
if (link && link.lastCSI) {
|
||||
drawAmplitudeChart(link.lastCSI);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Amplitude Chart (Canvas 2D)
|
||||
// ============================================
|
||||
let chartCanvas, chartCtx;
|
||||
|
||||
function initChart() {
|
||||
chartCanvas = document.getElementById('amplitude-chart');
|
||||
chartCtx = chartCanvas.getContext('2d');
|
||||
|
||||
// Set canvas resolution
|
||||
const rect = chartCanvas.getBoundingClientRect();
|
||||
chartCanvas.width = rect.width * window.devicePixelRatio;
|
||||
chartCanvas.height = rect.height * window.devicePixelRatio;
|
||||
chartCtx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||
|
||||
// Draw empty state
|
||||
drawEmptyChart();
|
||||
}
|
||||
|
||||
function drawEmptyChart() {
|
||||
const width = chartCanvas.width / window.devicePixelRatio;
|
||||
const height = chartCanvas.height / window.devicePixelRatio;
|
||||
|
||||
chartCtx.fillStyle = '#1a1a2e';
|
||||
chartCtx.fillRect(0, 0, width, height);
|
||||
|
||||
chartCtx.fillStyle = '#444';
|
||||
chartCtx.font = '12px sans-serif';
|
||||
chartCtx.textAlign = 'center';
|
||||
chartCtx.fillText('No data', width / 2, height / 2);
|
||||
}
|
||||
|
||||
function drawAmplitudeChart(frame) {
|
||||
const width = chartCanvas.width / window.devicePixelRatio;
|
||||
const height = chartCanvas.height / window.devicePixelRatio;
|
||||
|
||||
// Clear
|
||||
chartCtx.fillStyle = '#1a1a2e';
|
||||
chartCtx.fillRect(0, 0, width, height);
|
||||
|
||||
const subcarriers = frame.subcarriers;
|
||||
const nSub = subcarriers.length;
|
||||
if (nSub === 0) return;
|
||||
|
||||
const barWidth = width / nSub;
|
||||
const padding = 1;
|
||||
|
||||
// Find max amplitude for scaling
|
||||
let maxAmp = 0;
|
||||
subcarriers.forEach(s => {
|
||||
if (s.amplitude > maxAmp) maxAmp = s.amplitude;
|
||||
});
|
||||
if (maxAmp === 0) maxAmp = 1;
|
||||
|
||||
// Draw bars
|
||||
for (let i = 0; i < nSub; i++) {
|
||||
const amp = subcarriers[i].amplitude;
|
||||
const barHeight = (amp / maxAmp) * (height - 10);
|
||||
const x = i * barWidth + padding;
|
||||
const y = height - barHeight;
|
||||
|
||||
// Gradient color based on amplitude
|
||||
const intensity = amp / maxAmp;
|
||||
const r = Math.floor(79 + intensity * (255 - 79));
|
||||
const g = Math.floor(195 - intensity * 100);
|
||||
const b = Math.floor(247 - intensity * 150);
|
||||
chartCtx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
||||
|
||||
chartCtx.fillRect(x, y, barWidth - padding * 2, barHeight);
|
||||
}
|
||||
|
||||
// Draw channel/rssi info
|
||||
chartCtx.fillStyle = '#666';
|
||||
chartCtx.font = '10px monospace';
|
||||
chartCtx.textAlign = 'left';
|
||||
chartCtx.fillText(`CH${frame.channel} RSSI:${frame.rssi}dBm`, 4, height - 4);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Initialization
|
||||
// ============================================
|
||||
function init() {
|
||||
console.log('[Spaxel] Dashboard initializing...');
|
||||
|
||||
initScene();
|
||||
initChart();
|
||||
connectWebSocket();
|
||||
animate();
|
||||
|
||||
console.log('[Spaxel] Dashboard ready');
|
||||
}
|
||||
|
||||
// Start when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
|
@ -9,12 +9,14 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/middleware"
|
||||
"github.com/hashicorp/mdns"
|
||||
"github.com/spaxel/mothership/internal/dashboard"
|
||||
"github.com/spaxel/mothership/internal/ingestion"
|
||||
)
|
||||
|
||||
|
|
@ -25,6 +27,7 @@ var version = "dev"
|
|||
type Config struct {
|
||||
BindAddr string
|
||||
DataDir string
|
||||
StaticDir string
|
||||
MDNSName string
|
||||
MDNSEnabled bool
|
||||
LogLevel string
|
||||
|
|
@ -33,7 +36,7 @@ type Config struct {
|
|||
func main() {
|
||||
cfg := parseConfig()
|
||||
log.Printf("[INFO] Spaxel mothership v%s starting", version)
|
||||
log.Printf("[DEBUG] Config: bind=%s data=%s mdns=%s", cfg.BindAddr, cfg.DataDir, cfg.MDNSName)
|
||||
log.Printf("[DEBUG] Config: bind=%s data=%s static=%s mdns=%s", cfg.BindAddr, cfg.DataDir, cfg.StaticDir, cfg.MDNSName)
|
||||
|
||||
// Create context with cancellation for graceful shutdown
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
|
|
@ -59,6 +62,61 @@ func main() {
|
|||
ingestSrv := ingestion.NewServer()
|
||||
r.HandleFunc("/ws/node", ingestSrv.HandleNodeWS)
|
||||
|
||||
// Create dashboard hub and server
|
||||
dashboardHub := dashboard.NewHub()
|
||||
dashboardSrv := dashboard.NewServer(dashboardHub)
|
||||
|
||||
// Connect ingestion to dashboard (for state queries)
|
||||
dashboardHub.SetIngestionState(ingestSrv)
|
||||
|
||||
// Start dashboard hub in background
|
||||
go dashboardHub.Run()
|
||||
|
||||
// Dashboard WebSocket endpoint
|
||||
r.HandleFunc("/ws/dashboard", dashboardSrv.HandleDashboardWS)
|
||||
|
||||
// Serve dashboard static files
|
||||
staticDir := cfg.StaticDir
|
||||
if staticDir == "" {
|
||||
// Default: look for dashboard directory relative to binary or cwd
|
||||
staticDir = findDashboardDir()
|
||||
}
|
||||
|
||||
if staticDir != "" {
|
||||
// Check if directory exists
|
||||
if _, err := os.Stat(staticDir); err == nil {
|
||||
log.Printf("[INFO] Serving dashboard from %s", staticDir)
|
||||
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Try to serve static file, fall back to index.html for SPA routing
|
||||
path := filepath.Join(staticDir, r.URL.Path)
|
||||
|
||||
// If path is a directory, serve index.html
|
||||
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
||||
path = filepath.Join(path, "index.html")
|
||||
}
|
||||
|
||||
// If file exists, serve it
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
http.ServeFile(w, r, path)
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to index.html for SPA routing (except for /js/* paths)
|
||||
if filepath.Ext(r.URL.Path) == "" {
|
||||
http.ServeFile(w, r, filepath.Join(staticDir, "index.html"))
|
||||
return
|
||||
}
|
||||
|
||||
// File not found
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
} else {
|
||||
log.Printf("[WARN] Dashboard directory not found: %s", staticDir)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[WARN] No dashboard directory found, static files not served")
|
||||
}
|
||||
|
||||
// Start mDNS advertisement
|
||||
var mdnsServer *mdns.Server
|
||||
if cfg.MDNSEnabled {
|
||||
|
|
@ -69,7 +127,7 @@ func main() {
|
|||
"",
|
||||
8080,
|
||||
nil,
|
||||
[]string{"version=1", "ws=/ws/node", "api=/api"},
|
||||
[]string{"version=1", "ws=/ws/node", "dashboard=/ws/dashboard"},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] Failed to create mDNS service: %v", err)
|
||||
|
|
@ -88,7 +146,7 @@ func main() {
|
|||
Addr: cfg.BindAddr,
|
||||
Handler: r,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second, // Longer for WebSocket
|
||||
}
|
||||
|
||||
// Run server in goroutine
|
||||
|
|
@ -127,12 +185,14 @@ func main() {
|
|||
func parseConfig() Config {
|
||||
bindAddr := getEnv("SPAXEL_BIND_ADDR", "0.0.0.0:8080")
|
||||
dataDir := getEnv("SPAXEL_DATA_DIR", "/data")
|
||||
staticDir := getEnv("SPAXEL_STATIC_DIR", "")
|
||||
mdnsName := getEnv("SPAXEL_MDNS_NAME", "spaxel")
|
||||
mdnsEnabled := getEnvBool("SPAXEL_MDNS_ENABLED", true)
|
||||
logLevel := getEnv("SPAXEL_LOG_LEVEL", "info")
|
||||
|
||||
flag.StringVar(&bindAddr, "bind", bindAddr, "Listen address")
|
||||
flag.StringVar(&dataDir, "data", dataDir, "Data directory")
|
||||
flag.StringVar(&staticDir, "static", staticDir, "Static files directory (dashboard)")
|
||||
flag.StringVar(&mdnsName, "mdns-name", mdnsName, "mDNS service name")
|
||||
flag.BoolVar(&mdnsEnabled, "mdns", mdnsEnabled, "Enable mDNS advertisement")
|
||||
flag.StringVar(&logLevel, "log-level", logLevel, "Log level (debug, info, warn, error)")
|
||||
|
|
@ -141,6 +201,7 @@ func parseConfig() Config {
|
|||
return Config{
|
||||
BindAddr: bindAddr,
|
||||
DataDir: dataDir,
|
||||
StaticDir: staticDir,
|
||||
MDNSName: mdnsName,
|
||||
MDNSEnabled: mdnsEnabled,
|
||||
LogLevel: logLevel,
|
||||
|
|
@ -160,3 +221,26 @@ func getEnvBool(key string, defaultVal bool) bool {
|
|||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
// findDashboardDir attempts to locate the dashboard directory
|
||||
func findDashboardDir() string {
|
||||
// Try common locations
|
||||
candidates := []string{
|
||||
"dashboard", // When running from repo root
|
||||
"../dashboard", // When running from mothership/
|
||||
"../../dashboard", // When running from mothership/cmd/mothership/
|
||||
"/app/dashboard", // Docker container location
|
||||
}
|
||||
|
||||
for _, dir := range candidates {
|
||||
absPath, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(absPath, "index.html")); err == nil {
|
||||
return absPath
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
235
mothership/internal/dashboard/hub.go
Normal file
235
mothership/internal/dashboard/hub.go
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
// Package dashboard handles WebSocket connections from dashboard clients
|
||||
// and broadcasts CSI data from the ingestion server.
|
||||
package dashboard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spaxel/mothership/internal/ingestion"
|
||||
)
|
||||
|
||||
// Hub manages all dashboard client connections and broadcasts
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
clients map[*Client]struct{}
|
||||
broadcast chan []byte
|
||||
register chan *Client
|
||||
unregister chan *Client
|
||||
|
||||
// Reference to ingestion server for state queries
|
||||
ingestionState IngestionState
|
||||
}
|
||||
|
||||
// IngestionState is an interface to query node/link state from ingestion
|
||||
type IngestionState interface {
|
||||
GetConnectedNodesInfo() []ingestion.NodeInfo
|
||||
GetAllLinksInfo() []ingestion.LinkInfo
|
||||
}
|
||||
|
||||
// Client represents a dashboard WebSocket client
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
// NewHub creates a new dashboard hub
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[*Client]struct{}),
|
||||
broadcast: make(chan []byte, 256),
|
||||
register: make(chan *Client),
|
||||
unregister: make(chan *Client),
|
||||
}
|
||||
}
|
||||
|
||||
// SetIngestionState sets the ingestion state provider
|
||||
func (h *Hub) SetIngestionState(state IngestionState) {
|
||||
h.mu.Lock()
|
||||
h.ingestionState = state
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// Run starts the hub's main loop
|
||||
func (h *Hub) Run() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case client := <-h.register:
|
||||
h.mu.Lock()
|
||||
h.clients[client] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
log.Printf("[INFO] Dashboard client connected (total: %d)", len(h.clients))
|
||||
|
||||
// Send initial state
|
||||
h.sendInitialState(client)
|
||||
|
||||
case client := <-h.unregister:
|
||||
h.mu.Lock()
|
||||
if _, ok := h.clients[client]; ok {
|
||||
delete(h.clients, client)
|
||||
close(client.send)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
log.Printf("[INFO] Dashboard client disconnected (total: %d)", len(h.clients))
|
||||
|
||||
case message := <-h.broadcast:
|
||||
h.mu.RLock()
|
||||
for client := range h.clients {
|
||||
select {
|
||||
case client.send <- message:
|
||||
default:
|
||||
// Client buffer full, skip
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
|
||||
case <-ticker.C:
|
||||
// Periodic state broadcast
|
||||
h.broadcastState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register registers a new dashboard client
|
||||
func (h *Hub) Register(client *Client) {
|
||||
h.register <- client
|
||||
}
|
||||
|
||||
// Unregister unregisters a dashboard client
|
||||
func (h *Hub) Unregister(client *Client) {
|
||||
h.unregister <- client
|
||||
}
|
||||
|
||||
// Broadcast sends a message to all connected dashboard clients
|
||||
func (h *Hub) Broadcast(message []byte) {
|
||||
select {
|
||||
case h.broadcast <- message:
|
||||
default:
|
||||
// Channel full, drop message
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastCSI broadcasts a CSI frame to all dashboard clients
|
||||
func (h *Hub) BroadcastCSI(nodeMAC, peerMAC string, data []byte) {
|
||||
// For now, just forward the raw binary frame
|
||||
// Dashboard clients will parse it
|
||||
h.Broadcast(data)
|
||||
}
|
||||
|
||||
// BroadcastNodeConnected notifies dashboards of a new node
|
||||
func (h *Hub) BroadcastNodeConnected(mac, firmware, chip string) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "node_connected",
|
||||
"mac": mac,
|
||||
"firmware_version": firmware,
|
||||
"chip": chip,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
}
|
||||
|
||||
// BroadcastNodeDisconnected notifies dashboards of a node leaving
|
||||
func (h *Hub) BroadcastNodeDisconnected(mac string) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "node_disconnected",
|
||||
"mac": mac,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
}
|
||||
|
||||
// BroadcastLinkActive notifies dashboards of an active link
|
||||
func (h *Hub) BroadcastLinkActive(linkID, nodeMAC, peerMAC string) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "link_active",
|
||||
"id": linkID,
|
||||
"node_mac": nodeMAC,
|
||||
"peer_mac": peerMAC,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
}
|
||||
|
||||
// BroadcastLinkInactive notifies dashboards of an inactive link
|
||||
func (h *Hub) BroadcastLinkInactive(linkID string) {
|
||||
msg := map[string]interface{}{
|
||||
"type": "link_inactive",
|
||||
"id": linkID,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
}
|
||||
|
||||
func (h *Hub) sendInitialState(client *Client) {
|
||||
h.mu.RLock()
|
||||
state := h.ingestionState
|
||||
h.mu.RUnlock()
|
||||
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Build state message
|
||||
msg := map[string]interface{}{
|
||||
"type": "state",
|
||||
}
|
||||
|
||||
nodes := state.GetConnectedNodesInfo()
|
||||
if nodes != nil {
|
||||
msg["nodes"] = nodes
|
||||
}
|
||||
|
||||
links := state.GetAllLinksInfo()
|
||||
if links != nil {
|
||||
msg["links"] = links
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(msg)
|
||||
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
// Buffer full, skip
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) broadcastState() {
|
||||
h.mu.RLock()
|
||||
state := h.ingestionState
|
||||
clientCount := len(h.clients)
|
||||
h.mu.RUnlock()
|
||||
|
||||
if state == nil || clientCount == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Build state message
|
||||
msg := map[string]interface{}{
|
||||
"type": "state",
|
||||
}
|
||||
|
||||
nodes := state.GetConnectedNodesInfo()
|
||||
if nodes != nil {
|
||||
msg["nodes"] = nodes
|
||||
}
|
||||
|
||||
links := state.GetAllLinksInfo()
|
||||
if links != nil {
|
||||
msg["links"] = links
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(msg)
|
||||
h.Broadcast(data)
|
||||
}
|
||||
|
||||
// ClientCount returns the number of connected dashboard clients
|
||||
func (h *Hub) ClientCount() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
188
mothership/internal/dashboard/hub_test.go
Normal file
188
mothership/internal/dashboard/hub_test.go
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
package dashboard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spaxel/mothership/internal/ingestion"
|
||||
)
|
||||
|
||||
func TestHub_RegisterUnregister(t *testing.T) {
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
defer func() {
|
||||
// Hub has no shutdown method in Phase 1, just let it run
|
||||
}()
|
||||
|
||||
client := &Client{
|
||||
hub: hub,
|
||||
send: make(chan []byte, 10),
|
||||
}
|
||||
|
||||
// Register
|
||||
hub.Register(client)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
if hub.ClientCount() != 1 {
|
||||
t.Errorf("expected 1 client, got %d", hub.ClientCount())
|
||||
}
|
||||
|
||||
// Unregister
|
||||
hub.Unregister(client)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
if hub.ClientCount() != 0 {
|
||||
t.Errorf("expected 0 clients, got %d", hub.ClientCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Broadcast(t *testing.T) {
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
|
||||
client := &Client{
|
||||
hub: hub,
|
||||
send: make(chan []byte, 10),
|
||||
}
|
||||
|
||||
hub.Register(client)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Broadcast a message
|
||||
testMsg := []byte(`{"type":"test"}`)
|
||||
hub.Broadcast(testMsg)
|
||||
|
||||
// Client should receive it
|
||||
select {
|
||||
case msg := <-client.send:
|
||||
if string(msg) != string(testMsg) {
|
||||
t.Errorf("expected %s, got %s", testMsg, msg)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("expected to receive broadcast message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_BroadcastCSI(t *testing.T) {
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
|
||||
client := &Client{
|
||||
hub: hub,
|
||||
send: make(chan []byte, 10),
|
||||
}
|
||||
|
||||
hub.Register(client)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Broadcast CSI (raw binary data)
|
||||
testData := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
hub.BroadcastCSI("AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66", testData)
|
||||
|
||||
select {
|
||||
case msg := <-client.send:
|
||||
if string(msg) != string(testData) {
|
||||
t.Errorf("expected %v, got %v", testData, msg)
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("expected to receive CSI broadcast")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_NodeEvents(t *testing.T) {
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
|
||||
client := &Client{
|
||||
hub: hub,
|
||||
send: make(chan []byte, 10),
|
||||
}
|
||||
|
||||
hub.Register(client)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Test node connected event
|
||||
hub.BroadcastNodeConnected("AA:BB:CC:DD:EE:FF", "1.0.0", "ESP32-S3")
|
||||
|
||||
msg := <-client.send
|
||||
expected := `{"chip":"ESP32-S3","firmware_version":"1.0.0","mac":"AA:BB:CC:DD:EE:FF","type":"node_connected"}`
|
||||
if string(msg) != expected {
|
||||
t.Errorf("expected %s, got %s", expected, msg)
|
||||
}
|
||||
|
||||
// Test node disconnected event
|
||||
hub.BroadcastNodeDisconnected("AA:BB:CC:DD:EE:FF")
|
||||
|
||||
msg = <-client.send
|
||||
expected = `{"mac":"AA:BB:CC:DD:EE:FF","type":"node_disconnected"}`
|
||||
if string(msg) != expected {
|
||||
t.Errorf("expected %s, got %s", expected, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_LinkEvents(t *testing.T) {
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
|
||||
client := &Client{
|
||||
hub: hub,
|
||||
send: make(chan []byte, 10),
|
||||
}
|
||||
|
||||
hub.Register(client)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Test link active event
|
||||
hub.BroadcastLinkActive("AA:BB:CC:DD:EE:FF:11:22:33:44:55:66", "AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66")
|
||||
|
||||
msg := <-client.send
|
||||
expected := `{"id":"AA:BB:CC:DD:EE:FF:11:22:33:44:55:66","node_mac":"AA:BB:CC:DD:EE:FF","peer_mac":"11:22:33:44:55:66","type":"link_active"}`
|
||||
if string(msg) != expected {
|
||||
t.Errorf("expected %s, got %s", expected, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// MockIngestionState implements IngestionState for testing
|
||||
type MockIngestionState struct {
|
||||
nodes []ingestion.NodeInfo
|
||||
links []ingestion.LinkInfo
|
||||
}
|
||||
|
||||
func (m *MockIngestionState) GetConnectedNodesInfo() []ingestion.NodeInfo {
|
||||
return m.nodes
|
||||
}
|
||||
|
||||
func (m *MockIngestionState) GetAllLinksInfo() []ingestion.LinkInfo {
|
||||
return m.links
|
||||
}
|
||||
|
||||
func TestHub_InitialState(t *testing.T) {
|
||||
hub := NewHub()
|
||||
go hub.Run()
|
||||
|
||||
// Set mock ingestion state
|
||||
mock := &MockIngestionState{
|
||||
nodes: []ingestion.NodeInfo{
|
||||
{MAC: "AA:BB:CC:DD:EE:FF", FirmwareVersion: "1.0.0", Chip: "ESP32-S3"},
|
||||
},
|
||||
links: []ingestion.LinkInfo{
|
||||
{ID: "AA:BB:CC:DD:EE:FF:11:22:33:44:55:66", NodeMAC: "AA:BB:CC:DD:EE:FF", PeerMAC: "11:22:33:44:55:66"},
|
||||
},
|
||||
}
|
||||
hub.SetIngestionState(mock)
|
||||
|
||||
client := &Client{
|
||||
hub: hub,
|
||||
send: make(chan []byte, 10),
|
||||
}
|
||||
|
||||
hub.Register(client)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Should receive initial state
|
||||
msg := <-client.send
|
||||
// Just check it's a valid JSON with type "state"
|
||||
if len(msg) == 0 || msg[0] != '{' {
|
||||
t.Errorf("expected JSON state message, got %v", msg)
|
||||
}
|
||||
}
|
||||
146
mothership/internal/dashboard/server.go
Normal file
146
mothership/internal/dashboard/server.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package dashboard
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
// Dashboard ping/pong timing
|
||||
dashboardPingInterval = 30 * time.Second
|
||||
dashboardReadDeadline = 60 * time.Second
|
||||
|
||||
// Send buffer size per client
|
||||
sendBufferSize = 1024
|
||||
)
|
||||
|
||||
// Server handles WebSocket connections from dashboard clients
|
||||
type Server struct {
|
||||
hub *Hub
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
// NewServer creates a new dashboard server
|
||||
func NewServer(hub *Hub) *Server {
|
||||
return &Server{
|
||||
hub: hub,
|
||||
upgrader: websocket.Upgrader{
|
||||
// Allow all origins for development
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
ReadBufferSize: 256,
|
||||
WriteBufferSize: 4096,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDashboardWS handles WebSocket connections at /ws/dashboard
|
||||
func (s *Server) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
||||
// Upgrade HTTP connection to WebSocket
|
||||
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Dashboard WebSocket upgrade failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Create client
|
||||
client := &Client{
|
||||
hub: s.hub,
|
||||
send: make(chan []byte, sendBufferSize),
|
||||
}
|
||||
|
||||
// Register with hub
|
||||
s.hub.Register(client)
|
||||
|
||||
// Start write goroutine
|
||||
go s.writePump(conn, client)
|
||||
|
||||
// Run read pump in this goroutine
|
||||
s.readPump(conn, client)
|
||||
}
|
||||
|
||||
// readPump handles incoming messages from the dashboard client
|
||||
func (s *Server) readPump(conn *websocket.Conn, client *Client) {
|
||||
defer func() {
|
||||
conn.Close()
|
||||
s.hub.Unregister(client)
|
||||
}()
|
||||
|
||||
// Set read deadline
|
||||
conn.SetReadDeadline(time.Now().Add(dashboardReadDeadline))
|
||||
|
||||
// Set pong handler to reset deadline
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(dashboardReadDeadline))
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
log.Printf("[WARN] Dashboard read error: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
// Dashboard clients don't send meaningful messages in Phase 1
|
||||
// Just keep the connection alive
|
||||
}
|
||||
}
|
||||
|
||||
// writePump handles outgoing messages to the dashboard client
|
||||
func (s *Server) writePump(conn *websocket.Conn, client *Client) {
|
||||
ticker := time.NewTicker(dashboardPingInterval)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-client.send:
|
||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if !ok {
|
||||
// Hub closed the channel
|
||||
conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine message type (binary vs text)
|
||||
if len(message) > 0 && message[0] == '{' {
|
||||
// Looks like JSON, send as text
|
||||
err := conn.WriteMessage(websocket.TextMessage, message)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Dashboard write error: %v", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Binary CSI frame
|
||||
err := conn.WriteMessage(websocket.BinaryMessage, message)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] Dashboard write error: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hub returns the server's hub for external use
|
||||
func (s *Server) Hub() *Hub {
|
||||
return s.hub
|
||||
}
|
||||
|
||||
// Client represents a dashboard WebSocket client
|
||||
// (redeclared here for documentation; defined in hub.go)
|
||||
type dashboardClient = Client
|
||||
|
|
@ -11,6 +11,14 @@ import (
|
|||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// CSIBroadcaster is a callback for broadcasting CSI frames to dashboard
|
||||
type CSIBroadcaster interface {
|
||||
BroadcastCSI(nodeMAC, peerMAC string, data []byte)
|
||||
BroadcastNodeConnected(mac, firmware, chip string)
|
||||
BroadcastNodeDisconnected(mac string)
|
||||
BroadcastLinkActive(linkID, nodeMAC, peerMAC string)
|
||||
}
|
||||
|
||||
// Server manages WebSocket connections from ESP32 nodes
|
||||
type Server struct {
|
||||
mu sync.RWMutex
|
||||
|
|
@ -25,6 +33,9 @@ type Server struct {
|
|||
|
||||
// Shutdown state
|
||||
shutdown bool
|
||||
|
||||
// Dashboard broadcaster (optional)
|
||||
dashboardBroadcaster CSIBroadcaster
|
||||
}
|
||||
|
||||
// NodeConnection tracks state for a connected node
|
||||
|
|
@ -75,6 +86,13 @@ func NewServer() *Server {
|
|||
}
|
||||
}
|
||||
|
||||
// SetDashboardBroadcaster sets the callback for broadcasting CSI frames
|
||||
func (s *Server) SetDashboardBroadcaster(broadcaster CSIBroadcaster) {
|
||||
s.mu.Lock()
|
||||
s.dashboardBroadcaster = broadcaster
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// HandleNodeWS handles WebSocket connections at /ws/node
|
||||
func (s *Server) HandleNodeWS(w http.ResponseWriter, r *http.Request) {
|
||||
// Upgrade HTTP connection to WebSocket
|
||||
|
|
@ -127,11 +145,17 @@ func (s *Server) HandleNodeWS(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
s.connections[hello.MAC] = nc
|
||||
s.malformedCounts[hello.MAC] = &malformedCounter{}
|
||||
broadcaster := s.dashboardBroadcaster
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Printf("[INFO] Node connected: MAC=%s firmware=%s chip=%s",
|
||||
hello.MAC, hello.FirmwareVersion, hello.Chip)
|
||||
|
||||
// Broadcast node connected to dashboard
|
||||
if broadcaster != nil {
|
||||
broadcaster.BroadcastNodeConnected(hello.MAC, hello.FirmwareVersion, hello.Chip)
|
||||
}
|
||||
|
||||
// Send initial role and config
|
||||
s.sendRole(nc, "rx", "")
|
||||
s.sendConfig(nc, 20, 0, 0) // 20 Hz default
|
||||
|
|
@ -150,8 +174,15 @@ func (s *Server) handleMessages(nc *NodeConnection) {
|
|||
s.mu.Lock()
|
||||
delete(s.connections, nc.MAC)
|
||||
delete(s.malformedCounts, nc.MAC)
|
||||
broadcaster := s.dashboardBroadcaster
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Printf("[INFO] Node disconnected: MAC=%s", nc.MAC)
|
||||
|
||||
// Broadcast node disconnected to dashboard
|
||||
if broadcaster != nil {
|
||||
broadcaster.BroadcastNodeDisconnected(nc.MAC)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
|
|
@ -189,14 +220,27 @@ func (s *Server) handleBinaryFrame(nc *NodeConnection, data []byte) {
|
|||
linkID := frame.LinkID()
|
||||
s.mu.Lock()
|
||||
ring, exists := s.links[linkID]
|
||||
isNewLink := !exists
|
||||
if !exists {
|
||||
ring = NewRingBuffer()
|
||||
s.links[linkID] = ring
|
||||
}
|
||||
broadcaster := s.dashboardBroadcaster
|
||||
s.mu.Unlock()
|
||||
|
||||
// Push frame to ring buffer
|
||||
ring.Push(frame, time.Now())
|
||||
|
||||
// Broadcast to dashboard clients
|
||||
if broadcaster != nil {
|
||||
// Forward raw binary frame to dashboard
|
||||
broadcaster.BroadcastCSI(frame.MACString(), frame.PeerMACString(), data)
|
||||
|
||||
// Notify of new link
|
||||
if isNewLink {
|
||||
broadcaster.BroadcastLinkActive(linkID, frame.MACString(), frame.PeerMACString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleJSONMessage processes a JSON control message
|
||||
|
|
@ -349,6 +393,56 @@ func (s *Server) GetConnectedNodes() []string {
|
|||
return macs
|
||||
}
|
||||
|
||||
// NodeInfo represents a connected node's state for dashboard
|
||||
type NodeInfo struct {
|
||||
MAC string `json:"mac"`
|
||||
FirmwareVersion string `json:"firmware_version,omitempty"`
|
||||
Chip string `json:"chip,omitempty"`
|
||||
}
|
||||
|
||||
// GetConnectedNodesInfo returns detailed info about connected nodes
|
||||
func (s *Server) GetConnectedNodesInfo() []NodeInfo {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
nodes := make([]NodeInfo, 0, len(s.connections))
|
||||
for mac, nc := range s.connections {
|
||||
info := NodeInfo{MAC: mac}
|
||||
if nc.Hello != nil {
|
||||
info.FirmwareVersion = nc.Hello.FirmwareVersion
|
||||
info.Chip = nc.Hello.Chip
|
||||
}
|
||||
nodes = append(nodes, info)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// LinkInfo represents a link with its endpoints
|
||||
type LinkInfo struct {
|
||||
ID string `json:"id"`
|
||||
NodeMAC string `json:"node_mac"`
|
||||
PeerMAC string `json:"peer_mac"`
|
||||
}
|
||||
|
||||
// GetAllLinksInfo returns detailed info about all active links
|
||||
func (s *Server) GetAllLinksInfo() []LinkInfo {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
links := make([]LinkInfo, 0, len(s.links))
|
||||
for linkID := range s.links {
|
||||
// Parse linkID format "nodeMAC:peerMAC" (17 chars + 1 colon + 17 chars)
|
||||
if len(linkID) >= 35 {
|
||||
links = append(links, LinkInfo{
|
||||
ID: linkID,
|
||||
NodeMAC: linkID[:17],
|
||||
PeerMAC: linkID[18:],
|
||||
})
|
||||
}
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
// GetLinkBuffer returns the ring buffer for a specific link
|
||||
func (s *Server) GetLinkBuffer(nodeMAC, peerMAC string) *RingBuffer {
|
||||
linkID := nodeMAC + ":" + peerMAC
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue