diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index 6588dd8b..21da0ecd 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -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. 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)); } } + /// True if the resolved attribute is declared as a . + private static bool IsListAttribute(ResolvedAttribute attr) => + Enum.TryParse(attr.DataType, ignoreCase: true, out var dt) + && dt == DataType.List; + + /// + /// MV-8: coerces an incoming data-sourced value (an OPC UA array / IEnumerable) + /// into a typed List<elementClrType> matching the attribute's + /// . Each element is converted + /// with invariant culture (round-trip parse for DateTime). Returns + /// 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. + /// + private bool TryCoerceListValue(ResolvedAttribute attr, object? incoming, out object? typedList) + { + typedList = null; + + if (string.IsNullOrEmpty(attr.ElementDataType) + || !Enum.TryParse(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}", diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientArrayWriteTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientArrayWriteTests.cs new file mode 100644 index 00000000..c3babe72 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaClientArrayWriteTests.cs @@ -0,0 +1,56 @@ +using Opc.Ua; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters; + +/// +/// MV-8: the OPC UA WRITE path () +/// wraps the outgoing value in new Variant(value) 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 — 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. +/// +[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); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs index 7ea11242..ffa04e29 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs @@ -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.Instance, + dcl.Ref))); + + // On startup the actor subscribes its data-sourced tags through the DCL. + dcl.ExpectMsg(TimeSpan.FromSeconds(5)); + return actor; + } + + /// + /// MV-8: when a data-sourced attribute is declared DataType.List, an + /// incoming OPC UA array value (a CLR array surfaces from the SDK) must be + /// coerced into a typed List<int> 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. + /// + [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(TimeSpan.FromSeconds(5)); + + Assert.True(response.Found); + Assert.Equal("Good", response.Quality); + var list = Assert.IsType>(response.Value); + Assert.Equal(new[] { 10, 20, 30 }, list); + } + + /// + /// 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. + /// + [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. + 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(TimeSpan.FromSeconds(5)); + + Assert.True(response.Found); + Assert.Equal("Good", response.Quality); + var list = Assert.IsType>(response.Value); + Assert.Equal(new[] { 1.0, 2.5, 3.0 }, list); + } + + /// + /// MV-8: an element that cannot be coerced to the declared element type must + /// set the attribute quality to Bad and must NOT crash the actor (it + /// stays alive and continues to answer queries). + /// + [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(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)); + } + + /// + /// MV-8 guard: scalar (non-List) data-sourced attributes keep the existing + /// pass-through behaviour — a scalar value is stored unchanged. + /// + [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(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()); + } }