import { ref } from 'vue'; import { normalizeEncoderOptions } from '@/lib/thermal-printer/normalize-encoder-options'; import type { PrinterConnection, StoredBluetoothDevice, StoredUsbDevice } from '@/lib/thermal-printer/types'; const USB_STORAGE_KEY = 'thermal-printer.usb-device'; const BLUETOOTH_STORAGE_KEY = 'thermal-printer.bluetooth-device'; const CONNECTION_STORAGE_KEY = 'thermal-printer.active-connection'; const USB_CHUNK_SIZE = 256; const USB_CHUNK_DELAY_MS = 15; const USB_FLUSH_DELAY_MS = 200; type BleProfile = { service: string; characteristic: string; language: string; codepageMapping: string; chunkSize: number; chunkDelayMs: number; }; const BLE_PROFILES: BleProfile[] = [ { service: '000018f0-0000-1000-8000-00805f9b34fb', characteristic: '00002af1-0000-1000-8000-00805f9b34fb', language: 'esc-pos', codepageMapping: 'xprinter', chunkSize: 50, chunkDelayMs: 35, }, { service: '49535343-fe7d-4ae5-8fa9-9fafd205e455', characteristic: '49535343-8841-43f4-a8d4-ecbe34729bb3', language: 'esc-pos', codepageMapping: 'epson', chunkSize: 100, chunkDelayMs: 25, }, { service: '0000ae30-0000-1000-8000-00805f9b34fb', characteristic: '0000ae01-0000-1000-8000-00805f9b34fb', language: 'esc-pos', codepageMapping: 'epson', chunkSize: 100, chunkDelayMs: 35, }, ]; export const connectionState = ref(null); let activeConnection: PrinterConnection | null = null; let usbPort: SerialPort | null = null; let bluetoothDevice: BluetoothDevice | null = null; let bluetoothCharacteristic: BluetoothRemoteGATTCharacteristic | null = null; let activeBleProfile: BleProfile | null = null; let bluetoothEncoderSettings: StoredBluetoothDevice | null = null; let printQueue: Promise = Promise.resolve(); function sleep(ms: number): Promise { return new Promise((resolve) => { window.setTimeout(resolve, ms); }); } function readStoredConnection(): PrinterConnection | null { try { const raw = localStorage.getItem(CONNECTION_STORAGE_KEY); if (raw === 'usb' || raw === 'bluetooth') { return raw; } } catch { return null; } return null; } function storeActiveConnection(connection: PrinterConnection | null): void { if (connection) { localStorage.setItem(CONNECTION_STORAGE_KEY, connection); } else { localStorage.removeItem(CONNECTION_STORAGE_KEY); } } function readStoredUsbDevice(): StoredUsbDevice | null { try { const raw = localStorage.getItem(USB_STORAGE_KEY); if (!raw) { return null; } return JSON.parse(raw) as StoredUsbDevice; } catch { return null; } } function storeUsbDevice(device: StoredUsbDevice): void { localStorage.setItem(USB_STORAGE_KEY, JSON.stringify(device)); } function readStoredBluetoothDevice(): StoredBluetoothDevice | null { try { const raw = localStorage.getItem(BLUETOOTH_STORAGE_KEY); if (!raw) { return null; } return JSON.parse(raw) as StoredBluetoothDevice; } catch { return null; } } function storeBluetoothDevice(device: StoredBluetoothDevice): void { localStorage.setItem(BLUETOOTH_STORAGE_KEY, JSON.stringify(device)); } function clearUsbState(): void { usbPort = null; } function clearBluetoothState(): void { bluetoothDevice = null; bluetoothCharacteristic = null; activeBleProfile = null; } function setActiveConnection(connection: PrinterConnection | null): void { activeConnection = connection; connectionState.value = connection; storeActiveConnection(connection); } function markDisconnected(): void { setActiveConnection(null); } function bindUsbDisconnectHandler(port: SerialPort): void { port.addEventListener('disconnect', () => { if (activeConnection === 'usb') { clearUsbState(); markDisconnected(); } }); } async function openUsbPort(port: SerialPort): Promise { if (!port.readable && !port.writable) { await port.open({ baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none', bufferSize: 255, }); } const info = port.getInfo(); usbPort = port; setActiveConnection('usb'); bindUsbDisconnectHandler(port); if (info.usbVendorId && info.usbProductId) { storeUsbDevice({ vendorId: info.usbVendorId, productId: info.usbProductId, }); } } async function resolveBluetoothPrintChannel(device: BluetoothDevice): Promise<{ characteristic: BluetoothRemoteGATTCharacteristic; profile: BleProfile; }> { const server = device.gatt; if (!server) { throw new Error('Printer Bluetooth tidak mendukung GATT.'); } if (!server.connected) { await server.connect(); } const services = await server.getPrimaryServices().catch(() => []); const serviceIds = new Set(services.map((service) => service.uuid)); for (const profile of BLE_PROFILES) { if (!serviceIds.has(profile.service)) { continue; } try { const service = await server.getPrimaryService(profile.service); const characteristic = await service.getCharacteristic(profile.characteristic); return { characteristic, profile }; } catch { continue; } } for (const profile of BLE_PROFILES) { try { const service = await server.getPrimaryService(profile.service); const characteristic = await service.getCharacteristic(profile.characteristic); return { characteristic, profile }; } catch { continue; } } throw new Error('Service cetak printer Bluetooth tidak ditemukan.'); } async function attachBluetoothDevice(device: BluetoothDevice): Promise { const { characteristic, profile } = await resolveBluetoothPrintChannel(device); const encoderOptions = normalizeEncoderOptions({ language: profile.language, codepageMapping: profile.codepageMapping, }); bluetoothDevice = device; bluetoothCharacteristic = characteristic; activeBleProfile = profile; bluetoothEncoderSettings = { id: device.id, language: encoderOptions.language, codepageMapping: encoderOptions.codepageMapping, }; setActiveConnection('bluetooth'); storeBluetoothDevice(bluetoothEncoderSettings); device.addEventListener('gattserverdisconnected', () => { if (activeConnection === 'bluetooth') { clearBluetoothState(); markDisconnected(); } }); } async function writeBluetoothChunk( characteristic: BluetoothRemoteGATTCharacteristic, chunk: Uint8Array, ): Promise { try { await characteristic.writeValueWithResponse(chunk); } catch { await characteristic.writeValueWithoutResponse(chunk); await sleep(20); } } async function sendUsbData(data: Uint8Array): Promise { if (!usbPort?.writable) { throw new Error('Port USB printer tidak siap.'); } const writer = usbPort.writable.getWriter(); try { for (let offset = 0; offset < data.length; offset += USB_CHUNK_SIZE) { const chunk = data.slice(offset, offset + USB_CHUNK_SIZE); await writer.write(chunk); if (offset + USB_CHUNK_SIZE < data.length) { await sleep(USB_CHUNK_DELAY_MS); } } await writer.ready; } finally { writer.releaseLock(); } await sleep(USB_FLUSH_DELAY_MS); } async function sendBluetoothData(data: Uint8Array): Promise { if (!bluetoothCharacteristic || !activeBleProfile) { throw new Error('Printer Bluetooth belum siap.'); } const { chunkSize, chunkDelayMs } = activeBleProfile; for (let offset = 0; offset < data.length; offset += chunkSize) { const chunk = data.slice(offset, offset + chunkSize); await writeBluetoothChunk(bluetoothCharacteristic, chunk); if (offset + chunkSize < data.length) { await sleep(chunkDelayMs); } } await sleep(120); } async function reconnectUsb(): Promise { const stored = readStoredUsbDevice(); const ports = await navigator.serial!.getPorts(); if (!ports.length) { throw new Error('Printer USB tidak ditemukan. Hubungkan ulang secara manual.'); } const orderedPorts = [ ...ports.filter((candidate) => { const info = candidate.getInfo(); return stored?.vendorId === info.usbVendorId && stored?.productId === info.usbProductId; }), ...ports.filter((candidate) => { const info = candidate.getInfo(); return stored?.vendorId !== info.usbVendorId || stored?.productId !== info.usbProductId; }), ]; clearUsbState(); let lastError: Error | null = null; for (const port of orderedPorts) { try { await openUsbPort(port); return; } catch (error) { lastError = error instanceof Error ? error : new Error('Gagal membuka port USB printer.'); } } throw lastError ?? new Error('Printer USB tidak ditemukan. Hubungkan ulang secara manual.'); } async function reconnectBluetooth(): Promise { const stored = readStoredBluetoothDevice(); if (!stored?.id || !navigator.bluetooth?.getDevices) { throw new Error('Printer Bluetooth tidak ditemukan. Hubungkan ulang secara manual.'); } const devices = await navigator.bluetooth.getDevices(); const device = devices.find((candidate) => candidate.id === stored.id); if (!device) { throw new Error('Printer Bluetooth tidak ditemukan. Hubungkan ulang secara manual.'); } clearBluetoothState(); await attachBluetoothDevice(device); } async function restoreStoredConnection(): Promise { const storedConnection = readStoredConnection(); if (!storedConnection) { return false; } try { if (storedConnection === 'usb' && isWebSerialSupported()) { await reconnectUsb(); return activeConnection === 'usb'; } if (storedConnection === 'bluetooth' && isWebBluetoothSupported()) { await reconnectBluetooth(); return activeConnection === 'bluetooth'; } } catch { clearUsbState(); clearBluetoothState(); } return false; } async function ensureReady(): Promise { if (!activeConnection) { const restored = await restoreStoredConnection(); if (!restored) { throw new Error('Printer belum terhubung. Hubungkan printer terlebih dahulu.'); } } if (activeConnection === 'usb') { if (usbPort?.writable) { return; } await reconnectUsb(); return; } if (activeConnection === 'bluetooth') { if (bluetoothDevice?.gatt?.connected && bluetoothCharacteristic) { return; } await reconnectBluetooth(); } } async function forceReconnect(): Promise { const connection = activeConnection ?? readStoredConnection(); if (connection === 'usb') { await reconnectUsb(); return; } if (connection === 'bluetooth') { await reconnectBluetooth(); } } async function sendPrintDataInternal(data: Uint8Array): Promise { if (!activeConnection) { throw new Error('Printer belum terhubung. Hubungkan printer terlebih dahulu.'); } await ensureReady(); if (activeConnection === 'usb') { await sendUsbData(data); return; } if (activeConnection === 'bluetooth') { await sendBluetoothData(data); return; } throw new Error('Koneksi printer tidak dikenali.'); } export function getActiveConnection(): PrinterConnection | null { return activeConnection; } export function getBluetoothEncoderSettings(): StoredBluetoothDevice | null { return bluetoothEncoderSettings ?? readStoredBluetoothDevice(); } export function isWebSerialSupported(): boolean { return typeof navigator !== 'undefined' && 'serial' in navigator; } export function isWebBluetoothSupported(): boolean { return typeof navigator !== 'undefined' && 'bluetooth' in navigator; } export async function connectUsb(): Promise { if (!isWebSerialSupported()) { throw new Error('Browser tidak mendukung Web Serial. Gunakan Chrome/Edge dan sambungkan printer via USB.'); } const port = await navigator.serial!.requestPort(); clearUsbState(); await openUsbPort(port); } export async function connectBluetooth(): Promise { if (!isWebBluetoothSupported()) { throw new Error('Browser tidak mendukung Web Bluetooth. Gunakan Chrome/Edge dan aktifkan Bluetooth.'); } const device = await navigator.bluetooth!.requestDevice({ acceptAllDevices: true, optionalServices: BLE_PROFILES.map((profile) => profile.service), }); clearBluetoothState(); await attachBluetoothDevice(device); } export async function restoreConnection(): Promise { if (activeConnection === 'usb' && usbPort?.writable) { return true; } if (activeConnection === 'bluetooth' && bluetoothDevice?.gatt?.connected && bluetoothCharacteristic) { return true; } return restoreStoredConnection(); } export async function tryReconnect(): Promise { return restoreConnection(); } export function disconnect(): void { if (usbPort) { void usbPort.close().catch(() => undefined); } if (bluetoothDevice?.gatt?.connected) { bluetoothDevice.gatt.disconnect(); } clearUsbState(); clearBluetoothState(); markDisconnected(); } export async function sendPrintData(data: Uint8Array, maxAttempts = 3): Promise { const task = printQueue .catch(() => undefined) .then(async () => { let lastError: Error | null = null; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { await sendPrintDataInternal(data); return; } catch (error) { lastError = error instanceof Error ? error : new Error('Gagal mengirim data ke printer.'); clearUsbState(); clearBluetoothState(); if (attempt < maxAttempts) { await sleep(400); try { await forceReconnect(); } catch { // Retry will surface the final error. } } } } throw lastError ?? new Error('Gagal mencetak struk.'); }); printQueue = task.catch(() => undefined); await task; }