feat(dcl): coerce OPC UA array reads to typed list attributes; Bad quality on element mismatch

This commit is contained in:
Joseph Doherty
2026-06-16 15:39:19 -04:00
parent 872ce2b565
commit 4765706e94
3 changed files with 364 additions and 8 deletions
@@ -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&lt;elementClrType&gt;</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}",