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}",
|
||||
|
||||
Reference in New Issue
Block a user