From 17e1b4cc60593704b7aa0c3a1a5ca14ff6e8897c Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Mon, 1 Jun 2026 13:19:22 +0700 Subject: [PATCH] fix: improve BLE transmission stability by adjusting chunk sizes and delays, and enhance receipt rendering with combo sub-line support and word-wrapped notes. --- static/src/js/bluetooth_printer_manager.js | 21 ++-- static/src/js/escpos_generator.js | 58 +++++++++- static/src/js/pos_receipt_printer.js | 121 ++++++++++++--------- 3 files changed, 135 insertions(+), 65 deletions(-) diff --git a/static/src/js/bluetooth_printer_manager.js b/static/src/js/bluetooth_printer_manager.js index fb99a87..8e2d816 100755 --- a/static/src/js/bluetooth_printer_manager.js +++ b/static/src/js/bluetooth_printer_manager.js @@ -424,7 +424,7 @@ export class BluetoothPrinterManager { * @throws {TransmissionError} If transmission fails * @throws {PrinterBusyError} If printer is busy */ - async sendData(escposData) { + async sendData(escposData, isGraphics = false) { if (!this.server || !this.server.connected) { const error = new PrinterNotConnectedError(); if (this.errorNotificationService) { @@ -456,17 +456,18 @@ export class BluetoothPrinterManager { this.isPrinting = true; const startTime = performance.now(); - // OPTIMIZED: Use larger chunks for graphics data (faster transmission) - // Graphics data can handle larger chunks than text commands - const isLargeData = escposData.length > 1000; - const chunkSize = isLargeData ? 512 : 20; // Much larger chunks for graphics + // OPTIMIZED: Use safe chunks depending on transmission mode + // Large chunks (e.g. 512 bytes) overflow standard BLE serial buffers, truncating long receipts. + // Graphics data is sent in highly compatible 128-byte chunks; text is sent in safe 20-byte chunks. + const isGraphicsMode = isGraphics || escposData.length > 5000; + const chunkSize = isGraphicsMode ? 128 : 20; const chunks = []; for (let i = 0; i < escposData.length; i += chunkSize) { chunks.push(escposData.slice(i, i + chunkSize)); } - console.log(`[Bluetooth] Sending ${chunks.length} chunks (${escposData.length} bytes, ${chunkSize} bytes/chunk)`); + console.log(`[Bluetooth] Sending ${chunks.length} chunks (${escposData.length} bytes, ${chunkSize} bytes/chunk, mode: ${isGraphicsMode ? 'graphics' : 'text'})`); // Determine write method based on characteristic properties const useWriteWithoutResponse = this.characteristic.properties.writeWithoutResponse; @@ -476,10 +477,10 @@ export class BluetoothPrinterManager { throw new Error('Characteristic does not support write operations'); } - // OPTIMIZED: Reduce delays for faster transmission - const delay = isLargeData ? - (useWriteWithoutResponse ? 10 : 5) : // Much shorter delays for graphics - (useWriteWithoutResponse ? 50 : 25); // Normal delays for text + // OPTIMIZED: Safe delays between BLE writes + const delay = isGraphicsMode ? + (useWriteWithoutResponse ? 15 : 5) : // Delays for graphics chunks + (useWriteWithoutResponse ? 20 : 10); // Delays for text chunks // Send each chunk for (let i = 0; i < chunks.length; i++) { diff --git a/static/src/js/escpos_generator.js b/static/src/js/escpos_generator.js index 95e1355..14c339a 100755 --- a/static/src/js/escpos_generator.js +++ b/static/src/js/escpos_generator.js @@ -272,13 +272,59 @@ export class EscPosGenerator { cmds.push(...this.addLine(itemLine, { bold: true, height: 2 })); } - // Note line (customer note / kitchen note) — shown on both modes + // Combo sub-lines detail printing (shown on both basic/checker and full receipt modes) + if (line.comboLines && line.comboLines.length > 0) { + line.comboLines.forEach(sub => { + const subName = String(sub.productName || ''); + const subQty = sub.quantity || 0; + const subQtyStr = subQty % 1 === 0 ? subQty.toFixed(0) : subQty.toFixed(2); + + // Format: " - 1x Mie Goreng" + const prefix = ` - ${subQtyStr}x `; + const maxSubLen = W - prefix.length; + + const displayName = subName.length > maxSubLen + ? subName.substring(0, maxSubLen - 1) + '.' + : subName; + cmds.push(...this.addLine(prefix + displayName, { bold: true })); + }); + } + + // Note line (customer note / kitchen note) — word-wrapped to prevent truncation if (line.note) { - const noteText = `* ${line.note}`; - cmds.push(...this.addLine( - noteText.length > W ? noteText.substring(0, W - 1) + '.' : noteText, - { bold: true } - )); + const rawNote = String(line.note); + const rawLines = rawNote.split('\n'); + const wrappedLines = []; + const maxNoteWidth = W - 2; + + rawLines.forEach(rawLine => { + const words = rawLine.split(/\s+/); + let currentLine = ''; + for (const word of words) { + if (!word) continue; + if ((currentLine + (currentLine ? ' ' : '') + word).length <= maxNoteWidth) { + currentLine += (currentLine ? ' ' : '') + word; + } else { + if (currentLine) { + wrappedLines.push(currentLine); + } + let tempWord = word; + while (tempWord.length > maxNoteWidth) { + wrappedLines.push(tempWord.substring(0, maxNoteWidth)); + tempWord = tempWord.substring(maxNoteWidth); + } + currentLine = tempWord; + } + } + if (currentLine) { + wrappedLines.push(currentLine); + } + }); + + wrappedLines.forEach((l, i) => { + const prefix = i === 0 ? '* ' : ' '; + cmds.push(...this.addLine(prefix + l, { bold: true })); + }); } }); cmds.push(...this.addLine(this.divider())); diff --git a/static/src/js/pos_receipt_printer.js b/static/src/js/pos_receipt_printer.js index 29bdd52..fb78127 100755 --- a/static/src/js/pos_receipt_printer.js +++ b/static/src/js/pos_receipt_printer.js @@ -432,7 +432,7 @@ patch(PosPrinterService.prototype, { // Send data to printer console.log('[BluetoothPrint] Sending graphics to printer...'); const startTime = performance.now(); - await bluetoothManager.sendData(escposData); + await bluetoothManager.sendData(escposData, true); const endTime = performance.now(); console.log('[BluetoothPrint] Graphics print completed successfully in', (endTime - startTime).toFixed(0), 'ms'); @@ -494,7 +494,7 @@ patch(PosPrinterService.prototype, { // Send data to printer console.log('[BluetoothPrint] Sending text to printer...'); - await bluetoothManager.sendData(escposData); + await bluetoothManager.sendData(escposData, false); console.log('[BluetoothPrint] Text print completed successfully'); }, @@ -534,7 +534,7 @@ patch(PosPrinterService.prototype, { // Send data to printer console.log('[BluetoothPrint] Sending to printer...'); - await bluetoothManager.sendData(escposData); + await bluetoothManager.sendData(escposData, false); console.log('[BluetoothPrint] Print completed successfully'); }, @@ -753,12 +753,24 @@ patch(PosPrinterService.prototype, { // Combine notes (customer note takes priority; show both if both exist) const note = [customerNote, kitchenNote].filter(Boolean).join(' | '); + // Find combo sub-lines linked to this line + const comboSubLines = orderlines + .filter(subLine => subLine.combo_parent_id === line || subLine.combo_parent_id?.cid === line.cid || (line.id && subLine.combo_parent_id?.id === line.id)) + .map(subLine => { + return { + productName: subLine.full_product_name || subLine.product_id?.display_name || '', + quantity: subLine.qty || 0, + }; + }) + .filter(sub => sub.productName); + return { productName: line.full_product_name || line.product_id?.display_name || '', quantity: qty, price: unitPrice, total: lineTotal, note, + comboLines: comboSubLines, }; }) .filter(l => l.productName); @@ -911,28 +923,27 @@ patch(PosPrinterService.prototype, { console.log('[BluetoothPrint] Found', lineElements.length, 'line elements'); - const lines = lineElements.map((line, index) => { + const lines = []; + let lastParentLine = null; + + lineElements.forEach((line, index) => { console.log(`[BluetoothPrint] Processing line ${index}:`, line.outerHTML.substring(0, 300)); - // Odoo 19 orderline structure: - // div.product-name - // span.qty ← quantity (e.g. "999") - // span.text-wrap ← product name only - // div.product-price.price ← line total (formatted currency, e.g. "Rp 0.00") - // ul.info-list - // li.price-per-unit ← "unit_price / UoM" (only shown when qty != 1) - + // Check if this element represents a combo sub-line + const isComboSubLine = line.classList.contains('orderline-combo') || + line.closest('.orderline-combo') || + line.querySelector('.orderline-combo'); + // Get qty from the dedicated .qty span const qtySpan = line.querySelector('.qty'); const qtyText = qtySpan ? qtySpan.textContent.trim() : '1'; - // Get product name from span.text-wrap (excludes the qty span) + // Get product name const nameSpan = line.querySelector('.product-name .text-wrap'); let productName = ''; if (nameSpan) { productName = nameSpan.textContent.trim(); } else { - // Fallback: get .product-name text then strip the qty prefix const productNameEl = line.querySelector('.product-name'); if (productNameEl) { const fullText = productNameEl.textContent.trim(); @@ -942,51 +953,63 @@ patch(PosPrinterService.prototype, { : fullText; } } + if (!productName) return; - // Line total: div.product-price.price contains the formatted currency string - const priceTotalEl = line.querySelector('.product-price.price, .product-price'); - const priceTotalText = priceTotalEl ? priceTotalEl.textContent.trim() : '0'; - - // Unit price: li.price-per-unit contains "unit_price / UoM" - // Only shown when qty != 1; format: "Rp 1,234.56 / Unit" - const priceUnitEl = line.querySelector('.price-per-unit'); - const priceUnitText = priceUnitEl ? priceUnitEl.textContent.trim() : ''; - - console.log(`[BluetoothPrint] Line ${index} raw data:`, { productName, qtyText, priceTotalText, priceUnitText }); - - // Parse numbers (remove currency symbols and commas, keep digits/dot/minus) + // Parse numbers helper const parseNumber = (str) => { if (!str) return 0; const cleaned = str.replace(/[^0-9.-]/g, ''); return parseFloat(cleaned) || 0; }; - const qty = parseNumber(qtyText); - const lineTotal = parseNumber(priceTotalText); - // Extract unit price from "price / UoM" string (take part before " / ") - let unitPrice = 0; - if (priceUnitText) { - const slashIdx = priceUnitText.lastIndexOf('/'); - const priceStr = slashIdx > 0 ? priceUnitText.substring(0, slashIdx) : priceUnitText; - unitPrice = parseNumber(priceStr); - } else if (qty > 0 && lineTotal > 0) { - unitPrice = lineTotal / qty; + // Extract customer note/kitchen note from this line element + const noteEl = line.querySelector('.customer-note, .note, li.note, li.customer-note'); + let note = noteEl ? noteEl.textContent.trim() : ''; + + if (isComboSubLine && lastParentLine) { + // This is a combo subline, add it to the last parent line! + if (!lastParentLine.comboLines) { + lastParentLine.comboLines = []; + } + lastParentLine.comboLines.push({ + productName, + quantity: qty, + }); + console.log(`[BluetoothPrint] Added combo subline to parent:`, productName); } else { - unitPrice = lineTotal; - } + // This is a normal parent line + const priceTotalEl = line.querySelector('.product-price.price, .product-price'); + const priceTotalText = priceTotalEl ? priceTotalEl.textContent.trim() : '0'; - const parsedLine = { - productName: productName, - quantity: qty, - price: unitPrice, - total: lineTotal - }; - - console.log(`[BluetoothPrint] Line ${index} parsed:`, parsedLine); - - return parsedLine; - }).filter(line => line.productName); // Filter out empty lines + const priceUnitEl = line.querySelector('.price-per-unit'); + const priceUnitText = priceUnitEl ? priceUnitEl.textContent.trim() : ''; + + const lineTotal = parseNumber(priceTotalText); + let unitPrice = 0; + if (priceUnitText) { + const slashIdx = priceUnitText.lastIndexOf('/'); + const priceStr = slashIdx > 0 ? priceUnitText.substring(0, slashIdx) : priceUnitText; + unitPrice = parseNumber(priceStr); + } else if (qty > 0 && lineTotal > 0) { + unitPrice = lineTotal / qty; + } else { + unitPrice = lineTotal; + } + + const parsedLine = { + productName, + quantity: qty, + price: unitPrice, + total: lineTotal, + note, + comboLines: [], + }; + lines.push(parsedLine); + lastParentLine = parsedLine; + console.log(`[BluetoothPrint] Added parent line:`, parsedLine); + } + }); console.log('[BluetoothPrint] Parsed', lines.length, 'lines from HTML'); console.log('[BluetoothPrint] All lines:', JSON.stringify(lines, null, 2));