├── MANIFEST.in ├── README.rst ├── VERSION ├── debian ├── changelog ├── compat ├── control ├── copyright ├── python-pypostelium.install └── source │ └── format ├── pypostelium ├── __init__.py └── pypostelium.py ├── requirement.txt └── setup.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include VERSION 3 | include requirement.txt 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Hardware Telium Payment Terminal 2 | ================================ 3 | 4 | This module adds support for credit card reader and checks printers 5 | using the **Caisse Concert** protocol. This module is designed to 6 | be installed: 7 | 8 | - either on the **POSbox** (i.e. the proxy on which the USB devices are connected) and not on the main Odoo server. 9 | - either as `pywebdriver `_ dependency 10 | 11 | On the main Odoo server, you should install the module **pos_payment_terminal**. 12 | 13 | The configuration of the hardware is done in the configuration file of 14 | the Odoo server of the POSbox. You should add the following entries in 15 | the configuration file: 16 | 17 | * payment_terminal_device_name (default = /dev/ttyACM0) 18 | * payment_terminal_device_rate (default = 9600) 19 | 20 | The Caisse Concert protocol is used by many payment terminals in France 21 | from different manufacturers (Ingenico, Sagem, Verifone). From our 22 | experience, this protocol is only used in France. 23 | 24 | In France, Ingenico has the biggest market-share on payment terminals. 25 | In France, Ingenico terminals are loaded with the Telium Manager 26 | software stack which implements the Caisse Concert protocol natively. 27 | This module implements the protocol E+ (and not the protocol E), so it 28 | requires a Telium Manager **version 37783600** or superior. 29 | 30 | To get the version of the Telium Manager on an Ingenico 31 | terminal: 32 | 33 | :: 34 | 35 | press F > 0-TELIUM MANAGER > 2-Consultation > 4-Configuration 36 | > 2-Software > 1-TERMINAL > On Display > Telium Manager 37 | 38 | and then read the field **M20S**. 39 | 40 | You will need to configure your payment terminal to accept commands 41 | from the point of sale. On an Ingenico terminal: 42 | 43 | :: 44 | 45 | press F > 0-TELIUM MANAGER > 46 | 5-Initialization > 1-Parameters > Cash Connection and then select *On* 47 | and then **USB** or **USB Base** according to used cable. 48 | 49 | After that, you should reboot the terminal (normally by clicking simultaneously on keys `yellow` and `#`). 50 | This module has been successfully tested with: 51 | 52 | * Ingenico EFTSmart4S 53 | * Ingenico EFTSmart2 2640 with Telim Manager version 37784503 54 | * Ingenico iCT220 55 | * Ingenico iCT250 56 | * Ingenico i2200 cheque reader and writer 57 | 58 | This module has been developped during a POS code sprint at Akretion 59 | France from July 7th to July 10th 2014. This module is part of the POS 60 | project of the Odoo Community Association http://odoo-community.org/. 61 | You are invited to become a member and/or get involved in the 62 | Association ! 63 | 64 | Installation 65 | ============ 66 | 67 | :: 68 | 69 | sudo pip install git+https://github.com/akretion/pypostelium.git --upgrade 70 | 71 | Changelog 72 | ========= 73 | 74 | * Version 0.0.5 dated 2021-10-17 75 | 76 | * add get_status() 77 | * add auto device detection 78 | 79 | * Version 0.0.4 dated 2020-10-19 80 | 81 | * transaction_start() now returns True (success) or False (failure) 82 | 83 | * Version 0.0.3 dated 2020-05-18 84 | 85 | * Python3 support 86 | 87 | Contributors 88 | ============ 89 | 90 | * Alexis de Lattre 91 | * Sébastien BEAU 92 | * Sylvain Calador 93 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.0.5 2 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akretion/pypostelium/fddcc5ff84b12fcc88bb8c13049fe73702f7a0fb/debian/changelog -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 9 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: pypostelium 2 | Section: admin 3 | Priority: extra 4 | Maintainer: Sylvain Calador 5 | Build-Depends: debhelper (>= 9), python-setuptools, python-all 6 | X-Python-Version: >= 2.4 7 | VCS-Git: git://github.com/akretion/pypostelium.git 8 | VCS-Browser: https://github.com/akretion/pypostelium 9 | Standards-Version: 1.0.0 10 | Homepage: https://github.com/akretion/pywebdriver 11 | 12 | Package: python-pypostelium 13 | Architecture: all 14 | Depends: python, python-simplejson, python-serial, python-unidecode 15 | ${misc:Depends}, 16 | Description: Python Libraries for interacting with Point of Sale 17 | Telium Payment Terminal 18 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: http://dep.debian.net/deps/dep5 2 | Upstream-Name: pypostelium 3 | Source: http://www.akretion.com 4 | 5 | Files: * 6 | Copyright: 2014 - Today : Akretion (http://www.akretion.com) 7 | License: AGPL-3.0+ 8 | 9 | Files: debian/* 10 | Copyright: 2014 Sebastien BEAU 11 | License: AGPL-3.0+ 12 | 13 | License: AGPL-3.0+ 14 | This program is free software: you can redistribute it and/or modify 15 | it under the terms of the GNU Affero General Public License as 16 | published by the Free Software Foundation, either version 3 of the 17 | License, or (at your option) any later version. 18 | 19 | This program is distributed in the hope that it will be useful, 20 | but WITHOUT ANY WARRANTY; without even the implied warranty of 21 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 22 | GNU Affero General Public License for more details. 23 | 24 | You should have received a copy of the GNU Affero General Public License 25 | along with this program. If not, see . 26 | -------------------------------------------------------------------------------- /debian/python-pypostelium.install: -------------------------------------------------------------------------------- 1 | lib/ 2 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (quilt) 2 | -------------------------------------------------------------------------------- /pypostelium/__init__.py: -------------------------------------------------------------------------------- 1 | from .pypostelium import Driver 2 | -------------------------------------------------------------------------------- /pypostelium/pypostelium.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | ############################################################################### 3 | # 4 | # Python Point Of Sale Display Librarie 5 | # Copyright (C) 2014 Akretion (http://www.akretion.com). 6 | # @author Alexis de Lattre 7 | # @author Sébastien BEAU 8 | # @author Sylvain CALADOR 9 | # 10 | # This program is free software: you can redistribute it and/or modify 11 | # it under the terms of the GNU Affero General Public License as 12 | # published by the Free Software Foundation, either version 3 of the 13 | # License, or (at your option) any later version. 14 | # 15 | # This program is distributed in the hope that it will be useful, 16 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | # GNU Affero General Public License for more details. 19 | # 20 | # You should have received a copy of the GNU Affero General Public License 21 | # along with this program. If not, see . 22 | # 23 | ############################################################################### 24 | 25 | import simplejson 26 | import time 27 | import logging 28 | import curses.ascii 29 | from serial import Serial 30 | import serial.tools.list_ports 31 | 32 | import pycountry 33 | 34 | logger = logging.getLogger(__name__) 35 | 36 | # Name of Payment Terminal main providers 37 | AUTO_DEVICE_NAMES = ["ttyACM", "Desk500"] 38 | 39 | class Driver(object): 40 | def __init__(self, config): 41 | self.device_name = config.get( 42 | 'telium_terminal_device_name', '/dev/ttyACM0') 43 | self.device_rate = int(config.get( 44 | 'telium_terminal_device_rate', 9600)) 45 | self.retry_count = int(config.get( 46 | 'telium_terminal_retry_count', 0)) 47 | self.serial = False 48 | if self.device_name == "auto": 49 | self.device_name = self._find_auto_device_name() 50 | 51 | def _find_auto_device_name(self): 52 | connected_comports = [tuple(p) for p in list(serial.tools.list_ports.comports())] 53 | logger.debug(connected_comports) 54 | for port in connected_comports: 55 | if any(string in port[1] for string in AUTO_DEVICE_NAMES): 56 | return port[0] 57 | 58 | def serial_write(self, text): 59 | assert isinstance(text, str), 'text must be a string' 60 | self.serial.write(text.encode('ascii')) 61 | 62 | def initialize_msg(self): 63 | max_attempt = self.retry_count + 1 64 | attempt_nr = 0 65 | while attempt_nr < max_attempt: 66 | attempt_nr += 1 67 | self.send_one_byte_signal('ENQ') 68 | if self.get_one_byte_answer('ACK'): 69 | return True 70 | else: 71 | logger.warning("Terminal : SAME PLAYER TRY AGAIN") 72 | self.send_one_byte_signal('EOT') 73 | # Wait 1 sec between each attempt 74 | time.sleep(1) 75 | return False 76 | 77 | def send_one_byte_signal(self, signal): 78 | ascii_names = curses.ascii.controlnames 79 | assert signal in ascii_names, 'Wrong signal' 80 | char = ascii_names.index(signal) 81 | self.serial_write(chr(char)) 82 | logger.debug('Signal %s sent to terminal' % signal) 83 | 84 | def get_one_byte_answer(self, expected_signal): 85 | ascii_names = curses.ascii.controlnames 86 | one_byte_read = self.serial.read(1).decode('ascii') 87 | expected_char = ascii_names.index(expected_signal) 88 | if one_byte_read == chr(expected_char): 89 | logger.debug("%s received from terminal" % expected_signal) 90 | return True 91 | else: 92 | return False 93 | 94 | def prepare_data_to_send(self, payment_info_dict): 95 | amount = payment_info_dict['amount'] 96 | if payment_info_dict['payment_mode'] == 'check': 97 | payment_mode = 'C' 98 | elif payment_info_dict['payment_mode'] == 'card': 99 | payment_mode = '1' 100 | else: 101 | logger.error( 102 | "The payment mode '%s' is not supported" 103 | % payment_info_dict['payment_mode']) 104 | return False 105 | cur_decimals = payment_info_dict.get('currency_decimals', 2) 106 | cur_fact = 10**cur_decimals 107 | cur_iso_letter = payment_info_dict['currency_iso'].upper() 108 | if cur_iso_letter == 'EUR': # Small speed-up 109 | cur_numeric = '978' 110 | else: 111 | try: 112 | cur = pycountry.currencies.get(alpha_3=cur_iso_letter) 113 | cur_numeric = str(cur.numeric) 114 | except Exception: 115 | logger.error("Currency %s is not recognized" % cur_iso_letter) 116 | return False 117 | data = { 118 | 'pos_number': str(1).zfill(2), 119 | 'answer_flag': '0', 120 | 'transaction_type': '0', 121 | 'payment_mode': payment_mode, 122 | 'currency_numeric': cur_numeric.zfill(3), 123 | 'private': ' ' * 10, 124 | 'delay': 'A011', 125 | 'auto': 'B010', 126 | 'amount_msg': ('%.0f' % (amount * cur_fact)).zfill(8), 127 | } 128 | return data 129 | 130 | def generate_lrc(self, real_msg_with_etx): 131 | lrc = 0 132 | for char in real_msg_with_etx: 133 | lrc ^= ord(char) 134 | return lrc 135 | 136 | def send_message(self, data): 137 | '''We use protocol E+''' 138 | ascii_names = curses.ascii.controlnames 139 | real_msg = ( 140 | data['pos_number'] 141 | + data['amount_msg'] 142 | + data['answer_flag'] 143 | + data['payment_mode'] 144 | + data['transaction_type'] 145 | + data['currency_numeric'] 146 | + data['private'] 147 | + data['delay'] 148 | + data['auto'] 149 | ) 150 | logger.debug('Real message to send = %s' % real_msg) 151 | assert len(real_msg) == 34, 'Wrong length for protocol E+' 152 | real_msg_with_etx = real_msg + chr(ascii_names.index('ETX')) 153 | lrc = self.generate_lrc(real_msg_with_etx) 154 | message = chr(ascii_names.index('STX')) + real_msg_with_etx + chr(lrc) 155 | self.serial_write(message) 156 | logger.info('Message sent to terminal') 157 | 158 | def compare_data_vs_answer(self, data, answer_data): 159 | for field in [ 160 | 'pos_number', 'amount_msg', 161 | 'currency_numeric', 'private']: 162 | if data[field] != answer_data[field]: 163 | logger.warning( 164 | "Field %s has value '%s' in data and value '%s' in answer" 165 | % (field, data[field], answer_data[field])) 166 | 167 | def parse_terminal_answer(self, real_msg, data): 168 | answer_data = { 169 | 'pos_number': real_msg[0:2], 170 | 'transaction_result': real_msg[2], 171 | 'amount_msg': real_msg[3:11], 172 | 'payment_mode': real_msg[11], 173 | 'currency_numeric': real_msg[12:15], 174 | 'private': real_msg[15:26], 175 | } 176 | logger.debug('answer_data = %s' % answer_data) 177 | self.compare_data_vs_answer(data, answer_data) 178 | return answer_data 179 | 180 | def get_answer_from_terminal(self, data): 181 | ascii_names = curses.ascii.controlnames 182 | full_msg_size = 1+2+1+8+1+3+10+1+1 183 | msg = self.serial.read(size=full_msg_size).decode('ascii') 184 | logger.debug('%d bytes read from terminal' % full_msg_size) 185 | assert len(msg) == full_msg_size, 'Answer has a wrong size' 186 | if msg[0] != chr(ascii_names.index('STX')): 187 | logger.error( 188 | 'The first byte of the answer from terminal should be STX') 189 | if msg[-2] != chr(ascii_names.index('ETX')): 190 | logger.error( 191 | 'The byte before final of the answer from terminal ' 192 | 'should be ETX') 193 | lrc = msg[-1] 194 | computed_lrc = chr(self.generate_lrc(msg[1:-1])) 195 | if computed_lrc != lrc: 196 | logger.error( 197 | 'The LRC of the answer from terminal is wrong') 198 | real_msg = msg[1:-2] 199 | logger.debug('Real answer received = %s' % real_msg) 200 | return self.parse_terminal_answer(real_msg, data) 201 | 202 | def transaction_start(self, payment_info): 203 | '''This function sends the data to the serial/usb port. 204 | ''' 205 | payment_info_dict = simplejson.loads(payment_info) 206 | assert isinstance(payment_info_dict, dict), \ 207 | 'payment_info_dict should be a dict' 208 | logger.debug("payment_info_dict = %s" % payment_info_dict) 209 | res = False 210 | try: 211 | logger.debug( 212 | 'Opening serial port %s for payment terminal with baudrate %d' 213 | % (self.device_name, self.device_rate)) 214 | # IMPORTANT : don't modify timeout=3 seconds 215 | # This parameter is very important ; the Telium spec say 216 | # that we have to wait to up 3 seconds to get LRC 217 | self.serial = Serial( 218 | self.device_name, self.device_rate, 219 | timeout=3) 220 | logger.debug('serial.is_open = %s' % self.serial.isOpen()) 221 | if self.initialize_msg(): 222 | data = self.prepare_data_to_send(payment_info_dict) 223 | if not data: 224 | return res 225 | self.send_message(data) 226 | if self.get_one_byte_answer('ACK'): 227 | self.send_one_byte_signal('EOT') 228 | res = True 229 | 230 | logger.info("Now expecting answer from Terminal") 231 | if self.get_one_byte_answer('ENQ'): 232 | self.send_one_byte_signal('ACK') 233 | self.get_answer_from_terminal(data) 234 | self.send_one_byte_signal('ACK') 235 | if self.get_one_byte_answer('EOT'): 236 | logger.info("Answer received from Terminal") 237 | 238 | except Exception as e: 239 | logger.error('Exception in serial connection: %s' % str(e)) 240 | finally: 241 | if self.serial: 242 | logger.debug('Closing serial port for payment terminal') 243 | self.serial.close() 244 | return res 245 | 246 | def get_status(self, **kwargs): 247 | # When I use Odoo POS v8, it regularly goes through that code 248 | # and sends 999.99 to the credit card reader !!! 249 | # Si I comment the line below -- Alexis 250 | # telium_driver.push_task('transaction_start', json.dumps( 251 | # self.get_payment_info_from_price(999.99, 'card'), sort_keys=True)) 252 | # TODO Improve : Get the real model connected 253 | status = "disconnected" 254 | messages = [] 255 | connected_comports = [tuple(p) for p in list(serial.tools.list_ports.comports())] 256 | logger.debug(connected_comports) 257 | ports = [p[0] for p in connected_comports] 258 | if self.device_name in ports: 259 | status = "connected" 260 | else: 261 | devices = [p[1] for p in connected_comports] 262 | messages = ["Available device:"] + devices 263 | if status == "connected": 264 | self.vendor_product = "telium_image" 265 | else: 266 | self.vendor_product = False 267 | return {"status": status, "messages": messages} 268 | -------------------------------------------------------------------------------- /requirement.txt: -------------------------------------------------------------------------------- 1 | simplejson 2 | pyserial 3 | pycountry>=16.11.08 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup, find_packages 3 | version = open('VERSION').read().strip() 4 | 5 | setup( 6 | name='pypostelium', 7 | version=version, 8 | author='Akretion', 9 | author_email='contact@akretion.com', 10 | url='https://github.com/akretion/pypostelium', 11 | description='Python library for supporting Point of Sale payment terminal with Concert protocol', 12 | long_description=open('README.rst').read(), 13 | license='AGPLv3+', 14 | classifiers=[ 15 | 'Development Status :: 5 - Production/Stable', 16 | 'Intended Audience :: Developers', 17 | 'Topic :: Software Development :: User Interfaces', 18 | 'License :: OSI Approved :: GNU Affero General Public License v3', 19 | 'Programming Language :: Python :: 2.7', 20 | 'Programming Language :: Python :: 3.8', 21 | ], 22 | keywords='Concert Telium payment terminal', 23 | packages=find_packages(), 24 | install_requires=[r.strip() for r in 25 | open('requirement.txt').read().splitlines()], 26 | include_package_data=True, 27 | zip_safe=False, 28 | ) 29 | --------------------------------------------------------------------------------