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.
178 lines (177 loc) • 7.81 kB
JavaScript
import { WebSocketServer } from 'ws';
import { randomUUID } from 'crypto';
import { SessionManager } from './session-manager.js';
/**
* Normalize UUID based on platform requirements
* Noble expects different formats on different platforms
* @param uuid - UUID in short or full format
* @returns Platform-appropriate UUID format
*/
function normalizeUuid(uuid) {
if (!uuid)
return '';
// Platform-specific UUID handling
const platform = process.platform;
// Remove dashes and lowercase for processing
const cleanUuid = uuid.toLowerCase().replace(/-/g, '');
// Check if it's a short UUID (4 hex chars)
const isShortUuid = cleanUuid.length === 4 && /^[0-9a-fA-F]{4}$/.test(cleanUuid);
// Check if it's a standard Bluetooth UUID that can be shortened
const isStandardLongUuid = cleanUuid.length === 32 &&
cleanUuid.startsWith('0000') &&
cleanUuid.endsWith('00001000800000805f9b34fb');
if (platform === 'linux') {
// Linux Noble (BlueZ) prefers short UUIDs
if (isShortUuid) {
return cleanUuid;
}
// If it's a standard long UUID, extract the short form
if (isStandardLongUuid) {
// Extract characters 5-8 (the short UUID part)
return cleanUuid.substring(4, 8);
}
// Non-standard long UUID - return as-is
return cleanUuid;
}
else if (platform === 'darwin' || platform === 'win32') {
// macOS and Windows typically need full UUIDs
if (isShortUuid) {
// Expand short UUID to full Bluetooth UUID
// Standard base: 00000000-0000-1000-8000-00805F9B34FB
return `0000${cleanUuid}00001000800000805f9b34fb`;
}
// For full UUIDs, already cleaned
return cleanUuid;
}
else {
// Unknown platform - just return cleaned UUID
return cleanUuid;
}
}
/**
* BridgeServer - HTTP server and WebSocket routing
*
* Simplified server that only handles:
* - WebSocket server setup
* - URL parameter parsing
* - Session routing
*/
export class BridgeServer {
wss = null;
sessionManager;
constructor(logLevel, sharedState) {
this.sessionManager = new SessionManager(sharedState);
console.log(`[Bridge] Session-based architecture initialized`);
}
async start(port = 8080) {
this.wss = new WebSocketServer({ port });
console.log(`🚀 Session-based bridge listening on port ${port}`);
this.wss.on('connection', async (ws, req) => {
// Parse BLE config from URL
const url = new URL(req.url || '', 'http://localhost');
// Extract session ID or generate new one
const sessionParam = url.searchParams.get('session');
const sessionId = sessionParam || randomUUID();
const forceConnect = url.searchParams.get('force') === 'true';
// Enhanced debugging for session ID handling
if (sessionParam) {
console.log(`[Bridge] New WebSocket connection with provided session: ${sessionId}`);
}
else {
console.log(`[Bridge] New WebSocket connection, generated session: ${sessionId}`);
}
console.log(`[Bridge] Request URL: ${req.url}`);
console.log(`[Bridge] All URL params:`, Object.fromEntries(url.searchParams));
// Parse BLE config with UUID normalization
const rawService = url.searchParams.get('service') || '';
const rawWrite = url.searchParams.get('write') || '';
const rawNotify = url.searchParams.get('notify') || '';
const config = {
devicePrefix: url.searchParams.get('device') || '',
serviceUuid: normalizeUuid(rawService),
writeUuid: normalizeUuid(rawWrite),
notifyUuid: normalizeUuid(rawNotify)
};
// Log UUID normalization if any were normalized
if (rawService !== config.serviceUuid || rawWrite !== config.writeUuid || rawNotify !== config.notifyUuid) {
console.log(`[Bridge] UUID normalization on ${process.platform}:`);
if (rawService !== config.serviceUuid) {
console.log(` service: ${rawService} → ${config.serviceUuid}`);
}
}
// Validate required parameters
if (!config.devicePrefix || !config.serviceUuid || !config.writeUuid || !config.notifyUuid) {
ws.send(JSON.stringify({ type: 'error', error: 'Missing required parameters: device, service, write, notify' }));
ws.close();
return;
}
try {
// Get or create session
let session = this.sessionManager.getOrCreateSession(sessionId, config);
if (!session) {
// Session rejected - device is busy
// Find the blocking session
const blockingSession = this.sessionManager.getAllSessions()
.find(s => s.getStatus().hasTransport);
// If force parameter is set, clean up the blocking session
if (forceConnect && blockingSession) {
console.log(`[Bridge] Force takeover - cleaning up blocking session ${blockingSession.sessionId}`);
await blockingSession.forceCleanup('force takeover');
// Try again to create session
const newSession = this.sessionManager.getOrCreateSession(sessionId, config);
if (newSession) {
session = newSession;
}
}
if (!session) {
ws.send(JSON.stringify({
type: 'error',
error: 'Device is busy with another session',
blocking_session_id: blockingSession?.sessionId,
device: config.devicePrefix
}));
ws.close();
return;
}
}
// Connect BLE if not already connected
const deviceName = await session.connect();
// Send connection success
ws.send(JSON.stringify({ type: 'connected', device: deviceName }));
// Attach WebSocket to session
this.sessionManager.attachWebSocket(session, ws);
}
catch (error) {
console.error(`[Bridge] Connection error:`, error);
ws.send(JSON.stringify({ type: 'error', error: error.message || 'Connection failed' }));
ws.close();
}
});
}
async stop() {
console.log('[Bridge] Stopping...');
await this.sessionManager.stop();
if (this.wss) {
this.wss.close();
this.wss = null;
}
}
// Minimal observability interface for backward compatibility
getConnectionState() {
const sessions = this.sessionManager.getAllSessions();
const activeSession = sessions.find(s => s.getStatus().connected);
return {
connected: !!activeSession,
deviceName: activeSession?.getStatus().deviceName || null,
recovering: false,
state: activeSession ? 'active' : 'ready'
};
}
getState() {
const sessions = this.sessionManager.getAllSessions();
return sessions.some(s => s.getStatus().connected) ? 'active' : 'ready';
}
async scanDevices() {
return []; // Ultra simple - no scanning
}
}