@eventmsg/transport-webble
Version:
EventMsgV3 Web Bluetooth transport for browser-based communication with ESP32 devices
442 lines (438 loc) • 14.8 kB
JavaScript
import { BaseTransport, ConnectionError, DisconnectionError, SendError, ValidationError, getLogger } from "@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 BaseTransport {
logger = 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 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 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 ValidationError("Service configuration is required", { context: this.getErrorContext("config_validation") });
if (!config.service.uuid) throw new ValidationError("Service UUID is required", { context: this.getErrorContext("config_validation") });
if (!config.service.txCharacteristic) throw new ValidationError("TX characteristic UUID is required", { context: this.getErrorContext("config_validation") });
if (!config.service.rxCharacteristic) throw new ValidationError("RX characteristic UUID is required", { context: this.getErrorContext("config_validation") });
if (!UUID_REGEX.test(config.service.uuid)) throw new ValidationError("Invalid service UUID format", { context: {
...this.getErrorContext("config_validation"),
serviceUuid: config.service.uuid
} });
if (!UUID_REGEX.test(config.service.txCharacteristic)) throw new ValidationError("Invalid TX characteristic UUID format", { context: {
...this.getErrorContext("config_validation"),
txCharacteristic: config.service.txCharacteristic
} });
if (!UUID_REGEX.test(config.service.rxCharacteristic)) throw new 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 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 ConnectionError(errorInfo.message, {
cause: error,
context: {
...this.getErrorContext(operation),
solution: errorInfo.solution
}
});
if (operation === "disconnection") return new DisconnectionError(errorInfo.message, {
cause: error,
context: {
...this.getErrorContext(operation),
solution: errorInfo.solution
}
});
return new SendError(errorInfo.message, {
cause: error,
context: {
...this.getErrorContext(operation),
solution: errorInfo.solution
}
});
}
}
return new 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
export { NORDIC_UART_SERVICE, WEB_BLUETOOTH_ERROR_MAP, WEB_BLUETOOTH_ERROR_MESSAGES, WebBLEErrorCode, WebBLETransport, createNordicUARTConfig };
//# sourceMappingURL=index.js.map