UNPKG

ble-mcp-test

Version:

Complete BLE testing stack: WebSocket bridge server, MCP observability layer, and Web Bluetooth API mock. Test real BLE devices in Playwright/E2E tests without browser support.

164 lines (163 loc) 6.4 kB
import { EventEmitter } from 'events'; import { translateBluetoothError } from './bluetooth-errors.js'; /** * WebSocketHandler - Manages individual WebSocket connections and message routing * * Responsibilities: * - Handle WebSocket message parsing and validation * - Route messages to BLE session * - Forward BLE data to WebSocket * - Manage WebSocket lifecycle events * * Events: * - 'close': () - WebSocket connection closed * - 'error': (error: any) - WebSocket error occurred */ export class WebSocketHandler extends EventEmitter { ws; session; sharedState; lastActivity = Date.now(); constructor(ws, session, sharedState) { super(); this.ws = ws; this.session = session; this.sharedState = sharedState; this.setupWebSocketHandlers(); this.setupSessionHandlers(); this.session.addWebSocket(ws); } setupWebSocketHandlers() { // Handle incoming WebSocket messages this.ws.on('message', async (message) => { this.lastActivity = Date.now(); try { const msg = JSON.parse(message.toString()); // Handle data messages if (msg.type === 'data' && msg.data) { const data = new Uint8Array(msg.data); const hex = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(' '); console.log(`[WSHandler] TX: ${hex}`); this.sharedState?.logPacket('TX', data); await this.session.write(data); } // Handle force cleanup command else if (msg.type === 'force_cleanup') { await this.handleForceCleanup(msg); } // Handle admin cleanup command else if (msg.type === 'admin_cleanup') { await this.handleAdminCleanup(msg); } } catch (error) { const errorMessage = translateBluetoothError(error); console.error('[WSHandler] Message error:', errorMessage); this.sendError(errorMessage); } }); // Handle WebSocket close this.ws.on('close', (code, reason) => { console.log(`[WSHandler] WebSocket closed - code: ${code}, reason: ${reason || 'none'}, session: ${this.session.sessionId}`); this.session.removeWebSocket(this.ws); this.emit('close'); }); // Handle WebSocket errors this.ws.on('error', (error) => { console.log('[WSHandler] WebSocket error:', error.message); this.session.removeWebSocket(this.ws); this.emit('error', error); }); } setupSessionHandlers() { // Forward BLE data to WebSocket const dataHandler = (data) => { if (this.ws.readyState === this.ws.OPEN) { const hex = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(' '); console.log(`[WSHandler] RX: ${hex}`); this.ws.send(JSON.stringify({ type: 'data', data: Array.from(data) })); } }; // Handle session events this.session.on('data', dataHandler); // Clean up listeners when WebSocket closes this.once('close', () => { this.session.removeListener('data', dataHandler); }); } sendError(error) { if (this.ws.readyState === this.ws.OPEN) { this.ws.send(JSON.stringify({ type: 'error', error })); } } async handleForceCleanup(msg) { console.log('[WSHandler] Force cleanup requested', msg.all_sessions ? '(all sessions)' : '(current session)'); try { if (msg.all_sessions) { // Get session manager through session's config const sessionManager = this.session.sessionManager; if (sessionManager) { const deviceName = this.session.getStatus().deviceName; if (deviceName) { await sessionManager.forceCleanupDevice(deviceName, 'force cleanup all sessions'); } } } else { // Trigger session cleanup FIRST await this.session.forceCleanup('force cleanup command'); } // Only acknowledge AFTER cleanup is complete if (this.ws.readyState === this.ws.OPEN) { this.ws.send(JSON.stringify({ type: 'force_cleanup_complete', message: msg.all_sessions ? 'All sessions cleaned up' : 'Cleanup complete' })); } } catch (error) { console.error('[WSHandler] Force cleanup error:', error); } } async handleAdminCleanup(msg) { console.log('[WSHandler] Admin cleanup requested'); // Check auth token const requiredAuth = process.env.BLE_ADMIN_AUTH_TOKEN; if (requiredAuth && msg.auth !== requiredAuth) { console.log('[WSHandler] Admin cleanup rejected - invalid auth'); this.sendError('Unauthorized'); return; } try { // Get session manager through session's config const sessionManager = this.session.sessionManager; if (sessionManager && msg.action === 'cleanup_all') { await sessionManager.forceCleanupAll('admin cleanup'); if (this.ws.readyState === this.ws.OPEN) { this.ws.send(JSON.stringify({ type: 'admin_cleanup_complete', message: 'All sessions cleaned up', action: msg.action })); } } else { this.sendError('Invalid admin action'); } } catch (error) { console.error('[WSHandler] Admin cleanup error:', error); this.sendError('Admin cleanup failed'); } } getStatus() { return { connected: this.ws.readyState === this.ws.OPEN, lastActivity: this.lastActivity, sessionId: this.session.sessionId }; } }