feat(dcl): coerce OPC UA array reads to typed list attributes; Bad quality on element mismatch
This commit is contained in:
@@ -7,6 +7,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||||
@@ -433,21 +434,141 @@ public class InstanceActor : ReceiveActor
|
|||||||
if (!_tagPathToAttributes.TryGetValue(update.TagPath, out var attrNames))
|
if (!_tagPathToAttributes.TryGetValue(update.TagPath, out var attrNames))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Normalize array values to JSON strings so they survive Akka serialization
|
|
||||||
var value = update.Value is Array
|
|
||||||
? System.Text.Json.JsonSerializer.Serialize(update.Value, update.Value.GetType())
|
|
||||||
: update.Value;
|
|
||||||
|
|
||||||
// One tag path may back several attributes — update every one of them.
|
// One tag path may back several attributes — update every one of them.
|
||||||
|
// Each attribute is coerced according to its own declared data type, so
|
||||||
|
// we resolve and convert per attribute rather than once for the tag.
|
||||||
foreach (var attrName in attrNames)
|
foreach (var attrName in attrNames)
|
||||||
{
|
{
|
||||||
var changed = new AttributeValueChanged(
|
var resolved = _configuration?.Attributes
|
||||||
|
.FirstOrDefault(a => a.CanonicalName == attrName);
|
||||||
|
|
||||||
|
// MV-8: a List-typed attribute coerces the incoming OPC UA array
|
||||||
|
// (a CLR array/IEnumerable from the SDK) into a typed List<T>. On an
|
||||||
|
// element-type mismatch we set the attribute's quality to Bad, log a
|
||||||
|
// warning, and skip storing a value rather than crashing the actor.
|
||||||
|
if (resolved != null && IsListAttribute(resolved))
|
||||||
|
{
|
||||||
|
if (TryCoerceListValue(resolved, update.Value, out var typedList))
|
||||||
|
{
|
||||||
|
HandleAttributeValueChanged(new AttributeValueChanged(
|
||||||
|
_instanceUniqueName, update.TagPath, attrName,
|
||||||
|
typedList, update.Quality.ToString(), update.Timestamp));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"List attribute {Instance}.{Attribute} received a value that could not be coerced to List<{Element}>; marking quality Bad",
|
||||||
|
_instanceUniqueName, attrName, resolved.ElementDataType);
|
||||||
|
_attributeQualities[attrName] = "Bad";
|
||||||
|
_attributeTimestamps[attrName] = update.Timestamp;
|
||||||
|
var currentValue = _attributes.GetValueOrDefault(attrName);
|
||||||
|
PublishAndNotifyChildren(new AttributeValueChanged(
|
||||||
|
_instanceUniqueName, update.TagPath, attrName,
|
||||||
|
currentValue, "Bad", update.Timestamp));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scalars and non-List attributes keep the historical behaviour:
|
||||||
|
// array values are normalized to JSON strings so they survive Akka
|
||||||
|
// serialization; scalars pass through unchanged.
|
||||||
|
var value = update.Value is Array
|
||||||
|
? System.Text.Json.JsonSerializer.Serialize(update.Value, update.Value.GetType())
|
||||||
|
: update.Value;
|
||||||
|
|
||||||
|
HandleAttributeValueChanged(new AttributeValueChanged(
|
||||||
_instanceUniqueName, update.TagPath, attrName,
|
_instanceUniqueName, update.TagPath, attrName,
|
||||||
value, update.Quality.ToString(), update.Timestamp);
|
value, update.Quality.ToString(), update.Timestamp));
|
||||||
HandleAttributeValueChanged(changed);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>True if the resolved attribute is declared as a <see cref="DataType.List"/>.</summary>
|
||||||
|
private static bool IsListAttribute(ResolvedAttribute attr) =>
|
||||||
|
Enum.TryParse<DataType>(attr.DataType, ignoreCase: true, out var dt)
|
||||||
|
&& dt == DataType.List;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MV-8: coerces an incoming data-sourced value (an OPC UA array / IEnumerable)
|
||||||
|
/// into a typed <c>List<elementClrType></c> matching the attribute's
|
||||||
|
/// <see cref="ResolvedAttribute.ElementDataType"/>. Each element is converted
|
||||||
|
/// with invariant culture (round-trip parse for DateTime). Returns
|
||||||
|
/// <see langword="false"/> on a missing/invalid element type, a non-enumerable
|
||||||
|
/// value, or any element that cannot be coerced — the caller then marks the
|
||||||
|
/// attribute quality Bad. Never throws.
|
||||||
|
/// </summary>
|
||||||
|
private bool TryCoerceListValue(ResolvedAttribute attr, object? incoming, out object? typedList)
|
||||||
|
{
|
||||||
|
typedList = null;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(attr.ElementDataType)
|
||||||
|
|| !Enum.TryParse<DataType>(attr.ElementDataType, ignoreCase: true, out var elementType)
|
||||||
|
|| !AttributeValueCodec.IsValidElementType(elementType))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incoming is not System.Collections.IEnumerable enumerable || incoming is string)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var clrType = ListElementClrType(elementType);
|
||||||
|
var list = (System.Collections.IList)Activator.CreateInstance(
|
||||||
|
typeof(List<>).MakeGenericType(clrType))!;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var element in enumerable)
|
||||||
|
list.Add(CoerceElement(element, elementType));
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is FormatException or InvalidCastException
|
||||||
|
or OverflowException or ArgumentNullException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedList = list;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Type ListElementClrType(DataType t) => t switch
|
||||||
|
{
|
||||||
|
DataType.String => typeof(string),
|
||||||
|
DataType.Int32 => typeof(int),
|
||||||
|
DataType.Float => typeof(float),
|
||||||
|
DataType.Double => typeof(double),
|
||||||
|
DataType.Boolean => typeof(bool),
|
||||||
|
DataType.DateTime => typeof(DateTime),
|
||||||
|
_ => throw new FormatException($"Unsupported list element type '{t}'.")
|
||||||
|
};
|
||||||
|
|
||||||
|
private static object CoerceElement(object? element, DataType t)
|
||||||
|
{
|
||||||
|
if (element is null)
|
||||||
|
throw new FormatException("List elements may not be null.");
|
||||||
|
|
||||||
|
var culture = System.Globalization.CultureInfo.InvariantCulture;
|
||||||
|
return t switch
|
||||||
|
{
|
||||||
|
DataType.String => Convert.ToString(element, culture)
|
||||||
|
?? throw new FormatException("Null string element."),
|
||||||
|
DataType.Int32 => element is string si
|
||||||
|
? int.Parse(si, culture)
|
||||||
|
: Convert.ToInt32(element, culture),
|
||||||
|
DataType.Float => element is string sf
|
||||||
|
? float.Parse(sf, culture)
|
||||||
|
: Convert.ToSingle(element, culture),
|
||||||
|
DataType.Double => element is string sd
|
||||||
|
? double.Parse(sd, culture)
|
||||||
|
: Convert.ToDouble(element, culture),
|
||||||
|
DataType.Boolean => element is string sb
|
||||||
|
? bool.Parse(sb)
|
||||||
|
: Convert.ToBoolean(element, culture),
|
||||||
|
DataType.DateTime => element is string sdt
|
||||||
|
? DateTime.Parse(sdt, culture, System.Globalization.DateTimeStyles.RoundtripKind)
|
||||||
|
: Convert.ToDateTime(element, culture),
|
||||||
|
_ => throw new FormatException($"Unsupported list element type '{t}'.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleConnectionQualityChanged(ConnectionQualityChanged qualityChanged)
|
private void HandleConnectionQualityChanged(ConnectionQualityChanged qualityChanged)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Connection {Connection} quality changed to {Quality} for instance {Instance}",
|
_logger.LogWarning("Connection {Connection} quality changed to {Quality} for instance {Instance}",
|
||||||
|
|||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
using Opc.Ua;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MV-8: the OPC UA WRITE path (<see cref="RealOpcUaClient.WriteValueAsync"/>)
|
||||||
|
/// wraps the outgoing value in <c>new Variant(value)</c> and lets the OPC
|
||||||
|
/// Foundation SDK serialize it. For a structured multi-value (List) attribute
|
||||||
|
/// the value handed down is a CLR array. These tests assert that the load-bearing
|
||||||
|
/// step — wrapping an array in a <see cref="Variant"/> — succeeds without
|
||||||
|
/// throwing, which is what the write path relies on (no separate array handling
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public class RealOpcUaClientArrayWriteTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Variant_wraps_int_array_without_throwing()
|
||||||
|
{
|
||||||
|
var value = new[] { 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]
|
||||||
|
public void Variant_wraps_double_array_without_throwing()
|
||||||
|
{
|
||||||
|
var value = new[] { 1.5, 2.5, 3.5 };
|
||||||
|
|
||||||
|
var ex = Record.Exception(() => new Variant(value));
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
var variant = new Variant(value);
|
||||||
|
Assert.Equal(BuiltInType.Double, variant.TypeInfo.BuiltInType);
|
||||||
|
Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Variant_wraps_string_array_without_throwing()
|
||||||
|
{
|
||||||
|
var value = new[] { "a", "b", "c" };
|
||||||
|
|
||||||
|
var ex = Record.Exception(() => new Variant(value));
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
var variant = new Variant(value);
|
||||||
|
Assert.Equal(BuiltInType.String, variant.TypeInfo.BuiltInType);
|
||||||
|
Assert.Equal(ValueRanks.OneDimension, variant.TypeInfo.ValueRank);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit;
|
||||||
using Akka.TestKit.Xunit2;
|
using Akka.TestKit.Xunit2;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
@@ -444,4 +445,182 @@ public class InstanceActorTests : TestKit, IDisposable
|
|||||||
Assert.Equal("Good", response.Quality);
|
Assert.Equal("Good", response.Quality);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── MV-8: data-sourced List attribute coercion ─────────────────────────
|
||||||
|
|
||||||
|
private IActorRef CreateInstanceActorWithDcl(string instanceName, FlattenedConfiguration config, TestProbe dcl)
|
||||||
|
{
|
||||||
|
var actor = ActorOf(Props.Create(() => new InstanceActor(
|
||||||
|
instanceName,
|
||||||
|
JsonSerializer.Serialize(config),
|
||||||
|
_storage,
|
||||||
|
_compilationService,
|
||||||
|
_sharedScriptLibrary,
|
||||||
|
null,
|
||||||
|
_options,
|
||||||
|
NullLogger<InstanceActor>.Instance,
|
||||||
|
dcl.Ref)));
|
||||||
|
|
||||||
|
// On startup the actor subscribes its data-sourced tags through the DCL.
|
||||||
|
dcl.ExpectMsg<SubscribeTagsRequest>(TimeSpan.FromSeconds(5));
|
||||||
|
return actor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MV-8: when a data-sourced attribute is declared <c>DataType.List</c>, an
|
||||||
|
/// incoming OPC UA array value (a CLR array surfaces from the SDK) must be
|
||||||
|
/// coerced into a typed <c>List<int></c> whose elements match the
|
||||||
|
/// attribute's ElementDataType. The stored value must be a real list — not a
|
||||||
|
/// JSON string — so scripts read a typed collection.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void InstanceActor_DataSourcedListAttribute_CoercesArrayToTypedList()
|
||||||
|
{
|
||||||
|
const string tag = "ns=3;s=Pump.Setpoints";
|
||||||
|
var config = new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = "Pump-List",
|
||||||
|
Attributes =
|
||||||
|
[
|
||||||
|
new ResolvedAttribute
|
||||||
|
{
|
||||||
|
CanonicalName = "Setpoints", Value = null,
|
||||||
|
DataType = "List", ElementDataType = "Int32",
|
||||||
|
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var dcl = CreateTestProbe();
|
||||||
|
var actor = CreateInstanceActorWithDcl("Pump-List", config, dcl);
|
||||||
|
|
||||||
|
// OPC UA delivers an array value (CLR array) for the List-typed tag.
|
||||||
|
actor.Tell(new TagValueUpdate("PLC", tag, new[] { 10, 20, 30 }, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
actor.Tell(new GetAttributeRequest("corr-list", "Pump-List", "Setpoints", DateTimeOffset.UtcNow));
|
||||||
|
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
Assert.True(response.Found);
|
||||||
|
Assert.Equal("Good", response.Quality);
|
||||||
|
var list = Assert.IsType<List<int>>(response.Value);
|
||||||
|
Assert.Equal(new[] { 10, 20, 30 }, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MV-8: array elements coming in as a different CLR type (here, strings that
|
||||||
|
/// are valid integers) must still coerce to the declared element type.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void InstanceActor_DataSourcedListAttribute_CoercesElementTypes()
|
||||||
|
{
|
||||||
|
const string tag = "ns=3;s=Pump.Levels";
|
||||||
|
var config = new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = "Pump-ListCoerce",
|
||||||
|
Attributes =
|
||||||
|
[
|
||||||
|
new ResolvedAttribute
|
||||||
|
{
|
||||||
|
CanonicalName = "Levels", Value = null,
|
||||||
|
DataType = "List", ElementDataType = "Double",
|
||||||
|
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var dcl = CreateTestProbe();
|
||||||
|
var actor = CreateInstanceActorWithDcl("Pump-ListCoerce", config, dcl);
|
||||||
|
|
||||||
|
// Elements arrive as ints/strings but the attribute is List<double>.
|
||||||
|
actor.Tell(new TagValueUpdate("PLC", tag, new object[] { 1, "2.5", 3 }, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
actor.Tell(new GetAttributeRequest("corr-coerce", "Pump-ListCoerce", "Levels", DateTimeOffset.UtcNow));
|
||||||
|
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
Assert.True(response.Found);
|
||||||
|
Assert.Equal("Good", response.Quality);
|
||||||
|
var list = Assert.IsType<List<double>>(response.Value);
|
||||||
|
Assert.Equal(new[] { 1.0, 2.5, 3.0 }, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MV-8: an element that cannot be coerced to the declared element type must
|
||||||
|
/// set the attribute quality to <c>Bad</c> and must NOT crash the actor (it
|
||||||
|
/// stays alive and continues to answer queries).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void InstanceActor_DataSourcedListAttribute_ElementMismatch_SetsBadQuality_ActorAlive()
|
||||||
|
{
|
||||||
|
const string tag = "ns=3;s=Pump.Bad";
|
||||||
|
var config = new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = "Pump-ListBad",
|
||||||
|
Attributes =
|
||||||
|
[
|
||||||
|
new ResolvedAttribute
|
||||||
|
{
|
||||||
|
CanonicalName = "Counts", Value = null,
|
||||||
|
DataType = "List", ElementDataType = "Int32",
|
||||||
|
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var dcl = CreateTestProbe();
|
||||||
|
var actor = CreateInstanceActorWithDcl("Pump-ListBad", config, dcl);
|
||||||
|
Watch(actor);
|
||||||
|
|
||||||
|
// "not-a-number" cannot be coerced to int → Bad quality, no crash.
|
||||||
|
actor.Tell(new TagValueUpdate("PLC", tag, new object[] { 1, "not-a-number", 3 }, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
actor.Tell(new GetAttributeRequest("corr-bad", "Pump-ListBad", "Counts", DateTimeOffset.UtcNow));
|
||||||
|
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
Assert.True(response.Found);
|
||||||
|
Assert.Equal("Bad", response.Quality);
|
||||||
|
|
||||||
|
// The actor must still be alive (no crash / restart) and serving.
|
||||||
|
ExpectNoTerminated(actor, TimeSpan.FromMilliseconds(500));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MV-8 guard: scalar (non-List) data-sourced attributes keep the existing
|
||||||
|
/// pass-through behaviour — a scalar value is stored unchanged.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void InstanceActor_DataSourcedScalarAttribute_UnchangedByListPath()
|
||||||
|
{
|
||||||
|
const string tag = "ns=3;s=Pump.Speed";
|
||||||
|
var config = new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = "Pump-Scalar",
|
||||||
|
Attributes =
|
||||||
|
[
|
||||||
|
new ResolvedAttribute
|
||||||
|
{
|
||||||
|
CanonicalName = "Speed", Value = "0", DataType = "Int32",
|
||||||
|
DataSourceReference = tag, BoundDataConnectionName = "PLC"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var dcl = CreateTestProbe();
|
||||||
|
var actor = CreateInstanceActorWithDcl("Pump-Scalar", config, dcl);
|
||||||
|
|
||||||
|
actor.Tell(new TagValueUpdate("PLC", tag, 1450, QualityCode.Good, DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
actor.Tell(new GetAttributeRequest("corr-scalar", "Pump-Scalar", "Speed", DateTimeOffset.UtcNow));
|
||||||
|
var response = ExpectMsg<GetAttributeResponse>(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
Assert.True(response.Found);
|
||||||
|
Assert.Equal("Good", response.Quality);
|
||||||
|
Assert.Equal(1450, response.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExpectNoTerminated(IActorRef actor, TimeSpan within)
|
||||||
|
{
|
||||||
|
// The actor is Watch()ed; assert no Terminated arrives in the window.
|
||||||
|
ExpectNoMsg(within);
|
||||||
|
Assert.False(actor.IsNobody());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user