#!/usr/bin/env python2.7
# -*- coding: utf-8 -*-
# combine multiple physical payment devices (coin/banknote pay-in and pay-out)
# into one logical device
# (C) 2013 Max Gaukler <development@maxgaukler.de>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# The text of the license conditions can be read at
# <http://www.gnu.org/licenses/>.
import random
import unittest
from PaymentDeviceClient import PaymentDeviceClient
import logging
import time
from ConfigParser import NoOptionError
# for unittest
from ConfigParser import ConfigParser
import codecs
[docs]class PaymentDevicesManager(object):
def __init__(self, cfg):
self.devices = []
self._reset()
if cfg.get("payup_methods", "cash") == "on":
for n in range(1, 999):
prefix = "device" + str(n) + "_"
try:
cmd = cfg.get("cash_payment", "device" + str(n))
except NoOptionError:
break
options = {}
for (key, value) in cfg.items("cash_payment"):
if not key.startswith(prefix):
continue
# remove prefix
key = key[len(prefix):]
options[key] = value
self.devices.append(PaymentDeviceClient(cmd, options))
logging.info("cashPayment started with {0} devices".format(len(self.devices)))
self.mode = "start"
def _reset(self):
self.requestedPayin = None
self.maximumPayin = None
self.requestedPayout = None
self.payoutDeviceNumber = None
self.payoutDeviceDispensing = False
self.finishedAmount = 0
[docs] def poll(self):
"""
call repeatedly to update status
:rtype: None
"""
for d in self.devices:
try:
d.poll()
except Exception:
logging.error("device {0} failed - see cash-{1}.log. If it crashed before writing the logfile, try launching the server yourself with the commandline from gui.log ".format(d, d.options['name']))
raise
if self.mode == "idle":
pass
elif self.mode == "start":
# query if device can accept
if None in [d.canAccept() for d in self.devices]:
pass # still busy waiting for answer
else:
self.mode = "idle"
elif self.mode == "canPayout":
for i in range(len(self.devices)):
if self.canPayoutAmounts[i] == None:
# no valid reply received yet
self.canPayoutAmounts[i] = self.devices[i].possibleDispense()
elif self.mode == "payin":
if self.getCurrentAmount() >= self.requestedPayin:
self.abortPayin()
else:
self._updatePayinAmounts()
elif self.mode == "payinStop":
if False in [d.hasStopped() for d in self.devices]:
pass # waiting for devices to finish
else:
for d in self.devices:
final = d.getFinalAmountAndReset()
assert final >= 0
self.finishedAmount += final
logging.info("cash accept finished: total {0}".format(self.finishedAmount))
self.mode = "stopped"
elif self.mode == "payout":
# sequential:
# per client: dispense, wait until finished, get final amount
d = self.devices[self.payoutDeviceNumber]
if not self.payoutDeviceDispensing:
# self.finishedAmount is negative!
dispenseAmount = self.requestedPayout + self.finishedAmount
logging.info("trying to pay out {0} on device {1}".format(dispenseAmount, self.payoutDeviceNumber))
d.dispense(dispenseAmount)
self.payoutDeviceDispensing = True
else:
if d.hasStopped():
final = d.getFinalAmountAndReset()
assert final <= 0
self.finishedAmount += final
# go on to next device
self.payoutDeviceNumber += 1
self.payoutDeviceDispensing = False
if self.payoutDeviceNumber == len(self.devices):
# all devices are finished
logging.info("cash payout finished: total {0}".format(self.finishedAmount))
self.mode = "stopped"
elif self.mode == "empty":
pass # just poll and wait for stop command
elif self.mode == "emptyingStop":
if False in [d.hasStopped() for d in self.devices]:
pass # waiting for devices to finish
else:
for d in self.devices:
final = d.getFinalAmountAndReset()
assert final <= 0
self.finishedAmount += final
self.mode = "stopped"
elif self.mode == "stopped":
pass # do nothing
else:
raise Exception("unknown mode")
[docs] def canPayout(self):
"""
returns values [totalMaximumRequest, totalRemaining]:
every requested amount <= totalMaximumRequest can be paid out, with an unpaid rest <= totalRemaining
(the return value is only a conservative estimate, not the theoretical optimum)
Please warn the user if totalMaximumRequest is too low for the possible change
if this function returns None, the value is still being fetched.
In this case, sleep some time, then call poll() and then call the function again.
"""
if self.mode == "idle":
# fill canPayoutAmounts with "None" values
self.canPayoutAmounts = [None for _ in self.devices]
self.mode = "canPayout"
return None
if self.mode == "canPayout":
# commands already sent
if not None in self.canPayoutAmounts:
# all devices sent replies
canPayoutAmounts = self.canPayoutAmounts
self.canPayoutAmounts = None # invalidate cache
self.mode = "idle"
return self._canPayout_total(canPayoutAmounts)
else:
# still waiting for some replies
return None
else:
raise Exception("canPayout() is not possible when busy")
def _canPayout_total(self, canPayoutAmounts):
"""
find values totalMaximumRequest (as high as possible) and totalRemaining (as low as possible) so that:
every requested amount <= totalMaximumRequest can be paid out, with an unpaid rest <= totalRemaining
canPayoutAmounts=[[maximumRequest, remaining], ...] values for the individual payment devices
the return value is only a conservative estimate, not the theoretical optimum
:return: [totalMaximumRequest, totalRemaining]
"""
if not canPayoutAmounts:
return [0, 0]
[totalMaximumRequest, totalRemaining] = canPayoutAmounts[0]
for [maximumRequest, remainingAmount] in canPayoutAmounts[1:]:
if remainingAmount >= maximumRequest:
# cannot dispense (empty)
continue
if remainingAmount <= totalRemaining:
# this device has a finer resolution, i.e. smaller coins
if maximumRequest >= totalRemaining:
# and we may request the whole remaining amount
# example: previous device: max 200€ with 9,99€ rest
# this device: max n € with 0,50€ rest
# n=10: okay, n=2: not okay
# if the device has more than the previously remaining amount, we can payout even more than the previous maximum
totalMaximumRequest += maximumRequest - totalRemaining
totalRemaining = remainingAmount
# example: we now can pay >= (200 + n - 9,99) with 0,50 rest
assert remainingAmount <= maximumRequest
else:
# APPROXIMATION: assume zero payout
pass
return [totalMaximumRequest, totalRemaining]
[docs] def payin(self, requested, maximum):
assert self.mode == "idle"
self.requestedPayin = requested
self.maximumPayin = maximum
for d in self.devices:
d.accept(self.maximumPayin)
d.poll()
self.mode = "payin"
def _updatePayinAmounts(self):
"""
while accept is running:
if cash is inserted into one device, lower the allowed maximum of all other devices
(use case: there is a banknote accepting device and a separate coin
accepting device. If the user may pay in 100€ maximum and has already
inserted lots of coins, he may no longer use a 100€ banknote.)
"""
for d in self.devices:
maximum = self.maximumPayin - self.getCurrentAmount()
if d.getCurrentAmount() != None:
maximum += d.getCurrentAmount()
d.updateAcceptValue(maximum)
[docs] def getCurrentAmount(self):
"""
get intermediate amount, how much was paid in or out
"""
totalSum = self.finishedAmount
for d in self.devices:
if d.getCurrentAmount() != None:
totalSum += d.getCurrentAmount()
return totalSum
[docs] def empty(self):
"""
start service-empty mode
see PaymentDeviceClient.empty
:rtype: None
"""
assert self.mode == "idle"
self.mode = "empty"
for d in self.devices:
d.empty()
d.poll()
[docs] def stopEmptying(self):
"""
exit service-empty mode
use getFinalAmount() afterwards
:rtype: None
"""
assert self.mode == "empty"
self.mode = "emptyingStop"
for d in self.devices:
d.stopEmptying()
[docs] def statusText(self):
def formatCent(x):
return u"{:.2f}\u2009€".format(float(x) / 100).replace(".", ",")
totalSum = self.getCurrentAmount()
if self.mode.startswith("payout"):
totalSum = -totalSum
modes = {"payin": "Bitte bezahlen",
"payinStop": "Bezahlung wird abgeschlossen...",
"stopped": "Bitte warten, Daten werden gespeichert",
"idle": "Bereit",
"payout": u"Zahle Rückgeld...",
"start": "Bitte warten, initialisiere...",
"canPayout": u"Bitte warten, prüfe Wechselgeldvorrat...",
"empty": u"Service Ausleeren aktiv: Geldscheinspeicher->Cashbox (automatisch), Münzauswurf (manuell: Knopf drücken),\n zum Beenden Abbrechen drücken",
"emptyingStop": u"Service: beende automatisches/manuelles Ausleeren..."}
text = u""
if self.mode in modes.keys():
text += modes[self.mode]
else:
text += "bitte warten (Modus {0})".format(self.mode)
requested = None
if self.mode.startswith("payin"):
requested = self.requestedPayin
elif self.mode.startswith("payout"):
requested = self.requestedPayout
if requested is not None:
text += u":\n{0} von {1} ".format(formatCent(totalSum), formatCent(requested))
if self.mode.startswith("payin"):
text += "bezahlt (maximal " + formatCent(self.maximumPayin) + ")"
elif self.mode.startswith("payout"):
text += "ausgezahlt"
elif self.mode.startswith("empty"):
text += "\n" + formatCent(totalSum)
return text
[docs] def startingUp(self):
"""
return True if devices are still being started
No action methods may be called until this returns False
"""
return self.mode == "start"
[docs] def payout(self, value):
assert self.mode == "idle"
assert value >= 0
self.mode = "payout"
self.requestedPayout = value
self.payoutDeviceNumber = 0
[docs] def abortPayin(self):
if self.mode == "payin":
for d in self.devices:
d.stopAccepting()
self.mode = "payinStop"
elif self.mode == "payinStop":
pass
else:
raise Exception("abortPayin in wrong mode")
[docs] def getFinalAmount(self):
"""
if stopped, return the final amount and reset to idle state
else, return None
"""
if self.mode != "stopped":
return None
ret = self.finishedAmount
self._reset()
self.mode = "idle"
return ret
[docs]class PaymentDevicesManagerTest(unittest.TestCase):
""" Test PaymentDevicesManager
"""
[docs] def test_canPayout_with_one_random_datapoint_on_example_server(self):
"""
test the _canPayout_total() function with 10 random datapoints and the exampleserver (from example config)
"""
# probably hacky, should be improved
cfg = ConfigParser()
cfg.readfp(codecs.open('./FabLabKasse/config.ini.example', 'r', 'utf8'))
for _ in range(0, 9):
history = []
p = PaymentDevicesManager(cfg=cfg)
p.poll()
def randFactor(): # 0 or 1 or something inbetween
r = random.random() * 1.2 - 0.1
if r < 0:
r = 0
if r > 1:
r = 1
return r
def myRandInt(n): # 0 ... n, with a finite >0 probability for both endpoints
return int(randFactor() * n)
canPayoutAmounts = []
n = random.randint(2, 5)
# fill canPayoutAmounts with random foo
for _ in range(n):
canPayoutAmounts.append([int(randFactor() * randFactor() * 70000), myRandInt(1023)])
[canMaximumRequest, canRemain] = p._canPayout_total(canPayoutAmounts)
requested = myRandInt(canMaximumRequest)
paidOut = 0
for [maximumRequest, maximumRemaining] in canPayoutAmounts:
nowRequested = requested - paidOut
nowRequested_limited = nowRequested
if nowRequested > maximumRequest:
# requested more than the guaranteed amount
nowRequested_limited = maximumRequest + myRandInt(nowRequested - maximumRequest)
nowPaidOut = nowRequested_limited - myRandInt(maximumRemaining)
if nowPaidOut < 0:
nowPaidOut = 0
if nowRequested <= maximumRequest:
# request is in the accepted range, will be satisfied
self.assertTrue(maximumRequest >= nowRequested >= nowPaidOut >= nowRequested - maximumRemaining)
else:
# requested more than guaranteed, may not be satisfied
self.assertTrue(nowRequested >= nowPaidOut >= maximumRequest - maximumRemaining)
history.append([requested, paidOut, nowPaidOut, nowRequested])
paidOut += nowPaidOut
msg = "Failed: {0} {1} {2} {3} {4}\n".format(requested, paidOut, canMaximumRequest, canRemain, history)
msg += str(canPayoutAmounts)
self.assertTrue(requested - canRemain <= paidOut <= requested, msg=msg)
self.assertTrue(paidOut >= 0, msg=msg)
[docs]def demo():
"""Simple demonstration using two exampleServer devices"""
# TODO this code seems to be broken, maybe adapt code from unittest or discard
p = PaymentDevicesManager(["exampleServer", "exampleServer"])
def wait():
p.poll()
print p.statusText()
time.sleep(0.3)
while p.startingUp():
wait()
pay = None
while pay is None:
wait()
pay = p.canPayout()
print pay
print "Es dürfen maximal {0} Cent gezahlt werden. Ein Rest-Rückgeld von unter {1} Cent wird nicht zurückgegeben!".format(pay[0], pay[1])
shouldPay = 4213
p.payin(shouldPay, shouldPay + pay[0])
received = None
while received is None:
received = p.getFinalAmount()
wait()
print "Geld erhalten: {0}".format(received)
p.poll()
if received > shouldPay:
p.payout(received - shouldPay)
paidOut = None
while paidOut is None:
paidOut = p.getFinalAmount()
wait()
paidOut = -paidOut
print "Rückgeld gezahlt: {0}".format(paidOut)
print "nicht ausgezahltes Rückgeld: {0}".format(received - shouldPay - paidOut)
if __name__ == "__main__":
demo()