- Add thermal_printer.py service generating raw ESC/POS bytes for 58mm/80mm printers - Add /pos/api/sales/<id>/print endpoint (escpos_raw or browser mode) - Add printer.js with WebUSB and Web Serial support for direct browser-to-printer - Add thermal print button in ticket modal with connect/print workflow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
153 lines
5.3 KiB
JavaScript
153 lines
5.3 KiB
JavaScript
/**
|
|
* NexusPrinter — Web USB / Web Serial bridge for thermal printers.
|
|
*
|
|
* Connects to 58mm or 80mm ESC/POS printers via WebUSB or Web Serial APIs
|
|
* and sends raw byte commands generated by the backend.
|
|
*/
|
|
window.NexusPrinter = (function () {
|
|
'use strict';
|
|
|
|
let _device = null; // WebUSB device
|
|
let _endpoint = null; // USB bulk-out endpoint number
|
|
let _serialPort = null; // Web Serial port
|
|
let _writer = null; // Serial writable stream writer
|
|
let _type = null; // 'usb' | 'serial' | null
|
|
|
|
// ── Connection ─────────────────────────────────
|
|
|
|
async function connect() {
|
|
// Try WebUSB first
|
|
if ('usb' in navigator) {
|
|
try {
|
|
_device = await navigator.usb.requestDevice({ filters: [] });
|
|
await _device.open();
|
|
|
|
// Select first configuration if needed
|
|
if (_device.configuration === null) {
|
|
await _device.selectConfiguration(1);
|
|
}
|
|
|
|
// Claim first interface and find bulk-out endpoint
|
|
const iface = _device.configuration.interfaces[0];
|
|
await _device.claimInterface(iface.interfaceNumber);
|
|
|
|
const alt = iface.alternates[0];
|
|
const ep = alt.endpoints.find(e => e.direction === 'out' && e.type === 'bulk');
|
|
if (ep) {
|
|
_endpoint = ep.endpointNumber;
|
|
_type = 'usb';
|
|
_save();
|
|
return { ok: true, type: 'usb', name: _device.productName || 'USB Printer' };
|
|
}
|
|
} catch (e) {
|
|
// User cancelled or no USB device available
|
|
_device = null;
|
|
}
|
|
}
|
|
|
|
// Fall back to Web Serial
|
|
if ('serial' in navigator) {
|
|
try {
|
|
_serialPort = await navigator.serial.requestPort();
|
|
await _serialPort.open({ baudRate: 9600 });
|
|
_writer = _serialPort.writable.getWriter();
|
|
_type = 'serial';
|
|
_save();
|
|
return { ok: true, type: 'serial', name: 'Serial Printer' };
|
|
} catch (e) {
|
|
_serialPort = null;
|
|
_writer = null;
|
|
}
|
|
}
|
|
|
|
return { ok: false, error: 'No printer connected. Use a browser that supports WebUSB or Web Serial (Chrome/Edge).' };
|
|
}
|
|
|
|
async function disconnect() {
|
|
try {
|
|
if (_type === 'usb' && _device) {
|
|
await _device.close();
|
|
}
|
|
if (_type === 'serial') {
|
|
if (_writer) { _writer.releaseLock(); _writer = null; }
|
|
if (_serialPort) { await _serialPort.close(); _serialPort = null; }
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
_device = null;
|
|
_endpoint = null;
|
|
_type = null;
|
|
localStorage.removeItem('nexus_printer');
|
|
}
|
|
|
|
function isConnected() {
|
|
return _type !== null;
|
|
}
|
|
|
|
// ── Printing ───────────────────────────────────
|
|
|
|
async function sendRaw(bytes) {
|
|
if (!_type) return false;
|
|
|
|
try {
|
|
if (_type === 'usb' && _device && _endpoint !== null) {
|
|
// Send in chunks of 512 bytes (common USB max packet size)
|
|
const chunk = 512;
|
|
for (let i = 0; i < bytes.length; i += chunk) {
|
|
await _device.transferOut(_endpoint, bytes.slice(i, i + chunk));
|
|
}
|
|
return true;
|
|
}
|
|
if (_type === 'serial' && _writer) {
|
|
await _writer.write(bytes);
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
console.error('[NexusPrinter] send error:', e);
|
|
// Connection likely lost
|
|
await disconnect();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Print a sale ticket by fetching ESC/POS bytes from the backend.
|
|
* @param {number} saleId
|
|
* @param {number} [width=80] — 58 or 80 mm
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async function printSale(saleId, width) {
|
|
width = width || 80;
|
|
const token = localStorage.getItem('pos_token');
|
|
const resp = await fetch('/pos/api/sales/' + saleId + '/print', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': 'Bearer ' + token,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ printer_type: 'escpos_raw', width: width })
|
|
});
|
|
if (!resp.ok) {
|
|
console.error('[NexusPrinter] backend error', resp.status);
|
|
return false;
|
|
}
|
|
const buf = await resp.arrayBuffer();
|
|
return sendRaw(new Uint8Array(buf));
|
|
}
|
|
|
|
// ── Persistence helpers ────────────────────────
|
|
|
|
function _save() {
|
|
try { localStorage.setItem('nexus_printer', _type || ''); } catch (e) {}
|
|
}
|
|
|
|
// ── Public API ─────────────────────────────────
|
|
|
|
return {
|
|
connect: connect,
|
|
disconnect: disconnect,
|
|
isConnected: isConnected,
|
|
sendRaw: sendRaw,
|
|
printSale: printSale
|
|
};
|
|
})();
|