first commit

This commit is contained in:
Suherdy Yacob 2026-03-21 13:22:54 +07:00
commit 035dfe3dab
9 changed files with 237 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.pyc
__pycache__/

15
README.md Normal file
View File

@ -0,0 +1,15 @@
# POS Kitchen ESC/POS Network Printer
This module adds support for generic network ESC/POS thermal printers in the Point of Sale Kitchen Display / Printing configurations.
## Features
- Add basic raw network printing over IP using port 9100 without relying on IoT box or EPSON-only support.
- Configurable per-printer IP address directly under Point of Sale -> Printers settings.
- Formats standard kitchen receipt containing products, quantity, and internal notes (without prices).
- Replaces/upgrades the POS ticket screen's existing hidden reprint functionality with highly visible 'Reprint Kitchen Checker' buttons.
## Setup
- Go to POS Configuration -> Printers
- Add a new "Network ESC/POS Printer".
- Fill in the appropriate local IP address (e.g. `192.168.1.100`) and the POS categories the printer should handle.
- Set your POS configuration to route kitchen printing to this new printer as usual.

1
__init__.py Normal file
View File

@ -0,0 +1 @@
from . import models

17
__manifest__.py Normal file
View File

@ -0,0 +1,17 @@
{
'name': 'POS Kitchen ESC/POS Network Printer',
'version': '1.0',
'category': 'Sales/Point of Sale',
'summary': 'Support generic network (IP) ESC/POS printers for kitchen receipts',
'depends': ['point_of_sale'],
'data': [
'views/pos_printer_views.xml',
],
'assets': {
'point_of_sale._assets_pos': [
'pos_kitchen_printer/static/src/**/*',
],
},
'installable': True,
'license': 'LGPL-3',
}

1
models/__init__.py Normal file
View File

@ -0,0 +1 @@
from . import pos_printer

99
models/pos_printer.py Normal file
View File

