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.ScriptExecution;
|
||||
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.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
@@ -433,21 +434,141 @@ public class InstanceActor : ReceiveActor
|
||||
if (!_tagPathToAttributes.TryGetValue(update.TagPath, out var attrNames))
|
||||
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.
|
||||
// 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)
|
||||
{
|
||||
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,
|
||||
value, update.Quality.ToString(), update.Timestamp);
|
||||
HandleAttributeValueChanged(changed);
|
||||
value, update.Quality.ToString(), update.Timestamp));
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
_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.TestKit;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -444,4 +445,182 @@ public class InstanceActorTests : TestKit, IDisposable
|
||||
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