UNPKG

@eventmsg/transport-webble

Version:

EventMsgV3 Web Bluetooth transport for browser-based communication with ESP32 devices

447 lines (443 loc) 15.2 kB
let __eventmsg_core = require("@eventmsg/core"); //#region src/types/config.ts /** * Nordic UART Service preset configuration * Standard service used by most ESP32 BLE examples */ const NORDIC_UART_SERVICE = { uuid: "6e400001-b5a3-f393-e0a9-e50e24dcca9e", txCharacteristic: "6e400003-b5a3-f393-e0a9-e50e24dcca9e", rxCharacteristic: "6e400002-b5a3-f393-e0a9-e50e24dcca9e" }; /** * Create a WebBLE transport config with Nordic UART defaults */ function createNordicUARTConfig(localAddress, groupAddress, overrides) { return { localAddress, groupAddress, service: NORDIC_UART_SERVICE, connection: { timeout: 1e4, persistDevice: true, reconnectAttempts: 3, reconnectDelay: 1e3, mtu: 480 }, ...overrides }; } //#endregion //#region src/types/errors.ts /** * Web Bluetooth specific error codes */ const WebBLEErrorCode = { NOT_SUPPORTED: "NOT_SUPPORTED", INSECURE_CONTEXT: "INSECURE_CONTEXT", USER_CANCELLED: "USER_CANCELLED", DEVICE_NOT_FOUND: "DEVICE_NOT_FOUND", GATT_CONNECTION_FAILED: "GATT_CONNECTION_FAILED", SERVICE_NOT_FOUND: "SERVICE_NOT_FOUND", CHARACTERISTIC_NOT_FOUND: "CHARACTERISTIC_NOT_FOUND", NOTIFICATION_FAILED: "NOTIFICATION_FAILED", WRITE_FAILED: "WRITE_FAILED", UNEXPECTED_DISCONNECT: "UNEXPECTED_DISCONNECT", PERMISSION_DENIED: "PERMISSION_DENIED", TIMEOUT: "TIMEOUT" }; /** * Mapping of Web Bluetooth DOMException names to WebBLE error codes */ const WEB_BLUETOOTH_ERROR_MAP = { NotFoundError: WebBLEErrorCode.USER_CANCELLED, SecurityError: WebBLEErrorCode.INSECURE_CONTEXT, NotSupportedError: WebBLEErrorCode.NOT_SUPPORTED, NetworkError: WebBLEErrorCode.UNEXPECTED_DISCONNECT, TimeoutError: WebBLEErrorCode.TIMEOUT, NotAllowedError: WebBLEErrorCode.PERMISSION_DENIED }; /** * User-friendly error messages with solutions */ const WEB_BLUETOOTH_ERROR_MESSAGES = { [WebBLEErrorCode.NOT_SUPPORTED]: { message: "Web Bluetooth is not supported in this browser", solution: "Use Chrome, Edge, or Opera browser with Web Bluetooth support" }, [WebBLEErrorCode.INSECURE_CONTEXT]: { message: "Web Bluetooth requires a secure context (HTTPS)", solution: "Use HTTPS or localhost for development" }, [WebBLEErrorCode.USER_CANCELLED]: { message: "Device selection was cancelled", solution: "Please select a device from the list to continue" }, [WebBLEErrorCode.DEVICE_NOT_FOUND]: { message: "Bluetooth device not found or not available", solution: "Ensure device is powered on, in range, and advertising" }, [WebBLEErrorCode.GATT_CONNECTION_FAILED]: { message: "Failed to connect to device GATT server", solution: "Check device is available and try again" }, [WebBLEErrorCode.SERVICE_NOT_FOUND]: { message: "Required service not found on device", solution: "Verify device firmware supports the required service" }, [WebBLEErrorCode.CHARACTERISTIC_NOT_FOUND]: { message: "Required characteristic not found in service", solution: "Check device firmware implements the required characteristics" }, [WebBLEErrorCode.NOTIFICATION_FAILED]: { message: "Failed to enable notifications on characteristic", solution: "Verify characteristic supports notifications" }, [WebBLEErrorCode.WRITE_FAILED]: { message: "Failed to write data to characteristic", solution: "Check connection and characteristic write permissions" }, [WebBLEErrorCode.UNEXPECTED_DISCONNECT]: { message: "Device disconnected unexpectedly", solution: "Check device power and Bluetooth range" }, [WebBLEErrorCode.PERMISSION_DENIED]: { message: "Bluetooth permission denied", solution: "Allow Bluetooth access in browser settings" }, [WebBLEErrorCode.TIMEOUT]: { message: "Operation timed out", solution: "Check device availability and try again" } }; //#endregion //#region src/webble-transport.ts const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** * WebBLE Transport for EventMsgV3 protocol * * Provides Web Bluetooth connectivity for browser-based communication * with ESP32 devices using BLE/GATT protocol. * * Features: * - Nordic UART Service support by default * - Automatic data chunking for BLE MTU limitations * - Device caching and automatic reconnection * - Browser compatibility checks * - Comprehensive error handling */ var WebBLETransport = class extends __eventmsg_core.BaseTransport { logger = (0, __eventmsg_core.getLogger)("TRANSPORT_WEBBLE"); server; service; rxCharacteristic; txCharacteristic; mtu = 480; deviceCache = null; receiveBuffer = new Uint8Array(0); MAX_MESSAGE_SIZE = 4096; bufferTimeout; constructor(config) { super(config); this.checkBrowserSupport(); this.config.connection = { timeout: 1e4, persistDevice: true, reconnectAttempts: 3, reconnectDelay: 1e3, ...this.config.connection }; } /** * Check if Web Bluetooth is supported in current browser * @throws {ValidationError} If Web Bluetooth is not supported */ checkBrowserSupport() { if (!("bluetooth" in navigator)) throw new __eventmsg_core.ValidationError("Web Bluetooth API not supported in this browser", { context: { ...this.getErrorContext("browser_check"), solution: "Use Chrome, Edge, or Opera browser with Web Bluetooth support" } }); if (!window.isSecureContext) throw new __eventmsg_core.ValidationError("Web Bluetooth requires a secure context (HTTPS)", { context: { ...this.getErrorContext("browser_check"), solution: "Use HTTPS or localhost for development" } }); } /** * Validate WebBLE-specific configuration * @param config Configuration to validate * @throws {ValidationError} If configuration is invalid */ validateConfig(config) { super.validateConfig(config); if (!config.service) throw new __eventmsg_core.ValidationError("Service configuration is required", { context: this.getErrorContext("config_validation") }); if (!config.service.uuid) throw new __eventmsg_core.ValidationError("Service UUID is required", { context: this.getErrorContext("config_validation") }); if (!config.service.txCharacteristic) throw new __eventmsg_core.ValidationError("TX characteristic UUID is required", { context: this.getErrorContext("config_validation") }); if (!config.service.rxCharacteristic) throw new __eventmsg_core.ValidationError("RX characteristic UUID is required", { context: this.getErrorContext("config_validation") }); if (!UUID_REGEX.test(config.service.uuid)) throw new __eventmsg_core.ValidationError("Invalid service UUID format", { context: { ...this.getErrorContext("config_validation"), serviceUuid: config.service.uuid } }); if (!UUID_REGEX.test(config.service.txCharacteristic)) throw new __eventmsg_core.ValidationError("Invalid TX characteristic UUID format", { context: { ...this.getErrorContext("config_validation"), txCharacteristic: config.service.txCharacteristic } }); if (!UUID_REGEX.test(config.service.rxCharacteristic)) throw new __eventmsg_core.ValidationError("Invalid RX characteristic UUID format", { context: { ...this.getErrorContext("config_validation"), rxCharacteristic: config.service.rxCharacteristic } }); } /** * Transport-specific connection implementation * Handles Web Bluetooth device selection and GATT connection */ async doConnect() { try { this.device = await this.getOrRequestDevice(); if (!this.device.gatt) throw new Error("Device does not support GATT"); this.server = await this.device.gatt.connect(); this.service = await this.server.getPrimaryService(this.config.service.uuid); this.rxCharacteristic = await this.service.getCharacteristic(this.config.service.rxCharacteristic); this.txCharacteristic = await this.service.getCharacteristic(this.config.service.txCharacteristic); await this.txCharacteristic.startNotifications(); this.txCharacteristic.addEventListener("characteristicvaluechanged", this.handleIncomingData.bind(this)); this.device.addEventListener("gattserverdisconnected", this.handleGATTDisconnect.bind(this)); this.negotiateMTU(); if (this.config.connection?.persistDevice) this.cacheDevice(); this.logger.info("WebBLE connected successfully", { deviceName: this.device?.name || "Unknown", deviceId: this.device?.id, mtu: this.mtu, serviceUuid: this.config.service.uuid, rxCharacteristic: this.config.service.rxCharacteristic, txCharacteristic: this.config.service.txCharacteristic }); } catch (error) { await this.cleanupConnection(); throw this.wrapBLEError(error, "connection"); } } /** * Transport-specific disconnection implementation * Cleans up BLE resources and disconnects */ async doDisconnect() { try { this.logger.info("WebBLE disconnecting", { deviceName: this.device?.name || "Unknown", connected: this.server?.connected }); await this.cleanupConnection(); this.logger.info("WebBLE disconnected successfully"); } catch (error) { throw this.wrapBLEError(error, "disconnection"); } } /** * Transport-specific send implementation * Sends data via BLE characteristic with automatic chunking */ async doSend(data) { if (!this.rxCharacteristic) throw new __eventmsg_core.SendError("RX characteristic not available", { context: this.getErrorContext("send") }); try { const chunks = this.chunkData(data); this.logger.debug("Sending message via WebBLE", { totalSize: data.length, chunkCount: chunks.length, mtu: this.mtu }); for (const chunk of chunks) await this.sendChunk(chunk); this.logger.debug("Message sent successfully", { totalSize: data.length, chunksSent: chunks.length }); } catch (error) { throw this.wrapBLEError(error, "send"); } } /** * Get cached device or request new device from user */ getOrRequestDevice() { if (this.config.connection?.persistDevice && this.deviceCache) this.deviceCache = null; return this.requestDevice(); } /** * Request device selection from user */ async requestDevice() { const filters = this.config.filters || [{ services: [this.config.service.uuid] }]; try { return await navigator.bluetooth.requestDevice({ filters, optionalServices: [this.config.service.uuid] }); } catch (error) { throw this.wrapBLEError(error, "device_request"); } } /** * Handle incoming data from BLE characteristic */ handleIncomingData(event) { const value = event.target.value; if (!value) return; const chunk = new Uint8Array(value.buffer); this.logger.debug("WebBLE chunk received", { chunkSize: chunk.length, currentBufferSize: this.receiveBuffer.length, newBufferSize: this.receiveBuffer.length + chunk.length }); const newBuffer = new Uint8Array(this.receiveBuffer.length + chunk.length); newBuffer.set(this.receiveBuffer); newBuffer.set(chunk, this.receiveBuffer.length); this.receiveBuffer = newBuffer; if (this.receiveBuffer.length > this.MAX_MESSAGE_SIZE) { this.logger.warn("Message too large, clearing buffer", { bufferSize: this.receiveBuffer.length, maxSize: this.MAX_MESSAGE_SIZE, chunkSize: chunk.length }); this.clearReceiveBuffer(); return; } this.resetBufferTimeout(); if (this.receiveBuffer.length >= 10 && this.receiveBuffer[0] === 1 && this.receiveBuffer.at(-1) === 4) { this.logger.info("Message reassembled successfully", { finalSize: this.receiveBuffer.length, chunksReceived: this.receiveBuffer.length <= 500 ? 1 : Math.ceil(this.receiveBuffer.length / 500) }); this.emit("data", this.receiveBuffer); this.clearReceiveBuffer(); } else this.bufferTimeout = setTimeout(() => { if (this.receiveBuffer.length > 0) { this.logger.warn("Partial message timeout, clearing buffer", { bufferSize: this.receiveBuffer.length, timeoutMs: 5e3, hasSOH: this.receiveBuffer[0] === 1, hasEOT: this.receiveBuffer.at(-1) === 4 }); this.clearReceiveBuffer(); } }, 5e3); } /** * Clear receive buffer and timeout */ clearReceiveBuffer() { this.receiveBuffer = new Uint8Array(0); this.resetBufferTimeout(); } /** * Reset buffer timeout */ resetBufferTimeout() { if (this.bufferTimeout) { clearTimeout(this.bufferTimeout); this.bufferTimeout = void 0; } } /** * Handle GATT server disconnection event */ handleGATTDisconnect(_event) { this.handleUnexpectedDisconnect(); } /** * Negotiate MTU size with device */ negotiateMTU() { this.mtu = this.config.connection?.mtu || 20; } /** * Cache device for reconnection */ cacheDevice() { if (this.device) this.deviceCache = this.device.id; } /** * Split data into chunks that fit within BLE MTU */ chunkData(data) { const chunkSize = this.mtu; const chunks = []; for (let i = 0; i < data.length; i += chunkSize) chunks.push(data.slice(i, i + chunkSize)); return chunks; } /** * Send a single data chunk */ async sendChunk(chunk) { if (!this.rxCharacteristic) throw new Error("RX characteristic not available"); try { await this.rxCharacteristic.writeValueWithResponse(chunk); } catch (error) { this.logger.error("BLE chunk send failed", { error: error instanceof Error ? error.message : String(error), chunkSize: chunk.length, cause: error instanceof Error ? error.name : "Unknown" }); throw error; } } /** * Clean up BLE connection resources */ async cleanupConnection() { this.clearReceiveBuffer(); if (this.txCharacteristic) { try { await this.txCharacteristic.stopNotifications(); } catch {} this.txCharacteristic = void 0; } this.rxCharacteristic = void 0; this.service = void 0; if (this.server?.connected) this.server.disconnect(); this.server = void 0; this.device = null; } /** * Convert Web Bluetooth errors to transport errors */ wrapBLEError(error, operation) { if (error instanceof Error) { const bleErrorCode = WEB_BLUETOOTH_ERROR_MAP[error.name]; if (bleErrorCode) { const errorInfo = WEB_BLUETOOTH_ERROR_MESSAGES[bleErrorCode]; if (operation === "connection") return new __eventmsg_core.ConnectionError(errorInfo.message, { cause: error, context: { ...this.getErrorContext(operation), solution: errorInfo.solution } }); if (operation === "disconnection") return new __eventmsg_core.DisconnectionError(errorInfo.message, { cause: error, context: { ...this.getErrorContext(operation), solution: errorInfo.solution } }); return new __eventmsg_core.SendError(errorInfo.message, { cause: error, context: { ...this.getErrorContext(operation), solution: errorInfo.solution } }); } } return new __eventmsg_core.ConnectionError(`BLE ${operation} failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error instanceof Error ? error : new Error(String(error)), context: this.getErrorContext(operation) }); } }; //#endregion exports.NORDIC_UART_SERVICE = NORDIC_UART_SERVICE; exports.WEB_BLUETOOTH_ERROR_MAP = WEB_BLUETOOTH_ERROR_MAP; exports.WEB_BLUETOOTH_ERROR_MESSAGES = WEB_BLUETOOTH_ERROR_MESSAGES; exports.WebBLEErrorCode = WebBLEErrorCode; exports.WebBLETransport = WebBLETransport; exports.createNordicUARTConfig = createNordicUARTConfig; //# sourceMappingURL=index.cjs.map