diff options
author | Piotr Dobrowolski <admin@tastycode.pl> | 2017-01-13 23:42:10 +0100 |
---|---|---|
committer | Piotr Dobrowolski <admin@tastycode.pl> | 2017-01-13 23:42:10 +0100 |
commit | 4215faffd1225f8891cb378f0db295f07c3972f6 (patch) | |
tree | 68e8fb0518093b271f0881bf69c4ca6b8206bf98 | |
parent | 8da7e9538fe139662efbab46f2eda308cf441166 (diff) | |
download | bitvend-4215faffd1225f8891cb378f0db295f07c3972f6.tar.gz bitvend-4215faffd1225f8891cb378f0db295f07c3972f6.tar.bz2 bitvend-4215faffd1225f8891cb378f0db295f07c3972f6.tar.xz bitvend-4215faffd1225f8891cb378f0db295f07c3972f6.zip |
Initial fully working implementation
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | bitvend.py | 411 | ||||
-rw-r--r-- | bitvend/__init__.py | 0 | ||||
-rw-r--r-- | bitvend/mdb.py | 375 | ||||
-rw-r--r-- | bitvend/processor.py | 94 | ||||
-rw-r--r-- | bitvend/utils.py | 25 | ||||
-rw-r--r-- | requirements.txt | 11 | ||||
-rw-r--r-- | static/img/club-mate.png | bin | 0 -> 125768 bytes | |||
-rw-r--r-- | static/img/promicro.png | bin | 0 -> 378157 bytes | |||
-rw-r--r-- | templates/index.html | 50 |
10 files changed, 586 insertions, 381 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d20b64 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.pyc @@ -1,17 +1,12 @@ -import time -import struct import logging import threading import flask -import cachetools -import requests +from flask_qrcode import QRcode -try: - import queue -except ImportError: - import Queue as queue - -from functools import reduce +from bitvend.mdb import CashlessMDBDevice +from bitvend.utils import to_local_currency, from_local_currency, format_btc, \ + sat_to_btc +from bitvend.processor import PaymentProcessor logging.basicConfig(level=logging.INFO) @@ -21,376 +16,37 @@ logging.basicConfig(level=logging.INFO) #coin_counter = Counter('coins_inserted', 'Number of coins inserted into machine') #purchase_counter = Counter('purchases', 'Number of purchases') -RX_PIN = 4 -TX_PIN = 17 - -CASHLESS_RESET = 0x10 -CASHLESS_SETUP = 0x11 -CASHLESS_POLL = 0x12 -CASHLESS_VEND = 0x13 -CASHLESS_READER = 0x14 -CASHLESS_REVALUE = 0x15 - -CASHLESS_EXP = 0x17 -CASHLESS_EXP_ID = 0x00 -CASHLESS_EXP_READ = 0x01 -CASHLESS_EXP_WRITE = 0x02 -CASHLESS_EXP_WRITE_TIME = 0x03 -CASHLESS_EXP_FEATURE_ENABLE = 0x04 - -BILL_RESET = 0x30 -BILL_SETUP = 0x31 -BILL_POLL = 0x33 -BILL_TYPE = 0x34 -BILL_STACKER = 0x36 -BILL_EXP = 0x37 -BILL_EXP_ID = 0x00 - -# Page 26 - peripheral addresses -try: - import pigpio - - pi = pigpio.pi() - pi.wave_clear() - pi.set_mode(TX_PIN, pigpio.INPUT) - - try: - pi.bb_serial_read_close(RX_PIN) - except: - pass - - status = pi.bb_serial_read_open(RX_PIN, 9600, 9) - - if status != 0: - logging.error('Open failed: %d', status) - exit(1) -except: - pass - - -def compute_chk(data): - return reduce(lambda a, b: (a+b)%256, data, 0) - -def compute_checksum(cmd, data): - return compute_chk(bytearray([cmd]) + data) - -def bcd_decode(b): - return 10 * ((b & 0xf0) >> 4) + (b & 0x0f) - -@cachetools.cached(cachetools.TTLCache(32, 600)) -def get_exchange_rate(currency='PLN'): - return requests.get('https://blockchain.info/pl/ticker').json()[currency]['last'] - -def to_local_currency(sat): - # Returns satoshi in local lowest denomination currency (grosze) - rate = get_exchange_rate() - return int(sat / 1000000.0 * rate) - -def from_local_currency(val): - rate = get_exchange_rate() - return int(val / rate * 1000000) - -def sat_to_btc(amount): - return amount / 100000000.0 - -def format_btc(amount): - return (u'฿%.8f' % (sat_to_btc(amount),)).rstrip('0').rstrip('.') - -class MDBRequest(object): - timestamp = None - data = None - command = None - processed = False - - def __init__(self, command): - self.timestamp = time.time() - self.command = command - self.data = bytearray() - - def validate_checksum(self): - if not self.data: - return False - - try: - if self.ack: - if len(self.data) == 1 and self.processed: - return True # Only ACK - - return self.data[-2] == compute_checksum(self.command, self.data[:-2]) - else: - return self.data[-1] == compute_checksum(self.command, self.data[:-1]) - except KeyboardInterrupt: - raise - except: - logging.exception('Checksum validation failed for: %r %r', self.command, self.data) - return False - @property - def ack(self): - return self.data[-1] == 0x00 - - def __repr__(self): - return '<MDBRequest 0x%02x [%s] chk:%r>' % ( - self.command, - ' '.join(['0x%02x' % b for b in self.data]), - self.validate_checksum() - ) - -class MDBDevice(object): - current_request = None - send_buffer = None - - def __init__(self): - self.logger = logging.getLogger(type(self).__name__) - self.poll_queue = queue.Queue() - - def initialize(self): - self.logger.info('Initializing...') - # here should IO / connection initizliation go - - def run(self): - self.initialize() - - while True: - cnt, data = pi.bb_serial_read(RX_PIN) - for b in range(0, cnt, 2): - if data[b+1]: - if self.current_request: # and not self.current_request.processed: - self.logger.debug(self.current_request) - if self.current_request.processed and self.current_request.ack: - self.logger.info('Got response: %d',self.current_request.data[-1]) - - self.current_request = MDBRequest(data[b]) - self.send_buffer = None - elif self.current_request: - if self.current_request.processed and data[b] == 0xaa and self.send_buffer: - self.logger.warning('Received RETRANSMIT %r %r', self.current_request, self.send_buffer) - #self.send(self.send_buffer, checksum=False) - else: - self.current_request.data.append(data[b]) - else: - self.logger.warning('Received unexpected data: 0x%02x', data[b]) - - if self.current_request and not self.current_request.processed: - try: - resp = self.process_request(self.current_request) - - if resp is not None: - self.current_request.processed = True - self.send(resp) - except KeyboardInterrupt: - raise - except: - self.logger.exception('Request process failed!') - - def send(self, data, checksum=True): - data = list(data) - if checksum: - data.append(0x100 | compute_chk(data)) - - msg = struct.pack('<%dh' % len(data), *data) - - self.send_buffer = data - - self.logger.debug('>> [%d bytes] %s', len(data), ' '.join(['0x%02x' % b for b in data])) - - pi.wave_clear() - pi.wave_add_serial(TX_PIN, 9600, msg, bb_bits=9, bb_stop=6) - wid = pi.wave_create() - - pi.set_mode(TX_PIN, pigpio.OUTPUT) - pi.wave_send_once(wid) # transmit serial data - - while pi.wave_tx_busy(): # wait until all data sent - pass - - pi.wave_delete(wid) - pi.set_mode(TX_PIN, pigpio.INPUT) - - def process_request(self, req): - # Unimplemented... - return - -# -# This is mostly working cashless device implementation -# - -class CashlessMDBDevice(MDBDevice): - base_address = CASHLESS_RESET - state = 'IDLE' - - config_data = [ - 0x01, # Feature level - 0x19, 0x85, # PLN x---DD - 1, # scaling factor - 0x00, # decimal places factor - 10, # 10s response time - 0x00, # misc options... - ] - - manufacturer = 'GMD' - serial_number = '123456789012' - model_number = '123456789012' - software_version = (0x21, 0x37) - - def process_request(self, req): - if (req.command & self.base_address) != self.base_address: - # Target mismatch - return - - if not req.validate_checksum(): - # Invalid checksum - return - - if req.command == CASHLESS_RESET: - self.state = 'RESET' - self.logger.info('RESET: Device reset') - self.poll_queue.put([0x00]) - - return [] - - elif req.command == CASHLESS_POLL: - try: - msg = self.poll_queue.get_nowait() - self.logger.info('Sending POLL response: %r', msg) - return msg - except queue.Empty: - return [] - - #if self.state == 'RESET': - # self.state = 'IDLE' - # self.logger.info('POLL: Sending JUST RESET') - # return [0b00] # validator was reset - #elif self.state == 'IDLE': - # self.state = 'SESSION' - # self.logger.info('POLL: starting session') - # return [0x03, 0xff, 0xff] # goes up to 65535 x---DD - #else: - # return [] - # #return([0x02, 10, ord('J'), ord('P'), ord('P')] + [ord('X')] * (32-3)) - - elif req.command == CASHLESS_VEND: - if req.data[0] == 0x00: # vend request - self.logger.info('VEND: request %r', req) - value, product_bcd = struct.unpack('>xhhx', req.data) - product = bcd_decode(product_bcd) - self.logger.info('VEND: requested %d for %d', product, value) - # accept. two latter bytes are value subtracted from balance - # displayed after purchase - return [0x05, 0x00, 0xff] - - elif req.data[0] == 0x01: # vend cancel - self.logger.info('VEND: cancel') - return [0x06] # deny == ok - - elif req.data[0] == 0x02: # vend succ - self.logger.info('VEND: success %r', req) - return [] - - elif req.data[0] == 0x03: - self.logger.info('VEND: failure') - return [] - - elif req.data[0] == 0x04: - self.logger.info('VEND: session complete %r', req) - self.state = 'IDLE' - return [0x07] - - elif req.data[0] == 0x05: - self.logger.info('VEND: cash sale') - return [] - - elif req.data[0] == 0x06: - self.logger.info('VEND: negative vend request') - return [0x06] # deny - else: - self.logger.warning('VEND: unknown command %r', req) - - elif req.command == CASHLESS_EXP and req.data[0] == CASHLESS_EXP_ID: - self.logger.info('EXP_ID request') - return ( - bytearray([0x09]) + # peripheral ID - bytearray(self.manufacturer.rjust(3, ' ').encode('ascii')) + - bytearray(self.serial_number.rjust(12, '0').encode('ascii')) + - bytearray(self.model_number.rjust(12, '0').encode('ascii')) + - bytearray(self.software_version) - ) - - elif req.command == CASHLESS_SETUP and req.data[0] == 0x00 and len(req.data) == 6: - vmc_level, disp_cols, disp_rows, disp_info = req.data[1:-1] - - self.logger.info('SETUP config') - self.logger.info(' -> VMC level: %d', vmc_level) - self.logger.info(' -> Disp cols: %d', disp_cols) - self.logger.info(' -> Disp rows: %d', disp_rows) - self.logger.info(' -> Disp info: %d', disp_info) - - self.state = 'IDLE' - - return [0x01] + self.config_data - - elif req.command == CASHLESS_SETUP and req.data[0] == 0x01: - self.logger.info('SETUP max/min price: %r', req) - return [] - - elif req.command == CASHLESS_READER: - self.logger.info('READER update: %r', req.data[0]) - return [] - - def begin_session(self, amount): - if amount > 65535: - amount = 65535 - - self.poll_queue.put([0x03, (amount >> 8) & 0xff, amount & 0xff]) - - def cancel_session(self): - self.poll_queue.put([0x04]) - -# -# This is mostly unfinished Bill validator implementation -# - -class BillMDBDevice(MDBDevice): - scaling_factor = 50 - - bills = [ - 50, 100, 200, 500, 1000, 2000, 5000, 10000, - 0, 0, 0, 0, 0, 0, 0, 0, - ] - - feed_bills = [] - - def feed_amount(self, amount): - if amount % self.scaling_factor: - raise Exception('Invalid amount') - - while amount > 0: - bills_list = filter(lambda v: v <= amount, self.bills) - bills_list.sort() - - self.feed_bills.append(self.bills.index(bills_list[-1])) - amount -= bills_list[-1] - dev = CashlessMDBDevice() +proc = PaymentProcessor(dev) app = flask.Flask(__name__) +QRcode(app) +app.config['INPUT_ADDRESS'] = '12fkW5EBb3uBy1zD8pan4TcbabP5Fjato7' app.config['TEMPLATES_AUTO_RELOAD'] = True - +app.config['ITEMS'] = [ + { + 'name': 'Club Mate', + 'image': '/static/img/club-mate.png', + 'value': 500, + }, + { + 'name': 'Arduino Pro Micro', + 'image': '/static/img/promicro.png', + 'value': 1600, + }, +] +''' + { + 'name': 'Arduino Pro Mini', + 'image': '', + 'value': 750, + }, +''' @app.route('/') def index(): return flask.render_template( - 'index.html', items=[ - { - 'name': 'Club Mate', - 'image': 'http://scrummy.pl/2766-thickbox_default/club-mate.jpg', - 'value': 500, - }, - { - 'name': 'Arduino Pro Micro', - 'image': 'https://hackerspace.pl/~informatic/dropbox/1d59bf26f1352da558f249d815db6376.png', - 'value': 0, - } - ]) + 'index.html', items=app.config['ITEMS']) @app.route('/begin/<int:amount>') def begin_session(amount): @@ -399,7 +55,7 @@ def begin_session(amount): @app.route('/cancel') def cancel_session(): - dev.cancel_session(); + dev.cancel_session() return 'ok' @app.context_processor @@ -408,9 +64,12 @@ def ctx_utils(): 'from_local_currency': from_local_currency, 'to_local_currency': to_local_currency, 'format_btc': format_btc, + 'sat_to_btc': sat_to_btc, } if __name__ == "__main__": - app.run() - #threading.Thread(target=app.run, kwargs={'host': '0.0.0.0'}, daemon=True).start() - #dev.run() + threading.Thread(target=app.run, kwargs={ + 'host': '0.0.0.0' + }, daemon=True).start() + proc.start() + dev.run() diff --git a/bitvend/__init__.py b/bitvend/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/bitvend/__init__.py diff --git a/bitvend/mdb.py b/bitvend/mdb.py new file mode 100644 index 0000000..4a8c51c --- /dev/null +++ b/bitvend/mdb.py @@ -0,0 +1,375 @@ +import time +import struct +import logging + +try: + import queue +except ImportError: + import Queue as queue + +from functools import reduce + + +RX_PIN = 4 +TX_PIN = 17 + +# Page 26 - peripheral addresses + +CASHLESS_RESET = 0x10 +CASHLESS_SETUP = 0x11 +CASHLESS_POLL = 0x12 +CASHLESS_VEND = 0x13 +CASHLESS_READER = 0x14 +CASHLESS_REVALUE = 0x15 + +CASHLESS_EXP = 0x17 +CASHLESS_EXP_ID = 0x00 +CASHLESS_EXP_READ = 0x01 +CASHLESS_EXP_WRITE = 0x02 +CASHLESS_EXP_WRITE_TIME = 0x03 +CASHLESS_EXP_FEATURE_ENABLE = 0x04 + +BILL_RESET = 0x30 +BILL_SETUP = 0x31 +BILL_POLL = 0x33 +BILL_TYPE = 0x34 +BILL_STACKER = 0x36 +BILL_EXP = 0x37 +BILL_EXP_ID = 0x00 + +try: + import pigpio +except: + pass + + +def compute_chk(data): + return reduce(lambda a, b: (a+b)%256, data, 0) + +def compute_checksum(cmd, data): + return compute_chk(bytearray([cmd]) + data) + +def bcd_decode(b): + return 10 * ((b & 0xf0) >> 4) + (b & 0x0f) + + +class MDBRequest(object): + timestamp = None + data = None + command = None + processed = False + + def __init__(self, command): + self.timestamp = time.time() + self.command = command + self.data = bytearray() + + def validate_checksum(self): + if not self.data: + return False + + try: + if self.ack: + if len(self.data) == 1 and self.processed: + return True # Only ACK + + return self.data[-2] == compute_checksum(self.command, self.data[:-2]) + else: + return self.data[-1] == compute_checksum(self.command, self.data[:-1]) + except KeyboardInterrupt: + raise + except: + logging.exception('Checksum validation failed for: %r %r', self.command, self.data) + return False + @property + def ack(self): + return self.data[-1] == 0x00 + + def __repr__(self): + return '<MDBRequest 0x%02x [%s] chk:%r>' % ( + self.command, + ' '.join(['0x%02x' % b for b in self.data]), + self.validate_checksum() + ) + +class BackendDevice(object): + def __init__(self): + pass + + def open(self): + pass + + def close(self): + pass + + def read(self): + time.sleep(0.005) + return b'' + + def write(self, data): + pass + + +class RaspiDevice(BackendDevice): + def __init__(self, rx_pin=RX_PIN, tx_pin=TX_PIN): + self.rx_pin = rx_pin + self.tx_pin = tx_pin + + def open(self): + self.pi = pigpio.pi() + self.pi.wave_clear() + self.pi.set_mode(self.tx_pin, pigpio.INPUT) + + try: + self.pi.bb_serial_read_close(self.rx_pin) + except: + pass + + status = self.pi.bb_serial_read_open(self.rx_pin, 9600, 9) + if status: + raise Exception('Port open failed: %d', status) + + def read(self): + _, data = self.pi.bb_serial_read(self.rx_pin) + return data + + def write(self, data): + self.pi.wave_clear() + self.pi.wave_add_serial(self.tx_pin, 9600, data, bb_bits=9, bb_stop=6) + wid = self.pi.wave_create() + + self.pi.set_mode(self.tx_pin, pigpio.OUTPUT) + self.pi.wave_send_once(wid) # transmit serial data + + while self.pi.wave_tx_busy(): # wait until all data sent + pass + + self.pi.wave_delete(wid) + self.pi.set_mode(self.tx_pin, pigpio.INPUT) + + +class MDBDevice(object): + current_request = None + send_buffer = None + + def __init__(self): + self.logger = logging.getLogger(type(self).__name__) + self.poll_queue = queue.Queue() + self.backend = RaspiDevice() + + def initialize(self): + self.logger.info('Initializing...') + self.backend.open() + # here should IO / connection initizliation go + + def run(self): + self.initialize() + + while True: + data = self.backend.read() + for b in range(0, len(data), 2): + if data[b+1]: + if self.current_request: # and not self.current_request.processed: + self.logger.debug(self.current_request) + if self.current_request.processed and self.current_request.ack: + self.logger.info('Got response: %d',self.current_request.data[-1]) + + self.current_request = MDBRequest(data[b]) + self.send_buffer = None + elif self.current_request: + if self.current_request.processed and data[b] == 0xaa and self.send_buffer: + self.logger.warning('Received RETRANSMIT %r %r', self.current_request, self.send_buffer) + #self.send(self.send_buffer, checksum=False) + else: + self.current_request.data.append(data[b]) + else: + self.logger.warning('Received unexpected data: 0x%02x', data[b]) + + if self.current_request and not self.current_request.processed: + try: + resp = self.process_request(self.current_request) + + if resp is not None: + self.current_request.processed = True + self.send(resp) + except KeyboardInterrupt: + raise + except: + self.logger.exception('Request process failed!') + + def send(self, data, checksum=True): + data = list(data) + if checksum: + data.append(0x100 | compute_chk(data)) + + msg = struct.pack('<%dh' % len(data), *data) + + self.send_buffer = data + + self.logger.debug('>> [%d bytes] %s', len(data), ' '.join(['0x%02x' % b for b in data])) + + self.backend.write(msg) + + def process_request(self, req): + # Unimplemented... + return + +# +# This is mostly working cashless device implementation +# + +class CashlessMDBDevice(MDBDevice): + base_address = CASHLESS_RESET + state = 'IDLE' + + config_data = [ + 0x01, # Feature level + 0x19, 0x85, # PLN x---DD + 1, # scaling factor + 0x00, # decimal places factor + 10, # 10s response time + 0x00, # misc options... + ] + + manufacturer = 'GMD' + serial_number = '123456789012' + model_number = '123456789012' + software_version = (0x21, 0x37) + + def process_request(self, req): + if (req.command & self.base_address) != self.base_address: + # Target mismatch + return + + if not req.validate_checksum(): + # Invalid checksum + return + + if req.command == CASHLESS_RESET: + self.state = 'RESET' + self.logger.info('RESET: Device reset') + self.poll_queue.put([0x00]) + + return [] + + elif req.command == CASHLESS_POLL: + try: + msg = self.poll_queue.get_nowait() + self.logger.info('Sending POLL response: %r', msg) + return msg + except queue.Empty: + return [] + + #if self.state == 'RESET': + # self.state = 'IDLE' + # self.logger.info('POLL: Sending JUST RESET') + # return [0b00] # validator was reset + #elif self.state == 'IDLE': + # self.state = 'SESSION' + # self.logger.info('POLL: starting session') + # return [0x03, 0xff, 0xff] # goes up to 65535 x---DD + #else: + # return [] + # #return([0x02, 10, ord('J'), ord('P'), ord('P')] + [ord('X')] * (32-3)) + + elif req.command == CASHLESS_VEND: + if req.data[0] == 0x00: # vend request + self.logger.info('VEND: request %r', req) + value, product_bcd = struct.unpack('>xhhx', req.data) + product = bcd_decode(product_bcd) + self.logger.info('VEND: requested %d for %d', product, value) + # accept. two latter bytes are value subtracted from balance + # displayed after purchase + return [0x05, 0x00, 0xff] + + elif req.data[0] == 0x01: # vend cancel + self.logger.info('VEND: cancel') + return [0x06] # deny == ok + + elif req.data[0] == 0x02: # vend succ + self.logger.info('VEND: success %r', req) + return [] + + elif req.data[0] == 0x03: + self.logger.info('VEND: failure') + return [] + + elif req.data[0] == 0x04: + self.logger.info('VEND: session complete %r', req) + self.state = 'IDLE' + return [0x07] + + elif req.data[0] == 0x05: + self.logger.info('VEND: cash sale') + return [] + + elif req.data[0] == 0x06: + self.logger.info('VEND: negative vend request') + return [0x06] # deny + else: + self.logger.warning('VEND: unknown command %r', req) + + elif req.command == CASHLESS_EXP and req.data[0] == CASHLESS_EXP_ID: + self.logger.info('EXP_ID request') + return ( + bytearray([0x09]) + # peripheral ID + bytearray(self.manufacturer.rjust(3, ' ').encode('ascii')) + + bytearray(self.serial_number.rjust(12, '0').encode('ascii')) + + bytearray(self.model_number.rjust(12, '0').encode('ascii')) + + bytearray(self.software_version) + ) + + elif req.command == CASHLESS_SETUP and req.data[0] == 0x00 and len(req.data) == 6: + vmc_level, disp_cols, disp_rows, disp_info = req.data[1:-1] + + self.logger.info('SETUP config') + self.logger.info(' -> VMC level: %d', vmc_level) + self.logger.info(' -> Disp cols: %d', disp_cols) + self.logger.info(' -> Disp rows: %d', disp_rows) + self.logger.info(' -> Disp info: %d', disp_info) + + self.state = 'IDLE' + + return [0x01] + self.config_data + + elif req.command == CASHLESS_SETUP and req.data[0] == 0x01: + self.logger.info('SETUP max/min price: %r', req) + return [] + + elif req.command == CASHLESS_READER: + self.logger.info('READER update: %r', req.data[0]) + return [] + + def begin_session(self, amount): + if amount > 65535: + amount = 65535 + + self.poll_queue.put([0x03, (amount >> 8) & 0xff, amount & 0xff]) + + def cancel_session(self): + self.poll_queue.put([0x04]) + +# +# This is mostly unfinished Bill validator implementation +# + +class BillMDBDevice(MDBDevice): + scaling_factor = 50 + + bills = [ + 50, 100, 200, 500, 1000, 2000, 5000, 10000, + 0, 0, 0, 0, 0, 0, 0, 0, + ] + + feed_bills = [] + + def feed_amount(self, amount): + if amount % self.scaling_factor: + raise Exception('Invalid amount') + + while amount > 0: + bills_list = filter(lambda v: v <= amount, self.bills) + bills_list.sort() + + self.feed_bills.append(self.bills.index(bills_list[-1])) + amount -= bills_list[-1] diff --git a/bitvend/processor.py b/bitvend/processor.py new file mode 100644 index 0000000..1291940 --- /dev/null +++ b/bitvend/processor.py @@ -0,0 +1,94 @@ +import threading +import json +import websocket +import pprint +import time +import logging + +from bitvend.utils import to_local_currency + + +class PaymentProcessor(threading.Thread): + daemon = True + input_address = None + device = None + + def __init__(self, device, input_address='12fkW5EBb3uBy1zD8pan4TcbabP5Fjato7'): + super(PaymentProcessor, self).__init__() + self.device = device + self.input_address = input_address + self.logger = logging.getLogger(type(self).__name__) + + def run(self): + self.logger.info('Starting...') + ws = websocket.WebSocketApp( + "wss://ws.blockchain.info/inv", + on_message=self.on_message, + on_error=self.on_error, + on_close=self.on_close) + + ws.on_open = self.on_open + + while True: + ws.run_forever() + + def process_transaction(self, tx): + tx_size = tx['x']['size'] + tx_hash = tx['x']['hash'] + tx_value = sum([ + o['value'] for o in tx['x']['out'] if o['addr'] == self.input_address + ], 0) + fee = sum([i['prev_out']['value'] for i in tx['x']['inputs']]) - \ + sum([o['value'] for o in tx['x']['out']]) + + fee_byte = fee / tx_size + + self.logger.info('%r %r %r %r %r', tx_size, tx_hash, tx_value, fee, fee_byte) + self.logger.info('In local currency: %r', to_local_currency(tx_value)) + + if to_local_currency(tx_value) < 100: + self.logger.warning('Whyyyy so low...') + return + + if fee_byte < 50: + self.logger.warning('Fee too low...') + return + + self.logger.info('Transaction ok, going to device...') + + # FIXME we need better handling of ACK on POLL responses... + self.device.begin_session(to_local_currency(tx_value)) + self.device.begin_session(to_local_currency(tx_value)) + self.device.begin_session(to_local_currency(tx_value)) + + def on_message(self, ws, message): + #print message + data = json.loads(message) + if data['op'] == 'utx': + self.process_transaction(data) + + def on_error(self, ws, error): + self.logger.error(error) + + def on_close(self, ws): + self.logger.info('Connection closed') + + def on_open(self, ws): + self.logger.info('Connected') + + ws.send(json.dumps({ + "op": "addr_sub", + "addr": self.input_address + })) + + threading.Thread(target=self.keepalive, args=(ws,), daemon=True).start() + + def keepalive(self, ws): + # Keepalive thread target, just send ping once in a while + while True: + # FIXME check last ping time + self.logger.info('Pinging...') + ws.send(json.dumps({ + "op": "ping" + })) + time.sleep(20) diff --git a/bitvend/utils.py b/bitvend/utils.py new file mode 100644 index 0000000..d360477 --- /dev/null +++ b/bitvend/utils.py @@ -0,0 +1,25 @@ +import cachetools +import requests + +@cachetools.cached(cachetools.TTLCache(32, 600)) +def get_exchange_rate(currency='PLN'): + # Returns current exchange rate for selected currency + return requests.get('https://blockchain.info/pl/ticker').json()[currency]['last'] + +def to_local_currency(sat): + # Returns satoshi in local lowest denomination currency (grosze) + rate = get_exchange_rate() + return int(sat / 1000000.0 * rate) + +def from_local_currency(val): + # Returns satoshi value from local currency + rate = get_exchange_rate() + return int(val / rate * 1000000) + +def sat_to_btc(amount): + # Converts satoshi to BTC + return amount / 100000000.0 + +def format_btc(amount): + # Formats satoshi to human-readable format + return (u'฿%.8f' % (sat_to_btc(amount),)).rstrip('0').rstrip('.') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..47941f6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +cachetools==2.0.0 +click==6.7 +Flask==0.12 +itsdangerous==0.24 +Jinja2==2.9.4 +MarkupSafe==0.23 +pkg-resources==0.0.0 +requests==2.12.4 +six==1.10.0 +websocket-client==0.40.0 +Werkzeug==0.11.15 diff --git a/static/img/club-mate.png b/static/img/club-mate.png Binary files differnew file mode 100644 index 0000000..3267bbf --- /dev/null +++ b/static/img/club-mate.png diff --git a/static/img/promicro.png b/static/img/promicro.png Binary files differnew file mode 100644 index 0000000..a3cab8f --- /dev/null +++ b/static/img/promicro.png diff --git a/templates/index.html b/templates/index.html index 3258d08..b6e88c1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -16,6 +16,14 @@ body { margin-top: 80px; } + h3 { + margin: 0; + padding: 0; + padding-bottom: 0.5em; + } + footer { + padding-bottom: 20px; + } </style> </head> <body> @@ -29,20 +37,52 @@ <span class="icon-bar"></span> </button> </div> + <div class="navbar-collapse collapse" id="navbar-main"> + <p class="navbar-text navbar-right"> + 1zł = {{ format_btc(from_local_currency(100)) }} + </p> + <p class="navbar-text navbar-right"> + <b>Rate:</b> {{ to_local_currency(100000000) / 100 }}zł + </p> + </div> </div> </div> <div class="container"> + <div class="row"> {% for item in items %} - <div class="col-md-3"> + <div class="col-sm-6"> <div class="well vend-item"> - <img src="{{ item.image }}" class="img-responsive" /> - <span class="label label-default pull-right">{{ '%1.2fzł'|format(item.value/100) }} / {{ format_btc(from_local_currency(item.value)) }}</span> - <big>{{ item.name }}</big> - <img src="https://blockchain.info/qr?data=bitcoin:12ihVyoAGRci1K5hGQaQveEy7ThfwXfNSS&amount={{ from_local_currency(item.value) }}&size=288" class="img-responsive" /> + <div class="row"> + <div class="col-md-12"> + <div class="pull-right"> + <span class="label label-info">{{ '%.2fzł'|format(item.value/100) }}</span> + <span class="label label-primary">{{ format_btc(from_local_currency(item.value*1.1)) }}</span> + </div> + <h3>{{ item.name }}</h3> + </div> + <div class="col-xs-6"> + <img src="{{ item.image }}" class="img-responsive center-block" /> + </div> + <div class="col-xs-6"> + {% with btc_uri = 'bitcoin:%s?amount=%s' % (config['INPUT_ADDRESS'], sat_to_btc(from_local_currency(item.value*1.1))) %} + <a href="{{ btc_uri }}"> + <img src="{{ qrcode(btc_uri) }}" class="img-responsive center-block"/> + <center><code><small>{{ config['INPUT_ADDRESS'] }}</small></code></center> + </a> + {% endwith %} + </div> + </div> </div> </div> {% endfor %} + </div> + + <hr> + + <footer class="row"><big>made by <a href="https://wiki.hackerspace.pl/people:informatic:start">inf</a></big> + <span class="pull-right text-right">1infuHrvt7tuGY8nJcfTwB3wwAR1XwUcW • <a href="https://code.hackerspace.pl/informatic/bitvend">sauce</a><br />report any issues to <a href="mailto:informatic@hackerspace.pl">informatic@hackerspace.pl</a></span> + </footer> </div> </body> </html> |