Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion src/main/scala/io/stepfunc/dnp3c/conformance/Revision.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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),
Expand Down
173 changes: 173 additions & 0 deletions src/main/scala/io/stepfunc/dnp3c/conformance/v31r1/MultiDrop.scala
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
15 changes: 15 additions & 0 deletions src/main/scala/io/stepfunc/dnp3c/dnp3/IntegrationPlugin.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading