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.

513 lines (512 loc) 23.7 kB
import { EventEmitter } from 'events'; import noble from '@stoprocent/noble'; export class NobleTransport extends EventEmitter { peripheral = null; writeChar = null; notifyChar = null; connectInProgress = false; // Static flags to prevent connections during cleanup static cleanupInProgress = false; static cleanupStartTime = null; /** * Get current Noble resource state for monitoring */ static async getResourceState() { return { peripheralCount: Object.keys(noble._peripherals || {}).length, listenerCounts: { discover: noble.listenerCount('discover'), scanStop: noble.listenerCount('scanStop'), stateChange: noble.listenerCount('stateChange'), warning: noble.listenerCount('warning') }, scanningActive: noble._discovering || false, hciConnections: noble._bindings?.listenerCount?.('warning') || 0, cacheSize: Object.keys(noble._services || {}).length + Object.keys(noble._characteristics || {}).length }; } /** * Clean up Noble global resources (static helper for cleanup method) */ static async cleanupGlobalResources() { const state = await NobleTransport.getResourceState(); // Clean up scanStop listeners (critical leak source per docs/NOBLE-DISCOVERASYNC-LEAK.md) if (state.listenerCounts.scanStop > 90) { console.log('[Noble] Cleaning up scanStop listener leak (count > 90)'); noble.removeAllListeners('scanStop'); } // Clean up discover listeners if (state.listenerCounts.discover > 10) { console.log('[Noble] Cleaning up discover listener leak (count > 10)'); noble.removeAllListeners('discover'); } // Force stop scanning try { await noble.stopScanningAsync(); } catch { // Ignore stop scanning errors } // Clear peripheral cache if excessive if (state.peripheralCount > 50) { console.log('[Noble] Clearing excessive peripheral cache'); const peripherals = noble._peripherals || {}; Object.keys(peripherals).forEach(key => { try { peripherals[key]?.removeAllListeners?.(); } catch { // Ignore cleanup errors } }); noble._peripherals = {}; } } /** * Scan for device availability (based on check-device-available.js pattern) */ static async scanDeviceAvailability(devicePrefix, timeoutMs = 5000) { if (noble.state !== 'poweredOn') { try { await noble.waitForPoweredOnAsync(); } catch (e) { console.log(`[Noble] Device availability check failed - Bluetooth not powered on: ${e}`); return false; } } return new Promise((resolve) => { const timeout = setTimeout(() => { noble.removeAllListeners('discover'); noble.stopScanningAsync().catch(() => { }); resolve(false); }, timeoutMs); noble.on('discover', (peripheral) => { const id = peripheral.id; const name = peripheral.advertisement.localName || ''; if (id.startsWith(devicePrefix) || name.startsWith(devicePrefix)) { clearTimeout(timeout); noble.removeAllListeners('discover'); noble.stopScanningAsync().catch(() => { }); console.log(`[Noble] Device availability confirmed: ${name || 'Unknown'} [${id}]`); resolve(true); } }); noble.startScanningAsync([], true).catch(() => resolve(false)); }); } async resetNobleStack() { console.log('[Noble] WARNING: Skipping BLE stack reset to prevent crashes'); console.log('[Noble] rfkill operations can crash Noble if done during active operations'); console.log('[Noble] If BLE is truly stuck, manually run: sudo systemctl restart bluetooth'); // Force stop scanning first (safe operation) try { await noble.stopScanningAsync(); } catch { // Ignore stop scanning errors during reset } // DO NOT use rfkill here - it crashes Noble if there are active handles // Only wait for Noble state recovery // Wait for Noble to stabilize (all platforms) console.log('[Noble] Waiting for Noble state to stabilize...'); await new Promise((resolve) => { const timeout = setTimeout(() => { console.log('[Noble] State stabilization timeout - continuing anyway'); resolve(); }, 3000); const checkState = () => { if (noble.state === 'poweredOn') { clearTimeout(timeout); console.log('[Noble] Noble is powered on'); resolve(); } else { console.log(`[Noble] Current state: ${noble.state}, waiting...`); setTimeout(checkState, 500); } }; // Start checking immediately checkState(); }); console.log('[Noble] Noble state check complete'); } async connect(config) { // Block connection if cleanup is in progress if (NobleTransport.cleanupInProgress) { const cleanupTime = NobleTransport.cleanupStartTime ? Math.ceil((Date.now() - NobleTransport.cleanupStartTime) / 1000) : 0; const remainingTime = Math.max(1, 15 - cleanupTime); // Assume max 15s cleanup throw new Error(`BLE stack recovering, please try again in ${remainingTime} seconds`); } this.connectInProgress = true; try { // Wait for Noble to be ready with timeout if (noble.state !== 'poweredOn') { console.log(`[Noble] State: ${noble.state}, waiting for power on...`); await this.withInternalTimeout(noble.waitForPoweredOnAsync(), 15000, 'Bluetooth adapter timeout - check if Bluetooth is enabled'); } // Find device (handles complete scanning lifecycle) this.peripheral = await this.findDevice(config.devicePrefix); const deviceName = this.peripheral.advertisement.localName || this.peripheral.id; // Connect to peripheral with timeout console.log(`[Noble] Connecting to ${deviceName}...`); await this.withInternalTimeout(this.peripheral.connectAsync(), 10000, 'Device connection timeout'); // Discover services with timeout const services = await this.withInternalTimeout(this.peripheral.discoverServicesAsync(), 10000, 'Service discovery timeout'); const targetService = services.find((s) => s.uuid === config.serviceUuid || s.uuid === config.serviceUuid.toLowerCase().replace(/-/g, '')); if (!targetService) { throw new Error(`Service ${config.serviceUuid} not found`); } // Discover characteristics with timeout const characteristics = await this.withInternalTimeout(targetService.discoverCharacteristicsAsync(), 10000, 'Characteristic discovery timeout'); this.writeChar = characteristics.find((c) => c.uuid === config.writeUuid || c.uuid === config.writeUuid.toLowerCase().replace(/-/g, '')); this.notifyChar = characteristics.find((c) => c.uuid === config.notifyUuid || c.uuid === config.notifyUuid.toLowerCase().replace(/-/g, '')); if (!this.writeChar || !this.notifyChar) { throw new Error('Required characteristics not found'); } // Subscribe to notifications this.notifyChar.on('data', (data) => { this.emit('data', new Uint8Array(data)); }); // Subscribe to notifications with retry logic await this.subscribeWithRetry(this.notifyChar); // Handle unexpected disconnect this.peripheral.once('disconnect', () => { console.log(`[Noble] Device disconnected`); this.emit('disconnect'); }); console.log(`[Noble] Connected successfully to ${deviceName}`); this.connectInProgress = false; return deviceName; } catch (error) { this.connectInProgress = false; console.log(`[Noble] Connection failed: ${error}`); // For connection errors, we MUST do full cleanup // Incomplete connections leave the BLE stack in a bad state if (this.peripheral) { console.log(`[Noble] Performing full cleanup after connection error`); try { // Try graceful disconnect first if (this.peripheral.state === 'connected' || this.peripheral.state === 'connecting') { console.log(`[Noble] Peripheral state: ${this.peripheral.state} - attempting disconnect`); await Promise.race([ this.peripheral.disconnectAsync(), new Promise(resolve => setTimeout(resolve, 5000)) // 5s timeout ]); } // Remove all listeners this.peripheral.removeAllListeners?.(); } catch (cleanupError) { console.log(`[Noble] Cleanup after connection error failed: ${cleanupError}`); } } // Clear our references this.peripheral = null; this.writeChar = null; this.notifyChar = null; // Clear device search cleanup if it exists if (this.findDeviceCleanup) { this.findDeviceCleanup(); this.findDeviceCleanup = null; } // Always add recovery delay after connection errors console.log(`[Noble] Adding 3s recovery delay after connection error`); await new Promise(resolve => setTimeout(resolve, 3000)); throw error; } } findDeviceCleanup = null; async findDevice(devicePrefix) { // Ensure any previous scan is cleaned up if (this.findDeviceCleanup) { this.findDeviceCleanup(); this.findDeviceCleanup = null; } // Stop any existing scan (no-op if not scanning) await noble.stopScanningAsync(); return new Promise((resolve, reject) => { let timeout = null; let onDiscover = null; // Cleanup function that always runs const cleanupScan = () => { if (timeout) { clearTimeout(timeout); timeout = null; } if (onDiscover) { noble.removeListener('discover', onDiscover); onDiscover = null; } // Always try to stop scanning, ignore errors noble.stopScanningAsync().catch(() => { }); this.findDeviceCleanup = null; }; // Store cleanup function so it can be called externally if needed this.findDeviceCleanup = cleanupScan; // Start scanning console.log(`[Noble] Starting BLE scan for ${devicePrefix}...`); noble.startScanningAsync([], true).then(() => { // Scanning started successfully }).catch((error) => { cleanupScan(); reject(error); }); timeout = setTimeout(() => { cleanupScan(); reject(new Error(`Device ${devicePrefix} not found`)); }, 15000); onDiscover = async (device) => { const name = device.advertisement.localName || ''; const id = device.id; if ((name && name.startsWith(devicePrefix)) || id === devicePrefix) { cleanupScan(); console.log(`[Noble] Found device: ${name || id}`); resolve(device); } }; noble.on('discover', onDiscover); }); } async write(data) { if (!this.writeChar) { throw new Error('Not connected'); } await this.writeChar.writeAsync(Buffer.from(data), false); } /** * Internal timeout helper that just rejects - no cleanup * Cleanup is handled by the main try/catch in connect() */ async withInternalTimeout(promise, timeoutMs, errorMessage) { return Promise.race([ promise, new Promise((_, reject) => { setTimeout(() => reject(new Error(errorMessage)), timeoutMs); }) ]); } /** * Subscribe to notifications with retry logic * Common BLE issue - retry instead of going nuclear */ async subscribeWithRetry(notifyChar, maxRetries = 3) { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { console.log(`[Noble] Notification subscription attempt ${attempt}/${maxRetries}`); await this.withInternalTimeout(notifyChar.subscribeAsync(), 15000, // Increased from 5s to 15s `Notification subscription timeout (attempt ${attempt}/${maxRetries})`); console.log(`[Noble] Notification subscription successful`); return; // Success! } catch (error) { console.log(`[Noble] Subscription attempt ${attempt} failed: ${error}`); if (attempt === maxRetries) { throw new Error(`Notification subscription failed after ${maxRetries} attempts: ${error}`); } // Exponential backoff: 1s, 2s, 4s const delay = Math.pow(2, attempt - 1) * 1000; console.log(`[Noble] Retrying in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } } } /** * Unified cleanup method with configurable options * @param options - Cleanup configuration * @param options.force - Use aggressive cleanup (default: false) * @param options.resetStack - Reset BLE stack after cleanup (default: false for graceful, true for force) * @param options.verifyResources - Verify and clean Noble resources after cleanup (default: true) * @param options.deviceName - Device name for resource verification */ async cleanup(options = {}) { const { force = false, resetStack = force, verifyResources = true, deviceName = options.deviceName } = options; // SAFETY: Never run cleanup during active connection if (this.connectInProgress) { console.log(`[Noble] WARNING: Cleanup requested during active connection - skipping to prevent crash`); return; } console.log(`[Noble] Starting ${force ? 'aggressive' : 'graceful'} cleanup`); // Set cleanup flag to block new connections NobleTransport.cleanupInProgress = true; NobleTransport.cleanupStartTime = Date.now(); // Clean up any active device search if (this.findDeviceCleanup) { this.findDeviceCleanup(); this.findDeviceCleanup = null; } // Stop scanning try { await noble.stopScanningAsync(); } catch { // Ignore scan stop errors } // Handle peripheral cleanup if (this.peripheral) { // Remove listeners first this.peripheral.removeAllListeners?.(); if (this.notifyChar) { this.notifyChar.removeAllListeners?.(); } if (!force) { // Graceful cleanup try { // Try graceful unsubscribe if (this.notifyChar) { await Promise.race([ this.notifyChar.unsubscribeAsync(), new Promise(resolve => setTimeout(resolve, 1000)) ]); } // Try graceful disconnect with longer timeout console.log(`[Noble] Attempting graceful disconnect...`); await Promise.race([ this.peripheral.disconnectAsync(), new Promise(resolve => setTimeout(resolve, 10000)) // Increased from 2s to 10s ]); } catch (e) { console.log(`[Noble] Graceful disconnect failed: ${e}`); // Force disconnect as fallback try { console.log(`[Noble] Attempting force disconnect...`); this.peripheral._peripheral?.disconnect?.(); await new Promise(resolve => setTimeout(resolve, 3000)); } catch (forceError) { console.log(`[Noble] Force disconnect also failed: ${forceError}`); } } // Verify disconnect actually worked try { const state = this.peripheral.state; if (state === 'connected') { console.error(`[Noble] WARNING: Peripheral still shows connected after disconnect attempts`); // Last resort: OS-level disconnect if we have the address if (this.peripheral.address && process.platform === 'linux') { await this.osLevelDisconnect(this.peripheral.address); } } else { console.log(`[Noble] Disconnect verified - peripheral state: ${state}`); } } catch (e) { console.log(`[Noble] Could not verify disconnect state: ${e}`); } } else { // Aggressive cleanup - skip graceful attempts try { this.peripheral._peripheral?.disconnect?.(); } catch (e) { console.log(`[Noble] Force disconnect failed: ${e}`); } } } // Clear references this.peripheral = null; this.writeChar = null; this.notifyChar = null; // Remove all transport listeners this.removeAllListeners(); // Reset BLE stack if requested if (resetStack) { await this.resetNobleStack(); } // Verify and clean resources if requested if (verifyResources) { // Small delay to allow async cleanup to complete await new Promise(resolve => setTimeout(resolve, 500)); const state = await NobleTransport.getResourceState(); // Check for resource leaks and clean if needed if (state.listenerCounts.scanStop > 90 || state.listenerCounts.discover > 10 || state.peripheralCount > 100) { console.log('[Noble] Resource leak detected - cleaning global resources'); await NobleTransport.cleanupGlobalResources(); } } console.log(`[Noble] ${force ? 'Aggressive' : 'Graceful'} cleanup complete`); // Clear cleanup flag NobleTransport.cleanupInProgress = false; NobleTransport.cleanupStartTime = null; } /** * OS-level disconnect as last resort */ async osLevelDisconnect(address) { // Only supported on Linux currently if (process.platform !== 'linux') { console.log(`[Noble] OS-level disconnect not available on ${process.platform}`); return; } console.log(`[Noble] Attempting OS-level disconnect for ${address} (Linux)`); const { exec } = await import('child_process'); const { promisify } = await import('util'); const execAsync = promisify(exec); try { // Format address for hcitool (uppercase with colons) const formattedAddress = address.toUpperCase(); // Try hcitool disconnect (Linux only) await execAsync(`sudo hcitool ledc ${formattedAddress}`); console.log(`[Noble] OS-level disconnect successful`); // Give it a moment to take effect await new Promise(resolve => setTimeout(resolve, 1000)); } catch (e) { console.error(`[Noble] OS-level disconnect failed: ${e}`); // Check if it's an I/O error which indicates BLE stack corruption if (e.message?.includes('Input/output error')) { console.error(`[Noble] CRITICAL: BLE stack appears corrupted (I/O error)`); console.log(`[Noble] Attempting rfkill recovery to reset BLE hardware...`); try { // Try rfkill block/unblock to reset the BLE hardware (Linux only) await execAsync('sudo rfkill block bluetooth'); await new Promise(resolve => setTimeout(resolve, 1000)); await execAsync('sudo rfkill unblock bluetooth'); await new Promise(resolve => setTimeout(resolve, 2000)); console.log(`[Noble] rfkill recovery completed - BLE hardware reset`); // Wait for Noble to detect the power cycle console.log('[Noble] Waiting for Noble to detect BLE power cycle...'); await new Promise((resolve) => { const timeout = setTimeout(() => { console.log('[Noble] Power state recovery timeout - continuing anyway'); resolve(); }, 5000); const checkState = () => { if (noble.state === 'poweredOn') { clearTimeout(timeout); console.log('[Noble] Noble detected BLE power on'); resolve(); } else { setTimeout(checkState, 500); } }; checkState(); }); } catch (rfkillError) { console.error(`[Noble] rfkill recovery failed: ${rfkillError}`); console.error(`[Noble] MANUAL INTERVENTION REQUIRED: The BLE stack is corrupted.`); console.error(`[Noble] On Linux: Run 'sudo systemctl restart bluetooth' to recover.`); } } // For other errors, we already logged them - not fatal } } async disconnect() { await this.cleanup({ force: false, verifyResources: true }); } async forceCleanup() { await this.cleanup({ force: true, resetStack: true, verifyResources: true }); } }