@ -0,0 +1,99 @@
import socket
import logging
from odoo import models, fields, api, _
_logger = logging.getLogger(__name__)
class PosPrinter(models.Model):
_inherit = 'pos.printer'
printer_type = fields.Selection(
selection_add=[('network_escpos', 'Network ESC/POS Printer')],
ondelete={'network_escpos': 'set default'}
)
network_printer_ip = fields.Char(
string='Network Printer IP Address',
help="IP address of the generic ESC/POS network printer.",
default="0.0.0.0"
)
@api.model
def _load_pos_data_fields(self, config_id):
fields = super()._load_pos_data_fields(config_id)
if 'network_printer_ip' not in fields:
fields.append('network_printer_ip')
return fields
@api.model
def print_network_kitchen_receipt(self, printer_id, receipt_data):
"""
Receives structural receipt_data from POS frontend and sends ESC/POS string to network printer.
receipt_data expected format:
{
'order_name': 'Order 0001',
'order_time': '2023-10-01 12:00:00',
'lines': [ { 'name': 'Burger', 'qty': 1, 'note': 'No onions' } ],
'waiter': 'Mitchell Admin',
'table': 'T1'
}
"""
printer = self.browse(printer_id)
if not printer or not printer.network_printer_ip:
return {'successful': False, 'message': 'Printer IP not configured'}
# Build basic ESC/POS commands
ESC = b'\x1b'
GS = b'\x1d'
INIT = ESC + b'@'
CUT = GS + b'V\x41\x00'
BOLD_ON = ESC + b'E\x01'
BOLD_OFF = ESC + b'E\x00'
SIZE_DOUBLE = GS + b'!\x11'
SIZE_NORMAL = GS + b'!\x00'
ALIGN_CENTER = ESC + b'a\x01'
ALIGN_LEFT = ESC + b'a\x00'
NEWLINE = b'\n'
data = INIT
data += ALIGN_CENTER + SIZE_DOUBLE + BOLD_ON
data += b"KITCHEN RECEIPT" + NEWLINE
data += SIZE_NORMAL + BOLD_OFF + NEWLINE
if receipt_data.get('table'):
data += ALIGN_LEFT + SIZE_DOUBLE
data += f"Table: {receipt_data['table']}".encode('utf-8') + NEWLINE
data += SIZE_NORMAL
data += ALIGN_LEFT
data += f"Order: {receipt_data.get('order_name', '')}".encode('utf-8') + NEWLINE
data += f"Date: {receipt_data.get('order_time', '')}".encode('utf-8') + NEWLINE
if receipt_data.get('waiter'):
data += f"Waiter: {receipt_data['waiter']}".encode('utf-8') + NEWLINE
data += b"-" * 32 + NEWLINE
for line in receipt_data.get('lines', []):
qty = line.get('qty', 1)
name = line.get('name', '')
note = line.get('note', '')
data += SIZE_DOUBLE
data += f"{qty} x {name}".encode('utf-8') + NEWLINE
data += SIZE_NORMAL
if note:
data += f" NOTE: {note}".encode('utf-8') + NEWLINE
data += NEWLINE
data += b"-" * 32 + NEWLINE + NEWLINE + NEWLINE + CUT
# Send to printer
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(3.0)
s.connect((printer.network_printer_ip, 9100))
s.sendall(data)
s.close()
return {'successful': True}
except Exception as e:
_logger.error("Failed to print to network ESC/POS printer %s: %s", printer.network_printer_ip, e)
return {'successful': False, 'message': str(e)}

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<!-- Add Reprint Kitchen Button to Synced Orders (Right Pane underneath details) -->
<t t-name="pos_kitchen_printer.TicketScreen" t-inherit="point_of_sale.TicketScreen" t-inherit-mode="extension">
<xpath expr="//button[contains(@class, 'edit-order-payment')]" position="after">
<button t-if="this.pos.unwatched.printers.length"
class="control-button btn btn-secondary btn-lg lh-lg flex-grow-1 flex-shrink-1"
t-on-click="() => this.onClickReprintAll(_selectedSyncedOrder)">
<i class="fa fa-cutlery me-1" /> Reprint Kitchen Checker
</button>
</xpath>
<!-- Enhance the existing cutlery button for unsynced orders -->
<xpath expr="//button[contains(@t-on-click, 'onClickReprintAll') and contains(@class, 'd-flex')]" position="replace">
<button class="button btn btn-secondary btn-lg d-flex flex-row align-items-center justify-content-center flex-grow-1"
t-on-click="() => this.onClickReprintAll(_selectedSyncedOrder)"
t-if="this.pos.unwatched.printers.length and _selectedSyncedOrder.uiState.lastPrints.length">
<i class="fa fa-cutlery me-1" aria-hidden="true"/> Reprint Kitchen Checker
</button>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,67 @@
/** @odoo-module */
import { patch } from "@web/core/utils/patch";
import { PosStore } from "@point_of_sale/app/services/pos_store";
patch(PosStore.prototype, {
createPrinter(config) {
if (config.printer_type === "network_escpos") {
return {
config: config,
printReceipt: async (receipt, actionId) => {
// This dummy promise ensures the printer interface is fulfilled.
// The actual sending to backend is hooked in `printOrderChanges`.
return true;
}
};
}
return super.createPrinter(...arguments);
},
async printOrderChanges(data, printer) {
if (printer.config && printer.config.printer_type === "network_escpos") {
const changesData = data.changes?.data || [];
// Build the data object to send to backend
const lines = changesData.map(c => ({
name: c.name,
qty: c.qty,
note: c.note || ''
}));
const receipt_data = {
order_name: data.name || '',
order_time: data.time?.raw || new Date().toISOString(),
waiter: data.cashier || '',
table: data.table_name || '',
lines: lines
};
try {
const result = await this.env.services.orm.call(
'pos.printer',
'print_network_kitchen_receipt',
[printer.config.id, receipt_data]
);
if (!result.successful) {
this.env.services.notification.add(
result.message || "Network printer error",
{ type: "danger" }
);
return false;
}
return true;
} catch (error) {
console.error("Error printing to network printer", error);
this.env.services.notification.add(
"Error printing to network printer",
{ type: "danger" }
);
return false;
}
}
return super.printOrderChanges(data, printer);
}
});

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_pos_printer_form_inherit_network_escpos" model="ir.ui.view">
<field name="name">pos.printer.form.inherit.network.escpos</field>
<field name="model">pos.printer</field>
<field name="inherit_id" ref="point_of_sale.view_pos_printer_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='epson_printer_ip']" position="after">
<field name="network_printer_ip" invisible="printer_type != 'network_escpos'" required="printer_type == 'network_escpos'"/>
</xpath>
</field>
</record>
</odoo>