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 {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++) {

View File

@ -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()));

View File

@ -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));