fix(siteruntime): decode List value to typed array before DCL write (OPC UA array write path)
This commit is contained in:
@@ -431,11 +431,37 @@ public class InstanceActor : ReceiveActor
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MV (C1): for a data-sourced List attribute the incoming command.Value is
|
||||||
|
// the canonical JSON array string (ScopeAccessors encodes the script's
|
||||||
|
// List<T> for transport/storage). Writing that string straight to the DCL
|
||||||
|
// would push a String scalar to an array node. Decode it back to a typed
|
||||||
|
// List<T> so the DCL/Variant write produces a real array. A non-empty value
|
||||||
|
// that fails to decode (malformed JSON / bad element) is poison — reject the
|
||||||
|
// write rather than forward garbage to the device (mirrors the static-path
|
||||||
|
// rejection in HandleSetStaticAttributeCore). Scalars are unchanged.
|
||||||
|
object? writeValue = command.Value;
|
||||||
|
if (IsListAttribute(resolved) && !string.IsNullOrWhiteSpace(command.Value))
|
||||||
|
{
|
||||||
|
var decoded = DecodeAttributeValue(resolved, command.Value);
|
||||||
|
if (decoded == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"SetAttribute rejected — value for data-sourced List attribute '{Attribute}' on instance '{Instance}' is not a valid list",
|
||||||
|
attributeName, instanceName);
|
||||||
|
caller.Tell(new SetStaticAttributeResponse(
|
||||||
|
correlationId, instanceName, attributeName, false,
|
||||||
|
$"Invalid list value for attribute '{attributeName}'", DateTimeOffset.UtcNow));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeValue = decoded;
|
||||||
|
}
|
||||||
|
|
||||||
var writeRequest = new WriteTagRequest(
|
var writeRequest = new WriteTagRequest(
|
||||||
correlationId,
|
correlationId,
|
||||||
resolved.BoundDataConnectionName!,
|
resolved.BoundDataConnectionName!,
|
||||||
resolved.DataSourceReference!,
|
resolved.DataSourceReference!,
|
||||||
command.Value,
|
writeValue,
|
||||||
DateTimeOffset.UtcNow);
|
DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
// Ask the DCL and pipe the result back to the original caller. The DCL
|
// Ask the DCL and pipe the result back to the original caller. The DCL
|
||||||
|
|||||||
+32
-8
@@ -1,20 +1,44 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// MV-8: the OPC UA WRITE path (<see cref="RealOpcUaClient.WriteValueAsync"/>)
|
/// SCOPE: these tests cover ONLY the SDK-level building block the write path
|
||||||
/// wraps the outgoing value in <c>new Variant(value)</c> and lets the OPC
|
/// relies on — that <c>new Variant(collection)</c> wraps a CLR array / list as a
|
||||||
/// Foundation SDK serialize it. For a structured multi-value (List) attribute
|
/// typed array <see cref="Variant"/> (ValueRank = OneDimension) without throwing.
|
||||||
/// the value handed down is a CLR array. These tests assert that the load-bearing
|
/// They are NOT an end-to-end test of the runtime write flow: they feed a
|
||||||
/// step — wrapping an array in a <see cref="Variant"/> — succeeds without
|
/// hand-built collection straight into <see cref="Variant"/>, bypassing the
|
||||||
/// throwing, which is what the write path relies on (no separate array handling
|
/// InstanceActor decode step that produces that collection.
|
||||||
/// is required in our code). A full device round-trip needs a live server and is
|
///
|
||||||
/// covered by the live OPC UA browse/read smoke tests.
|
/// The END-TO-END flow — a script's canonical JSON list string being DECODED to a
|
||||||
|
/// typed <c>List<T></c> before the <c>WriteTagRequest</c> reaches the DCL
|
||||||
|
/// (so OPC UA writes an array node, not a String scalar) — is covered by
|
||||||
|
/// <c>InstanceActorTests.InstanceActor_DataSourcedListWrite_SendsTypedArrayToDcl_NotJsonString</c>.
|
||||||
|
/// The runtime hands <see cref="RealOpcUaClient.WriteValueAsync"/> a
|
||||||
|
/// <c>List<T></c> (the codec's decode result), which the SDK wraps
|
||||||
|
/// identically to a CLR array — see the <c>List<int></c> case below. A full
|
||||||
|
/// device round-trip needs a live server and is covered by the live OPC UA smoke
|
||||||
|
/// tests.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
public class RealOpcUaClientArrayWriteTests
|
public class RealOpcUaClientArrayWriteTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Variant_wraps_int_list_as_array_without_throwing()
|
||||||
|
{
|
||||||
|
// The runtime actually hands WriteValueAsync a List<T> (the decode result),
|
||||||
|
// not a raw T[]; assert the SDK wraps it as a typed array all the same.
|
||||||
|
var value = new List<int> { 10, 20, 30 };
|
||||||
|
|
||||||
|
var ex = Record.Exception(() => new Variant(value));
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
var variant = new Variant(value);
|
||||||
|
Assert.Equal(BuiltInType.Int32, variant.TypeInfo.BuiltInType);
|
||||||
|
Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Variant_wraps_int_array_without_throwing()
|
public void Variant_wraps_int_array_without_throwing()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -660,6 +660,94 @@ public class InstanceActorTests : TestKit, IDisposable
|
|||||||
Assert.Equal(1450, response.Value);
|
Assert.Equal(1450, response.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MV (C1 fix): a WRITE to a data-sourced <c>DataType.List</c> attribute must
|
||||||
|
/// send the DCL a TYPED collection (so OPC UA writes an array node), NOT the
|
||||||
|
/// canonical JSON string the script layer produced. The script path encodes
|
||||||
|
/// <c>List<int></c> to <c>"[10,20,30]"</c>; HandleSetDataAttribute must
|
||||||
|
/// decode that back to a typed <c>List<int></c> before building the
|
||||||
|
/// WriteTagRequest. We assert the captured WriteTagRequest.Value is the typed
|
||||||
|
/// list {10,20,30} — never the string "[10,20,30]".
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void InstanceActor_DataSourcedListWrite_SendsTypedArrayToDcl_NotJsonString()
|
||||||
|
{
|
||||||
|
const string tag = "ns=3;s=Pump.Setpoints";
|
||||||
|
var config = new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = "Pump-ListWrite",
|
||||||
|
Attributes =
|
||||||
|
[
|
||||||
|
new ResolvedAttribute
|
||||||
|
{
|
||||||
|
CanonicalName = "Setpoints", Value = null,
|
||||||
|
DataType = "List", ElementDataType = "Int32",
|
||||||
|
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var dcl = CreateTestProbe();
|
||||||
|
var actor = CreateInstanceActorWithDcl("Pump-ListWrite", config, dcl);
|
||||||
|
|
||||||
|
// Script-style write: ScopeAccessors (AttributeValueCodec.Encode) has
|
||||||
|
// already encoded the script's List<int> to the canonical JSON array string,
|
||||||
|
// which is an array of element STRINGS (not raw JSON numbers).
|
||||||
|
actor.Tell(new SetStaticAttributeCommand(
|
||||||
|
"corr-write", "Pump-ListWrite", "Setpoints", "[\"10\",\"20\",\"30\"]", DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
// The DCL must receive a WriteTagRequest carrying a TYPED collection.
|
||||||
|
var write = dcl.ExpectMsg<WriteTagRequest>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal("PLC", write.ConnectionName);
|
||||||
|
Assert.Equal(tag, write.TagPath);
|
||||||
|
Assert.IsNotType<string>(write.Value);
|
||||||
|
var list = Assert.IsType<List<int>>(write.Value);
|
||||||
|
Assert.Equal(new[] { 10, 20, 30 }, list);
|
||||||
|
|
||||||
|
// Complete the Ask so the actor replies success to the caller.
|
||||||
|
dcl.Reply(new WriteTagResponse("corr-write", true, null, DateTimeOffset.UtcNow));
|
||||||
|
var response = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.True(response.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MV (C1 fix): a malformed value written to a data-sourced List attribute
|
||||||
|
/// must be REJECTED before reaching the DCL — Success=false and NO
|
||||||
|
/// WriteTagRequest is forwarded (mirrors the static-path malformed rejection).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void InstanceActor_DataSourcedListWrite_Malformed_Rejected_NoDclWrite()
|
||||||
|
{
|
||||||
|
const string tag = "ns=3;s=Pump.Bad";
|
||||||
|
var config = new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = "Pump-ListWriteBad",
|
||||||
|
Attributes =
|
||||||
|
[
|
||||||
|
new ResolvedAttribute
|
||||||
|
{
|
||||||
|
CanonicalName = "Setpoints", Value = null,
|
||||||
|
DataType = "List", ElementDataType = "Int32",
|
||||||
|
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var dcl = CreateTestProbe();
|
||||||
|
var actor = CreateInstanceActorWithDcl("Pump-ListWriteBad", config, dcl);
|
||||||
|
|
||||||
|
// Malformed JSON (unterminated array, non-int element) → reject the write.
|
||||||
|
actor.Tell(new SetStaticAttributeCommand(
|
||||||
|
"corr-bad-write", "Pump-ListWriteBad", "Setpoints", "[\"a\"", DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
var response = ExpectMsg<SetStaticAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.False(response.Success);
|
||||||
|
Assert.NotNull(response.ErrorMessage);
|
||||||
|
|
||||||
|
// No write must reach the DCL.
|
||||||
|
dcl.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
|
||||||
|
}
|
||||||
|
|
||||||
private void ExpectNoTerminated(IActorRef actor, TimeSpan within)
|
private void ExpectNoTerminated(IActorRef actor, TimeSpan within)
|
||||||
{
|
{
|
||||||
// The actor is Watch()ed; assert no Terminated arrives in the window.
|
// The actor is Watch()ed; assert no Terminated arrives in the window.
|
||||||
|
|||||||
Reference in New Issue
Block a user