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.

320 lines
12 KiB

9 years ago
  1. import logging
  2. import simplejson
  3. import time
  4. import curses.ascii
  5. from threading import Thread, Lock
  6. from queue import Queue
  7. from odoo import http
  8. from odoo.tools.config import config
  9. from odoo.addons.hw_proxy.controllers import main as hw_proxy
  10. logger = logging.getLogger(__name__)
  11. try:
  12. from serial import Serial
  13. except (ImportError, IOError) as err:
  14. logger.debug(err)
  15. try:
  16. import pycountry
  17. EUR_CY_NBR = False
  18. except (ImportError, IOError) as err:
  19. logger.debug(err)
  20. logger.warning(
  21. 'Unable to import pycountry, only EUR currency is supported')
  22. EUR_CY_NBR = 978
  23. class TeliumPaymentTerminalDriver(Thread):
  24. def __init__(self):
  25. Thread.__init__(self)
  26. self.queue = Queue()
  27. self.lock = Lock()
  28. self.status = {'status': 'connecting', 'messages': []}
  29. self.device_name = config.get(
  30. 'telium_terminal_device_name', '/dev/ttyACM0')
  31. self.device_rate = int(config.get(
  32. 'telium_terminal_device_rate', 9600))
  33. self.serial = False
  34. def get_status(self):
  35. self.push_task('status')
  36. return self.status
  37. def set_status(self, status, message=None):
  38. if status == self.status['status']:
  39. if message is not None and message != self.status['messages'][-1]:
  40. self.status['messages'].append(message)
  41. else:
  42. self.status['status'] = status
  43. if message:
  44. self.status['messages'] = [message]
  45. else:
  46. self.status['messages'] = []
  47. if status == 'error' and message:
  48. logger.error('Payment Terminal Error: ' + message)
  49. elif status == 'disconnected' and message:
  50. logger.warning('Disconnected Terminal: ' + message)
  51. def lockedstart(self):
  52. with self.lock:
  53. if not self.is_alive():
  54. self.daemon = True
  55. self.start()
  56. def push_task(self, task, data=None):
  57. self.lockedstart()
  58. self.queue.put((time.time(), task, data))
  59. def serial_write(self, text):
  60. assert isinstance(text, str), 'text must be a string'
  61. raw = text.encode()
  62. logger.debug("%s raw send to terminal" % raw)
  63. logger.debug("%s send to terminal" % text)
  64. self.serial.write(raw)
  65. def serial_read(self, size=1):
  66. raw = self.serial.read(size)
  67. msg = raw.decode('ascii')
  68. logger.debug("%s raw received from terminal" % raw)
  69. logger.debug("%s received from terminal" % msg)
  70. return msg
  71. def initialize_msg(self):
  72. max_attempt = 3
  73. attempt_nr = 0
  74. while attempt_nr < max_attempt:
  75. attempt_nr += 1
  76. self.send_one_byte_signal('ENQ')
  77. if self.get_one_byte_answer('ACK'):
  78. return True
  79. else:
  80. logger.warning("Terminal : SAME PLAYER TRY AGAIN")
  81. self.send_one_byte_signal('EOT')
  82. # Wait 1 sec between each attempt
  83. time.sleep(1)
  84. return False
  85. def send_one_byte_signal(self, signal):
  86. ascii_names = curses.ascii.controlnames
  87. assert signal in ascii_names, 'Wrong signal'
  88. char = ascii_names.index(signal)
  89. self.serial_write(chr(char))
  90. logger.debug('Signal %s sent to terminal' % signal)
  91. def get_one_byte_answer(self, expected_signal):
  92. assert isinstance(expected_signal, str), 'expected_signal must be a string'
  93. ascii_names = curses.ascii.controlnames
  94. one_byte_read = self.serial_read(1)
  95. expected_char = ascii_names.index(expected_signal)
  96. if one_byte_read == chr(expected_char):
  97. return True
  98. else:
  99. return False
  100. def _get_amount(self, payment_info_dict):
  101. amount = payment_info_dict['amount']
  102. cur_decimals = payment_info_dict['currency_decimals']
  103. cur_fact = 10 ** cur_decimals
  104. return ('%.0f' % (amount * cur_fact)).zfill(8)
  105. def prepare_data_to_send(self, payment_info_dict):
  106. if payment_info_dict['payment_mode'] == 'check':
  107. payment_mode = 'C'
  108. elif payment_info_dict['payment_mode'] == 'card':
  109. payment_mode = '1'
  110. else:
  111. logger.error(
  112. "The payment mode '%s' is not supported"
  113. % payment_info_dict['payment_mode'])
  114. return False
  115. cur_iso_letter = payment_info_dict['currency_iso'].upper()
  116. try:
  117. if EUR_CY_NBR:
  118. cur_numeric = str(EUR_CY_NBR)
  119. else:
  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': 'A010',
  133. 'auto': 'B010',
  134. 'amount_msg': self._get_amount(payment_info_dict),
  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 ['pos_number', 'amount_msg', 'currency_numeric', 'private']:
  164. if data[field] != answer_data[field]:
  165. logger.warning(
  166. "Field %s has value '%s' in data and value '%s' in answer"
  167. % (field, data[field], answer_data[field]))
  168. def parse_terminal_answer(self, real_msg, data):
  169. answer_data = {
  170. 'pos_number': real_msg[0:2],
  171. 'transaction_result': real_msg[2],
  172. 'amount_msg': real_msg[3:11],
  173. 'payment_mode': real_msg[11],
  174. 'currency_numeric': real_msg[12:15],
  175. 'private': real_msg[15:26],
  176. }
  177. logger.debug('answer_data = %s' % answer_data)
  178. self.compare_data_vs_answer(data, answer_data)
  179. return answer_data
  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)
  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. def transaction_start(self, payment_info):
  202. '''This function sends the data to the serial/usb port.
  203. '''
  204. payment_info_dict = simplejson.loads(payment_info)
  205. assert isinstance(payment_info_dict, dict), \
  206. 'payment_info_dict should be a dict'
  207. try:
  208. logger.debug(
  209. 'Opening serial port %s for payment terminal with baudrate %d'
  210. % (self.device_name, self.device_rate))
  211. # IMPORTANT : don't modify timeout=3 seconds
  212. # This parameter is very important ; the Telium spec say
  213. # that we have to wait to up 3 seconds to get LRC
  214. self.serial = Serial(
  215. self.device_name, self.device_rate,
  216. timeout=3)
  217. logger.debug('serial.is_open = %s' % self.serial.isOpen())
  218. if self.serial.isOpen():
  219. self.set_status("connected",
  220. "Connected to {} with baudrate {}".format(
  221. self.device_name, self.device_rate))
  222. else:
  223. self.set_status("disconnected",
  224. "Could not connect to {}"
  225. .format(self.device_name))
  226. if self.initialize_msg():
  227. data = self.prepare_data_to_send(payment_info_dict)
  228. if not data:
  229. return
  230. self.send_message(data)
  231. if self.get_one_byte_answer('ACK'):
  232. self.send_one_byte_signal('EOT')
  233. self.status['in_transaction'] = True
  234. logger.debug("Now expecting answer from Terminal")
  235. # We wait the end of transaction
  236. attempt_nr = 0
  237. while attempt_nr < 600:
  238. attempt_nr += 1
  239. if self.get_one_byte_answer('ENQ'):
  240. self.send_one_byte_signal('ACK')
  241. answer = self.get_answer_from_terminal(data)
  242. # '0' : accepted transaction
  243. # '7' : refused transaction
  244. if answer['transaction_result'] == '0' \
  245. and self._get_amount(payment_info_dict) == answer['amount_msg']:
  246. self.status['latest_transactions'] = {payment_info_dict['order_id']: {}}
  247. logger.info("Transaction OK")
  248. self.send_one_byte_signal('ACK')
  249. if self.get_one_byte_answer('EOT'):
  250. logger.debug("Answer received from Terminal")
  251. break
  252. time.sleep(0.5)
  253. self.status['in_transaction'] = False
  254. except Exception as e:
  255. logger.error('Exception in serial connection: %s' % str(e))
  256. self.set_status("error",
  257. "Exception in serial connection to {}"
  258. .format(self.device_name))
  259. finally:
  260. if self.serial:
  261. logger.debug('Closing serial port for payment terminal')
  262. self.serial.close()
  263. def run(self):
  264. while True:
  265. try:
  266. timestamp, task, data = self.queue.get(True)
  267. if task == 'transaction_start':
  268. self.transaction_start(data)
  269. elif task == 'status':
  270. pass
  271. except Exception as e:
  272. self.set_status('error', str(e))
  273. driver = TeliumPaymentTerminalDriver()
  274. hw_proxy.drivers['telium_payment_terminal'] = driver
  275. class TeliumPaymentTerminalProxy(hw_proxy.Proxy):
  276. @http.route(
  277. '/hw_proxy/payment_terminal_transaction_start',
  278. type='json', auth='none', cors='*')
  279. def payment_terminal_transaction_start(self, payment_info):
  280. logger.debug(
  281. 'Telium: Call payment_terminal_transaction_start with '
  282. 'payment_info=%s', payment_info)
  283. driver.push_task('transaction_start', payment_info)