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
JavaScript
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
};
}
}