diff --git a/README.md b/README.md index 2ed87cd..3ae9210 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,8 @@ The library jar, `io.stepfunc:dnp3c`, is published to Maven Central. See [Automa ## Known limitations -- **Not implemented:** 8.10.2 (multi-drop support) and 8.12.2 (collision avoidance). The report shows them struck through. +- **Not implemented:** 8.12.2 (collision avoidance), and in 2.7, 8.10.2 (multi-drop support). The report shows them struck through. +- **3.1r1's 8.10.2** (multi-drop support) runs over serial, with the DUT on a drop with other devices (`serial.otherDevices`). The tool checks what the DUT sends; whether it raised carrier or asserted RTS, and when, the plugin checks on the line, e.g. by asking the tester to read an oscilloscope. - **Report by exception** isn't verified: where a procedure requires a device whose class 0 response spans several link frames to also report by exception, the log notes it but the tool doesn't check it. - **8.11.2.5.8** covers a device that holds unsolicited responses until it has a minimum number of events, not one that holds them for a time. - **8.11.2.1 step 24:** the procedure expects a READ to be answered while the device's startup unsolicited response is still unconfirmed, while IEEE 1815's outstation state table would let the device defer it. The tool follows the procedure. diff --git a/src/main/scala/io/stepfunc/dnp3c/conformance/Revision.scala b/src/main/scala/io/stepfunc/dnp3c/conformance/Revision.scala index fb430c4..eb8f439 100644 --- a/src/main/scala/io/stepfunc/dnp3c/conformance/Revision.scala +++ b/src/main/scala/io/stepfunc/dnp3c/conformance/Revision.scala @@ -161,7 +161,8 @@ object Revisions { v31r1.Rules.all, (options, selected) => v31r1.EventRules.configurationProblems(options, selected) ++ - v31r1.Network.configurationProblems(options, selected) + v31r1.Network.configurationProblems(options, selected) ++ + v31r1.MultiDrop.configurationProblems(options, selected) ) /** every revision, oldest first */ diff --git a/src/main/scala/io/stepfunc/dnp3c/conformance/api/options/SerialOptions.scala b/src/main/scala/io/stepfunc/dnp3c/conformance/api/options/SerialOptions.scala index 59dd3ea..a03f6ac 100644 --- a/src/main/scala/io/stepfunc/dnp3c/conformance/api/options/SerialOptions.scala +++ b/src/main/scala/io/stepfunc/dnp3c/conformance/api/options/SerialOptions.scala @@ -4,7 +4,7 @@ package io.stepfunc.dnp3c.conformance.api.options import io.stepfunc.dnp3c.conformance.api.{ - BooleanOption, IntegerOption, OptionGroup, OptionSource, OptionTypes, StringOption, TestOption + BooleanOption, IndexListOption, IntegerOption, OptionGroup, OptionSource, OptionTypes, StringOption, TestOption } import io.stepfunc.dnp3c.protocol.channel.SerialSettings @@ -35,7 +35,31 @@ object SerialOptions extends OptionGroup { val stopBits: IntegerOption = OptionTypes.integer(this, "stopBits", 1, "The number of stop bits", Some(1), Some(2)) - def options: List[TestOption] = List(enabled, port, baudRate, dataBits, parity, stopBits) + val multiDrop: BooleanOption = OptionTypes.boolean( + this, + "multiDrop", + false, + "Does the DUT support a multi-drop physical layer, e.g. RS-485? 3.1r1's 8.10.2 tests it over serial, with the " + + "devices at serial.otherDevices on the same drop" + ) + + val otherDevices: IndexListOption = OptionTypes.indexList( + this, + "otherDevices", + "The DNP3 addresses of the other devices on the DUT's drop, at least two, for 3.1r1's 8.10.2", + Nil) + + val rtsDelays: StringOption = OptionTypes.string( + this, + "rtsDelays", + "", + "The DUT's settings of the delays between raising carrier (RTS) and transmitting, and between transmitting and " + + "dropping it, that 3.1r1's 8.10.2 step 9c tests, as before/after pairs in ms, e.g. 0/0, 10/5, 50/20; empty " + + "where they aren't configurable" + ) + + def options: List[TestOption] = + List(enabled, port, baudRate, dataBits, parity, stopBits, multiDrop, otherDevices, rtsDelays) def settings(source: OptionSource): SerialSettings = SerialSettings( source.get(port), diff --git a/src/main/scala/io/stepfunc/dnp3c/conformance/v31r1/MultiDrop.scala b/src/main/scala/io/stepfunc/dnp3c/conformance/v31r1/MultiDrop.scala new file mode 100644 index 0000000..050cc8a --- /dev/null +++ b/src/main/scala/io/stepfunc/dnp3c/conformance/v31r1/MultiDrop.scala @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright Step Function I/O LLC + +package io.stepfunc.dnp3c.conformance.v31r1 + +import io.stepfunc.dnp3c.conformance.api._ +import io.stepfunc.dnp3c.conformance.api.options.{ LinkOptions, OutstationOptions, SerialOptions } +import io.stepfunc.dnp3c.conformance.s8.helpers.GenericSteps.{ expectAtLeastOneResponse, send } +import io.stepfunc.dnp3c.dnp3._ +import io.stepfunc.dnp3c.dnp3.app.{ Message, Sequence } +import io.stepfunc.dnp3c.dnp3.app.objects.{ Group60Var2, Group60Var3, Group60Var4 } +import io.stepfunc.dnp3c.protocol.parsing.UInt16LE + +/** + * 3.1r1's 8.10.2, Multi-Drop Support, over serial: class 1, 2 and 3 reads to the other devices on the drop + * (serial.otherDevices) and to the broadcast address, which nothing from the DUT may follow, and one to the DUT, which + * it answers; then the same for each RTS delay setting (serial.rtsDelays). The harness sees what the DUT sends; whether + * it raised carrier or asserted RTS, and when, is for the plugin to check on the line, e.g. with an oscilloscope. + */ +object MultiDrop { + + private val pairs = """\s*(\d{1,6})\s*/\s*(\d{1,6})\s*""".r + + /** the RTS delays to test, distinct, or why they can't be read */ + def rtsDelays(options: OptionSource): Either[String, List[RtsDelays]] = { + val text = options.get(SerialOptions.rtsDelays).trim + if (text.isEmpty) Right(Nil) + else { + val parsed = text.split(",", -1).toList.map { + case pairs(before, after) => Some(RtsDelays(before.toInt, after.toInt)) + case _ => None + } + if (!parsed.forall(_.isDefined)) Left("must be before/after pairs in ms, separated by commas, e.g. 0/0, 10/5") + else if (parsed.distinct.size < parsed.size) Left("lists a setting more than once") + else Right(parsed.flatten) + } + } + + // the addresses of ordinary devices, as the link addresses allow: not broadcast, self or reserved ones + private val maxAddress = 0xFFEF + + /** What 8.10.2 needs configured where it runs: two or more other devices, and RTS delays that can be read */ + def configurationProblems(options: OptionSource, selected: Iterable[TestProcedure]): List[ConfigProblem] = + if (!selected.exists(p => p.definition == multiDrop && p.disabledBy(options).isEmpty)) Nil + else { + val others = options.get(SerialOptions.otherDevices).map(_.value) + val own = Set(options.get(LinkOptions.destination), options.get(LinkOptions.source)) + List( + Option.when(others.distinct.size < 2)( + ConfigProblem( + SerialOptions.otherDevices.id, + others.mkString(", "), + "3.1r1's 8.10.2 needs the addresses of at least two other devices on the drop")), + Option.when(others.exists(_ > maxAddress))( + ConfigProblem( + SerialOptions.otherDevices.id, + others.mkString(", "), + s"must be ordinary devices' addresses, at most $maxAddress: not broadcast, self or reserved ones" + )), + Option.when(others.exists(own.contains))( + ConfigProblem( + SerialOptions.otherDevices.id, + others.mkString(", "), + s"mustn't hold the DUT's address (${LinkOptions.destination.id}) or the master's (${LinkOptions.source.id})" + )), + rtsDelays(options).left.toOption.map(why => + ConfigProblem(SerialOptions.rtsDelays.id, options.get(SerialOptions.rtsDelays), why)) + ).flatten + } + + private def read(seq: Sequence): Message = Requests.readAllObjects(seq, Group60Var2, Group60Var3, Group60Var4) + + // nothing from the DUT's address within the window; frames from other devices are noted + private def nothingFromDut(windowMs: Int): TestStep = new TestStep { + def description: List[Comment] = + List(s"Verify that no frame comes from the DUT within $windowMs ms; frames from other devices are noted") + def run(driver: TestDriver): List[DeferredCheck] = { + val deadline = driver.clock.deadline(windowMs) + var left = driver.clock.msLeft(deadline) + while (left > 0) { + driver.readFrame(Some(left.toInt)).foreach { frame => + if (frame.header.src == driver.remote) throw new Exception(s"The DUT sent a frame: $frame") + driver.reporter.info(s"A frame from address ${frame.header.src.value}: $frame") + } + left = driver.clock.msLeft(deadline) + } + Nil + } + } + + // checked at once, while the line's recording is of this exchange + private val noCarrier: TestStep = new TestStep { + def description: List[Comment] = List("Verify on the line that the DUT didn't raise carrier or assert RTS") + def run(driver: TestDriver): List[DeferredCheck] = + driver.reporter.plugin.verifyNoCarrier(driver.reporter.checkReporter(CheckTiming.Immediate)).toList + } + + // the other devices' late answers, read until the line is quiet for the link timeout, so none mixes with the + // DUT's response; a frame from the DUT fails + private val quiet: TestStep = new TestStep { + def description: List[Comment] = List("Wait until the line is quiet, with nothing from the DUT") + def run(driver: TestDriver): List[DeferredCheck] = { + var frame = driver.readFrame() + while (frame.isDefined) { + frame.foreach { f => + if (f.header.src == driver.remote) throw new Exception(s"The DUT sent a frame: $f") + driver.reporter.info(s"A frame from address ${f.header.src.value}: $f") + } + frame = driver.readFrame() + } + Nil + } + } + + private def carrierTiming(delays: Option[RtsDelays]): TestStep = new TestStep { + def description: List[Comment] = List( + "Verify on the line that the DUT raised carrier (RTS) an appropriate time before its response, and dropped it " + + "an appropriate time after" + delays.fold("")(d => s", as set: ${d.beforeMs} ms and ${d.afterMs} ms")) + def run(driver: TestDriver): List[DeferredCheck] = + driver.reporter.plugin + .verifyCarrierTiming(driver.reporter.checkReporter(CheckTiming.Immediate), delays) + .toList + } + + private def setDelays(delays: Option[RtsDelays]): TestStep = new TestStep { + def description: List[Comment] = List(delays.fold("Restore the DUT's usual RTS delays")(d => + s"Set the DUT to raise carrier (RTS) ${d.beforeMs} ms before transmitting, and to drop it ${d.afterMs} ms after")) + def run(driver: TestDriver): List[DeferredCheck] = { + driver.reporter.plugin.setRtsDelays(driver.reporter.pluginReporter(), delays) + Nil + } + } + + val multiDrop: ProcedureDefinition = new ProcedureDefinition { + override protected def requiredOptions: List[OptionCondition] = List(SerialOptions.enabled, SerialOptions.multiDrop) + + override protected def steps(options: OptionSource): Iterable[LabeledStep] = { + val others = options.get(SerialOptions.otherDevices).map(_.value) + val window = options.get(OutstationOptions.responseTimeout) + val delays = rtsDelays(options).getOrElse(Nil) + // a sequence number for each request, in order + val seqs = Iterator.iterate(Sequence.zero)(_.next) + def answered(seq: Sequence, d: Option[RtsDelays]) = + quiet + send(read(seq)) + expectAtLeastOneResponse(seq) + carrierTiming(d) + List( + 1 --> describe("The DUT supports a multi-drop physical layer"), + 2 --> describe(s"The DUT shares the drop with the devices at addresses ${others.mkString(", ")}"), + 3 --> describe("An oscilloscope or protocol analyzer watches the DUT's signals, for the plugin's checks"), + (4 to 5) --> + (if (others.isEmpty) describe("No other devices are set (serial.otherDevices)") + else + others + .map(a => + send(read(seqs.next()), LinkOverride.destination(UInt16LE(a))) + nothingFromDut(window) + noCarrier) + .reduce(_ + _)), + (6 to 7) --> + (send(read(seqs.next()), LinkOverride.destination(UInt16LE(0xFFFF))) + nothingFromDut(window) + noCarrier), + (8 to 9) --> answered(seqs.next(), None), + Label("step: 9c") --> + (if (delays.isEmpty) describe("The DUT's RTS delays aren't configurable (serial.rtsDelays)") + else + describe( + "The settings (serial.rtsDelays) are to cover, for each delay, every selection where it has four or " + + "fewer, and otherwise its minimum, maximum and two values between") + + delays.map(d => setDelays(Some(d)) + answered(seqs.next(), Some(d))).reduce(_ + _)) + ) + } + + override protected def cleanup(options: OptionSource): Iterable[LabeledStep] = + if (rtsDelays(options).getOrElse(Nil).isEmpty) Nil + else List(Label("cleanup") --> setDelays(None)) + } +} diff --git a/src/main/scala/io/stepfunc/dnp3c/conformance/v31r1/Tree.scala b/src/main/scala/io/stepfunc/dnp3c/conformance/v31r1/Tree.scala index d790cbd..17f4bee 100644 --- a/src/main/scala/io/stepfunc/dnp3c/conformance/v31r1/Tree.scala +++ b/src/main/scala/io/stepfunc/dnp3c/conformance/v31r1/Tree.scala @@ -306,7 +306,7 @@ object Tree { ) ), section("8.10", "Multi-Drop Support")( - notYet("8.10.2", "Test Procedure") + test("8.10.2", "Test Procedure", MultiDrop.multiDrop) ), section("8.11", "Unsolicited Responses")( section("8.11.2", "Test Procedure")( diff --git a/src/main/scala/io/stepfunc/dnp3c/dnp3/DefaultIntegrationPlugin.scala b/src/main/scala/io/stepfunc/dnp3c/dnp3/DefaultIntegrationPlugin.scala index e487569..ae1309d 100644 --- a/src/main/scala/io/stepfunc/dnp3c/dnp3/DefaultIntegrationPlugin.scala +++ b/src/main/scala/io/stepfunc/dnp3c/dnp3/DefaultIntegrationPlugin.scala @@ -165,6 +165,30 @@ class DefaultIntegrationPlugin extends IntegrationPlugin { ManualSteps.UserAction(reporter, "Restore the DUT's usual network setup, the one it had before this procedure") } + override def setRtsDelays(reporter: PluginReporter, delays: Option[RtsDelays]): Unit = { + val what = delays match { + case Some(d) => + s"Set the DUT to raise carrier (RTS) ${d.beforeMs} ms before transmitting, and drop it ${d.afterMs} ms after" + case None => "Restore the DUT's usual RTS delays" + } + ManualSteps.UserAction(reporter, what) + } + + override def verifyNoCarrier(reporter: CheckReporter): Option[DeferredCheck] = + ManualSteps.verification( + reporter, + "On the oscilloscope or analyzer, verify that the DUT didn't transmit, raise carrier or assert RTS since the " + + "last request", + Nil) + + override def verifyCarrierTiming(reporter: CheckReporter, delays: Option[RtsDelays]): Option[DeferredCheck] = + ManualSteps.verification( + reporter, + "On the oscilloscope or analyzer, verify that the DUT raised carrier (RTS) an appropriate time before the first " + + "bit of its response, and dropped it an appropriate time after the last", + delays.toList.map(d => s"The DUT is set to ${d.beforeMs} ms before and ${d.afterMs} ms after") + ) + override def verifyDisconnectNoticed(reporter: CheckReporter): Option[DeferredCheck] = ManualSteps.verification(reporter, "Verify that the DUT noticed that the master closed its TCP connection", Nil) diff --git a/src/main/scala/io/stepfunc/dnp3c/dnp3/IntegrationPlugin.scala b/src/main/scala/io/stepfunc/dnp3c/dnp3/IntegrationPlugin.scala index b001c9e..73c93a2 100644 --- a/src/main/scala/io/stepfunc/dnp3c/dnp3/IntegrationPlugin.scala +++ b/src/main/scala/io/stepfunc/dnp3c/dnp3/IntegrationPlugin.scala @@ -116,6 +116,9 @@ final case class NetworkSetup( } } +/** A DUT's delays between raising carrier (RTS) and transmitting, and between transmitting and dropping it */ +final case class RtsDelays(beforeMs: Int, afterMs: Int) + object InputKind { case object BinaryInput extends InputKind[BinaryPoint]("binary input") case object DoubleBitInput extends InputKind[DoubleBitPoint]("double-bit binary input") @@ -268,6 +271,9 @@ trait IntegrationPlugin { // restores the DUT's usual network setup, the one it had when the procedure started (3.1r1's section 9) def restoreNetwork(reporter: PluginReporter): Unit = throw new UnsupportedOperationException("this plugin doesn't implement restoreNetwork") + // sets the DUT's RTS delays, or restores its usual ones (3.1r1's 8.10.2) + def setRtsDelays(reporter: PluginReporter, delays: Option[RtsDelays]): Unit = + throw new UnsupportedOperationException("this plugin doesn't implement setRtsDelays") // =================== // Message validations @@ -351,6 +357,15 @@ trait IntegrationPlugin { def verifyCommandEventTime(reporter: CheckReporter, sentMs: Long, eventMs: Long): Option[DeferredCheck] = throw new UnsupportedOperationException("this plugin doesn't implement verifyCommandEventTime") + // Check, on the line, that the DUT didn't transmit, raise carrier or assert RTS since the last request, which wasn't + // to it (3.1r1's 8.10.2) + def verifyNoCarrier(reporter: CheckReporter): Option[DeferredCheck] = + throw new UnsupportedOperationException("this plugin doesn't implement verifyNoCarrier") + // Check, on the line, that the DUT raised carrier (asserted RTS) an appropriate time before the first bit of its + // response, and dropped it an appropriate time after the last, the delays it was set to if given (3.1r1's 8.10.2) + def verifyCarrierTiming(reporter: CheckReporter, delays: Option[RtsDelays]): Option[DeferredCheck] = + throw new UnsupportedOperationException("this plugin doesn't implement verifyCarrierTiming") + // Check that the DUT noticed that the master closed its TCP connection (3.1r1's 9.5.1) def verifyDisconnectNoticed(reporter: CheckReporter): Option[DeferredCheck] = throw new UnsupportedOperationException("this plugin doesn't implement verifyDisconnectNoticed") diff --git a/src/test/scala/io/stepfunc/dnp3c/conformance/v31r1/MultiDropTest.scala b/src/test/scala/io/stepfunc/dnp3c/conformance/v31r1/MultiDropTest.scala new file mode 100644 index 0000000..48e3c0d --- /dev/null +++ b/src/test/scala/io/stepfunc/dnp3c/conformance/v31r1/MultiDropTest.scala @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright Step Function I/O LLC + +package io.stepfunc.dnp3c.conformance.v31r1 + +import scala.collection.mutable +import scala.util.Try + +import io.stepfunc.dnp3c.conformance.api.{ Options, TestProcedure } +import io.stepfunc.dnp3c.conformance.api.options.SerialOptions +import io.stepfunc.dnp3c.dnp3._ +import io.stepfunc.dnp3c.dnp3.app.{ Message, Unsolicited } +import io.stepfunc.dnp3c.dnp3.link.{ Control, FrameValidator, LinkFrame } +import io.stepfunc.dnp3c.protocol.Hex +import io.stepfunc.dnp3c.protocol.parsing.UInt16LE +import org.junit.runner.RunWith +import org.scalatestplus.junit.JUnitRunner +import org.scalatest.matchers.should.Matchers +import org.scalatest.funsuite.AnyFunSuite + +// 3.1r1's 8.10.2, with the DUT at address 1024 and other devices at 5 and 6 +@RunWith(classOf[JUnitRunner]) +class MultiDropTest extends AnyFunSuite with Matchers { + + /** + * Answers reads to its address, and to the broadcast address if answersBroadcast; the other devices answer theirs, + * which the harness sees on the drop too + */ + private class Drop(answersBroadcast: Boolean = false, lateOther: Boolean = false) + extends StubDriver(StubDriver.reporter()) { + val delays: mutable.ListBuffer[Option[RtsDelays]] = mutable.ListBuffer.empty + var carrierChecks = 0 + private val frames = mutable.Queue.empty[LinkFrame] + private val responses = mutable.Queue.empty[Message] + + val plugin: DefaultIntegrationPlugin = new DefaultIntegrationPlugin { + override def setRtsDelays(reporter: PluginReporter, d: Option[RtsDelays]): Unit = delays += d + override def verifyNoCarrier(reporter: CheckReporter): Option[DeferredCheck] = None + override def verifyCarrierTiming(reporter: CheckReporter, d: Option[RtsDelays]): Option[DeferredCheck] = { + carrierChecks += 1 + None + } + } + override val reporter: ProcedureReporter = StubDriver.reporter(plugin) + + override def writeMessage(message: Message, options: LinkOverride): Unit = { + val to = options.destination.getOrElse(remote).value + val seq = message.ctrl.seq.value.toInt + if (to == remote.value || (to == 0xFFFF && answersBroadcast)) { + frames.enqueue(LinkFrame.headerOnly(Control(0x44.toByte), local, remote)) + if (to == remote.value) responses.enqueue(Message.parse(Hex.from("%02X 81 00 00".format(0xC0 | seq)))) + } else if (to != 0xFFFF) { + frames.enqueue(LinkFrame.headerOnly(Control(0x44.toByte), local, UInt16LE(to))) + // the other device's answer comes again later, after the harness has stopped waiting for the DUT + if (lateOther) late.enqueue(LinkFrame.headerOnly(Control(0x44.toByte), local, UInt16LE(to))) + } + } + + private val late = mutable.Queue.empty[LinkFrame] + + // the late answers come only after the silence windows, which read with the response timeout: to a read with + // the link timeout, the default + override def readFrame(timeout: Option[Int]): Option[LinkFrame] = + if (frames.nonEmpty) Some(frames.dequeue()) + else if (late.nonEmpty && timeout.isEmpty) Some(late.dequeue()) + else timedOut(timeout) + + // a message read takes the DUT's frames; any other device's frame in the way breaks it, as the real driver's + // link validation does + override def readMessage( + unsolicited: Unsolicited, + validator: FrameValidator, + linkOverride: LinkOverride, + timeoutMs: Option[Int]): Option[Message] = { + if ((frames ++ late).exists(_.header.src != remote)) + throw new Exception("A frame from another device came with the DUT's response") + frames.clear() + if (responses.isEmpty) timedOut(timeoutMs) else Some(responses.dequeue()) + } + } + + private val bench = Map( + SerialOptions.enabled.id -> "true", + SerialOptions.multiDrop.id -> "true", + SerialOptions.otherDevices.id -> "5, 6") + + private def run(drop: Drop, settings: Map[String, String] = Map()): Boolean = { + val config = MultiDrop.multiDrop.configure(Options.from(bench ++ settings)) + val passed = Try(config.steps.foreach(s => s.step.run(drop).foreach(_.check()))).isSuccess + config.cleanup.foreach(s => s.step.run(drop).foreach(_.check())) + passed + } + + test("the DUT answers its own reads only, while the other devices answer theirs") { + val drop = new Drop + run(drop) should equal(true) + drop.carrierChecks should equal(1) + run(new Drop(answersBroadcast = true)) should equal(false) + } + + test("a late frame from another device before the DUT's read is waited out") { + val drop = new Drop(lateOther = true) + run(drop) should equal(true) + } + + test("each RTS delay setting is tested, and the usual ones restored") { + val drop = new Drop + run(drop, Map(SerialOptions.rtsDelays.id -> "0/0, 10/5")) should equal(true) + drop.delays.toList should equal(List(Some(RtsDelays(0, 0)), Some(RtsDelays(10, 5)), None)) + drop.carrierChecks should equal(3) + } + + test("8.10.2 needs serial, a multi-drop DUT, two other devices and readable delays") { + MultiDrop.multiDrop.disabledBy(Options.from(bench - SerialOptions.enabled.id)) should not equal None + MultiDrop.multiDrop.disabledBy(Options.from(bench - SerialOptions.multiDrop.id)) should not equal None + def problems(settings: Map[String, String]) = MultiDrop + .configurationProblems(Options.from(bench ++ settings), List(TestProcedure("8.10.2", "t", MultiDrop.multiDrop))) + .map(_.key) + problems(Map()) should equal(Nil) + problems(Map(SerialOptions.otherDevices.id -> "5")) should equal(List(SerialOptions.otherDevices.id)) + problems(Map(SerialOptions.otherDevices.id -> "5, 1024")) should equal(List(SerialOptions.otherDevices.id)) + for (bad <- List("10-5", "0/0,", ",", "2147483648/0", "0/0, 0/0")) + problems(Map(SerialOptions.rtsDelays.id -> bad)) should equal(List(SerialOptions.rtsDelays.id)) + problems(Map(SerialOptions.otherDevices.id -> "65534, 65535")) should equal(List(SerialOptions.otherDevices.id)) + // with nothing set, the steps can still be described + MultiDrop.multiDrop.configure(Options.from(bench - SerialOptions.otherDevices.id)).steps should not be empty + } +}