You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

302 lines
11 KiB

10 years ago
  1. # -*- encoding: utf-8 -*-
  2. ##############################################################################
  3. #
  4. # Hardware Telium Payment Terminal module for Odoo
  5. # Copyright (C) 2014 Akretion (http://www.akretion.com)
  6. # @author Alexis de Lattre <alexis.delattre@akretion.com>
  7. #
  8. # This program is free software: you can redistribute it and/or modify
  9. # it under the terms of the GNU Affero General Public License as
  10. # published by the Free Software Foundation, either version 3 of the
  11. # License, or (at your option) any later version.
  12. #
  13. # This program is distributed in the hope that it will be useful,
  14. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. # GNU Affero General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU Affero General Public License
  19. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. #
  21. ##############################################################################
  22. import logging
  23. import simplejson
  24. import time
  25. import curses.ascii
  26. from threading import Thread, Lock
  27. from Queue import Queue
  28. import openerp.addons.hw_proxy.controllers.main as hw_proxy
  29. from openerp import http
  30. from openerp.tools.config import config
  31. logger = logging.getLogger(__name__)
  32. try:
  33. import pycountry
  34. from serial import Serial
  35. except (ImportError, IOError) as err:
  36. logger.debug(err)
  37. class TeliumPaymentTerminalDriver(Thread):
  38. def __init__(self):
  39. Thread.__init__(self)
  40. self.queue = Queue()
  41. self.lock = Lock()
  42. self.status = {'status': 'connecting', 'messages': []}
  43. self.device_name = config.get(
  44. 'telium_terminal_device_name', '/dev/ttyACM0')
  45. self.device_rate = int(config.get(
  46. 'telium_terminal_device_rate', 9600))
  47. self.serial = False
  48. def get_status(self):
  49. self.push_task('status')
  50. return self.status
  51. def set_status(self, status, message=None):
  52. if status == self.status['status']:
  53. if message is not None and message != self.status['messages'][-1]:
  54. self.status['messages'].append(message)
  55. else:
  56. self.status['status'] = status
  57. if message:
  58. self.status['messages'] = [message]
  59. else:
  60. self.status['messages'] = []
  61. if status == 'error' and message:
  62. logger.error('Payment Terminal Error: '+message)
  63. elif status == 'disconnected' and message:
  64. logger.warning('Disconnected Terminal: '+message)
  65. def lockedstart(self):
  66. with self.lock:
  67. if not self.isAlive():
  68. self.daemon = True
  69. self.start()
  70. def push_task(self, task, data=None):
  71. self.lockedstart()
  72. self.queue.put((time.time(), task, data))
  73. def serial_write(self, text):
  74. assert isinstance(text, str), 'text must be a string'
  75. self.serial.write(text)
  76. def initialize_msg(self):
  77. max_attempt = 3
  78. attempt_nr = 0
  79. while attempt_nr < max_attempt:
  80. attempt_nr += 1
  81. self.send_one_byte_signal('ENQ')
  82. if self.get_one_byte_answer('ACK'):
  83. return True
  84. else:
  85. logger.warning("Terminal : SAME PLAYER TRY AGAIN")
  86. self.send_one_byte_signal('EOT')
  87. # Wait 1 sec between each attempt
  88. time.sleep(1)
  89. return False
  90. def send_one_byte_signal(self, signal):
  91. ascii_names = curses.ascii.controlnames
  92. assert signal in ascii_names, 'Wrong signal'
  93. char = ascii_names.index(signal)
  94. self.serial_write(chr(char))
  95. logger.debug('Signal %s sent to terminal' % signal)
  96. def get_one_byte_answer(self, expected_signal):
  97. ascii_names = curses.ascii.controlnames
  98. one_byte_read = self.serial.read(1)
  99. expected_char = ascii_names.index(expected_signal)
  100. if one_byte_read == chr(expected_char):
  101. logger.debug("%s received from terminal" % expected_signal)
  102. return True
  103. else:
  104. return False
  105. def prepare_data_to_send(self, payment_info_dict):
  106. amount = payment_info_dict['amount']
  107. if payment_info_dict['payment_mode'] == 'check':
  108. payment_mode = 'C'
  109. elif payment_info_dict['payment_mode'] == 'card':
  110. payment_mode = '1'
  111. else:
  112. logger.error(
  113. "The payment mode '%s' is not supported"
  114. % payment_info_dict['payment_mode'])
  115. return False
  116. cur_decimals = payment_info_dict['currency_decimals']
  117. cur_fact = 10**cur_decimals
  118. cur_iso_letter = payment_info_dict['currency_iso'].upper()
  119. try:
  120. cur = pycountry.currencies.get(alpha_3=cur_iso_letter)
  121. cur_numeric = str(cur.numeric)
  122. except:
  123. logger.error("Currency %s is not recognized" % cur_iso_letter)
  124. return False
  125. data = {
  126. 'pos_number': str(1).zfill(2),
  127. 'answer_flag': '0',
  128. 'transaction_type': '0',
  129. 'payment_mode': payment_mode,
  130. 'currency_numeric': cur_numeric.zfill(3),
  131. 'private': ' ' * 10,
  132. 'delay': 'A011',
  133. 'auto': 'B010',
  134. 'amount_msg': ('%.0f' % (amount * cur_fact)).zfill(8),
  135. }
  136. return data
  137. def generate_lrc(self, real_msg_with_etx):
  138. lrc = 0
  139. for char in real_msg_with_etx:
  140. lrc ^= ord(char)
  141. return lrc
  142. def send_message(self, data):
  143. '''We use protocol E+'''
  144. ascii_names = curses.ascii.controlnames
  145. real_msg = (
  146. data['pos_number'] +
  147. data['amount_msg'] +
  148. data['answer_flag'] +
  149. data['payment_mode'] +
  150. data['transaction_type'] +
  151. data['currency_numeric'] +
  152. data['private'] +
  153. data['delay'] +
  154. data['auto'])
  155. logger.debug('Real message to send = %s' % real_msg)
  156. assert len(real_msg) == 34, 'Wrong length for protocol E+'
  157. real_msg_with_etx = real_msg + chr(ascii_names.index('ETX'))
  158. lrc = self.generate_lrc(real_msg_with_etx)
  159. message = chr(ascii_names.index('STX')) + real_msg_with_etx + chr(lrc)
  160. self.serial_write(message)
  161. logger.info('Message sent to terminal')
  162. def compare_data_vs_answer(self, data, answer_data):
  163. for field in [
  164. 'pos_number', 'amount_msg',
  165. 'currency_numeric', 'private']:
  166. if data[field] != answer_data[field]:
  167. logger.warning(
  168. "Field %s has value '%s' in data and value '%s' in answer"
  169. % (field, data[field], answer_data[field]))
  170. def parse_terminal_answer(self, real_msg, data):
  171. answer_data = {
  172. 'pos_number': real_msg[0:2],
  173. 'transaction_result': real_msg[2],
  174. 'amount_msg': real_msg[3:11],
  175. 'payment_mode': real_msg[11],
  176. 'currency_numeric': real_msg[12:15],
  177. 'private': real_msg[15:26],
  178. }
  179. logger.debug('answer_data = %s' % answer_data)
  180. self.compare_data_vs_answer(data, answer_data)
  181. return answer_data
  182. def get_answer_from_terminal(self, data):
  183. ascii_names = curses.ascii.controlnames
  184. full_msg_size = 1+2+1+8+1+3+10+1+1
  185. msg = self.serial.read(size=full_msg_size)
  186. logger.debug('%d bytes read from terminal' % full_msg_size)
  187. assert len(msg) == full_msg_size, 'Answer has a wrong size'
  188. if msg[0] != chr(ascii_names.index('STX')):
  189. logger.error(
  190. 'The first byte of the answer from terminal should be STX')
  191. if msg[-2] != chr(ascii_names.index('ETX')):
  192. logger.error(
  193. 'The byte before final of the answer from terminal '
  194. 'should be ETX')
  195. lrc = msg[-1]
  196. computed_lrc = chr(self.generate_lrc(msg[1:-1]))
  197. if computed_lrc != lrc:
  198. logger.error(
  199. 'The LRC of the answer from terminal is wrong')
  200. real_msg = msg[1:-2]
  201. logger.debug('Real answer received = %s' % real_msg)
  202. return self.parse_terminal_answer(real_msg, data)
  203. def transaction_start(self, payment_info):
  204. '''This function sends the data to the serial/usb port.
  205. '''
  206. payment_info_dict = simplejson.loads(payment_info)
  207. assert isinstance(payment_info_dict, dict), \
  208. 'payment_info_dict should be a dict'
  209. try:
  210. logger.debug(
  211. 'Opening serial port %s for payment terminal with baudrate %d'
  212. % (self.device_name, self.device_rate))
  213. # IMPORTANT : don't modify timeout=3 seconds
  214. # This parameter is very important ; the Telium spec say
  215. # that we have to wait to up 3 seconds to get LRC
  216. self.serial = Serial(
  217. self.device_name, self.device_rate,
  218. timeout=3)
  219. logger.debug('serial.is_open = %s' % self.serial.isOpen())
  220. if self.serial.isOpen():
  221. self.set_status("connected",
  222. "Connected to {}".format(self.device_name))
  223. else:
  224. self.set_status("disconnected",
  225. "Could not connect to {}"
  226. .format(self.device_name))
  227. if self.initialize_msg():
  228. data = self.prepare_data_to_send(payment_info_dict)
  229. if not data:
  230. return
  231. self.send_message(data)
  232. if self.get_one_byte_answer('ACK'):
  233. self.send_one_byte_signal('EOT')
  234. logger.info("Now expecting answer from Terminal")
  235. if self.get_one_byte_answer('ENQ'):
  236. self.send_one_byte_signal('ACK')
  237. self.get_answer_from_terminal(data)
  238. self.send_one_byte_signal('ACK')
  239. if self.get_one_byte_answer('EOT'):
  240. logger.info("Answer received from Terminal")
  241. except Exception as e:
  242. logger.error('Exception in serial connection: %s' % str(e))
  243. self.set_status("error",
  244. "Exception in serial connection to {}"
  245. .format(self.device_name))
  246. finally:
  247. if self.serial:
  248. logger.debug('Closing serial port for payment terminal')
  249. self.serial.close()
  250. def run(self):
  251. while True:
  252. try:
  253. timestamp, task, data = self.queue.get(True)
  254. if task == 'transaction_start':
  255. self.transaction_start(data)
  256. elif task == 'status':
  257. pass
  258. except Exception as e:
  259. self.set_status('error', str(e))
  260. driver = TeliumPaymentTerminalDriver()
  261. hw_proxy.drivers['telium_payment_terminal'] = driver
  262. class TeliumPaymentTerminalProxy(hw_proxy.Proxy):
  263. @http.route(
  264. '/hw_proxy/payment_terminal_transaction_start',
  265. type='json', auth='none', cors='*')
  266. def payment_terminal_transaction_start(self, payment_info):
  267. logger.debug(
  268. 'Telium: Call payment_terminal_transaction_start with '
  269. 'payment_info=%s', payment_info)
  270. driver.push_task('transaction_start', payment_info)