I can understand DNP3 writes may be tricky since there's not one way to simply write a value to a tag. The directOperateBinary looks like it will solve that problem, but introduces another problem for deriving the device name and index rather than accepting tagPath.
Yeah, you'll have to figure that out. There's zero correlation between a tag path and whatever the point index might be (unless you create one by convention).
Direct- or Select-And- Operate in DNP3 is a complex operation, it's not simply a write. There's some fidelity loss when you map it to something conceptually simpler like the OPC UA write service. The operations require many parameters and there's no way to provide those via the write service of another protocol, which is why there are some defaults to make it work.
The proper way to map DNP3 operations to OPC UA would be via OPC UA methods, and that's what we would have done if we were a more generic/standalone server, but since the primary consumer is Ignition itself we provide the scripting functions instead, which are basically the same thing but easier to use than OPC UA methods.
@msteele Hi, I'm having the exact same issue with BinaryOutput, AnalogueOutput DNP3 tags having Bad("Bad_OutOfService: The source of the data is not operational.") quality. And I'm also reading them from a SEL RTAC using the new DNP3 driver.
Just wondering what did you do at the end to get them working please?
In our tag processor, we have two line items for each tag.
To make the data readable and get rid of the error you mention, we map the .status of BOs in our Ignition_DNP tag map.
E.g. destination tag Ignition_DNP.BO_TripBreaker_OurBreaker as datatype SPS
to source expression OurRelay_SEL.OurBreaker_52a_closed as datatype SPS.
We also have to map the trip signal from Ignition to the appropriate trip signal for a breaker or control.
E.g. destination tag OurRelay_SEL.RB1_TripBreaker_OurBreaker.operPulse as datatype OPERSPC to source expression Ignition_DNP.BO_TripBreaker_OurBreaker.operTrip as datatype OPERSPC.
We finally got around to testing system.dnp.directOperateBinary() to a SEL RTAC's DNPC 61131 data structure. Our interpretation of DNP3 is that there are 5 function codes (Trip, Close, Pulse, Latch On, Latch Off) so the point of this exercise was to map out which combination of tcc and opType produce these five function codes over the wire.
Observations:
*As expected, tcc = 0, opType = 0 gives an error since there's no applicable function code.
†tcc = 0, opType = 2 gives an Ignition error, which makes sense because PULSE_OFF is, per my understanding, not an actual DNP function code.
‡Where tcc != 0 NUL, tcc overrides opType.
Additional arguments used in our test: count = 1, onTime = 1, offTime = 1. Our understanding is the time arguments should have no effect on RTAC response.
As mentioned upthread, if you want to poll a tag value via your BO, you need to map something to the .status SPS object in your RTAC. This object is a sibling to the five .oper* objects representing the DNP3 function codes.
Some helper functions for DNP3 BO and AO writes. getDNP3Index presumes your addressing uses the standard syntax, e.g. BinaryOutput0
# DNP3 Control + conversion of tagPath to inputs needed for system.dnp.* functions
# Compile the patterns once, because "dramatically faster"
devNamePattern = re.compile(r'\[(.*?)\]')
devIndexPattern = re.compile(r'(\d+)$')
def getDnp3Device(tagPath):
"""Extracts the device name for the given tag path."""
# Since opcItemPath is a tag property, no timeout is needed.
opcPath = system.tag.readBlocking(tagPath + '.OpcItemPath')[0].value
match = devNamePattern.search(opcPath)
if match:
return match.group(1)
return None
#
def getDnp3Index(tagPath):
"""Extracts the DNP3 index from the """
opcPath = system.tag.readBlocking(tagPath + '.OpcItemPath')[0].value
match = devIndexPattern.search(opcPath)
if match:
return match.group(1)
return None
#
# Map integers representing DNP3 BO commands to tcc, opType
funcCodesMap = {
0: (2, 0), # Trip
1: (1, 0), # Close
2: (0, 1), # Pulse
3: (0, 3), # Latch On
4: (0, 4), # Latch Off
}
# Binary operate.
def doWriteDnp3Bo(tagPath, dnp3FuncCode):
"""Wrapper for `system.dnp.directOperateBinary` func call for new DNP3 devices.
dnp3FuncCodes:
0=Trip
1=Close
2=Pulse
3=Latch On
4=Latch Off
"""
deviceName = control.getDnp3Device(tagPath)
index = int(control.getDnp3Index(tagPath))
tcc, opType = funcCodesMap[dnp3FuncCode]
# system.dnp.directOperateBinary(deviceName, index, tcc, opType, count, onTime, offTime)
system.dnp.directOperateBinary(deviceName, index, tcc, opType, 1, 1, 1)
#
# Analog operate. Default variation is float.
def doWriteDnp3Ao(tagPath, value, variation=3):
"""Wrapper for `system.dnp.directOperateAnalog` func call for new DNP3 devices. Default variation is float."""
deviceName = control.getDnp3Device(tagPath)
index = int(control.getDnp3Index(tagPath))
system.dnp.directOperateAnalog(deviceName, variation, index, value)
#
Edit: two minor bugfixes. Also, apparently Pulse Off may be a thing in the DNP3 standard after all, even though it's not supported by RTAC.
It's defined as an Op Type, but not seemingly required, and this note follows later in the spec:
Vendors are free to implement operations using the PULSE_OFF value in the Op Type field, but these may not be interoperable with all DNP3 masters, and alternate outstation vendors may not implement these operations.
One use would be the generation of a pulse train that ends in the output remaining active. For example, if the output value was active prior to a PULSE_OFF command, then the output would go inactive for the time determined by the Off-time and then the output would change to active.