dress/resources/js/contexts/printer-context.tsx

372 lines
14 KiB
TypeScript

import React, { createContext, useContext, useState, useCallback, ReactNode, useEffect } from 'react';
import { toast } from 'sonner';
export type PrinterType = 'bluetooth' | 'usb' | null;
export type PaperSize = '58' | '80';
interface PrinterState {
device: any;
type: PrinterType;
isConnected: boolean;
name: string | null;
paperSize: PaperSize;
}
interface PrinterContextType extends PrinterState {
connectBluetooth: () => Promise<void>;
connectUsb: () => Promise<void>;
disconnect: () => Promise<void>;
sendData: (data: Uint8Array) => Promise<void>;
setPaperSize: (size: PaperSize) => void;
}
const PrinterContext = createContext<PrinterContextType | undefined>(undefined);
async function findWriteCharacteristic(services: any[]) {
for (const service of services) {
const characteristics = await service.getCharacteristics();
for (const char of characteristics) {
if (char.properties.write || char.properties.writeWithoutResponse) {
return char;
}
}
}
return null;
}
export function PrinterProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<PrinterState>({
device: null,
type: null,
isConnected: false,
name: null,
paperSize: (localStorage.getItem('printer_paper_size') as PaperSize) || '58',
});
const [characteristic, setCharacteristic] = useState<any>(null);
// Save paper size to local storage
useEffect(() => {
localStorage.setItem('printer_paper_size', state.paperSize);
}, [state.paperSize]);
// USB disconnect listener
useEffect(() => {
const nav = navigator as any;
if (!nav.usb) return;
const handleUsbDisconnect = (event: any) => {
// Check if the disconnected device matches our current device
// We use vendorId and productId as a fallback if reference check fails
const currentDevice = state.device;
if (state.type === 'usb' && currentDevice &&
(event.device === currentDevice ||
(event.device.vendorId === currentDevice.vendorId &&
event.device.productId === currentDevice.productId))) {
setState(prev => ({ ...prev, device: null, type: null, isConnected: false, name: null }));
toast.error('Printer USB terputus');
}
};
nav.usb.addEventListener('disconnect', handleUsbDisconnect);
return () => nav.usb.removeEventListener('disconnect', handleUsbDisconnect);
}, [state.type, state.device]);
// Bluetooth disconnect listener
useEffect(() => {
if (state.type === 'bluetooth' && state.device) {
const device = state.device;
const handleDisconnect = () => {
setState(prev => ({ ...prev, device: null, type: null, isConnected: false, name: null }));
setCharacteristic(null);
toast.error('Printer Bluetooth terputus');
};
device.addEventListener('gattserverdisconnected', handleDisconnect);
return () => device.removeEventListener('gattserverdisconnected', handleDisconnect);
}
}, [state.type, state.device]);
// Auto-reconnect on mount
useEffect(() => {
const autoReconnect = async () => {
const nav = navigator as any;
const lastType = localStorage.getItem('last_printer_type');
if (lastType === 'bluetooth' && nav.bluetooth?.getDevices) {
try {
const devices = await nav.bluetooth.getDevices();
if (devices.length > 0) {
const device = devices[0];
const server = await device.gatt?.connect();
const services = await server.getPrimaryServices();
const writeChar = await findWriteCharacteristic(services);
if (writeChar) {
setCharacteristic(writeChar);
setState(prev => ({
...prev,
device,
type: 'bluetooth',
isConnected: true,
name: device.name || 'Bluetooth Printer',
}));
}
}
} catch (e) {
console.error('Auto-reconnect bluetooth failed:', e);
}
} else if (lastType === 'usb' && nav.usb?.getDevices) {
try {
const devices = await nav.usb.getDevices();
if (devices.length > 0) {
const device = devices[0];
await device.open();
if (device.configuration === null) await device.selectConfiguration(1);
const iface = device.configuration.interfaces[0];
await device.claimInterface(iface.interfaceNumber);
setState(prev => ({
...prev,
device,
type: 'usb',
isConnected: true,
name: device.productName || 'USB Printer',
}));
}
} catch (e) {
console.error('Auto-reconnect usb failed:', e);
}
}
};
autoReconnect();
}, []);
const connectBluetooth = useCallback(async () => {
try {
const nav = navigator as any;
if (!nav.bluetooth) {
toast.error('Bluetooth tidak didukung di browser ini');
return;
}
const device = await nav.bluetooth.requestDevice({
acceptAllDevices: true,
optionalServices: [
'000018f0-0000-1000-8000-00805f9b34fb',
'49535343-fe7d-41aa-83b1-d10935904914',
'0000ff00-0000-1000-8000-00805f9b34fb',
'00001101-0000-1000-8000-00805f9b34fb'
]
});
const server = await device.gatt?.connect();
const services = await server.getPrimaryServices();
const writeChar = await findWriteCharacteristic(services);
if (!writeChar) {
throw new Error('Tidak dapat menemukan characteristic untuk menulis data');
}
setCharacteristic(writeChar);
setState(prev => ({
...prev,
device,
type: 'bluetooth',
isConnected: true,
name: device.name || 'Bluetooth Printer',
}));
localStorage.setItem('last_printer_type', 'bluetooth');
toast.success(`Terhubung ke ${device.name || 'Printer Bluetooth'}`);
} catch (error: any) {
console.error('Bluetooth connection error:', error);
if (error.name !== 'NotFoundError') {
toast.error(error.message || 'Gagal menghubungkan printer bluetooth');
}
}
}, []);
const connectUsb = useCallback(async () => {
try {
const nav = navigator as any;
if (!nav.usb) {
toast.error('USB tidak didukung di browser ini');
return;
}
const device = await nav.usb.requestDevice({
filters: []
});
await device.open();
if (device.configuration === null) {
await device.selectConfiguration(1);
}
const iface = device.configuration.interfaces[0];
await device.claimInterface(iface.interfaceNumber);
setState(prev => ({
...prev,
device,
type: 'usb',
isConnected: true,
name: device.productName || 'USB Printer',
}));
localStorage.setItem('last_printer_type', 'usb');
toast.success(`Terhubung ke ${device.productName || 'Printer USB'}`);
} catch (error: any) {
console.error('USB connection error:', error);
if (error.name !== 'NotFoundError') {
toast.error(error.message || 'Gagal menghubungkan printer USB');
}
}
}, []);
const setPaperSize = useCallback((size: PaperSize) => {
setState(prev => ({ ...prev, paperSize: size }));
}, []);
const sendData = useCallback(async (data: Uint8Array) => {
if (!state.isConnected || !state.device) {
toast.error('Printer belum terhubung');
return;
}
try {
if (state.type === 'bluetooth') {
const device = state.device;
let activeChar = characteristic;
// Try to reconnect if GATT is disconnected
if (!device.gatt?.connected) {
toast.loading('Menghubungkan kembali...', { id: 'printer-reconnect' });
try {
const server = await device.gatt?.connect();
const services = await server.getPrimaryServices();
const writeChar = await findWriteCharacteristic(services);
if (writeChar) {
setCharacteristic(writeChar);
activeChar = writeChar;
toast.success('Printer terhubung kembali', { id: 'printer-reconnect' });
} else {
throw new Error('Gagal menemukan characteristic');
}
} catch (e) {
toast.error('Printer terputus', { id: 'printer-reconnect' });
setState(prev => ({ ...prev, device: null, type: null, isConnected: false, name: null }));
setCharacteristic(null);
return;
}
}
if (!activeChar) {
throw new Error('Characteristic tidak ditemukan');
}
// Bluetooth printers often have small buffers and limited MTU.
// 20 bytes is the standard BLE MTU.
const chunkSize = 20;
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
// Prefer writeValue (with response) for better reliability
// it acts as a primitive flow control
await activeChar.writeValue(chunk);
// Add a tiny delay between chunks if the data is large to avoid buffer overflow
// especially for cheaper printers
if (i % 100 === 0 && i > 0) {
await new Promise(resolve => setTimeout(resolve, 50));
}
}
} else if (state.type === 'usb') {
const device = state.device as any;
try {
// Check if device is still open
if (!device.opened) {
await device.open();
}
const endpoint = device.configuration.interfaces[0].alternates[0].endpoints.find((e: any) => e.direction === 'out' && e.type === 'bulk');
if (!endpoint) {
throw new Error('Tidak dapat menemukan USB endpoint');
}
await device.transferOut(endpoint.endpointNumber, data);
} catch (e) {
console.error('USB print error:', e);
setState(prev => ({ ...prev, device: null, type: null, isConnected: false, name: null }));
toast.error('Printer USB terputus');
return;
}
}
} catch (error: any) {
console.error('Print error:', error);
toast.error('Gagal mengirim data ke printer: ' + (error.message || 'Error tidak diketahui'));
}
}, [state.isConnected, state.device, state.type, characteristic]);
const disconnect = useCallback(async () => {
if (state.type === 'bluetooth' && state.device) {
if (state.device.gatt?.connected) {
state.device.gatt.disconnect();
}
} else if (state.type === 'usb' && state.device) {
try {
await state.device.close();
} catch (e) {
console.error('Error closing USB device:', e);
}
}
localStorage.removeItem('last_printer_type');
setState(prev => ({ ...prev, device: null, type: null, isConnected: false, name: null }));
setCharacteristic(null);
toast.info('Printer terputus');
}, [state.device, state.type]);
return (
<PrinterContext.Provider value={{
...state,
connectBluetooth,
connectUsb,
disconnect,
sendData,
setPaperSize,
}}>
{children}
</PrinterContext.Provider>
);
}
export function usePrinterContext() {
const context = useContext(PrinterContext);
if (context === undefined) {
throw new Error('usePrinterContext must be used within a PrinterProvider');
}
return context;
}