store/resources/js/lib/thermal-printer/stable-transport.ts

566 lines
15 KiB
TypeScript

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<PrinterConnection | null>(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<void> = Promise.resolve();
function sleep(ms: number): Promise<void> {
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<void> {
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<void> {
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<void> {
try {
await characteristic.writeValueWithResponse(chunk);
} catch {
await characteristic.writeValueWithoutResponse(chunk);
await sleep(20);
}
}
async function sendUsbData(data: Uint8Array): Promise<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<void> {
const connection = activeConnection ?? readStoredConnection();
if (connection === 'usb') {
await reconnectUsb();
return;
}
if (connection === 'bluetooth') {
await reconnectBluetooth();
}
}
async function sendPrintDataInternal(data: Uint8Array): Promise<void> {
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<void> {
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<void> {
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<boolean> {
if (activeConnection === 'usb' && usbPort?.writable) {
return true;
}
if (activeConnection === 'bluetooth' && bluetoothDevice?.gatt?.connected && bluetoothCharacteristic) {
return true;
}
return restoreStoredConnection();
}
export async function tryReconnect(): Promise<boolean> {
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<void> {
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;
}