From 99ae9a7471e409931cbecf9a42bb012e9972f8c3 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 22 Oct 2026 09:55:22 -0700 Subject: [PATCH] Check binary event time by the clock state, and an option for TB2017-003a outstation.bulletins lists the technical bulletins a device implements, none by default. With TB2017-003a listed, 2.7's 8.14.2.9 to 8.14.2.11 and 8.22.2.9 to 8.22.2.11 check its rule (5.1.3.1.2) in place of absolute time only: events recorded while the clock is synchronized in g2v2/g4v2, those recorded while it's unsynchronized as g2v3/g4v3 after g51v2. The result notes the bulletin. IntegrationPlugin.timeQuality, with a default of None, reports whether the device's clock is synchronized. dnp3c asks before and after each batch of binary and double-bit input events, and 3.1r1's 8.14.1.1 checks, and 2.7's with the bulletin, use that state where the procedure doesn't know it. A batch the clock changed during may be reported each way in one response. Without the plugin's answer, the checks accept either way throughout, as before. --- CHANGELOG.md | 9 + README.md | 2 + docs/automation.md | 2 + .../dnp3c/conformance/api/Bulletin.scala | 21 +++ .../dnp3c/conformance/api/PluginSteps.scala | 32 +++- .../dnp3c/conformance/api/TestOption.scala | 21 +++ .../api/options/OutstationOptions.scala | 14 +- .../dnp3c/conformance/common/EventTime.scala | 129 +++++++++++++ .../common/InputEventProcedures.scala | 4 +- .../conformance/common/InputEventSteps.scala | 19 +- .../dnp3c/conformance/v31r1/EventRules.scala | 66 +++---- .../dnp3c/conformance/v31r1/InputEvents.scala | 17 +- .../dnp3c/dnp3/IntegrationPlugin.scala | 13 ++ .../io/stepfunc/dnp3c/dnp3/TestReporter.scala | 11 ++ .../conformance/common/EventTimeTest.scala | 173 ++++++++++++++++++ .../conformance/v31r1/EventRulesTest.scala | 2 +- 16 files changed, 470 insertions(+), 65 deletions(-) create mode 100644 src/main/scala/io/stepfunc/dnp3c/conformance/api/Bulletin.scala create mode 100644 src/main/scala/io/stepfunc/dnp3c/conformance/common/EventTime.scala create mode 100644 src/test/scala/io/stepfunc/dnp3c/conformance/common/EventTimeTest.scala diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d0c17..f0bd8f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.3.0 + +- **Technical bulletins:** `outstation.bulletins` lists the bulletins a device implements, where they change what a revision's steps check; none by default. The first is TB2017-003a: with it, 2.7's binary and double-bit event tests with time (8.14.2.9 to 8.14.2.11, 8.22.2.9 to 8.22.2.11) accept events recorded while the clock is unsynchronized with relative time after g51v2, and require it. +- **Clock state:** integration plugins can tell dnp3c whether the device's clock is synchronized (`IntegrationPlugin.timeQuality`), so 3.1 rev 1's binary and double-bit event tests, and 2.7's with TB2017-003a, check each event's variation against the state it was recorded in. A batch of events recorded while the clock changed may be reported each way in one response. + +### Upgrading from 0.2.0 + +- Integration plugins: `IntegrationPlugin.timeQuality` has a default, so a plugin keeps compiling; without it, the checks accept either way of reporting the events, as before. + ## 0.2.0 - **DNP3 IED Certification Procedure 3.1 rev 1**, subset levels 1 to 3, alongside 2.7. Choose the revision with `-rev`; `-revs` lists them. diff --git a/README.md b/README.md index 29752a2..9139c50 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ dnp3c implements two revisions of the DNP Users Group's *DNP3 IED Certification - **3.1 rev 1** (`3.1r1`, 13 September 2022) covers Subset Levels 1 to 3. Its tests have the numbers it gives them. One it leaves without a number has its section's number followed by a letter for its place among the section's unnumbered tests, e.g. 8.6.5b for the second; if it's the section's only test, it has the section's number and title, e.g. 8.6.1 Restart. - **2.7** (`2.7`, 2 March 2016) covers Subset Level 2. Where a technical bulletin replaces a 2.7 test outright, dnp3c runs the replacement: TB2017-001's 8.6.4.2 and 8.6.4.3, whose steps are numbered 1.a, 1.b, and so on. +A device can declare the technical bulletins it implements, where they change what a revision's steps check, with `outstation.bulletins`, e.g. `outstation.bulletins = TB2017-003a`. It's empty by default, so a run checks each revision as published. The one bulletin it knows so far is TB2017-003a: a device that implements it reports binary and double-bit input events recorded while its clock is unsynchronized with relative time after an unsynchronized CTO (g51v2 and g2v3 or g4v3), and uses absolute time (g2v2, g4v2) only for events recorded while it's synchronized. 2.7's 8.14.2.9 to 8.14.2.11 and 8.22.2.9 to 8.22.2.11 accept only absolute time; for a device that lists the bulletin, they check its rule instead, and the result notes it. 3.1 rev 1 already has the rule (8.14.1.1). Both revisions learn whether the clock was synchronized when the events were generated from the integration plugin, where it knows (see [Automating the tests](docs/automation.md)); where it doesn't, they accept either way, as long as all the events are reported the same way. + The section, procedure and step numbers in the tool, its logs and the report are those of the revision being run. Each revision is run as published, so a result under 2.7 can differ from one under 3.1 rev 1, which changes what some tests send or accept, often to apply technical bulletins published after 2.7. ### Where 3.1 rev 1 contradicts itself diff --git a/docs/automation.md b/docs/automation.md index a57dff9..2594f2e 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -136,6 +136,8 @@ To fail a step, throw an exception with a message saying what went wrong. The pr A few actions are used only by 3.1r1's tests, e.g. `setColdRestartSupport` (8.8.2). They have a default that throws `UnsupportedOperationException`, so a plugin written before them keeps compiling; override one if the device runs a test that uses it. `DefaultIntegrationPlugin` prompts for them like any other. +`timeQuality` tells dnp3c whether the device's clock is synchronized, for the events it records now. dnp3c asks before and after each batch of binary and double-bit input events it has the plugin generate, and checks the variations the device reports them in against that: an event recorded while the clock was synchronized in the variation requested, one recorded while it was unsynchronized with relative time after an unsynchronized CTO (g51v2). 3.1r1's rules for event reporting (8.14.1.1) require that, and so does 2.7 for a device that lists TB2017-003a in `outstation.bulletins`. The default returns `None`, for a plugin that doesn't know: the checks then accept either way, as long as all the events are reported the same way. + 3.1r1's network tests (section 9) set up the device's network with `configureNetwork`, which gives the whole setup: TCP or UDP, the port to listen on, the masters to accept. Each test ends with `restoreNetwork`, which puts back the setup the device had before, even when the test fails. Every method gets a `PluginReporter` (verifications get a `CheckReporter`, which extends it). Anything you pass to its `log` method appears in the procedure's log, e.g. what the plugin did, or the values it compared. diff --git a/src/main/scala/io/stepfunc/dnp3c/conformance/api/Bulletin.scala b/src/main/scala/io/stepfunc/dnp3c/conformance/api/Bulletin.scala new file mode 100644 index 0000000..6765506 --- /dev/null +++ b/src/main/scala/io/stepfunc/dnp3c/conformance/api/Bulletin.scala @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright Step Function I/O LLC + +package io.stepfunc.dnp3c.conformance.api + +/** + * A DNP Users Group technical bulletin that a device can declare it implements (outstation.bulletins), where it changes + * what a revision's steps check, or makes a requirement they leave implicit. A run checks a revision as published + * unless the configuration lists the bulletin, and each check it changes names it. + */ +enum Bulletin(val id: String) { + + // TB2017-003a 5.1.3.1.2: binary and double-bit input events recorded while the clock is unsynchronized are reported + // with relative time after an unsynchronized CTO (g51v2), never with absolute time + case TB2017_003a extends Bulletin("TB2017-003a") +} + +object Bulletin { + + def fromId(id: String): Option[Bulletin] = values.find(_.id.equalsIgnoreCase(id.trim)) +} diff --git a/src/main/scala/io/stepfunc/dnp3c/conformance/api/PluginSteps.scala b/src/main/scala/io/stepfunc/dnp3c/conformance/api/PluginSteps.scala index 90e8218..d5d6910 100644 --- a/src/main/scala/io/stepfunc/dnp3c/conformance/api/PluginSteps.scala +++ b/src/main/scala/io/stepfunc/dnp3c/conformance/api/PluginSteps.scala @@ -112,16 +112,40 @@ object PluginSteps { } def generateBinaryInputChangeEvents(): TestStep = - action("Change binary inputs several times, one or more of them, to create events") { + binaryEvents("Change binary inputs several times, one or more of them, to create events") { (plugin, reporter) => plugin.generateInputChanges(reporter, PointKind.BinaryInput) } def generateExactBinaryInputChangeEvents(numEvents: Int): TestStep = - action(s"Generate ${numEvents} Binary Input Change Event(s)") { (plugin, reporter) => + binaryEvents(s"Generate ${numEvents} Binary Input Change Event(s)") { (plugin, reporter) => plugin.generateExactInputEvents(reporter, PointKind.BinaryInput, numEvents) } + // a step that generates binary or double-bit input events, recording the time quality the plugin reports for the + // DUT's clock before and after, for the checks of how the events are reported (IntegrationPlugin.timeQuality) + private def binaryEvents(desc: Comment*)(generate: (IntegrationPlugin, PluginReporter) => Unit): TestStep = + new TestStep { + override def description: List[Comment] = desc.toList + + override def run(driver: TestDriver): List[DeferredCheck] = { + val plugin = driver.reporter.plugin + val before = plugin.timeQuality(driver.reporter.pluginReporter()) + generate(plugin, driver.reporter.pluginReporter()) + val after = plugin.timeQuality(driver.reporter.pluginReporter()) + driver.reporter.recordEventClock(before, after) + (before, after) match { + case (Some(b), Some(a)) if a == b => + driver.reporter.info(s"The plugin reports the DUT's clock ${b.toString.toLowerCase}") + case (Some(b), Some(a)) => + driver.reporter.info( + s"The plugin reports the DUT's clock ${b.toString.toLowerCase} before the events and ${a.toString.toLowerCase} after") + case _ => () + } + Nil + } + } + // Double-bit Binary Inputs def generateDoubleBitBinaryInputPattern(): TestStep = action("Set the double-bit inputs to a known pattern") { (plugin, reporter) => @@ -129,13 +153,13 @@ object PluginSteps { } def generateDoubleBitBinaryInputChangeEvents(): TestStep = - action("Change double-bit binary inputs several times, one or more of them, to create events") { + binaryEvents("Change double-bit binary inputs several times, one or more of them, to create events") { (plugin, reporter) => plugin.generateInputChanges(reporter, PointKind.DoubleBitInput) } def generateExactDoubleBitBinaryInputChangeEvents(numEvents: Int): TestStep = - action(s"Generate ${numEvents} Double-bit Binary Input Change Event(s)") { (plugin, reporter) => + binaryEvents(s"Generate ${numEvents} Double-bit Binary Input Change Event(s)") { (plugin, reporter) => plugin.generateExactInputEvents(reporter, PointKind.DoubleBitInput, numEvents) } diff --git a/src/main/scala/io/stepfunc/dnp3c/conformance/api/TestOption.scala b/src/main/scala/io/stepfunc/dnp3c/conformance/api/TestOption.scala index 7762768..5392220 100644 --- a/src/main/scala/io/stepfunc/dnp3c/conformance/api/TestOption.scala +++ b/src/main/scala/io/stepfunc/dnp3c/conformance/api/TestOption.scala @@ -50,6 +50,8 @@ trait IndexListOption extends TypedTestOption[List[Index]] trait RequiredIntegerOption extends TypedTestOption[Option[Int]] { final override def required: Boolean = true } +// the technical bulletins a device implements, by id +trait BulletinsOption extends TypedTestOption[Set[Bulletin]] // procedures to leave out of a run, by revision and id, each with the reason: a family of properties, one per // procedure, under the option's id, e.g. "test.exclude.3.1r1/8.26.2 = ". Each is keyed by the rest of its key, // e.g. "3.1r1/8.26.2", which the revisions check: the options don't know them @@ -124,6 +126,9 @@ object OptionTypes { // one of the allowed words, in any case def choice(group: OptionGroup, name: String, default: String, desc: String, allowed: List[String]): StringOption = ChoiceOptionImpl(group, name, default, desc, allowed) + // a comma separated list of bulletin ids, none by default + def bulletins(group: OptionGroup, name: String, desc: String): BulletinsOption = + BulletinsOptionImpl(group, name, desc) private case class StringOptionImpl(group: OptionGroup, name: String, default: String, description: String) extends StringOption { @@ -163,6 +168,22 @@ object OptionTypes { .mkString(", ")}")) } + private case class BulletinsOptionImpl(group: OptionGroup, name: String, desc: String) extends BulletinsOption { + + private def known: String = Bulletin.values.map(_.id).mkString(", ") + + override def description: String = s"$desc, of: $known" + + override def default: Set[Bulletin] = Set.empty + + override def defaultAsString: String = "" + + override def parse(s: String): Set[Bulletin] = + s.split(",").map(_.trim).filter(_.nonEmpty).map { id => + Bulletin.fromId(id).getOrElse(throw new Exception(s"'$id' isn't a bulletin dnp3c knows: $known")) + }.toSet + } + private case class BooleanOptionImpl(group: OptionGroup, name: String, default: Boolean, description: String) extends BooleanOption { override def parse(s: String): Boolean = Parse.boolean(s) diff --git a/src/main/scala/io/stepfunc/dnp3c/conformance/api/options/OutstationOptions.scala b/src/main/scala/io/stepfunc/dnp3c/conformance/api/options/OutstationOptions.scala index f6277e4..f9f0584 100644 --- a/src/main/scala/io/stepfunc/dnp3c/conformance/api/options/OutstationOptions.scala +++ b/src/main/scala/io/stepfunc/dnp3c/conformance/api/options/OutstationOptions.scala @@ -3,7 +3,9 @@ package io.stepfunc.dnp3c.conformance.api.options -import io.stepfunc.dnp3c.conformance.api.{ IntegerOption, OptionGroup, OptionTypes, RequiredIntegerOption, TestOption } +import io.stepfunc.dnp3c.conformance.api.{ + BulletinsOption, IntegerOption, OptionGroup, OptionTypes, RequiredIntegerOption, TestOption +} import io.stepfunc.dnp3c.dnp3.transport.Transport object OutstationOptions extends OptionGroup { @@ -38,6 +40,13 @@ object OutstationOptions extends OptionGroup { List(1, 2, 3) ) + val bulletins: BulletinsOption = OptionTypes.bulletins( + this, + "bulletins", + "Technical bulletins the outstation implements, comma separated, which change what some steps of a revision " + + "check: none by default, so a run checks each revision as published" + ) + def prefix: String = "outstation" def description: String = "Outstation application layer options" @@ -47,5 +56,6 @@ object OutstationOptions extends OptionGroup { selectTimeout, responseTimeout, appConfirmTimeout, - configuredFragmentSize) + configuredFragmentSize, + bulletins) } diff --git a/src/main/scala/io/stepfunc/dnp3c/conformance/common/EventTime.scala b/src/main/scala/io/stepfunc/dnp3c/conformance/common/EventTime.scala new file mode 100644 index 0000000..be88fea --- /dev/null +++ b/src/main/scala/io/stepfunc/dnp3c/conformance/common/EventTime.scala @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright Step Function I/O LLC + +package io.stepfunc.dnp3c.conformance.common + +import scala.util.Try + +import io.stepfunc.dnp3c.dnp3.{ Comment, DeferredCheck, MessageValidation, ProcedureReporter, TimeQuality } +import io.stepfunc.dnp3c.dnp3.app.{ Message, Variation } +import io.stepfunc.dnp3c.dnp3.app.objects.{ Group51Var1, Group51Var2 } + +/** + * How binary and double-bit input events recorded with time are reported, by the state of the device's clock when it + * recorded them: an event recorded while the clock was synchronized in the variation preferred; one recorded while it + * was unsynchronized with relative time, after an unsynchronized CTO (g51v2). That's TB2017-003a 5.1.3.1.2, which + * 3.1r1's 8.14.1.1 adopts, and which 2.7 applies only for a device that lists the bulletin. + */ +object EventTime { + + /** how the device's clock stood when it recorded the events, as far as the procedure, or the plugin, knows */ + enum Clock { + case Synchronized, Unsynchronized + // it changed while the events were recorded, so some may have been recorded each way + case Changed + case Unknown + } + + /** which of the batches of events a procedure generated a response reports */ + enum Batches { + // the latest batch only: the procedure confirmed the earlier ones + case Latest + // every batch since the procedure started + case All + } + + /** + * How the clock stood while the batches were generated, by the time quality the plugin reported before and after each + * (IntegrationPlugin.timeQuality): unknown if it didn't say, every time + */ + def recorded(reporter: ProcedureReporter, batches: Batches): Clock = { + val clocks = batches match { + case Batches.Latest => reporter.eventClocks.lastOption.toList + case Batches.All => reporter.eventClocks + } + val qualities = clocks.flatMap((before, after) => List(before, after)) + if (qualities.isEmpty || qualities.contains(None)) Clock.Unknown + else + qualities.flatten.distinct match { + case List(TimeQuality.Synchronized) => Clock.Synchronized + case List(TimeQuality.Unsynchronized) => Clock.Unsynchronized + case _ => Clock.Changed + } + } + + // a way to report the events: the variations they may use, and the CTOs that may come with them + final case class Form(name: String, events: List[Variation], ctos: Set[Variation]) { + def validation: MessageValidation = Validations.eventsWithCtos(events, ctos) + + def describe: String = + s"$name (${events.mkString(" or ")}${if (ctos.isEmpty) "" else s", ${ctos.mkString(" or ")}"})" + } + + /** + * The ways to report the input's events, recorded with time, for a read whose preferred variation has time + * + * @param relativeTime whether the device reports events with relative time: only one that does can report events + * recorded unsynchronized as the rule requires, so where the clock is unknown, one that doesn't reports them as + * synchronized + */ + def withTime(input: InputType, preferred: Variation, relativeTime: Boolean, clock: Clock): List[Form] = { + val relative = input.relativeTimeVariation + val unsynchronized = Form("unsynchronized: relative time after g51v2", List(relative), Set(Group51Var2)) + val synchronized = + if (preferred == relative) Form("synchronized: relative time after g51v1", List(relative), Set(Group51Var1)) + else Form(s"synchronized: $preferred", List(preferred), Set.empty) + // each event as its own state requires, in any mix + val either = Form( + "synchronized and unsynchronized, each as its state requires", + (synchronized.events ++ unsynchronized.events).distinct, + synchronized.ctos ++ unsynchronized.ctos + ) + clock match { + case Clock.Synchronized => List(synchronized) + case Clock.Unsynchronized => List(unsynchronized) + case Clock.Changed => List(either) + case Clock.Unknown => if (relativeTime) List(synchronized, unsynchronized) else List(synchronized) + } + } + + /** + * For a device that implements TB2017-003a, a response to a read of `requested`, a variation with absolute time, + * reports the events as 5.1.3.1.2 requires, by the clock's state as the plugin reported it while generating them. 2.7 + * checks this in place of the variation its steps require, and notes it on the result. + * + * @param relativeTime whether the device reports events with relative time (see withTime) + */ + def tb2017003a(input: InputType, requested: Variation, relativeTime: Boolean): MessageValidation = + new MessageValidation { + private val bulletin = "TB2017-003a 5.1.3.1.2" + + def description: List[Comment] = List(Comment( + s"Verify that the events are reported as $bulletin requires, which the DUT implements: $requested for events " + + s"recorded while the clock was synchronized, ${input.relativeTimeVariation} after g51v2 for those recorded " + + "while it was unsynchronized, by the clock's state as the plugin reports it")) + + def validate(reporter: ProcedureReporter, message: Message): List[DeferredCheck] = { + // noted first, so the log of a check that fails says which rule it applied + val note = s"The events were checked as $bulletin requires, which the DUT implements, in place of the steps' " + + s"$requested only" + if (!reporter.notes.contains(note)) reporter.note(note) + val clock = recorded(reporter, Batches.Latest) + oneOf(message, reporter, withTime(input, requested, relativeTime, clock), s"$bulletin requires", clock) + Nil + } + } + + /** the response reports the events in one of the forms; what for names the rules, for the failure */ + def oneOf(message: Message, reporter: ProcedureReporter, forms: List[Form], what: String, clock: Clock): Unit = + if (!forms.exists(f => Try(f.validation.validate(reporter, message)).isSuccess)) + throw new Exception( + s"The events aren't reported as $what${describe(clock)}; expected ${forms.map(_.describe).mkString(" or ")}") + + private def describe(clock: Clock): String = clock match { + case Clock.Synchronized => ", for events recorded while the clock was synchronized" + case Clock.Unsynchronized => ", for events recorded while the clock was unsynchronized" + case Clock.Changed => ", for events recorded while the clock went from one state to the other" + case Clock.Unknown => "" + } +} diff --git a/src/main/scala/io/stepfunc/dnp3c/conformance/common/InputEventProcedures.scala b/src/main/scala/io/stepfunc/dnp3c/conformance/common/InputEventProcedures.scala index 9336ce4..20f5a41 100644 --- a/src/main/scala/io/stepfunc/dnp3c/conformance/common/InputEventProcedures.scala +++ b/src/main/scala/io/stepfunc/dnp3c/conformance/common/InputEventProcedures.scala @@ -67,13 +67,13 @@ object InputEventProcedures { // runs whether or not the DUT reports events with time: a DUT without them must reject the request abstract class AllQualifierWithTime(input: InputType) extends EventProcedure(input) { override protected def steps(options: OptionSource): Iterable[LabeledStep] = - events.AllQualifierWithTimeSteps(options.get(input.withTime)) + events.AllQualifierWithTimeSteps(options.get(input.withTime), options) } abstract class CountQualifierWithTime(input: InputType, twoByteCount: Boolean) extends EventProcedure(input, List(input.withTime)) { override protected def steps(options: OptionSource): Iterable[LabeledStep] = - events.CountQualifierWithTimeSteps(options.get(input.numEventsGenerated), twoByteCount) + events.CountQualifierWithTimeSteps(options.get(input.numEventsGenerated), twoByteCount, options) } // runs whether or not the DUT reports events with relative time: a DUT without them must reject the request diff --git a/src/main/scala/io/stepfunc/dnp3c/conformance/common/InputEventSteps.scala b/src/main/scala/io/stepfunc/dnp3c/conformance/common/InputEventSteps.scala index 994f5a2..100690e 100644 --- a/src/main/scala/io/stepfunc/dnp3c/conformance/common/InputEventSteps.scala +++ b/src/main/scala/io/stepfunc/dnp3c/conformance/common/InputEventSteps.scala @@ -4,6 +4,7 @@ package io.stepfunc.dnp3c.conformance.common import io.stepfunc.dnp3c.conformance.api._ +import io.stepfunc.dnp3c.conformance.api.options.OutstationOptions import io.stepfunc.dnp3c.conformance.common.GenericSteps._ import io.stepfunc.dnp3c.dnp3.app._ import io.stepfunc.dnp3c.dnp3.app.objects._ @@ -113,7 +114,7 @@ class InputEventSteps(inputType: InputType) { ) } - def AllQualifierWithTimeSteps(supported: Boolean): List[LabeledStep] = { + def AllQualifierWithTimeSteps(supported: Boolean, options: OptionSource): List[LabeledStep] = { val steps1To5 = List( 1 --> send(Requests.readAllObjects(Sequence.one, anyVariation)), (2 to 3) --> expectEventsEmptied(Sequence.one), @@ -128,7 +129,7 @@ class InputEventSteps(inputType: InputType) { (7 to 12) --> expectSingleResponse( Sequence.two, AutoConfirm.Yes, - Validations.eventResponse(verifyLatestEvents, verifySingleVariationAndQualifiers(absoluteTimeVariation)) + Validations.eventResponse(verifyLatestEvents, withTimeObjects(options)) ), 13 --> send(Requests.readAllObjects(Sequence.three, anyVariation)), 14 --> expectNullResponse(Sequence.three) @@ -146,7 +147,8 @@ class InputEventSteps(inputType: InputType) { steps1To5 ++ steps6To14 } - def CountQualifierWithTimeSteps(numEvents: Int, twoBytesQualifier: Boolean): List[LabeledStep] = { + def CountQualifierWithTimeSteps(numEvents: Int, twoBytesQualifier: Boolean, options: OptionSource) + : List[LabeledStep] = { List( 1 --> send(Requests.readAllObjects(Sequence.one, anyVariation)), (2 to 3) --> expectEventsEmptied(Sequence.one), @@ -155,13 +157,13 @@ class InputEventSteps(inputType: InputType) { (6 to 11) --> expectSingleResponse( Sequence.two, AutoConfirm.Yes, - Validations.eventResponse(verifyFirstEvent, verifySingleVariationAndQualifiers(absoluteTimeVariation)) + Validations.eventResponse(verifyFirstEvent, withTimeObjects(options)) ), 12 --> send(Requests.readCount(Sequence.three, absoluteTimeVariation, numEvents - 1, twoBytesQualifier)), (13 to 15) --> expectSingleResponse( Sequence.three, AutoConfirm.Yes, - Validations.eventResponse(verifyRestEvents, verifySingleVariationAndQualifiers(absoluteTimeVariation)) + Validations.eventResponse(verifyRestEvents, withTimeObjects(options)) ), 16 --> send(Requests.readAllObjects(Sequence.four, anyVariation)), 17 --> expectNullResponse(Sequence.four) @@ -246,6 +248,13 @@ class InputEventSteps(inputType: InputType) { private[common] def verifyValidGroupVariationAndQualifiers: MessageValidation = Validations.eventsWithCtos(List(noTimeVariation, absoluteTimeVariation, relativeTimeVariation)) + // the events of a read of absolute time: in that variation only, as the steps require, or for a DUT that implements + // TB2017-003a, as its rules require + private def withTimeObjects(options: OptionSource): MessageValidation = + if (options.get(OutstationOptions.bulletins).contains(Bulletin.TB2017_003a)) + EventTime.tb2017003a(inputType, absoluteTimeVariation, options.get(withRelativeTime)) + else verifySingleVariationAndQualifiers(absoluteTimeVariation) + // relative-time events only, each preceded by a CTO private[common] def verifyVar3WithCtoResponse: MessageValidation = Validations.eventsWithCtos(List(relativeTimeVariation)) diff --git a/src/main/scala/io/stepfunc/dnp3c/conformance/v31r1/EventRules.scala b/src/main/scala/io/stepfunc/dnp3c/conformance/v31r1/EventRules.scala index 1e56dcf..495b479 100644 --- a/src/main/scala/io/stepfunc/dnp3c/conformance/v31r1/EventRules.scala +++ b/src/main/scala/io/stepfunc/dnp3c/conformance/v31r1/EventRules.scala @@ -3,17 +3,15 @@ package io.stepfunc.dnp3c.conformance.v31r1 -import scala.util.Try - import io.stepfunc.dnp3c.conformance.api.{ ConfigProblem, OptionSource, ProcedureDefinition, TestProcedure } import io.stepfunc.dnp3c.conformance.api.options.{ OutstationOptions, TimeSyncOptions } import io.stepfunc.dnp3c.conformance.api.options.{ AnalogInputOptions, AnalogOutputOptions, ClassOptions } import io.stepfunc.dnp3c.conformance.api.options.SupportOptions import io.stepfunc.dnp3c.conformance.api.options.SupportOptions.{ Inputs, Outputs } -import io.stepfunc.dnp3c.conformance.common.{ InputType, Validations } +import io.stepfunc.dnp3c.conformance.common.{ EventTime, InputType } +import io.stepfunc.dnp3c.conformance.common.EventTime.{ Batches, Clock, Form } import io.stepfunc.dnp3c.dnp3.{ Comment, DeferredCheck, MessageValidation, ProcedureReporter } import io.stepfunc.dnp3c.dnp3.app.{ Message, Variation } -import io.stepfunc.dnp3c.dnp3.app.objects.{ Group51Var1, Group51Var2 } /** * 3.1r1's rules for reporting binary (8.14.1.1) and double-bit (8.22) input events: which variation reports each @@ -25,25 +23,13 @@ import io.stepfunc.dnp3c.dnp3.app.objects.{ Group51Var1, Group51Var2 } * variation after an unsynchronized CTO (g51v2); one recorded without time, without time. * * How the events were recorded follows from the options, which say whether the device's events carry time (all points - * of an input type alike), and from the clock's state where the procedure knows it. Where it doesn't, the response - * must be consistent with one state throughout. The order of the events is checked against the order they were + * of an input type alike), and from the clock's state where the procedure knows it, or else where the plugin reported + * it while the events were generated (IntegrationPlugin.timeQuality). Where neither does, the response must be + * consistent with one state throughout. The order of the events is checked against the order they were * generated, by the event checks the plugin does. */ object EventRules { - /** how the device's clock stood when it recorded the events, as far as the procedure knows */ - sealed trait Clock - object Clock { - case object Synchronized extends Clock - case object Unsynchronized extends Clock - case object Unknown extends Clock - } - - // a way to report the events: the variations they may use, and the CTOs that may come with them - private case class Form(name: String, events: Variation, ctos: Set[Variation]) { - def validation: MessageValidation = Validations.eventsWithCtos(List(events), ctos) - } - /** * What 3.1r1 needs configured whatever procedures a run selects: e.g. at subset levels 2 and 3, reads of every input * and of its events, which only a level 1 device may lack. What a procedure needs is its own (configurationProblems). @@ -126,45 +112,35 @@ object EventRules { /** * The response reports the input's events as the rules require, for a read of `requested` * - * @param clock how the device's clock stood when it recorded the events, if the procedure knows + * @param clock how the device's clock stood when it recorded the events, if the procedure knows; if it doesn't, as + * the plugin reported it while generating them (EventTime.recorded), if it did + * @param batches the batches of events the response reports */ def consistent( input: InputType, requested: Variation, options: OptionSource, - clock: => Clock = Clock.Unknown): MessageValidation = new MessageValidation { + clock: => Clock = Clock.Unknown, + batches: Batches = Batches.Latest): MessageValidation = new MessageValidation { - private def forms: List[Form] = { + private def forms(clock: Clock): List[Form] = { val preferredVariation = preferred(input, requested, options) val withTime = options.get(input.withTime) || options.get(input.withRelativeTime) - val relativeTime = options.get(input.withRelativeTime) - val noTime = Form("without time", input.noTimeVariation, Set.empty) - val unsynchronized = - Form("unsynchronized: relative time after g51v2", input.relativeTimeVariation, Set(Group51Var2)) - val synchronized = - if (preferredVariation == input.relativeTimeVariation) - Form("synchronized: relative time after g51v1", input.relativeTimeVariation, Set(Group51Var1)) - else Form(s"synchronized: $preferredVariation", preferredVariation, Set.empty) - if (preferredVariation == input.noTimeVariation || !withTime) List(noTime) - else - clock match { - case Clock.Synchronized => List(synchronized) - case Clock.Unsynchronized => List(unsynchronized) - // only a device that reports relative time can report unsynchronized events as the rules require - case Clock.Unknown => if (relativeTime) List(synchronized, unsynchronized) else List(synchronized) - } + if (preferredVariation == input.noTimeVariation || !withTime) + List(Form("without time", List(input.noTimeVariation), Set.empty)) + else EventTime.withTime(input, preferredVariation, options.get(input.withRelativeTime), clock) } def description: List[Comment] = List(Comment( - s"Verify that the events are reported as 3.1r1's rules for event reporting require, for a read of $requested")) + s"Verify that the events are reported as 3.1r1's rules for event reporting require, for a read of $requested, " + + "by the state of the DUT's clock when it recorded them, where the procedure or the plugin knows it")) def validate(reporter: ProcedureReporter, message: Message): List[DeferredCheck] = { - val allowed = forms - if (!allowed.exists(f => Try(f.validation.validate(reporter, message)).isSuccess)) - throw new Exception( - s"The events aren't reported as 3.1r1's rules require for a read of $requested; expected " + - allowed.map(f => s"${f.name} (${f.events}${if (f.ctos.isEmpty) "" else s", ${f.ctos.mkString(" or ")}"})") - .mkString(" or ")) + val known = clock match { + case Clock.Unknown => EventTime.recorded(reporter, batches) + case c => c + } + EventTime.oneOf(message, reporter, forms(known), s"3.1r1's rules require for a read of $requested", known) Nil } } diff --git a/src/main/scala/io/stepfunc/dnp3c/conformance/v31r1/InputEvents.scala b/src/main/scala/io/stepfunc/dnp3c/conformance/v31r1/InputEvents.scala index ee5aa88..bc2be0b 100644 --- a/src/main/scala/io/stepfunc/dnp3c/conformance/v31r1/InputEvents.scala +++ b/src/main/scala/io/stepfunc/dnp3c/conformance/v31r1/InputEvents.scala @@ -7,6 +7,7 @@ import io.stepfunc.dnp3c.conformance.api._ import io.stepfunc.dnp3c.conformance.common.GenericSteps._ import io.stepfunc.dnp3c.conformance.common.InputEventProcedures.NoEvents import io.stepfunc.dnp3c.conformance.common.{ InputType, Validations } +import io.stepfunc.dnp3c.conformance.common.EventTime.{ Batches, Clock } import io.stepfunc.dnp3c.dnp3.app._ import io.stepfunc.dnp3c.dnp3.{ AutoConfirm, Comment, DeferredCheck, MessageValidation, Requests, TestDriver } @@ -35,13 +36,15 @@ object InputEvents { import input._ - // an event response: the events the plugin generated, a request for confirmation, the rules, and the flags + // an event response: the events the plugin generated, a request for confirmation, the rules, and the flags; the + // clock as the procedure knows it, or else as the plugin reported it for the batches the response reports protected def events( events: MessageValidation, requested: Variation, options: OptionSource, - clock: => EventRules.Clock = EventRules.Clock.Unknown): MessageValidation = - Validations.eventResponse(events, EventRules.consistent(input, requested, options, clock)) + clock: => Clock = Clock.Unknown, + batches: Batches = Batches.Latest): MessageValidation = + Validations.eventResponse(events, EventRules.consistent(input, requested, options, clock, batches)) // steps 1 to 3: read the events left and confirm them; a response with events must ask for confirmation protected def empty(seq: Sequence): List[LabeledStep] = List( @@ -200,7 +203,10 @@ object InputEvents { 19 --> generateEvents, 20 --> send(Requests.readAllObjects(Sequence.four, anyVariation)), (21 to 23) --> - expectSingleResponse(Sequence.four, AutoConfirm.Yes, events(verifyOldAndNewEvents, anyVariation, options)), + expectSingleResponse( + Sequence.four, + AutoConfirm.Yes, + events(verifyOldAndNewEvents, anyVariation, options, batches = Batches.All)), 24 --> send(Requests.readAllObjects(Sequence.five, anyVariation)), 25 --> expectNullResponse(Sequence.five) ) @@ -227,7 +233,6 @@ object InputEvents { final class AllQualifierWithRelativeTime(input: InputType) extends EventTest(input) { import input._ import io.stepfunc.dnp3c.conformance.api.options.TimeSyncOptions - import EventRules.Clock // 8.14.2m waits for the clock to go unsynchronized: how long is the manufacturer's to say override def configurationProblems(options: OptionSource): List[ConfigProblem] = @@ -265,7 +270,7 @@ object InputEvents { // the third phase: a device that reports relative time and asks for the time val expires = options.get(withRelativeTime) && synchronize val expiry = "the DUT reports events with relative time and asks for the time" - // after step 17's time write the clock is synchronized; with no write, its state isn't known + // after step 17's time write the clock is synchronized; with no write, the procedure doesn't know its state def afterSync: Clock = if (synchronize && needTime) Clock.Synchronized else Clock.Unknown start ++ List( (3 to 4) --> diff --git a/src/main/scala/io/stepfunc/dnp3c/dnp3/IntegrationPlugin.scala b/src/main/scala/io/stepfunc/dnp3c/dnp3/IntegrationPlugin.scala index 21a2986..bc5bbd2 100644 --- a/src/main/scala/io/stepfunc/dnp3c/dnp3/IntegrationPlugin.scala +++ b/src/main/scala/io/stepfunc/dnp3c/dnp3/IntegrationPlugin.scala @@ -121,6 +121,14 @@ final case class NetworkSetup( } } +/** + * The time-quality state of a DUT's clock, which says how it reports binary and double-bit input events recorded in it + * (TB2017-003a 5.1.3.1): synchronized with a time source, or drifted since and needing synchronization + */ +enum TimeQuality { + case Synchronized, Unsynchronized +} + /** A DUT's collision avoidance delays: a backoff is the fixed delay plus a random one of up to maxRandomMs */ final case class CollisionDelays(fixedMs: Int, maxRandomMs: Int) @@ -184,6 +192,11 @@ trait IntegrationPlugin { def generateInputChanges(reporter: PluginReporter, kind: InputKind[?]): Unit // generates exactly numEvents events of the kind def generateExactInputEvents(reporter: PluginReporter, kind: InputKind[?], numEvents: Int): Unit + // the time-quality state of the DUT's clock now, for events it records, if the plugin knows it. Asked before and + // after each generation of binary and double-bit input events, it tells the checks of their variations how the + // events were recorded (3.1r1's 8.14.1.1, TB2017-003a 5.1.3.1.2). The default doesn't know, and those checks then + // accept any one way of reporting them throughout. + def timeQuality(reporter: PluginReporter): Option[TimeQuality] = None def generateClassEvents(reporter: PluginReporter, eventClass: EventClass): Unit def generateExactClassEvents(reporter: PluginReporter, eventClass: EventClass, numEvents: Int): Unit // sets the inputs of the kind to a known pattern: binary, double-bit and analog inputs diff --git a/src/main/scala/io/stepfunc/dnp3c/dnp3/TestReporter.scala b/src/main/scala/io/stepfunc/dnp3c/dnp3/TestReporter.scala index 0e8a07a..c676e2b 100644 --- a/src/main/scala/io/stepfunc/dnp3c/dnp3/TestReporter.scala +++ b/src/main/scala/io/stepfunc/dnp3c/dnp3/TestReporter.scala @@ -158,4 +158,15 @@ final class ProcedureReporter(logger: Logger, val plugin: IntegrationPlugin) ext } def warnings: List[String] = warned.toList + + private val clocks = scala.collection.mutable.ListBuffer.empty[(Option[TimeQuality], Option[TimeQuality])] + + /** + * Record the time quality the plugin reported before and after the procedure generated a batch of binary or + * double-bit input events, for the checks of how they're reported + */ + def recordEventClock(before: Option[TimeQuality], after: Option[TimeQuality]): Unit = clocks += before -> after + + /** the time qualities recorded for each batch of events, oldest first */ + def eventClocks: List[(Option[TimeQuality], Option[TimeQuality])] = clocks.toList } diff --git a/src/test/scala/io/stepfunc/dnp3c/conformance/common/EventTimeTest.scala b/src/test/scala/io/stepfunc/dnp3c/conformance/common/EventTimeTest.scala new file mode 100644 index 0000000..e8a5729 --- /dev/null +++ b/src/test/scala/io/stepfunc/dnp3c/conformance/common/EventTimeTest.scala @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Copyright Step Function I/O LLC + +package io.stepfunc.dnp3c.conformance.common + +import io.stepfunc.dnp3c.conformance.Run +import io.stepfunc.dnp3c.conformance.api.{ Bulletin, Options, ProcedureDefinition } +import io.stepfunc.dnp3c.conformance.api.options.{ OutstationOptions, TimeSyncOptions } +import io.stepfunc.dnp3c.conformance.common.EventTime.{ Batches, Clock } +import io.stepfunc.dnp3c.conformance.{ v27, v31r1 } +import io.stepfunc.dnp3c.dnp3._ +import io.stepfunc.dnp3c.dnp3.app._ +import io.stepfunc.dnp3c.dnp3.app.objects.{ Group2Var2, Group4Var2 } +import org.junit.runner.RunWith +import org.scalatestplus.junit.JUnitRunner +import org.scalatest.matchers.should.Matchers +import org.scalatest.funsuite.AnyFunSuite + +// the clock the plugin reports while events are generated, and how 2.7 and 3.1r1 check the events' variations by it +@RunWith(classOf[JUnitRunner]) +class EventTimeTest extends AnyFunSuite with Matchers { + + import TimeQuality.{ Synchronized, Unsynchronized } + + // a plugin that reports the clock's time quality before and after each generation as given, in turn, and passes every + // check of the events' values + private class Plugin(qualities: Option[TimeQuality]*) extends DefaultIntegrationPlugin { + private val answers = Iterator.continually(qualities).flatten + override def timeQuality(reporter: PluginReporter): Option[TimeQuality] = answers.next() + override def generateInputChanges(reporter: PluginReporter, kind: InputKind[?]): Unit = () + override def generateExactInputEvents(reporter: PluginReporter, kind: InputKind[?], numEvents: Int): Unit = () + override def verifyInputEvents[P <: MeasurementPoint]( + reporter: CheckReporter, + kind: InputKind[P], + expectation: EventExpectation, + points: Seq[GenIndexedPoint[P]]): Option[DeferredCheck] = None + } + + // a DUT that answers a read of events with absolute time with the objects, in hex, asking for confirmation, and any + // other read with a Null Response + private class Dut(reporter: ProcedureReporter, objects: String) extends RespondingDriver(reporter) { + override def writeMessage(message: Message, options: LinkOverride): Unit = + if (message.function != AppFunction.confirm) { + val events = message.headers.exists(h => h.variation == Group2Var2 || h.variation == Group4Var2) + val control = 0xC0 | (if (events) 0x20 else 0) | message.ctrl.seq.value + enqueue(f"$control%02X 81 00 00 " + (if (events) objects else "")) + } + } + + // one event at index 0, online, in each form, for binary and double-bit inputs + private case class Forms(absolute: String, relative: String) + private val binary = Forms("02 02 17 01 00 01 00 00 00 00 00 00", "02 03 17 01 00 01 00 00") + private val doubleBit = Forms("04 02 17 01 00 01 00 00 00 00 00 00", "04 03 17 01 00 01 00 00") + private val synchronizedCto = "33 01 07 01 00 00 00 00 00 00" + private val unsynchronizedCto = "33 02 07 01 00 00 00 00 00 00" + + private val tb2017003a = OutstationOptions.bulletins.id -> Bulletin.TB2017_003a.id + + private def passes( + procedure: ProcedureDefinition, + objects: String, + plugin: Plugin, + settings: (String, String)*): Boolean = { + val reporter = StubDriver.reporter(plugin) + Run.passes(procedure, new Dut(reporter, objects), Options.from(settings.toMap)) + } + + private val families = List( + (binary, v27.Procedure8_14_2_9, v31r1.InputEvents.binaryAllWithTime), + (doubleBit, v27.Procedure8_22_2_9, v31r1.InputEvents.doubleBitAllWithTime)) + + test("the option lists the bulletins, by id in any case, and none by default") { + Options.from(Map()).get(OutstationOptions.bulletins) should equal(Set.empty) + Options.from(Map(OutstationOptions.bulletins.id -> " tb2017-003A ")).get(OutstationOptions.bulletins) should + equal(Set(Bulletin.TB2017_003a)) + an[Exception] should be thrownBy Options.from(Map(OutstationOptions.bulletins.id -> "TB2017-003")) + } + + test("the clock recorded for a batch: as the plugin reports it, changed if it changed, unknown if it didn't say") { + def recorded(batches: Batches, clocks: (Option[TimeQuality], Option[TimeQuality])*): Clock = { + val reporter = StubDriver.reporter() + clocks.foreach((before, after) => reporter.recordEventClock(before, after)) + EventTime.recorded(reporter, batches) + } + recorded(Batches.Latest) should equal(Clock.Unknown) + recorded(Batches.Latest, Some(Synchronized) -> Some(Synchronized)) should equal(Clock.Synchronized) + recorded(Batches.Latest, Some(Unsynchronized) -> Some(Unsynchronized)) should equal(Clock.Unsynchronized) + recorded(Batches.Latest, Some(Synchronized) -> Some(Unsynchronized)) should equal(Clock.Changed) + recorded(Batches.Latest, Some(Synchronized) -> None) should equal(Clock.Unknown) + val twoBatches = List(Some(Unsynchronized) -> Some(Unsynchronized), Some(Synchronized) -> Some(Synchronized)) + recorded(Batches.Latest, twoBatches*) should equal(Clock.Synchronized) + recorded(Batches.All, twoBatches*) should equal(Clock.Changed) + } + + test("2.7 without the bulletin accepts only absolute time, whatever the plugin reports") { + for ((forms, procedure, _) <- families) { + passes(procedure, forms.absolute, new Plugin(Some(Unsynchronized))) should equal(true) + passes(procedure, unsynchronizedCto + " " + forms.relative, new Plugin(Some(Unsynchronized))) should equal(false) + } + } + + test("2.7 with TB2017-003a: absolute time for synchronized events, relative time after g51v2 for unsynchronized") { + for ((forms, procedure, _) <- families) { + val unsynchronized = unsynchronizedCto + " " + forms.relative + passes(procedure, forms.absolute, new Plugin(Some(Synchronized)), tb2017003a) should equal(true) + passes(procedure, unsynchronized, new Plugin(Some(Synchronized)), tb2017003a) should equal(false) + passes(procedure, unsynchronized, new Plugin(Some(Unsynchronized)), tb2017003a) should equal(true) + passes(procedure, forms.absolute, new Plugin(Some(Unsynchronized)), tb2017003a) should equal(false) + // a synchronized event is reported in the variation requested, not with relative time after g51v1 + passes(procedure, synchronizedCto + " " + forms.relative, new Plugin(Some(Synchronized)), tb2017003a) should + equal(false) + } + } + + test("2.7 with TB2017-003a, where the plugin doesn't know the clock: either way, throughout") { + for ((forms, procedure, _) <- families) { + passes(procedure, forms.absolute, new Plugin(None), tb2017003a) should equal(true) + passes(procedure, unsynchronizedCto + " " + forms.relative, new Plugin(None), tb2017003a) should equal(true) + } + } + + test("2.7 with TB2017-003a notes the bulletin on the result, whether it passes or fails") { + val reporter = StubDriver.reporter(new Plugin(Some(Synchronized))) + Run.passes(v27.Procedure8_14_2_9, new Dut(reporter, binary.absolute), Options.from(Map(tb2017003a))) should + equal(true) + reporter.notes.head should include("TB2017-003a 5.1.3.1.2") + // and before a check that fails, for its log + val failing = StubDriver.reporter(new Plugin(Some(Unsynchronized))) + Run.passes(v27.Procedure8_14_2_9, new Dut(failing, binary.absolute), Options.from(Map(tb2017003a))) should + equal(false) + failing.notes.head should include("TB2017-003a 5.1.3.1.2") + } + + test("3.1r1 checks the events by the clock the plugin reports, with or without the bulletin") { + for ((forms, _, procedure) <- families; bulletins <- List(Nil, List(tb2017003a))) { + val unsynchronized = unsynchronizedCto + " " + forms.relative + passes(procedure, forms.absolute, new Plugin(Some(Synchronized)), bulletins*) should equal(true) + passes(procedure, forms.absolute, new Plugin(Some(Unsynchronized)), bulletins*) should equal(false) + passes(procedure, unsynchronized, new Plugin(Some(Unsynchronized)), bulletins*) should equal(true) + passes(procedure, unsynchronized, new Plugin(Some(Synchronized)), bulletins*) should equal(false) + // unknown, as before: either way, throughout + passes(procedure, forms.absolute, new Plugin(None), bulletins*) should equal(true) + passes(procedure, unsynchronized, new Plugin(None), bulletins*) should equal(true) + } + } + + test("events recorded while the clock changed may be reported each way, in one response") { + val mixed = s"${binary.absolute} $unsynchronizedCto ${binary.relative}" + passes(v31r1.InputEvents.binaryAllWithTime, mixed, new Plugin(Some(Synchronized), Some(Unsynchronized))) should + equal(true) + passes(v27.Procedure8_14_2_9, mixed, new Plugin(Some(Synchronized), Some(Unsynchronized)), tb2017003a) should + equal(true) + // but not where the clock didn't change, or where the plugin doesn't know it did + passes(v31r1.InputEvents.binaryAllWithTime, mixed, new Plugin(Some(Synchronized))) should equal(false) + passes(v31r1.InputEvents.binaryAllWithTime, mixed, new Plugin(None)) should equal(false) + // relative time after a synchronized CTO is still wrong for a read of absolute time + val wrongCto = s"${binary.absolute} $synchronizedCto ${binary.relative}" + passes(v31r1.InputEvents.binaryAllWithTime, wrongCto, new Plugin(Some(Synchronized), Some(Unsynchronized))) should + equal(false) + } + + test("a device without relative time reports events as synchronized where the clock is unknown, as before") { + val absoluteOnly = TimeSyncOptions.binaryInput.withRelativeTime.id -> "false" + passes(v27.Procedure8_14_2_9, binary.absolute, new Plugin(None), tb2017003a, absoluteOnly) should equal(true) + passes( + v27.Procedure8_14_2_9, + s"$unsynchronizedCto ${binary.relative}", + new Plugin(None), + tb2017003a, + absoluteOnly) should + equal(false) + } +} diff --git a/src/test/scala/io/stepfunc/dnp3c/conformance/v31r1/EventRulesTest.scala b/src/test/scala/io/stepfunc/dnp3c/conformance/v31r1/EventRulesTest.scala index cb50e76..651daf2 100644 --- a/src/test/scala/io/stepfunc/dnp3c/conformance/v31r1/EventRulesTest.scala +++ b/src/test/scala/io/stepfunc/dnp3c/conformance/v31r1/EventRulesTest.scala @@ -7,7 +7,7 @@ import io.stepfunc.dnp3c.conformance.Revisions import io.stepfunc.dnp3c.conformance.api.{ ConfigurationException, Options } import io.stepfunc.dnp3c.conformance.api.options.{ BinaryInputOptions, OutstationOptions, TimeSyncOptions } import io.stepfunc.dnp3c.conformance.common.InputType.BinaryInput -import io.stepfunc.dnp3c.conformance.v31r1.EventRules.Clock +import io.stepfunc.dnp3c.conformance.common.EventTime.Clock import io.stepfunc.dnp3c.dnp3.Validate import io.stepfunc.dnp3c.dnp3.app.Message import io.stepfunc.dnp3c.dnp3.app.objects.{ Group2Var0, Group2Var1, Group2Var2, Group2Var3 }