pos_bluetooth_thermal_printer/static/src/js/bluetooth_printer_manager.js

765 lines
28 KiB
JavaScript
Executable File

/** @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>} 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<Object>} 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<void>}
*/
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<boolean>} 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<boolean>} 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<void>}
*/
_sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
export default BluetoothPrinterManager;