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.

284 lines
11 KiB

9 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. from serial import Serial
  29. import pycountry
  30. import openerp.addons.hw_proxy.controllers.main as hw_proxy
  31. from openerp import http
  32. from openerp.tools.config import config
  33. logger = logging.getLogger(__name__)
  34. class TeliumPaymentTerminalDriver(Thread):
  35. def __init__(self):
  36. Thread.__init__(self)
  37. self.queue = Queue()
  38. self.lock = Lock()
  39. self.status = {'status': 'connecting', 'messages': []}
  40. self.device_name = config.get(
  41. 'telium_terminal_device_name', '/dev/ttyACM0')
  42. self.device_rate = int(config.get(
  43. 'telium_terminal_device_rate', 9600))
  44. self.serial = False
  45. def get_status(self):
  46. self.push_task('status')
  47. return self.status
  48. def set_status(self, status, message=None):
  49. if status == self.status['status']:
  50. if message is not None and message != self.status['messages'][-1]:
  51. self.status['messages'].append(message)
  52. else:
  53. self.status['status'] = status
  54. if message:
  55. self.status['messages'] = [message]
  56. else:
  57. self.status['messages'] = []
  58. if status == 'error' and message:
  59. logger.error('Payment Terminal Error: '+message)
  60. elif status == 'disconnected' and message:
  61. logger.warning('Disconnected Terminal: '+message)
  62. def lockedstart(self):
  63. with self.lock:
  64. if not self.isAlive():
  65. self.daemon = True
  66. self.start()
  67. def push_task(self, task, data=None):
  68. self.lockedstart()
  69. self.queue.put((time.time(), task, data))
  70. def serial_write(self, text):
  71. assert isinstance(text, str), 'text must be a string'
  72. self.serial.write(text)
  73. def initialize_msg(self):
  74. max_attempt = 3
  75. attempt_nr = 0
  76. while attempt_nr < max_attempt:
  77. attempt_nr += 1
  78. self.send_one_byte_signal('ENQ')
  79. if self.get_one_byte_answer('ACK'):
  80. return True
  81. else:
  82. logger.warning("Terminal : SAME PLAYER TRY AGAIN")
  83. self.send_one_byte_signal('EOT')
  84. # Wait 1 sec between each attempt
  85. time.sleep(1)
  86. return False
  87. def send_one_byte_signal(self, signal):
  88. ascii_names = curses.ascii.controlnames
  89. assert signal in ascii_names, 'Wrong signal'
  90. char = ascii_names.index(signal)
  91. self.serial_write(chr(char))
  92. logger.debug('Signal %s sent to terminal' % signal)
  93. def get_one_byte_answer(self, expected_signal):
  94. ascii_names = curses.ascii.controlnames
  95. one_byte_read = self.serial.read(1)
  96. expected_char = ascii_names.index(expected_signal)
  97. if one_byte_read == chr(expected_char):
  98. logger.debug("%s received from terminal" % expected_signal)
  99. return True
  100. else:
  101. return False
  102. def prepare_data_to_send(self, payment_info_dict):
  103. amount = payment_info_dict['amount']
  104. if payment_info_dict['payment_mode'] == 'check':
  105. payment_mode = 'C'
  106. elif payment_info_dict['payment_mode'] == 'card':
  107. payment_mode = '1'
  108. else:
  109. logger.error(
  110. "The payment mode '%s' is not supported"
  111. % payment_info_dict['payment_mode'])
  112. return False
  113. cur_iso_letter = payment_info_dict['currency_iso'].upper()
  114. try:
  115. cur = pycountry.currencies.get(letter=cur_iso_letter)
  116. cur_numeric = str(cur.numeric)
  117. except:
  118. logger.error("Currency %s is not recognized" % cur_iso_letter)
  119. return False
  120. data = {
  121. 'pos_number': str(1).zfill(2),
  122. 'answer_flag': '0',
  123. 'transaction_type': '0',
  124. 'payment_mode': payment_mode,
  125. 'currency_numeric': cur_numeric.zfill(3),
  126. 'private': ' ' * 10,
  127. 'delay': 'A011',
  128. 'auto': 'B010',
  129. 'amount_msg': ('%.0f' % (amount * 100)).zfill(8),
  130. }
  131. return data
  132. def generate_lrc(self, real_msg_with_etx):
  133. lrc = 0
  134. for char in real_msg_with_etx:
  135. lrc ^= ord(char)
  136. return lrc
  137. def send_message(self, data):
  138. '''We use protocol E+'''
  139. ascii_names = curses.ascii.controlnames
  140. real_msg = (
  141. data['pos_number'] +
  142. data['amount_msg'] +
  143. data['answer_flag'] +
  144. data['payment_mode'] +
  145. data['transaction_type'] +
  146. data['currency_numeric'] +
  147. data['private'] +
  148. data['delay'] +
  149. data['auto'])
  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. def compare_data_vs_answer(self, data, answer_data):
  158. for field in [
  159. 'pos_number', 'amount_msg',
  160. 'currency_numeric', 'private']:
  161. if data[field] != answer_data[field]:
  162. logger.warning(
  163. "Field %s has value '%s' in data and value '%s' in answer"
  164. % (field, data[field], answer_data[field]))
  165. def parse_terminal_answer(self, real_msg, data):
  166. answer_data = {
  167. 'pos_number': real_msg[0:2],
  168. 'transaction_result': real_msg[2],
  169. 'amount_msg': real_msg[3:11],
  170. 'payment_mode': real_msg[11],
  171. 'currency_numeric': real_msg[12:15],
  172. 'private': real_msg[15:26],
  173. }
  174. logger.debug('answer_data = %s' % answer_data)
  175. self.compare_data_vs_answer(data, answer_data)
  176. return answer_data
  177. def get_answer_from_terminal(self, data):
  178. ascii_names = curses.ascii.controlnames
  179. full_msg_size = 1+2+1+8+1+3+10+1+1
  180. msg = self.serial.read(size=full_msg_size)
  181. logger.debug('%d bytes read from terminal' % full_msg_size)
  182. assert len(msg) == full_msg_size, 'Answer has a wrong size'
  183. if msg[0] != chr(ascii_names.index('STX')):
  184. logger.error(
  185. 'The first byte of the answer from terminal should be STX')
  186. if msg[-2] != chr(ascii_names.index('ETX')):
  187. logger.error(
  188. 'The byte before final of the answer from terminal '
  189. 'should be ETX')
  190. lrc = msg[-1]
  191. computed_lrc = chr(self.generate_lrc(msg[1:-1]))
  192. if computed_lrc != lrc:
  193. logger.error(
  194. 'The LRC of the answer from terminal is wrong')
  195. real_msg = msg[1:-2]
  196. logger.debug('Real answer received = %s' % real_msg)
  197. return self.parse_terminal_answer(real_msg, data)
  198. def transaction_start(self, payment_info):
  199. '''This function sends the data to the serial/usb port.
  200. '''
  201. payment_info_dict = simplejson.loads(payment_info)
  202. assert isinstance(payment_info_dict, dict), \
  203. 'payment_info_dict should be a dict'
  204. try:
  205. logger.debug(
  206. 'Opening serial port %s for payment terminal with baudrate %d'
  207. % (self.device_name, self.device_rate))
  208. # IMPORTANT : don't modify timeout=3 seconds
  209. # This parameter is very important ; the Telium spec say
  210. # that we have to wait to up 3 seconds to get LRC
  211. self.serial = Serial(
  212. self.device_name, self.device_rate,
  213. timeout=3)
  214. logger.debug('serial.is_open = %s' % self.serial.isOpen())
  215. if self.initialize_msg():
  216. data = self.prepare_data_to_send(payment_info_dict)
  217. if not data:
  218. return
  219. self.send_message(data)
  220. if self.get_one_byte_answer('ACK'):
  221. self.send_one_byte_signal('EOT')
  222. logger.info("Now expecting answer from Terminal")
  223. if self.get_one_byte_answer('ENQ'):
  224. self.send_one_byte_signal('ACK')
  225. self.get_answer_from_terminal(data)
  226. self.send_one_byte_signal('ACK')
  227. if self.get_one_byte_answer('EOT'):
  228. logger.info("Answer received from Terminal")
  229. except Exception, e:
  230. logger.error('Exception in serial connection: %s' % str(e))
  231. finally:
  232. if self.serial:
  233. logger.debug('Closing serial port for payment terminal')
  234. self.serial.close()
  235. def run(self):
  236. while True:
  237. try:
  238. timestamp, task, data = self.queue.get(True)
  239. if task == 'transaction_start':
  240. self.transaction_start(data)
  241. elif task == 'status':
  242. pass
  243. except Exception as e:
  244. self.set_status('error', str(e))
  245. driver = TeliumPaymentTerminalDriver()
  246. hw_proxy.drivers['telium_payment_terminal'] = driver
  247. class TeliumPaymentTerminalProxy(hw_proxy.Proxy):
  248. @http.route(
  249. '/hw_proxy/payment_terminal_transaction_start',
  250. type='json', auth='none', cors='*')
  251. def payment_terminal_transaction_start(self, payment_info):
  252. logger.debug(
  253. 'Telium: Call payment_terminal_transaction_start with '
  254. 'payment_info=%s', payment_info)
  255. driver.push_task('transaction_start', payment_info)