/** @odoo-module **/ /** * Bluetooth Printer Manager * * Manages bluetooth connections to thermal printers using the Web Bluetooth API. * Handles device discovery, connection management, auto-reconnection, and data transmission. */ // Custom error classes for better error handling export class BluetoothNotAvailableError extends Error { constructor(message = 'Web Bluetooth API is not available') { super(message); this.name = 'BluetoothNotAvailableError'; } } export class UserCancelledError extends Error { constructor(message = 'User cancelled device selection') { super(message); this.name = 'UserCancelledError'; } } export class DeviceNotFoundError extends Error { constructor(message = 'Bluetooth device not found') { super(message); this.name = 'DeviceNotFoundError'; } } export class ConnectionFailedError extends Error { constructor(message = 'Failed to connect to bluetooth device') { super(message); this.name = 'ConnectionFailedError'; } } export class PrinterNotConnectedError extends Error { constructor(message = 'Printer is not connected') { super(message); this.name = 'PrinterNotConnectedError'; } } export class TransmissionError extends Error { constructor(message = 'Failed to transmit data to printer') { super(message); this.name = 'TransmissionError'; } } export class PrinterBusyError extends Error { constructor(message = 'Printer is busy processing another job') { super(message); this.name = 'PrinterBusyError'; } } export class TimeoutError extends Error { constructor(message = 'Operation timed out') { super(message); this.name = 'TimeoutError'; } } /** * Bluetooth Printer Manager Service */ export class BluetoothPrinterManager { constructor(errorNotificationService = null) { // Debug logging this.debugMode = true; // Set to false to disable verbose logging // Connection state this.device = null; this.server = null; this.service = null; this.characteristic = null; this.connectionStatus = 'disconnected'; // Reconnection state this.reconnectAttempts = 0; this.maxReconnectAttempts = 3; this.reconnectDelays = [1000, 2000, 4000]; // Exponential backoff in milliseconds this.isReconnecting = false; this.autoReconnectEnabled = true; // Event listeners this.eventListeners = { 'connection-status-changed': [], 'print-completed': [], 'print-failed': [], 'reconnection-attempt': [], 'reconnection-success': [], 'reconnection-failure': [] }; // Printer state this.lastError = null; this.isPrinting = false; // Error notification service this.errorNotificationService = errorNotificationService; // Bluetooth service UUID for serial port profile (commonly used by thermal printers) // Using the standard Serial Port Profile UUID (SPP) // Most thermal printers use SPP for communication this.serviceUUID = '00001101-0000-1000-8000-00805f9b34fb'; // Serial Port Profile this.characteristicUUID = '00002af1-0000-1000-8000-00805f9b34fb'; } /** * Set error notification service * @param {Object} errorNotificationService - Error notification service instance */ setErrorNotificationService(errorNotificationService) { this.errorNotificationService = errorNotificationService; } /** * Debug log helper * @private * @param {string} message - Log message * @param {any} data - Optional data to log */ _log(message, data = null) { if (this.debugMode) { if (data) { console.log(`[BluetoothPrinter] ${message}`, data); } else { console.log(`[BluetoothPrinter] ${message}`); } } } /** * Check if Web Bluetooth API is available * @returns {boolean} True if available */ isBluetoothAvailable() { const available = typeof navigator !== 'undefined' && navigator.bluetooth !== undefined; this._log(`Bluetooth API available: ${available}`); return available; } /** * Scan for available bluetooth devices * @returns {Promise} Array of bluetooth devices * @throws {BluetoothNotAvailableError} If Web Bluetooth API is not available * @throws {UserCancelledError} If user cancels device selection */ async scanDevices() { if (!this.isBluetoothAvailable()) { const error = new BluetoothNotAvailableError(); if (this.errorNotificationService) { this.errorNotificationService.handleError(error, { operation: 'scanDevices' }); } throw error; } try { this._log('Starting device scan...'); // Request bluetooth device with filters for thermal printers // Many thermal printers advertise with specific name patterns const device = await navigator.bluetooth.requestDevice({ filters: [ { namePrefix: 'RPP' }, // RPP02 and similar { namePrefix: 'Printer' }, // Generic printer names { namePrefix: 'POS' }, // POS printers { namePrefix: 'TM-' }, // Epson TM series { namePrefix: 'SM-' }, // Star Micronics { namePrefix: 'BlueTooth' },// Generic bluetooth printers { namePrefix: 'BT-' }, // BT prefix printers { namePrefix: 'MTP' }, // Mobile thermal printers { namePrefix: 'SPP' }, // Serial Port Profile devices ], optionalServices: [ this.serviceUUID, // Serial Port Profile '000018f0-0000-1000-8000-00805f9b34fb', // Alternative service '49535343-fe7d-4ae5-8fa9-9fafd205e455', // Microchip transparent UART '0000ffe0-0000-1000-8000-00805f9b34fb', // Common serial service 'battery_service' ] }); this._log('Device found via filtered scan', { name: device.name, id: device.id }); return [device]; } catch (error) { this._log('Filtered scan failed, trying acceptAllDevices fallback', error.name); // If filtered search fails, try acceptAllDevices as fallback if (error.name === 'NotFoundError') { try { this._log('Attempting acceptAllDevices scan...'); const device = await navigator.bluetooth.requestDevice({ acceptAllDevices: true, optionalServices: [ this.serviceUUID, '000018f0-0000-1000-8000-00805f9b34fb', '49535343-fe7d-4ae5-8fa9-9fafd205e455', '0000ffe0-0000-1000-8000-00805f9b34fb', 'battery_service' ] }); this._log('Device found via acceptAllDevices', { name: device.name, id: device.id }); return [device]; } catch (fallbackError) { this._log('AcceptAllDevices fallback also failed', fallbackError.name); error = fallbackError; } } if (error.name === 'NotFoundError') { const cancelError = new UserCancelledError(); if (this.errorNotificationService) { this.errorNotificationService.handleError(cancelError, { operation: 'scanDevices' }); } throw cancelError; } // Log unexpected errors if (this.errorNotificationService) { this.errorNotificationService.logError(error, { operation: 'scanDevices' }); } throw error; } } /** * Connect to a bluetooth printer * @param {string} deviceId - Bluetooth device ID (or device object) * @returns {Promise} Connection object with device info * @throws {ConnectionFailedError} If connection fails * @throws {BluetoothNotAvailableError} If Web Bluetooth API is not available */ async connectToPrinter(deviceId) { if (!this.isBluetoothAvailable()) { const error = new BluetoothNotAvailableError(); if (this.errorNotificationService) { this.errorNotificationService.handleError(error, { operation: 'connectToPrinter' }); } throw error; } try { this._log('Attempting to connect to printer...', deviceId); this._setConnectionStatus('connecting'); // If deviceId is actually a device object from scanDevices, use it directly let device; if (typeof deviceId === 'object' && deviceId.gatt) { this._log('Using device object directly'); device = deviceId; } else { // Try to get previously paired device this._log('Looking for previously paired device...'); const devices = await navigator.bluetooth.getDevices(); this._log(`Found ${devices.length} previously paired devices`); device = devices.find(d => d.id === deviceId || d.name === deviceId); if (!device) { this._log('Device not found in paired devices'); const error = new DeviceNotFoundError(`Device ${deviceId} not found`); if (this.errorNotificationService) { this.errorNotificationService.handleError(error, { operation: 'connectToPrinter', deviceId: deviceId }); } throw error; } } this.device = device; this._log('Device selected', { name: this.device.name, id: this.device.id }); // Set up disconnect handler this.device.addEventListener('gattserverdisconnected', () => { this._log('GATT server disconnected event fired'); this._onDisconnected(); }); // Connect to GATT server this._log('Connecting to GATT server...'); this.server = await this.device.gatt.connect(); this._log('GATT server connected successfully'); // Try to get the printer service - try multiple common UUIDs const serviceUUIDs = [ this.serviceUUID, // Serial Port Profile '000018f0-0000-1000-8000-00805f9b34fb', // Alternative serial service '49535343-fe7d-4ae5-8fa9-9fafd205e455', // Microchip transparent UART '0000ffe0-0000-1000-8000-00805f9b34fb', // Common serial service '6e400001-b5a3-f393-e0a9-e50e24dcca9e', // Nordic UART Service ]; const characteristicUUIDs = [ this.characteristicUUID, // Default characteristic '00002af1-0000-1000-8000-00805f9b34fb', // Write characteristic '49535343-8841-43f4-a8d4-ecbe34729bb3', // Microchip TX '0000ffe1-0000-1000-8000-00805f9b34fb', // Common write characteristic '6e400002-b5a3-f393-e0a9-e50e24dcca9e', // Nordic UART TX ]; let serviceFound = false; for (const serviceUUID of serviceUUIDs) { try { console.log(`Trying service UUID: ${serviceUUID}`); this.service = await this.server.getPrimaryService(serviceUUID); // Try to find a writable characteristic for (const charUUID of characteristicUUIDs) { try { this.characteristic = await this.service.getCharacteristic(charUUID); console.log(`Found characteristic: ${charUUID}`); serviceFound = true; break; } catch (charError) { // Try next characteristic continue; } } if (serviceFound) { break; } } catch (serviceError) { // Try next service UUID continue; } } if (!serviceFound) { console.warn('Standard printer services not found, attempting to use any available writable characteristic'); // Last resort: try to find any writable characteristic try { const services = await this.server.getPrimaryServices(); for (const service of services) { const characteristics = await service.getCharacteristics(); for (const char of characteristics) { if (char.properties.write || char.properties.writeWithoutResponse) { this.service = service; this.characteristic = char; console.log(`Using fallback characteristic: ${char.uuid}`); serviceFound = true; break; } } if (serviceFound) break; } } catch (fallbackError) { console.error('Failed to find any writable characteristic:', fallbackError); if (this.errorNotificationService) { this.errorNotificationService.logError(fallbackError, { operation: 'findWritableCharacteristic', deviceName: this.device.name }); } } } this._setConnectionStatus('connected'); this.reconnectAttempts = 0; this.lastError = null; return { deviceId: this.device.id, deviceName: this.device.name, connected: true }; } catch (error) { this._setConnectionStatus('error'); this.lastError = error.message; if (error instanceof DeviceNotFoundError) { throw error; } const connectionError = new ConnectionFailedError(`Failed to connect: ${error.message}`); if (this.errorNotificationService) { this.errorNotificationService.handleError(connectionError, { operation: 'connectToPrinter', originalError: error.message }); } throw connectionError; } } /** * Disconnect from the bluetooth printer * @returns {Promise} */ async disconnect() { this.autoReconnectEnabled = false; if (this.server && this.server.connected) { try { await this.server.disconnect(); } catch (error) { console.error('Error during disconnect:', error); } } this.device = null; this.server = null; this.service = null; this.characteristic = null; this._setConnectionStatus('disconnected'); this.lastError = null; } /** * Send ESC/POS data to the printer (OPTIMIZED FOR SPEED) * @param {Uint8Array} escposData - ESC/POS command bytes * @returns {Promise} True if transmission successful * @throws {PrinterNotConnectedError} If printer is not connected * @throws {TransmissionError} If transmission fails * @throws {PrinterBusyError} If printer is busy */ async sendData(escposData) { if (!this.server || !this.server.connected) { const error = new PrinterNotConnectedError(); if (this.errorNotificationService) { this.errorNotificationService.handleError(error, { operation: 'sendData' }); } throw error; } if (this.isPrinting) { const error = new PrinterBusyError(); if (this.errorNotificationService) { this.errorNotificationService.handleError(error, { operation: 'sendData' }); } throw error; } if (!this.characteristic) { const error = new TransmissionError('Printer characteristic not available'); if (this.errorNotificationService) { this.errorNotificationService.handleError(error, { operation: 'sendData', reason: 'characteristic_unavailable' }); } throw error; } try { this.isPrinting = true; const startTime = performance.now(); // OPTIMIZED: Use larger chunks for graphics data (faster transmission) // Graphics data can handle larger chunks than text commands const isLargeData = escposData.length > 1000; const chunkSize = isLargeData ? 512 : 20; // Much larger chunks for graphics const chunks = []; for (let i = 0; i < escposData.length; i += chunkSize) { chunks.push(escposData.slice(i, i + chunkSize)); } console.log(`[Bluetooth] Sending ${chunks.length} chunks (${escposData.length} bytes, ${chunkSize} bytes/chunk)`); // Determine write method based on characteristic properties const useWriteWithoutResponse = this.characteristic.properties.writeWithoutResponse; const useWrite = this.characteristic.properties.write; if (!useWrite && !useWriteWithoutResponse) { throw new Error('Characteristic does not support write operations'); } // OPTIMIZED: Reduce delays for faster transmission const delay = isLargeData ? (useWriteWithoutResponse ? 10 : 5) : // Much shorter delays for graphics (useWriteWithoutResponse ? 50 : 25); // Normal delays for text // Send each chunk for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; try { if (useWriteWithoutResponse) { // Faster but no acknowledgment await this.characteristic.writeValueWithoutResponse(chunk); } else { // Slower but with acknowledgment await this.characteristic.writeValue(chunk); } // OPTIMIZED: Minimal delay between chunks if (delay > 0) { await this._sleep(delay); } // Progress logging every 20% if (i % Math.ceil(chunks.length / 5) === 0) { const progress = Math.round((i / chunks.length) * 100); console.log(`[Bluetooth] Progress: ${progress}%`); } } catch (chunkError) { console.error(`Failed to send chunk ${i + 1}/${chunks.length}:`, chunkError); throw chunkError; } } const endTime = performance.now(); const duration = ((endTime - startTime) / 1000).toFixed(2); const speed = (escposData.length / 1024 / (duration || 1)).toFixed(2); console.log(`[Bluetooth] Transmission complete in ${duration}s (${speed} KB/s)`); this.isPrinting = false; this._emit('print-completed', { success: true }); return true; } catch (error) { this.isPrinting = false; this._emit('print-failed', { error: error.message }); const transmissionError = new TransmissionError(`Failed to send data: ${error.message}`); if (this.errorNotificationService) { this.errorNotificationService.handleError(transmissionError, { operation: 'sendData', dataSize: escposData.length, originalError: error.message }); } throw transmissionError; } } /** * Get current connection status * @returns {string} Connection status: 'connected', 'disconnected', 'connecting', 'error' */ getConnectionStatus() { return this.connectionStatus; } /** * Get detailed connection information * @returns {Object} Connection status object */ getConnectionInfo() { return { status: this.connectionStatus, deviceName: this.device ? this.device.name : null, deviceId: this.device ? this.device.id : null, lastError: this.lastError, reconnectAttempts: this.reconnectAttempts, isReconnecting: this.isReconnecting, timestamp: Date.now() }; } /** * Attempt automatic reconnection with exponential backoff * @returns {Promise} True if reconnection successful */ async autoReconnect() { if (!this.autoReconnectEnabled || this.isReconnecting) { return false; } if (!this.device) { console.warn('Cannot reconnect: no device information available'); return false; } this.isReconnecting = true; this._setConnectionStatus('connecting'); for (let attempt = 0; attempt < this.maxReconnectAttempts; attempt++) { this.reconnectAttempts = attempt + 1; // Emit reconnection attempt event this._emit('reconnection-attempt', { attempt: this.reconnectAttempts, maxAttempts: this.maxReconnectAttempts }); // Notify via error service if (this.errorNotificationService) { this.errorNotificationService.handleReconnectionAttempt( this.reconnectAttempts, this.maxReconnectAttempts ); } try { console.log(`Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}`); // Try to reconnect await this.connectToPrinter(this.device); this.isReconnecting = false; this.reconnectAttempts = 0; console.log('Reconnection successful'); // Emit reconnection success event this._emit('reconnection-success', { deviceName: this.device.name }); // Notify via error service if (this.errorNotificationService) { this.errorNotificationService.handleReconnectionSuccess(this.device.name); } return true; } catch (error) { console.error(`Reconnection attempt ${this.reconnectAttempts} failed:`, error); // Log the error if (this.errorNotificationService) { this.errorNotificationService.logError(error, { operation: 'autoReconnect', attempt: this.reconnectAttempts, maxAttempts: this.maxReconnectAttempts }); } // Wait before next attempt (exponential backoff) if (attempt < this.maxReconnectAttempts - 1) { const delay = this.reconnectDelays[attempt]; console.log(`Waiting ${delay}ms before next attempt`); await this._sleep(delay); } } } // All reconnection attempts failed this.isReconnecting = false; this._setConnectionStatus('error'); this.lastError = 'Reconnection failed after maximum attempts'; console.error('All reconnection attempts failed'); // Emit reconnection failure event this._emit('reconnection-failure', { attempts: this.maxReconnectAttempts }); // Notify via error service if (this.errorNotificationService) { this.errorNotificationService.handleReconnectionFailure(); } return false; } /** * Enable or disable automatic reconnection * @param {boolean} enabled - Whether to enable auto-reconnect */ setAutoReconnect(enabled) { this.autoReconnectEnabled = enabled; } /** * Add event listener * @param {string} event - Event name * @param {Function} callback - Callback function */ addEventListener(event, callback) { if (this.eventListeners[event]) { this.eventListeners[event].push(callback); } } /** * Remove event listener * @param {string} event - Event name * @param {Function} callback - Callback function */ removeEventListener(event, callback) { if (this.eventListeners[event]) { const index = this.eventListeners[event].indexOf(callback); if (index > -1) { this.eventListeners[event].splice(index, 1); } } } /** * Handle disconnection event * @private */ _onDisconnected() { console.log('Bluetooth device disconnected'); this._setConnectionStatus('disconnected'); // Attempt auto-reconnection if enabled if (this.autoReconnectEnabled) { console.log('Starting auto-reconnection...'); this.autoReconnect().catch(error => { console.error('Auto-reconnection failed:', error); }); } } /** * Set connection status and emit event * @private * @param {string} status - New connection status */ _setConnectionStatus(status) { const oldStatus = this.connectionStatus; this.connectionStatus = status; if (oldStatus !== status) { const statusData = { oldStatus, newStatus: status, deviceName: this.device ? this.device.name : null, timestamp: Date.now() }; this._emit('connection-status-changed', statusData); // Notify error service about status change if (this.errorNotificationService) { this.errorNotificationService.handleStatusChange(statusData); } } } /** * Emit event to all listeners * @private * @param {string} event - Event name * @param {Object} data - Event data */ _emit(event, data) { if (this.eventListeners[event]) { this.eventListeners[event].forEach(callback => { try { callback(data); } catch (error) { console.error(`Error in event listener for ${event}:`, error); } }); } } /** * Sleep for specified milliseconds * @private * @param {number} ms - Milliseconds to sleep * @returns {Promise} */ _sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } } export default BluetoothPrinterManager;