fix: improve BLE transmission stability by adjusting chunk sizes and delays, and enhance receipt rendering with combo sub-line support and word-wrapped notes.

This commit is contained in:
Suherdy Yacob 2026-06-01 13:19:22 +07:00
parent 3a28e7ac08
commit 17e1b4cc60
3 changed files with 135 additions and 65 deletions

View File

@ -424,7 +424,7 @@ export class BluetoothPrinterManager {
* @throws {TransmissionError} If transmission fails * @throws {TransmissionError} If transmission fails
* @throws {PrinterBusyError} If printer is busy * @throws {PrinterBusyError} If printer is busy
*/ */
async sendData(escposData) { async sendData(escposData, isGraphics = false) {
if (!this.server || !this.server.connected) { if (!this.server || !this.server.connected) {
const error = new PrinterNotConnectedError(); const error = new PrinterNotConnectedError();
if (this.errorNotificationService) { if (this.errorNotificationService) {
@ -456,17 +456,18 @@ export class BluetoothPrinterManager {
this.isPrinting = true; this.isPrinting = true;
const startTime = performance.now(); const startTime = performance.now();
// OPTIMIZED: Use larger chunks for graphics data (faster transmission) // OPTIMIZED: Use safe chunks depending on transmission mode
// Graphics data can handle larger chunks than text commands // Large chunks (e.g. 512 bytes) overflow standard BLE serial buffers, truncating long receipts.
const isLargeData = escposData.length > 1000; // Graphics data is sent in highly compatible 128-byte chunks; text is sent in safe 20-byte chunks.
const chunkSize = isLargeData ? 512 : 20; // Much larger chunks for graphics const isGraphicsMode = isGraphics || escposData.length > 5000;
const chunkSize = isGraphicsMode ? 128 : 20;
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)`); console.log(`[Bluetooth] Sending ${chunks.length} chunks (${escposData.length} bytes, ${chunkSize} bytes/chunk, mode: ${isGraphicsMode ? 'graphics' : 'text'})`);
// Determine write method based on characteristic properties // Determine write method based on characteristic properties
const useWriteWithoutResponse = this.characteristic.properties.writeWithoutResponse; const useWriteWithoutResponse = this.characteristic.properties.writeWithoutResponse;
@ -476,10 +477,10 @@ export class BluetoothPrinterManager {
throw new Error('Characteristic does not support write operations'); throw new Error('Characteristic does not support write operations');
} }
// OPTIMIZED: Reduce delays for faster transmission // OPTIMIZED: Safe delays between BLE writes
const delay = isLargeData ? const delay = isGraphicsMode ?
(useWriteWithoutResponse ? 10 : 5) : // Much shorter delays for graphics (useWriteWithoutResponse ? 15 : 5) : // Delays for graphics chunks
(useWriteWithoutResponse ? 50 : 25); // Normal delays for text (useWriteWithoutResponse ? 20 : 10); // Delays for text chunks
// Send each chunk // Send each chunk
for (let i = 0; i < chunks.length; i++) { for (let i = 0; i < chunks.length; i++) {

View File

@ -272,13 +272,59 @@ export class EscPosGenerator {
cmds.push(...this.addLine(itemLine, { bold: true, height: 2 })); 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) { if (line.note) {
const noteText = `* ${line.note}`; const rawNote = String(line.note);
cmds.push(...this.addLine( const rawLines = rawNote.split('\n');
noteText.length > W ? noteText.substring(0, W - 1) + '.' : noteText, const wrappedLines = [];
{ bold: true } 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())); cmds.push(...this.addLine(this.divider()));

View File

@ -432,7 +432,7 @@ patch(PosPrinterService.prototype, {
// Send data to printer // Send data to printer
console.log('[BluetoothPrint] Sending graphics to printer...'); console.log('[BluetoothPrint] Sending graphics to printer...');
const startTime = performance.now(); const startTime = performance.now();
await bluetoothManager.sendData(escposData); await bluetoothManager.sendData(escposData, true);
const endTime = performance.now(); const endTime = performance.now();
console.log('[BluetoothPrint] Graphics print completed successfully in', (endTime - startTime).toFixed(0), 'ms'); console.log('[BluetoothPrint] Graphics print completed successfully in', (endTime - startTime).toFixed(0), 'ms');
@ -494,7 +494,7 @@ patch(PosPrinterService.prototype, {
// Send data to printer // Send data to printer
console.log('[BluetoothPrint] Sending text 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'); console.log('[BluetoothPrint] Text print completed successfully');
}, },
@ -534,7 +534,7 @@ patch(PosPrinterService.prototype, {
// Send data to printer // Send data to printer
console.log('[BluetoothPrint] Sending to printer...'); console.log('[BluetoothPrint] Sending to printer...');
await bluetoothManager.sendData(escposData); await bluetoothManager.sendData(escposData, false);
console.log('[BluetoothPrint] Print completed successfully'); console.log('[BluetoothPrint] Print completed successfully');
}, },
@ -753,12 +753,24 @@ patch(PosPrinterService.prototype, {
// Combine notes (customer note takes priority; show both if both exist) // Combine notes (customer note takes priority; show both if both exist)
const note = [customerNote, kitchenNote].filter(Boolean).join(' | '); 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 { return {
productName: line.full_product_name || line.product_id?.display_name || '', productName: line.full_product_name || line.product_id?.display_name || '',
quantity: qty, quantity: qty,
price: unitPrice, price: unitPrice,
total: lineTotal, total: lineTotal,
note, note,
comboLines: comboSubLines,
}; };
}) })
.filter(l => l.productName); .filter(l => l.productName);
@ -911,28 +923,27 @@ patch(PosPrinterService.prototype, {
console.log('[BluetoothPrint] Found', lineElements.length, 'line elements'); 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)); console.log(`[BluetoothPrint] Processing line ${index}:`, line.outerHTML.substring(0, 300));
// Odoo 19 orderline structure: // Check if this element represents a combo sub-line
// div.product-name const isComboSubLine = line.classList.contains('orderline-combo') ||
// span.qty ← quantity (e.g. "999") line.closest('.orderline-combo') ||
// span.text-wrap ← product name only line.querySelector('.orderline-combo');
// 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)
// Get qty from the dedicated .qty span // Get qty from the dedicated .qty span
const qtySpan = line.querySelector('.qty'); const qtySpan = line.querySelector('.qty');
const qtyText = qtySpan ? qtySpan.textContent.trim() : '1'; 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'); const nameSpan = line.querySelector('.product-name .text-wrap');
let productName = ''; let productName = '';
if (nameSpan) { if (nameSpan) {
productName = nameSpan.textContent.trim(); productName = nameSpan.textContent.trim();
} else { } else {
// Fallback: get .product-name text then strip the qty prefix
const productNameEl = line.querySelector('.product-name'); const productNameEl = line.querySelector('.product-name');
if (productNameEl) { if (productNameEl) {
const fullText = productNameEl.textContent.trim(); const fullText = productNameEl.textContent.trim();
@ -942,51 +953,63 @@ patch(PosPrinterService.prototype, {
: fullText; : fullText;
} }
} }
if (!productName) return;
// Line total: div.product-price.price contains the formatted currency string // Parse numbers helper
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)
const parseNumber = (str) => { const parseNumber = (str) => {
if (!str) return 0; if (!str) return 0;
const cleaned = str.replace(/[^0-9.-]/g, ''); const cleaned = str.replace(/[^0-9.-]/g, '');
return parseFloat(cleaned) || 0; return parseFloat(cleaned) || 0;
}; };
const qty = parseNumber(qtyText); const qty = parseNumber(qtyText);
const lineTotal = parseNumber(priceTotalText);
// Extract unit price from "price / UoM" string (take part before " / ") // Extract customer note/kitchen note from this line element
let unitPrice = 0; const noteEl = line.querySelector('.customer-note, .note, li.note, li.customer-note');
if (priceUnitText) { let note = noteEl ? noteEl.textContent.trim() : '';
const slashIdx = priceUnitText.lastIndexOf('/');
const priceStr = slashIdx > 0 ? priceUnitText.substring(0, slashIdx) : priceUnitText; if (isComboSubLine && lastParentLine) {
unitPrice = parseNumber(priceStr); // This is a combo subline, add it to the last parent line!
} else if (qty > 0 && lineTotal > 0) { if (!lastParentLine.comboLines) {
unitPrice = lineTotal / qty; lastParentLine.comboLines = [];
}
lastParentLine.comboLines.push({
productName,
quantity: qty,
});
console.log(`[BluetoothPrint] Added combo subline to parent:`, productName);
} else { } 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 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);
} }
});
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
console.log('[BluetoothPrint] Parsed', lines.length, 'lines from HTML'); console.log('[BluetoothPrint] Parsed', lines.length, 'lines from HTML');
console.log('[BluetoothPrint] All lines:', JSON.stringify(lines, null, 2)); console.log('[BluetoothPrint] All lines:', JSON.stringify(lines, null, 2));