feat: implement global printer context and auto-reconnect for Bluetooth and USB devices
This commit is contained in:
parent
ede4831f96
commit
1296e10be7
@ -8,6 +8,8 @@ import SettingsLayout from '@/layouts/settings/layout';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
|
||||
import { PrinterProvider } from '@/contexts/printer-context';
|
||||
|
||||
createInertiaApp({
|
||||
title: (title) => (title ? `${title} - ${appName}` : appName),
|
||||
layout: (name) => {
|
||||
@ -26,7 +28,9 @@ createInertiaApp({
|
||||
withApp(app) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
{app}
|
||||
<PrinterProvider>
|
||||
{app}
|
||||
</PrinterProvider>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
371
resources/js/contexts/printer-context.tsx
Normal file
371
resources/js/contexts/printer-context.tsx
Normal file
@ -0,0 +1,371 @@
|
||||
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;
|
||||
}
|
||||
@ -1,224 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { usePrinterContext } from '@/contexts/printer-context';
|
||||
|
||||
// Type definitions for Web Bluetooth and Web USB
|
||||
// This is to satisfy TypeScript if the global types are missing
|
||||
|
||||
|
||||
export type PrinterType = 'bluetooth' | 'usb' | null;
|
||||
export type PaperSize = '58' | '80';
|
||||
|
||||
interface PrinterState {
|
||||
device: any; // Using any to avoid complex type issues with experimental APIs
|
||||
type: PrinterType;
|
||||
isConnected: boolean;
|
||||
name: string | null;
|
||||
paperSize: PaperSize;
|
||||
}
|
||||
export type { PrinterType, PaperSize } from '@/contexts/printer-context';
|
||||
|
||||
export function usePrinter() {
|
||||
const [state, setState] = useState<PrinterState>(() => {
|
||||
return {
|
||||
device: null,
|
||||
type: null,
|
||||
isConnected: false,
|
||||
name: null,
|
||||
paperSize: '58',
|
||||
};
|
||||
});
|
||||
|
||||
const [characteristic, setCharacteristic] = useState<any>(null);
|
||||
|
||||
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']
|
||||
});
|
||||
|
||||
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',
|
||||
}));
|
||||
|
||||
toast.success(`Terhubung ke ${device.name || 'Printer Bluetooth'}`);
|
||||
|
||||
device.addEventListener('gattserverdisconnected', () => {
|
||||
setState(prev => ({ ...prev, device: null, type: null, isConnected: false, name: null }));
|
||||
setCharacteristic(null);
|
||||
toast.error('Printer Bluetooth terputus');
|
||||
});
|
||||
|
||||
} 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',
|
||||
}));
|
||||
|
||||
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;
|
||||
|
||||
// Try to reconnect if GATT is disconnected
|
||||
if (!device.gatt?.connected) {
|
||||
toast.loading('Menghubungkan kembali...', { id: 'printer-reconnect' });
|
||||
const server = await device.gatt?.connect();
|
||||
const services = await server.getPrimaryServices();
|
||||
const writeChar = await findWriteCharacteristic(services);
|
||||
|
||||
if (writeChar) {
|
||||
setCharacteristic(writeChar);
|
||||
toast.success('Printer terhubung kembali', { id: 'printer-reconnect' });
|
||||
} else {
|
||||
toast.error('Gagal menghubungkan kembali printer', { id: 'printer-reconnect' });
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!characteristic) {
|
||||
throw new Error('Characteristic tidak ditemukan');
|
||||
}
|
||||
|
||||
const chunkSize = 20;
|
||||
|
||||
for (let i = 0; i < data.length; i += chunkSize) {
|
||||
const chunk = data.slice(i, i + chunkSize);
|
||||
|
||||
// Use writeValueWithoutResponse for better stability if supported
|
||||
if (characteristic.writeValueWithoutResponse) {
|
||||
await characteristic.writeValueWithoutResponse(chunk);
|
||||
} else {
|
||||
await characteristic.writeValue(chunk);
|
||||
}
|
||||
}
|
||||
} else if (state.type === 'usb') {
|
||||
const device = state.device as any;
|
||||
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 (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) {
|
||||
await state.device.close();
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, device: null, type: null, isConnected: false, name: null }));
|
||||
setCharacteristic(null);
|
||||
toast.info('Printer terputus');
|
||||
}, [state.device, state.type]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
connectBluetooth,
|
||||
connectUsb,
|
||||
disconnect,
|
||||
sendData,
|
||||
setPaperSize,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
return usePrinterContext();
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ export function useOrderPrint() {
|
||||
.feed(1)
|
||||
.line('Terima Kasih')
|
||||
.line('Selamat Belanja Kembali')
|
||||
.feed(1)
|
||||
.feed(3)
|
||||
.cut();
|
||||
|
||||
await sendData(result.encode());
|
||||
|
||||
Loading…
Reference in New Issue
Block a user