diff --git a/README.md b/README.md index 67156a5..46f170b 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,17 @@ expectIncoming( ) ``` +### Setting up default handlers of certain messages + +Sometimes Central Systems will send a charge point messages that it just doesn't +see coming. Two back-offices of major Dutch charge point vendors tend to probe +charge points periodically for their configuration settings using +`GetConfigurationReq` messages. In order to keep such unanticipated incoming +messages from getting in your incoming message queue and making your tests fail, +you can catch them with `handlingIncomingMessages`. An example of its use is given +[here](examples/ocpp1x/handling-incoming-messages.scala). + + ## Running on the command line with an interactive prompt You can also go into an interactive testing session on the command line. To do that, pass the `-i` command line flag: @@ -504,9 +515,6 @@ It's far from finished now. The next steps I plan to develop: * Add a command in interactive mode to run a script from a file or URL - * Add a functionality to automatically respond to messages matching a - certain pattern in a certain way - * Messages of OCPP 2.0 that seem to be in demand: * ChangeAvailability * Reset diff --git a/build.sbt b/build.sbt index 9f79351..747d6a0 100644 --- a/build.sbt +++ b/build.sbt @@ -59,7 +59,8 @@ lazy val coreDeps = Seq( "com.thenewmotion.ocpp" %% "ocpp-j-api" % "9.2.3", "com.typesafe.scala-logging" %% "scala-logging" % "3.9.0", - "org.specs2" %% "specs2-core" % "4.3.4" % "test" + "org.scalatest" %% "scalatest" % "3.2.2" % "test", + "org.scalamock" %% "scalamock" % "5.1.0" % "test" ) def loaderDeps(scalaVersion: String) = Seq( diff --git a/cmd/src/main/scala/chargepoint/docile/test/InteractiveOcppTest.scala b/cmd/src/main/scala/chargepoint/docile/test/InteractiveOcppTest.scala index 9ef4006..fd268a4 100644 --- a/cmd/src/main/scala/chargepoint/docile/test/InteractiveOcppTest.scala +++ b/cmd/src/main/scala/chargepoint/docile/test/InteractiveOcppTest.scala @@ -2,7 +2,7 @@ package chargepoint.docile package test import chargepoint.docile.dsl._ -import com.thenewmotion.ocpp.VersionFamily +import com.thenewmotion.ocpp.{Version, Version1X, VersionFamily} import com.thenewmotion.ocpp.messages.v1x.{CentralSystemReq, CentralSystemReqRes, CentralSystemRes, ChargePointReq, ChargePointReqRes, ChargePointRes} import com.thenewmotion.ocpp.messages.v20._ import com.thenewmotion.ocpp.messages.{ReqRes, Request, Response} @@ -61,6 +61,7 @@ object InteractiveOcpp1XTest { trait V1XPromptCommands extends InteractiveOcppTest.PromptCommands[ VersionFamily.V1X.type, + Version1X, CentralSystemReq, CentralSystemRes, CentralSystemReqRes, @@ -98,6 +99,7 @@ object InteractiveOcpp20Test { trait V20PromptCommands extends InteractiveOcppTest.PromptCommands[ VersionFamily.V20.type, + Version.V20.type, CsmsRequest, CsmsResponse, CsmsReqRes, @@ -114,7 +116,7 @@ object InteractiveOcppTest { new InteractiveOcpp1XTest { - private def connDat = connectionData + private def connDat = connection implicit val csmsMessageTypes = VersionFamily.V1XCentralSystemMessages implicit val csMessageTypes = VersionFamily.V1XChargePointMessages @@ -123,7 +125,7 @@ object InteractiveOcppTest { val ops: Ocpp1XTest.V1XOps = new Ocpp1XTest.V1XOps with expectations.Ops[VersionFamily.V1X.type, CentralSystemReq, CentralSystemRes, CentralSystemReqRes, ChargePointReq, ChargePointRes, ChargePointReqRes] with shortsend.OpsV1X { - def connectionData = connDat + def connection = connDat implicit val csmsMessageTypes = VersionFamily.V1XCentralSystemMessages implicit val csMessageTypes = VersionFamily.V1XChargePointMessages @@ -131,7 +133,7 @@ object InteractiveOcppTest { } val promptCommands: InteractiveOcpp1XTest.V1XPromptCommands = new InteractiveOcpp1XTest.V1XPromptCommands { - def connectionData = connDat + def connection = connDat } }.asInstanceOf[OcppTest[vfam.type]] @@ -139,7 +141,7 @@ object InteractiveOcppTest { new InteractiveOcpp20Test { - private def connDat = connectionData + private def connDat = connection implicit val csmsMessageTypes = VersionFamily.V20CsmsMessages implicit val csMessageTypes = VersionFamily.V20CsMessages @@ -147,7 +149,7 @@ object InteractiveOcppTest { val ops: Ocpp20Test.V20Ops = new Ocpp20Test.V20Ops with expectations.Ops[VersionFamily.V20.type, CsmsRequest, CsmsResponse, CsmsReqRes, CsRequest, CsResponse, CsReqRes] { - def connectionData = connDat + def connection = connDat implicit val csmsMessageTypes = VersionFamily.V20CsmsMessages implicit val csMessageTypes = VersionFamily.V20CsMessages @@ -155,13 +157,14 @@ object InteractiveOcppTest { } val promptCommands: InteractiveOcpp20Test.V20PromptCommands = new InteractiveOcpp20Test.V20PromptCommands { - def connectionData = connDat + def connection = connDat } }.asInstanceOf[OcppTest[vfam.type]] } trait PromptCommands[ VFam <: VersionFamily, + VersionBound <: Version, OutReq <: Request, InRes <: Response, OutReqRes[_ <: OutReq, _ <: InRes] <: ReqRes[_, _], @@ -170,12 +173,12 @@ object InteractiveOcppTest { InReqRes[_ <: InReq, _ <: OutRes] <: ReqRes[_, _] ] { - protected def connectionData: OcppConnectionData[VFam, OutReq, InRes, OutReqRes, InReq, OutRes, InReqRes] + protected def connection: DocileConnection[VFam, VersionBound, OutReq, InRes, OutReqRes, InReq, OutRes, InReqRes] def q: Unit = - connectionData.receivedMsgManager.currentQueueContents foreach println + connection.receivedMsgManager.currentQueueContents foreach println def whoami: Unit = - println(connectionData.chargePointIdentity) + println(connection.chargePointIdentity) } } diff --git a/core/src/main/scala/chargepoint/docile/dsl/CoreOps.scala b/core/src/main/scala/chargepoint/docile/dsl/CoreOps.scala index 84f2645..a1c2aa2 100644 --- a/core/src/main/scala/chargepoint/docile/dsl/CoreOps.scala +++ b/core/src/main/scala/chargepoint/docile/dsl/CoreOps.scala @@ -13,7 +13,6 @@ import scala.util.{Failure, Success, Try} import com.thenewmotion.ocpp.json.api.{OcppError, OcppException} import com.thenewmotion.ocpp.messages.{ReqRes, Request, Response} import com.typesafe.scalalogging.Logger -import expectations.{IncomingMessage => GenericIncomingMessage} import org.slf4j.LoggerFactory trait CoreOps[ @@ -26,13 +25,16 @@ trait CoreOps[ InReqRes[_ <: InReq, _ <: OutRes] <: ReqRes[_, _] ] extends OpsLogging with MessageLogging { + type IncomingMessage = + GenericIncomingMessage[OutReq, InRes, OutReqRes, InReq, OutRes, InReqRes] + type IncomingMessageProcessor[+T] = + GenericIncomingMessageProcessor[OutReq, InRes, OutReqRes, InReq, OutRes, InReqRes, T] implicit val csmsMessageTypes: CsmsMessageTypesForVersionFamily[VFam, OutReq, InRes, OutReqRes] implicit val csMessageTypes: CsMessageTypesForVersionFamily[VFam, InReq, OutRes, InReqRes] implicit def executionContext: ExecutionContext - type IncomingMessage = GenericIncomingMessage[OutReq, InRes, OutReqRes, InReq, OutRes, InReqRes] object IncomingMessage { def apply(res: InRes): IncomingMessage = GenericIncomingMessage[OutReq, InRes, OutReqRes, InReq, OutRes, InReqRes](res) def apply(req: InReq, respond: OutRes => Unit): IncomingMessage = GenericIncomingMessage[OutReq, InRes, OutReqRes, InReq, OutRes, InReqRes](req, respond) @@ -42,7 +44,7 @@ trait CoreOps[ val logger = Logger(LoggerFactory.getLogger("script")) def say(m: String): Unit = logger.info(m) - protected def connectionData: OcppConnectionData[VFam, OutReq, InRes, OutReqRes, InReq, OutRes, InReqRes] + protected def connection: DocileConnection[VFam, _, OutReq, InRes, OutReqRes, InReq, OutRes, InReqRes] /** * Send an OCPP request to the Central System under test. @@ -57,32 +59,21 @@ trait CoreOps[ * @tparam Q */ def send[Q <: OutReq](req: Q)(implicit reqRes: OutReqRes[Q, _ <: InRes]): Unit = - connectionData.ocppClient match { - case None => - throw ExpectationFailed("Trying to send an OCPP message while not connected") - case Some (client) => - outgoingLogger.info(s"$req") - client.send(req)(reqRes) onComplete { - case Success(res) => - incomingLogger.info(s"$res") - connectionData.receivedMsgManager.enqueue( - IncomingMessage(res) - ) - case Failure(OcppException(ocppError)) => - incomingLogger.info(s"$ocppError") - connectionData.receivedMsgManager.enqueue( - IncomingMessage(ocppError) - ) - case Failure(e) => - opsLogger.error(s"Failed to get response to outgoing OCPP request $req: ${e.getMessage}\n\t${e.getStackTrace.mkString("\n\t")}") - throw ExecutionError(e) - } + connection.sendRequestAndManageResponse(req) + + // WIP an operation to add a default handler for a certain subset of incoming messages + def handlingIncomingMessages[T](proc: IncomingMessageProcessor[_])(f: => T): T = { + connection.pushIncomingMessageHandler(proc) + val result = f + connection.popIncomingMessageHandler() + + result } def awaitIncoming(num: Int)(implicit awaitTimeout: AwaitTimeout): Seq[IncomingMessage] = { val timeout = awaitTimeout.toDuration - def getMsgs = connectionData.receivedMsgManager.dequeue(num) + def getMsgs = connection.receivedMsgManager.dequeue(num) Try(Await.result(getMsgs, timeout)) match { case Success(msgs) => msgs @@ -97,7 +88,7 @@ trait CoreOps[ * This can be used in interactive mode to get out of a situation where you've received a bunch of messages that you * don't really care about, and you want to get on with things. */ - def flushQ(): Unit = connectionData.receivedMsgManager.flush() + def flushQ(): Unit = connection.receivedMsgManager.flush() def fail(message: String): Nothing = throw ExpectationFailed(message) diff --git a/core/src/main/scala/chargepoint/docile/dsl/DocileConnection.scala b/core/src/main/scala/chargepoint/docile/dsl/DocileConnection.scala new file mode 100644 index 0000000..e9119e7 --- /dev/null +++ b/core/src/main/scala/chargepoint/docile/dsl/DocileConnection.scala @@ -0,0 +1,192 @@ +package chargepoint.docile.dsl + +import java.net.URI + +import com.thenewmotion.ocpp.{Version, Version1X, VersionFamily} +import com.thenewmotion.ocpp.json.api.{Ocpp1XJsonClient, Ocpp20JsonClient, OcppException, OcppJsonClient, RequestHandler} +import com.thenewmotion.ocpp.messages.{ReqRes, Request, Response} +import com.thenewmotion.ocpp.messages.v1x.{CentralSystemReq, CentralSystemReqRes, CentralSystemRes, ChargePointReq, ChargePointReqRes, ChargePointRes} +import com.thenewmotion.ocpp.messages.v20.{CsReqRes, CsRequest, CsResponse, CsmsReqRes, CsmsRequest, CsmsResponse} +import com.typesafe.scalalogging.Logger +import javax.net.ssl.SSLContext +import org.slf4j.LoggerFactory + +import scala.concurrent.{Await, ExecutionContext, Future, Promise} +import scala.concurrent.duration._ +import scala.util.{Failure, Success} + +trait DocileConnection[ + VFam <: VersionFamily, + VersionBound <: Version, // shouldn't be necessary. we need some type level function from versionfamily to this + OutgoingReqBound <: Request, + IncomingResBound <: Response, + OutgoingReqRes[_ <: OutgoingReqBound, _ <: IncomingResBound] <: ReqRes[_, _], + IncomingReqBound <: Request, + OutgoingResBound <: Response, + IncomingReqRes[_ <: IncomingReqBound, _ <: OutgoingResBound] <: ReqRes[_, _] +] extends MessageLogging { + + val receivedMsgManager: ReceivedMsgManager[OutgoingReqBound, IncomingResBound, OutgoingReqRes, IncomingReqBound, OutgoingResBound, IncomingReqRes] = + new ReceivedMsgManager[OutgoingReqBound, IncomingResBound, OutgoingReqRes, IncomingReqBound, OutgoingResBound, IncomingReqRes]() + + var incomingMessageHandlerStack: List[GenericIncomingMessageProcessor[ + OutgoingReqBound, IncomingResBound, OutgoingReqRes, IncomingReqBound, OutgoingResBound, IncomingReqRes, _ + ]] = List() + + var ocppClient: Option[OcppJsonClient[ + VFam, + OutgoingReqBound, + IncomingResBound, + OutgoingReqRes, + IncomingReqBound, + OutgoingResBound, + IncomingReqRes + ]] = None + + // TODO handle this more gently. The identity should be known after the script is started, regardless of whether the connection was established or not. + // that also has to do with being able to renew connections or disconnect and reconnect while executing a script + def chargePointIdentity: String = connectedCpId.getOrElse(sys.error("Asked for charge point ID on not yet connected DocileConnection")) + + var connectedCpId: Option[String] = None + + private val connectionLogger = Logger(LoggerFactory.getLogger("connection")) + + final def pushIncomingMessageHandler(handler: GenericIncomingMessageProcessor[OutgoingReqBound, IncomingResBound, OutgoingReqRes, IncomingReqBound, OutgoingResBound, IncomingReqRes, _]): Unit = { + incomingMessageHandlerStack = handler :: incomingMessageHandlerStack + } + + final def popIncomingMessageHandler(): Unit = { + incomingMessageHandlerStack = incomingMessageHandlerStack.tail + } + + def connect(chargePointId: String, + endpoint: URI, + version: VersionBound, + authKey: Option[String] + )(implicit executionContext: ExecutionContext, sslContext: SSLContext): Unit = { + + connectedCpId = Some(chargePointId) + + connectionLogger.info(s"Connecting to OCPP v${version.name} endpoint $endpoint") + + val connection = createClient(chargePointId, endpoint, version, authKey)(executionContext, sslContext) { + new RequestHandler[IncomingReqBound, OutgoingResBound, IncomingReqRes] { + def apply[REQ <: IncomingReqBound, RES <: OutgoingResBound](req: REQ)(implicit reqRes: IncomingReqRes[REQ, RES], ec: ExecutionContext): Future[RES] = { + + incomingLogger.info(s"$req") + + val responsePromise = Promise[OutgoingResBound]() + + def respond(res: OutgoingResBound): Unit = { + outgoingLogger.info(s"$res") + responsePromise.success(res) + () + } + + val incomingMsg = GenericIncomingMessage[OutgoingReqBound, IncomingResBound, OutgoingReqRes, IncomingReqBound, OutgoingResBound, IncomingReqRes](req, respond _) + val incomingMsgHandler = incomingMessageHandlerStack.find(_.accepts(incomingMsg)) + + incomingMsgHandler.map(_.fireSideEffects(incomingMsg)).getOrElse(receivedMsgManager.enqueue(incomingMsg)) + + // TODO nicer conversion? + responsePromise.future.map(_.asInstanceOf[RES])(ec) + } + } + } + + connection.onClose.foreach { _ => + connectionLogger.info(s"Gracefully disconnected from endpoint $endpoint") + ocppClient = None + }(executionContext) + + ocppClient = Some(connection) + } + + def sendRequestAndManageResponse[REQ <: OutgoingReqBound](req: REQ)( + implicit reqRes: OutgoingReqRes[REQ, _ <: IncomingResBound], + executionContext: ExecutionContext + ): Unit = + ocppClient match { + case None => + throw ExpectationFailed("Trying to send an OCPP message while not connected") + case Some(client) => + outgoingLogger.info(s"$req") + client.send(req)(reqRes) onComplete { + case Success(res) => + incomingLogger.info(s"$res") + receivedMsgManager.enqueue( + GenericIncomingMessage[OutgoingReqBound, IncomingResBound, OutgoingReqRes, IncomingReqBound, OutgoingResBound, IncomingReqRes](res) + ) + case Failure(OcppException(ocppError)) => + incomingLogger.info(s"$ocppError") + receivedMsgManager.enqueue( + GenericIncomingMessage[OutgoingReqBound, IncomingResBound, OutgoingReqRes, IncomingReqBound, OutgoingResBound, IncomingReqRes](ocppError) + ) + case Failure(e) => + connectionLogger.error(s"Failed to get response to outgoing OCPP request $req: ${e.getMessage}\n\t${e.getStackTrace.mkString("\n\t")}") + throw ExecutionError(e) + } + } + + /** Template method to be implemented by version-specific extending classes to establish a connection for that + * version of OCPP */ + protected def createClient( + chargePointId: String, + endpoint: URI, + version: VersionBound, + authKey: Option[String] + )(implicit executionContext: ExecutionContext, sslContext: SSLContext): RequestHandler[IncomingReqBound, OutgoingResBound, IncomingReqRes] => OcppJsonClient[ + VFam, + OutgoingReqBound, + IncomingResBound, + OutgoingReqRes, + IncomingReqBound, + OutgoingResBound, + IncomingReqRes + ] + + + def disconnect(): Unit = ocppClient.foreach { conn => + Await.result(conn.close(), 45.seconds) + } +} + +object DocileConnection { + def forVersion1x(): DocileConnection[ + VersionFamily.V1X.type, + Version1X, + CentralSystemReq, + CentralSystemRes, + CentralSystemReqRes, + ChargePointReq, + ChargePointRes, + ChargePointReqRes + ] = { + new DocileConnection[VersionFamily.V1X.type, Version1X, CentralSystemReq, CentralSystemRes, CentralSystemReqRes, ChargePointReq, ChargePointRes, ChargePointReqRes] { + type VersionBound = Version1X + + override protected def createClient(chargePointId: String, endpoint: URI, version: Version1X, authKey: Option[String])(implicit executionContext: ExecutionContext, sslContext: SSLContext): RequestHandler[ChargePointReq, ChargePointRes, ChargePointReqRes] => Ocpp1XJsonClient = { reqHandler => + OcppJsonClient.forVersion1x(chargePointId, endpoint, List(version), authKey)(reqHandler)(executionContext, sslContext) + } + } + } + + def forVersion20(): DocileConnection[ + VersionFamily.V20.type, + Version.V20.type, + CsmsRequest, + CsmsResponse, + CsmsReqRes, + CsRequest, + CsResponse, + CsReqRes + ] = { + new DocileConnection[VersionFamily.V20.type, Version.V20.type, CsmsRequest, CsmsResponse, CsmsReqRes, CsRequest, CsResponse, CsReqRes] { + type VersionBound = Version.V20.type + + override protected def createClient(chargePointId: String, endpoint: URI, version: Version.V20.type, authKey: Option[String])(implicit executionContext: ExecutionContext, sslContext: SSLContext): RequestHandler[CsRequest, CsResponse, CsReqRes] => Ocpp20JsonClient = { reqHandler => + OcppJsonClient.forVersion20(chargePointId, endpoint, authKey)(reqHandler)(executionContext, sslContext) + } + } + } +} diff --git a/core/src/main/scala/chargepoint/docile/dsl/expectations/IncomingMessage.scala b/core/src/main/scala/chargepoint/docile/dsl/GenericIncomingMessage.scala similarity index 93% rename from core/src/main/scala/chargepoint/docile/dsl/expectations/IncomingMessage.scala rename to core/src/main/scala/chargepoint/docile/dsl/GenericIncomingMessage.scala index 0e51a42..50b8eb4 100644 --- a/core/src/main/scala/chargepoint/docile/dsl/expectations/IncomingMessage.scala +++ b/core/src/main/scala/chargepoint/docile/dsl/GenericIncomingMessage.scala @@ -1,12 +1,11 @@ -package chargepoint.docile -package dsl -package expectations +package chargepoint.docile.dsl -import scala.language.higherKinds -import com.thenewmotion.ocpp.messages.{ReqRes, Request, Response} import com.thenewmotion.ocpp.json.api.OcppError +import com.thenewmotion.ocpp.messages.{ReqRes, Request, Response} + +import scala.language.higherKinds -sealed trait IncomingMessage[ +sealed trait GenericIncomingMessage[ OutgoingReqBound <: Request, IncomingResBound <: Response, OutgoingReqRes[_ <: OutgoingReqBound, _ <: IncomingResBound] <: ReqRes[_, _], @@ -15,7 +14,7 @@ sealed trait IncomingMessage[ IncomingReqRes[_ <: IncomingReqBound, _ <: OutgoingResBound] <: ReqRes[_, _] ] -object IncomingMessage { +object GenericIncomingMessage { def apply[ OutReq <: Request, InRes <: Response, @@ -23,8 +22,7 @@ object IncomingMessage { InReq <: Request, OutRes <: Response, InReqRes[_ <: InReq, _ <: OutRes] <: ReqRes[_, _] - ](res: InRes)( - ): IncomingResponse[ + ](res: InRes): IncomingResponse[ OutReq, InRes, OutReqRes, @@ -90,7 +88,7 @@ case class IncomingResponse[ InReqRes[_ <: InReq, _ <: OutRes] <: ReqRes[_, _] ]( res: InRes -) extends IncomingMessage[ +) extends GenericIncomingMessage[ OutReq, InRes, OutReqRes, @@ -109,7 +107,7 @@ case class IncomingRequest[ ]( req: InReq, respond: OutRes => Unit -) extends IncomingMessage[ +) extends GenericIncomingMessage[ OutReq, InRes, OutReqRes, @@ -125,7 +123,7 @@ case class IncomingError[ InReq <: Request, OutRes <: Response, InReqRes[_ <: InReq, _ <: OutRes] <: ReqRes[_, _] -](error: OcppError) extends IncomingMessage[ +](error: OcppError) extends GenericIncomingMessage[ OutReq, InRes, OutReqRes, diff --git a/core/src/main/scala/chargepoint/docile/dsl/GenericIncomingMessageProcessor.scala b/core/src/main/scala/chargepoint/docile/dsl/GenericIncomingMessageProcessor.scala new file mode 100644 index 0000000..91a7fb4 --- /dev/null +++ b/core/src/main/scala/chargepoint/docile/dsl/GenericIncomingMessageProcessor.scala @@ -0,0 +1,54 @@ +package chargepoint.docile.dsl + +import com.thenewmotion.ocpp.messages.{ReqRes, Request, Response} + +import scala.language.higherKinds + +/** + * A partial function with side effects working on incoming OCPP messages + */ +trait GenericIncomingMessageProcessor[ + OutgoingReqBound <: Request, + IncomingResBound <: Response, + OutgoingReqRes[_ <: OutgoingReqBound, _ <: IncomingResBound] <: ReqRes[_, _], + IncomingReqBound <: Request, + OutgoingResBound <: Response, + IncomingReqRes[_ <: IncomingReqBound, _ <: OutgoingResBound] <: ReqRes[_, _], + +T +] { + type IncomingMessage = GenericIncomingMessage[ + OutgoingReqBound, + IncomingResBound, + OutgoingReqRes, + IncomingReqBound, + OutgoingResBound, + IncomingReqRes + ] + + /** Whether this processor can do something with a certain incoming message */ + def accepts(msg: IncomingMessage): Boolean + + /** + * The outcome of applying this processor to the given incoming message. + * + * Applying the processor do a message outside of its domain should throw + * a MatchError. + */ + def result(msg: IncomingMessage): T + + /** + * Execute the side effects of this processor. + * + * In an OCPP test, this is supposed to happen when an assertion expecting a + * certain incoming message has received an incoming message that matches + * the expectation. + */ + def fireSideEffects(msg: IncomingMessage): Unit + + def lift(msg: IncomingMessage): Option[T] = + if (accepts(msg)) + Some(result(msg)) + else + None +} + diff --git a/core/src/main/scala/chargepoint/docile/dsl/OcppTest.scala b/core/src/main/scala/chargepoint/docile/dsl/OcppTest.scala index ecf49aa..13ad4d4 100644 --- a/core/src/main/scala/chargepoint/docile/dsl/OcppTest.scala +++ b/core/src/main/scala/chargepoint/docile/dsl/OcppTest.scala @@ -7,19 +7,13 @@ import com.thenewmotion.ocpp.VersionFamily.{CsMessageTypesForVersionFamily, Csms import javax.net.ssl.SSLContext import scala.language.higherKinds -import scala.concurrent.{Await, ExecutionContext, Future, Promise} -import scala.concurrent.duration.DurationInt +import scala.concurrent.ExecutionContext import com.thenewmotion.ocpp.{Version, Version1X, VersionFamily} -import com.thenewmotion.ocpp.json.api._ import com.thenewmotion.ocpp.messages.{ReqRes, Request, Response} import com.thenewmotion.ocpp.messages.v1x.{CentralSystemReq, CentralSystemReqRes, CentralSystemRes, ChargePointReq, ChargePointReqRes, ChargePointRes} -import com.thenewmotion.ocpp.messages.v20._ -import com.typesafe.scalalogging.Logger -import org.slf4j.LoggerFactory -import expectations.IncomingMessage +import com.thenewmotion.ocpp.messages.v20.{CsmsRequest, CsmsResponse, CsmsReqRes, CsRequest, CsResponse, CsReqRes} -trait OcppTest[VFam <: VersionFamily] extends MessageLogging { - private val connectionLogger = Logger(LoggerFactory.getLogger("connection")) +trait OcppTest[VFam <: VersionFamily] { // these type variables are filled in differently for OCPP 1.X and OCPP 2.0 type OutgoingReqBound <: Request @@ -32,17 +26,18 @@ trait OcppTest[VFam <: VersionFamily] extends MessageLogging { implicit val csMessageTypes: CsMessageTypesForVersionFamily[VFam, IncomingReqBound, OutgoingResBound, IncomingReqRes] implicit val csmsMessageTypes: CsmsMessageTypesForVersionFamily[VFam, OutgoingReqBound, IncomingResBound, OutgoingReqRes] + val executionContext: ExecutionContext /** * The current OCPP with some associated data * - * This is a var instead of a val an immutable because I hope this will allow - * us to write tests that disconnect and reconnect when we have a more - * complete test DSL. + * This member is effectively the interface between CoreOps and the mutable + * data required to implement the core operations defined in CoreOps */ - protected var connectionData: OcppConnectionData[ + protected val connection: DocileConnection[ VFam, + VersionBound, OutgoingReqBound, IncomingResBound, OutgoingReqRes, @@ -58,88 +53,11 @@ trait OcppTest[VFam <: VersionFamily] extends MessageLogging { authKey: Option[String], defaultAwaitTimeout: AwaitTimeout )(implicit sslContext: SSLContext): Unit = { - val receivedMsgManager = new ReceivedMsgManager[ - OutgoingReqBound, - IncomingResBound, - OutgoingReqRes, - IncomingReqBound, - OutgoingResBound, - IncomingReqRes - ]() - - connect(receivedMsgManager, chargePointId, endpoint, version, authKey) + connection.connect(chargePointId, endpoint, version, authKey)(executionContext, sslContext) run(defaultAwaitTimeout) - disconnect() + connection.disconnect() } - private def connect( - receivedMsgManager: ReceivedMsgManager[OutgoingReqBound, IncomingResBound, OutgoingReqRes, IncomingReqBound, OutgoingResBound, IncomingReqRes], - chargePointId: String, - endpoint: URI, - version: VersionBound, - authKey: Option[String] - )(implicit sslContext: SSLContext): Unit = { - - connectionLogger.info(s"Connecting to OCPP v${version.name} endpoint $endpoint") - - val connection = connect(chargePointId, endpoint, version, authKey)(sslContext) { - new RequestHandler[IncomingReqBound, OutgoingResBound, IncomingReqRes] { - def apply[REQ <: IncomingReqBound, RES <: OutgoingResBound](req: REQ)(implicit reqRes: IncomingReqRes[REQ, RES], ec: ExecutionContext): Future[RES] = { - - incomingLogger.info(s"$req") - - val responsePromise = Promise[OutgoingResBound]() - - def respond(res: OutgoingResBound): Unit = { - outgoingLogger.info(s"$res") - responsePromise.success(res) - () - } - - receivedMsgManager.enqueue( - IncomingMessage[OutgoingReqBound, IncomingResBound, OutgoingReqRes, IncomingReqBound, OutgoingResBound, IncomingReqRes](req, respond _) - ) - - // TODO nicer conversion? - responsePromise.future.map(_.asInstanceOf[RES]) - } - } - } - - connection.onClose.foreach { _ => - connectionLogger.info(s"Gracefully disconnected from endpoint $endpoint") - connectionData = connectionData.copy[ - VFam, - OutgoingReqBound, - IncomingResBound, - OutgoingReqRes, - IncomingReqBound, - OutgoingResBound, - IncomingReqRes - ](ocppClient = None) - }(executionContext) - - connectionData = OcppConnectionData[VFam, OutgoingReqBound, IncomingResBound, OutgoingReqRes, IncomingReqBound, OutgoingResBound, IncomingReqRes](Some(connection), receivedMsgManager, chargePointId) - } - - protected def connect( - chargePointId: String, - endpoint: URI, - version: VersionBound, - authKey: Option[String] - )(implicit sslContext: SSLContext): RequestHandler[IncomingReqBound, OutgoingResBound, IncomingReqRes] => OcppJsonClient[ - VFam, - OutgoingReqBound, - IncomingResBound, - OutgoingReqRes, - IncomingReqBound, - OutgoingResBound, - IncomingReqRes - ] - - private def disconnect(): Unit = connectionData.ocppClient.foreach { conn => - Await.result(conn.close(), 45.seconds) - } protected def run(defaultAwaitTimeout: AwaitTimeout): Unit } @@ -154,24 +72,17 @@ trait Ocpp1XTest extends OcppTest[VersionFamily.V1X.type] { type IncomingReqRes[Q <: ChargePointReq, S <: ChargePointRes] = ChargePointReqRes[Q, S] type VersionBound = Version1X - override protected var connectionData: OcppConnectionData[ + override protected val connection: DocileConnection[ VersionFamily.V1X.type, + Version1X, CentralSystemReq, CentralSystemRes, CentralSystemReqRes, ChargePointReq, ChargePointRes, ChargePointReqRes - ] = _ + ] = DocileConnection.forVersion1x() - protected override def connect( - chargePointId: String, - endpoint: URI, - version: Version1X, - authKey: Option[String] - )(implicit sslCtx: SSLContext): ChargePointRequestHandler => Ocpp1XJsonClient = { reqHandler => - OcppJsonClient.forVersion1x(chargePointId, endpoint, List(version), authKey)(reqHandler)(executionContext, sslCtx) - } } object Ocpp1XTest { @@ -205,24 +116,16 @@ trait Ocpp20Test extends OcppTest[VersionFamily.V20.type] { override type IncomingReqRes[Q <: CsRequest, S <: CsResponse] = CsReqRes[Q, S] override type VersionBound = V20.type - override protected var connectionData: OcppConnectionData[ + override protected val connection: DocileConnection[ VersionFamily.V20.type, + Version.V20.type, CsmsRequest, CsmsResponse, CsmsReqRes, CsRequest, CsResponse, CsReqRes - ] = _ - - protected override def connect( - chargePointId: String, - endpoint: URI, - version: V20.type, - authKey: Option[String] - )(implicit sslCtx: SSLContext): CsRequestHandler => Ocpp20JsonClient = { reqHandler => - OcppJsonClient.forVersion20(chargePointId, endpoint, authKey)(reqHandler)(executionContext, sslCtx) - } + ] = DocileConnection.forVersion20() } object Ocpp20Test { @@ -247,26 +150,3 @@ object Ocpp20Test { with ocpp20transactions.Ops } -case class OcppConnectionData[ - VFam <: VersionFamily, - OutgoingReqBound <: Request, - IncomingResBound <: Response, - OutgoingReqRes[_ <: OutgoingReqBound, _ <: IncomingResBound] <: ReqRes[_, _], - IncomingReqBound <: Request, - OutgoingResBound <: Response, - IncomingReqRes[_ <: IncomingReqBound, _ <: OutgoingResBound] <: ReqRes[_, _] -]( - ocppClient: - Option[OcppJsonClient[ - VFam, - OutgoingReqBound, - IncomingResBound, - OutgoingReqRes, - IncomingReqBound, - OutgoingResBound, - IncomingReqRes - ] - ], - receivedMsgManager: ReceivedMsgManager[OutgoingReqBound, IncomingResBound, OutgoingReqRes, IncomingReqBound, OutgoingResBound, IncomingReqRes], - chargePointIdentity: String -) diff --git a/core/src/main/scala/chargepoint/docile/dsl/ReceivedMsgManager.scala b/core/src/main/scala/chargepoint/docile/dsl/ReceivedMsgManager.scala index 2686fbe..049b5e3 100644 --- a/core/src/main/scala/chargepoint/docile/dsl/ReceivedMsgManager.scala +++ b/core/src/main/scala/chargepoint/docile/dsl/ReceivedMsgManager.scala @@ -1,8 +1,9 @@ package chargepoint.docile.dsl +import chargepoint.docile.dsl + import scala.language.higherKinds import scala.concurrent.{Future, Promise} -import chargepoint.docile.dsl.expectations.{IncomingMessage => GenericIncomingMessage} import com.thenewmotion.ocpp.messages.{ReqRes, Request, Response} import com.typesafe.scalalogging.StrictLogging @@ -19,7 +20,7 @@ class ReceivedMsgManager[ import ReceivedMsgManager._ - type IncomingMessage = GenericIncomingMessage[OutReq, InRes, OutReqRes, InReq, OutRes, InReqRes] + type IncomingMessage = dsl.GenericIncomingMessage[OutReq, InRes, OutReqRes, InReq, OutRes, InReqRes] private val messages = mutable.Queue[IncomingMessage]() diff --git a/core/src/main/scala/chargepoint/docile/dsl/expectations/Ops.scala b/core/src/main/scala/chargepoint/docile/dsl/expectations/Ops.scala index d393894..f2bdaee 100644 --- a/core/src/main/scala/chargepoint/docile/dsl/expectations/Ops.scala +++ b/core/src/main/scala/chargepoint/docile/dsl/expectations/Ops.scala @@ -21,36 +21,7 @@ trait Ops[ ] { self: CoreOps[VFam, OutReq, InRes, OutReqRes, InReq, OutRes, InReqRes] => - /** An IncomingMessageProcessor[T] is like a PartialFunction[T] with side effects */ - trait IncomingMessageProcessor[+T] { - /** Whether this processor can do something with a certain incoming message */ - // TODO add type alias in CoreOps to get rid of this VFam parameterization everywhere? - def accepts(msg: IncomingMessage): Boolean - - /** - * The outcome of applying this processor to the given incoming message. - * - * Applying the processor do a message outside of its domain should throw - * a MatchError. - */ - def result(msg: IncomingMessage): T - - /** - * Execute the side effects of this processor. - * - * In an OCPP test, this is supposed to happen when an assertion expecting a - * certain incoming message has received an inomcing message that matches - * the expectation. - */ - def fireSideEffects(msg: IncomingMessage): Unit - - def lift(msg: IncomingMessage): Option[T] = - if (accepts(msg)) - Some(result(msg)) - else - None - } - + // TODO document why we need this subset of IncomingMessageProcessor sealed trait IncomingRequestProcessor[+T] extends IncomingMessageProcessor[T] def expectIncoming[T](proc: IncomingMessageProcessor[T])(implicit awaitTimeout: AwaitTimeout): T = { @@ -159,13 +130,13 @@ trait Ops[ error restrictedBy { case e@OcppError(`code`, _) => e } - def getConfigurationReq = requestMatching { case r: GetConfigurationReq => r } + def getConfigurationReq: IncomingRequestProcessor[GetConfigurationReq] = requestMatching { case r: GetConfigurationReq => r } def changeConfigurationReq = requestMatching { case r: ChangeConfigurationReq => r } def getDiagnosticsReq = requestMatching { case r: GetDiagnosticsReq => r } def changeAvailabilityReq = requestMatching { case r: ChangeAvailabilityReq => r } - def getLocalListVersionReq = requestMatching { case r if r == GetLocalListVersionReq => r } + def getLocalListVersionReq = requestMatching { case r: GetLocalListVersionReq.type => r } def sendLocalListReq = requestMatching { case r: SendLocalListReq => r } - def clearCacheReq = requestMatching { case r if r == ClearCacheReq => r } + def clearCacheReq: IncomingRequestProcessor[ClearCacheReq.type] = requestMatching { case r: ClearCacheReq.type => r } def resetReq = requestMatching { case r: ResetReq => r } def updateFirmwareReq = requestMatching { case r: UpdateFirmwareReq => r } def remoteStartTransactionReq = requestMatching { case r: RemoteStartTransactionReq => r } diff --git a/core/src/main/scala/chargepoint/docile/dsl/shortsend/OpsV1X.scala b/core/src/main/scala/chargepoint/docile/dsl/shortsend/OpsV1X.scala index ddbc5e3..2227532 100644 --- a/core/src/main/scala/chargepoint/docile/dsl/shortsend/OpsV1X.scala +++ b/core/src/main/scala/chargepoint/docile/dsl/shortsend/OpsV1X.scala @@ -40,8 +40,8 @@ trait OpsV1X { def bootNotification( chargePointVendor: String = "The New Motion BV", chargePointModel: String = "Test Basic", - chargePointSerialNumber: Option[String] = Some(connectionData.chargePointIdentity), - chargeBoxSerialNumber: Option[String] = Some(connectionData.chargePointIdentity), + chargePointSerialNumber: Option[String] = Some(connection.chargePointIdentity), + chargeBoxSerialNumber: Option[String] = Some(connection.chargePointIdentity), firmwareVersion: Option[String] = Some("1.0.0"), iccid: Option[String] = None, imsi: Option[String] = None, diff --git a/core/src/main/scala/chargepoint/docile/dsl/shortsend/OpsV20.scala b/core/src/main/scala/chargepoint/docile/dsl/shortsend/OpsV20.scala index 5ae735c..8b821c6 100644 --- a/core/src/main/scala/chargepoint/docile/dsl/shortsend/OpsV20.scala +++ b/core/src/main/scala/chargepoint/docile/dsl/shortsend/OpsV20.scala @@ -43,7 +43,7 @@ trait OpsV20 { def bootNotification( chargingStation: ChargingStation = ChargingStation( - serialNumber = Some(connectionData.chargePointIdentity), + serialNumber = Some(connection.chargePointIdentity), model = "Docile", vendorName = "The New Motion BV", firmwareVersion = Some("1.0.0"), diff --git a/core/src/test/scala/chargepoint/docile/dsl/ReceivedMsgManagerSpec.scala b/core/src/test/scala/chargepoint/docile/dsl/ReceivedMsgManagerSpec.scala index b599f74..9c75f67 100644 --- a/core/src/test/scala/chargepoint/docile/dsl/ReceivedMsgManagerSpec.scala +++ b/core/src/test/scala/chargepoint/docile/dsl/ReceivedMsgManagerSpec.scala @@ -1,101 +1,90 @@ package chargepoint.docile.dsl -import chargepoint.docile.dsl.expectations.IncomingMessage import com.thenewmotion.ocpp.messages.v1x._ -import org.specs2.mutable.Specification -import org.specs2.specification.Scope -import org.specs2.concurrent.ExecutionEnv - -class ReceivedMsgManagerSpec(implicit ee: ExecutionEnv) extends Specification { - - "ReceivedMsgManager" should { - - "pass on messages to those that requested them" in { - val sut = new ReceivedMsgManager[ - CentralSystemReq, - CentralSystemRes, - CentralSystemReqRes, - ChargePointReq, - ChargePointRes, - ChargePointReqRes - ] - val testMsg = IncomingMessage[ - CentralSystemReq, - CentralSystemRes, - CentralSystemReqRes, - ChargePointReq, - ChargePointRes, - ChargePointReqRes - ](StatusNotificationRes) - - val f = sut.dequeue(1) - - f.isCompleted must beFalse - - sut.enqueue(testMsg) - - f must beEqualTo(List(testMsg)).await - } +import org.scalatest.flatspec.AnyFlatSpec +import scala.concurrent.ExecutionContext.Implicits.global - "remember incoming messages until someone dequeues them" in { - val sut = new ReceivedMsgManager[ - CentralSystemReq, - CentralSystemRes, - CentralSystemReqRes, - ChargePointReq, - ChargePointRes, - ChargePointReqRes - ] - val testMsg = IncomingMessage[ - CentralSystemReq, - CentralSystemRes, - CentralSystemReqRes, - ChargePointReq, - ChargePointRes, - ChargePointReqRes - ](StatusNotificationRes) - - sut.enqueue(testMsg) - - sut.dequeue(1) must beEqualTo(List(testMsg)).await - } +class ReceivedMsgManagerSpec extends AnyFlatSpec { - "fulfill request for messages once enough are available" in new TestScope { - sut.enqueue(testMsg(1)) - sut.enqueue(testMsg(2)) + "ReceivedMsgManager" should "pass on messages to those that requested them" in { + val sut = receivedMsgManager() + val testMsg = GenericIncomingMessage[ + CentralSystemReq, + CentralSystemRes, + CentralSystemReqRes, + ChargePointReq, + ChargePointRes, + ChargePointReqRes + ](StatusNotificationRes) - val f = sut.dequeue(3) + val f = sut.dequeue(1) - f.isCompleted must beFalse + assert(!f.isCompleted) - sut.enqueue(testMsg(3)) + sut.enqueue(testMsg) - f must beEqualTo(List(testMsg(1), testMsg(2), testMsg(3))).await + f.map { dequeuedMsg => + assert(dequeuedMsg === List(testMsg)) } + } + + it should "remember incoming messages until someone dequeues them" in { + val sut = receivedMsgManager() + val testMsg = GenericIncomingMessage[ + CentralSystemReq, + CentralSystemRes, + CentralSystemReqRes, + ChargePointReq, + ChargePointRes, + ChargePointReqRes + ](StatusNotificationRes) - "allow a peek at what's in the queue" in new TestScope { - sut.enqueue(testMsg(1)) - sut.enqueue(testMsg(2)) + sut.enqueue(testMsg) - sut.currentQueueContents mustEqual List(testMsg(1), testMsg(2)) + sut.dequeue(1) map { dequeuedMsgs => + assert(dequeuedMsgs === List(testMsg)) } + } + + it should "fulfill request for messages once enough are available" in { + val sut = receivedMsgManager() + sut.enqueue(testMsg(1)) + sut.enqueue(testMsg(2)) - "flush the queue" in new TestScope { - sut.enqueue(testMsg(1)) - sut.enqueue(testMsg(2)) + val f = sut.dequeue(3) - sut.currentQueueContents mustEqual List(testMsg(1), testMsg(2)) + assert(!f.isCompleted) - sut.flush() + sut.enqueue(testMsg(3)) - sut.currentQueueContents must beEmpty + f map { dequeuedMsgs => + assert(dequeuedMsgs === List(testMsg(1), testMsg(2), testMsg(3))) } } - private trait TestScope extends Scope { - val testIdTagInfo = IdTagInfo(status = AuthorizationStatus.Accepted) + it should "allow a peek at what's in the queue" in { + val sut = receivedMsgManager() + sut.enqueue(testMsg(1)) + sut.enqueue(testMsg(2)) + + assert(sut.currentQueueContents === List(testMsg(1), testMsg(2))) + } + + it should "flush the queue" in { + val sut = receivedMsgManager() + sut.enqueue(testMsg(1)) + sut.enqueue(testMsg(2)) - val sut = new ReceivedMsgManager[ + assert(sut.currentQueueContents === List(testMsg(1), testMsg(2))) + + sut.flush() + + assert(sut.currentQueueContents.isEmpty) + } + + private val testIdTagInfo = IdTagInfo(status = AuthorizationStatus.Accepted) + + private def receivedMsgManager() = new ReceivedMsgManager[ CentralSystemReq, CentralSystemRes, CentralSystemReqRes, @@ -104,7 +93,7 @@ class ReceivedMsgManagerSpec(implicit ee: ExecutionEnv) extends Specification { ChargePointReqRes ] - def testMsg(seqNo: Int) = IncomingMessage[ + private def testMsg(seqNo: Int) = GenericIncomingMessage[ CentralSystemReq, CentralSystemRes, CentralSystemReqRes, @@ -115,6 +104,4 @@ class ReceivedMsgManagerSpec(implicit ee: ExecutionEnv) extends Specification { transactionId = seqNo, idTag = testIdTagInfo )) - - } } diff --git a/core/src/test/scala/chargepoint/docile/dsl/expectations/OpsSpec.scala b/core/src/test/scala/chargepoint/docile/dsl/expectations/OpsSpec.scala index ae2008e..1f780c4 100644 --- a/core/src/test/scala/chargepoint/docile/dsl/expectations/OpsSpec.scala +++ b/core/src/test/scala/chargepoint/docile/dsl/expectations/OpsSpec.scala @@ -3,41 +3,38 @@ package chargepoint.docile.dsl.expectations import scala.collection.JavaConverters._ import scala.concurrent.TimeoutException import scala.concurrent.ExecutionContext.global -import chargepoint.docile.dsl.{AwaitTimeout, AwaitTimeoutInMillis, CoreOps, OcppConnectionData} +import chargepoint.docile.dsl.{AwaitTimeout, AwaitTimeoutInMillis, CoreOps, DocileConnection, GenericIncomingMessage, IncomingError, IncomingRequest, IncomingResponse} +import com.thenewmotion.ocpp.Version1X import com.thenewmotion.ocpp.VersionFamily.{V1X, V1XCentralSystemMessages, V1XChargePointMessages} import com.thenewmotion.ocpp.json.api.OcppError import com.thenewmotion.ocpp.messages.v1x._ -import org.specs2.mutable.Specification +import org.scalatest.flatspec.AnyFlatSpec -class OpsSpec extends Specification { +class OpsSpec extends AnyFlatSpec { - "Ops" should { - "await for messages ignoring not matched" in { - val mock = new MutableOpsMock() + "Ops" should "await for messages ignoring not matched" in { + val mock = new MutableOpsMock() - import mock.ops._ + import mock.ops._ - implicit val awaitTimeout: AwaitTimeout = AwaitTimeoutInMillis(5000) + implicit val awaitTimeout: AwaitTimeout = AwaitTimeoutInMillis(5000) - mock send GetConfigurationReq(keys = List()) - mock send ClearCacheReq + mock send GetConfigurationReq(keys = List()) + mock send ClearCacheReq - val result: Seq[ChargePointReq] = + val result: Seq[ChargePointReq] = expectAllIgnoringUnmatched( + clearCacheReq respondingWith ClearCacheRes(accepted = true) + ) - expectAllIgnoringUnmatched( - clearCacheReq respondingWith ClearCacheRes(accepted = true) - ) - - result must_=== Seq(ClearCacheReq) - mock.responses.size must_=== 1 - mock.responses.head must_=== ClearCacheRes(accepted = true) - } + assert(result === Seq(ClearCacheReq)) + assert(mock.responses.size === 1) + assert(mock.responses.head === ClearCacheRes(accepted = true)) } class MutableOpsMock { import java.util.concurrent.{ArrayBlockingQueue, BlockingQueue, TimeUnit} - private val requestsQueue: BlockingQueue[IncomingMessage[CentralSystemReq, CentralSystemRes, CentralSystemReqRes, ChargePointReq, ChargePointRes, ChargePointReqRes]] = new ArrayBlockingQueue[IncomingMessage[CentralSystemReq, CentralSystemRes, CentralSystemReqRes, ChargePointReq, ChargePointRes, ChargePointReqRes]](1000) + private val requestsQueue: BlockingQueue[GenericIncomingMessage[CentralSystemReq, CentralSystemRes, CentralSystemReqRes, ChargePointReq, ChargePointRes, ChargePointReqRes]] = new ArrayBlockingQueue[GenericIncomingMessage[CentralSystemReq, CentralSystemRes, CentralSystemReqRes, ChargePointReq, ChargePointRes, ChargePointReqRes]](1000) private val responsesQueue: BlockingQueue[ChargePointRes] = new ArrayBlockingQueue[ChargePointRes](1000) def responses: Iterable[ChargePointRes] = responsesQueue.asScala @@ -79,22 +76,15 @@ class OpsSpec extends Specification { ](err)) } - val ops: Ops[ - V1X.type, - CentralSystemReq, - CentralSystemRes, - CentralSystemReqRes, - ChargePointReq, - ChargePointRes, - ChargePointReqRes - ] = new Ops[V1X.type, CentralSystemReq, CentralSystemRes, CentralSystemReqRes, ChargePointReq, ChargePointRes, ChargePointReqRes] + val ops = new Ops[V1X.type, CentralSystemReq, CentralSystemRes, CentralSystemReqRes, ChargePointReq, ChargePointRes, ChargePointReqRes] with CoreOps[V1X.type, CentralSystemReq, CentralSystemRes, CentralSystemReqRes, ChargePointReq, ChargePointRes, ChargePointReqRes] { implicit val csMessageTypes = V1XChargePointMessages implicit val csmsMessageTypes = V1XCentralSystemMessages implicit val executionContext = global - override protected def connectionData: OcppConnectionData[ + override protected def connection: DocileConnection[ V1X.type, + Version1X, CentralSystemReq, CentralSystemRes, CentralSystemReqRes, diff --git a/core/src/test/scala/chargepoint/docile/dsl/ocpp20transactions/OpsSpec.scala b/core/src/test/scala/chargepoint/docile/dsl/ocpp20transactions/OpsSpec.scala index 6fc8e5d..1339e9c 100644 --- a/core/src/test/scala/chargepoint/docile/dsl/ocpp20transactions/OpsSpec.scala +++ b/core/src/test/scala/chargepoint/docile/dsl/ocpp20transactions/OpsSpec.scala @@ -6,50 +6,47 @@ import scala.concurrent.ExecutionContext import com.thenewmotion.ocpp.VersionFamily import com.thenewmotion.ocpp.VersionFamily.{V20CsMessages, V20CsmsMessages} import com.thenewmotion.ocpp.messages.v20._ -import org.specs2.mutable.Specification -import org.specs2.specification.Scope +import org.scalatest.flatspec.AnyFlatSpec -class OpsSpec extends Specification { +class OpsSpec extends AnyFlatSpec { - "ocpp20transactions.Ops" should { + "ocpp20transactions.Ops" should "generate a transaction UUID on transaction start" in { + val (tx, _) = ops().startTransactionAtCablePluggedIn() - "generate a transaction UUID on transaction start" in new TestScope { - val (tx, _) = startTransactionAtCablePluggedIn() - - tx.data.id must beMatching("\\p{XDigit}{8}-\\p{XDigit}{4}-\\p{XDigit}{4}-\\p{XDigit}{4}-\\p{XDigit}{12}") - } + assert(tx.data.id.matches("\\p{XDigit}{8}-\\p{XDigit}{4}-\\p{XDigit}{4}-\\p{XDigit}{4}-\\p{XDigit}{12}")) + } - "return transaction messages with incrementing sequence numbers" in new TestScope { - val (tx, req0) = startTransactionAtCablePluggedIn() + it should "return transaction messages with incrementing sequence numbers" in { + val (tx, req0) = ops().startTransactionAtCablePluggedIn() - val req1 = tx.startEnergyOffer() - val req2 = tx.end() + val req1 = tx.startEnergyOffer() + val req2 = tx.end() - (req0.seqNo, req1.seqNo, req2.seqNo) mustEqual ((0, 1, 2)) - } + assert((req0.seqNo, req1.seqNo, req2.seqNo) === ((0, 1, 2))) + } - "return transaction messages with incrementing sequence numbers over multiple transactions" in new TestScope { - val (tx0, req0) = startTransactionAtCablePluggedIn() + it should "return transaction messages with incrementing sequence numbers over multiple transactions" in { + val myOps = ops() + val (tx0, req0) = myOps.startTransactionAtCablePluggedIn() - val req1 = tx0.end() + val req1 = tx0.end() - val (tx1, req2) = startTransactionAtAuthorized() + val (tx1, req2) = myOps.startTransactionAtAuthorized() - val req3 = tx1.end() + val req3 = tx1.end() - List(req0, req1, req2, req3).map(_.seqNo) mustEqual 0.to(3).toList - } + assert(List(req0, req1, req2, req3).map(_.seqNo) === 0.to(3).toList) + } - "specify the EVSE and connector ID on the first message, and not later" in new TestScope { - val (tx, req0) = startTransactionAtAuthorized(evseId = 2, connectorId = 3) + it should "specify the EVSE and connector ID on the first message, and not later" in { + val (tx, req0) = ops().startTransactionAtAuthorized(evseId = 2, connectorId = 3) - val req1 = tx.plugInCable() + val req1 = tx.plugInCable() - (req0.evse, req1.evse) mustEqual ((Some(EVSE(2, Some(3))), None)) - } + assert((req0.evse, req1.evse) === ((Some(EVSE(2, Some(3))), None))) } - trait TestScope extends Scope with CoreOps[ + def ops(): Ops = new CoreOps[ VersionFamily.V20.type, CsmsRequest, CsmsResponse, @@ -58,7 +55,7 @@ class OpsSpec extends Specification { CsResponse, CsReqRes ] with Ops { - protected lazy val connectionData = sys.error("This test should not do anything with the OCPP connection") + protected lazy val connection = sys.error("This test should not do anything with the OCPP connection") val csmsMessageTypes = V20CsmsMessages val csMessageTypes = V20CsMessages implicit val executionContext = ExecutionContext.global diff --git a/examples/ocpp1x/handling-incoming-messages.scala b/examples/ocpp1x/handling-incoming-messages.scala new file mode 100644 index 0000000..a3cb43c --- /dev/null +++ b/examples/ocpp1x/handling-incoming-messages.scala @@ -0,0 +1,72 @@ +// This example script shows how handlingIncomingMessages can be used to +// disregard unanticipated requests from the Central System during a test of +// remote start / stop functionality + +import com.thenewmotion.ocpp.messages.v1x.meter.{Location, Measurand, Meter, ReadingContext, UnitOfMeasure, Value, ValueFormat} + +handlingIncomingMessages(getConfigurationReq.respondingWith(GetConfigurationRes(List(), List()))) { + handlingIncomingMessages(getLocalListVersionReq.respondingWith(GetLocalListVersionRes(AuthListNotSupported))) { + + bootNotification() + + for (i <- 0.to(2)) { + statusNotification(scope = ConnectorScope(i)) + } + + say("Waiting for remote start message") + val startRequest = expectIncoming(remoteStartTransactionReq.respondingWith(RemoteStartTransactionRes(true))) + val chargeTokenId = startRequest.idTag + val connector = startRequest.connector + + say("Received start request, starting...") + statusNotification(scope = connector.get, status = ChargePointStatus.Occupied(Some(OccupancyKind.Preparing))) + val transId = startTransaction(connector = connector.get, meterStart = 300, idTag = chargeTokenId).transactionId + statusNotification(scope = connector.get, status = ChargePointStatus.Occupied(Some(OccupancyKind.Charging))) + + say(s"Transaction started with ID $transId; sending MeterValues in 5s") + + Thread.sleep(5000) + + say("Sending MeterValues") + + val mv = Value( + "5000", + context = ReadingContext.SamplePeriodic, + format = ValueFormat.Raw, + measurand = Measurand.EnergyActiveImportRegister, + phase = None, + location = Location.Outlet, + unit = UnitOfMeasure.Wh + ) + meterValues(transactionId=Some(transId), meters = List(Meter(ZonedDateTime.now(), List(mv)))) + + say("Waiting for remote stop...") + + def waitForValidRemoteStop(): Unit = { + val shouldStop = + expectIncoming( + requestMatching({case r: RemoteStopTransactionReq => r.transactionId == transId}) + .respondingWith(RemoteStopTransactionRes(_)) + ) + + if (shouldStop) { + say("Received RemoteStopTransaction request; stopping transaction") + () + } else { + say(s"Received RemoteStopTransaction request for other transaction with ID. I'll keep waiting for a stop for $transId.") + waitForValidRemoteStop() + } + } + + // handle UnlockConnectorReq if present + handlingIncomingMessages(unlockConnectorReq.respondingWith(UnlockConnectorRes(UnlockStatus.NotSupported))) { + waitForValidRemoteStop() + + statusNotification(scope = connector.get, status = ChargePointStatus.Occupied(Some(OccupancyKind.Finishing))) + stopTransaction(transactionId = transId, idTag = Some(chargeTokenId)) + statusNotification(scope = connector.get, status = ChargePointStatus.Available()) + + say("Transaction stopped") + } + } +} diff --git a/examples/ocpp1x/remote-transaction.scala b/examples/ocpp1x/remote-transaction.scala index 182b8ac..b98501c 100644 --- a/examples/ocpp1x/remote-transaction.scala +++ b/examples/ocpp1x/remote-transaction.scala @@ -10,9 +10,9 @@ val auth = authorize(chargeTokenId).idTag if (auth.status == AuthorizationStatus.Accepted) { say("Obtained authorization from Central System; starting transaction") - statusNotification(status = ChargePointStatus.Occupied(Some(OccupancyKind.Preparing))) - val transId = startTransaction(meterStart = 300, idTag = chargeTokenId).transactionId - statusNotification(status = ChargePointStatus.Occupied(Some(OccupancyKind.Charging))) + statusNotification(status = ChargePointStatus.Occupied(Some(OccupancyKind.Preparing)), timestamp=Some(ZonedDateTime.now.withNano(0))) + val transId = startTransaction(meterStart = 300, idTag = chargeTokenId, timestamp=ZonedDateTime.now.withNano(0)).transactionId + statusNotification(status = ChargePointStatus.Occupied(Some(OccupancyKind.Charging)), timestamp=Some(ZonedDateTime.now.withNano(0))) say(s"Transaction started with ID $transId; awaiting remote stop") @@ -36,9 +36,9 @@ if (auth.status == AuthorizationStatus.Accepted) { // handle UnlockConnectorReq if present Try(expectIncoming(unlockConnectorReq.respondingWith(UnlockConnectorRes(UnlockStatus.NotSupported)))) - statusNotification(status = ChargePointStatus.Occupied(Some(OccupancyKind.Finishing))) - stopTransaction(transactionId = transId, idTag = Some(chargeTokenId)) - statusNotification(status = ChargePointStatus.Available()) + statusNotification(status = ChargePointStatus.Occupied(Some(OccupancyKind.Finishing)), timestamp=Some(ZonedDateTime.now.withNano(0))) + stopTransaction(transactionId = transId, idTag = Some(chargeTokenId), timestamp=ZonedDateTime.now.withNano(0)) + statusNotification(status = ChargePointStatus.Available(), timestamp=Some(ZonedDateTime.now.withNano(0))) say("Transaction stopped")