bill-acceptor
rs-232 serial bill acceptor in scala and akka
git clone https://9o.is/git/bill-acceptor.git
commit b01af23e38ce0825cb52351ad592cf1879f7165a Author: Jul <jul@9o.is> Date: Sat, 28 Mar 2015 23:20:35 -0400 Initial Commit Diffstat:
| A | .gitignore | | | 66 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | README.md | | | 61 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | apex/src/main/resources/reference.conf | | | 12 | ++++++++++++ |
| A | apex/src/main/scala/inc/pyc/bill/acceptor/apex/Apex.scala | | | 69 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | apex/src/main/scala/inc/pyc/bill/acceptor/apex/ByteImplicit.scala | | | 28 | ++++++++++++++++++++++++++++ |
| A | apex/src/main/scala/inc/pyc/bill/acceptor/apex/Decoder.scala | | | 79 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | apex/src/main/scala/inc/pyc/bill/acceptor/apex/Driver.scala | | | 249 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | apex/src/readme.md | | | 9 | +++++++++ |
| A | apex/src/test/scala/inc/pyc/bill/acceptor/apex/ApexConfig.scala | | | 9 | +++++++++ |
| A | apex/src/test/scala/inc/pyc/bill/acceptor/apex/ApexDriverSpec.scala | | | 496 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | apex/src/test/scala/inc/pyc/bill/acceptor/apex/ApexMockDriver.scala | | | 9 | +++++++++ |
| A | apex/src/test/scala/inc/pyc/bill/acceptor/apex/ApexSpec.scala | | | 150 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | apex/src/test/scala/inc/pyc/bill/acceptor/apex/MockApex.scala | | | 12 | ++++++++++++ |
| A | core/src/main/scala/inc/pyc/bill/acceptor/BillAcceptor.scala | | | 220 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | core/src/main/scala/inc/pyc/bill/acceptor/BillAcceptorErrors.scala | | | 70 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | core/src/main/scala/inc/pyc/bill/acceptor/Commands.scala | | | 44 | ++++++++++++++++++++++++++++++++++++++++++++ |
| A | core/src/main/scala/inc/pyc/bill/acceptor/Data.scala | | | 28 | ++++++++++++++++++++++++++++ |
| A | core/src/main/scala/inc/pyc/bill/acceptor/Events.scala | | | 31 | +++++++++++++++++++++++++++++++ |
| A | core/src/main/scala/inc/pyc/bill/acceptor/Inhibitable.scala | | | 28 | ++++++++++++++++++++++++++++ |
| A | core/src/main/scala/inc/pyc/bill/acceptor/OpenableStacker.scala | | | 53 | +++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | core/src/main/scala/inc/pyc/bill/acceptor/Settings.scala | | | 28 | ++++++++++++++++++++++++++++ |
| A | core/src/main/scala/inc/pyc/bill/acceptor/StateFunctions.scala | | | 33 | +++++++++++++++++++++++++++++++++ |
| A | core/src/main/scala/inc/pyc/bill/acceptor/States.scala | | | 113 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | core/src/main/scala/inc/pyc/bill/acceptor/driver/Commands.scala | | | 32 | ++++++++++++++++++++++++++++++++ |
| A | core/src/main/scala/inc/pyc/bill/acceptor/driver/Data.scala | | | 21 | +++++++++++++++++++++ |
| A | core/src/main/scala/inc/pyc/bill/acceptor/driver/Driver.scala | | | 141 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | core/src/test/scala/inc/pyc/bill/acceptor/BaseSpec.scala | | | 18 | ++++++++++++++++++ |
| A | core/src/test/scala/inc/pyc/bill/acceptor/BillAcceptorErrorsSpec.scala | | | 106 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | core/src/test/scala/inc/pyc/bill/acceptor/BillAcceptorSpec.scala | | | 307 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | core/src/test/scala/inc/pyc/bill/acceptor/InhibitableSpec.scala | | | 76 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | core/src/test/scala/inc/pyc/bill/acceptor/MockBillAcceptor.scala | | | 56 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | core/src/test/scala/inc/pyc/bill/acceptor/MockConfig.scala | | | 23 | +++++++++++++++++++++++ |
| A | core/src/test/scala/inc/pyc/bill/acceptor/OpenableSpec.scala | | | 85 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | core/src/test/scala/inc/pyc/bill/acceptor/SpecHelper.scala | | | 34 | ++++++++++++++++++++++++++++++++++ |
| A | core/src/test/scala/inc/pyc/bill/acceptor/driver/DriverSpec.scala | | | 203 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | core/src/test/scala/inc/pyc/bill/acceptor/driver/MockDriver.scala | | | 24 | ++++++++++++++++++++++++ |
| A | project/Build.scala | | | 38 | ++++++++++++++++++++++++++++++++++++++ |
| A | project/BuildSettings.scala | | | 54 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | project/plugins.sbt | | | 14 | ++++++++++++++ |
| A | project/sbt-launch.jar | | | 0 | |
| A | sbt.sh | | | 2 | ++ |
41 files changed, 3131 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore @@ -0,0 +1,66 @@ +# use glob syntax. +syntax: glob +*.swp +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +.cache + +# logs +derby.log + +# eclipse conf file +.settings +.classpath +.project +.manager +.externalToolBuilders + +# ensime/emacs conf files +.ensime +.scala_dependencies + +# building +target +null +tmp* +dist +test-output + +# sbt +target +lib_managed +src_managed +project/boot +project/plugins/project + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.eml +*.iml +*.ipr +*.iws +.idea + +# Pax Runner (for easy OSGi launching) +runner + +#grunt/requirejs stuff +node_modules/ +bower_components/ +.grunt/ +_SpecRunner.html + +sbt-linuxlab.sh diff --git a/README.md b/README.md @@ -0,0 +1,61 @@ +# Bill Acceptor RS-232 Library + +Communicates with a bill acceptor using [Flow](https://github.com/jodersky/flow), jodersky's rs232 serial communication library for Akka. + +---- +## which bill acceptors are supported? +1. [Apex 7000 Series](http://pyramidacceptors.com/apex-7000/) +2. [ID-003 Protocol](ftp://67.205.101.207/Pripherals/BillAcceptors/JCM/ID003/ID-003%20Protocol%20Spec.pdf) *(coming soon)* + +---- +## usage + +Instantiate the acceptor actor inside an Akka parent actor. + + import inc.pyc.bill.acceptor._, Events._, Commands._ + lazy val acceptor = context.actorOf(BillAcceptor.props(context.system), "BillAcceptor") + +Example of sending bill acceptor a command. + + acceptor ! Listen + +### commands + +**Listen** : listen to serial port and poll bill acceptor +**UnListen** : stop listening to serial port +**Inhibit** : accept all bills +**UnInhibit**: do not accept bills +**Stack** : accept the bill in escrow +**Return** : do not accept the bill in escrow + +### events + +**Disconnected** : not connected to serial port +**Ready** : bill acceptor is ready to be used after sending listen command +**Inserted(bill: Currency#Value)** : bill was inserted and is waiting in escrow mode +**Confirmed(bill: Currency#Value)**: bill is or will be stacked and cannot be returned; safe to give credit + +---- +## configuration + +By default, configuration is not needed, but below is an example that can be added to application.conf in case changes need to be made. + + bill-acceptor { + currency = "USD" + driver = "inc.pyc.bill.acceptor.apex.Apex" + currency = "USD" + port = "/dev/ttyUSB0" + baud = 9600 + parity = 2 + char-size = 7 + buffer-size = 10 + two-stop-bits = off + } + +**Note**: the only supported currency at the moment is USD. + +---- +## installation + +- flow-native needs to be installed to use serial communication. [Read here for basic instructions](https://github.com/jodersky/flow#basic-usage). +- This depends on a separate library: "currency-lib". In the same directory you git cloned this project, run "git clone https://github.com/pyc-inc/currency-lib.git" diff --git a/apex/src/main/resources/reference.conf b/apex/src/main/resources/reference.conf @@ -0,0 +1,11 @@ +bill-acceptor { + currency = "USD" + driver = "inc.pyc.bill.acceptor.apex.Apex" + currency = "USD" + port = "/dev/ttyUSB0" + baud = 9600 + parity = 2 + char-size = 7 + buffer-size = 10 + two-stop-bits = off +} +\ No newline at end of file diff --git a/apex/src/main/scala/inc/pyc/bill/acceptor/apex/Apex.scala b/apex/src/main/scala/inc/pyc/bill/acceptor/apex/Apex.scala @@ -0,0 +1,68 @@ +package inc.pyc.bill +package acceptor +package apex + +import Commands._ +import States._ +import Data._ +import Events._ +import akka.actor._ +import concurrent.duration._ + +/** + * Pyramid Acceptor's Apex Bill Acceptor. + */ +class Apex extends FSM[State, Data] + with LoggingFSM[State, Data] + with BillAcceptor + with BillAcceptorErrors + with OpenableStacker + with Inhibitable { + + def createDriver: ActorRef = + context.actorOf(Props[ApexDriver], "ApexDriver") + + when(Accepting) { + case Event(Rejecting, _) => + goto(Rejecting) + + case Event(Cheated, _) => + goto(Cheated) + } + + // BUG in Apex: Returning never goes to Returned + // I may have an old firmware + // Use timeouts to continue transitions + when(Returning, stateTimeout = 500 milli) { + case Event(StateTimeout, _) => + goto(Returned) + } + + when(Returned, stateTimeout = 200 millis) { + case Event(StateTimeout, Bill(driver, _)) => + goto(Idle) using driver + } + + when(Rejecting)(gotoIdle) + when(Cheated)(gotoIdle) + + override def handleTransitions: TransitionHandler = { + case Stacking -> Stacked => + // Give Credit when Stacked + nextStateData match { + case Bill(_, Inserted(bill)) => + host ! Confirmed(bill) + + case _ => + } + } + + /** + * State function whose only option is + * to go to Idle state. + */ + def gotoIdle: StateFunction = { + case Event(Idle, _) => + goto(Idle) + } +} +\ No newline at end of file diff --git a/apex/src/main/scala/inc/pyc/bill/acceptor/apex/ByteImplicit.scala b/apex/src/main/scala/inc/pyc/bill/acceptor/apex/ByteImplicit.scala @@ -0,0 +1,27 @@ +package inc.pyc.bill.acceptor.apex + +object Implicits { + + implicit class ByteImplicit(byte: Byte) { + + /** + * Checks if bit in byte is set. + */ + def bit(i: Int): Boolean = ((byte >> i) & 1) == 1 + + /** + * Gets the value of a number by exclusively clearing all bits + * less than the lower bit mark and greater than the higher bit mark. + */ + def bits(lower: Int, higher: Int): Byte = { + + // clear bits i to most significant bit + val i_MSB_Mask = ((1 << higher) - 1) + + // clear bits i to least significant bit + val i_LSB_Mask = ~((1 << lower) - 1) + + (byte & i_MSB_Mask & i_LSB_Mask) toByte + } + } +} +\ No newline at end of file diff --git a/apex/src/main/scala/inc/pyc/bill/acceptor/apex/Decoder.scala b/apex/src/main/scala/inc/pyc/bill/acceptor/apex/Decoder.scala @@ -0,0 +1,78 @@ +package inc.pyc.bill +package acceptor +package apex + +import States._ +import Implicits._ +import inc.pyc.currency._ +import scala.collection.immutable.SortedMap + +private[apex] object ApexDecoder { + + // order of the mapping matters + + private val statusBits = SortedMap( + 6 -> Returned, + 5 -> Returning, + 4 -> Stacked, + 3 -> Stacking, + 2 -> Escrow, + 1 -> Accepting, + 0 -> Idle)(implicitly[Ordering[Int]].reverse) + + private val events1Bits = SortedMap( + 0 -> Cheated, + 1 -> Rejecting, + 2 -> JamInAcceptor, + 3 -> StackerFull)(implicitly[Ordering[Int]].reverse) + + private val events2Bits = SortedMap( + 2 -> Failure, + 1 -> CommunicationError, + 0 -> PowerUp)(implicitly[Ordering[Int]].reverse) + + /** + * Status codes + * + * Scan byte 0 of the data fields received by operator. + */ + def findStatus(byte: Byte) = + statusBits find (byte bit _._1) map (_._2) + + /** + * Event codes + * + * Although not a status by APEX definition, + * FSM will translate it as a status. + * + * Scan byte 1 of the data fields received by operator. + */ + def findEvents_1(byte: Byte) = { + // bit 4 should eq 1 if bill cassette is present + if (byte bit 4) { + events1Bits find (byte bit _._1) map (_._2) + } else { + Some(StackerOpen) + } + } + + /** + * Event codes + * + * Although not a status by APEX definition, + * FSM will translate it as a status. + * + * Scan byte 2 of the data fields received by operator. + */ + def findEvents_2(byte: Byte) = + events2Bits find (byte bit _._1) map (_._2) + + /** + * Country specific data codes. + * The values of the array is the value of the bill. + * If value is zero, bit is reserved or unknown. + */ + val CURRENCY: Map[Currency, Array[Int]] = Map( + USD -> Array(0, 1, 2, 5, 10, 20, 50, 100)) + +} +\ No newline at end of file diff --git a/apex/src/main/scala/inc/pyc/bill/acceptor/apex/Driver.scala b/apex/src/main/scala/inc/pyc/bill/acceptor/apex/Driver.scala @@ -0,0 +1,248 @@ +package inc.pyc.bill +package acceptor +package apex + +import Events._ +import Commands._ +import States._ +import driver._ +import Implicits._ +import ApexDecoder._ +import akka.actor._ +import com.github.jodersky.flow.Serial._ +import akka.util.ByteString + +/** + * Pyramid Acceptor's Apex 7000 series bill acceptor: + * http://www.pyramidacceptors.com/files/RS_232.pdf + */ +class ApexDriver extends FSM[State, DriverData] + with LoggingFSM[State, DriverData] + with Driver { + + when(Idle) { + case Event(Received(data), _) => + buffer ++= data + buffer = syncrhronize(buffer) + if (bufferIsReady) parsePacket(buffer(1)) + stay + + case Event(UnInhibit, _) => + enabledBills = Bills.disabled + stay + + case Event(Inhibit, _) => + enabledBills = Bills.enabled + stay + + case Event(Stack, _) => + self ! Input(buildPacket(Cmd.Stack)) + stay + + case Event(Return, _) => + self ! Input(buildPacket(Cmd.Return)) + stay + } + + def poll = Input(buildPacket()) + + /** Data buffer received from operator */ + var buffer = ByteString() + + /** Enabled bills */ + var enabledBills = Bills.enabled + + /** Message start code */ + val STX = ByteString(0x02) + + /** Message end code */ + val ETX = ByteString(0x03) + + /** Msg type */ + def msgType(toSlave: Boolean = true): Byte = { + val msgType = if (toSlave) 0x10 else 0x20 + msgType toByte + } + + /** + * Checks if buffer is ready to be parsed. + * Byte after `STX` is length of packet. + * + * Requirements are: + * 1. Buffer data must begin with the legitimate packet `STX` + * 2. Entire packet must be available in the buffer, according to packet length. + */ + def bufferIsReady = + buffer.length > 1 && + buffer.length >= buffer(1) && + ByteString(buffer(0)) == STX + + /** + * Tries to parse the next available packet in the buffer. + * @param length the length of the packet + */ + def parsePacket(length: Int): Unit = { + val packet = buffer slice (0, length) + buffer = buffer drop length + parse(packet) + } + + /** + * Tries to parse the packet. + * + * TODO re-send messages not acknowledged + */ + def parse(packet: ByteString): Unit = { + if (validPacket(packet) && nonAck(packet) && fromSlave(packet) && hasETX(packet)) { + + val data = packet drop 3 dropRight 2 + val byte0 = data(0) + val byte1 = data(1) + val byte2 = data(2) + + // Order matters + val response = (findEvents_2(byte2)) orElse + (findEvents_1(byte1)) orElse + (findStatus(byte0)) + + response map (_ match { + case Escrow => + acknowledge(packet) + val billValue = (byte2 bits (3, 6)) >> 3 + val value = CURRENCY(currency)(billValue) + val bill = currency(value) + fsm ! Inserted(bill) + + case event => + acknowledge(packet) + fsm ! event + }) + } + } + + /** + * Validates the packet by computing the checksum. + * The checksum is calculated on all bytes + * (except: STX, ETX and the checksum byte itself) + */ + def validPacket(packet: ByteString): Boolean = { + val payload = packet drop 1 dropRight 2 + val sum = packet takeRight 1 + val realsum = ByteString(checksum(payload)) + sum == realsum + } + + /** + * One byte checksum. The checksum is calculated on all bytes + * (except: STX, ETX and the checksum byte itself). This is done + * by bitwise Exclusive OR-ing (XOR) the bytes. + */ + def checksum(payload: ByteString): Byte = + payload.foldLeft(0x00)((a, b) => a ^ b) toByte + + /** + * Synchronizes the data in the buffer by finding + * `STX` in data and returning all bytes after that. + * + * e.g. + * jiberish + garbage + stx + data => stx + data + */ + def syncrhronize(buffer: ByteString): ByteString = { + val i = buffer indexOf STX(0) + if (i < 0) buffer + else buffer drop i + } + + /** + * Checks if packet is not an acknowledgment. + */ + def nonAck(packet: ByteString): Boolean = { + val byte2 = packet(2) + val nibble = byte2 bits (0, 4) + nibble == 0x00 + } + + /** + * Checks if packet is from the "slave" or bill acceptor + * messages coming from Flow's Serial Operator actor. + */ + def fromSlave(packet: ByteString): Boolean = { + val byte2 = packet(2) + val nibble = byte2 bits (4, 8) + nibble == 0x20 + } + + /** + * Checks if the second to last byte in the + * packet is an end of message code. + */ + def hasETX(packet: ByteString): Boolean = { + ETX(0) == packet(packet.size - 2) + } + + /** + * Builds a packet. + * + * | STX | length | meta | data | ETX | checksum | + * + * @param cmd command given to the bill acceptor. Escrow MUST be enabled. + */ + def buildPacket(cmd: ByteString = Cmd.Escrow): ByteString = { + val length = 0x08 + val payload = ByteString(length, msgType(), enabledBills, cmd(0), 0x00) + val sum = ByteString(checksum(payload)) + STX ++ payload ++ ETX ++ sum + } + + /** + * Echoes a packet with the acknowledgment bit + * enabled to the operator. + */ + def acknowledge(packet: ByteString): Unit = { + val ack = convertToAck(packet) + self ! Input(ack) + } + + /** + * Converts a packet into an echo by + * setting the acknowledge bit on. + */ + def convertToAck(packet: ByteString): ByteString = { + val _length = ByteString(packet(1)) + val _meta = ByteString(msgType(toSlave = true) + 1) // +1 ack + val _body = packet takeRight (packet.size - 3) dropRight 2 + val payload = _length ++ _meta ++ _body + val sum = ByteString(checksum(payload)) + STX ++ payload ++ ETX ++ sum + } +} + +/** + * Byte 0 of the data fields for messages to the operator. + */ +object Bills { + private val `1` = 0x01 + private val `2` = 0x02 + private val `5` = 0x04 + private val `10` = 0x08 + private val `20` = 0x10 + private val `50` = 0x20 + private val `100` = 0x40 + + val disabled: Byte = 0 + val enabled: Byte = + (`1` + `2` + `5` + `10` + `20` + `50` + `100`) toByte +} + +/** + * Byte 1 of the data fields for messages to the operator. + */ +object Cmd { + val _escrow = 0x10 + val _stack = 0x20 + val _return = 0x40 + + val Escrow = ByteString(_escrow) + val Stack = ByteString(_escrow + _stack) + val Return = ByteString(_escrow + _return) +} +\ No newline at end of file diff --git a/apex/src/readme.md b/apex/src/readme.md @@ -0,0 +1,8 @@ +#Apex RS-232 Spec + +[Apex RS232 Spec PDF](http://www.pyramidacceptors.com/files/RS_232.pdf) + + +#Apex State Transition + + +\ No newline at end of file diff --git a/apex/src/test/scala/inc/pyc/bill/acceptor/apex/ApexConfig.scala b/apex/src/test/scala/inc/pyc/bill/acceptor/apex/ApexConfig.scala @@ -0,0 +1,8 @@ +package inc.pyc.bill.acceptor.apex + +import com.typesafe.config._ + +object ApexConfig { + val config = + ConfigFactory.load().getConfig("bill-acceptor") +} +\ No newline at end of file diff --git a/apex/src/test/scala/inc/pyc/bill/acceptor/apex/ApexDriverSpec.scala b/apex/src/test/scala/inc/pyc/bill/acceptor/apex/ApexDriverSpec.scala @@ -0,0 +1,495 @@ +package inc.pyc.bill +package acceptor +package apex + +import driver._ +import States._ +import Commands._ +import Events._ +import inc.pyc.currency._ +import Implicits._ +import driver.Input +import akka.actor._ +import akka.testkit._ +import akka.util.ByteString +import concurrent.duration._ +import com.github.jodersky.flow._ +import Serial._ + +class ApexDriverSpec extends BaseSpec(ApexConfig.config) + with SpecHelper + with ApexDriverTest { + + "The Apex Driver" should { + testApexDriver + + "Implicit byte manipulations" should { + "be able to extract bits from a byte" in { + val byte = 0xAF toByte // -> 1 0 1 0 1 1 1 1 OR 175/-81 + val nib1 = 0x0F toByte // 1st nibble -> - - - - 1 1 1 1 OR 15 + val nib2 = 0xA0 toByte // 2nd nibble -> 1 0 1 0 - - - - OR 160/-96 + val bit1 = 0x28 toByte // bits -> - 0 1 0 1 - - - OR 40 + + byte bits (0, 4) should be(nib1) + byte bits (4, 8) should be(nib2) + byte bits (3, 7) should be(bit1) + byte bits (0, 8) should be(byte) + } + } + } + +} + +trait ApexDriverTest { + this: BaseSpec with SpecHelper => + + def testApexDriver { + val operator = TestProbe() + val ref = TestFSMRef(new MockApexDriver(testActor)) + val driver = ref.underlyingActor + + def b(bytes: Int*) = ByteString(bytes: _*) + + def expectAllBillsEnabled(byte: Byte) { + byte should be(0x7F) + } + + def expectAllBillsDisabled(byte: Byte) { + byte should be(0x00) + } + + def idle(func: => Unit) { + ref.setState(Idle, Operator(operator.ref)) + ref.cancelTimer("poll") + func + } + + // if message is processed, it should send ack to operator + def expectMsgParsed(data: ByteString, should: Boolean = true) { + idle { + ref ! Received(data) + val ack = driver.convertToAck(data) + if (should) operator.expectMsg(Write(ack)) + else operator.expectNoMsg + } + } + + def expectNoMsgParsed(data: ByteString) { + expectMsgParsed(data, should = false) + } + + // builds a packet that operator can send to driver + // (function too bloated) + def packet( + data0: Int, + data1: Int = 0, + data2: Int = 0, + len: Option[Byte] = None, + meta: Byte = driver.msgType(toSlave = false), + sum: Option[Byte] = None, + etx: Boolean = true): ByteString = { + + val data = Seq(data0, data1, data2) + val _data = b(meta) ++ b(data: _*) + val dataWithETX = if (etx) _data ++ driver.ETX else _data + val altlen: Byte = (dataWithETX.size + 3) toByte + val length: Byte = len.getOrElse(altlen) + val payload = b(length) ++ _data + val sumval: Byte = sum.getOrElse(driver.checksum(payload)) + driver.STX ++ b(length) ++ dataWithETX ++ b(sumval) + } + + // byte 0 of data field (Operator -> Driver) + val idling = 0x01 + val accepting = 0x02 + val escrowed = 0x04 + val stacking = 0x08 + val stacked = 0x10 + val returning = 0x20 + val returned = 0x40 + + // byte 1 of data field (Operator -> Driver) + val cheated = 0x01 + val rejected = 0x02 + val jammed = 0x04 + val stackerFull = 0x08 + val stackerClosed = 0x10 + + // byte 2 of data field (Operator -> Driver) + val powerup = 0x01 + val invalid = 0x02 + val failure = 0x04 + val dollar1 = 0x08 + val dollar2 = 0x10 + val dollar5 = 0x18 + val dollar10 = 0x20 + val dollar20 = 0x28 + val dollar50 = 0x30 + val dollar100 = 0x38 + + "define the message start code" in { + driver.STX should be(ByteString(0x02)) + } + + "define the message end code" in { + driver.ETX should be(ByteString(0x03)) + } + + "set baud rate to 9600" in { + driver.settings.baud should be(9600) + } + + "set even parity bit" in { + driver.settings.parity should be(Parity.Even) + } + + "set buffer size to 10 bits" in { + driver.bufferSize should be(10) + } + + "set one stop bit" in { + driver.settings.twoStopBits should be(false) + } + + "set character size to 7 bits" in { + driver.settings.characterSize should be(7) + } + + "enable all the bills by default" in { + driver.enabledBills should be(Bills.enabled) + } + + "fill the buffer when data is received from the operator" in + idle { + ref ! Received(b(0x34, 0x21, 0xD6)) + List(0x88, 0xA2).map(byte => ref ! Received(b(byte))) + driver.buffer should be(b(0x34, 0x21, 0xD6, 0x88, 0xA2)) + } + + "synchronize the buffer" in idle { + ref ! Received(b(0x11, 0x06, driver.STX(0), 0x07, 0x7F)) + driver.buffer should be(driver.STX ++ b(0x07, 0x7F)) + } + + "parse the packet when it is ready in buffer" in idle { + driver.bufferIsReady should be(false) + ref ! Received(b(0x86, 0xF4, 0x87)) + driver.bufferIsReady should be(false) + driver.buffer ++= b(0x23, 0xB2, 0xB4) + driver.bufferIsReady should be(true) + ref ! Received(b(0x1A)) + driver.buffer should be(b(0xB2, 0xB4, 0x1A)) + } + + def make(meta: ByteString = b(0x00), data: ByteString = b(0x00)) = { + val payload = b(0x07) ++ meta ++ data ++ driver.ETX + val sum = b(driver.checksum(payload)) + driver.STX ++ payload ++ sum + } + + "be able to check if packet is from bill acceptor or slave" in { + val fromAcceptor = b(0x1A) // bit 4 is set + val fromSlave = b(0x2A) // bit 5 is set + driver.fromSlave(make(fromSlave)) should be(true) + driver.fromSlave(make(fromAcceptor)) should be(false) + } + + "be able to check if packet is an ackowledgment" in { + val ack = b(0x01) + val nonack = b(0x00) + driver.nonAck(make(ack)) should be(false) + driver.nonAck(make(nonack)) should be(true) + } + + "be able to send acknowledgments to the operator" in idle { + val packet = make(data = b(0x40)) + driver.acknowledge(packet) + operator.expectMsgPF(200 millis) { + case Write(data, _) => + data(3) should be(0x40) + driver.nonAck(data) should be(false) + } + } + + "be able to validate a packet" in { + val retTest = b(2, 8, 16, 125, 80, 0, 3, 53) + val stackTest = b(2, 8, 16, 125, 16, 0, 3, 117) + val error = b(2, 8, 16, 125, 16, 0, 3, 50) + + driver.validPacket(retTest) should be(true) + driver.validPacket(stackTest) should be(true) + driver.validPacket(error) should be(false) + } + + "uninhibit bills" in { + ref ! UnInhibit + expectAllBillsDisabled(driver.enabledBills) + } + + "inhibit bills" in { + ref ! Inhibit + expectAllBillsEnabled(driver.enabledBills) + } + + "stack bill" in { + ref ! Stack + operator.expectMsgPF(500 millis) { + case Write(data, _) => + val byte = data(4) + byte bit (5) should be(true) + byte bit (6) should be(false) + } + } + + "return bill" in { + ref ! Return + operator.expectMsgPF(500 millis) { + case Write(data, _) => + val byte = data(4) + byte bit (5) should be(false) + byte bit (6) should be(true) + } + } + + // IMPORTANT TESTS! + // The tests below check if malformed packets + // are ignored and if packets are parsed correctly. + // This reduces the risk of + // malicious incoming data from EMPs. + + "receive idling packet from operator" in { + expectMsgParsed(packet(idling, stackerClosed)) + expectMsg(Idle) + } + + "receive accepting packet from operator" in { + expectMsgParsed(packet(accepting, stackerClosed)) + expectMsg(Accepting) + } + + "prioritize accepting bit over idling bit " in { + expectMsgParsed(packet(idling + accepting, stackerClosed)) + expectMsg(Accepting) + } + + "receive escrow packet for unkown amount" in { + expectMsgParsed(packet(escrowed, stackerClosed, 0x00)) + expectMsg(Inserted(USD.invalid)) + } + + "receive escrow packet for $1" in { + expectMsgParsed(packet(escrowed, stackerClosed, dollar1)) + expectMsg(Inserted(USD(1))) + } + + "receive escrow packet for $2" in { + expectMsgParsed(packet(escrowed, stackerClosed, dollar2)) + expectMsg(Inserted(USD(2))) + } + + "receive escrow packet for $5" in { + expectMsgParsed(packet(escrowed, stackerClosed, dollar5)) + expectMsg(Inserted(USD(5))) + } + + "receive escrow packet for $10" in { + expectMsgParsed(packet(escrowed, stackerClosed, dollar10)) + expectMsg(Inserted(USD(10))) + } + + "receive escrow packet for $20" in { + expectMsgParsed(packet(escrowed, stackerClosed, dollar20)) + expectMsg(Inserted(USD(20))) + } + + "receive escrow packet for $50" in { + expectMsgParsed(packet(escrowed, stackerClosed, dollar50)) + expectMsg(Inserted(USD(50))) + } + + "receive escrow packet for $100" in { + expectMsgParsed(packet(escrowed, stackerClosed, dollar100)) + expectMsg(Inserted(USD(100))) + } + + "receive stacking packet from operator" in { + expectMsgParsed(packet(stacking, stackerClosed)) + expectMsg(Stacking) + } + + "prioritize stacking bit over escrow bit " in { + expectMsgParsed(packet(escrowed + stacking, stackerClosed)) + expectMsg(Stacking) + } + + "receive stacked packet from operator" in { + expectMsgParsed(packet(stacked, stackerClosed)) + expectMsg(Stacked) + } + + "prioritize stacked bit over escrow bit " in { + expectMsgParsed(packet(escrowed + stacked, stackerClosed)) + expectMsg(Stacked) + } + + "prioritize stacked bit over stacking bit " in { + expectMsgParsed(packet(stacked + stacking, stackerClosed)) + expectMsg(Stacked) + } + + "receive returning packet from operator" in { + expectMsgParsed(packet(returning, stackerClosed)) + expectMsg(Returning) + } + + "prioritize returning bit over escrow bit " in { + expectMsgParsed(packet(escrowed + returning, stackerClosed)) + expectMsg(Returning) + } + + "receive returned packet from operator" in { + expectMsgParsed(packet(returned, stackerClosed)) + expectMsg(Returned) + } + + "prioritize returned bit over escrow bit " in { + expectMsgParsed(packet(escrowed + returned, stackerClosed)) + expectMsg(Returned) + } + + "prioritize returned bit over returning bit " in { + expectMsgParsed(packet(returned + returning, stackerClosed)) + expectMsg(Returned) + } + + "receive cheated packet from operator" in { + expectMsgParsed(packet(0x00, cheated + stackerClosed)) + expectMsg(Cheated) + } + + "receive bill rejected packet from operator" in { + expectMsgParsed(packet(0x00, rejected + stackerClosed)) + expectMsg(Rejecting) + } + + "prioritize rejected bit over cheated bit " in { + expectMsgParsed(packet(0x00, cheated + rejected + stackerClosed)) + expectMsg(Rejecting) + } + + "receive bill jammed packet from operator" in { + expectMsgParsed(packet(0x00, jammed + stackerClosed)) + expectMsg(JamInAcceptor) + } + + "prioritize jammed bit over rejected and cheated bit" in { + expectMsgParsed(packet(0x00, cheated + rejected + jammed + stackerClosed)) + expectMsg(JamInAcceptor) + } + + "receive stacker full packet from operator" in { + expectMsgParsed(packet(0x00, stackerFull + stackerClosed)) + expectMsg(StackerFull) + } + + "prioritize stacker full bit over jammed bit" in { + expectMsgParsed(packet(0x00, stackerFull + jammed + stackerClosed)) + expectMsg(StackerFull) + } + + "receive power up packet from operator" in { + expectMsgParsed(packet(0x00, stackerClosed, powerup)) + expectMsg(PowerUp) + } + + "receive invalid command packet from operator" in { + expectMsgParsed(packet(0x00, stackerClosed, invalid)) + expectMsg(CommunicationError) + } + + "prioritize invalid command bit over power up bit" in { + expectMsgParsed(packet(0x00, stackerClosed, powerup + invalid)) + expectMsg(CommunicationError) + } + + "receive failure packet from operator" in { + expectMsgParsed(packet(0x00, stackerClosed, failure)) + expectMsg(Failure) + } + + "prioritize failure bit over power up and invalid command bit" in { + expectMsgParsed(packet(0x00, stackerClosed, powerup + invalid + failure)) + expectMsg(Failure) + } + + "ignore all statuses and events, except for power up and failures, when stacker is open" in { + expectMsgParsed(packet(idling)) + expectMsg(StackerOpen) + + expectMsgParsed(packet(idling, stackerFull)) + expectMsg(StackerOpen) + + expectMsgParsed(packet(escrowed, stackerFull + cheated, dollar100)) + expectMsg(StackerOpen) + + expectMsgParsed(packet(idling, 0x00, powerup)) + expectMsg(PowerUp) + + expectMsgParsed(packet(0x00, 0x00, failure)) + expectMsg(Failure) + + expectMsgParsed(packet(idling, 0x00, invalid)) + expectMsg(CommunicationError) + } + + "prioritize Apex event over Apex status as the official FSM status" in { + expectMsgParsed(packet(returned + returning, cheated + stackerClosed)) + expectMsg(Cheated) + + expectMsgParsed(packet(escrowed, cheated + stackerClosed, dollar10 + powerup)) + expectMsg(PowerUp) + + expectMsgParsed(packet(returned, stackerClosed, failure)) + expectMsg(Failure) + } + + "not parse packets with invalid checksum" in { + expectNoMsgParsed(packet(idling, sum = Some(0x01))) + expectNoMsg + + expectNoMsgParsed(packet(0x00, 0x00, failure, sum = Some(0x01))) + expectNoMsg + } + + "parse packets with only the slave to master bit turned on in the meta byte" in { + expectMsgParsed(packet(idling, stackerClosed, meta = driver.msgType(toSlave = false))) + expectMsg(Idle) + + expectNoMsgParsed(packet(idling, meta = driver.msgType(toSlave = true))) + expectNoMsg + + expectNoMsgParsed(packet(idling, meta = + (driver.msgType(toSlave = false) + 1 toByte))) + expectNoMsg + + expectNoMsgParsed(packet(idling, meta = + (driver.msgType(toSlave = false) + 2 toByte))) + expectNoMsg + + expectNoMsgParsed(packet(idling, meta = + (driver.msgType(toSlave = false) + 83 toByte))) + expectNoMsg + } + + "not parse packets with acknowledgment bit on" in { + expectNoMsgParsed(packet(idling, meta = + (driver.msgType(toSlave = false) + 1) toByte)) + expectNoMsg + } + + "not parse packets with missing end of message code" in { + expectNoMsgParsed(packet(idling, etx = false)) + expectNoMsg + } + } +} +\ No newline at end of file diff --git a/apex/src/test/scala/inc/pyc/bill/acceptor/apex/ApexMockDriver.scala b/apex/src/test/scala/inc/pyc/bill/acceptor/apex/ApexMockDriver.scala @@ -0,0 +1,8 @@ +package inc.pyc.bill.acceptor +package apex + +import akka.actor._ + +class MockApexDriver(testActor: ActorRef) extends ApexDriver { + override val fsm: ActorRef = testActor +} +\ No newline at end of file diff --git a/apex/src/test/scala/inc/pyc/bill/acceptor/apex/ApexSpec.scala b/apex/src/test/scala/inc/pyc/bill/acceptor/apex/ApexSpec.scala @@ -0,0 +1,149 @@ +package inc.pyc.bill +package acceptor +package apex + +import inc.pyc.currency._ +import Events._ +import States._ +import Data._ +import akka.actor._ +import com.typesafe.config._ +import akka.testkit._ +import concurrent.duration._ +import akka.actor.FSM.StateTimeout + +class ApexSpec extends BaseSpec(ApexConfig.config) + with SpecHelper + with ApexTest { + + "An Apex" should { + testApex + } +} + +trait ApexTest { + this: BaseSpec with SpecHelper => + + def testApex { + + val driver = TestProbe() + val fsm = TestFSMRef(new MockApex(testActor, driver.ref)) + + def aDriver = Driver(driver.ref) + def aBill = Bill(Driver(driver.ref), Inserted(USD(5))) + + def state(s: State, d: Data = NullData)(func: => Unit) { + fsm.setState(s, d); func + } + + type Event = (State, Data) + + val allEvents: List[Event] = { + val allStates: List[State] = List( + Disconnected, + Disconnecting, + Connecting, + PowerUp, + Disabled, + Idle, + Accepting, + Escrow, + Stacking, + Stacked, + Rejecting, + Returning, + Returned, + StackerFull, + StackerOpen, + JamInAcceptor, + Cheated, + Failure, + CommunicationError) + + val allData: List[Data] = List( + NullData, + aDriver, + aBill) + + for { + state <- allStates + data <- allData + } yield (state, data) + } + + def expectTransitions(from: Event)(to: Event*) { + + def test(should: Boolean, e: Event) { + fsm.setState(from._1, from._2) + fsm ! e._1 + if (should) { + fsm.stateName should be(e._1) + fsm.stateData should be(e._2) + } else { + fsm.stateName should be(from._1) + fsm.stateData should be(from._2) + } + } + + allEvents foreach { event => + if (to.exists(_ == event)) { + test(should = true, event) + } else if (event != from) { + test(should = false, event) + } + } + } + + /* + "only go to rejecting and cheated states when Accepting" in + expectTransitions ((Accepting, aDriver)) ( + (Rejecting, aDriver), + (Cheated, aDriver)) + */ + + "be able to reject when accepting" in + state(Accepting, aDriver) { + fsm ! Rejecting + fsm.stateName should be(Rejecting) + fsm.stateData should be(aDriver) + } + + "be able to report a cheater when accepting" in + state(Accepting, aDriver) { + fsm ! Cheated + fsm.stateName should be(Cheated) + fsm.stateData should be(aDriver) + } + + "set state timer when returning" in + state(Returning) { + fsm.isStateTimerActive should be(true) + } + + "go to returned if returning timer expires" in + state(Returning, aBill) { + fsm ! StateTimeout + fsm.stateName should be(Returned) + fsm.stateData should be(aBill) + } + + "set state timer when returned" in + state(Returned) { + fsm.isStateTimerActive should be(true) + } + + "go to idle if returned timer expires" in + state(Returned, aBill) { + fsm ! StateTimeout + fsm.stateName should be(Idle) + fsm.stateData should be(aBill.driver) + } + + "notify confirmed bill when stacked" in + state(Stacking, aBill) { + fsm ! Stacked + val confirmation = Confirmed(aBill.inserted.bill) + expectMsg(confirmation) + } + } +} +\ No newline at end of file diff --git a/apex/src/test/scala/inc/pyc/bill/acceptor/apex/MockApex.scala b/apex/src/test/scala/inc/pyc/bill/acceptor/apex/MockApex.scala @@ -0,0 +1,11 @@ +package inc.pyc.bill.acceptor +package apex + +import akka.actor._ + +class MockApex(testActor: ActorRef, driverRef: ActorRef) + extends Apex { + + override val host: ActorRef = testActor + override def createDriver: ActorRef = driverRef +} +\ No newline at end of file diff --git a/core/src/main/scala/inc/pyc/bill/acceptor/BillAcceptor.scala b/core/src/main/scala/inc/pyc/bill/acceptor/BillAcceptor.scala @@ -0,0 +1,219 @@ +package inc.pyc.bill +package acceptor + +import Commands._ +import States._ +import Data._ +import Events._ +import inc.pyc.currency._ +import akka.actor._ +import akka.actor.SupervisorStrategy._ +import scala.concurrent._ +import duration._ +import com.github.jodersky.flow.Serial.Closed + +object BillAcceptor { + def props(system: ActorSystem) = Props(Class forName Settings(system).driver) +} + +/** + * The interface to communicate with the Bill Acceptor. + */ +trait BillAcceptor extends StateFunctions { + this: FSM[State, Data] with LoggingFSM[State, Data] => + + override val supervisorStrategy = + OneForOneStrategy() { + case ex => + log error ("Driver crashed while {}: {}", stateName, ex getMessage) + self ! Closed + Stop + } + + /** + * Creates a driver. + */ + def createDriver: ActorRef + + /** + * The actor that receives events. + * This is the actor that initialized this actor. + */ + val host: ActorRef = context parent + + /** + * Handles events that are sent to this FSM. + */ + def handleEvents: StateFunction = FSM NullFunction + + /** + * Handles state transitions. + */ + def handleTransitions: TransitionHandler = FSM NullFunction + + /** + * Time it takes for `Connecting` state to timeout. + */ + val connectingTimeout: FiniteDuration = 3 seconds + + /** + * Time it takes for `PowerUp` state to timeout. + */ + val powerupTimeout: FiniteDuration = 3 seconds + + /** + * Time it takes for `Disconnecting` state to timeout. + */ + val disconnectingTimeout: FiniteDuration = 3 seconds + + /** + * Time it takes for `Escrow` state to timeout. + */ + val escrowTimeout: FiniteDuration = 4 seconds + + /** + * Function to execute after `Escrow` state times out. + */ + val escrowTimeoutHandler: () => State = () => { + log warning "Escrow timed out" + self ! Return + stay + } + + startWith(Disconnected, NullData) + + when(Disconnected) { + case Event(Listen, _) => + val driver = createDriver + context watch driver + driver ! Listen + goto(Connecting) using Driver(driver) + } + + when(Connecting, stateTimeout = connectingTimeout)( + shutdownOnTimeout orElse { + case Event(PowerUp, _) => + goto(PowerUp) + + case Event(Idle, _) => + host ! Ready + goto(Idle) + }) + + when(Disconnecting, stateTimeout = disconnectingTimeout)( + shutdownOnTimeout orElse { + case Event(Closed, Driver(ref)) => + context stop ref + goto(Disconnected) using NullData + }) + + when(PowerUp, stateTimeout = powerupTimeout)( + shutdownOnTimeout orElse { + case Event(Idle, _) => + host ! Ready + goto(Idle) + }) + + when(Disabled)( + unlisten orElse { + case Event(s: State, _) => + stay + }) + + when(Idle)( + unlisten orElse { + case Event(Accepting, _) => + goto(Accepting) + }) + + when(Accepting) { + case Event(ins: Inserted, Driver(ref)) => + if (invalid(ins.bill)) self ! Return + else host ! ins + goto(Escrow) using Bill(Driver(ref), ins) + } + + when(Escrow, stateTimeout = escrowTimeout) { + case Event(Stack, Bill(Driver(ref), _)) => + ref ! Stack + goto(Stacking) + + case Event(Return, Bill(Driver(ref), _)) => + ref ! Return + goto(Returning) + + case Event(StateTimeout, _) => + escrowTimeoutHandler() + } + + when(Stacking) { + case Event(Stacked, _) => + goto(Stacked) + } + + when(Returning) { + case Event(Returned, _) => + goto(Returned) + } + + when(Returned) { + case Event(Idle, Bill(driver, _)) => + goto(Idle) using driver + } + + when(Stacked) { + case Event(Idle, Bill(driver, _)) => + goto(Idle) using driver + } + + onTransition( + handleTransitions orElse { + case _ -> Disconnected => + host ! Disconnected + + case _ -> Cheated => + log warning "Someone was caught cheating" + }) + + whenUnhandled( + handleEvents orElse { + case Event(CommunicationError, _) => + log warning ("Communication error in {}/{}", stateName, stateData) + stay + + case Event(Closed, _) => + log error ("Driver unexpectedly closed port while {}", stateName) + goto(Disconnected) using NullData + + case Event(Terminated(driver), Driver(ref)) => + if (driver equals ref) { + self ! Closed + stay + } else stay + + case e => + log warning ("unhandled request {} in state {}", e, stateName) + stay + }) + + override def logDepth = 12 + + onTermination { + case StopEvent(FSM.Failure(_), state, data) => + val lastEvents = getLog.mkString("\n\t") + log.warning("Failure in state {} with data {}\n" + + "Events leading up to this point:\n\t{}", + state, data, lastEvents) + } + + // These states will be initialized by the bill acceptor model. + when(Rejecting)(FSM.NullFunction) + when(StackerFull)(FSM.NullFunction) + when(JamInAcceptor)(FSM.NullFunction) + when(Cheated)(FSM.NullFunction) + when(Failure)(FSM.NullFunction) + when(CommunicationError)(FSM.NullFunction) + when(StackerOpen)(FSM.NullFunction) + + initialize() +} +\ No newline at end of file diff --git a/core/src/main/scala/inc/pyc/bill/acceptor/BillAcceptorErrors.scala b/core/src/main/scala/inc/pyc/bill/acceptor/BillAcceptorErrors.scala @@ -0,0 +1,69 @@ +package inc.pyc.bill +package acceptor + +import Commands._ +import States._ +import akka.actor._ + +/** + * Handles errors in the acceptor + * while and after the bill is moving. + */ +trait BillAcceptorErrors { + this: BillAcceptor with FSM[State, Data] => + + // Bill may be in transit + // during any of these states + + when(PowerUp)(handleError) + when(Accepting)(handleError) + when(Stacking)(handleError) + when(Returning)(handleError) + when(Rejecting)(handleError) + when(Cheated)(handleError) + + /** + * Handles the possible errors for the + * failure of the bill acceptor. + * + * Include this when powering up or + * when bill is moving in the acceptor. + */ + def handleError: StateFunction = { + case Event(JamInAcceptor, _) => + log error ("Jammed in {}/{}", stateName, stateData) + goto(JamInAcceptor) + + case Event(StackerFull, _) => + log error ("Stacker Full in {}/{}", stateName, stateData) + goto(StackerFull) + + case Event(Failure, _) => + log error ("Failure in {}/{}", stateName, stateData) + goto(Failure) + } + + when(JamInAcceptor)(unlisten orElse { + case Event(JamInAcceptor, _) => stay + case Event(s: acceptor.State, _) => unlistenIfOtherState + }) + + when(StackerFull)(unlisten orElse { + case Event(StackerFull, _) => stay + case Event(s: acceptor.State, _) => unlistenIfOtherState + }) + + when(Failure)(unlisten orElse { + case Event(Failure, _) => stay + case Event(s: acceptor.State, _) => unlistenIfOtherState + }) + + /** + * Stop listening if any state is sent. + */ + def unlistenIfOtherState: State = { + log info ("Operating again after state {}", stateName) + self ! UnListen + stay + } +} +\ No newline at end of file diff --git a/core/src/main/scala/inc/pyc/bill/acceptor/Commands.scala b/core/src/main/scala/inc/pyc/bill/acceptor/Commands.scala @@ -0,0 +1,43 @@ +package inc.pyc.bill +package acceptor + + +/** + * Command from the app to the bill acceptor. + */ +sealed trait Command + + +object Commands { + /** + * Command to turn on the driver and + * listen for inserted bills. + */ + case object Listen extends Command + + /** + * Command to turn off the driver and + * stop listening for inserted bills. + */ + case object UnListen extends Command + + /** + * Accept a bill in bill acceptor (escrow mode). + */ + case object Stack extends Command + + /** + * Reject a bill in bill acceptor (escrow mode). + */ + case object Return extends Command + + /** + * Turn on acceptance of all currency. + */ + case object Inhibit extends Command + + /** + * Turn off acceptance of all currency. + */ + case object UnInhibit extends Command +} +\ No newline at end of file diff --git a/core/src/main/scala/inc/pyc/bill/acceptor/Data.scala b/core/src/main/scala/inc/pyc/bill/acceptor/Data.scala @@ -0,0 +1,27 @@ +package inc.pyc.bill +package acceptor + +import Events._ +import akka.actor.ActorRef + +/** + * Payload data sent by the bill acceptor. + */ +sealed trait Data + +object Data { + /** + * The driver sent a message with no data. + */ + case object NullData extends Data + + /** + * The bill acceptor driver actor. + */ + case class Driver(ref: ActorRef) extends Data + + /** + * Container to hold the driver actor and inserted bill. + */ + case class Bill(driver: Driver, inserted: Inserted) extends Data +} +\ No newline at end of file diff --git a/core/src/main/scala/inc/pyc/bill/acceptor/Events.scala b/core/src/main/scala/inc/pyc/bill/acceptor/Events.scala @@ -0,0 +1,30 @@ +package inc.pyc.bill +package acceptor + +import inc.pyc.currency.Currency + +/** + * When something occurs and the host actor needs to be + * notified with information. + */ +sealed trait Event + +object Events { + + /** + * When the bill acceptor is ready for service. + */ + case object Ready + + /** + * Command to notify that a bill was inserted + * and the value of the bill is `bill` + */ + case class Inserted(bill: Currency#Value) extends Event + + /** + * Command to notify that a bill has been accepted + * and cannot be returned. + */ + case class Confirmed(bill: Currency#Value) extends Event +} +\ No newline at end of file diff --git a/core/src/main/scala/inc/pyc/bill/acceptor/Inhibitable.scala b/core/src/main/scala/inc/pyc/bill/acceptor/Inhibitable.scala @@ -0,0 +1,27 @@ +package inc.pyc.bill +package acceptor + +import Commands._ +import States._ +import Data._ +import akka.actor._ + +/** + * Bill acceptor that can inhibit + * and uninhibit bills. + */ +trait Inhibitable { + this: BillAcceptor with FSM[State, Data] => + + when(Disabled) { + case Event(Inhibit, Driver(ref)) => + ref ! Inhibit + goto(Idle) + } + + when(Idle) { + case Event(UnInhibit, Driver(ref)) => + ref ! UnInhibit + goto(Disabled) + } +} +\ No newline at end of file diff --git a/core/src/main/scala/inc/pyc/bill/acceptor/OpenableStacker.scala b/core/src/main/scala/inc/pyc/bill/acceptor/OpenableStacker.scala @@ -0,0 +1,52 @@ +package inc.pyc.bill +package acceptor + +import Commands._ +import States._ +import akka.actor._ +import concurrent.duration._ + +/** + * When the bill acceptor can detect when the stacker + * is open or not, use this trait. + */ +trait OpenableStacker extends StateFunctions { + this: BillAcceptor with FSM[State, Data] => + + when(Disabled)(stackerOpen) + when(Idle)(stackerOpen) + + /** + * Time it takes for `StackerOpen` state to timeout. + */ + val stackerOpenTimeout: FiniteDuration = 5 minutes + + when(StackerOpen, stateTimeout = stackerOpenTimeout)( + unlisten orElse { + case Event(StackerOpen, _) => + stay + + case Event(s: acceptor.State, _) => + self ! UnListen + log warning "Stacker is closed" + stay + + case Event(StateTimeout, _) => + log error "Stacker is still open .... " + stay + }) + + override def handleTransitions: TransitionHandler = { + case _ -> StackerOpen => + log warning "Stacker is open" + } + + /** + * In the event when a state can + * transition to `StackerOpen`. + */ + def stackerOpen: StateFunction = { + case Event(StackerOpen, _) => + goto(StackerOpen) + } +} +\ No newline at end of file diff --git a/core/src/main/scala/inc/pyc/bill/acceptor/Settings.scala b/core/src/main/scala/inc/pyc/bill/acceptor/Settings.scala @@ -0,0 +1,28 @@ +package inc.pyc.bill +package acceptor + +import akka.actor._ +import com.typesafe.config._ + +class SettingsImpl(config: Config) extends Extension { + val currency = config getString "bill-acceptor.currency" + val driver = config getString "bill-acceptor.driver" + val port = config getString "bill-acceptor.port" + val bufferSize = config getInt "bill-acceptor.buffer-size" + val baud = config getInt "bill-acceptor.baud" + val characterSize = config getInt "bill-acceptor.char-size" + val twoStopBits = config getBoolean "bill-acceptor.two-stop-bits" + val parity = config getInt "bill-acceptor.parity" +} + +object Settings extends ExtensionId[SettingsImpl] + with ExtensionIdProvider { + + override def lookup = Settings + + override def createExtension(system: ExtendedActorSystem) = + new SettingsImpl(system.settings.config) + + override def get(system: ActorSystem): SettingsImpl = super.get(system) +} + diff --git a/core/src/main/scala/inc/pyc/bill/acceptor/StateFunctions.scala b/core/src/main/scala/inc/pyc/bill/acceptor/StateFunctions.scala @@ -0,0 +1,32 @@ +package inc.pyc.bill +package acceptor + +import Data._ +import Commands._ +import States._ +import akka.actor._ + +/** + * Helper functions that return `StateFunction` + */ +trait StateFunctions { + this: BillAcceptor with FSM[State, Data] => + + /** + * Sends the shutdown signal to the driver when it times out. + */ + def shutdownOnTimeout: StateFunction = { + case Event(StateTimeout, Driver(ref)) => + ref ! driver.Shutdown // left hanging, kill it + stay + } + + /** + * Can stop listening and disconnect the driver. + */ + def unlisten: StateFunction = { + case Event(UnListen, Driver(ref)) => + ref ! UnListen + goto(Disconnecting) + } +} +\ No newline at end of file diff --git a/core/src/main/scala/inc/pyc/bill/acceptor/States.scala b/core/src/main/scala/inc/pyc/bill/acceptor/States.scala @@ -0,0 +1,112 @@ +package inc.pyc.bill +package acceptor + +/** + * State of the bill acceptor + */ +sealed trait State + +object States { + /** + * When bill acceptor is not connected or + * listening to a serial port. + */ + case object Disconnected extends State + + /** + * The driver is closing the serial port. + */ + case object Disconnecting extends State + + /** + * The driver is opening serial port. + */ + case object Connecting extends State + + /** + * Ready to accept currency. + */ + case object Idle extends State + + /** + * The Acceptor is drawing the bill in and + * examining it with validation sensors + */ + case object Accepting extends State + + /** + * Bill validation is complete. + * One byte of data is sent with this status + * to indicate the inserted denomination. + */ + case object Escrow extends State + + /** + * A bill is being transported to the stacker + */ + case object Stacking extends State + + /** + * Status is reported after cash has been stacked. + */ + case object Stacked extends State + + /** + * The Acceptor judged a bill invalid or the Host + * disabled acceptance of a specific denomination. + */ + case object Rejecting extends State + + /** + * During Escrow status, the acceptor received a Return + * command from the Host; the bill is being returned. + */ + case object Returning extends State + + /** + * Status is reported after cash has been returned. + */ + case object Returned extends State + + /** + * The Acceptor will not draw any notes into + * the path during this status. + */ + case object Disabled extends State + + /** + * The stacker is either removed or not completely installed. + */ + case object StackerOpen extends State + + /** + * Standard response when the acceptor receives power. + */ + case object PowerUp extends State + + /** + * The stacker is full. + */ + case object StackerFull extends State + + /** + * A bill is jammed inside the Acceptor head + */ + case object JamInAcceptor extends State + + /** + * The Acceptor has detected an action thought to be mischievous + */ + case object Cheated extends State + + /** + * Normal Acceptor operation cannot continue because of a + * failure, abnormal condition or incorrect setting + */ + case object Failure extends State + + /** + * An error has developed in the communication data + */ + case object CommunicationError extends State +} +\ No newline at end of file diff --git a/core/src/main/scala/inc/pyc/bill/acceptor/driver/Commands.scala b/core/src/main/scala/inc/pyc/bill/acceptor/driver/Commands.scala @@ -0,0 +1,32 @@ +package inc.pyc.bill +package acceptor +package driver + +import akka.AkkaException +import scala.util.control.NoStackTrace +import akka.util.ByteString +import com.github.jodersky.flow.Serial.Event + +/** + * Shutdown driver + */ +case object Shutdown + +/** + * Command to send data to serial. + */ +case class Input(input: ByteString) + +/** + * Command when writing to the serial actor. + */ +private[acceptor] case class Wrote(data: ByteString) extends Event + +// exceptions +private[acceptor] class CommandFailedException(reason: Throwable) + extends AkkaException("Connection failed, stopping driver. Reason: " + reason, reason) + with NoStackTrace + +private[acceptor] class OperatorCrashException + extends AkkaException("Operator crashed, exiting driver", new Throwable) + with NoStackTrace diff --git a/core/src/main/scala/inc/pyc/bill/acceptor/driver/Data.scala b/core/src/main/scala/inc/pyc/bill/acceptor/driver/Data.scala @@ -0,0 +1,20 @@ +package inc.pyc.bill +package acceptor +package driver + +import akka.actor.ActorRef + +/** + * Data used by driver + */ +sealed trait DriverData + +/** + * Null + */ +case object NullData extends DriverData + +/** + * Container to hold the serial operator actor. + */ +case class Operator(ref: ActorRef) extends DriverData +\ No newline at end of file diff --git a/core/src/main/scala/inc/pyc/bill/acceptor/driver/Driver.scala b/core/src/main/scala/inc/pyc/bill/acceptor/driver/Driver.scala @@ -0,0 +1,140 @@ +package inc.pyc.bill +package acceptor +package driver + +import States._ +import Commands._ +import inc.pyc.currency._ +import akka.actor._ +import akka.io.IO +import akka.util.ByteString +import com.github.jodersky.flow._ +import com.github.jodersky.flow.Serial._ +import scala.concurrent.duration._ + +/** + * Main Bill Acceptor Driver trait. + */ +private[acceptor] trait Driver { + this: FSM[State, DriverData] with LoggingFSM[State, DriverData] => + + import context.{ dispatcher, system } + + /** The Bill Acceptor FSM is the the parent actor */ + val fsm = context parent + + /** + * Serial Connection Actor + */ + def serial: ActorRef = IO(Serial) + + /** Currency that is currently being accepted. */ + val currency = findCurrency(Settings(system).currency) + + /** Command to poll bill acceptor's status. */ + def poll: Input + + /** Serial port (/dev/tty*) */ + val port: String = Settings(system).port + + /** Size of buffer */ + val bufferSize: Int = Settings(system).bufferSize + + /** Settings for serial */ + val settings = SerialSettings( + baud = Settings(system).baud, + characterSize = Settings(system).characterSize, + twoStopBits = Settings(system).twoStopBits, + parity = Parity(Settings(system).parity)) + + /** + * Timer for disconnecting state + */ + val disconnectingTimeout = 2 seconds + + startWith(Disconnected, NullData) + + when(Disconnected) { + case Event(Listen, _) => + log debug "Requesting operator to open port" + serial ! Open(port, settings, bufferSize) + stay + + case Event(CommandFailed(cmd, reason), _) => + throw new CommandFailedException(reason) + + case Event(Opened(port), _) => + log debug ("Port {} is now open", port) + val operator = sender + context watch operator + goto(Idle) using (Operator(operator)) + } + + when(Idle) { + case Event(Terminated(ref), Operator(op)) => + if (op equals ref) throw new OperatorCrashException + else stay + + case Event(Shutdown, Operator(op)) => + op ! Kill + stop(FSM.Failure("Terminated driver while idle")) + + case Event(UnListen, Operator(op)) => + log debug "Driver's serial connection is closing" + op ! Close + goto(Disconnecting) + + case Event(Input(data), Operator(op)) => + op ! Write(data) + stay + } + + when(Disconnecting, stateTimeout = disconnectingTimeout) { + case Event(Closed, _) => + log debug "Operator closed port normally" + fsm ! Closed + stay + + case Event(Terminated(ref), Operator(op)) => + if (ref equals op) { + log debug "Operator terminated normally, exiting driver" + context unwatch op + goto(Disconnected) using NullData + } else stay + + case Event(StateTimeout, _) => + log warning "Driver left hanging while disconnecting" + stop() + } + + onTransition { + case Disconnected -> Idle => + setTimer("poll", poll, 100 milliseconds, true) + + case Idle -> _ => + cancelTimer("poll") + } + + whenUnhandled { + case Event(Input(_), _) => + stay // ignore + + case e => + log warning ("unhandled request {} in state {}", e, stateName) + stay + } + /* + override def logDepth = 12 + + onTermination { + case StopEvent(FSM.Failure(_), state, data) => + val lastEvents = getLog.mkString("\n\t") + log.warning("Failure in state {} with data {}\n" + + "Events leading up to this point:\n\t{}", + state, data, lastEvents) + } + */ + protected def formatData(data: ByteString) = + data.map("0x" + Integer.toHexString(_)) mkString ("[", ",", "]") + +} +\ No newline at end of file diff --git a/core/src/test/scala/inc/pyc/bill/acceptor/BaseSpec.scala b/core/src/test/scala/inc/pyc/bill/acceptor/BaseSpec.scala @@ -0,0 +1,17 @@ +package inc.pyc.bill.acceptor + +import akka.actor._ +import akka.testkit._ +import org.scalatest._ +import MockConfig._ +import com.typesafe.config._ + +abstract class BaseSpec(config: Config) + extends TestKit(ActorSystem("BaseSpec", config)) + with ImplicitSender + with WordSpecLike + with ShouldMatchers + with BeforeAndAfterAll { + + override def afterAll = TestKit.shutdownActorSystem(system) +} +\ No newline at end of file diff --git a/core/src/test/scala/inc/pyc/bill/acceptor/BillAcceptorErrorsSpec.scala b/core/src/test/scala/inc/pyc/bill/acceptor/BillAcceptorErrorsSpec.scala @@ -0,0 +1,105 @@ +package inc.pyc.bill.acceptor + +import Commands._ +import States._ +import Data._ +import Events._ +import driver.Shutdown +import inc.pyc.currency._ +import akka.actor._ +import akka.testkit._ +import akka.actor.SupervisorStrategy._ +import com.github.jodersky.flow.Serial.Closed +import concurrent.duration._ +import FSM.StateTimeout + +class BillAcceptorErrorsSpec extends BaseSpec(MockConfig.config) + with SpecHelper + with ErrorsTest { + + "A bill acceptor with error handling support" should { + testBillAcceptorErrors + } +} + +trait ErrorsTest { + this: BaseSpec with SpecHelper => + + def testBillAcceptorErrors { + val driver = TestProbe() + val fsm = TestFSMRef(new MockErrorBillAcceptor(testActor, driver.ref)) + def driverData = Driver(driver.ref) + + def state(s: State, d: Data = NullData)(f: => Unit) { + fsm.setState(s, d); f + } + + def expectErrorHandling(s: State) { + state(s) { + error("Jammed") { + fsm ! JamInAcceptor + fsm.stateName should be(JamInAcceptor) + } + } + + state(s) { + error("Stacker Full") { + fsm ! StackerFull + fsm.stateName should be(StackerFull) + } + } + + state(s) { + error("Failure") { + fsm ! Failure + fsm.stateName should be(Failure) + } + } + } + + def expectRecoveryHandling(s: State) { + state(s, driverData) { + within(1000 millis) { + fsm ! s + fsm.stateName should be(s) + fsm ! Idle + fsm.stateName should be(Disconnecting) + } + } + } + + "handle errors when powering up" in + expectErrorHandling(PowerUp) + + "handle errors when accepting" in + expectErrorHandling(Accepting) + + "handle errors when stacking" in + expectErrorHandling(Stacking) + + "handle errors when returning" in + expectErrorHandling(Returning) + + "handle errors when rejecting" in + expectErrorHandling(Rejecting) + + "handle errors when cheated" in + expectErrorHandling(Cheated) + + "be able to operate again and unlisten when jammed" in + state(JamInAcceptor, driverData) { + within(2 second) { + fsm ! JamInAcceptor + fsm.stateName should be(JamInAcceptor) + fsm ! Idle + fsm.stateName should be(Disconnecting) + } + } + + "be able to operate again and unlisten when stacker is full" in + expectRecoveryHandling(StackerFull) + + "be able to operate again and unlisten after failure" in + expectRecoveryHandling(Failure) + } +} +\ No newline at end of file diff --git a/core/src/test/scala/inc/pyc/bill/acceptor/BillAcceptorSpec.scala b/core/src/test/scala/inc/pyc/bill/acceptor/BillAcceptorSpec.scala @@ -0,0 +1,306 @@ +package inc.pyc.bill +package acceptor + +import Commands._ +import States._ +import Data._ +import Events._ +import driver.Shutdown +import inc.pyc.currency._ +import akka.actor._ +import akka.testkit._ +import akka.actor.SupervisorStrategy._ +import com.github.jodersky.flow.Serial.Closed +import concurrent.duration._ +import FSM.StateTimeout + +class BillAcceptorSpec extends BaseSpec(MockConfig.config) + with SpecHelper + with BillAcceptorTest { + + "A bill acceptor" should { + testBillAcceptor + } +} + +trait BillAcceptorTest { + this: BaseSpec with SpecHelper => + + def testBillAcceptor { + var driver = TestProbe() + var fsm = TestFSMRef(new MockBillAcceptor(testActor, driver.ref)) + def driverData = Driver(driver.ref) + def billData(inserted: Inserted) = Bill(driverData, inserted) + + def reset { + driver = TestProbe() + fsm = TestFSMRef(new MockBillAcceptor(testActor, driver.ref)) + } + + def state(s: State, d: Data = NullData)(f: => Unit) { + fsm.setState(s, d); f + } + + "be disconnected when it starts" in { + fsm.stateName should be(Disconnected) + fsm.stateData should be(NullData) + } + + "disconnect when driver disconnects unexpectedly" in + state(Idle, driverData) { + within(500 millis) { + fsm ! Closed + expectMsg(Disconnected) + fsm.stateName should be(Disconnected) + fsm.stateData should be(NullData) + } + } + + "report an error when driver disconnects unexpectedly" in + state(Idle) { + error("Driver unexpectedly closed port") { + fsm ! Closed + expectMsg(Disconnected) + } + } + + "disconnect when driver is terminated unexpectedly" in + state(Idle, driverData) { + within(500 millis) { + fsm.watch(driver.ref) + driver.ref ! PoisonPill + expectMsg(Disconnected) + fsm.stateName should be(Disconnected) + fsm.stateData should be(NullData) + } + reset + } + + "warn about unknown message" in { + def test = warn("unhandled") { + fsm ! "unknown" + } + + test + fsm.setState(Escrow) + test + fsm.setState(Connecting) + test + } + + "warn when someone cheats" in { + warn("Someone was caught cheating") { + fsm.setState(Cheated) + } + } + + "warn when there is an error communicating" in { + warn("Communication error") { + fsm ! CommunicationError + } + } + + "be connecting after listen command is sent" in + state(Disconnected) { + expectMsg(Disconnected) + fsm ! Listen + driver.expectMsg(Listen) + fsm.stateName should be(Connecting) + fsm.stateData should be(driverData) + } + + "be able to go to idle state after connecting" in + state(Connecting, driverData) { + fsm ! Idle + fsm.stateName should be(Idle) + fsm.stateData should be(driverData) + } + + "notify when it is idle after connecting" in { + expectMsg(Ready) + } + + "be able to go to power up state after connecting" in + state(Connecting, driverData) { + fsm ! PowerUp + fsm.stateName should be(PowerUp) + fsm.stateData should be(driverData) + } + + "be able to go to idle state after powering up" in + state(PowerUp, driverData) { + fsm ! Idle + fsm.stateName should be(Idle) + fsm.stateData should be(driverData) + } + + "notify when it is idle after powering up" in { + expectMsg(Ready) + } + + "be able to start accepting bills in idle state" in + state(Idle, driverData) { + fsm ! Accepting + fsm.stateName should be(Accepting) + fsm.stateData should be(driverData) + } + + "be able to go to escrow state with inserted bill while accepting" in + state(Accepting, driverData) { + val bill = Inserted(USD(5)) + fsm ! bill + fsm.stateName should be(Escrow) + fsm.stateData should be(billData(bill)) + } + + "notify when there is an inserted bill" in { + expectMsg(Inserted(USD(5))) + } + + "return bill if it is invalid currency value" in + state(Accepting, driverData) { + fsm ! Inserted(USD(0)) + driver.expectMsg(Return) + } + + "set a state timer in escrow" in + state(Escrow) { + fsm.isStateTimerActive should be(true) + } + + "return bill if escrow timer goes off" in + state(Escrow, billData(Inserted(USD(100)))) { + fsm ! StateTimeout + driver.expectMsg(Return) + } + + "warn if escrow timer goes off" in + state(Escrow) { + warn("Escrow timed out") { + fsm ! StateTimeout + } + } + + "stack when stacking" in { + val data = billData(Inserted(USD(50))) + state(Stacking, data) { + fsm ! Stacked + fsm.stateName should be(Stacked) + fsm.stateData should be(data) + } + } + + "return when returning" in { + val data = billData(Inserted(USD(50))) + state(Returning, data) { + fsm ! Returned + fsm.stateName should be(Returned) + fsm.stateData should be(data) + } + } + + "be able to idle after bill has been stacked" in { + val data = billData(Inserted(USD(50))) + state(Stacked, data) { + fsm ! Idle + fsm.stateName should be(Idle) + fsm.stateData should be(data.driver) + } + } + + "be able to idle after bill has been returned" in { + val data = billData(Inserted(USD(50))) + state(Returned, data) { + fsm ! Idle + fsm.stateName should be(Idle) + fsm.stateData should be(data.driver) + } + } + + "be able to unlisten only when idle or disabled" in { + def test(should: Boolean, s: State) = state(s, driverData) { + fsm ! UnListen + + if (should) { + driver.expectMsg(UnListen) + fsm.stateName should be(Disconnecting) + } else { + driver.expectNoMsg + fsm.stateName should be(s) + } + + } + + test(should = true, Idle) + test(should = true, Disabled) + test(should = false, Escrow) + test(should = false, Stacking) + test(should = false, PowerUp) + } + + "disconnect when connection closes" in + state(Disconnecting) { + fsm ! Closed + fsm.stateName should be(Disconnected) + fsm.stateData should be(NullData) + } + + "notify when bill acceptor disconnects" in { + expectMsg(Disconnected) + } + + "set a state timer when connecting" in + state(Connecting) { + fsm.isStateTimerActive should be(true) + } + + "set a state timer when disconnecting" in + state(Disconnecting) { + fsm.isStateTimerActive should be(true) + } + + "set a state timer when powering up" in + state(PowerUp) { + fsm.isStateTimerActive should be(true) + } + + "stay by default when there is a communication error" in { + def test(s: State) = state(s, driverData) { + fsm ! CommunicationError + fsm.stateName should be(s) + fsm.stateData should be(driverData) + } + + test(Idle) + test(Escrow) + test(Accepting) + test(Returning) + } + + def timeoutTest(s: State): Unit = state(s, driverData) { + fsm ! StateTimeout + driver.expectMsg(Shutdown) + fsm.stateName should be(s) + } + + "shutdown the driver when connecting times out" in + timeoutTest(Connecting) + + "shutdown the driver when disconnecting times out" in + timeoutTest(Disconnecting) + + "shutdown the driver when powering up times out" in + timeoutTest(PowerUp) + + "call handleEvents function" in { + fsm ! "event-test" + expectMsg("event-test") + } + + "call handleTransitions function" in { + fsm.setState(Disconnecting) + fsm.setState(Cheated) + expectMsg("transition-test") + } + } +} +\ No newline at end of file diff --git a/core/src/test/scala/inc/pyc/bill/acceptor/InhibitableSpec.scala b/core/src/test/scala/inc/pyc/bill/acceptor/InhibitableSpec.scala @@ -0,0 +1,75 @@ +package inc.pyc.bill.acceptor + +import Commands._ +import States._ +import Data._ +import Events._ +import driver.Shutdown +import inc.pyc.currency._ +import akka.actor._ +import akka.testkit._ +import akka.actor.SupervisorStrategy._ +import com.github.jodersky.flow.Serial.Closed +import concurrent.duration._ +import FSM.StateTimeout + +class InhibitableSpec extends BaseSpec(MockConfig.config) + with SpecHelper + with InhibitableTest { + + "An inhibitable bill acceptor" should { + testInhibitable + } +} + +trait InhibitableTest { + this: BaseSpec with SpecHelper => + + def testInhibitable { + val driver = TestProbe() + val fsm = TestFSMRef(new MockInhibitBillAcceptor(testActor, driver.ref)) + def driverData = Driver(driver.ref) + + def state(s: State, d: Data = NullData)(f: => Unit) { + fsm.setState(s, d); f + } + + "only be able to inhibit when disabled" in { + state(Disabled, driverData) { + fsm ! Inhibit + fsm.stateName should be(Idle) + fsm.stateData should be(driverData) + } + + state(Idle, driverData) { + fsm ! Inhibit + fsm.stateName should be(Idle) + fsm.stateData should be(driverData) + } + + state(Escrow) { + fsm ! Inhibit + fsm.stateName should be(Escrow) + } + } + + "only be able to uninhibit when idle" in { + state(Idle, driverData) { + fsm ! UnInhibit + fsm.stateName should be(Disabled) + fsm.stateData should be(driverData) + } + + state(Disabled, driverData) { + fsm ! UnInhibit + fsm.stateName should be(Disabled) + fsm.stateData should be(driverData) + } + + state(Escrow) { + fsm ! UnInhibit + fsm.stateName should be(Escrow) + } + } + } +} +\ No newline at end of file diff --git a/core/src/test/scala/inc/pyc/bill/acceptor/MockBillAcceptor.scala b/core/src/test/scala/inc/pyc/bill/acceptor/MockBillAcceptor.scala @@ -0,0 +1,55 @@ +package inc.pyc.bill +package acceptor + +import States._ +import akka.actor._ + +class MockBillAcceptor(testActor: ActorRef, driver: ActorRef) + extends FSM[State, Data] + with LoggingFSM[State, Data] + with BillAcceptor { + + def createDriver: ActorRef = driver + override val host: ActorRef = testActor + + override def handleEvents: StateFunction = { + case Event("event-test", _) => + testActor ! "event-test" + stay + } + + override def handleTransitions: TransitionHandler = { + case Disconnecting -> Cheated => + testActor ! "transition-test" + } +} + +class MockOpenableBillAcceptor(testActor: ActorRef, driver: ActorRef) + extends FSM[State, Data] + with LoggingFSM[State, Data] + with BillAcceptor + with OpenableStacker { + + def createDriver: ActorRef = driver + override val host: ActorRef = testActor +} + +class MockErrorBillAcceptor(testActor: ActorRef, driver: ActorRef) + extends FSM[State, Data] + with LoggingFSM[State, Data] + with BillAcceptor + with BillAcceptorErrors { + + def createDriver: ActorRef = driver + override val host: ActorRef = testActor +} + +class MockInhibitBillAcceptor(testActor: ActorRef, driver: ActorRef) + extends FSM[State, Data] + with LoggingFSM[State, Data] + with BillAcceptor + with Inhibitable { + + def createDriver: ActorRef = driver + override val host: ActorRef = testActor +} +\ No newline at end of file diff --git a/core/src/test/scala/inc/pyc/bill/acceptor/MockConfig.scala b/core/src/test/scala/inc/pyc/bill/acceptor/MockConfig.scala @@ -0,0 +1,22 @@ +package inc.pyc.bill.acceptor + +import com.typesafe.config.ConfigFactory + +object MockConfig { + val config = ConfigFactory.parseString(""" +akka { + loglevel = "WARNING" + loggers = [akka.testkit.TestEventListener] + stdout-loglevel = "OFF" +} +bill-acceptor { + currency = "USD" + driver = "inc.pyc.bill.acceptor.MockBillAcceptor" + port = "/dev/null" + buffer-size = 0 + baud = 0 + char-size = 0 + two-stop-bits = off + parity = 0 +}""") +} +\ No newline at end of file diff --git a/core/src/test/scala/inc/pyc/bill/acceptor/OpenableSpec.scala b/core/src/test/scala/inc/pyc/bill/acceptor/OpenableSpec.scala @@ -0,0 +1,84 @@ +package inc.pyc.bill.acceptor + +import Commands._ +import States._ +import Data._ +import Events._ +import driver.Shutdown +import inc.pyc.currency._ +import akka.actor._ +import akka.testkit._ +import akka.actor.SupervisorStrategy._ +import com.github.jodersky.flow.Serial.Closed +import concurrent.duration._ +import FSM.StateTimeout + +class OpenableSpec extends BaseSpec(MockConfig.config) + with SpecHelper + with OpenableTest { + + "A bill acceptor with openable stacker support" should { + testOpenable + } +} + +trait OpenableTest { + this: BaseSpec with SpecHelper => + + def testOpenable { + val driver = TestProbe() + val fsm = TestFSMRef(new MockOpenableBillAcceptor(testActor, driver.ref)) + def driverData = Driver(driver.ref) + + def state(s: State, d: Data = NullData)(f: => Unit) { + fsm.setState(s, d); f + } + + "warn when stacker opens" in state(Idle) { + warn("Stacker is open") { + fsm.setState(StackerOpen) + } + } + + "warn when stacker closes" in state(StackerOpen) { + warn("Stacker is closed") { + fsm ! Idle + } + } + + "be disconnecting when stacker closes" in + state(StackerOpen, driverData) { + fsm ! Idle + fsm.stateName should be(Disconnecting) + } + + "report an error if stacker never closes" in + state(StackerOpen) { + error("Stacker is still open") { + fsm ! StateTimeout + } + } + + "go to stacker open state only when idle and disabled" in { + state(Idle) { + fsm ! StackerOpen + fsm.stateName should be(StackerOpen) + } + + state(Disabled) { + fsm ! StackerOpen + fsm.stateName should be(StackerOpen) + } + + state(Escrow) { + fsm ! StackerOpen + fsm.stateName should be(Escrow) + } + + state(Stacking) { + fsm ! StackerOpen + fsm.stateName should be(Stacking) + } + } + } +} +\ No newline at end of file diff --git a/core/src/test/scala/inc/pyc/bill/acceptor/SpecHelper.scala b/core/src/test/scala/inc/pyc/bill/acceptor/SpecHelper.scala @@ -0,0 +1,33 @@ +package inc.pyc.bill.acceptor + +import akka.actor._ +import akka.testkit._ + +trait SpecHelper { + this: BaseSpec => + + def terminates(ref: ActorRef)(func: => Unit) { + val test = TestProbe() + test.watch(ref) + func + test.expectTerminated(ref) + } + + def warn(start: String)(func: => Unit) { + EventFilter.warning(occurrences = 1, start = start) intercept { + func + } + } + + def error(start: String)(func: => Unit) { + EventFilter.error(occurrences = 1, start = start) intercept { + func + } + } + + def info(start: String)(func: => Unit) { + EventFilter.info(occurrences = 1, start = start) intercept { + func + } + } +} +\ No newline at end of file diff --git a/core/src/test/scala/inc/pyc/bill/acceptor/driver/DriverSpec.scala b/core/src/test/scala/inc/pyc/bill/acceptor/driver/DriverSpec.scala @@ -0,0 +1,202 @@ +package inc.pyc.bill +package acceptor +package driver + +import Commands._ +import States._ +import inc.pyc.currency._ +import akka.actor._ +import akka.testkit._ +import akka.util.ByteString +import concurrent.duration._ +import com.github.jodersky.flow._ +import Serial._ + +class DriverSpec extends BaseSpec(MockConfig.config) + with SpecHelper + with DriverTest { + + "A bill acceptor rs-232 driver" should { + testDriver + } +} + +trait DriverTest { + this: BaseSpec with SpecHelper => + + def testDriver { + + var operator = TestProbe() + val serial = TestProbe() + var driver = TestFSMRef(new MockDriver(testActor, serial.ref)) + def boxed = Operator(operator.ref) + + def reset { + operator = TestProbe() + driver = TestFSMRef(new MockDriver(testActor, serial.ref)) + } + + def state(s: State, d: DriverData = NullData)(f: => Unit) { + driver.setState(s, d); f + } + + "be disconnected when it starts" in { + driver.stateName should be(Disconnected) + driver.stateData should be(NullData) + } + + "load currency from configurations" in { + driver.underlyingActor.currency should be(USD) + } + + "warn about unknown message" in { + + def test = warn("unhandled") { + driver ! "unknown" + } + + driver.setState(Disconnected) + test + driver.setState(Idle) + test + driver.setState(Disconnecting) + test + } + + "be able to open the serial port" in + state(Disconnected) { + val du = driver.underlyingActor + driver ! Listen + serial.expectMsg(Open(du.port, du.settings, du.bufferSize)) + } + + "crash if opening the serial port fails" in + state(Disconnected) { + val du = driver.underlyingActor + val cmd = Open(du.port, du.settings, du.bufferSize) + + intercept[CommandFailedException] { + driver receive CommandFailed(cmd, new Throwable) + } + } + + "be idle when port is open" in + state(Disconnected) { + driver ! Opened("port") + driver.stateName should be(Idle) + driver.stateData should be(Operator(testActor)) + } + + "set timer to poll operator every 100 millis when idle" in + state(Disconnected) { + driver.isTimerActive("poll") should be(false) + driver.setState(Idle, boxed) + driver.isTimerActive("poll") should be(true) + val time = 3 + val bs: ByteString = driver.underlyingActor.poll.input + operator.receiveN((time - 1) * 10, time seconds) + operator.awaitAssert(operator.expectMsg(Write(bs)), time seconds, 100 millis) + } + + "stop the poll timer when disconnecting" in + state(Disconnected) { + driver.setState(Idle, boxed) + driver.isTimerActive("poll") should be(true) + driver.setState(Disconnecting, boxed) + driver.isTimerActive("poll") should be(false) + } + + "crash if operator fails while idle" in + state(Idle, boxed) { + EventFilter[OperatorCrashException](occurrences = 1) intercept { + driver.watch(operator.ref) + operator.ref ! Kill + } + reset + } + + "send input data to operator when idle" in + state(Idle, boxed) { + val data = ByteString(0x01) + + driver ! Input(data) + operator.expectMsg(Write(data)) + driver.stateName should be(Idle) + driver.stateData should be(boxed) + + driver.setState(Disconnecting, boxed) + driver ! Input(data) + driver.setState(Disconnected, boxed) + driver ! Input(data) + operator.expectNoMsg + } + + "be able to unlisten when idle" in + state(Idle, boxed) { + driver ! UnListen + operator.expectMsg(Close) + driver.stateName should be(Disconnecting) + driver.stateData should be(boxed) + } + + "notify when port is closed while disconnecting" in + state(Disconnecting, boxed) { + driver ! Closed + expectMsg(Closed) + } + + "disconnect when operator is terminated while disconnecting" in + state(Disconnecting, boxed) { + driver.watch(operator.ref) + operator.ref ! PoisonPill + awaitAssert(driver.stateName should be(Disconnected), 800 millis) + awaitAssert(driver.stateData should be(NullData), 800 millis) + reset + } + + "terminate the operator if forced to shutdown when idle" in + state(Idle, boxed) { + terminates(operator.ref) { + driver ! Shutdown + } + reset + } + + "terminate if forced to shutdown when idle" in + state(Idle, boxed) { + terminates(driver) { + driver ! Shutdown + } + reset + } + + "report an error if forced to shutdown when idle" in + state(Idle, boxed) { + error("Terminated driver while idle") { + driver ! Shutdown + } + reset + } + + "set a state timer when disconnecting" in + state(Disconnecting) { + driver.isStateTimerActive should be(true) + } + + "terminate if state timer goes off while disconnecting" in + state(Disconnecting) { + terminates(driver) { + driver ! FSM.StateTimeout + } + reset + } + + "warn if state timer goes off while disconnecting" in + state(Disconnecting) { + warn("Driver left hanging while disconnecting") { + driver ! FSM.StateTimeout + } + reset + } + } +} +\ No newline at end of file diff --git a/core/src/test/scala/inc/pyc/bill/acceptor/driver/MockDriver.scala b/core/src/test/scala/inc/pyc/bill/acceptor/driver/MockDriver.scala @@ -0,0 +1,23 @@ +package inc.pyc.bill +package acceptor +package driver + +import akka.actor._ +import akka.util.ByteString +import com.github.jodersky.flow._ +import com.github.jodersky.flow.Serial._ +import inc.pyc.bill.acceptor.Data +import inc.pyc.bill.acceptor.State + +class MockDriver(testActor: ActorRef, serialr: ActorRef) + extends FSM[State, DriverData] + with LoggingFSM[State, DriverData] + with Driver { + + def poll = Input(ByteString(0x05)) + + override val fsm = testActor + + override def serial = serialr + +} +\ No newline at end of file diff --git a/project/Build.scala b/project/Build.scala @@ -0,0 +1,38 @@ +import sbt._ +import sbt.Keys._ + +object ProjectBuild extends Build { + + import BuildSettings._ + + /** Aggregates tasks for all projects */ + lazy val root = Project("bill-acceptor-all", file(".")) + .settings(appSettings: _*) + .aggregate(core, apex) + + + lazy val core = module("core") + .settings( + name := "bill-acceptor-core" + ) + .settings(libraryDependencies ++= + Seq( + "com.typesafe.akka" %% "akka-actor" % "2.3.6", + "com.github.jodersky" %% "flow" % "2.0.3", + "ch.qos.logback" % "logback-classic" % "1.0.13" % "compile", + "org.scalatest" %% "scalatest" % "2.2.1" % "test", + "com.typesafe.akka" %% "akka-testkit" % "2.3.6" % "test" + ) + ) + .dependsOn(currency) + + + lazy val apex = module("apex") + .settings( + name := "bill-acceptor-apex" + ) + .dependsOn(core) + .dependsOn(core % "test->test") + + lazy val currency = ProjectRef(uri("../currency-lib"), "currency-lib") +} diff --git a/project/BuildSettings.scala b/project/BuildSettings.scala @@ -0,0 +1,54 @@ +import sbt._ +import sbt.Keys._ + +import sbtbuildinfo.Plugin._ +import com.typesafe.sbteclipse.plugin.EclipsePlugin.EclipseKeys + +object BuildSettings { + val buildTime = SettingKey[String]("build-time") + + val defaultScalaVersion = "2.10.4" + + val basicSettings = Defaults.defaultSettings ++ Seq( + name := "bill-acceptor", + version := "0.1-SNAPSHOT", + organization := "inc.pyc", + scalaVersion := defaultScalaVersion, + scalacOptions <<= scalaVersion map { sv: String => + if (sv.startsWith("2.10.")) + Seq("-deprecation", "-unchecked", "-feature", "-language:postfixOps", "-language:implicitConversions") + else + Seq("-deprecation", "-unchecked") + }, + resolvers ++= Seq[Resolver]( + "Sonatype Releases" at "https://oss.sonatype.org/content/repositories/releases" + ) + ) + + val appSettings = + basicSettings ++ + buildInfoSettings ++ + seq( + buildTime := System.currentTimeMillis.toString, + + // build-info + buildInfoKeys ++= Seq[BuildInfoKey](buildTime), + buildInfoPackage := "inc.pyc", + sourceGenerators in Compile <+= buildInfo, + + publishArtifact in Test := false, + + publish <<= publish dependsOn (test in Test), + + // eclipse + EclipseKeys.withSource := true, + + publishMavenStyle := true + ) + + + def module(name: String, settings: Seq[Def.Setting[_]] = Seq.empty) = + Project(name, file(name.replace("-", "")), settings = appSettings) + +} + diff --git a/project/plugins.sbt b/project/plugins.sbt @@ -0,0 +1,14 @@ +resolvers += Resolver.url( + "bintray-sbt-plugin-releases", + url("http://dl.bintray.com/content/sbt/sbt-plugin-releases"))( + Resolver.ivyStylePatterns) + +resolvers += "softprops-maven" at "http://dl.bintray.com/content/softprops/maven" + +resolvers += "Era7 maven releases" at "http://releases.era7.com.s3.amazonaws.com" + +addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.2.5") + +addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "2.4.0") + +addSbtPlugin("ohnosequences" % "sbt-s3-resolver" % "0.10.1") diff --git a/project/sbt-launch.jar b/project/sbt-launch.jar Binary files differ. diff --git a/sbt.sh b/sbt.sh @@ -0,0 +1,2 @@ +#!/bin/bash +java -Dfile.encoding=UTF8 -Xms512M -Xmx1536M -Xss1M -XX:+CMSClassUnloadingEnabled -XX:MaxPermSize=384M -jar `dirname $0`/project/sbt-launch.jar "$@"