Add prominent hardware requirements table at top of README documenting the three paths to real CSI data (ESP32, research NIC, commodity WiFi). Include remaining Three.js visualization components for dashboard. https://claude.ai/code/session_01Ki7pvEZtJDvqJkmyn6B714
259 lines
7.2 KiB
JavaScript
259 lines
7.2 KiB
JavaScript
// WebSocket Client for Three.js Visualization - WiFi DensePose
|
|
// Connects to ws://localhost:8000/ws/pose and manages real-time data flow
|
|
|
|
export class WebSocketClient {
|
|
constructor(options = {}) {
|
|
this.url = options.url || 'ws://localhost:8000/ws/pose';
|
|
this.ws = null;
|
|
this.state = 'disconnected'; // disconnected, connecting, connected, error
|
|
this.isRealData = false;
|
|
|
|
// Reconnection settings
|
|
this.reconnectAttempts = 0;
|
|
this.maxReconnectAttempts = options.maxReconnectAttempts || 15;
|
|
this.reconnectDelays = [500, 1000, 2000, 4000, 8000, 15000, 30000];
|
|
this.reconnectTimer = null;
|
|
this.autoReconnect = options.autoReconnect !== false;
|
|
|
|
// Heartbeat
|
|
this.heartbeatInterval = null;
|
|
this.heartbeatFrequency = options.heartbeatFrequency || 25000;
|
|
this.lastPong = 0;
|
|
|
|
// Metrics
|
|
this.metrics = {
|
|
messageCount: 0,
|
|
errorCount: 0,
|
|
connectTime: null,
|
|
lastMessageTime: null,
|
|
latency: 0,
|
|
bytesReceived: 0
|
|
};
|
|
|
|
// Callbacks
|
|
this._onMessage = options.onMessage || (() => {});
|
|
this._onStateChange = options.onStateChange || (() => {});
|
|
this._onError = options.onError || (() => {});
|
|
}
|
|
|
|
// Attempt to connect
|
|
connect() {
|
|
if (this.state === 'connecting' || this.state === 'connected') {
|
|
console.warn('[WS-VIZ] Already connected or connecting');
|
|
return;
|
|
}
|
|
|
|
this._setState('connecting');
|
|
console.log(`[WS-VIZ] Connecting to ${this.url}`);
|
|
|
|
try {
|
|
this.ws = new WebSocket(this.url);
|
|
this.ws.binaryType = 'arraybuffer';
|
|
|
|
this.ws.onopen = () => this._handleOpen();
|
|
this.ws.onmessage = (event) => this._handleMessage(event);
|
|
this.ws.onerror = (event) => this._handleError(event);
|
|
this.ws.onclose = (event) => this._handleClose(event);
|
|
|
|
// Connection timeout
|
|
this._connectTimeout = setTimeout(() => {
|
|
if (this.state === 'connecting') {
|
|
console.warn('[WS-VIZ] Connection timeout');
|
|
this.ws.close();
|
|
this._setState('error');
|
|
this._scheduleReconnect();
|
|
}
|
|
}, 8000);
|
|
|
|
} catch (err) {
|
|
console.error('[WS-VIZ] Failed to create WebSocket:', err);
|
|
this._setState('error');
|
|
this._onError(err);
|
|
this._scheduleReconnect();
|
|
}
|
|
}
|
|
|
|
disconnect() {
|
|
this.autoReconnect = false;
|
|
this._clearTimers();
|
|
|
|
if (this.ws) {
|
|
this.ws.onclose = null; // Prevent reconnect on intentional close
|
|
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
this.ws.close(1000, 'Client disconnect');
|
|
}
|
|
this.ws = null;
|
|
}
|
|
|
|
this._setState('disconnected');
|
|
this.isRealData = false;
|
|
console.log('[WS-VIZ] Disconnected');
|
|
}
|
|
|
|
// Send a message
|
|
send(data) {
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
console.warn('[WS-VIZ] Cannot send - not connected');
|
|
return false;
|
|
}
|
|
|
|
const msg = typeof data === 'string' ? data : JSON.stringify(data);
|
|
this.ws.send(msg);
|
|
return true;
|
|
}
|
|
|
|
_handleOpen() {
|
|
clearTimeout(this._connectTimeout);
|
|
this.reconnectAttempts = 0;
|
|
this.metrics.connectTime = Date.now();
|
|
this._setState('connected');
|
|
console.log('[WS-VIZ] Connected successfully');
|
|
|
|
// Start heartbeat
|
|
this._startHeartbeat();
|
|
|
|
// Request initial state
|
|
this.send({ type: 'get_status', timestamp: Date.now() });
|
|
}
|
|
|
|
_handleMessage(event) {
|
|
this.metrics.messageCount++;
|
|
this.metrics.lastMessageTime = Date.now();
|
|
|
|
const rawSize = typeof event.data === 'string' ? event.data.length : event.data.byteLength;
|
|
this.metrics.bytesReceived += rawSize;
|
|
|
|
try {
|
|
const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
|
|
// Handle pong
|
|
if (data.type === 'pong') {
|
|
this.lastPong = Date.now();
|
|
if (data.timestamp) {
|
|
this.metrics.latency = Date.now() - data.timestamp;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle connection_established
|
|
if (data.type === 'connection_established') {
|
|
console.log('[WS-VIZ] Server confirmed connection:', data.payload);
|
|
return;
|
|
}
|
|
|
|
// Detect real vs mock data from metadata
|
|
if (data.data && data.data.metadata) {
|
|
this.isRealData = data.data.metadata.mock_data === false && data.data.metadata.source !== 'mock';
|
|
} else if (data.metadata) {
|
|
this.isRealData = data.metadata.mock_data === false;
|
|
}
|
|
|
|
// Calculate latency from message timestamp
|
|
if (data.timestamp) {
|
|
const msgTime = new Date(data.timestamp).getTime();
|
|
if (!isNaN(msgTime)) {
|
|
this.metrics.latency = Date.now() - msgTime;
|
|
}
|
|
}
|
|
|
|
// Forward to callback
|
|
this._onMessage(data);
|
|
|
|
} catch (err) {
|
|
this.metrics.errorCount++;
|
|
console.error('[WS-VIZ] Failed to parse message:', err);
|
|
}
|
|
}
|
|
|
|
_handleError(event) {
|
|
this.metrics.errorCount++;
|
|
console.error('[WS-VIZ] WebSocket error:', event);
|
|
this._onError(event);
|
|
}
|
|
|
|
_handleClose(event) {
|
|
clearTimeout(this._connectTimeout);
|
|
this._stopHeartbeat();
|
|
this.ws = null;
|
|
|
|
const wasConnected = this.state === 'connected';
|
|
console.log(`[WS-VIZ] Connection closed: code=${event.code}, reason=${event.reason}, clean=${event.wasClean}`);
|
|
|
|
if (event.wasClean || !this.autoReconnect) {
|
|
this._setState('disconnected');
|
|
} else {
|
|
this._setState('error');
|
|
this._scheduleReconnect();
|
|
}
|
|
}
|
|
|
|
_setState(newState) {
|
|
if (this.state === newState) return;
|
|
const oldState = this.state;
|
|
this.state = newState;
|
|
this._onStateChange(newState, oldState);
|
|
}
|
|
|
|
_startHeartbeat() {
|
|
this._stopHeartbeat();
|
|
this.heartbeatInterval = setInterval(() => {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
this.send({ type: 'ping', timestamp: Date.now() });
|
|
}
|
|
}, this.heartbeatFrequency);
|
|
}
|
|
|
|
_stopHeartbeat() {
|
|
if (this.heartbeatInterval) {
|
|
clearInterval(this.heartbeatInterval);
|
|
this.heartbeatInterval = null;
|
|
}
|
|
}
|
|
|
|
_scheduleReconnect() {
|
|
if (!this.autoReconnect) return;
|
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
console.error('[WS-VIZ] Max reconnect attempts reached');
|
|
this._setState('error');
|
|
return;
|
|
}
|
|
|
|
const delayIdx = Math.min(this.reconnectAttempts, this.reconnectDelays.length - 1);
|
|
const delay = this.reconnectDelays[delayIdx];
|
|
this.reconnectAttempts++;
|
|
|
|
console.log(`[WS-VIZ] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.connect();
|
|
}, delay);
|
|
}
|
|
|
|
_clearTimers() {
|
|
clearTimeout(this._connectTimeout);
|
|
clearTimeout(this.reconnectTimer);
|
|
this._stopHeartbeat();
|
|
}
|
|
|
|
getMetrics() {
|
|
return {
|
|
...this.metrics,
|
|
state: this.state,
|
|
isRealData: this.isRealData,
|
|
reconnectAttempts: this.reconnectAttempts,
|
|
uptime: this.metrics.connectTime ? (Date.now() - this.metrics.connectTime) / 1000 : 0
|
|
};
|
|
}
|
|
|
|
isConnected() {
|
|
return this.state === 'connected';
|
|
}
|
|
|
|
dispose() {
|
|
this.disconnect();
|
|
this._onMessage = () => {};
|
|
this._onStateChange = () => {};
|
|
this._onError = () => {};
|
|
}
|
|
}
|