#!/usr/bin/env python2.7
# -*- coding: utf-8 -*-
# connection to MDB interface hardware, which is connected to a MDB cash changer
# (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 time
import serial
import logging
import re
import unittest
import FabLabKasse.cashPayment.server.helpers.coin_payout_helper as coin_payout_helper
[docs]class BusError(Exception):
pass
[docs]class InterfaceHardwareError(Exception):
pass
[docs]class MissingResetEventError(Exception):
pass
[docs]class MdbCashDevice(object):
# changer commands - subtracting 8 from the (wrong) values in the specification
CMD_RESET = 0x08 - 8
CMD_TUBE_STATUS = 0x0A - 8
CMD_POLL = 0x0B - 8
CMD_SETUP = 0x09 - 8
CMD_COIN_TYPE = 0x0C - 8
CMD_DISPENSE = 0x0D - 8
CMD_EXPANSION = 0x0F - 8
NAK = 0xFF
ACK = 0x00
RET = 0xAA
IGNORE = "ignore"
WARNING = "warning"
BUSY = "busy"
ERROR = "error"
JUST_RESET = "just reset"
statusEvents = {
0b0001: ["Escrow request", IGNORE],
0b0010: ["Payout Busy", BUSY],
0b0011: ["valid coin did not get to the place where credit is given", WARNING],
0b0100: ["Defective Tube Sensor", WARNING],
0b0101: ["Double Arrival", IGNORE],
0b0110: ["Acceptor unplugged", ERROR],
0b0111: ["Tube jam", WARNING],
0b1000: ["ROM checksum error", ERROR],
0b1001: ["coin routing error", ERROR],
0b1010: ["Busy", BUSY],
0b1011: ["Was just reset", JUST_RESET],
0b1100: ["Coin jam", WARNING],
0b1101: ["Possible credited coin removal", WARNING]
}
def __init__(self, port, addr=0b00001, extensionConfig=None):
"""
:param extensionConfig: settings for extension commands (by the interface hardware, not on the MDB bus). dictionary.
:param extensionConfig["hopper"]: Set to False to only use MDB. Set to a coin value to enable an external non-MDB hopper (like Compact Hopper SBB) with the given coin value (e.g. 200 for 2.00€). Currently this hopper is always used first for payout, so it should be filled with the highest possible coin value.
:param extensionConfig["leds"]: True to enable RGB-LEDs for payin/payout via extension command
"""
if not extensionConfig:
extensionConfig = {"hopper": False, "leds": False}
self.ser = serial.serial_for_url(port, 38400, timeout=0.2)
assert 0 <= addr < 2 ** 5
self.addr = addr
self.buffer = ""
self.extensionConfig = extensionConfig
assert self.extensionConfig.get("hopper", False) in [False] + range(1, 9999), "invalid extensionConfig for hopper"
self.reset()
# =======================
# debug functions
# =======================
def __repr__(self):
return "<MDB>"
[docs] def printDebug(self, s, debugLevel):
logLevels = {-1: logging.ERROR, 0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG, 3: logging.DEBUG - 1}
logging.getLogger(self.__repr__()).log(logLevels[debugLevel], s)
[docs] def error(self, s):
self.printDebug(s, -1)
[docs] def log(self, s):
self.printDebug(s, 1)
[docs] def warn(self, s):
self.printDebug(s, 0)
# =======================
# Low-Level Send/Receive
# =======================
# clear all read buffers
[docs] def flushRead(self):
while self.ser.inWaiting() > 0:
self.warn("flushing serial read buffer")
self.ser.read(self.ser.inWaiting())
if self.buffer:
self.warn("flushing input buffer")
self.buffer = ""
[docs] def serialCmd(self, text):
logging.debug("serial send: '{0}'".format(text))
self.ser.write(text + "\n")
# get data from serial port
# return values:
# None: No answer from interface hardware received.
# raises:
# BusError: the bus device failed to respond, it is guaranteed that no ACK has been sent. Please resend.
# InterfaceHardwareError: the interface hardware did not respond properly, a packet might have been lost between interface and PC!
# Do not attempt to resend, because the lost reply could have been ACKed by the interface!
[docs] def read(self):
bytesToRead = self.ser.inWaiting()
# logging.debug(bytesToRead)
if bytesToRead > 0:
self.buffer += self.ser.read(bytesToRead)
else:
pass
# logging.debug("not read:" + self.ser.read())
if self.buffer:
logging.debug("buffer: {0}".format(self.buffer.__repr__()))
if not ("\n" in self.buffer):
# we have not yet received a full response
return None
if not self.buffer.endswith("\n"):
self.warn("received more than one response - flushing buffer. ignore next warnings. buffer:" + self.buffer.__repr__())
self.buffer = ""
raise InterfaceHardwareError("received more than one response")
if self.buffer == "RT\n":
self.log("bus timeout")
self.buffer = ""
raise BusError("bus timeout")
if self.buffer == "RN\n":
self.log("bus NAK")
self.buffer = ""
raise BusError("bus NAK")
if self.buffer == "RE\n":
self.log("bus error")
self.buffer = ""
raise BusError("bus error")
if not self.buffer.startswith("R:"):
self.warn("response has wrong start, skipping data. ignore next warnings. buffer:" + self.buffer.__repr__())
self.buffer = ""
raise InterfaceHardwareError("response has wrong start")
# everything is okay, return data
ret = self.buffer[2:]
self.buffer = ""
return ret
# =======================
# High-Level Send/Receive
# =======================
[docs] def checksum(self, data):
sum = 0
for byte in data:
sum += byte
return sum % 256
# raises BusError or InterfaceHardwareError, see send()
# returns valid data
[docs] def cmd(self, command, data=None):
if data is None:
data = []
assert 0 <= command < 8
bytes = [self.addr << 3 | command] + data
bytes.append(self.checksum(bytes))
send = ""
resp = None
for b in bytes:
send += "{0:02X}".format(b)
for _ in range(3):
try:
self.serialCmd(send)
for _ in range(30):
# timeout for interface board: 1sec
time.sleep(0.1)
resp = self.read() # possibly raises BusError (will be caught below) or InterfaceHardwareError (will not be caught)
if resp is not None:
resp = resp.strip()
logging.debug("resp: " + resp.__repr__())
break
if resp is None:
raise InterfaceHardwareError("interface timeout")
else:
break
except (BusError):
# BusError is not dramatic, just resend
logging.debug("bus error, resending")
time.sleep(1)
continue
if resp is None:
raise BusError("no successful response after 3 retries")
responseData = []
# convert response (hex-string) to a byte array
for i in range(len(resp) / 2):
try:
responseData.append(int(resp[2 * i:2 * i + 2], 16))
except ValueError:
raise InterfaceHardwareError("cannot parse hex response")
if len(responseData) > 1:
if sum(responseData[:-1]) % 256 != responseData[-1]:
raise InterfaceHardwareError("checksum mismatch") # interface checks the checksum itself, so we are in big trouble!
del responseData[-1] # discard checksum
logging.debug("respData: " + responseData.__repr__())
assert responseData != [MdbCashDevice.NAK] # NAK will already be caught in read()
return responseData
[docs] def extensionCmd(self, data):
"""in addition to the MDB commands, the interface hardware provides extension commands for other
features (LEDs, hopper, ...). Failure on these commands is not tolerated.
"""
self.serialCmd("X" + data)
for _ in range(30):
# timeout for interface board: 1sec
time.sleep(0.1)
resp = self.read() # possibly raises BusError or InterfaceHardwareError (both will not be caught)
if resp is not None:
resp = resp.strip()
logging.debug("resp: " + resp.__repr__())
return resp
raise InterfaceHardwareError("interface timeout")
# =======================
# High-Level Commands
# =======================
[docs] def reset(self):
for i in range(20):
time.sleep(1)
self.flushRead()
try:
if self.cmd(MdbCashDevice.CMD_RESET) == [MdbCashDevice.ACK]:
# device responded to reset: discard first poll
time.sleep(0.5)
self.poll(wasJustReset=True)
self.getSetup()
self.getTubeStatus()
self.setAcceptCoins(False, manualDispenseEnabled=False)
return
except (InterfaceHardwareError, BusError, MissingResetEventError), e:
logging.debug("reset failed with exception: " + e.__repr__())
continue
raise Exception("Device did not respond to reset attempts for 10 seconds")
[docs] def getSetup(self):
d = self.cmd(MdbCashDevice.CMD_SETUP)
assert 8 <= len(d) <= 23
assert d[0] in [2, 3]
# d[1,2] country code
coinScalingFactor = d[3]
# decimal places d[4]
# coin routing d[5,6]
self.coinValues = []
# unsent value-bytes are zero
while len(d) < 23:
d.append(0)
for byte in d[7:23]:
if byte == 0xFF:
value = 0 # vending token, ignored
else:
value = byte * coinScalingFactor
self.coinValues.append(value)
logging.debug("coin values: {0}".format(self.coinValues))
[docs] def getValue(self, type):
return self.coinValues[type]
[docs] def poll(self, wasJustReset=False):
""" get events from device.
:param wasJustReset: set this to True at the first poll after the RESET command
"""
receivedResetEvent = False
def getBits(byte, lowest, highest): # cut out bits lowest...highest (including highest) from byte
# example: getBits(0b0110,2,3)=0b11
mask = 0
for bit in range(lowest, highest + 1):
mask |= (1 << bit)
return (byte & mask) >> lowest
data = self.cmd(MdbCashDevice.CMD_POLL)
if data == []:
return False
assert len(data) <= 16
status = {"manuallyDispensed": [], "accepted": [], "busy": False}
if data == [MdbCashDevice.ACK]:
return status
while data:
# parse status response
if data[0] & 1 << 7:
# coin dispensed because of MANUAL! REQUEST (by pressing the button at the changer device itself)
assert len(data) >= 2
dispensedType = getBits(data[0], 0, 3)
dispensedNumber = getBits(data[0], 4, 6)
status["manuallyDispensed"] += [{"count": dispensedNumber, "denomination": self.getValue(dispensedType), "storage": "tube{0}".format(dispensedType)}]
del data[0:2]
# remaining in tube: data[1]
else:
if data[0] & 1 << 6:
assert len(data) >= 2
acceptedType = getBits(data[0], 0, 3)
# unused:
acceptedRouting = getBits(data[0], 4, 5)
assert acceptedRouting != 2 # this value isnt allowed
# coins now in tube: data[1]
if acceptedRouting != 3: # 3 == Reject, not accepted!
if acceptedRouting == 0:
storage = "cashbox"
else:
storage = "tube{0}".format(acceptedType)
status["accepted"] += [{"count": 1, "denomination": self.getValue(acceptedType), "storage": storage}]
del data[0:2]
else:
if data[0] & 1 << 5:
# "slug" = counterfeit coin - ignore
del data[0]
else:
# status events
assert data[0] in MdbCashDevice.statusEvents
[description, severity] = MdbCashDevice.statusEvents[data[0]]
if severity == MdbCashDevice.JUST_RESET:
receivedResetEvent = True
if wasJustReset:
logging.debug("received JUST RESET event after reset.")
else:
raise Exception("received unexpected JUST RESET event")
elif severity == MdbCashDevice.WARNING:
logging.warning(description)
elif severity == MdbCashDevice.ERROR:
raise Exception(description)
elif severity == MdbCashDevice.BUSY:
status["busy"] = True
# BUG: if the payout-stack is removed and reattached, the device may send BUSY even while we are not doing payout/payin.
# by design, we shouldn't just ignore it because this would remove protections against accidental state mismatches between device and this code
logging.debug("received event: {0}. If this happens shortly before an error, read the following explanation: If at this moment a service operator was doing something with the device (the payout unit was removed or the device menu was used), then it is a known bug which can be ignored. Otherwise it probably has happened because of a state mismatch between this driver and the device, then it is a severe problem. To be safe, CashServer will halt with an exception.".format(description))
elif severity == MdbCashDevice.IGNORE:
pass
else:
raise Exception("unknown severity. ups.")
del data[0]
if wasJustReset and not receivedResetEvent:
raise MissingResetEventError("did not receive JUST_RESET response at first poll")
return status
[docs] def setAcceptCoins(self, acceptCoins, manualDispenseEnabled=False):
# simplified: always accept either all values or no value
map = {True: [0xFF, 0xFF], False: [0x00, 0x00]}
d = []
d += map[acceptCoins]
d += map[manualDispenseEnabled]
# logging.warning("debug")
# d=[0xFF, 0xFF, 0xFF, 0xFF]
assert self.cmd(MdbCashDevice.CMD_COIN_TYPE, d) == [MdbCashDevice.ACK]
[docs] def getTubeStatus(self):
d = self.cmd(MdbCashDevice.CMD_TUBE_STATUS)
assert 2 <= len(d) <= 18
tubeStatus = d[0] << 8 + d[1]
status = [{} for i in range(16)]
for i in range(16):
status[i]["okay"] = True
status[i]["full"] = (tubeStatus & i) > 0
if len(d) > 2 + i:
status[i]["count"] = d[2 + i]
else:
# 0 count bytes at the end of the packet are not sent
status[i]["count"] = 0
status[i]["okay"] = not (status[i]["count"] == 0 and status[i]["full"]) # count 0 and full means "defective"
if not status[i]["okay"]:
self.warn("tube {0} defective".format(i))
return status
# dispense coin - may only be called if poll() returns busy==False
# may only be called if enough coins are availavle!
[docs] def dispenseCoin(self, type, amount):
assert 0 <= type <= 15
assert 0 < amount <= 15
self.log("dispensing {0}x value {1} (coin type {2})".format(amount, self.coinValues[type], type))
assert self.cmd(MdbCashDevice.CMD_DISPENSE, [type | amount << 4]) == [MdbCashDevice.ACK]
[docs] def tryDispenseCoinFromExternalHopper(self):
"""dispense a coin from an external non-MDB hopper connected directly to the interface board.
:returns: False if it failed (or no external hopper is enabled), True if one coin was dispensed
"""
if not self.extensionConfig.get("hopper", False):
logging.debug("skipping external hopper, disabled")
return False
#==============================================================================
# hopper protocol, copied from kassenautomat.mdb-interface/main.c:
#
# Hopper Protocol:
#
# command: H
#
# response:
# A = ACK: command received, starting a dispense operation, please resend to poll for the result
# B = busy, please resend command until you receive something else than busy (must not take more than 3 seconds)
# E01 = out of service because of a serious error #01.
# 01 is the hexadecimal error number from hopperErrorEnum in task_hopper.h
# Please reset board to exit this state, otherwise all hopper requests will be ignored and answered with this error.
# RD = okay, dispensed a coin
# RE = okay, hopper is empty, could not dispense a coin
#==============================================================================
self.log("trying to dispense from hopper")
response = self.extensionCmd("H")
assert response == "A", "Did not receive ACK on first dispense request, but {0}. Lost reply from a previous command?".format(response)
poll_tries = 20
for _ in range(poll_tries):
response = self.extensionCmd("H")
if response == "B":
# received BUSY answer
logging.debug("hopper busy, polling again...")
time.sleep(0.3)
continue
else:
break
logging.info("response: {0}".format(response))
if response == "B":
raise Exception("Hopper still busy after 6 seconds")
elif response == "RD":
logging.info("dispensed coin from hopper")
return True
elif response == "RE":
logging.info("hopper empty")
return False
elif response.startswith("E"):
errors = {"E00": "HOPPER_OKAY",
"E01": "HOPPER_ERR_SENSOR1",
"E02": "HOPPER_ERR_SENSOR2",
"E03": "HOPPER_ERR_SHORT_COIN_IMPULSE",
"E04": "HOPPER_ERR_UNEXPECTED_COIN",
"E05": "HOPPER_ERR_EARLY_COIN",
"E06": "HOPPER_ERR_UNEXPECTED_COIN_AT_COOLDOWN"}
errorText = errors.get(response, "unknown error")
logging.warn("hopper is disabled because of hardware error {0} - {1}. poweroff interface board to re-enable.".format(response, errorText))
return False
else:
raise BusError("received unknown response {0}.".format(response))
[docs] def setLEDs(self, leds):
""" set RGB-LED color via extension command, if it is enabled in the extensionConfig.
:param leds: list of two LED color values.
color value: RR GG BB in hex plus a mode of N (normal) or special modes B (blink) or T (timeout: switch off after 20 sec)
e.g. "00FF00N" = green normal, "FF0000B" = red blink, "0000FFT" = blue with timeout (will switch off after 20sec or the next command)
"""
if not self.extensionConfig.get("leds", False):
return
assert isinstance(leds, list)
assert len(leds) == 2
for led in leds:
assert re.match(r"^[0-9A-F]{6}[BTN]$", led), "invalid LED value"
assert self.extensionCmd("L{0}{1}".format(leds[0], leds[1])) == "OK", "LED command failed"
# =======================
# higher level functions
# =======================
# return a list of [coinType, value] sorted by descending value, ignoring 0-value coins
[docs] def getSortedCoinValues(self):
v = []
for i in range(16):
if self.coinValues[i] == 0:
continue
v.append([i, self.coinValues[i]])
def cmpItem(x, y):
return cmp(x[1], y[1])
v.sort(cmp=cmpItem, reverse=True)
return v
[docs] def getPossiblePayout(self):
v = self.getSortedCoinValues()
t = self.getTubeStatus()
logging.debug("coinValues: {0}".format(v))
logging.debug("tubeStatus: {0}".format(t))
# create list of (value, count) tuples
coins = [(value, t[index]["count"]) for [index, value] in v]
# TODO make max. number of coins configurable
return coin_payout_helper.get_possible_payout(coins)
# dispense one coin type for the given value - may only be called if poll() returns busy==False
# returns: dictionary with count, denomination, storage (tubeXX)
# or: False if nothing could be dispensed
# if not False, call again with the remaining value as soon as the device is not busy anymore
[docs] def dispenseValue(self, maximumDispense):
assert isinstance(maximumDispense, int)
# first try to dispense from external hopper
hopperCoinValue = self.extensionConfig.get("hopper", False)
if hopperCoinValue is not False and 0 < hopperCoinValue <= maximumDispense:
if self.tryDispenseCoinFromExternalHopper():
return {"count": 1, "denomination": hopperCoinValue, "storage": "hopper"}
# it did not work, now use MDB bus
tubeStatus = self.getTubeStatus()
sortedCoinValues = self.getSortedCoinValues()
# get number of avail. coins by value
coinsAvailable = {}
for [coinType, coinValue] in sortedCoinValues:
if coinValue not in coinsAvailable:
coinsAvailable[coinValue] = 0
coinsAvailable[coinValue] += tubeStatus[coinType]["count"]
def shouldSplit(coinValue):
"""determine if the payout of 1* coin X can be split into smaller pieces (X/2 or X/5),
without running out of smaller coins.
This is used so that the coin storage does not run short of often paid out coins (like 1€),
while the small ones keep overflowing.
This function assumes a greedy payout strategy:
If 1*X is to be paid out, the maximum remaining payout amount is 2*X.
"""
splitFactor = 2
if (coinValue / 2) not in coinsAvailable:
# there is no "half" coin of the currently used value
# Try splitting by factor 5 (50c -> 5*10c).
if (coinValue / 5) in coinsAvailable:
splitFactor = 5
else:
# cannot split by 5
return False
# Check that more than enough smaller coins remain so that we don't run out of them.
# If we allow splitting (return True), the next smaller coin will be paid out,
# then dispenseValue() returns and will be called again.
if coinsAvailable[coinValue / splitFactor] < 20:
# too few of the smaller coins
return False
# only split if there are significantly more smaller coins available than large coins
return coinsAvailable[coinValue / splitFactor] > coinsAvailable[coinValue] + 10
for [coinType, coinValue] in sortedCoinValues:
# how many coins should be dispensed? maximum 15 at once
assert isinstance(coinValue, int)
number = maximumDispense / coinValue # integer division, implies truncation
numberAvailable = tubeStatus[coinType]["count"]
if number > numberAvailable:
number = numberAvailable
if number == 0:
continue
if shouldSplit(coinValue):
logging.debug("splitting payout: skipping 1x out of {number}x{coinValue}, will be paid as smaller coins".format(number=number, coinValue=coinValue))
number = number - 1
if number == 0:
continue
if number > 15:
number = 15
self.dispenseCoin(coinType, number)
dispensed = coinValue * number
assert dispensed <= maximumDispense
return {"count": number, "denomination": coinValue, "storage": "tube{0}".format(coinType)}
return False