566 lines
15 KiB
TypeScript
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;
|
|
}
|