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.
145 lines (144 loc) • 5.05 kB
JavaScript
export class WebSocketTransport {
ws = null;
serverUrl;
messageHandler;
connectionToken; // v0.4.0: Store token for force cleanup
sessionId; // v0.4.5: Session management
constructor(serverUrl = 'ws://localhost:8080') {
this.serverUrl = serverUrl;
}
async connect(options) {
const url = new URL(this.serverUrl);
if (options?.device)
url.searchParams.set('device', options.device);
if (options?.service)
url.searchParams.set('service', options.service);
if (options?.write)
url.searchParams.set('write', options.write);
if (options?.notify)
url.searchParams.set('notify', options.notify);
// Session management
if (options?.session) {
url.searchParams.set('session', options.session);
this.sessionId = options.session;
}
this.ws = new WebSocket(url.toString());
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Connection timeout'));
}, 10000);
this.ws.onopen = () => {
// WebSocket opened, wait for connected message
};
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'connected') {
clearTimeout(timeout);
// v0.4.0: Store token for force cleanup
if (msg.token) {
this.connectionToken = msg.token;
}
resolve();
}
else if (msg.type === 'error') {
clearTimeout(timeout);
reject(new Error(msg.error || 'Connection failed'));
}
}
catch {
// Ignore invalid messages
}
};
this.ws.onerror = () => {
clearTimeout(timeout);
reject(new Error('WebSocket error'));
};
this.ws.onclose = () => {
this.ws = null;
if (this.messageHandler) {
this.messageHandler({ type: 'disconnected' });
}
};
});
}
send(data) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('Not connected');
}
this.ws.send(JSON.stringify({
type: 'data',
data: Array.from(data)
}));
}
onMessage(callback) {
this.messageHandler = callback;
if (this.ws) {
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (this.messageHandler) {
this.messageHandler(msg);
}
}
catch {
// Ignore invalid messages
}
};
}
}
async forceCleanup() {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('Not connected');
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Force cleanup timeout'));
}, 5000);
// Store reference to WebSocket
const ws = this.ws;
const originalHandler = ws.onmessage;
// Listen for cleanup confirmation
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'cleanup_complete' || msg.type === 'force_cleanup_complete') {
clearTimeout(timeout);
ws.onmessage = originalHandler;
resolve();
}
else if (originalHandler) {
originalHandler.call(ws, event);
}
}
catch {
if (originalHandler)
originalHandler.call(ws, event);
}
};
// Send force cleanup request
// v0.4.0: Include token for authentication
const request = { type: 'force_cleanup' };
if (this.connectionToken) {
request.token = this.connectionToken;
}
ws.send(JSON.stringify(request));
});
}
disconnect() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
isConnected() {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
// Session management methods
getSessionId() {
return this.sessionId;
}
async reconnectToSession(sessionId) {
return this.connect({ session: sessionId });
}
}