perf: optimize BLE transmission throughput and fix layout truncation on basic receipts

This commit is contained in:
Suherdy Yacob 2026-06-01 22:14:01 +07:00
parent d1df33bdb2
commit 0ca500fc01
3 changed files with 44 additions and 34 deletions

View File

@ -456,18 +456,18 @@ export class BluetoothPrinterManager {
this.isPrinting = true; this.isPrinting = true;
const startTime = performance.now(); const startTime = performance.now();
// OPTIMIZED: Use safe chunks depending on transmission mode // Use 128-byte chunks for all modes — this is the standard BLE ATT MTU payload size
// Large chunks (e.g. 512 bytes) overflow standard BLE serial buffers, truncating long receipts. // and is well within the serial buffer of all common thermal printers.
// Graphics data is sent in highly compatible 128-byte chunks; text is sent in safe 20-byte chunks. // Using only 20 bytes (the BLE minimum) is 6× slower and causes connection timeouts
const isGraphicsMode = isGraphics || escposData.length > 5000; // on long receipts (many items), resulting in missing lines at the end.
const chunkSize = isGraphicsMode ? 128 : 20; const chunkSize = 128;
const chunks = []; const chunks = [];
for (let i = 0; i < escposData.length; i += chunkSize) { for (let i = 0; i < escposData.length; i += chunkSize) {
chunks.push(escposData.slice(i, i + chunkSize)); chunks.push(escposData.slice(i, i + chunkSize));
} }
console.log(`[Bluetooth] Sending ${chunks.length} chunks (${escposData.length} bytes, ${chunkSize} bytes/chunk, mode: ${isGraphicsMode ? 'graphics' : 'text'})`); console.log(`[Bluetooth] Sending ${chunks.length} chunks (${escposData.length} bytes, ${chunkSize} bytes/chunk)`);
// Prefer write with response if available for natural BLE flow control, fallback to write without response // Prefer write with response if available for natural BLE flow control, fallback to write without response
const useWrite = this.characteristic.properties.write; const useWrite = this.characteristic.properties.write;
@ -486,19 +486,18 @@ export class BluetoothPrinterManager {
try { try {
if (writeWithAck) { if (writeWithAck) {
// Safe write with response (OS/device level acknowledgment) // Write with response: OS provides ack, minimal sleep needed
await this.characteristic.writeValue(chunk); await this.characteristic.writeValue(chunk);
// Minimal safety sleep of 5ms between chunks
await this._sleep(5); await this._sleep(5);
} else { } else {
// Write without response (needs explicit flow control to prevent buffer overrun) // Write without response: explicit flow control to prevent printer buffer overrun
await this.characteristic.writeValueWithoutResponse(chunk); await this.characteristic.writeValueWithoutResponse(chunk);
// Adaptive sleep: base delay of 35ms, with 120ms flush every 8 chunks // 30ms between chunks; 80ms flush pause every 10 chunks
if ((i + 1) % 8 === 0) { if ((i + 1) % 10 === 0) {
await this._sleep(120); await this._sleep(80);
} else { } else {
await this._sleep(35); await this._sleep(30);
} }
} }

View File

@ -231,7 +231,7 @@ export class EscPosGenerator {
if (receiptData.orderData) { if (receiptData.orderData) {
const o = receiptData.orderData; const o = receiptData.orderData;
if (receiptData.isBasicReceipt) { if (receiptData.isBasicReceipt) {
// Basic receipt (table checker): all info at double height for readability // Basic receipt (table checker): double-height for readability, keep full 32-char width
if (o.orderName) cmds.push(...this.addLine(`Order: ${o.orderName}`, { height: 2, bold: true })); if (o.orderName) cmds.push(...this.addLine(`Order: ${o.orderName}`, { height: 2, bold: true }));
if (o.date) cmds.push(...this.addLine(o.date, { height: 2 })); if (o.date) cmds.push(...this.addLine(o.date, { height: 2 }));
if (o.cashier) cmds.push(...this.addLine(`By: ${o.cashier}`, { height: 2 })); if (o.cashier) cmds.push(...this.addLine(`By: ${o.cashier}`, { height: 2 }));
@ -256,16 +256,15 @@ export class EscPosGenerator {
if (receiptData.isBasicReceipt) { if (receiptData.isBasicReceipt) {
// ── Basic receipt / table checker ────────────────────────── // ── Basic receipt / table checker ──────────────────────────
// Show only qty + product name (no price/total), at double-width // Show qty + product name at double HEIGHT only (keeps full 32-char width).
// double-height for maximum readability on 58mm paper. // This ensures long product names are NOT truncated at 13 chars.
// At width=2, each char is 2x wide, so effective cols = W/2 = 16 // Format: "1 Pangsit Mie Ayam (Biasa)"
const halfW = Math.floor(W / 2); const qtyLabel = qtyStr.padEnd(2) + ' ';
const qtyLabel = qtyStr.padEnd(2) + ' '; const nameMaxLen = W - qtyLabel.length;
const nameMaxLen = halfW - qtyLabel.length;
const displayName = name.length > nameMaxLen const displayName = name.length > nameMaxLen
? name.substring(0, nameMaxLen - 1) + '.' ? name.substring(0, nameMaxLen - 1) + '.'
: name; : name;
cmds.push(...this.addLine(qtyLabel + displayName, { bold: true, width: 2, height: 2 })); cmds.push(...this.addLine(qtyLabel + displayName, { bold: true, height: 2 }));
} else { } else {
// ── Full receipt ─────────────────────────────────────────── // ── Full receipt ───────────────────────────────────────────
// Line 1: product name // Line 1: product name
@ -289,10 +288,9 @@ export class EscPosGenerator {
const subQtyStr = subQty % 1 === 0 ? subQty.toFixed(0) : subQty.toFixed(2); const subQtyStr = subQty % 1 === 0 ? subQty.toFixed(0) : subQty.toFixed(2);
if (receiptData.isBasicReceipt) { if (receiptData.isBasicReceipt) {
// Double-height sub-lines on basic receipt // Double-height sub-lines, full 32-char width
const halfW = Math.floor(W / 2); const prefix = ` ${subQtyStr}x `;
const prefix = ` ${subQtyStr}x `; const maxSubLen = W - prefix.length;
const maxSubLen = halfW - prefix.length;
const displaySub = subName.length > maxSubLen const displaySub = subName.length > maxSubLen
? subName.substring(0, maxSubLen - 1) + '.' ? subName.substring(0, maxSubLen - 1) + '.'
: subName; : subName;
@ -314,6 +312,7 @@ export class EscPosGenerator {
const rawNote = String(line.note); const rawNote = String(line.note);
const rawLines = rawNote.split('\n'); const rawLines = rawNote.split('\n');
const wrappedLines = []; const wrappedLines = [];
// For basic receipt: full W chars available (height:2 only, not width:2)
const maxNoteWidth = W - 2; const maxNoteWidth = W - 2;
rawLines.forEach(rawLine => { rawLines.forEach(rawLine => {
@ -342,6 +341,7 @@ export class EscPosGenerator {
wrappedLines.forEach((l, i) => { wrappedLines.forEach((l, i) => {
const prefix = i === 0 ? '* ' : ' '; const prefix = i === 0 ? '* ' : ' ';
// Both modes: bold, and double-height on basic receipt
if (receiptData.isBasicReceipt) { if (receiptData.isBasicReceipt) {
cmds.push(...this.addLine(prefix + l, { bold: true, height: 2 })); cmds.push(...this.addLine(prefix + l, { bold: true, height: 2 }));
} else { } else {

View File

@ -723,8 +723,19 @@ patch(PosPrinterService.prototype, {
? order.getOrderlines() ? order.getOrderlines()
: (order.lines || []); : (order.lines || []);
const isBasic = !!this._currentPrintBasic;
const lines = orderlines const lines = orderlines
.filter(line => !line.combo_parent_id) // skip combo sub-lines .filter(line => {
if (line.combo_parent_id) return false; // skip combo sub-lines
// On basic receipt: mirror the XML filter — exclude reward lines and
// lines with negative price/qty (same as receipt_overrides.xml t-if)
if (isBasic) {
if (line.is_reward_line) return false;
if ((line.price_unit || 0) < 0) return false;
if ((line.qty || 0) < 0) return false;
}
return true;
})
.map(line => { .map(line => {
const qty = line.qty || 0; const qty = line.qty || 0;