From df7938dfe4a0f6bc1fd3b097a2cacc34f6840626 Mon Sep 17 00:00:00 2001 From: Alexis de Lattre Date: Thu, 10 Jul 2014 20:39:07 +0200 Subject: [PATCH] Add module hw_telium_payment_terminal. --- hw_telium_payment_terminal/__init__.py | 24 ++ hw_telium_payment_terminal/__openerp__.py | 56 ++++ .../controllers/__init__.py | 24 ++ .../controllers/main.py | 277 ++++++++++++++++++ 4 files changed, 381 insertions(+) create mode 100644 hw_telium_payment_terminal/__init__.py create mode 100644 hw_telium_payment_terminal/__openerp__.py create mode 100644 hw_telium_payment_terminal/controllers/__init__.py create mode 100644 hw_telium_payment_terminal/controllers/main.py 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..2ca39fc6 --- /dev/null +++ b/hw_telium_payment_terminal/__openerp__.py @@ -0,0 +1,56 @@ +# -*- 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 Payment Terminals 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) +* payment_terminal_device_timeout (default = 2 seconds) + +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. It has been tested a an Ingenico EFTSmart4S terminal with Telim Manager version 37784503. The protocol E is implemented (we may implement the protocol E+ in the future). + +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 ! + +Please contact Alexis de Lattre from Akretion for any help or question about this module. + """, + 'author': 'Akretion', + 'website': 'http://www.akretion.com', + 'depends': ['hw_proxy'], + 'external_dependencies': { + 'python' : ['serial'], + }, + 'data': [], + 'active': False, +} 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..31936292 --- /dev/null +++ b/hw_telium_payment_terminal/controllers/main.py @@ -0,0 +1,277 @@ +# -*- 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 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 + data = { + 'pos_number': str(1).zfill(2), + 'answer_flag': '0', + 'transaction_type': '0', + 'payment_mode': payment_mode, + 'currency_numeric': + payment_info_dict['currency_iso_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') + answer_data = 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)