diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b169a45..45f9611c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -359,9 +359,18 @@ 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: + name: conformance (${{ matrix.revision }}, level ${{ matrix.level }}) 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 @@ -378,11 +387,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 - working-directory: conformance/results + # dnp3c keeps each revision's results, for each subset level, in a folder of its own + 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: | @@ -402,8 +416,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] @@ -446,8 +470,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: @@ -600,8 +639,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 ac6975ee..126fb943 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 @@ -441,3 +439,25 @@ 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 +# +# 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 +# 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/device.properties b/conformance/device.properties deleted file mode 100644 index b1b03b90..00000000 --- a/conformance/device.properties +++ /dev/null @@ -1,422 +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 -control.uninstalled.index = 65535 - -# Maximum number of control points that are supported in a single request min: 1 -control.maxControlPointsInSingleRequest = 1 - -# List of all installed BO indices -control.installedPoints = 0 ... 9 - -# Index of a supported Group12Var1 -control.primary.valid.index = 0 - -# Valid control function in hexadecimal from set HashSet(04, 03, 81, 41, 01) -control.primary.valid.function = 3 - -# Valid count for Group12Var1 min: 1 max: 255 -control.primary.valid.count = 1 - -# On time in ms min: 0 max: 65535 -control.primary.valid.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -control.primary.valid.offTime.ms = 100 - -# Index of a supported Group12Var1 -control.alternate.valid.index = 1 - -# Valid control function in hexadecimal from set HashSet(04, 03, 81, 41, 01) -control.alternate.valid.function = 3 - -# Valid count for Group12Var1 min: 1 max: 255 -control.alternate.valid.count = 1 - -# On time in ms min: 0 max: 65535 -control.alternate.valid.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -control.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 - -# Valid count for Group12Var1 min: 1 max: 255 -control.latchOnOffPoint.count = 1 - -# On time in ms min: 0 max: 65535 -control.latchOnOffPoint.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -control.latchOnOffPoint.offTime.ms = 100 - -# Optional control point supporting complementary function codes Pulse On/Close (0x41) and Pulse On/Trip (0x81). -control.pulseOnOffPoint.index = 1 - -# Valid count for Group12Var1 min: 1 max: 255 -control.pulseOnOffPoint.count = 1 - -# On time in ms min: 0 max: 65535 -control.pulseOnOffPoint.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -control.pulseOnOffPoint.offTime.ms = 100 - -# Optional control point supporting complementary function codes 0x03, 0x04, 0x41 and 0x81. -control.latchAndPulsePoint.index = 1 - -# Valid count for Group12Var1 min: 1 max: 255 -control.latchAndPulsePoint.count = 1 - -# On time in ms min: 0 max: 65535 -control.latchAndPulsePoint.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -control.latchAndPulsePoint.offTime.ms = 100 - -# Optional single function point supported function code Pulse On/NUL (0x01). -control.singleFunctionPulseOnPoint.index = 1 - -# Valid count for Group12Var1 min: 1 max: 255 -control.singleFunctionPulseOnPoint.count = 1 - -# On time in ms min: 0 max: 65535 -control.singleFunctionPulseOnPoint.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -control.singleFunctionPulseOnPoint.offTime.ms = 100 - -# Optional single function point supported function code Latch On/NUL (0x03). -control.singleFunctionLatchOnPoint.index = 1 - -# Valid count for Group12Var1 min: 1 max: 255 -control.singleFunctionLatchOnPoint.count = 1 - -# On time in ms min: 0 max: 65535 -control.singleFunctionLatchOnPoint.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -control.singleFunctionLatchOnPoint.offTime.ms = 100 - -# Optional single function point supported function code Latch Off/NUL (0x04). -control.singleFunctionLatchOffPoint.index = 1 - -# Valid count for Group12Var1 min: 1 max: 255 -control.singleFunctionLatchOffPoint.count = 1 - -# On time in ms min: 0 max: 65535 -control.singleFunctionLatchOffPoint.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -control.singleFunctionLatchOffPoint.offTime.ms = 100 - -# Optional single function point supported function code Pulse On/Close (0x41). -control.singleFunctionPulseOnClosePoint.index = 1 - -# Valid count for Group12Var1 min: 1 max: 255 -control.singleFunctionPulseOnClosePoint.count = 1 - -# On time in ms min: 0 max: 65535 -control.singleFunctionPulseOnClosePoint.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -control.singleFunctionPulseOnClosePoint.offTime.ms = 100 - -# Optional single function point supported function code Pulse On/Trip (0x81). -control.singleFunctionPulseOnTripPoint.index = 1 - -# Valid count for Group12Var1 min: 1 max: 255 -control.singleFunctionPulseOnTripPoint.count = 1 - -# On time in ms min: 0 max: 65535 -control.singleFunctionPulseOnTripPoint.onTime.ms = 100 - -# Off time in ms min: 0 max: 65535 -control.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.maxAnalogOutputsInSingleRequest = 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.maxDoubleBitBinaryInputEvents = 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.numBinaryEventsGenerated = 1 - -# Binary Input Events class assignment. min: 1 max: 3 -binaryInput.binaryEventsClass = 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 - -# Double-bit Input Events class assignment. min: 1 max: 3 -doubleBitInput.doubleBitEventsClass = 1 - -################################################################################ -# Options related to counters (g20 and g22) -################################################################################ - -# Number of Counter Events that will be generated manually. min: 1 -counter.numCounterEventsGenerated = 1 - -# Counter Events class assignment. min: 1 max: 3 -counter.counterEventsClass = 1 - -################################################################################ -# Options related to analog inputs (g30 and g32) -################################################################################ - -# Analog Input Events class assignment. min: 1 max: 3 -analogInput.analogInputEventsClass = 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.doubleBit = true - -# Is the device is configurable in a manner such that all Double Bit points can be uninstalled or disabled? -supports.disable.doubleBit = true - -# Does the DUT support Double Bit change events? -supports.doubleBitChangeEvents = 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.analog = true - -# Is the device is configurable in a manner such that all Analog Input points can be uninstalled or disabled? -supports.disable.analog = true - -# Does the DUT support Analog Input change events? -supports.analogChangeEvents = 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 - -# 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 - -# 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 - diff --git a/conformance/pom.xml b/conformance/pom.xml index 4e37be00..eb334a41 100644 --- a/conformance/pom.xml +++ b/conformance/pom.xml @@ -12,8 +12,11 @@ UTF-8 3.9.0 1.8.0-M1 - 0.1.0 + 0.2.0 4.10.0 + + 3.1r1 @@ -43,6 +46,8 @@ conformance io.stepfunc.conformance.dnp3.Main + -rev + ${conformance.revision} -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 2af055d5..047021c3 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 72c6be4b..0025e1b3 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, 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 @@ -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() } @@ -49,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") } } @@ -108,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 = { @@ -132,39 +126,76 @@ 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") + 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(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) } - override def uninstallDoubleBitBinaryInputs(reporter: PluginReporter): Unit = { - config = config.copy(testDatabaseConfig = config.testDatabaseConfig.copy(disableDoubleBitBinaryInputs = true)) - reporter.log("All DBBI points were uninstalled") - 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 uninstallCounters(reporter: PluginReporter): Unit = { - config = config.copy(testDatabaseConfig = config.testDatabaseConfig.copy(disableCounters = true)) - reporter.log("All counter points were uninstalled") - cyclePower(reporter) + 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 uninstallBinaryOutputs(reporter: PluginReporter): Unit = { - config = config.copy(commandHandlerConfig = config.commandHandlerConfig.copy(disableBinaryOutput = true)) - reporter.log("All BO points were uninstalled") - cyclePower(reporter) + 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}") + } } - override def uninstallAnalogOutputs(reporter: PluginReporter): Unit = { - config = config.copy(commandHandlerConfig = config.commandHandlerConfig.copy(disableAnalogOutput = true)) - reporter.log("All AO points were uninstalled") - cyclePower(reporter) + // 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, 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 { + 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, disableBinaryOutputStatus = false, disableAnalogOutputStatus = false), commandHandlerConfig = CommandHandlerConfig(disableBinaryOutput = false, disableAnalogOutput = false) ) reporter.log("All points were reinstalled") @@ -220,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 } @@ -231,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 = { @@ -395,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 } @@ -410,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 } @@ -442,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 } @@ -804,6 +835,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 +864,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 5905b803..9d55d723 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, disableBinaryOutputStatus: Boolean = false, disableAnalogOutputStatus: 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 d8169d02..5b0f08dc 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 16279d26..156a2b09 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 @@ -123,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 @@ -131,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 @@ -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 diff --git a/guide/docs/about/interoperability.mdx b/guide/docs/about/interoperability.mdx index 778285b4..a7441d42 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.