diff --git a/.travis.yml b/.travis.yml index 96ed724c..9cec74e1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,6 +17,7 @@ install: - export PATH=${HOME}/maintainer-quality-tools/travis:${PATH} - travis_install_nightly - printf '[options]\n\nrunning_env = dev' > ${HOME}/.openerp_serverrc + - pip install unidecode pyserial pycountry script: - travis_run_tests diff --git a/hw_customer_display/__init__.py b/hw_customer_display/__init__.py new file mode 100644 index 00000000..0914de44 --- /dev/null +++ b/hw_customer_display/__init__.py @@ -0,0 +1,24 @@ +# -*- encoding: utf-8 -*- +############################################################################## +# +# Hardware Customer Display module for Odoo +# Copyright (C) 2014 Akretion (http://www.akretion.com) +# @author Alexis de Lattre +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + + +from . import controllers diff --git a/hw_customer_display/__openerp__.py b/hw_customer_display/__openerp__.py new file mode 100644 index 00000000..848f70fd --- /dev/null +++ b/hw_customer_display/__openerp__.py @@ -0,0 +1,85 @@ +# -*- encoding: utf-8 -*- +############################################################################## +# +# Hardware Customer Display module for Odoo +# Copyright (C) 2014 Akretion (http://www.akretion.com) +# @author Alexis de Lattre +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + + +{ + 'name': 'Hardware Customer Display', + 'version': '0.1', + 'category': 'Hardware Drivers', + 'license': 'AGPL-3', + 'summary': 'Adds support for Customer Display in the Point of Sale', + 'description': """ +Hardware Customer Display +========================= + +This module adds support for Customer Display in the Point of Sale. +This module is designed to be installed on the *POSbox* (i.e. the +proxy on which the USB devices are connected) and not on the main +Odoo server. On the main Odoo server, you should install the module +*pos_customer_display*. + +The configuration of the hardware is done in the configuration file of +the Odoo server of the POSbox. You should add the following entries in +the configuration file: + +* customer_display_device_name (default = /dev/ttyUSB0) +* customer_display_device_rate (default = 9600) +* customer_display_device_timeout (default = 2 seconds) + +The number of cols of the Customer Display (usually 20) should be +configured on the main Odoo server, in the menu Point of Sale > +Configuration > Point of Sales. The number of rows is supposed to be 2. + +It should support most serial and USB-serial LCD displays out-of-the-box +or with inheritance of a few functions. + +It has been tested with: + +* Bixolon BCD-1100 (Datasheet : + http://www.bixolon.com/html/en/product/product_detail.xhtml?prod_id=61) +* Bixolon BCD-1000 + +To setup the BCD-1100 on Linux, you will find some technical instructions +on this page: +http://techtuxwords.blogspot.fr/2012/12/linux-and-bixolon-bcd-1100.html + +If you have a kernel >= 3.12, you should also read this: +http://www.leniwiec.org/en/2014/06/25/ubuntu-14-04lts-how-to-pass-id-ven +dor-and-id-product-to-ftdi_sio-driver/ + +This module has been developped during a POS code sprint at Akretion +France from July 7th to July 10th 2014. This module is part of the POS +project of the Odoo Community Association http://odoo-community.org/. +You are invited to become a member and/or get involved in the +Association ! + +This module has been written by Alexis de Lattre from Akretion +. + """, + 'author': 'Akretion', + 'website': 'http://www.akretion.com', + 'depends': ['hw_proxy'], + 'external_dependencies': { + 'python': ['serial', 'unidecode'], + }, + 'data': [], +} diff --git a/hw_customer_display/controllers/__init__.py b/hw_customer_display/controllers/__init__.py new file mode 100644 index 00000000..7ae94c9e --- /dev/null +++ b/hw_customer_display/controllers/__init__.py @@ -0,0 +1,24 @@ +# -*- encoding: utf-8 -*- +############################################################################## +# +# Hardware Customer Display module for Odoo +# Copyright (C) 2014 Akretion (http://www.akretion.com) +# @author Alexis de Lattre +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + + +from . import main diff --git a/hw_customer_display/controllers/main.py b/hw_customer_display/controllers/main.py new file mode 100644 index 00000000..6d40289b --- /dev/null +++ b/hw_customer_display/controllers/main.py @@ -0,0 +1,178 @@ +# -*- encoding: utf-8 -*- +############################################################################## +# +# Hardware Customer Display module for Odoo +# Copyright (C) 2014 Akretion (http://www.akretion.com) +# @author Alexis de Lattre +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + + +import logging +import simplejson +import time +from threading import Thread, Lock +from Queue import Queue +from unidecode import unidecode +from serial import Serial +import openerp.addons.hw_proxy.controllers.main as hw_proxy +from openerp import http +from openerp.tools.config import config + + +logger = logging.getLogger(__name__) + + +class CustomerDisplayDriver(Thread): + def __init__(self): + Thread.__init__(self) + self.queue = Queue() + self.lock = Lock() + self.status = {'status': 'connecting', 'messages': []} + self.device_name = config.get( + 'customer_display_device_name', '/dev/ttyUSB0') + self.device_rate = int(config.get( + 'customer_display_device_rate', 9600)) + self.device_timeout = int(config.get( + 'customer_display_device_timeout', 2)) + self.serial = False + + def get_status(self): + self.push_task('status') + return self.status + + def set_status(self, status, message=None): + if status == self.status['status']: + if message is not None and message != self.status['messages'][-1]: + self.status['messages'].append(message) + else: + self.status['status'] = status + if message: + self.status['messages'] = [message] + else: + self.status['messages'] = [] + + if status == 'error' and message: + logger.error('Display Error: '+message) + elif status == 'disconnected' and message: + logger.warning('Disconnected Display: '+message) + + def lockedstart(self): + with self.lock: + if not self.isAlive(): + self.daemon = True + self.start() + + def push_task(self, task, data=None): + self.lockedstart() + self.queue.put((time.time(), task, data)) + + def move_cursor(self, col, row): + # Bixolon spec : 11. "Move Cursor to Specified Position" + self.cmd_serial_write('\x1B\x6C' + chr(col) + chr(row)) + + def display_text(self, lines): + logger.debug( + "Preparing to send the following lines to LCD: %s" % lines) + # We don't check the number of rows/cols here, because it has already + # been checked in the POS client in the JS code + lines_ascii = [] + for line in lines: + lines_ascii.append(unidecode(line)) + row = 0 + for dline in lines_ascii: + row += 1 + self.move_cursor(1, row) + self.serial_write(dline) + + def setup_customer_display(self): + '''Set LCD cursor to off + If your LCD has different setup instruction(s), you should + inherit this function''' + # Bixolon spec : 35. "Set Cursor On/Off" + self.cmd_serial_write('\x1F\x43\x00') + logger.debug('LCD cursor set to off') + + def clear_customer_display(self): + '''If your LCD has different clearing instruction, you should inherit + this function''' + # Bixolon spec : 12. "Clear Display Screen and Clear String Mode" + self.cmd_serial_write('\x0C') + logger.debug('Customer display cleared') + + def cmd_serial_write(self, command): + '''If your LCD requires a prefix and/or suffix on all commands, + you should inherit this function''' + assert isinstance(command, str), 'command must be a string' + self.serial_write(command) + + def serial_write(self, text): + assert isinstance(text, str), 'text must be a string' + self.serial.write(text) + + def send_text_customer_display(self, text_to_display): + '''This function sends the data to the serial/usb port. + We open and close the serial connection on every message display. + Why ? + 1. Because it is not a problem for the customer display + 2. Because it is not a problem for performance, according to my tests + 3. Because it allows recovery on errors : you can unplug/replug the + customer display and it will work again on the next message without + problem + ''' + lines = simplejson.loads(text_to_display) + assert isinstance(lines, list), 'lines_list should be a list' + try: + logger.debug( + 'Opening serial port %s for customer display with baudrate %d' + % (self.device_name, self.device_rate)) + self.serial = Serial( + self.device_name, self.device_rate, + timeout=self.device_timeout) + logger.debug('serial.is_open = %s' % self.serial.isOpen()) + self.setup_customer_display() + self.clear_customer_display() + self.display_text(lines) + except Exception, e: + logger.error('Exception in serial connection: %s' % str(e)) + finally: + if self.serial: + logger.debug('Closing serial port for customer display') + self.serial.close() + + def run(self): + while True: + try: + timestamp, task, data = self.queue.get(True) + if task == 'display': + self.send_text_customer_display(data) + elif task == 'status': + pass + except Exception as e: + self.set_status('error', str(e)) + +driver = CustomerDisplayDriver() + +hw_proxy.drivers['customer_display'] = driver + + +class CustomerDisplayProxy(hw_proxy.Proxy): + @http.route( + '/hw_proxy/send_text_customer_display', type='json', auth='none', + cors='*') + def send_text_customer_display(self, text_to_display): + logger.debug('LCD: Call send_text_customer_display') + driver.push_task('display', text_to_display) diff --git a/hw_customer_display/test-scripts/customer-display-test.py b/hw_customer_display/test-scripts/customer-display-test.py new file mode 100755 index 00000000..ad3a50d4 --- /dev/null +++ b/hw_customer_display/test-scripts/customer-display-test.py @@ -0,0 +1,68 @@ +#! /usr/bin/python +# -*- encoding: utf-8 -*- +# Author : Alexis de Lattre +# The licence is in the file __openerp__.py +# This is a test script, that you can use if you want to test/play +# with the customer display independantly from the Odoo server +# It has been tested with a Bixolon BCD-1100 + +from serial import Serial +from unidecode import unidecode +import sys + +DEVICE = '/dev/ttyUSB0' +DEVICE_RATE = 9600 +DEVICE_COLS = 20 + + +def display_text(ser, line1, line2): + print "convert to ascii" + line1 = unidecode(line1) + line2 = unidecode(line2) + print "set lines to the right lenght (%s)" % DEVICE_COLS + for line in [line1, line2]: + if len(line) < DEVICE_COLS: + line += ' ' * (DEVICE_COLS - len(line)) + elif len(line) > DEVICE_COLS: + line = line[0:DEVICE_COLS] + assert len(line) == DEVICE_COLS, 'Wrong length' + print "try to clear display" + ser.write('\x0C') + print "clear done" + print "try to position at start of 1st line" + ser.write('\x1B\x6C' + chr(1) + chr(1)) + print "position done" + print "try to write 1st line" + ser.write(line1) + print "write 1st line done" + print "try to position at start of 2nd line" + ser.write('\x1B\x6C' + chr(1) + chr(2)) + print "position done" + print "try to write 2nd line" + ser.write(line2) + print "write done" + + +def open_close_display(line1, line2): + ser = False + try: + print "open serial port" + ser = Serial(DEVICE, DEVICE_RATE, timeout=2) + print "serial port open =", ser.isOpen() + print "try to set cursor to off" + ser.write('\x1F\x43\x00') + print "cursor set to off" + display_text(ser, line1, line2) + except Exception, e: + print "EXCEPTION e=", e + sys.exit(1) + finally: + if ser: + print "close serial port" + ser.close() + + +if __name__ == '__main__': + line1 = u'POS Code Sprint' + line2 = u'@ Akretion 2014/07' + open_close_display(line1, line2) diff --git a/hw_telium_payment_terminal/__init__.py b/hw_telium_payment_terminal/__init__.py new file mode 100644 index 00000000..c535b703 --- /dev/null +++ b/hw_telium_payment_terminal/__init__.py @@ -0,0 +1,24 @@ +# -*- encoding: utf-8 -*- +############################################################################## +# +# Hardware Telium Payment Terminal module for Odoo +# Copyright (C) 2014 Akretion (http://www.akretion.com) +# @author Alexis de Lattre +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + + +from . import controllers diff --git a/hw_telium_payment_terminal/__openerp__.py b/hw_telium_payment_terminal/__openerp__.py new file mode 100644 index 00000000..ca408d52 --- /dev/null +++ b/hw_telium_payment_terminal/__openerp__.py @@ -0,0 +1,83 @@ +# -*- encoding: utf-8 -*- +############################################################################## +# +# Hardware Telium Payment Terminal module for Odoo +# Copyright (C) 2014 Akretion (http://www.akretion.com) +# @author Alexis de Lattre +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + + +{ + 'name': 'Hardware Telium Payment Terminal', + 'version': '0.1', + 'category': 'Hardware Drivers', + 'license': 'AGPL-3', + 'summary': 'Adds support for Payment Terminals using Telium protocol', + 'description': """ +Hardware Telium Payment Terminal +================================ + +This module adds support for credit card reader and checks printers +using Telium protocol in the Point of Sale. This module is designed to +be installed on the *POSbox* (i.e. the proxy on which the USB devices +are connected) and not on the main Odoo server. On the main Odoo server, +you should install the module *pos_payment_terminal*. + +The configuration of the hardware is done in the configuration file of +the Odoo server of the POSbox. You should add the following entries in +the configuration file: + +* payment_terminal_device_name (default = /dev/ttyACM0) +* payment_terminal_device_rate (default = 9600) + +The Telium protocol is used by Ingenico and Sagem payment terminals. It +is based on the Concert protocol, so it can probably work with payment +terminals from other brands. This module implements the protocol E+ (and +not the protocol E), so it requires a Telium Manager version 37783600 +or superior. To get the version of the Telium Manager on an Ingenico +terminal press F > 0-TELIUM MANAGER > 2-Consultation > 4-Configuration +> 2-Software > 1-TERMINAL > On Display > Telium Manager and then read +the field *M20S*. + +You will need to configure your payment terminal to accept commands +from the POS. On an Ingenico terminal press F > 0-TELIUM MANAGER > +5-Initialization > 1-Parameters > Cash Connection and then select *On* +and then *USB*. After that, you should reboot the terminal. + +This module has been successfully tested with: + +* Ingenico EFTSmart4S +* Ingenico EFTSmart2 2640 with Telim Manager version 37784503 +* Ingenico i2200 cheque reader and writer + +This module has been developped during a POS code sprint at Akretion +France from July 7th to July 10th 2014. This module is part of the POS +project of the Odoo Community Association http://odoo-community.org/. +You are invited to become a member and/or get involved in the +Association ! + +This module has been written by Alexis de Lattre + from Akretion. + """, + 'author': 'Akretion', + 'website': 'http://www.akretion.com', + 'depends': ['hw_proxy'], + 'external_dependencies': { + 'python': ['serial', 'pycountry'], + }, + 'data': [], +} diff --git a/hw_telium_payment_terminal/controllers/__init__.py b/hw_telium_payment_terminal/controllers/__init__.py new file mode 100644 index 00000000..d91efc38 --- /dev/null +++ b/hw_telium_payment_terminal/controllers/__init__.py @@ -0,0 +1,24 @@ +# -*- encoding: utf-8 -*- +############################################################################## +# +# Hardware Telium Payment Terminal module for Odoo +# Copyright (C) 2014 Akretion (http://www.akretion.com) +# @author Alexis de Lattre +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + + +from . import main diff --git a/hw_telium_payment_terminal/controllers/main.py b/hw_telium_payment_terminal/controllers/main.py new file mode 100644 index 00000000..3dbd1b7f --- /dev/null +++ b/hw_telium_payment_terminal/controllers/main.py @@ -0,0 +1,284 @@ +# -*- encoding: utf-8 -*- +############################################################################## +# +# Hardware Telium Payment Terminal module for Odoo +# Copyright (C) 2014 Akretion (http://www.akretion.com) +# @author Alexis de Lattre +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + + +import logging +import simplejson +import time +import curses.ascii +from threading import Thread, Lock +from Queue import Queue +from serial import Serial +import pycountry +import openerp.addons.hw_proxy.controllers.main as hw_proxy +from openerp import http +from openerp.tools.config import config + + +logger = logging.getLogger(__name__) + + +class TeliumPaymentTerminalDriver(Thread): + def __init__(self): + Thread.__init__(self) + self.queue = Queue() + self.lock = Lock() + self.status = {'status': 'connecting', 'messages': []} + self.device_name = config.get( + 'telium_terminal_device_name', '/dev/ttyACM0') + self.device_rate = int(config.get( + 'telium_terminal_device_rate', 9600)) + self.serial = False + + def get_status(self): + self.push_task('status') + return self.status + + def set_status(self, status, message=None): + if status == self.status['status']: + if message is not None and message != self.status['messages'][-1]: + self.status['messages'].append(message) + else: + self.status['status'] = status + if message: + self.status['messages'] = [message] + else: + self.status['messages'] = [] + + if status == 'error' and message: + logger.error('Payment Terminal Error: '+message) + elif status == 'disconnected' and message: + logger.warning('Disconnected Terminal: '+message) + + def lockedstart(self): + with self.lock: + if not self.isAlive(): + self.daemon = True + self.start() + + def push_task(self, task, data=None): + self.lockedstart() + self.queue.put((time.time(), task, data)) + + def serial_write(self, text): + assert isinstance(text, str), 'text must be a string' + self.serial.write(text) + + def initialize_msg(self): + max_attempt = 3 + attempt_nr = 0 + while attempt_nr < max_attempt: + attempt_nr += 1 + self.send_one_byte_signal('ENQ') + if self.get_one_byte_answer('ACK'): + return True + else: + logger.warning("Terminal : SAME PLAYER TRY AGAIN") + self.send_one_byte_signal('EOT') + # Wait 1 sec between each attempt + time.sleep(1) + return False + + def send_one_byte_signal(self, signal): + ascii_names = curses.ascii.controlnames + assert signal in ascii_names, 'Wrong signal' + char = ascii_names.index(signal) + self.serial_write(chr(char)) + logger.debug('Signal %s sent to terminal' % signal) + + def get_one_byte_answer(self, expected_signal): + ascii_names = curses.ascii.controlnames + one_byte_read = self.serial.read(1) + expected_char = ascii_names.index(expected_signal) + if one_byte_read == chr(expected_char): + logger.debug("%s received from terminal" % expected_signal) + return True + else: + return False + + def prepare_data_to_send(self, payment_info_dict): + amount = payment_info_dict['amount'] + if payment_info_dict['payment_mode'] == 'check': + payment_mode = 'C' + elif payment_info_dict['payment_mode'] == 'card': + payment_mode = '1' + else: + logger.error( + "The payment mode '%s' is not supported" + % payment_info_dict['payment_mode']) + return False + cur_iso_letter = payment_info_dict['currency_iso'].upper() + try: + cur = pycountry.currencies.get(letter=cur_iso_letter) + cur_numeric = str(cur.numeric) + except: + logger.error("Currency %s is not recognized" % cur_iso_letter) + return False + data = { + 'pos_number': str(1).zfill(2), + 'answer_flag': '0', + 'transaction_type': '0', + 'payment_mode': payment_mode, + 'currency_numeric': cur_numeric.zfill(3), + 'private': ' ' * 10, + 'delay': 'A011', + 'auto': 'B010', + 'amount_msg': ('%.0f' % (amount * 100)).zfill(8), + } + return data + + def generate_lrc(self, real_msg_with_etx): + lrc = 0 + for char in real_msg_with_etx: + lrc ^= ord(char) + return lrc + + def send_message(self, data): + '''We use protocol E+''' + ascii_names = curses.ascii.controlnames + real_msg = ( + data['pos_number'] + + data['amount_msg'] + + data['answer_flag'] + + data['payment_mode'] + + data['transaction_type'] + + data['currency_numeric'] + + data['private'] + + data['delay'] + + data['auto'] + ) + logger.debug('Real message to send = %s' % real_msg) + assert len(real_msg) == 34, 'Wrong length for protocol E+' + real_msg_with_etx = real_msg + chr(ascii_names.index('ETX')) + lrc = self.generate_lrc(real_msg_with_etx) + message = chr(ascii_names.index('STX')) + real_msg_with_etx + chr(lrc) + self.serial_write(message) + logger.info('Message sent to terminal') + + def compare_data_vs_answer(self, data, answer_data): + for field in [ + 'pos_number', 'amount_msg', + 'currency_numeric', 'private']: + if data[field] != answer_data[field]: + logger.warning( + "Field %s has value '%s' in data and value '%s' in answer" + % (field, data[field], answer_data[field])) + + def parse_terminal_answer(self, real_msg, data): + answer_data = { + 'pos_number': real_msg[0:2], + 'transaction_result': real_msg[2], + 'amount_msg': real_msg[3:11], + 'payment_mode': real_msg[11], + 'currency_numeric': real_msg[12:15], + 'private': real_msg[15:26], + } + logger.debug('answer_data = %s' % answer_data) + self.compare_data_vs_answer(data, answer_data) + return answer_data + + def get_answer_from_terminal(self, data): + ascii_names = curses.ascii.controlnames + full_msg_size = 1+2+1+8+1+3+10+1+1 + msg = self.serial.read(size=full_msg_size) + logger.debug('%d bytes read from terminal' % full_msg_size) + assert len(msg) == full_msg_size, 'Answer has a wrong size' + if msg[0] != chr(ascii_names.index('STX')): + logger.error( + 'The first byte of the answer from terminal should be STX') + if msg[-2] != chr(ascii_names.index('ETX')): + logger.error( + 'The byte before final of the answer from terminal ' + 'should be ETX') + lrc = msg[-1] + computed_lrc = chr(self.generate_lrc(msg[1:-1])) + if computed_lrc != lrc: + logger.error( + 'The LRC of the answer from terminal is wrong') + real_msg = msg[1:-2] + logger.debug('Real answer received = %s' % real_msg) + return self.parse_terminal_answer(real_msg, data) + + def transaction_start(self, payment_info): + '''This function sends the data to the serial/usb port. + ''' + payment_info_dict = simplejson.loads(payment_info) + assert isinstance(payment_info_dict, dict), \ + 'payment_info_dict should be a dict' + logger.debug("payment_info_dict = %s" % payment_info_dict) + try: + logger.debug( + 'Opening serial port %s for payment terminal with baudrate %d' + % (self.device_name, self.device_rate)) + # IMPORTANT : don't modify timeout=3 seconds + # This parameter is very important ; the Telium spec say + # that we have to wait to up 3 seconds to get LRC + self.serial = Serial( + self.device_name, self.device_rate, + timeout=3) + logger.debug('serial.is_open = %s' % self.serial.isOpen()) + if self.initialize_msg(): + data = self.prepare_data_to_send(payment_info_dict) + if not data: + return + self.send_message(data) + if self.get_one_byte_answer('ACK'): + self.send_one_byte_signal('EOT') + + logger.info("Now expecting answer from Terminal") + if self.get_one_byte_answer('ENQ'): + self.send_one_byte_signal('ACK') + self.get_answer_from_terminal(data) + self.send_one_byte_signal('ACK') + if self.get_one_byte_answer('EOT'): + logger.info("Answer received from Terminal") + + except Exception, e: + logger.error('Exception in serial connection: %s' % str(e)) + finally: + if self.serial: + logger.debug('Closing serial port for payment terminal') + self.serial.close() + + def run(self): + while True: + try: + timestamp, task, data = self.queue.get(True) + if task == 'transaction_start': + self.transaction_start(data) + elif task == 'status': + pass + except Exception as e: + self.set_status('error', str(e)) + +driver = TeliumPaymentTerminalDriver() + +hw_proxy.drivers['telium_payment_terminal'] = driver + + +class TeliumPaymentTerminalProxy(hw_proxy.Proxy): + @http.route( + '/hw_proxy/payment_terminal_transaction_start', + type='json', auth='none', cors='*') + def payment_terminal_transaction_start(self, payment_info): + logger.debug('Telium: Call payment_terminal_transaction_start') + driver.push_task('transaction_start', payment_info) diff --git a/hw_telium_payment_terminal/test-scripts/telium-test.py b/hw_telium_payment_terminal/test-scripts/telium-test.py new file mode 100755 index 00000000..67245558 --- /dev/null +++ b/hw_telium_payment_terminal/test-scripts/telium-test.py @@ -0,0 +1,219 @@ +#! /usr/bin/python +# -*- encoding: utf-8 -*- +############################################################################## +# +# Hardware Telium Test script +# Copyright (C) 2014 Akretion (http://www.akretion.com) +# @author Alexis de Lattre +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + + +from serial import Serial +import curses.ascii +import time +import pycountry + + +DEVICE = '/dev/ttyACM0' +DEVICE_RATE = 9600 +PAYMENT_MODE = 'card' # 'card' ou 'check' +CURRENCY_ISO = 'EUR' +AMOUNT = 12.42 + + +def serial_write(serial, text): + assert isinstance(text, str), 'text must be a string' + serial.write(text) + + +def initialize_msg(serial): + max_attempt = 3 + attempt_nr = 0 + while attempt_nr < max_attempt: + attempt_nr += 1 + send_one_byte_signal(serial, 'ENQ') + if get_one_byte_answer(serial, 'ACK'): + return True + else: + print "Terminal : SAME PLAYER TRY AGAIN" + send_one_byte_signal(serial, 'EOT') + # Wait 1 sec between each attempt + time.sleep(1) + return False + + +def send_one_byte_signal(serial, signal): + ascii_names = curses.ascii.controlnames + assert signal in ascii_names, 'Wrong signal' + char = ascii_names.index(signal) + serial_write(serial, chr(char)) + print 'Signal %s sent to terminal' % signal + + +def get_one_byte_answer(serial, expected_signal): + ascii_names = curses.ascii.controlnames + one_byte_read = serial.read(1) + expected_char = ascii_names.index(expected_signal) + if one_byte_read == chr(expected_char): + print "%s received from terminal" % expected_signal + return True + else: + return False + + +def prepare_data_to_send(): + if PAYMENT_MODE == 'check': + payment_mode = 'C' + elif PAYMENT_MODE == 'card': + payment_mode = '1' + else: + print "The payment mode '%s' is not supported" % PAYMENT_MODE + return False + cur_iso_letter = CURRENCY_ISO.upper() + try: + cur = pycountry.currencies.get(letter=cur_iso_letter) + cur_numeric = str(cur.numeric) + except: + print "Currency %s is not recognized" % cur_iso_letter + return False + data = { + 'pos_number': str(1).zfill(2), + 'answer_flag': '0', + 'transaction_type': '0', + 'payment_mode': payment_mode, + 'currency_numeric': cur_numeric.zfill(3), + 'private': ' ' * 10, + 'delay': 'A011', + 'auto': 'B010', + 'amount_msg': ('%.0f' % (AMOUNT * 100)).zfill(8), + } + return data + + +def generate_lrc(real_msg_with_etx): + lrc = 0 + for char in real_msg_with_etx: + lrc ^= ord(char) + return lrc + + +def send_message(serial, data): + '''We use protocol E+''' + ascii_names = curses.ascii.controlnames + real_msg = ( + data['pos_number'] + + data['amount_msg'] + + data['answer_flag'] + + data['payment_mode'] + + data['transaction_type'] + + data['currency_numeric'] + + data['private'] + + data['delay'] + + data['auto'] + ) + print 'Real message to send = %s' % real_msg + assert len(real_msg) == 34, 'Wrong length for protocol E+' + real_msg_with_etx = real_msg + chr(ascii_names.index('ETX')) + lrc = generate_lrc(real_msg_with_etx) + message = chr(ascii_names.index('STX')) + real_msg_with_etx + chr(lrc) + serial_write(serial, message) + print 'Message sent to terminal' + + +def compare_data_vs_answer(data, answer_data): + for field in [ + 'pos_number', 'amount_msg', + 'currency_numeric', 'private']: + if data[field] != answer_data[field]: + print ( + "Field %s has value '%s' in data and value '%s' in answer" + % (field, data[field], answer_data[field])) + + +def parse_terminal_answer(real_msg, data): + answer_data = { + 'pos_number': real_msg[0:2], + 'transaction_result': real_msg[2], + 'amount_msg': real_msg[3:11], + 'payment_mode': real_msg[11], + 'currency_numeric': real_msg[12:15], + 'private': real_msg[15:26], + } + print 'answer_data = %s' % answer_data + compare_data_vs_answer(data, answer_data) + return answer_data + + +def get_answer_from_terminal(serial, data): + ascii_names = curses.ascii.controlnames + full_msg_size = 1+2+1+8+1+3+10+1+1 + msg = serial.read(size=full_msg_size) + print '%d bytes read from terminal' % full_msg_size + assert len(msg) == full_msg_size, 'Answer has a wrong size' + if msg[0] != chr(ascii_names.index('STX')): + print 'The first byte of the answer from terminal should be STX' + if msg[-2] != chr(ascii_names.index('ETX')): + print 'The byte before final of the answer from terminal should be ETX' + lrc = msg[-1] + computed_lrc = chr(generate_lrc(msg[1:-1])) + if computed_lrc != lrc: + print 'The LRC of the answer from terminal is wrong' + real_msg = msg[1:-2] + print 'Real answer received = %s' % real_msg + return parse_terminal_answer(real_msg, data) + + +def transaction_start(): + '''This function sends the data to the serial/usb port. + ''' + serial = False + try: + print( + 'Opening serial port %s for payment terminal with ' + 'baudrate %d' % (DEVICE, DEVICE_RATE)) + # IMPORTANT : don't modify timeout=3 seconds + # This parameter is very important ; the Telium spec say + # that we have to wait to up 3 seconds to get LRC + serial = Serial( + DEVICE, DEVICE_RATE, timeout=3) + print 'serial.is_open = %s' % serial.isOpen() + if initialize_msg(serial): + data = prepare_data_to_send() + if not data: + return + send_message(serial, data) + if get_one_byte_answer(serial, 'ACK'): + send_one_byte_signal(serial, 'EOT') + + print "Now expecting answer from Terminal" + if get_one_byte_answer(serial, 'ENQ'): + send_one_byte_signal(serial, 'ACK') + get_answer_from_terminal(serial, data) + send_one_byte_signal(serial, 'ACK') + if get_one_byte_answer(serial, 'EOT'): + print "Answer received from Terminal" + + except Exception, e: + print 'Exception in serial connection: %s' % str(e) + finally: + if serial: + print 'Closing serial port for payment terminal' + serial.close() + + +if __name__ == '__main__': + transaction_start() diff --git a/pos_customer_display/__init__.py b/pos_customer_display/__init__.py new file mode 100644 index 00000000..bb27ef75 --- /dev/null +++ b/pos_customer_display/__init__.py @@ -0,0 +1 @@ +from . import pos_customer_display diff --git a/pos_customer_display/__openerp__.py b/pos_customer_display/__openerp__.py new file mode 100644 index 00000000..584f3059 --- /dev/null +++ b/pos_customer_display/__openerp__.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# POS Customer Display module for Odoo +# Copyright (C) 2014 Aurélien DUMAINE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + + +{ + 'name': 'POS Customer Display', + 'version': '0.1', + 'category': 'Point Of Sale', + 'summary': 'Manage Customer Display device from POS front end', + 'description': """ +POS Customer Display +==================== + +This module adds support for Customer Display in the Point of Sale. This +module is designed to be installed on the *main Odoo server*. On the +*POSbox*, you should install the module *hw_customer_display*. + +The number of rows and cols of the Customer Display (usually 2 x 20) +should be configured on the main Odoo server, in the menu Point of Sale +> Configuration > Point of Sales. + +It has been tested with a Bixolon BCD-1100 +(http://www.bixolon.com/html/en/product/product_detail.xhtml?prod_id=61), +but should support most serial and USB-serial LCD displays +out-of-the-box, cf the module *hw_customer_display* for more info. + +This module has been developped during a POS code sprint at Akretion +France from July 7th to July 10th 2014. This module is part of the POS +project of the Odoo Community Association http://odoo-community.org/. +You are invited to become a member and/or get involved in the +Association ! + """, + 'author': 'Aurélien DUMAINE', + 'depends': ['point_of_sale'], + 'data': [ + 'pos_customer_display.xml', + 'customer_display_view.xml', + ], +} diff --git a/pos_customer_display/customer_display_view.xml b/pos_customer_display/customer_display_view.xml new file mode 100644 index 00000000..80b238a6 --- /dev/null +++ b/pos_customer_display/customer_display_view.xml @@ -0,0 +1,16 @@ + + + + + pos.config.form.view.inherit + pos.config + + + + + + + + + + diff --git a/pos_customer_display/i18n/es.po b/pos_customer_display/i18n/es.po new file mode 100644 index 00000000..fcb58692 --- /dev/null +++ b/pos_customer_display/i18n/es.po @@ -0,0 +1,87 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * pos_customer_display +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 8.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2014-11-17 11:00+0000\n" +"PO-Revision-Date: 2014-11-17 18:33+0100\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: \n" +"Language: es\n" +"X-Generator: Poedit 1.6.10\n" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:56 +#, python-format +msgid "Cancel Payment" +msgstr "Pago Cancelado" + +#. module: pos_customer_display +#: field:pos.config,iface_customer_display:0 +msgid "Customer display" +msgstr "Visor cliente" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:41 +#, python-format +msgid "Delete Item" +msgstr "Producto Eliminado" + +#. module: pos_customer_display +#: help:pos.config,iface_customer_display:0 +msgid "Display data on the customer display" +msgstr "Mostrar información en el visor de cliente" + +#. module: pos_customer_display +#: help:pos.config,customer_display_line_length:0 +msgid "Length of the LEDs lines of the customer display" +msgstr "Longitud de las líneas LED en el visor de cliente" + +#. module: pos_customer_display +#: field:pos.config,customer_display_line_length:0 +msgid "Line length" +msgstr "Longitud de línea " + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:69 +#, python-format +msgid "Next Customer" +msgstr "Próximo Cliente" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:81 +#, python-format +msgid "Point of Sale Closed" +msgstr "Punto Venta Cerrado" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:75 +#, python-format +msgid "Point of Sale Open" +msgstr "Punto Venta Abierto" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:48 +#, python-format +msgid "TOTAL: " +msgstr "TOTAL: " + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:63 +#, python-format +msgid "Your Change:" +msgstr "Su Cambio:" diff --git a/pos_customer_display/i18n/fr.po b/pos_customer_display/i18n/fr.po new file mode 100644 index 00000000..d5332a88 --- /dev/null +++ b/pos_customer_display/i18n/fr.po @@ -0,0 +1,86 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * pos_customer_display +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 8.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2014-10-24 13:45+0000\n" +"PO-Revision-Date: 2014-10-24 13:45+0000\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:56 +#, python-format +msgid "Cancel Payment" +msgstr "Paiement annulé" + +#. module: pos_customer_display +#: field:pos.config,iface_customer_display:0 +msgid "Customer display" +msgstr "Afficheur client" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:41 +#, python-format +msgid "Delete Item" +msgstr "Article supprimé" + +#. module: pos_customer_display +#: help:pos.config,iface_customer_display:0 +msgid "Display data on the customer display" +msgstr "Utiliser l'afficheur client" + +#. module: pos_customer_display +#: help:pos.config,customer_display_line_length:0 +msgid "Length of the LEDs lines of the customer display" +msgstr "Longueur des lignes de l'afficheur client: nombre de caractères" + +#. module: pos_customer_display +#: field:pos.config,customer_display_line_length:0 +msgid "Line length" +msgstr "Longueur des lignes" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:69 +#, python-format +msgid "Next Customer" +msgstr "Client suivant" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:81 +#, python-format +msgid "Point of Sale Closed" +msgstr "Caisse fermée" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:75 +#, python-format +msgid "Point of Sale Open" +msgstr "Caisse ouverte" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:48 +#, python-format +msgid "TOTAL: " +msgstr "TOTAL : " + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:63 +#, python-format +msgid "Your Change:" +msgstr "Monnaie à rendre :" + diff --git a/pos_customer_display/i18n/pos_customer_display.pot b/pos_customer_display/i18n/pos_customer_display.pot new file mode 100644 index 00000000..fa783681 --- /dev/null +++ b/pos_customer_display/i18n/pos_customer_display.pot @@ -0,0 +1,86 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * pos_customer_display +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 8.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2014-10-24 13:44+0000\n" +"PO-Revision-Date: 2014-10-24 13:44+0000\n" +"Last-Translator: <>\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:56 +#, python-format +msgid "Cancel Payment" +msgstr "" + +#. module: pos_customer_display +#: field:pos.config,iface_customer_display:0 +msgid "Customer display" +msgstr "" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:41 +#, python-format +msgid "Delete Item" +msgstr "" + +#. module: pos_customer_display +#: help:pos.config,iface_customer_display:0 +msgid "Display data on the customer display" +msgstr "" + +#. module: pos_customer_display +#: help:pos.config,customer_display_line_length:0 +msgid "Length of the LEDs lines of the customer display" +msgstr "" + +#. module: pos_customer_display +#: field:pos.config,customer_display_line_length:0 +msgid "Line length" +msgstr "" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:69 +#, python-format +msgid "Next Customer" +msgstr "" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:81 +#, python-format +msgid "Point of Sale Closed" +msgstr "" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:75 +#, python-format +msgid "Point of Sale Open" +msgstr "" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:48 +#, python-format +msgid "TOTAL: " +msgstr "" + +#. module: pos_customer_display +#. openerp-web +#: code:addons/pos_customer_display/static/src/js/customer_display.js:63 +#, python-format +msgid "Your Change:" +msgstr "" + diff --git a/pos_customer_display/pos_customer_display.py b/pos_customer_display/pos_customer_display.py new file mode 100644 index 00000000..d2bcb43e --- /dev/null +++ b/pos_customer_display/pos_customer_display.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# POS Customer Display module for Odoo +# Copyright (C) 2014 Aurélien DUMAINE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +import logging +from openerp.osv import fields, orm + +_logger = logging.getLogger(__name__) + + +class pos_config(orm.Model): + _name = 'pos.config' + _inherit = 'pos.config' + + _columns = { + 'iface_customer_display': fields.boolean( + 'Customer display', help="Display data on the customer display"), + 'customer_display_line_length': fields.integer( + 'Line length', + help="Length of the LEDs lines of the customer display"), + } + + _defaults = { + 'customer_display_line_length': 20, + } + +# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: diff --git a/pos_customer_display/pos_customer_display.xml b/pos_customer_display/pos_customer_display.xml new file mode 100644 index 00000000..3aaa679e --- /dev/null +++ b/pos_customer_display/pos_customer_display.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/pos_customer_display/static/src/js/customer_display.js b/pos_customer_display/static/src/js/customer_display.js new file mode 100755 index 00000000..fd404ebb --- /dev/null +++ b/pos_customer_display/static/src/js/customer_display.js @@ -0,0 +1,229 @@ +/* + POS Customer display module for Odoo + Copyright (C) 2014 Aurélien DUMAINE + Copyright (C) 2014 Barroux Abbey (www.barroux.org) + @author: Aurélien DUMAINE + @author: Alexis de Lattre + @author: Father Odilon (Barroux Abbey) + The licence is in the file __openerp__.py +*/ + +openerp.pos_customer_display = function(instance){ + module = instance.point_of_sale; + + var _t = instance.web._t; + + + module.PosModel = module.PosModel.extend({ + prepare_text_customer_display: function(type, data){ + if (this.config.iface_customer_display != true) + return; + var line_length = this.config.customer_display_line_length || 20; + var currency_rounding = Math.ceil(Math.log(1.0 / this.currency.rounding) / Math.log(10)); + + if (type == 'addProduct'){ + // in order to not recompute qty in options..., we assume that the new ordeLine is the last of the collection + // addOrderline exists but is not called by addProduct, should we handle it ? + var line = this.get('selectedOrder').getLastOrderline(); + var price_unit = line.get_unit_price() * (1.0 - (line.get_discount() / 100.0)); + price_unit = price_unit.toFixed(currency_rounding); + var l21 = line.get_quantity_str_with_unit() + ' x ' + price_unit; + var l22 = ' ' + line.get_display_price().toFixed(currency_rounding); + var lines_to_send = new Array( + this.proxy.align_left(line.get_product().display_name, line_length), + this.proxy.align_left(l21, line_length - l22.length) + l22 + ); + + } else if (type == 'removeOrderline') { + // first click on the backspace button set the amount to 0 => we can't precise the deleted qunatity and price + var line = data['line']; + var lines_to_send = new Array( + this.proxy.align_left(_t("Delete Item"), line_length), + this.proxy.align_right(line.get_product().display_name, line_length) + ); + + } else if (type == 'addPaymentline') { + var total = this.get('selectedOrder').getTotalTaxIncluded().toFixed(currency_rounding); + var lines_to_send = new Array( + this.proxy.align_left(_t("TOTAL: "), line_length), + this.proxy.align_right(total, line_length) + ); + + } else if (type == 'removePaymentline') { + var line = data['line']; + var amount = line.get_amount().toFixed(currency_rounding); + var lines_to_send = new Array( + this.proxy.align_left(_t("Cancel Payment"), line_length), + this.proxy.align_right(line.cashregister.journal_id[1] , line_length - 1 - amount.length) + ' ' + amount + ); + + } else if (type == 'update_payment') { + var change = data['change']; + var lines_to_send = new Array( + this.proxy.align_left(_t("Your Change:"), line_length), + this.proxy.align_right(change, line_length) + ); + + } else if (type == 'pushOrder') { + var lines_to_send = new Array( + this.proxy.align_center(_t("Next Customer"), line_length), + this.proxy.align_left(' ', line_length) + ); + + } else if (type == 'openPOS') { + var lines_to_send = new Array( + this.proxy.align_center(_t("Point of Sale Open"), line_length), + this.proxy.align_left(' ', line_length) + ); + + } else if (type = 'closePOS') { + var lines_to_send = new Array( + this.proxy.align_center(_t("Point of Sale Closed"), line_length), + this.proxy.align_left(' ', line_length) + ); + } else { + console.warn('Unknown message type'); + return; + } + + this.proxy.send_text_customer_display(lines_to_send, line_length); + //console.log('prepare_text_customer_display type=' + type + ' | l1=' + lines_to_send[0] + ' | l2=' + lines_to_send[1]); + }, + + }); + + + module.ProxyDevice = module.ProxyDevice.extend({ + send_text_customer_display: function(data, line_length){ + //FIXME : this function is call twice. The first time, it is not called by prepare_text_customer_display : WHY ? + if (_.isEmpty(data) || data.length != 2 || data[0].length != line_length || data[1].length != line_length){ + console.warn("send_text_customer_display: Bad Data argument. Data=" + data + ' line_length=' + line_length); + } else { +// alert(JSON.stringify(data)); + return this.message('send_text_customer_display', {'text_to_display' : JSON.stringify(data)}); + } + }, + + align_left: function(string, length){ + if (string) { + if (string.length > length) + { + return string.substring(0,length); + } + else if (string.length < length) + { + while(string.length < length) + string = string + ' '; + return string; + } + } + return string; + }, + + align_right: function(string, length){ + if (string) { + if (string.length > length) + { + return string.substring(0,length); + } + else if (string.length < length) + { + while(string.length < length) + string = ' ' + string; + return string; + } + } + return string; + }, + + align_center: function(string, length){ + if (string) { + if (string.length > length) + { + return string.substring(0, length); + } + else if (string.length < length) + { + ini = (length - string.length) / 2; + while(string.length < length - ini) + string = ' ' + string; + while(string.length < length) + string = string + ' '; + return string; + } + } + return string; + }, + }); + + var _super_addProduct_ = module.Order.prototype.addProduct; + module.Order.prototype.addProduct = function(product, options){ + res = _super_addProduct_.call(this, product, options); + if (product) { + this.pos.prepare_text_customer_display('addProduct', {'product' : product, 'options' : options}); + } + return res; + }; + + var _super_removeOrderline_ = module.Order.prototype.removeOrderline; + module.Order.prototype.removeOrderline = function(line){ + if (line) { + this.pos.prepare_text_customer_display('removeOrderline', {'line' : line}); + } + return _super_removeOrderline_.call(this, line); + }; + + var _super_removePaymentline_ = module.Order.prototype.removePaymentline; + module.Order.prototype.removePaymentline = function(line){ + if (line) { + this.pos.prepare_text_customer_display('removePaymentline', {'line' : line}); + } + return _super_removePaymentline_.call(this, line); + }; + + var _super_addPaymentline_ = module.Order.prototype.addPaymentline; + module.Order.prototype.addPaymentline = function(cashregister){ + res = _super_addPaymentline_.call(this, cashregister); + if (cashregister) { + this.pos.prepare_text_customer_display('addPaymentline', {'cashregister' : cashregister}); + } + return res; + }; + + var _super_pushOrder_ = module.PosModel.prototype.push_order; + module.PosModel.prototype.push_order = function(order){ + res = _super_pushOrder_.call(this, order); + if (order) { + this.prepare_text_customer_display('pushOrder', {'order' : order}); + } + return res; + }; + + var _super_update_payment_summary_ = module.PaymentScreenWidget.prototype.update_payment_summary; + module.PaymentScreenWidget.prototype.update_payment_summary = function(){ + res = _super_update_payment_summary_.call(this); + var currentOrder = this.pos.get('selectedOrder'); + var paidTotal = currentOrder.getPaidTotal(); + var dueTotal = currentOrder.getTotalTaxIncluded(); + var change = paidTotal > dueTotal ? paidTotal - dueTotal : 0; + if (change) { + change_rounded = change.toFixed(2); + this.pos.prepare_text_customer_display('update_payment', {'change': change_rounded}); + } + return res; + }; + + var _super_closePOS_ = module.PosWidget.prototype.close; + module.PosWidget.prototype.close = function(){ + this.pos.prepare_text_customer_display('closePOS', {}); + return _super_closePOS_.call(this); + }; + + var _super_proxy_start_ = module.ProxyStatusWidget.prototype.start; + module.ProxyStatusWidget.prototype.start = function(){ + res = _super_proxy_start_.call(this); + this.pos.prepare_text_customer_display('openPOS', {}); + return res; + }; + +}; diff --git a/pos_payment_terminal/__init__.py b/pos_payment_terminal/__init__.py new file mode 100644 index 00000000..9e67b3d9 --- /dev/null +++ b/pos_payment_terminal/__init__.py @@ -0,0 +1 @@ +from . import pos_payment_terminal diff --git a/pos_payment_terminal/__openerp__.py b/pos_payment_terminal/__openerp__.py new file mode 100644 index 00000000..ab46d83e --- /dev/null +++ b/pos_payment_terminal/__openerp__.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# POS Payment Terminal module for Odoo +# Copyright (C) 2014 Aurélien DUMAINE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + + +{ + 'name': 'POS Payment Terminal', + 'version': '0.1', + 'category': 'Point Of Sale', + 'summary': 'Manage Payment Terminal device from POS front end', + 'description': """ +POS Payment Terminal +==================== + +This module adds support for credit card reader and checks printer +in the Point of Sale. This module is designed to be installed on the +*main Odoo server*. On the *POSbox*, you should install the module +*hw_x* depending on the protocol implemented in your device. Ingenico +and Sagem devices support the Telium protocol implemented in the +*hw_telium_payment_terminal* module. + +This module support two payment methods : cards and checks. The payment +method should be configured on the main Odoo server, in the menu Point +of Sale > Configuration > Payment Methods. + +This module has been developped during a POS code sprint at Akretion +France from July 7th to July 10th 2014. This module is part of the POS +project of the Odoo Community Association http://odoo-community.org/. +You are invited to become a member and/or get involved in the +Association ! + """, + 'author': 'Aurélien DUMAINE', + 'depends': ['point_of_sale'], + 'data': [ + 'pos_payment_terminal.xml', + 'pos_payment_terminal_view.xml', + ], + 'qweb': ['static/src/xml/pos_payment_terminal.xml'], +} diff --git a/pos_payment_terminal/pos_payment_terminal.py b/pos_payment_terminal/pos_payment_terminal.py new file mode 100644 index 00000000..a54252ad --- /dev/null +++ b/pos_payment_terminal/pos_payment_terminal.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# POS Payment Terminal module for Odoo +# Copyright (C) 2014 Aurélien DUMAINE +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +from openerp import models, fields + + +class account_journal(models.Model): + _inherit = 'account.journal' + + payment_mode = fields.Selection( + (('card', 'Card'), ('check', 'Check')), 'Payment mode', + help="Select the payment mode sent to the payment terminal") + + +class pos_config(models.Model): + _inherit = 'pos.config' + + iface_payment_terminal = fields.Boolean( + 'Payment Terminal', + help="A payment terminal is available on the Proxy") diff --git a/pos_payment_terminal/pos_payment_terminal.xml b/pos_payment_terminal/pos_payment_terminal.xml new file mode 100644 index 00000000..6f46ad01 --- /dev/null +++ b/pos_payment_terminal/pos_payment_terminal.xml @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/pos_payment_terminal/pos_payment_terminal_view.xml b/pos_payment_terminal/pos_payment_terminal_view.xml new file mode 100644 index 00000000..f04d4648 --- /dev/null +++ b/pos_payment_terminal/pos_payment_terminal_view.xml @@ -0,0 +1,28 @@ + + + + + + pos.payment.terminal.config.form + pos.config + + + + + + + + + + pos.payment.terminal.journal.form + account.journal + + + + + + + + + + diff --git a/pos_payment_terminal/static/src/css/pos_payment_terminal.css b/pos_payment_terminal/static/src/css/pos_payment_terminal.css new file mode 100644 index 00000000..bfec68ef --- /dev/null +++ b/pos_payment_terminal/static/src/css/pos_payment_terminal.css @@ -0,0 +1,12 @@ +.pos .payment-terminal-transaction-start button { + width: 150px; + height: 60px; + font-size: 18px; + cursor: pointer; + text-align:center; + box-sizing: border-box; + -moz-box-sizing: border-box; + left: 105%; + bottom: 10px; + position: absolute; +} diff --git a/pos_payment_terminal/static/src/js/pos_payment_terminal.js b/pos_payment_terminal/static/src/js/pos_payment_terminal.js new file mode 100755 index 00000000..f63f6f3e --- /dev/null +++ b/pos_payment_terminal/static/src/js/pos_payment_terminal.js @@ -0,0 +1,50 @@ +openerp.pos_payment_terminal = function(instance){ + module = instance.point_of_sale; + + module.ProxyDevice = module.ProxyDevice.extend({ + payment_terminal_transaction_start: function(line, currency_iso){ + var data = {'amount' : line.get_amount(), + 'currency_iso' : currency_iso, + 'payment_mode' : line.cashregister.journal.payment_mode}; +// alert(JSON.stringify(data)); + this.message('payment_terminal_transaction_start', {'payment_info' : JSON.stringify(data)}); + }, + }); + + //TODO make the button bigger and with better name + + var _super_PaymentScreenWidget_init_ = module.PaymentScreenWidget.prototype.init; + module.PaymentScreenWidget.prototype.init = function(parent, options){ + _super_PaymentScreenWidget_init_.call(this, parent, options); + var self = this; + this.payment_terminal_transaction_start = function(event){ + var node = this; + while (node && !node.classList.contains('paymentline')){ + node = node.parentNode; + } + if (node && !_.isEmpty(node.line) && self.pos.config.iface_payment_terminal){ + self.pos.proxy.payment_terminal_transaction_start(node.line, self.pos.currency.name); + } + event.stopPropagation(); + }; + }; + + var _super_renderPaymentline_ = module.PaymentScreenWidget.prototype.render_paymentline; + module.PaymentScreenWidget.prototype.render_paymentline = function(line){ + var el_node = _super_renderPaymentline_.call(this, line); + if (line.cashregister.journal.payment_mode && this.pos.config.iface_payment_terminal){ + if (!this.pos.currency.name){ + var self = this; + var currencies = new instance.web.Model('res.currency').query(['name']) + .filter([['id','=',this.pos.currency.id]]) + .all().then(function (currency) { + self.pos.currency.name = currency[0].name; + }); + } + el_node.querySelector('.payment-terminal-transaction-start') + .addEventListener('click', this.payment_terminal_transaction_start); + } + return el_node; + }; + +}; diff --git a/pos_payment_terminal/static/src/xml/pos_payment_terminal.xml b/pos_payment_terminal/static/src/xml/pos_payment_terminal.xml new file mode 100644 index 00000000..ce7459c4 --- /dev/null +++ b/pos_payment_terminal/static/src/xml/pos_payment_terminal.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + +