From e50a677694e4f00a3983ca665e5744c0c763b6a3 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Wed, 14 Oct 2026 11:11:40 -0700 Subject: [PATCH 01/16] Run the 3.1r1 subset level 3 conformance tests Runs dnp3c's revision 3.1r1 at subset level 3, against the dnp3c built locally (0.1.0-SNAPSHOT) until it's released. The plugin implements the actions 3.1r1 adds: cold restart support, setting an analog input, frozen counter and small analog input events, uninstalling frozen counters and analog inputs, normal and abnormal flags, an event on one point, and checking frozen counter events. Fixes to the test outstation that dnp3c's stricter checks need, which 2.7 passes with too: - a binary output in local mode clears ONLINE and keeps its other flags, rather than setting LOCAL_FORCED, and a command to it gets LOCAL (7), as TB2017-001's 8.6.4 tests and 3.1r1's check - an analog output among 10 to 19 accepts 70000, the 32-bit value 3.1r1's 8.4.1.2.13 commands device.ini gives the 3.1r1 values the outstation's setup implies, and lists the tests that fail on gaps in the dnp3 outstation. --- conformance/device.ini | 25 ++++ conformance/pom.xml | 4 +- .../dnp3/CustomOutstationApplication.scala | 6 +- .../dnp3/Dnp3rsIntegrationPlugin.scala | 74 ++++++++++- .../conformance/dnp3/OutstationConfig.scala | 2 +- .../dnp3/QueuedControlHandler.scala | 6 +- .../conformance/dnp3/TrackingDatabase.scala | 122 ++++++++++++++++-- 7 files changed, 224 insertions(+), 15 deletions(-) diff --git a/conformance/device.ini b/conformance/device.ini index ac6975eed..727b9791b 100644 --- a/conformance/device.ini +++ b/conformance/device.ini @@ -441,3 +441,28 @@ attributes.supportedVariations = 241, 245, 247, 250, 252 # The supported device attribute variations that the DUT reports as read/write attributes.writableVariations = 245, 247 +outstation.subset.level = 3 + +# 3.1r1, subset level 3 +# +# Tests that fail on gaps in the dnp3 outstation, which no option turns off: +# - a read of a type whose points are all uninstalled gets a null response, without IIN2.1 or IIN2.2: 8.13.2-4, +# 8.16.1.2.4, 8.21.1, 8.21-8.13.2-4, and 8.23.2.x.1 +# - a range read that runs past the last point gets the points there are, without IIN2.2: 8.23.2.x.3 +# - a static read of a variation without flags reports a point whose flags aren't normal in that variation, rather +# than one with flags: 8.24.2-4 to 8.24.2-6 +# - Assign Class (function code 22) isn't supported: 8.26.2, which also needs binary inputs in class 2 +# Serial (8.10.2, 8.12.2, 6.6.2-4) and network (section 9) tests are off; so are output events (section 10). +binaryInput.defaultEventVariation = 2 +doubleBitInput.defaultEventVariation = 1 +# the plugin asks for the time 5 s after a sync, and its clock is unsynchronized 5 s after that +timesync.timeToUnsynchronizedMs = 5000 +analogOutput.commands32Bit = true +analogOutput.index32Bit = 10 +analogInput.valueReturn.deadband = 1 +# dnp3 doesn't support Assign Class (function code 22): 8.26 fails; these only make the configuration valid +class.assign.class1BinaryInputs = 0 +class.assign.class2BinaryInputs = 1, 2 +timesync.supportLanTimeSync = true +# a binary output that stays remote while output 0 is in local mode, which takes output 0's command (outputs 10 to 19 do) +flag.tests.individualRemoteSupervisoryRemoteOutputIndex = 10 diff --git a/conformance/pom.xml b/conformance/pom.xml index 4e37be008..787c781a9 100644 --- a/conformance/pom.xml +++ b/conformance/pom.xml @@ -12,7 +12,7 @@ UTF-8 3.9.0 1.8.0-M1 - 0.1.0 + 0.1.0-SNAPSHOT 4.10.0 @@ -43,6 +43,8 @@ conformance io.stepfunc.conformance.dnp3.Main + -rev + 3.1r1 -config device.ini -run diff --git a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/CustomOutstationApplication.scala b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/CustomOutstationApplication.scala index 2af055d52..047021c37 100644 --- a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/CustomOutstationApplication.scala +++ b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/CustomOutstationApplication.scala @@ -34,7 +34,11 @@ class CustomOutstationApplication extends OutstationApplication { true } - override def coldRestart: RestartDelay = RestartDelay.milliseconds(ushort(5000)) + // whether the outstation supports cold restart (3.1r1's 8.8.2) + @volatile var coldRestartSupported = true + + override def coldRestart: RestartDelay = + if (coldRestartSupported) RestartDelay.milliseconds(ushort(5000)) else RestartDelay.notSupported() override def warmRestart: RestartDelay = RestartDelay.notSupported() diff --git a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/Dnp3rsIntegrationPlugin.scala b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/Dnp3rsIntegrationPlugin.scala index 72c6be4b8..5cd5c4472 100644 --- a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/Dnp3rsIntegrationPlugin.scala +++ b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/Dnp3rsIntegrationPlugin.scala @@ -2,7 +2,7 @@ package io.stepfunc.conformance.dnp3 import io.stepfunc.dnp3c.dnp3.app._ import io.stepfunc.dnp3c.dnp3.app.objects.AttributeValue -import io.stepfunc.dnp3c.dnp3.{ActionResult, ActionTiming, CheckReporter, DeferredCheck, EventExpectation, InputKind, IntegrationPlugin, PluginReporter} +import io.stepfunc.dnp3c.dnp3.{AbnormalFlag, ActionResult, ActionTiming, CheckReporter, DeferredCheck, EventExpectation, InputKind, IntegrationPlugin, PluginReporter, PointKind} import io.stepfunc.dnp3c.protocol.parsing.UInt8 import io.stepfunc.dnp3.{AddressFilter, AnalogInput, AnalogOutputStatus, BinaryInput, BinaryOutputStatus, Counter, DoubleBit, DoubleBitBinaryInput, EventBufferConfig, FrozenCounter, LinkErrorMode, Outstation, Runtime, RuntimeConfig, OutstationServer} import org.joou.UInteger @@ -20,10 +20,13 @@ class Dnp3IntegrationPlugin extends IntegrationPlugin { private var controlHandler: QueuedControlHandler = _ private var config = StackConfig.Default private var eventBatch = 0 + private var app: CustomOutstationApplication = _ + private var coldRestartSupported = true override def startProcedure(reporter: PluginReporter): Unit = { config = StackConfig.Default + coldRestartSupported = true startOutstation() } @@ -162,9 +165,72 @@ class Dnp3IntegrationPlugin extends IntegrationPlugin { cyclePower(reporter) } + // Actions 3.1r1 uses + + override def setColdRestartSupport(reporter: PluginReporter, enabled: Boolean): Unit = { + coldRestartSupported = enabled + app.coldRestartSupported = enabled + reporter.log(s"Cold restart support is set to $enabled") + } + + override def setAnalogInput(reporter: PluginReporter, index: Int, value: Double): Unit = { + this.eventBatch = this.eventBatch + 1 + printAnalogInput(reporter, trackingDatabase.setAnalogInputValue(index, value, this.eventBatch)) + } + + override def generateExactFrozenCounterEvents(reporter: PluginReporter, numEvents: Int): Unit = { + this.eventBatch = this.eventBatch + 1 + 1 to numEvents foreach { _ => + val event = trackingDatabase.generateFrozenCounterEvent(0, this.eventBatch) + reporter.log(s"Updated frozen counter ${event.idx}: value=${event.value.value}") + } + } + + // the analog input values go up by 100 an event from 0, so they stay below 32768 + override def generateExactSmallAnalogInputEvents(reporter: PluginReporter, numEvents: Int): Unit = + generateExactInputEvents(reporter, InputKind.AnalogInput, numEvents) + + override def uninstallFrozenCounters(reporter: PluginReporter): Unit = { + config = config.copy(testDatabaseConfig = config.testDatabaseConfig.copy(disableFrozenCounters = true)) + reporter.log("All frozen counter points were uninstalled") + cyclePower(reporter) + } + + override def uninstallAnalogInputs(reporter: PluginReporter): Unit = { + config = config.copy(testDatabaseConfig = config.testDatabaseConfig.copy(disableAnalogInputs = true)) + reporter.log("All analog input points were uninstalled") + cyclePower(reporter) + } + + // the change of one binary input, the kind 3.1r1's 8.26 uses + override def generateEventOn(reporter: PluginReporter, kind: PointKind, index: Int): Unit = kind match { + case PointKind.BinaryInput => + this.eventBatch = this.eventBatch + 1 + printBinaryInput(reporter, trackingDatabase.generateBinaryInputEvent(index, this.eventBatch)) + case _ => throw new Exception(s"Events on one ${kind.name} aren't supported") + } + + override def setFlagsNormal(reporter: PluginReporter, kind: PointKind): Unit = { + trackingDatabase.setFlags(kind, None, 0x01) + reporter.log(s"Every ${kind.name} point's flags are normal") + } + + // COMM_LOST set on the first point, with ONLINE + override def setFlagAbnormal(reporter: PluginReporter, kind: PointKind, indices: Seq[Int]): AbnormalFlag = { + val index = indices.headOption.getOrElse(throw new Exception(s"No ${kind.name} point to set a flag of")) + trackingDatabase.setFlags(kind, Some(Seq(index)), 0x05) + reporter.log(s"${kind.name} $index has COMM_LOST set") + AbnormalFlag(index, Some(0x05)) + } + + override def verifyFrozenCounterEvents(reporter: CheckReporter, points: Seq[GenIndexedPoint[CounterPoint]]): Option[DeferredCheck] = { + verifyLatestEvents(reporter, points, "frozen counter", trackingDatabase.popFrozenCounterEvent, checkFrozenCounter) + None + } + override def reinstallPoints(reporter: PluginReporter): Unit = { config = config.copy( - testDatabaseConfig = config.testDatabaseConfig.copy(disableBinaryInputs = false, disableDoubleBitBinaryInputs = false, disableCounters = false), + testDatabaseConfig = config.testDatabaseConfig.copy(disableBinaryInputs = false, disableDoubleBitBinaryInputs = false, disableCounters = false, disableFrozenCounters = false, disableAnalogInputs = false), commandHandlerConfig = CommandHandlerConfig(disableBinaryOutput = false, disableAnalogOutput = false) ) reporter.log("All points were reinstalled") @@ -804,6 +870,8 @@ class Dnp3IntegrationPlugin extends IntegrationPlugin { // Create config val app = new CustomOutstationApplication + app.coldRestartSupported = coldRestartSupported + this.app = app val information = new CustomOutstationInformation this.controlHandler = new QueuedControlHandler(config.commandHandlerConfig.disableBinaryOutput, config.commandHandlerConfig.disableAnalogOutput) val listener = new CustomConnectionStateListener @@ -831,7 +899,7 @@ class Dnp3IntegrationPlugin extends IntegrationPlugin { this.outstation = server.addOutstation(dnp3Config, app, information, controlHandler, listener, AddressFilter.any()) // Create the database - this.trackingDatabase = new TrackingDatabase(app, this.outstation, config.testDatabaseConfig) + this.trackingDatabase = new TrackingDatabase(app, this.outstation, config.testDatabaseConfig, this.controlHandler) app.onWriteStringAttribute = trackingDatabase.writeStringAttribute // Start the server diff --git a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/OutstationConfig.scala b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/OutstationConfig.scala index 5905b803d..a8d703c55 100644 --- a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/OutstationConfig.scala +++ b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/OutstationConfig.scala @@ -10,7 +10,7 @@ case class OutstationConfig(confirmTimeoutMs: Int, selectTimeoutMs: Int, fragmen // retryDelayMs: the wait after a failed unsolicited response series before starting another case class UnsolicitedResponseConfig(allowUnsolicited: Boolean, retryDelayMs: Int, maxNumRetries: Option[Int]) -case class TestDatabaseConfig(disableBinaryInputs: Boolean, disableDoubleBitBinaryInputs: Boolean, disableCounters: Boolean, isGlobalLocalControl: Boolean, localControlIndex: Option[Int]) +case class TestDatabaseConfig(disableBinaryInputs: Boolean, disableDoubleBitBinaryInputs: Boolean, disableCounters: Boolean, isGlobalLocalControl: Boolean, localControlIndex: Option[Int], disableFrozenCounters: Boolean = false, disableAnalogInputs: Boolean = false) case class CommandHandlerConfig(disableBinaryOutput: Boolean, disableAnalogOutput: Boolean) diff --git a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/QueuedControlHandler.scala b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/QueuedControlHandler.scala index d8169d027..5b0f08dc4 100644 --- a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/QueuedControlHandler.scala +++ b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/QueuedControlHandler.scala @@ -10,6 +10,8 @@ class QueuedControlHandler(val binaryOutputsDisabled: Boolean, val analogOutputs val waitTime = 1000 private val binaryOutputCommands = new ConcurrentLinkedDeque[Integer]() + // the binary outputs in local mode, which a command gets LOCAL (7) from (TB2017-001's 8.6.4.2 and 8.6.4.3, 3.1r1's 8.6.4) + @volatile var localOutputs: Set[Int] = Set.empty private val analogOutputCommands = new ConcurrentLinkedDeque[Integer]() override def beginFragment(): Unit = {} @@ -112,6 +114,7 @@ class QueuedControlHandler(val binaryOutputsDisabled: Boolean, val analogOutputs private def checkBinaryOutputCommand(crob: Group12Var1, index: UShort, operate: Boolean) : CommandStatus = { if (binaryOutputsDisabled) return CommandStatus.NOT_SUPPORTED + if (localOutputs.contains(index.intValue())) return CommandStatus.LOCAL val result = index.intValue() match { case 0 => @@ -240,7 +243,8 @@ class QueuedControlHandler(val binaryOutputsDisabled: Boolean, val analogOutputs case 0 => if(value == 10.0) CommandStatus.SUCCESS else CommandStatus.FORMAT_ERROR case 1 => if(value == 20.0) CommandStatus.SUCCESS else CommandStatus.FORMAT_ERROR - case i if i.intValue() >= 10 && i.intValue() <= 19 => if(value == 10.0) CommandStatus.SUCCESS else CommandStatus.FORMAT_ERROR + // or a value only 32 bits hold, as 3.1r1's 8.4.1.2.13 commands + case i if i.intValue() >= 10 && i.intValue() <= 19 => if(value == 10.0 || value == 70000.0) CommandStatus.SUCCESS else CommandStatus.FORMAT_ERROR case _ => CommandStatus.NOT_SUPPORTED } diff --git a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/TrackingDatabase.scala b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/TrackingDatabase.scala index 16279d269..e42238ac2 100644 --- a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/TrackingDatabase.scala +++ b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/TrackingDatabase.scala @@ -1,5 +1,6 @@ package io.stepfunc.conformance.dnp3 +import io.stepfunc.dnp3c.dnp3.PointKind import io.stepfunc.dnp3c.dnp3.app.EventClass import io.stepfunc.dnp3c.dnp3.app.objects.AttributeValue import io.stepfunc.dnp3._ @@ -40,12 +41,14 @@ class TypedEvent[T](val idx: Int, val batch: Int, val eventClass: EventClass, va } } -class TrackingDatabase(val app: CustomOutstationApplication, val outstation: Outstation, testDatabaseConfig: TestDatabaseConfig) { +class TrackingDatabase(val app: CustomOutstationApplication, val outstation: Outstation, testDatabaseConfig: TestDatabaseConfig, controlHandler: QueuedControlHandler) { // Create all the tracking points private val binaryPoints = mutable.SortedMap((for (i <- 0 to 9) yield i -> new BinaryInput(ushort(i), false, new Flags(ubyte(0x01)), Timestamp.invalidTimestamp())): _*) private val doubleBitPoints = mutable.SortedMap((for (i <- 0 to 9) yield i -> new DoubleBitBinaryInput(ushort(i), DoubleBit.DETERMINED_OFF, new Flags(ubyte(0x41)), Timestamp.invalidTimestamp())): _*) private val counters = mutable.SortedMap((for (i <- 0 to 9) yield i -> new Counter(ushort(i), uint(0), new Flags(ubyte(0x01)), Timestamp.invalidTimestamp())): _*) private val analogInputs = mutable.SortedMap((for (i <- 0 to 9) yield i -> new AnalogInput(ushort(i), 0.0, new Flags(ubyte(0x01)), Timestamp.invalidTimestamp())): _*) + // the frozen counters as the database starts them, changed only by generateFrozenCounterEvent and setFlags + private val frozenCounters = mutable.SortedMap((for (i <- 0 to 9) yield i -> new FrozenCounter(ushort(i), uint(0), new Flags(ubyte(0x01)), Timestamp.invalidTimestamp())): _*) private val binaryOutputs = mutable.SortedMap((for (i <- 0 to 19) yield i -> new BinaryOutputStatus(ushort(i), false, new Flags(ubyte(0x01)), Timestamp.invalidTimestamp())): _*) private val analogOutputs = mutable.SortedMap((for (i <- 0 to 19) yield i -> new AnalogOutputStatus(ushort(i), 0.0, new Flags(ubyte(0x01)), Timestamp.invalidTimestamp())): _*) @@ -109,13 +112,15 @@ class TrackingDatabase(val app: CustomOutstationApplication, val outstation: Out db.addCounter(e.index, io.stepfunc.dnp3.EventClass.CLASS2, counterConfig) db.updateCounter(e, updateOptions) - val frozenCounter = new FrozenCounter(e.index, e.value, e.flags, e.time) - db.addFrozenCounter(e.index, io.stepfunc.dnp3.EventClass.CLASS1, frozenCounterConfig) - db.updateFrozenCounter(frozenCounter, updateOptions) + if (!testDatabaseConfig.disableFrozenCounters) { + val frozenCounter = new FrozenCounter(e.index, e.value, e.flags, e.time) + db.addFrozenCounter(e.index, io.stepfunc.dnp3.EventClass.CLASS1, frozenCounterConfig) + db.updateFrozenCounter(frozenCounter, updateOptions) + } }) } - this.analogInputs.values.foreach(e => { + if (!testDatabaseConfig.disableAnalogInputs) this.analogInputs.values.foreach(e => { val aiConfig = new AnalogInputConfig() aiConfig.staticVariation = StaticAnalogInputVariation.GROUP30_VAR2 @@ -146,10 +151,13 @@ class TrackingDatabase(val app: CustomOutstationApplication, val outstation: Out // Places binary outputs in the local state, like flipping a switch on the device def setLocalControl(indices: Iterable[Int]): Unit = { app.isLocalControl = true + controlHandler.localOutputs = controlHandler.localOutputs ++ indices outstation.transaction(db => { indices.foreach(idx => { val point = binaryOutputs(idx) - val newPoint = new BinaryOutputStatus(point.index, point.value, new Flags(ubyte(0x11)), point.time) + // ONLINE cleared, the state and every other flag kept: LOCAL_FORCED means a value forced locally, not local mode + // (IEEE 1815-2012 Table 11-5; 3.1r1's 8.6.4 step 1.9) + val newPoint = new BinaryOutputStatus(point.index, point.value, new Flags(ubyte(point.flags.value.intValue() & ~0x01)), point.time) binaryOutputs(idx) = newPoint db.updateBinaryOutputStatus(newPoint, UpdateOptions.noEvent()) }) @@ -159,10 +167,11 @@ class TrackingDatabase(val app: CustomOutstationApplication, val outstation: Out // Puts every binary output back under remote control def clearLocalControl(): Unit = { app.isLocalControl = false + controlHandler.localOutputs = Set.empty outstation.transaction(db => { binaryOutputs.keys.toList.foreach(idx => { val point = binaryOutputs(idx) - val newPoint = new BinaryOutputStatus(point.index, point.value, new Flags(ubyte(0x01)), point.time) + val newPoint = new BinaryOutputStatus(point.index, point.value, new Flags(ubyte(point.flags.value.intValue() | 0x01)), point.time) binaryOutputs(idx) = newPoint db.updateBinaryOutputStatus(newPoint, UpdateOptions.noEvent()) }) @@ -240,6 +249,103 @@ class TrackingDatabase(val app: CustomOutstationApplication, val outstation: Out newEvent } + // a frozen counter event: the frozen value goes up by one (3.1r1's 8.25) + def generateFrozenCounterEvent(idx: Int, eventBatch: Int): TypedEvent[FrozenCounter] = { + val value = frozenCounters(idx).value.longValue() + 1 + val newPoint = new FrozenCounter(ushort(idx), uint(value), new Flags(ubyte(0x01)), app.now()) + + this.frozenCounters(idx) = newPoint + val newEvent = new TypedEvent[FrozenCounter](idx, eventBatch, EventClass.Class1, newPoint) + this.events.append(newEvent) + + outstation.transaction(db => { + db.updateFrozenCounter(newPoint, UpdateOptions.detectEvent()) + }) + + newEvent + } + + // an analog input set to a value, with the event that makes (3.1r1's 8.19.2.4) + def setAnalogInputValue(idx: Int, value: Double, eventBatch: Int): TypedEvent[AnalogInput] = { + val newPoint = new AnalogInput(ushort(idx), value, new Flags(ubyte(0x01)), app.now()) + + this.analogInputs(idx) = newPoint + val newEvent = new TypedEvent[AnalogInput](idx, eventBatch, EventClass.Class3, newPoint) + this.events.append(newEvent) + + outstation.transaction(db => { + db.updateAnalogInput(newPoint, UpdateOptions.detectEvent()) + }) + + newEvent + } + + // Sets the flag bits of the points of a kind, all of them or those given, keeping each point's state and value, without + // events (3.1r1's 8.24 and 8.25). The state bits of binary points are kept in their flags octet. + def setFlags(kind: PointKind, indices: Option[Seq[Int]], bits: Int): Unit = { + def state(on: Boolean): Int = if (on) 0x80 else 0x00 + def doubleBitState(value: DoubleBit): Int = value match { + case DoubleBit.INTERMEDIATE => 0x00 + case DoubleBit.DETERMINED_OFF => 0x40 + case DoubleBit.DETERMINED_ON => 0x80 + case _ => 0xC0 + } + def flags(b: Int): Flags = new Flags(ubyte(b)) + def which[T](points: mutable.SortedMap[Int, T]): Seq[Int] = indices.getOrElse(points.keys.toSeq) + outstation.transaction(db => { + kind match { + case PointKind.BinaryInput => which(binaryPoints).foreach { i => + val p = binaryPoints(i) + binaryPoints(i) = new BinaryInput(p.index, p.value, flags(bits | state(p.value)), p.time) + db.updateBinaryInput(binaryPoints(i), UpdateOptions.noEvent()) + } + case PointKind.DoubleBitInput => which(doubleBitPoints).foreach { i => + val p = doubleBitPoints(i) + doubleBitPoints(i) = new DoubleBitBinaryInput(p.index, p.value, flags(bits | doubleBitState(p.value)), p.time) + db.updateDoubleBitBinaryInput(doubleBitPoints(i), UpdateOptions.noEvent()) + } + case PointKind.BinaryOutputStatus => which(binaryOutputs).foreach { i => + val p = binaryOutputs(i) + binaryOutputs(i) = new BinaryOutputStatus(p.index, p.value, flags(bits | state(p.value)), p.time) + db.updateBinaryOutputStatus(binaryOutputs(i), UpdateOptions.noEvent()) + } + case PointKind.Counter => which(counters).foreach { i => + val p = counters(i) + counters(i) = new Counter(p.index, p.value, flags(bits), p.time) + db.updateCounter(counters(i), UpdateOptions.noEvent()) + } + case PointKind.FrozenCounter => which(frozenCounters).foreach { i => + val p = frozenCounters(i) + frozenCounters(i) = new FrozenCounter(p.index, p.value, flags(bits), p.time) + db.updateFrozenCounter(frozenCounters(i), UpdateOptions.noEvent()) + } + case PointKind.AnalogInput => which(analogInputs).foreach { i => + val p = analogInputs(i) + analogInputs(i) = new AnalogInput(p.index, p.value, flags(bits), p.time) + db.updateAnalogInput(analogInputs(i), UpdateOptions.noEvent()) + } + case PointKind.AnalogOutputStatus => which(analogOutputs).foreach { i => + val p = analogOutputs(i) + analogOutputs(i) = new AnalogOutputStatus(p.index, p.value, flags(bits), p.time) + db.updateAnalogOutputStatus(analogOutputs(i), UpdateOptions.noEvent()) + } + } + }) + } + + def getAllFrozenCounterEvents(batch: BatchSpecifier): Seq[TypedEvent[FrozenCounter]] = { + getAllEvents(batch).flatMap { + case event @ TypedEvent(_: FrozenCounter) => Some(event.asInstanceOf[TypedEvent[FrozenCounter]]) + case _ => None + } + } + + def popFrozenCounterEvent(batch: BatchSpecifier): Option[TypedEvent[FrozenCounter]] = { + val head = getAllFrozenCounterEvents(batch).find(e => !e.isHandled) + head.foreach(_.handle()) + head + } + def resetHandledEvents(): Unit = { this.events.foreach(e => e.reset()) } @@ -249,7 +355,7 @@ class TrackingDatabase(val app: CustomOutstationApplication, val outstation: Out getAllBinaryInputs.map(e => e.asInstanceOf[Event]).toList ::: (if (!isClass0) getAllDoubleBitBinaryInputs.map(_.asInstanceOf[Event]).toList else Nil) ::: getAllCounters.map(_.asInstanceOf[Event]).toList ::: - getAllCounters.map(e => new TypedEvent[FrozenCounter](e.idx, e.batch, EventClass.All, new FrozenCounter(e.value.index, uint(0), e.value.flags, e.value.time)).asInstanceOf[Event]).toList ::: + this.frozenCounters.map(e => new TypedEvent[FrozenCounter](e._1, 0, EventClass.All, e._2).asInstanceOf[Event]).toList ::: getAllAnalogInputs.map(_.asInstanceOf[Event]).toList ::: this.binaryOutputs.map(e => new TypedEvent[BinaryOutputStatus](e._1, 0, EventClass.All, e._2).asInstanceOf[Event]).toList ::: this.analogOutputs.map(e => new TypedEvent[AnalogOutputStatus](e._1, 0, EventClass.All, e._2).asInstanceOf[Event]).toList From 42b15e0eeea09a4a9327dbb4fde89276a8718049 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Wed, 14 Oct 2026 12:24:59 -0700 Subject: [PATCH 02/16] Link the known 3.1r1 gaps to their issues --- conformance/device.ini | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/conformance/device.ini b/conformance/device.ini index 727b9791b..1dc67b200 100644 --- a/conformance/device.ini +++ b/conformance/device.ini @@ -446,12 +446,12 @@ outstation.subset.level = 3 # 3.1r1, subset level 3 # # Tests that fail on gaps in the dnp3 outstation, which no option turns off: -# - a read of a type whose points are all uninstalled gets a null response, without IIN2.1 or IIN2.2: 8.13.2-4, +# - a read of a type whose points are all uninstalled gets a null response, without IIN2.1 or IIN2.2 (#478): 8.13.2-4, # 8.16.1.2.4, 8.21.1, 8.21-8.13.2-4, and 8.23.2.x.1 -# - a range read that runs past the last point gets the points there are, without IIN2.2: 8.23.2.x.3 +# - a range read that runs past the last point gets the points there are, without IIN2.2 (#260): 8.23.2.x.3 # - a static read of a variation without flags reports a point whose flags aren't normal in that variation, rather -# than one with flags: 8.24.2-4 to 8.24.2-6 -# - Assign Class (function code 22) isn't supported: 8.26.2, which also needs binary inputs in class 2 +# than one with flags (#479): 8.24.2-4 to 8.24.2-6 +# - Assign Class (function code 22) isn't supported (#480): 8.26.2, which also needs binary inputs in class 2 # Serial (8.10.2, 8.12.2, 6.6.2-4) and network (section 9) tests are off; so are output events (section 10). binaryInput.defaultEventVariation = 2 doubleBitInput.defaultEventVariation = 1 From 33a9cfa02e73388ac805532a47cf469997cb8040 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Wed, 14 Oct 2026 15:28:01 -0700 Subject: [PATCH 03/16] Update the conformance plugin and configuration to dnp3c's current API The plugin implements IntegrationPlugin's kind-based methods: uninstall, generatePattern, checkOperate and checkNotOperate, in place of the per-type ones, with PointKind in place of InputKind. The configuration uses dnp3c's renamed keys (binaryOutput.* for control.*, one pattern for the input groups' event options, supports.doubleBitInput* and supports.analogInput*), and drops supports.noClassAssignment, an option dnp3c removed. --- conformance/device.ini | 122 +++++++------ conformance/device.properties | 122 +++++++------ .../dnp3/Dnp3rsIntegrationPlugin.scala | 163 +++++++----------- 3 files changed, 184 insertions(+), 223 deletions(-) diff --git a/conformance/device.ini b/conformance/device.ini index 1dc67b200..e1a25227e 100644 --- a/conformance/device.ini +++ b/conformance/device.ini @@ -55,139 +55,139 @@ outstation.fragmentSize = 2048 ################################################################################ # Index not assigned to any control point -control.uninstalled.index = 65535 +binaryOutput.uninstalled.index = 65535 # Maximum number of control points that are supported in a single request min: 1 -control.maxControlPointsInSingleRequest = 4 +binaryOutput.maxPointsInSingleRequest = 4 # List of all installed BO indices -control.installedPoints = 10 ... 19 +binaryOutput.installedPoints = 10 ... 19 # Index of a supported Group12Var1 -control.primary.valid.index = 0 +binaryOutput.primary.valid.index = 0 # Valid control function in hexadecimal, one of: 0x01, 0x03, 0x04, 0x41, 0x81 -control.primary.valid.function = 0x03 +binaryOutput.primary.valid.function = 0x03 # Valid count for Group12Var1 min: 1 max: 255 -control.primary.valid.count = 1 +binaryOutput.primary.valid.count = 1 # On time in ms min: 0 max: 65535 -control.primary.valid.onTime.ms = 100 +binaryOutput.primary.valid.onTime.ms = 100 # Off time in ms min: 0 max: 65535 -control.primary.valid.offTime.ms = 200 +binaryOutput.primary.valid.offTime.ms = 200 # Index of a supported Group12Var1 -control.alternate.valid.index = 1 +binaryOutput.alternate.valid.index = 1 # Valid control function in hexadecimal, one of: 0x01, 0x03, 0x04, 0x41, 0x81 -control.alternate.valid.function = 0x81 +binaryOutput.alternate.valid.function = 0x81 # Valid count for Group12Var1 min: 1 max: 255 -control.alternate.valid.count = 5 +binaryOutput.alternate.valid.count = 5 # On time in ms min: 0 max: 65535 -control.alternate.valid.onTime.ms = 300 +binaryOutput.alternate.valid.onTime.ms = 300 # Off time in ms min: 0 max: 65535 -control.alternate.valid.offTime.ms = 400 +binaryOutput.alternate.valid.offTime.ms = 400 # Optional control point supporting complementary function codes Latch On/NUL (0x03) and Latch Off/NUL (0x04). -control.latchOnOffPoint.index = 2 +binaryOutput.latchOnOffPoint.index = 2 # Valid count for Group12Var1 min: 1 max: 255 -control.latchOnOffPoint.count = 10 +binaryOutput.latchOnOffPoint.count = 10 # On time in ms min: 0 max: 65535 -control.latchOnOffPoint.onTime.ms = 500 +binaryOutput.latchOnOffPoint.onTime.ms = 500 # Off time in ms min: 0 max: 65535 -control.latchOnOffPoint.offTime.ms = 600 +binaryOutput.latchOnOffPoint.offTime.ms = 600 # Optional control point supporting complementary function codes Pulse On/Close (0x41) and Pulse On/Trip (0x81). -control.pulseOnOffPoint.index = 3 +binaryOutput.pulseOnOffPoint.index = 3 # Valid count for Group12Var1 min: 1 max: 255 -control.pulseOnOffPoint.count = 15 +binaryOutput.pulseOnOffPoint.count = 15 # On time in ms min: 0 max: 65535 -control.pulseOnOffPoint.onTime.ms = 700 +binaryOutput.pulseOnOffPoint.onTime.ms = 700 # Off time in ms min: 0 max: 65535 -control.pulseOnOffPoint.offTime.ms = 800 +binaryOutput.pulseOnOffPoint.offTime.ms = 800 # Optional control point supporting complementary function codes 0x03, 0x04, 0x41 and 0x81. -control.latchAndPulsePoint.index = 4 +binaryOutput.latchAndPulsePoint.index = 4 # Valid count for Group12Var1 min: 1 max: 255 -control.latchAndPulsePoint.count = 20 +binaryOutput.latchAndPulsePoint.count = 20 # On time in ms min: 0 max: 65535 -control.latchAndPulsePoint.onTime.ms = 900 +binaryOutput.latchAndPulsePoint.onTime.ms = 900 # Off time in ms min: 0 max: 65535 -control.latchAndPulsePoint.offTime.ms = 1000 +binaryOutput.latchAndPulsePoint.offTime.ms = 1000 # Optional single function point supported function code Pulse On/NUL (0x01). -control.singleFunctionPulseOnPoint.index = 5 +binaryOutput.singleFunctionPulseOnPoint.index = 5 # Valid count for Group12Var1 min: 1 max: 255 -control.singleFunctionPulseOnPoint.count = 25 +binaryOutput.singleFunctionPulseOnPoint.count = 25 # On time in ms min: 0 max: 65535 -control.singleFunctionPulseOnPoint.onTime.ms = 1100 +binaryOutput.singleFunctionPulseOnPoint.onTime.ms = 1100 # Off time in ms min: 0 max: 65535 -control.singleFunctionPulseOnPoint.offTime.ms = 1200 +binaryOutput.singleFunctionPulseOnPoint.offTime.ms = 1200 # Optional single function point supported function code Latch On/NUL (0x03). -control.singleFunctionLatchOnPoint.index = 6 +binaryOutput.singleFunctionLatchOnPoint.index = 6 # Valid count for Group12Var1 min: 1 max: 255 -control.singleFunctionLatchOnPoint.count = 30 +binaryOutput.singleFunctionLatchOnPoint.count = 30 # On time in ms min: 0 max: 65535 -control.singleFunctionLatchOnPoint.onTime.ms = 1300 +binaryOutput.singleFunctionLatchOnPoint.onTime.ms = 1300 # Off time in ms min: 0 max: 65535 -control.singleFunctionLatchOnPoint.offTime.ms = 1400 +binaryOutput.singleFunctionLatchOnPoint.offTime.ms = 1400 # Optional single function point supported function code Latch Off/NUL (0x04). -control.singleFunctionLatchOffPoint.index = 7 +binaryOutput.singleFunctionLatchOffPoint.index = 7 # Valid count for Group12Var1 min: 1 max: 255 -control.singleFunctionLatchOffPoint.count = 35 +binaryOutput.singleFunctionLatchOffPoint.count = 35 # On time in ms min: 0 max: 65535 -control.singleFunctionLatchOffPoint.onTime.ms = 1500 +binaryOutput.singleFunctionLatchOffPoint.onTime.ms = 1500 # Off time in ms min: 0 max: 65535 -control.singleFunctionLatchOffPoint.offTime.ms = 1600 +binaryOutput.singleFunctionLatchOffPoint.offTime.ms = 1600 # Optional single function point supported function code Pulse On/Close (0x41). -control.singleFunctionPulseOnClosePoint.index = 8 +binaryOutput.singleFunctionPulseOnClosePoint.index = 8 # Valid count for Group12Var1 min: 1 max: 255 -control.singleFunctionPulseOnClosePoint.count = 40 +binaryOutput.singleFunctionPulseOnClosePoint.count = 40 # On time in ms min: 0 max: 65535 -control.singleFunctionPulseOnClosePoint.onTime.ms = 1700 +binaryOutput.singleFunctionPulseOnClosePoint.onTime.ms = 1700 # Off time in ms min: 0 max: 65535 -control.singleFunctionPulseOnClosePoint.offTime.ms = 1800 +binaryOutput.singleFunctionPulseOnClosePoint.offTime.ms = 1800 # Optional single function point supported function code Pulse On/Trip (0x81). -control.singleFunctionPulseOnTripPoint.index = 9 +binaryOutput.singleFunctionPulseOnTripPoint.index = 9 # Valid count for Group12Var1 min: 1 max: 255 -control.singleFunctionPulseOnTripPoint.count = 45 +binaryOutput.singleFunctionPulseOnTripPoint.count = 45 # On time in ms min: 0 max: 65535 -control.singleFunctionPulseOnTripPoint.onTime.ms = 1900 +binaryOutput.singleFunctionPulseOnTripPoint.onTime.ms = 1900 # Off time in ms min: 0 max: 65535 -control.singleFunctionPulseOnTripPoint.offTime.ms = 2000 +binaryOutput.singleFunctionPulseOnTripPoint.offTime.ms = 2000 ################################################################################ # Options related to analog outputs (g41) @@ -200,7 +200,7 @@ analogOutput.uninstalled.index = 65535 analogOutput.installedPoints = 10 ... 19 # Maximum number of analog outputs that are supported in a single request min: 1 -analogOutput.maxAnalogOutputsInSingleRequest = 4 +analogOutput.maxPointsInSingleRequest = 4 # Index of a supported Group41Var2 analogOutput.primary.valid.index = 0 @@ -250,7 +250,7 @@ event.buffer.maxAnalogInputEvents = 200 event.buffer.maxCounterEvents = 200 # Maximum number of double-bit binary input events min: 1 -event.buffer.maxDoubleBitBinaryInputEvents = 200 +event.buffer.maxDoubleBitInputEvents = 200 ################################################################################ # Behaviors that can vary between devices, but still remain conformant @@ -293,37 +293,37 @@ flag.tests.individualRemoteSupervisoryOutputIndex = 0 ################################################################################ # Number of Binary Input Events that will be generated manually. min: 1 -binaryInput.numBinaryEventsGenerated = 10 +binaryInput.numEventsGenerated = 10 # Binary Input Events class assignment. min: 1 max: 3 -binaryInput.binaryEventsClass = 1 +binaryInput.eventsClass = 1 ################################################################################ # Options related to Double-bit inputs (g3 and g4) ################################################################################ # Number of Double-bit Input Events that will be generated manually. min: 1 -doubleBitInput.numDoubleBitEventsGenerated = 10 +doubleBitInput.numEventsGenerated = 10 # Double-bit Input Events class assignment. min: 1 max: 3 -doubleBitInput.doubleBitEventsClass = 1 +doubleBitInput.eventsClass = 1 ################################################################################ # Options related to counters (g20 and g22) ################################################################################ # Number of Counter Events that will be generated manually. min: 1 -counter.numCounterEventsGenerated = 10 +counter.numEventsGenerated = 10 # Counter Events class assignment. min: 1 max: 3 -counter.counterEventsClass = 2 +counter.eventsClass = 2 ################################################################################ # Options related to analog inputs (g30 and g32) ################################################################################ # Analog Input Events class assignment. min: 1 max: 3 -analogInput.analogInputEventsClass = 3 +analogInput.eventsClass = 3 ################################################################################ # Unsolicited response options @@ -355,13 +355,13 @@ supports.disable.binaryInput = true supports.binaryInputChangeEvents = true # Does the DUT support Double Bit points? -supports.doubleBit = true +supports.doubleBitInput = true # Is the device is configurable in a manner such that all Double Bit points can be uninstalled or disabled? -supports.disable.doubleBit = true +supports.disable.doubleBitInput = true # Does the DUT support Double Bit change events? -supports.doubleBitChangeEvents = true +supports.doubleBitInputChangeEvents = true # Does the DUT support Counter points? supports.counter = true @@ -382,13 +382,13 @@ supports.disable.frozenCounter = true supports.frozenCounterChangeEvents = true # Does the DUT support Analog Input points? -supports.analog = true +supports.analogInput = true # Is the device is configurable in a manner such that all Analog Input points can be uninstalled or disabled? -supports.disable.analog = true +supports.disable.analogInput = true # Does the DUT support Analog Input change events? -supports.analogChangeEvents = true +supports.analogInputChangeEvents = true # Does the DUT support Binary Output status points? supports.binaryOutput.status = true @@ -414,8 +414,6 @@ supports.dataLinkConfirmRequests = false # Does the the DUT link layer self addressing? supports.selfAddress = true -# Is the DUT configurable such that points can be removed from all Classes (i.e. points belong to no Class)? -supports.noClassAssignment = true # Does the DUT support placing its binary outputs in a local state? supports.globalRemoteSupervisoryControl = true diff --git a/conformance/device.properties b/conformance/device.properties index b1b03b904..b1f8c2c71 100644 --- a/conformance/device.properties +++ b/conformance/device.properties @@ -52,139 +52,139 @@ outstation.fragmentSize = 2048 ################################################################################ # Index not assigned to any control point -control.uninstalled.index = 65535 +binaryOutput.uninstalled.index = 65535 # Maximum number of control points that are supported in a single request min: 1 -control.maxControlPointsInSingleRequest = 1 +binaryOutput.maxPointsInSingleRequest = 1 # List of all installed BO indices -control.installedPoints = 0 ... 9 +binaryOutput.installedPoints = 0 ... 9 # Index of a supported Group12Var1 -control.primary.valid.index = 0 +binaryOutput.primary.valid.index = 0 # Valid control function in hexadecimal from set HashSet(04, 03, 81, 41, 01) -control.primary.valid.function = 3 +binaryOutput.primary.valid.function = 3 # Valid count for Group12Var1 min: 1 max: 255 -control.primary.valid.count = 1 +binaryOutput.primary.valid.count = 1 # On time in ms min: 0 max: 65535 -control.primary.valid.onTime.ms = 100 +binaryOutput.primary.valid.onTime.ms = 100 # Off time in ms min: 0 max: 65535 -control.primary.valid.offTime.ms = 100 +binaryOutput.primary.valid.offTime.ms = 100 # Index of a supported Group12Var1 -control.alternate.valid.index = 1 +binaryOutput.alternate.valid.index = 1 # Valid control function in hexadecimal from set HashSet(04, 03, 81, 41, 01) -control.alternate.valid.function = 3 +binaryOutput.alternate.valid.function = 3 # Valid count for Group12Var1 min: 1 max: 255 -control.alternate.valid.count = 1 +binaryOutput.alternate.valid.count = 1 # On time in ms min: 0 max: 65535 -control.alternate.valid.onTime.ms = 100 +binaryOutput.alternate.valid.onTime.ms = 100 # Off time in ms min: 0 max: 65535 -control.alternate.valid.offTime.ms = 100 +binaryOutput.alternate.valid.offTime.ms = 100 # Optional control point supporting complementary function codes Latch On/NUL (0x03) and Latch Off/NUL (0x04). -control.latchOnOffPoint.index = 1 +binaryOutput.latchOnOffPoint.index = 1 # Valid count for Group12Var1 min: 1 max: 255 -control.latchOnOffPoint.count = 1 +binaryOutput.latchOnOffPoint.count = 1 # On time in ms min: 0 max: 65535 -control.latchOnOffPoint.onTime.ms = 100 +binaryOutput.latchOnOffPoint.onTime.ms = 100 # Off time in ms min: 0 max: 65535 -control.latchOnOffPoint.offTime.ms = 100 +binaryOutput.latchOnOffPoint.offTime.ms = 100 # Optional control point supporting complementary function codes Pulse On/Close (0x41) and Pulse On/Trip (0x81). -control.pulseOnOffPoint.index = 1 +binaryOutput.pulseOnOffPoint.index = 1 # Valid count for Group12Var1 min: 1 max: 255 -control.pulseOnOffPoint.count = 1 +binaryOutput.pulseOnOffPoint.count = 1 # On time in ms min: 0 max: 65535 -control.pulseOnOffPoint.onTime.ms = 100 +binaryOutput.pulseOnOffPoint.onTime.ms = 100 # Off time in ms min: 0 max: 65535 -control.pulseOnOffPoint.offTime.ms = 100 +binaryOutput.pulseOnOffPoint.offTime.ms = 100 # Optional control point supporting complementary function codes 0x03, 0x04, 0x41 and 0x81. -control.latchAndPulsePoint.index = 1 +binaryOutput.latchAndPulsePoint.index = 1 # Valid count for Group12Var1 min: 1 max: 255 -control.latchAndPulsePoint.count = 1 +binaryOutput.latchAndPulsePoint.count = 1 # On time in ms min: 0 max: 65535 -control.latchAndPulsePoint.onTime.ms = 100 +binaryOutput.latchAndPulsePoint.onTime.ms = 100 # Off time in ms min: 0 max: 65535 -control.latchAndPulsePoint.offTime.ms = 100 +binaryOutput.latchAndPulsePoint.offTime.ms = 100 # Optional single function point supported function code Pulse On/NUL (0x01). -control.singleFunctionPulseOnPoint.index = 1 +binaryOutput.singleFunctionPulseOnPoint.index = 1 # Valid count for Group12Var1 min: 1 max: 255 -control.singleFunctionPulseOnPoint.count = 1 +binaryOutput.singleFunctionPulseOnPoint.count = 1 # On time in ms min: 0 max: 65535 -control.singleFunctionPulseOnPoint.onTime.ms = 100 +binaryOutput.singleFunctionPulseOnPoint.onTime.ms = 100 # Off time in ms min: 0 max: 65535 -control.singleFunctionPulseOnPoint.offTime.ms = 100 +binaryOutput.singleFunctionPulseOnPoint.offTime.ms = 100 # Optional single function point supported function code Latch On/NUL (0x03). -control.singleFunctionLatchOnPoint.index = 1 +binaryOutput.singleFunctionLatchOnPoint.index = 1 # Valid count for Group12Var1 min: 1 max: 255 -control.singleFunctionLatchOnPoint.count = 1 +binaryOutput.singleFunctionLatchOnPoint.count = 1 # On time in ms min: 0 max: 65535 -control.singleFunctionLatchOnPoint.onTime.ms = 100 +binaryOutput.singleFunctionLatchOnPoint.onTime.ms = 100 # Off time in ms min: 0 max: 65535 -control.singleFunctionLatchOnPoint.offTime.ms = 100 +binaryOutput.singleFunctionLatchOnPoint.offTime.ms = 100 # Optional single function point supported function code Latch Off/NUL (0x04). -control.singleFunctionLatchOffPoint.index = 1 +binaryOutput.singleFunctionLatchOffPoint.index = 1 # Valid count for Group12Var1 min: 1 max: 255 -control.singleFunctionLatchOffPoint.count = 1 +binaryOutput.singleFunctionLatchOffPoint.count = 1 # On time in ms min: 0 max: 65535 -control.singleFunctionLatchOffPoint.onTime.ms = 100 +binaryOutput.singleFunctionLatchOffPoint.onTime.ms = 100 # Off time in ms min: 0 max: 65535 -control.singleFunctionLatchOffPoint.offTime.ms = 100 +binaryOutput.singleFunctionLatchOffPoint.offTime.ms = 100 # Optional single function point supported function code Pulse On/Close (0x41). -control.singleFunctionPulseOnClosePoint.index = 1 +binaryOutput.singleFunctionPulseOnClosePoint.index = 1 # Valid count for Group12Var1 min: 1 max: 255 -control.singleFunctionPulseOnClosePoint.count = 1 +binaryOutput.singleFunctionPulseOnClosePoint.count = 1 # On time in ms min: 0 max: 65535 -control.singleFunctionPulseOnClosePoint.onTime.ms = 100 +binaryOutput.singleFunctionPulseOnClosePoint.onTime.ms = 100 # Off time in ms min: 0 max: 65535 -control.singleFunctionPulseOnClosePoint.offTime.ms = 100 +binaryOutput.singleFunctionPulseOnClosePoint.offTime.ms = 100 # Optional single function point supported function code Pulse On/Trip (0x81). -control.singleFunctionPulseOnTripPoint.index = 1 +binaryOutput.singleFunctionPulseOnTripPoint.index = 1 # Valid count for Group12Var1 min: 1 max: 255 -control.singleFunctionPulseOnTripPoint.count = 1 +binaryOutput.singleFunctionPulseOnTripPoint.count = 1 # On time in ms min: 0 max: 65535 -control.singleFunctionPulseOnTripPoint.onTime.ms = 100 +binaryOutput.singleFunctionPulseOnTripPoint.onTime.ms = 100 # Off time in ms min: 0 max: 65535 -control.singleFunctionPulseOnTripPoint.offTime.ms = 100 +binaryOutput.singleFunctionPulseOnTripPoint.offTime.ms = 100 ################################################################################ # Options related to analog outputs (g41) @@ -197,7 +197,7 @@ analogOutput.uninstalled.index = 65535 analogOutput.installedPoints = 0 ... 9 # Maximum number of analog outputs that are supported in a single request min: 1 -analogOutput.maxAnalogOutputsInSingleRequest = 1 +analogOutput.maxPointsInSingleRequest = 1 # Index of a supported Group41Var2 analogOutput.primary.valid.index = 0 @@ -247,7 +247,7 @@ event.buffer.maxAnalogInputEvents = 10 event.buffer.maxCounterEvents = 10 # Maximum number of double-bit binary input events min: 1 -event.buffer.maxDoubleBitBinaryInputEvents = 10 +event.buffer.maxDoubleBitInputEvents = 10 ################################################################################ # Behaviors that can vary between devices, but still remain conformant @@ -287,37 +287,37 @@ flag.tests.individualRemoteSupervisoryOutputIndex = 0 ################################################################################ # Number of Binary Input Events that will be generated manually. min: 1 -binaryInput.numBinaryEventsGenerated = 1 +binaryInput.numEventsGenerated = 1 # Binary Input Events class assignment. min: 1 max: 3 -binaryInput.binaryEventsClass = 1 +binaryInput.eventsClass = 1 ################################################################################ # Options related to Double-bit inputs (g3 and g4) ################################################################################ # Number of Double-bit Input Events that will be generated manually. min: 1 -doubleBitInput.numDoubleBitEventsGenerated = 1 +doubleBitInput.numEventsGenerated = 1 # Double-bit Input Events class assignment. min: 1 max: 3 -doubleBitInput.doubleBitEventsClass = 1 +doubleBitInput.eventsClass = 1 ################################################################################ # Options related to counters (g20 and g22) ################################################################################ # Number of Counter Events that will be generated manually. min: 1 -counter.numCounterEventsGenerated = 1 +counter.numEventsGenerated = 1 # Counter Events class assignment. min: 1 max: 3 -counter.counterEventsClass = 1 +counter.eventsClass = 1 ################################################################################ # Options related to analog inputs (g30 and g32) ################################################################################ # Analog Input Events class assignment. min: 1 max: 3 -analogInput.analogInputEventsClass = 1 +analogInput.eventsClass = 1 ################################################################################ # Unsolicited response options @@ -346,13 +346,13 @@ supports.disable.binaryInput = true supports.binaryInputChangeEvents = true # Does the DUT support Double Bit points? -supports.doubleBit = true +supports.doubleBitInput = true # Is the device is configurable in a manner such that all Double Bit points can be uninstalled or disabled? -supports.disable.doubleBit = true +supports.disable.doubleBitInput = true # Does the DUT support Double Bit change events? -supports.doubleBitChangeEvents = true +supports.doubleBitInputChangeEvents = true # Does the DUT support Counter points? supports.counter = true @@ -373,13 +373,13 @@ supports.disable.frozenCounter = true supports.frozenCounterChangeEvents = true # Does the DUT support Analog Input points? -supports.analog = true +supports.analogInput = true # Is the device is configurable in a manner such that all Analog Input points can be uninstalled or disabled? -supports.disable.analog = true +supports.disable.analogInput = true # Does the DUT support Analog Input change events? -supports.analogChangeEvents = true +supports.analogInputChangeEvents = true # Does the DUT support Binary Output status points? supports.binaryOutput.status = true @@ -405,8 +405,6 @@ supports.dataLinkConfirmRequests = true # Does the DUT support link-layer self addressing? supports.selfAddress = false -# Is the DUT configurable such that points can be removed from all Classes (i.e. points belong to no Class)? -supports.noClassAssignment = true # Does the DUT support placing its binary outputs in a local state? supports.globalRemoteSupervisoryControl = true diff --git a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/Dnp3rsIntegrationPlugin.scala b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/Dnp3rsIntegrationPlugin.scala index 5cd5c4472..35606dc7a 100644 --- a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/Dnp3rsIntegrationPlugin.scala +++ b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/Dnp3rsIntegrationPlugin.scala @@ -2,7 +2,7 @@ package io.stepfunc.conformance.dnp3 import io.stepfunc.dnp3c.dnp3.app._ import io.stepfunc.dnp3c.dnp3.app.objects.AttributeValue -import io.stepfunc.dnp3c.dnp3.{AbnormalFlag, ActionResult, ActionTiming, CheckReporter, DeferredCheck, EventExpectation, InputKind, IntegrationPlugin, PluginReporter, PointKind} +import io.stepfunc.dnp3c.dnp3.{AbnormalFlag, ActionResult, ActionTiming, CheckReporter, DeferredCheck, EventExpectation, InputKind, IntegrationPlugin, OutputKind, PluginReporter, PointKind} import io.stepfunc.dnp3c.protocol.parsing.UInt8 import io.stepfunc.dnp3.{AddressFilter, AnalogInput, AnalogOutputStatus, BinaryInput, BinaryOutputStatus, Counter, DoubleBit, DoubleBitBinaryInput, EventBufferConfig, FrozenCounter, LinkErrorMode, Outstation, Runtime, RuntimeConfig, OutstationServer} import org.joou.UInteger @@ -52,50 +52,49 @@ class Dnp3IntegrationPlugin extends IntegrationPlugin { cyclePower(reporter) } - override def checkBinaryOutputOperate(reporter: CheckReporter, index: Int): Option[DeferredCheck] = { - controlHandler.checkCrob(index) - reporter.log(f"Binary output $index did operate") - None - } - - override def checkBinaryOutputNotOperate(reporter: CheckReporter): Option[DeferredCheck] = { - controlHandler.checkNoCrob() - reporter.log(f"Binary output did NOT operate") + override def checkOperate(reporter: CheckReporter, kind: OutputKind, index: Int): Option[DeferredCheck] = { + kind match { + case PointKind.BinaryOutputStatus => controlHandler.checkCrob(index) + case PointKind.AnalogOutputStatus => controlHandler.checkAnalog(index) + } + reporter.log(s"${output(kind)} $index did operate") None } - override def checkAnalogOutputOperate(reporter: CheckReporter, index: Int): Option[DeferredCheck] = { - controlHandler.checkAnalog(index) - reporter.log(f"Analog output $index did operate") + override def checkNotOperate(reporter: CheckReporter, kind: OutputKind): Option[DeferredCheck] = { + kind match { + case PointKind.BinaryOutputStatus => controlHandler.checkNoCrob() + case PointKind.AnalogOutputStatus => controlHandler.checkNoAnalog() + } + reporter.log(s"${output(kind)} did NOT operate") None } - override def checkAnalogOutputNotOperate(reporter: CheckReporter): Option[DeferredCheck] = { - controlHandler.checkNoAnalog() - reporter.log(f"Analog output did NOT operate") - None + private def output(kind: OutputKind): String = kind match { + case PointKind.BinaryOutputStatus => "Binary output" + case PointKind.AnalogOutputStatus => "Analog output" } override def generateClassEvents(reporter: PluginReporter, eventClass: EventClass): Unit = { eventClass match { - case EventClass.Class1 => generateInputChanges(reporter, InputKind.BinaryInput) - case EventClass.Class2 => generateInputChanges(reporter, InputKind.Counter) - case EventClass.Class3 => generateInputChanges(reporter, InputKind.AnalogInput) + case EventClass.Class1 => generateInputChanges(reporter, PointKind.BinaryInput) + case EventClass.Class2 => generateInputChanges(reporter, PointKind.Counter) + case EventClass.Class3 => generateInputChanges(reporter, PointKind.AnalogInput) case _ => { - generateInputChanges(reporter, InputKind.BinaryInput) + generateInputChanges(reporter, PointKind.BinaryInput) this.eventBatch = this.eventBatch - 1 - generateInputChanges(reporter, InputKind.Counter) + generateInputChanges(reporter, PointKind.Counter) this.eventBatch = this.eventBatch - 1 - generateInputChanges(reporter, InputKind.AnalogInput) + generateInputChanges(reporter, PointKind.AnalogInput) } } } override def generateExactClassEvents(reporter: PluginReporter, eventClass: EventClass, numEvents: Int): Unit = { eventClass match { - case EventClass.Class1 => generateExactInputEvents(reporter, InputKind.BinaryInput, numEvents) - case EventClass.Class2 => generateExactInputEvents(reporter, InputKind.Counter, numEvents) - case EventClass.Class3 => generateExactInputEvents(reporter, InputKind.AnalogInput, numEvents) + case EventClass.Class1 => generateExactInputEvents(reporter, PointKind.BinaryInput, numEvents) + case EventClass.Class2 => generateExactInputEvents(reporter, PointKind.Counter, numEvents) + case EventClass.Class3 => generateExactInputEvents(reporter, PointKind.AnalogInput, numEvents) case _ => throw new Exception(s"Cannot generate an exact number of $eventClass events") } } @@ -111,22 +110,14 @@ class Dnp3IntegrationPlugin extends IntegrationPlugin { } private def generateInputEvent(reporter: PluginReporter, kind: InputKind[?], idx: Int): Unit = kind match { - case InputKind.BinaryInput => printBinaryInput(reporter, trackingDatabase.generateBinaryInputEvent(idx, this.eventBatch)) - case InputKind.DoubleBitInput => printDoubleBitBinaryInput(reporter, trackingDatabase.generateDoubleBitBinaryInputEvent(idx, this.eventBatch)) - case InputKind.Counter => printCounter(reporter, trackingDatabase.generateCounterEvent(idx, this.eventBatch)) - case InputKind.AnalogInput => printAnalogInput(reporter, trackingDatabase.generateAnalogInputEvent(idx, this.eventBatch)) + case PointKind.BinaryInput => printBinaryInput(reporter, trackingDatabase.generateBinaryInputEvent(idx, this.eventBatch)) + case PointKind.DoubleBitInput => printDoubleBitBinaryInput(reporter, trackingDatabase.generateDoubleBitBinaryInputEvent(idx, this.eventBatch)) + case PointKind.Counter => printCounter(reporter, trackingDatabase.generateCounterEvent(idx, this.eventBatch)) + case PointKind.AnalogInput => printAnalogInput(reporter, trackingDatabase.generateAnalogInputEvent(idx, this.eventBatch)) } - override def generateBinaryInputPattern(reporter: PluginReporter): Unit = { - generateInputChanges(reporter, InputKind.BinaryInput) - } - - override def generateDoubleBitBinaryInputPattern(reporter: PluginReporter): Unit = { - generateInputChanges(reporter, InputKind.DoubleBitInput) - } - - override def generateAnalogInputPattern(reporter: PluginReporter): Unit = { - generateInputChanges(reporter, InputKind.AnalogInput) + override def generatePattern(reporter: PluginReporter, kind: InputKind[?]): Unit = { + generateInputChanges(reporter, kind) } override def generateTimeEvent(reporter: PluginReporter): Unit = { @@ -135,33 +126,19 @@ class Dnp3IntegrationPlugin extends IntegrationPlugin { printBinaryInput(reporter, event) } - override def uninstallBinaryInputs(reporter: PluginReporter): Unit = { - config = config.copy(testDatabaseConfig = config.testDatabaseConfig.copy(disableBinaryInputs = true)) - reporter.log("All BI points were uninstalled") - cyclePower(reporter) - } - - override def uninstallDoubleBitBinaryInputs(reporter: PluginReporter): Unit = { - config = config.copy(testDatabaseConfig = config.testDatabaseConfig.copy(disableDoubleBitBinaryInputs = true)) - reporter.log("All DBBI points were uninstalled") - cyclePower(reporter) - } - - override def uninstallCounters(reporter: PluginReporter): Unit = { - config = config.copy(testDatabaseConfig = config.testDatabaseConfig.copy(disableCounters = true)) - reporter.log("All counter points were uninstalled") - cyclePower(reporter) - } - - override def uninstallBinaryOutputs(reporter: PluginReporter): Unit = { - config = config.copy(commandHandlerConfig = config.commandHandlerConfig.copy(disableBinaryOutput = true)) - reporter.log("All BO points were uninstalled") - cyclePower(reporter) - } - - override def uninstallAnalogOutputs(reporter: PluginReporter): Unit = { - config = config.copy(commandHandlerConfig = config.commandHandlerConfig.copy(disableAnalogOutput = true)) - reporter.log("All AO points were uninstalled") + override def uninstall(reporter: PluginReporter, kind: PointKind): Unit = { + val database = config.testDatabaseConfig + val commands = config.commandHandlerConfig + config = kind match { + case PointKind.BinaryInput => config.copy(testDatabaseConfig = database.copy(disableBinaryInputs = true)) + case PointKind.DoubleBitInput => config.copy(testDatabaseConfig = database.copy(disableDoubleBitBinaryInputs = true)) + case PointKind.Counter => config.copy(testDatabaseConfig = database.copy(disableCounters = true)) + case PointKind.FrozenCounter => config.copy(testDatabaseConfig = database.copy(disableFrozenCounters = true)) + case PointKind.AnalogInput => config.copy(testDatabaseConfig = database.copy(disableAnalogInputs = true)) + case PointKind.BinaryOutputStatus => config.copy(commandHandlerConfig = commands.copy(disableBinaryOutput = true)) + case PointKind.AnalogOutputStatus => config.copy(commandHandlerConfig = commands.copy(disableAnalogOutput = true)) + } + reporter.log(s"All ${kind.name} points were uninstalled") cyclePower(reporter) } @@ -188,19 +165,7 @@ class Dnp3IntegrationPlugin extends IntegrationPlugin { // the analog input values go up by 100 an event from 0, so they stay below 32768 override def generateExactSmallAnalogInputEvents(reporter: PluginReporter, numEvents: Int): Unit = - generateExactInputEvents(reporter, InputKind.AnalogInput, numEvents) - - override def uninstallFrozenCounters(reporter: PluginReporter): Unit = { - config = config.copy(testDatabaseConfig = config.testDatabaseConfig.copy(disableFrozenCounters = true)) - reporter.log("All frozen counter points were uninstalled") - cyclePower(reporter) - } - - override def uninstallAnalogInputs(reporter: PluginReporter): Unit = { - config = config.copy(testDatabaseConfig = config.testDatabaseConfig.copy(disableAnalogInputs = true)) - reporter.log("All analog input points were uninstalled") - cyclePower(reporter) - } + generateExactInputEvents(reporter, PointKind.AnalogInput, numEvents) // the change of one binary input, the kind 3.1r1's 8.26 uses override def generateEventOn(reporter: PluginReporter, kind: PointKind, index: Int): Unit = kind match { @@ -286,7 +251,7 @@ class Dnp3IntegrationPlugin extends IntegrationPlugin { } override def generateUnsolicitedEvents(reporter: PluginReporter, timing: ActionTiming): ActionResult = { - generateExactInputEvents(reporter, InputKind.BinaryInput, 1) + generateExactInputEvents(reporter, PointKind.BinaryInput, 1) ActionResult.Performed } @@ -297,10 +262,10 @@ class Dnp3IntegrationPlugin extends IntegrationPlugin { } override def generateMultiFragmentResponse(reporter: PluginReporter): Unit = { - generateExactInputEvents(reporter, InputKind.BinaryInput, 200) - generateExactInputEvents(reporter, InputKind.DoubleBitInput, 200) - generateExactInputEvents(reporter, InputKind.Counter, 200) - generateExactInputEvents(reporter, InputKind.AnalogInput, 200) + generateExactInputEvents(reporter, PointKind.BinaryInput, 200) + generateExactInputEvents(reporter, PointKind.DoubleBitInput, 200) + generateExactInputEvents(reporter, PointKind.Counter, 200) + generateExactInputEvents(reporter, PointKind.AnalogInput, 200) } override def recordCurrentCounterValues(reporter: PluginReporter): Unit = { @@ -461,10 +426,10 @@ class Dnp3IntegrationPlugin extends IntegrationPlugin { override def verifyInputCurrentValues[P <: MeasurementPoint](reporter: CheckReporter, kind: InputKind[P], points: Seq[GenIndexedPoint[P]]): Option[DeferredCheck] = { kind match { - case InputKind.BinaryInput => verifyAllCurrentStatus(reporter, points, "binary input", trackingDatabase.getAllBinaryInputs, checkBinaryInput) - case InputKind.DoubleBitInput => verifyAllCurrentStatus(reporter, points, "double-bit binary input", trackingDatabase.getAllDoubleBitBinaryInputs, checkDoubleBitBinaryInput) - case InputKind.Counter => verifyAllCurrentStatus(reporter, points, "counter", trackingDatabase.getAllCounters, checkCounter) - case InputKind.AnalogInput => verifyAllCurrentStatus(reporter, points, "analog input", trackingDatabase.getAllAnalogInputs, checkAnalogInput) + case PointKind.BinaryInput => verifyAllCurrentStatus(reporter, points, "binary input", trackingDatabase.getAllBinaryInputs, checkBinaryInput) + case PointKind.DoubleBitInput => verifyAllCurrentStatus(reporter, points, "double-bit binary input", trackingDatabase.getAllDoubleBitBinaryInputs, checkDoubleBitBinaryInput) + case PointKind.Counter => verifyAllCurrentStatus(reporter, points, "counter", trackingDatabase.getAllCounters, checkCounter) + case PointKind.AnalogInput => verifyAllCurrentStatus(reporter, points, "analog input", trackingDatabase.getAllAnalogInputs, checkAnalogInput) } None } @@ -476,14 +441,14 @@ class Dnp3IntegrationPlugin extends IntegrationPlugin { override def verifyInputEvents[P <: MeasurementPoint](reporter: CheckReporter, kind: InputKind[P], expectation: EventExpectation, points: Seq[GenIndexedPoint[P]]): Option[DeferredCheck] = { kind match { - case InputKind.BinaryInput => verifyEvents(reporter, expectation, points, "binary input", trackingDatabase.popBinaryEvent, checkBinaryInput) - case InputKind.DoubleBitInput => verifyEvents(reporter, expectation, points, "double-bit binary input", trackingDatabase.popDoubleBitBinaryEvent, checkDoubleBitBinaryInput) - case InputKind.Counter if expectation == EventExpectation.CurrentValuesWithOptionalEarlier => + case PointKind.BinaryInput => verifyEvents(reporter, expectation, points, "binary input", trackingDatabase.popBinaryEvent, checkBinaryInput) + case PointKind.DoubleBitInput => verifyEvents(reporter, expectation, points, "double-bit binary input", trackingDatabase.popDoubleBitBinaryEvent, checkDoubleBitBinaryInput) + case PointKind.Counter if expectation == EventExpectation.CurrentValuesWithOptionalEarlier => verifyCurrentValues(reporter, points, "counter", trackingDatabase.getAllCounterEvents(BatchSpecifier.All), checkCounter) - case InputKind.AnalogInput if expectation == EventExpectation.CurrentValuesWithOptionalEarlier => + case PointKind.AnalogInput if expectation == EventExpectation.CurrentValuesWithOptionalEarlier => verifyCurrentValues(reporter, points, "analog input", trackingDatabase.getAllAnalogInputEvents(BatchSpecifier.All), checkAnalogInput) - case InputKind.Counter => verifyEvents(reporter, expectation, points, "counter", trackingDatabase.popCounterEvent, checkCounter) - case InputKind.AnalogInput => verifyEvents(reporter, expectation, points, "analog input", trackingDatabase.popAnalogInputEvent, checkAnalogInput) + case PointKind.Counter => verifyEvents(reporter, expectation, points, "counter", trackingDatabase.popCounterEvent, checkCounter) + case PointKind.AnalogInput => verifyEvents(reporter, expectation, points, "analog input", trackingDatabase.popAnalogInputEvent, checkAnalogInput) } None } @@ -508,10 +473,10 @@ class Dnp3IntegrationPlugin extends IntegrationPlugin { override def verifyFirstInputEvent[P <: MeasurementPoint](reporter: CheckReporter, kind: InputKind[P], point: GenIndexedPoint[P]): Option[DeferredCheck] = { kind match { - case InputKind.BinaryInput => verifyFirstEvent(reporter, point, "binary input", trackingDatabase.popBinaryEvent, checkBinaryInput) - case InputKind.DoubleBitInput => verifyFirstEvent(reporter, point, "double-bit binary input", trackingDatabase.popDoubleBitBinaryEvent, checkDoubleBitBinaryInput) - case InputKind.Counter => verifyFirstEvent(reporter, point, "counter", trackingDatabase.popCounterEvent, checkCounter) - case InputKind.AnalogInput => verifyFirstEvent(reporter, point, "analog input", trackingDatabase.popAnalogInputEvent, checkAnalogInput) + case PointKind.BinaryInput => verifyFirstEvent(reporter, point, "binary input", trackingDatabase.popBinaryEvent, checkBinaryInput) + case PointKind.DoubleBitInput => verifyFirstEvent(reporter, point, "double-bit binary input", trackingDatabase.popDoubleBitBinaryEvent, checkDoubleBitBinaryInput) + case PointKind.Counter => verifyFirstEvent(reporter, point, "counter", trackingDatabase.popCounterEvent, checkCounter) + case PointKind.AnalogInput => verifyFirstEvent(reporter, point, "analog input", trackingDatabase.popAnalogInputEvent, checkAnalogInput) } None } From 108005e45e95778ad8fc2aeef50271eb12eb6d6d Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Wed, 14 Oct 2026 15:28:01 -0700 Subject: [PATCH 04/16] Build dnp3c from source for the conformance tests dnp3c with revision 3.1r1 isn't on Maven Central yet, so the conformance job checks it out at a pinned commit and installs the version conformance/pom.xml names. The packet capture check looks in the folder dnp3c now keeps 3.1r1's level 3 results in. --- .github/workflows/ci.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b169a454..074f7ec65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -374,6 +374,18 @@ jobs: - name: Install Java bindings shell: bash run: (cd ffi/bindings/java/dnp3 && sudo mvn install) + # dnp3c with revision 3.1r1 isn't on Maven Central yet: build the version conformance/pom.xml names from source, + # at a pinned commit, into the repository the tests run with (root's, as they run as root) + - name: Checkout dnp3c + uses: actions/checkout@v6 + with: + repository: stepfunc/dnp3c + ref: 917b42fa0e194b3a9ec5a4a2f5f6fa621928725c + path: dnp3c + - name: Install dnp3c + working-directory: dnp3c + # building dnp3c needs a full JDK 21 + run: sudo JAVA_HOME="$JAVA_HOME_21_X64" mvn --batch-mode install -DskipTests - name: Capture packets during the conformance tests working-directory: conformance # capturing needs root, which the tests run as; local runs leave it off @@ -382,7 +394,8 @@ jobs: working-directory: conformance run: sudo mvn --batch-mode scala:run - name: Check the packet captures - working-directory: conformance/results + # dnp3c keeps each revision's results, for each subset level, in a folder of its own + working-directory: conformance/results/3.1r1/L3 # every procedure whose log shows a transmitted frame ("--P->") must have captured packets; # a pcap of only its 24-octet header captured none run: | From d8ca1e01a54bc61124fddbd1eac8adb69d98bd47 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Wed, 14 Oct 2026 15:41:27 -0700 Subject: [PATCH 05/16] Build the conformance tests with dnp3c's latest main --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 074f7ec65..71c3c0174 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -380,7 +380,7 @@ jobs: uses: actions/checkout@v6 with: repository: stepfunc/dnp3c - ref: 917b42fa0e194b3a9ec5a4a2f5f6fa621928725c + ref: 001b20726aeaf394a02ff5732a0fd6db266a0c3a path: dnp3c - name: Install dnp3c working-directory: dnp3c From 9f39881f3c1406d16af5cf496d6ec10548961e4b Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Wed, 14 Oct 2026 16:51:02 -0700 Subject: [PATCH 06/16] Use dnp3c's lettered ids for the 3.1r1 tests dnp3c now names the tests 3.1r1 doesn't number with their section's number and a letter, e.g. 8.13.2d and 8.21d, so the list of known gaps in device.ini uses those, and the conformance job builds the dnp3c commit that introduced them. --- .github/workflows/ci.yml | 2 +- conformance/device.ini | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71c3c0174..fc26b5149 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -380,7 +380,7 @@ jobs: uses: actions/checkout@v6 with: repository: stepfunc/dnp3c - ref: 001b20726aeaf394a02ff5732a0fd6db266a0c3a + ref: 1a5422372475dd9c2ddee4a610230c6ed9751e35 path: dnp3c - name: Install dnp3c working-directory: dnp3c diff --git a/conformance/device.ini b/conformance/device.ini index e1a25227e..f990ff039 100644 --- a/conformance/device.ini +++ b/conformance/device.ini @@ -444,13 +444,13 @@ outstation.subset.level = 3 # 3.1r1, subset level 3 # # Tests that fail on gaps in the dnp3 outstation, which no option turns off: -# - a read of a type whose points are all uninstalled gets a null response, without IIN2.1 or IIN2.2 (#478): 8.13.2-4, -# 8.16.1.2.4, 8.21.1, 8.21-8.13.2-4, and 8.23.2.x.1 +# - a read of a type whose points are all uninstalled gets a null response, without IIN2.1 or IIN2.2 (#478): 8.13.2d, +# 8.16.1.2.4, 8.21.1, 8.21d, and 8.23.2.x.1 # - a range read that runs past the last point gets the points there are, without IIN2.2 (#260): 8.23.2.x.3 # - a static read of a variation without flags reports a point whose flags aren't normal in that variation, rather -# than one with flags (#479): 8.24.2-4 to 8.24.2-6 +# than one with flags (#479): 8.24.2d to 8.24.2f # - Assign Class (function code 22) isn't supported (#480): 8.26.2, which also needs binary inputs in class 2 -# Serial (8.10.2, 8.12.2, 6.6.2-4) and network (section 9) tests are off; so are output events (section 10). +# Serial (8.10.2, 8.12.2, 6.6.2d) and network (section 9) tests are off; so are output events (section 10). binaryInput.defaultEventVariation = 2 doubleBitInput.defaultEventVariation = 1 # the plugin asks for the time 5 s after a sync, and its clock is unsynchronized 5 s after that From f52fb43ad45c94119f77dc330e97992e33f26a4f Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 15 Oct 2026 10:50:30 -0700 Subject: [PATCH 07/16] Remove the output status points when they're uninstalled Uninstalling binary or analog output status only disabled their controls, and the points stayed in the database, so a read of them still returned data. Leave them out of the database too, as for the input types. --- .../stepfunc/conformance/dnp3/Dnp3rsIntegrationPlugin.scala | 6 +++--- .../io/stepfunc/conformance/dnp3/OutstationConfig.scala | 2 +- .../io/stepfunc/conformance/dnp3/TrackingDatabase.scala | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/Dnp3rsIntegrationPlugin.scala b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/Dnp3rsIntegrationPlugin.scala index 35606dc7a..0025e1b34 100644 --- a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/Dnp3rsIntegrationPlugin.scala +++ b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/Dnp3rsIntegrationPlugin.scala @@ -135,8 +135,8 @@ class Dnp3IntegrationPlugin extends IntegrationPlugin { case PointKind.Counter => config.copy(testDatabaseConfig = database.copy(disableCounters = true)) case PointKind.FrozenCounter => config.copy(testDatabaseConfig = database.copy(disableFrozenCounters = true)) case PointKind.AnalogInput => config.copy(testDatabaseConfig = database.copy(disableAnalogInputs = true)) - case PointKind.BinaryOutputStatus => config.copy(commandHandlerConfig = commands.copy(disableBinaryOutput = true)) - case PointKind.AnalogOutputStatus => config.copy(commandHandlerConfig = commands.copy(disableAnalogOutput = true)) + case PointKind.BinaryOutputStatus => config.copy(testDatabaseConfig = database.copy(disableBinaryOutputStatus = true), commandHandlerConfig = commands.copy(disableBinaryOutput = true)) + case PointKind.AnalogOutputStatus => config.copy(testDatabaseConfig = database.copy(disableAnalogOutputStatus = true), commandHandlerConfig = commands.copy(disableAnalogOutput = true)) } reporter.log(s"All ${kind.name} points were uninstalled") cyclePower(reporter) @@ -195,7 +195,7 @@ class Dnp3IntegrationPlugin extends IntegrationPlugin { override def reinstallPoints(reporter: PluginReporter): Unit = { config = config.copy( - testDatabaseConfig = config.testDatabaseConfig.copy(disableBinaryInputs = false, disableDoubleBitBinaryInputs = false, disableCounters = false, disableFrozenCounters = false, disableAnalogInputs = false), + testDatabaseConfig = config.testDatabaseConfig.copy(disableBinaryInputs = false, disableDoubleBitBinaryInputs = false, disableCounters = false, disableFrozenCounters = false, disableAnalogInputs = false, disableBinaryOutputStatus = false, disableAnalogOutputStatus = false), commandHandlerConfig = CommandHandlerConfig(disableBinaryOutput = false, disableAnalogOutput = false) ) reporter.log("All points were reinstalled") diff --git a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/OutstationConfig.scala b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/OutstationConfig.scala index a8d703c55..9d55d7232 100644 --- a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/OutstationConfig.scala +++ b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/OutstationConfig.scala @@ -10,7 +10,7 @@ case class OutstationConfig(confirmTimeoutMs: Int, selectTimeoutMs: Int, fragmen // retryDelayMs: the wait after a failed unsolicited response series before starting another case class UnsolicitedResponseConfig(allowUnsolicited: Boolean, retryDelayMs: Int, maxNumRetries: Option[Int]) -case class TestDatabaseConfig(disableBinaryInputs: Boolean, disableDoubleBitBinaryInputs: Boolean, disableCounters: Boolean, isGlobalLocalControl: Boolean, localControlIndex: Option[Int], disableFrozenCounters: Boolean = false, disableAnalogInputs: Boolean = false) +case class TestDatabaseConfig(disableBinaryInputs: Boolean, disableDoubleBitBinaryInputs: Boolean, disableCounters: Boolean, isGlobalLocalControl: Boolean, localControlIndex: Option[Int], disableFrozenCounters: Boolean = false, disableAnalogInputs: Boolean = false, disableBinaryOutputStatus: Boolean = false, disableAnalogOutputStatus: Boolean = false) case class CommandHandlerConfig(disableBinaryOutput: Boolean, disableAnalogOutput: Boolean) diff --git a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/TrackingDatabase.scala b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/TrackingDatabase.scala index e42238ac2..156a2b090 100644 --- a/conformance/src/main/scala/io/stepfunc/conformance/dnp3/TrackingDatabase.scala +++ b/conformance/src/main/scala/io/stepfunc/conformance/dnp3/TrackingDatabase.scala @@ -128,7 +128,7 @@ class TrackingDatabase(val app: CustomOutstationApplication, val outstation: Out db.updateAnalogInput(e, updateOptions) }) - this.binaryOutputs.values.foreach(e => { + if (!testDatabaseConfig.disableBinaryOutputStatus) this.binaryOutputs.values.foreach(e => { val boConfig = new BinaryOutputStatusConfig() boConfig.staticVariation = StaticBinaryOutputStatusVariation.GROUP10_VAR2 @@ -136,7 +136,7 @@ class TrackingDatabase(val app: CustomOutstationApplication, val outstation: Out db.updateBinaryOutputStatus(e, updateOptions) }) - this.analogOutputs.values.foreach(e => { + if (!testDatabaseConfig.disableAnalogOutputStatus) this.analogOutputs.values.foreach(e => { val aoConfig = new AnalogOutputStatusConfig() aoConfig.staticVariation = StaticAnalogOutputStatusVariation.GROUP40_VAR2 From 1478f22ccc6a4bffe4a52219f2449b213dd0fd04 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 15 Oct 2026 10:50:30 -0700 Subject: [PATCH 08/16] Build the conformance tests with dnp3c's current main It answers a read of a point type the outstation doesn't have the same way for every type (dnp3c #70). --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc26b5149..c9532400a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -380,7 +380,7 @@ jobs: uses: actions/checkout@v6 with: repository: stepfunc/dnp3c - ref: 1a5422372475dd9c2ddee4a610230c6ed9751e35 + ref: 8d549e90ac0dc25612773c5d86131112b8d85b78 path: dnp3c - name: Install dnp3c working-directory: dnp3c From 17dac39b4cef9414224f5dea4b2ba6a96ff8459e Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 15 Oct 2026 10:57:08 -0700 Subject: [PATCH 09/16] Run the 2.7 and 3.1r1 conformance tests in parallel The conformance job becomes a matrix of two runs: revision 2.7 at subset level 2 and 3.1r1 at subset level 3, each with its own results artifact. A job keeps the name conformance, which branch protection requires, and passes only if both runs pass. Releases publish both reports, with an index linking them, and package both in conformance-results.zip. --- .github/workflows/ci.yml | 57 ++++++++++++++++++++++++++++++++++------ conformance/device.ini | 4 +-- conformance/pom.xml | 5 +++- 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9532400a..67119b22b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -359,9 +359,17 @@ jobs: with: name: java-bindings-pom path: ffi/bindings/java/dnp3/pom.xml - # Run the conformance tests - conformance: + # Run the conformance tests, for each revision of the test procedures at the subset level it tests + conformance-run: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - revision: "2.7" + level: 2 + - revision: "3.1r1" + level: 3 steps: - name: Checkout uses: actions/checkout@v6 @@ -390,12 +398,16 @@ jobs: working-directory: conformance # capturing needs root, which the tests run as; local runs leave it off run: echo "tcp.capture.adapter = /127.0.0.1" >> device.ini + - name: Select the subset level + working-directory: conformance + # device.ini is for 3.1r1 at level 3; a later value overrides it + run: echo "outstation.subset.level = ${{ matrix.level }}" >> device.ini - name: Run the conformance tests working-directory: conformance - run: sudo mvn --batch-mode scala:run + run: sudo mvn --batch-mode scala:run -Dconformance.revision=${{ matrix.revision }} - name: Check the packet captures # dnp3c keeps each revision's results, for each subset level, in a folder of its own - working-directory: conformance/results/3.1r1/L3 + working-directory: conformance/results/${{ matrix.revision }}/L${{ matrix.level }} # every procedure whose log shows a transmitted frame ("--P->") must have captured packets; # a pcap of only its 24-octet header captured none run: | @@ -415,8 +427,18 @@ jobs: if: always() uses: actions/upload-artifact@v7 with: - name: conformance-results + name: conformance-results-${{ matrix.revision }} path: conformance/results + # The check branch protection requires: passes only if every revision's run passed + conformance: + needs: [conformance-run] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check the conformance runs + run: | + echo "conformance runs: ${{ needs.conformance-run.result }}" + [ "${{ needs.conformance-run.result }}" = "success" ] # Quality gate to ensure all tests pass before releases quality-gate: needs: [linting, msrv, test, features, conformance] @@ -459,8 +481,23 @@ jobs: - name: Download conformance test results uses: actions/download-artifact@v8 with: - name: conformance-results + # each revision's artifact holds /L/, so they merge without colliding + pattern: conformance-results-* + merge-multiple: true path: artifacts/conformance-results + - name: Index the conformance test reports + working-directory: artifacts/conformance-results + run: | + { + echo '' + echo 'dnp3 conformance test reports' + echo '

dnp3 conformance test reports

' + } > index.html + cat index.html - name: Checkout stepfunc/docs uses: actions/checkout@v6 with: @@ -613,8 +650,12 @@ jobs: mkdir release cd artifacts/c-bindings zip -r ../../release/dnp3-${{github.ref_name}}.zip . - cd ../conformance-results - zip -r ../../release/conformance-results.zip . + # each revision's results are in an artifact of its own, holding /L/ + cd ../.. + mkdir conformance-results + cp -a artifacts/conformance-results-*/. conformance-results/ + cd conformance-results + zip -r ../release/conformance-results.zip . - name: Create GitHub release uses: softprops/action-gh-release@v2 with: diff --git a/conformance/device.ini b/conformance/device.ini index f990ff039..d3ffcb493 100644 --- a/conformance/device.ini +++ b/conformance/device.ini @@ -439,6 +439,8 @@ attributes.supportedVariations = 241, 245, 247, 250, 252 # The supported device attribute variations that the DUT reports as read/write attributes.writableVariations = 245, 247 +# 3.1r1 runs at level 3. 2.7 only runs at level 2: CI runs it with "outstation.subset.level = 2" appended to this file, +# and a local run needs the same, with -Dconformance.revision=2.7 (see pom.xml) outstation.subset.level = 3 # 3.1r1, subset level 3 @@ -447,8 +449,6 @@ outstation.subset.level = 3 # - a read of a type whose points are all uninstalled gets a null response, without IIN2.1 or IIN2.2 (#478): 8.13.2d, # 8.16.1.2.4, 8.21.1, 8.21d, and 8.23.2.x.1 # - a range read that runs past the last point gets the points there are, without IIN2.2 (#260): 8.23.2.x.3 -# - a static read of a variation without flags reports a point whose flags aren't normal in that variation, rather -# than one with flags (#479): 8.24.2d to 8.24.2f # - Assign Class (function code 22) isn't supported (#480): 8.26.2, which also needs binary inputs in class 2 # Serial (8.10.2, 8.12.2, 6.6.2d) and network (section 9) tests are off; so are output events (section 10). binaryInput.defaultEventVariation = 2 diff --git a/conformance/pom.xml b/conformance/pom.xml index 787c781a9..0ef8c49bb 100644 --- a/conformance/pom.xml +++ b/conformance/pom.xml @@ -14,6 +14,9 @@ 1.8.0-M1 0.1.0-SNAPSHOT 4.10.0 + + 3.1r1 @@ -44,7 +47,7 @@ io.stepfunc.conformance.dnp3.Main -rev - 3.1r1 + ${conformance.revision} -config device.ini -run From 2bbfc54b51ea374a24511d9e734f48d84bd0113a Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 15 Oct 2026 10:57:08 -0700 Subject: [PATCH 10/16] Describe both conformance revisions in the guide Link the 3.1 rev 1 and 2.7 reports, and replace the text saying level 3 procedures are still being written. --- guide/docs/about/interoperability.mdx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/guide/docs/about/interoperability.mdx b/guide/docs/about/interoperability.mdx index 778285b40..a7441d429 100644 --- a/guide/docs/about/interoperability.mdx +++ b/guide/docs/about/interoperability.mdx @@ -36,13 +36,16 @@ The library will be level 4 compliant when we add AO/BO command events (groups 1 ## Conformance Testing -The DNP3 User Group publishes official conformance test procedures for outstations. These procedures validate all level 2 functionality. Procedures for level 3 conformance testing are currently under development by the user group. +The DNP3 User Group publishes the conformance test procedures for outstations, the DNP3 IED Certification Procedure. CI runs two revisions of it against the outstation, using [dnp3c](https://github.com/stepfunc/dnp3c), our implementation of the procedures: -We created an internal implementation of these test procedures that we automatically run against the outstation implementation. We use these procedures to augment our own unit and integration tests to meet the conformance standards. +* Revision 3.1 rev 1 (2022), which covers subset levels 1 to 3. We run it at level 3. +* Revision 2.7 (2016), which covers subset level 2. + +Some procedures are disabled in this configuration, such as the network tests in section 9 and the output event tests in section 10. Each report gives the reason for every procedure it skips. We use these procedures to augment our own unit and integration tests. import sitedata from '../../sitedata.json' -<>The conformance test report for version {sitedata.version} is published alongside this guide. +<>The conformance test reports for version {sitedata.version} are published alongside this guide: revision 3.1 rev 1, level 3 and revision 2.7, level 2. :::note The DNP User Group only certifies outstation devices as conforming to the specification, not libraries. Contact Step Function I/O if you would like to engage us to conduct conformance testing for your outstation product. From 54e6e1602b88349ab171bfc81575e0b241a721bb Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 15 Oct 2026 11:15:53 -0700 Subject: [PATCH 11/16] Drop the read gaps fixed by #487 from the configuration's notes --- conformance/device.ini | 3 --- 1 file changed, 3 deletions(-) diff --git a/conformance/device.ini b/conformance/device.ini index d3ffcb493..37cef237e 100644 --- a/conformance/device.ini +++ b/conformance/device.ini @@ -446,9 +446,6 @@ outstation.subset.level = 3 # 3.1r1, subset level 3 # # Tests that fail on gaps in the dnp3 outstation, which no option turns off: -# - a read of a type whose points are all uninstalled gets a null response, without IIN2.1 or IIN2.2 (#478): 8.13.2d, -# 8.16.1.2.4, 8.21.1, 8.21d, and 8.23.2.x.1 -# - a range read that runs past the last point gets the points there are, without IIN2.2 (#260): 8.23.2.x.3 # - Assign Class (function code 22) isn't supported (#480): 8.26.2, which also needs binary inputs in class 2 # Serial (8.10.2, 8.12.2, 6.6.2d) and network (section 9) tests are off; so are output events (section 10). binaryInput.defaultEventVariation = 2 From 74583a71bdbc47f320dcd9f3cab83d2effc9772f Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 15 Oct 2026 12:51:38 -0700 Subject: [PATCH 12/16] Exclude 8.26.2 until Assign Class is supported dnp3c now excludes procedures through the configuration, each for one revision, and reports them as not run. Exclude 3.1r1's 8.26.2 (#480), so the run carries on to the procedures after it, and build with the dnp3c commit that qualifies exclusions by revision. --- .github/workflows/ci.yml | 2 +- conformance/device.ini | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67119b22b..a09b347ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -388,7 +388,7 @@ jobs: uses: actions/checkout@v6 with: repository: stepfunc/dnp3c - ref: 8d549e90ac0dc25612773c5d86131112b8d85b78 + ref: 25c338513976fdeb826c670e41041b1cdf4af059 path: dnp3c - name: Install dnp3c working-directory: dnp3c diff --git a/conformance/device.ini b/conformance/device.ini index 37cef237e..126fb943e 100644 --- a/conformance/device.ini +++ b/conformance/device.ini @@ -445,8 +445,8 @@ outstation.subset.level = 3 # 3.1r1, subset level 3 # -# Tests that fail on gaps in the dnp3 outstation, which no option turns off: -# - Assign Class (function code 22) isn't supported (#480): 8.26.2, which also needs binary inputs in class 2 +# Tests excluded for gaps in the dnp3 outstation, which no option turns off. The report shows them as not run. +test.exclude.3.1r1/8.26.2 = Assign Class (function code 22) isn't supported yet (stepfunc/dnp3#480) # Serial (8.10.2, 8.12.2, 6.6.2d) and network (section 9) tests are off; so are output events (section 10). binaryInput.defaultEventVariation = 2 doubleBitInput.defaultEventVariation = 1 From d5c2ad5b77c3bca182b4400063df802d07753222 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 15 Oct 2026 12:58:57 -0700 Subject: [PATCH 13/16] Name the conformance runs by revision and subset level --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a09b347ae..a4a1a5cdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -361,6 +361,7 @@ jobs: path: ffi/bindings/java/dnp3/pom.xml # Run the conformance tests, for each revision of the test procedures at the subset level it tests conformance-run: + name: conformance (${{ matrix.revision }}, level ${{ matrix.level }}) runs-on: ubuntu-latest strategy: fail-fast: false From 5a9817edb85a4db45e45d208cd64a90db60d34c4 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 15 Oct 2026 19:49:43 -0700 Subject: [PATCH 14/16] Build the conformance tests with dnp3c's timing tolerance option dnp3c's tolerance on the times it measures is now an option, test.timingToleranceMs (dnp3c #93); the default stays 500 ms for timed intervals and is 500 ms, up from 200 ms, past each timeout. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4a1a5cdc..1847cd819 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -389,7 +389,7 @@ jobs: uses: actions/checkout@v6 with: repository: stepfunc/dnp3c - ref: 25c338513976fdeb826c670e41041b1cdf4af059 + ref: 2dcc1602a2004f90872b08a5004a1505cf4e32e4 path: dnp3c - name: Install dnp3c working-directory: dnp3c From 740592bd4682fd0d4d9a930a22cec52c127648ab Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 15 Oct 2026 19:56:08 -0700 Subject: [PATCH 15/16] Remove conformance/device.properties, which nothing uses The conformance tests run with device.ini, which the pom passes to dnp3c. device.properties no longer passes dnp3c's validation either. --- conformance/device.properties | 420 ---------------------------------- 1 file changed, 420 deletions(-) delete mode 100644 conformance/device.properties diff --git a/conformance/device.properties b/conformance/device.properties deleted file mode 100644 index b1f8c2c71..000000000 --- a/conformance/device.properties +++ /dev/null @@ -1,420 +0,0 @@ -################################################################################ -# Options for the test harness itself -################################################################################ - -# Directory where test results are saved -test.output.directory = ./results - -################################################################################ -# TCP options -################################################################################ - -# IP address of the DUT -tcp.device.address = 127.0.0.1 - -# IP port of the DUT min: 1 max: 65535 -tcp.device.port = 20000 - -# IP address of the network adapter on which to capture pcaps -tcp.capture.adapter = - -################################################################################ -# link-layer options -################################################################################ - -# Link layer address of the test harness (master) -link.source = 1 - -# Link layer address of the DUT (outstation) -link.destination = 1024 - -# Number of configured link layer retries min: 1 max: 10 -link.num.retry = 3 - -# Link layer timeout in milliseconds min: 100 -link.timeout.ms = 1000 - -################################################################################ -# Outstation application layer options -################################################################################ - -# Device select timeout in milliseconds min: 1 -outstation.select.timeout.ms = 2000 - -# Response timeout in milliseconds min: 1 -outstation.response.timeout.ms = 2000 - -# Configurable fragment size (preferably not 2048) -outstation.fragmentSize = 2048 - -################################################################################ -# Options related to controls (g12v1) -################################################################################ - -# Index not assigned to any control point -binaryOutput.uninstalled.index = 65535 - -# Maximum number of control points that are supported in a single request min: 1 -binaryOutput.maxPointsInSingleRequest = 1 - -# List of all installed BO indices -binaryOutput.installedPoints = 0 ... 9 - -# Index of a supported Group12Var1 -binaryOutput.primary.valid.index = 0 - -# Valid control function in hexadecimal from set HashSet(04, 03, 81, 41, 01) -binaryOutput.primary.valid.function = 3 - -# Valid count for Group12Var1 min: 1 max: 255 -binaryOutput.primary.valid.count = 1 - -# On time in ms min: 0 max: 65535 -binaryOutput.primary.valid.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -binaryOutput.primary.valid.offTime.ms = 100 - -# Index of a supported Group12Var1 -binaryOutput.alternate.valid.index = 1 - -# Valid control function in hexadecimal from set HashSet(04, 03, 81, 41, 01) -binaryOutput.alternate.valid.function = 3 - -# Valid count for Group12Var1 min: 1 max: 255 -binaryOutput.alternate.valid.count = 1 - -# On time in ms min: 0 max: 65535 -binaryOutput.alternate.valid.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -binaryOutput.alternate.valid.offTime.ms = 100 - -# Optional control point supporting complementary function codes Latch On/NUL (0x03) and Latch Off/NUL (0x04). -binaryOutput.latchOnOffPoint.index = 1 - -# Valid count for Group12Var1 min: 1 max: 255 -binaryOutput.latchOnOffPoint.count = 1 - -# On time in ms min: 0 max: 65535 -binaryOutput.latchOnOffPoint.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -binaryOutput.latchOnOffPoint.offTime.ms = 100 - -# Optional control point supporting complementary function codes Pulse On/Close (0x41) and Pulse On/Trip (0x81). -binaryOutput.pulseOnOffPoint.index = 1 - -# Valid count for Group12Var1 min: 1 max: 255 -binaryOutput.pulseOnOffPoint.count = 1 - -# On time in ms min: 0 max: 65535 -binaryOutput.pulseOnOffPoint.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -binaryOutput.pulseOnOffPoint.offTime.ms = 100 - -# Optional control point supporting complementary function codes 0x03, 0x04, 0x41 and 0x81. -binaryOutput.latchAndPulsePoint.index = 1 - -# Valid count for Group12Var1 min: 1 max: 255 -binaryOutput.latchAndPulsePoint.count = 1 - -# On time in ms min: 0 max: 65535 -binaryOutput.latchAndPulsePoint.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -binaryOutput.latchAndPulsePoint.offTime.ms = 100 - -# Optional single function point supported function code Pulse On/NUL (0x01). -binaryOutput.singleFunctionPulseOnPoint.index = 1 - -# Valid count for Group12Var1 min: 1 max: 255 -binaryOutput.singleFunctionPulseOnPoint.count = 1 - -# On time in ms min: 0 max: 65535 -binaryOutput.singleFunctionPulseOnPoint.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -binaryOutput.singleFunctionPulseOnPoint.offTime.ms = 100 - -# Optional single function point supported function code Latch On/NUL (0x03). -binaryOutput.singleFunctionLatchOnPoint.index = 1 - -# Valid count for Group12Var1 min: 1 max: 255 -binaryOutput.singleFunctionLatchOnPoint.count = 1 - -# On time in ms min: 0 max: 65535 -binaryOutput.singleFunctionLatchOnPoint.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -binaryOutput.singleFunctionLatchOnPoint.offTime.ms = 100 - -# Optional single function point supported function code Latch Off/NUL (0x04). -binaryOutput.singleFunctionLatchOffPoint.index = 1 - -# Valid count for Group12Var1 min: 1 max: 255 -binaryOutput.singleFunctionLatchOffPoint.count = 1 - -# On time in ms min: 0 max: 65535 -binaryOutput.singleFunctionLatchOffPoint.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -binaryOutput.singleFunctionLatchOffPoint.offTime.ms = 100 - -# Optional single function point supported function code Pulse On/Close (0x41). -binaryOutput.singleFunctionPulseOnClosePoint.index = 1 - -# Valid count for Group12Var1 min: 1 max: 255 -binaryOutput.singleFunctionPulseOnClosePoint.count = 1 - -# On time in ms min: 0 max: 65535 -binaryOutput.singleFunctionPulseOnClosePoint.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -binaryOutput.singleFunctionPulseOnClosePoint.offTime.ms = 100 - -# Optional single function point supported function code Pulse On/Trip (0x81). -binaryOutput.singleFunctionPulseOnTripPoint.index = 1 - -# Valid count for Group12Var1 min: 1 max: 255 -binaryOutput.singleFunctionPulseOnTripPoint.count = 1 - -# On time in ms min: 0 max: 65535 -binaryOutput.singleFunctionPulseOnTripPoint.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -binaryOutput.singleFunctionPulseOnTripPoint.offTime.ms = 100 - -################################################################################ -# Options related to analog outputs (g41) -################################################################################ - -# Index not assigned to any analog output point -analogOutput.uninstalled.index = 65535 - -# List of all installed AO indices -analogOutput.installedPoints = 0 ... 9 - -# Maximum number of analog outputs that are supported in a single request min: 1 -analogOutput.maxPointsInSingleRequest = 1 - -# Index of a supported Group41Var2 -analogOutput.primary.valid.index = 0 - -# Value of supported by Group41Var2 min: 0 -analogOutput.primary.valid.value = 10 - -# Index of a supported Group41Var2 -analogOutput.alternate.valid.index = 1 - -# Value of supported by Group41Var2 min: 0 -analogOutput.alternate.valid.value = 11 - -################################################################################ -# Class options -################################################################################ - -# Support Class 1 event reporting. -class.supportClass1 = true - -# Number of Class 1 that will be generated manually. min: 1 -class.numClass1EventsGenerated = 1 - -# Support Class 2 event reporting. -class.supportClass2 = true - -# Number of Class 2 that will be generated manually. min: 1 -class.numClass2EventsGenerated = 1 - -# Support Class 3 event reporting. -class.supportClass3 = true - -# Number of Class 3 that will be generated manually. min: 1 -class.numClass3EventsGenerated = 1 - -################################################################################ -# Event buffer sizes -################################################################################ - -# Maximum number of binary input events min: 1 -event.buffer.maxBinaryInputEvents = 10 - -# Maximum number of analog input events min: 1 -event.buffer.maxAnalogInputEvents = 10 - -# Maximum number of binary counter events min: 1 -event.buffer.maxCounterEvents = 10 - -# Maximum number of double-bit binary input events min: 1 -event.buffer.maxDoubleBitInputEvents = 10 - -################################################################################ -# Behaviors that can vary between devices, but still remain conformant -################################################################################ - -# Does the device issue link reset if it receives a NACK while waiting for ACK? -behaviors.resetsLinkOnNack = false - -################################################################################ -# Time synchronization options -################################################################################ - -# Does the DUT support setting its time via DNP3 from a master (i.e. reports IIN1-4) ? -timesync.supportSynchronizingTimeFromMaster = true - -# Does the DUT support Binary Input Change with Time events (g2v2) ? -timesync.binaryInputChangeWithTime = true - -# Does the DUT support Binary Input Change with Relative Time events (g2v3) ? -timesync.binaryInputChangeWithRelativeTime = true - -# Maximum time (in milliseconds) from reset to IIN1-4 assertion -timesync.maxTimeFromResetToIIN14Ms = 0 - -# Latency to use when synchronizing the time min: 0 -timesync.timeWriteLatencyMs = 5 - -################################################################################ -# Options related to tests in section 8.6 -################################################################################ - -# binary output point used in test case 8.6.4.3 -flag.tests.individualRemoteSupervisoryOutputIndex = 0 - -################################################################################ -# Options related to binary inputs (g1 and g2) -################################################################################ - -# Number of Binary Input Events that will be generated manually. min: 1 -binaryInput.numEventsGenerated = 1 - -# Binary Input Events class assignment. min: 1 max: 3 -binaryInput.eventsClass = 1 - -################################################################################ -# Options related to Double-bit inputs (g3 and g4) -################################################################################ - -# Number of Double-bit Input Events that will be generated manually. min: 1 -doubleBitInput.numEventsGenerated = 1 - -# Double-bit Input Events class assignment. min: 1 max: 3 -doubleBitInput.eventsClass = 1 - -################################################################################ -# Options related to counters (g20 and g22) -################################################################################ - -# Number of Counter Events that will be generated manually. min: 1 -counter.numEventsGenerated = 1 - -# Counter Events class assignment. min: 1 max: 3 -counter.eventsClass = 1 - -################################################################################ -# Options related to analog inputs (g30 and g32) -################################################################################ - -# Analog Input Events class assignment. min: 1 max: 3 -analogInput.eventsClass = 1 - -################################################################################ -# Unsolicited response options -################################################################################ - -# Unsolicited confirmation timeout value (in ms) min: 1 -unsolicited.unsolicitedConfirmTimeoutMs = 5000 - -# Other Master address where unsolicited responses can be configured to send to min: 0 max: 65519 -unsolicited.otherMasterAddress = 2 - -# Does the device supports a minimum amount of event data before sending an unsolicited response? -unsolicited.hasMinNumEventsBeforeUnsolicited = false - -################################################################################ -# Optionally supported DNP3 features -################################################################################ - -# Does the DUT support Binary Input points? -supports.binaryInput = true - -# Is the device is configurable in a manner such that all Binary Input points can be uninstalled or disabled? -supports.disable.binaryInput = true - -# Does the DUT support Binary Input change events? -supports.binaryInputChangeEvents = true - -# Does the DUT support Double Bit points? -supports.doubleBitInput = true - -# Is the device is configurable in a manner such that all Double Bit points can be uninstalled or disabled? -supports.disable.doubleBitInput = true - -# Does the DUT support Double Bit change events? -supports.doubleBitInputChangeEvents = true - -# Does the DUT support Counter points? -supports.counter = true - -# Is the device is configurable in a manner such that all Counter points can be uninstalled or disabled? -supports.disable.counter = true - -# Does the DUT support Counter change events? -supports.counterChangeEvents = true - -# Does the DUT support Frozen Counter points? -supports.frozenCounter = true - -# Is the device is configurable in a manner such that all Frozen Counter points can be uninstalled or disabled? -supports.disable.frozenCounter = true - -# Does the DUT support Frozen Counter change events? -supports.frozenCounterChangeEvents = true - -# Does the DUT support Analog Input points? -supports.analogInput = true - -# Is the device is configurable in a manner such that all Analog Input points can be uninstalled or disabled? -supports.disable.analogInput = true - -# Does the DUT support Analog Input change events? -supports.analogInputChangeEvents = true - -# Does the DUT support Binary Output status points? -supports.binaryOutput.status = true - -# Does the DUT support Binary Output points? -supports.binaryOutput.commands = true - -# Is the device is configurable in a manner such that all Binary Output points can be uninstalled or disabled? -supports.disable.binaryOutput = true - -# Does the DUT support Analog Output status points? -supports.analogOutput.status = true - -# Does the DUT support Analog Output points? -supports.analogOutput.commands = true - -# Is the device is configurable in a manner such that all Analog Output points can be uninstalled or disabled? -supports.disable.analogOutput = true - -# Can the DUT be configured to request data link layer confirmation? -supports.dataLinkConfirmRequests = true - -# Does the DUT support link-layer self addressing? -supports.selfAddress = false - - -# Does the DUT support placing its binary outputs in a local state? -supports.globalRemoteSupervisoryControl = true - -# Does the DUT support placing individual binary outputs in a local state? -supports.individualRemoteSupervisoryControl = true - -# Does the DUT has a configurable fragment size? -supports.configurableFragmentSize = false - -# Does the DUT support unsolicited responses? -supports.unsolicitedResponse = true - From 760f25f7091ab79b9e64a188ff0b43d03dbfbebb Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 15 Oct 2026 20:39:21 -0700 Subject: [PATCH 16/16] Build the conformance tests with dnp3c 0.2.0 from Maven Central dnp3c 0.2.0, with revision 3.1r1 and the timing tolerance option, is on Maven Central, so CI no longer builds dnp3c from source at a pinned commit. --- .github/workflows/ci.yml | 12 ------------ conformance/pom.xml | 2 +- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1847cd819..45f9611c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -383,18 +383,6 @@ jobs: - name: Install Java bindings shell: bash run: (cd ffi/bindings/java/dnp3 && sudo mvn install) - # dnp3c with revision 3.1r1 isn't on Maven Central yet: build the version conformance/pom.xml names from source, - # at a pinned commit, into the repository the tests run with (root's, as they run as root) - - name: Checkout dnp3c - uses: actions/checkout@v6 - with: - repository: stepfunc/dnp3c - ref: 2dcc1602a2004f90872b08a5004a1505cf4e32e4 - path: dnp3c - - name: Install dnp3c - working-directory: dnp3c - # building dnp3c needs a full JDK 21 - run: sudo JAVA_HOME="$JAVA_HOME_21_X64" mvn --batch-mode install -DskipTests - name: Capture packets during the conformance tests working-directory: conformance # capturing needs root, which the tests run as; local runs leave it off diff --git a/conformance/pom.xml b/conformance/pom.xml index 0ef8c49bb..eb334a41f 100644 --- a/conformance/pom.xml +++ b/conformance/pom.xml @@ -12,7 +12,7 @@ UTF-8 3.9.0 1.8.0-M1 - 0.1.0-SNAPSHOT + 0.2.0 4.10.0