fix(dcl): coerce scalar OPC UA writes to the node DataType (#58)
Codec-encoded scalars ("True", "42.5") reached RealOpcUaClient.WriteValuesAsync
as strings and were sent as String Variants, so a Boolean/Double node answered
BadTypeMismatch (0x80740000).
RealOpcUaClient now reads each written node's DataType attribute once per
session (batched, cached, cleared on connect/disconnect; derived types and
enumerations resolved via the supertype chain) and converts non-null scalar
values with the new OpcUaWriteCoercion table: Boolean, SByte..UInt64, Float,
Double, String, DateTime, Guid, all culture-invariant. Overflow, fractional
values to integer types and unparseable strings are refused client-side with
BadTypeMismatch and a message naming the value and target type - never a
rounded value. Arrays, null, unmapped types and nodes whose DataType read
fails pass through unchanged. WriteValueAsync now surfaces the outcome's own
status code instead of always BadNodeIdUnknown. MxGateway path untouched.
Tests: OpcUaWriteCoercionTests (coercion table incl. overflow, invalid and
culture-sensitive strings) and OpcUaWriteCoercionLiveTests (RequiresOpcUa:
String writes to Double/Boolean/UInt32 opc-plc nodes read back typed; reverting
the coercion reproduces 0x80740000). Component-DataConnectionLayer.md updated.
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
using System.Collections;
|
||||
using System.Globalization;
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #58: converts a scalar write value to the CLR type that matches the target node's
|
||||
/// OPC UA built-in <c>DataType</c> before <see cref="RealOpcUaClient"/> wraps it in a
|
||||
/// <see cref="Variant"/>.
|
||||
/// <para>
|
||||
/// The runtime hands the DCL codec-encoded scalars (an attribute value of <c>"True"</c> or
|
||||
/// <c>"42.5"</c>), and <c>new Variant("42.5")</c> is a String Variant, which a conformant
|
||||
/// server rejects on a Double node with <c>BadTypeMismatch</c>. The OPC UA write service does
|
||||
/// no implicit conversion, so the client has to send the node's own type.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Rules: every string parse is culture-invariant; a value that cannot be represented exactly
|
||||
/// in the target type (overflow, a fractional value to an integer type, an unparseable string)
|
||||
/// is an error, never a rounded or truncated value. Arrays/collections, <c>null</c> and target
|
||||
/// types with no scalar mapping here (structures, abstract <c>Number</c>, <c>BaseDataType</c>,
|
||||
/// …) pass through unchanged, so the server stays the judge of those.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class OpcUaWriteCoercion
|
||||
{
|
||||
/// <summary>The outcome of a coercion attempt.</summary>
|
||||
/// <param name="Success">False when the value cannot be represented in the target type.</param>
|
||||
/// <param name="Value">The value to write (the original value when nothing applied).</param>
|
||||
/// <param name="Error">Why the conversion failed; null on success.</param>
|
||||
internal readonly record struct Result(bool Success, object? Value, string? Error)
|
||||
{
|
||||
internal static Result Ok(object? value) => new(true, value, null);
|
||||
internal static Result Fail(string error) => new(false, null, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="value"/> is a scalar this class may coerce: not null, not a
|
||||
/// string-less collection. A <c>byte[]</c> is a ByteString scalar in OPC UA, but it is
|
||||
/// never a codec scalar and is left to the SDK as-is.
|
||||
/// </summary>
|
||||
internal static bool IsCoercibleScalar(object? value) =>
|
||||
value is not null && (value is string || value is not IEnumerable);
|
||||
|
||||
/// <summary>
|
||||
/// Coerces <paramref name="value"/> to the CLR representation of <paramref name="target"/>.
|
||||
/// </summary>
|
||||
internal static Result Coerce(object? value, BuiltInType target)
|
||||
{
|
||||
if (!IsCoercibleScalar(value))
|
||||
return Result.Ok(value);
|
||||
|
||||
try
|
||||
{
|
||||
return target switch
|
||||
{
|
||||
BuiltInType.Boolean => ToBoolean(value!),
|
||||
BuiltInType.SByte => ToInteger(value!, target, sbyte.MinValue, sbyte.MaxValue, d => (sbyte)d),
|
||||
BuiltInType.Byte => ToInteger(value!, target, byte.MinValue, byte.MaxValue, d => (byte)d),
|
||||
BuiltInType.Int16 => ToInteger(value!, target, short.MinValue, short.MaxValue, d => (short)d),
|
||||
BuiltInType.UInt16 => ToInteger(value!, target, ushort.MinValue, ushort.MaxValue, d => (ushort)d),
|
||||
BuiltInType.Int32 => ToInteger(value!, target, int.MinValue, int.MaxValue, d => (int)d),
|
||||
// An enumeration DataType travels as Int32 on the wire (Part 6 §5.1.2).
|
||||
BuiltInType.Enumeration => ToInteger(value!, BuiltInType.Int32, int.MinValue, int.MaxValue, d => (int)d),
|
||||
BuiltInType.UInt32 => ToInteger(value!, target, uint.MinValue, uint.MaxValue, d => (uint)d),
|
||||
BuiltInType.Int64 => ToInteger(value!, target, long.MinValue, long.MaxValue, d => (long)d),
|
||||
BuiltInType.UInt64 => ToInteger(value!, target, ulong.MinValue, ulong.MaxValue, d => (ulong)d),
|
||||
BuiltInType.Float => ToFloat(value!),
|
||||
BuiltInType.Double => ToDouble(value!),
|
||||
BuiltInType.String => Result.Ok(ToInvariantString(value!)),
|
||||
BuiltInType.DateTime => ToDateTime(value!),
|
||||
BuiltInType.Guid => ToGuid(value!),
|
||||
_ => Result.Ok(value)
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or OverflowException or InvalidCastException)
|
||||
{
|
||||
return Fail(value!, target, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static Result Fail(object value, BuiltInType target, string reason) =>
|
||||
Result.Fail($"Cannot convert {Describe(value)} to the node's OPC UA DataType {target}: {reason}");
|
||||
|
||||
private static string Describe(object value) => value is string s
|
||||
? $"String \"{(s.Length > 64 ? s[..64] + "…" : s)}\""
|
||||
: $"{value.GetType().Name} {ToInvariantString(value)}";
|
||||
|
||||
private static Result ToBoolean(object value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case bool b:
|
||||
return Result.Ok(b);
|
||||
case string s:
|
||||
var t = s.Trim();
|
||||
if (bool.TryParse(t, out var parsed)) return Result.Ok(parsed);
|
||||
if (t == "1") return Result.Ok(true);
|
||||
if (t == "0") return Result.Ok(false);
|
||||
return Fail(value, BuiltInType.Boolean, "expected true/false or 1/0.");
|
||||
default:
|
||||
if (TryToDecimal(value, out var d) && (d == 0m || d == 1m)) return Result.Ok(d == 1m);
|
||||
return Fail(value, BuiltInType.Boolean, "only 0 or 1 map to a Boolean.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Result ToInteger(object value, BuiltInType target, decimal min, decimal max, Func<decimal, object> narrow)
|
||||
{
|
||||
decimal d;
|
||||
switch (value)
|
||||
{
|
||||
case bool b:
|
||||
d = b ? 1m : 0m;
|
||||
break;
|
||||
case string s:
|
||||
var t = s.Trim();
|
||||
// Integer syntax first (full Int64/UInt64 range), then a decimal literal that
|
||||
// happens to be whole ("42.0") — a fractional or exponent-overflowing string fails.
|
||||
if (!decimal.TryParse(t, NumberStyles.Integer, CultureInfo.InvariantCulture, out d)
|
||||
&& !decimal.TryParse(t, NumberStyles.Float, CultureInfo.InvariantCulture, out d))
|
||||
return Fail(value, target, "not a number.");
|
||||
break;
|
||||
default:
|
||||
if (value is double dbl && (double.IsNaN(dbl) || double.IsInfinity(dbl)))
|
||||
return Fail(value, target, "NaN/Infinity has no integer value.");
|
||||
if (value is float flt && (float.IsNaN(flt) || float.IsInfinity(flt)))
|
||||
return Fail(value, target, "NaN/Infinity has no integer value.");
|
||||
if (!TryToDecimal(value, out d))
|
||||
return Fail(value, target, IsNumeric(value) ? "out of range." : "not a numeric value.");
|
||||
break;
|
||||
}
|
||||
|
||||
if (d != decimal.Truncate(d))
|
||||
return Fail(value, target, "the value has a fractional part.");
|
||||
if (d < min || d > max)
|
||||
return Fail(value, target, $"out of range [{min.ToString(CultureInfo.InvariantCulture)}, {max.ToString(CultureInfo.InvariantCulture)}].");
|
||||
return Result.Ok(narrow(d));
|
||||
}
|
||||
|
||||
private static Result ToDouble(object value) =>
|
||||
TryToDouble(value, out var d, out var reason) ? Result.Ok(d) : Fail(value, BuiltInType.Double, reason!);
|
||||
|
||||
private static Result ToFloat(object value)
|
||||
{
|
||||
if (value is float f) return Result.Ok(f);
|
||||
if (!TryToDouble(value, out var d, out var reason)) return Fail(value, BuiltInType.Float, reason!);
|
||||
if (double.IsFinite(d) && (d > float.MaxValue || d < float.MinValue))
|
||||
return Fail(value, BuiltInType.Float, "out of range.");
|
||||
return Result.Ok((float)d);
|
||||
}
|
||||
|
||||
private static bool TryToDouble(object value, out double result, out string? reason)
|
||||
{
|
||||
reason = null;
|
||||
switch (value)
|
||||
{
|
||||
case double d:
|
||||
result = d;
|
||||
return true;
|
||||
case bool b:
|
||||
result = b ? 1d : 0d;
|
||||
return true;
|
||||
case string s:
|
||||
var t = s.Trim();
|
||||
if (!double.TryParse(t, NumberStyles.Float, CultureInfo.InvariantCulture, out result))
|
||||
{
|
||||
reason = "not a number.";
|
||||
return false;
|
||||
}
|
||||
// .NET parses an out-of-range literal ("1e999") to ±Infinity; only an explicit
|
||||
// infinity literal may mean infinity.
|
||||
if (double.IsInfinity(result) && !t.Contains("Infinity", StringComparison.OrdinalIgnoreCase) && !t.Contains('∞'))
|
||||
{
|
||||
reason = "out of range.";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
case IConvertible c when IsNumeric(value):
|
||||
result = c.ToDouble(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
default:
|
||||
result = 0d;
|
||||
reason = "not a numeric value.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Result ToDateTime(object value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case DateTime dt:
|
||||
return Result.Ok(dt);
|
||||
case DateTimeOffset dto:
|
||||
return Result.Ok(dto.UtcDateTime);
|
||||
case string s:
|
||||
// Script/codec timestamps are UTC by convention: an offset-less string is taken
|
||||
// as UTC, and an offset-bearing one is normalised to UTC.
|
||||
if (!DateTime.TryParse(s.Trim(), CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
|
||||
return Fail(value, BuiltInType.DateTime, "not a recognised date/time (use ISO 8601).");
|
||||
return Result.Ok(parsed);
|
||||
default:
|
||||
return Fail(value, BuiltInType.DateTime, "not a date/time value.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Result ToGuid(object value) => value switch
|
||||
{
|
||||
Guid g => Result.Ok(g),
|
||||
Uuid u => Result.Ok((Guid)u),
|
||||
string s when Guid.TryParse(s.Trim(), out var g) => Result.Ok(g),
|
||||
_ => Fail(value, BuiltInType.Guid, "not a GUID.")
|
||||
};
|
||||
|
||||
private static string ToInvariantString(object value) => value switch
|
||||
{
|
||||
string s => s,
|
||||
DateTime dt => dt.ToString("O", CultureInfo.InvariantCulture),
|
||||
double d => d.ToString("R", CultureInfo.InvariantCulture),
|
||||
float f => f.ToString("R", CultureInfo.InvariantCulture),
|
||||
IFormattable fmt => fmt.ToString(null, CultureInfo.InvariantCulture),
|
||||
_ => value.ToString() ?? string.Empty
|
||||
};
|
||||
|
||||
private static bool IsNumeric(object value) => value is sbyte or byte or short or ushort or int or uint
|
||||
or long or ulong or float or double or decimal;
|
||||
|
||||
private static bool TryToDecimal(object value, out decimal d)
|
||||
{
|
||||
d = 0m;
|
||||
if (!IsNumeric(value)) return false;
|
||||
try
|
||||
{
|
||||
d = Convert.ToDecimal(value, CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
// A double beyond ±7.9e28 is out of range of every integer type anyway.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,14 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
private readonly OpcUaGlobalOptions _globalOptions;
|
||||
private readonly ILogger<RealOpcUaClient> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #58: built-in type of each written node's <c>DataType</c> attribute, read once per
|
||||
/// node per session so scalar writes can be coerced to it (<see cref="OpcUaWriteCoercion"/>).
|
||||
/// Cleared whenever the session is created or closed — a reconnect may land on a server
|
||||
/// whose address space changed.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<NodeId, BuiltInType> _writeDataTypes = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the RealOpcUaClient class.
|
||||
/// </summary>
|
||||
@@ -201,6 +209,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
var sessionFactory = new DefaultSessionFactory();
|
||||
#pragma warning restore CS0618
|
||||
var userIdentity = BuildUserIdentity(opts.UserIdentity);
|
||||
_writeDataTypes.Clear();
|
||||
_session = await sessionFactory.CreateAsync(
|
||||
appConfig, configuredEndpoint, false,
|
||||
"ScadaBridge-DCL-Session", (uint)opts.SessionTimeoutMs, userIdentity, null, cancellationToken);
|
||||
@@ -562,6 +571,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
await _session.CloseAsync(cancellationToken);
|
||||
_session = null;
|
||||
}
|
||||
_writeDataTypes.Clear();
|
||||
_monitoredItems.Clear();
|
||||
_callbacks.Clear();
|
||||
_alarmItems.Clear();
|
||||
@@ -1379,7 +1389,7 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
var outcomes = await WriteValuesAsync([(nodeId, value)], cancellationToken);
|
||||
var outcome = outcomes[0];
|
||||
if (outcome.Error != null)
|
||||
throw new ServiceResultException(StatusCodes.BadNodeIdUnknown, outcome.Error);
|
||||
throw new ServiceResultException(outcome.StatusCode, outcome.Error);
|
||||
return outcome.StatusCode;
|
||||
}
|
||||
|
||||
@@ -1392,17 +1402,12 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
|
||||
var outcomes = new OpcUaWriteOutcome[values.Count];
|
||||
|
||||
var resolvable = new List<(int Index, string NodeId, WriteValue Write)>(values.Count);
|
||||
var resolved = new List<(int Index, NodeId NodeId)>(values.Count);
|
||||
for (var i = 0; i < values.Count; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
resolvable.Add((i, values[i].NodeId, new WriteValue
|
||||
{
|
||||
NodeId = OpcUaNodeReference.Resolve(values[i].NodeId, session.NamespaceUris),
|
||||
AttributeId = Attributes.Value,
|
||||
Value = new DataValue(new Variant(values[i].Value))
|
||||
}));
|
||||
resolved.Add((i, OpcUaNodeReference.Resolve(values[i].NodeId, session.NamespaceUris)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1410,6 +1415,43 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea #58: coerce scalar values to each node's DataType so a codec-encoded scalar
|
||||
// ("True", "42.5") is not sent as a String Variant and rejected with BadTypeMismatch.
|
||||
await EnsureWriteDataTypesAsync(
|
||||
session,
|
||||
resolved.Where(r => OpcUaWriteCoercion.IsCoercibleScalar(values[r.Index].Value)).Select(r => r.NodeId),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var resolvable = new List<(int Index, string NodeId, WriteValue Write)>(resolved.Count);
|
||||
foreach (var (i, nodeId) in resolved)
|
||||
{
|
||||
var value = values[i].Value;
|
||||
if (OpcUaWriteCoercion.IsCoercibleScalar(value) && _writeDataTypes.TryGetValue(nodeId, out var builtInType))
|
||||
{
|
||||
var coerced = OpcUaWriteCoercion.Coerce(value, builtInType);
|
||||
if (!coerced.Success)
|
||||
{
|
||||
outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadTypeMismatch, coerced.Error);
|
||||
continue;
|
||||
}
|
||||
value = coerced.Value;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
resolvable.Add((i, values[i].NodeId, new WriteValue
|
||||
{
|
||||
NodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
Value = new DataValue(new Variant(value))
|
||||
}));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
outcomes[i] = new OpcUaWriteOutcome(values[i].NodeId, StatusCodes.BadTypeMismatch, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
for (var offset = 0; offset < resolvable.Count; offset += MaxNodesPerServiceCall)
|
||||
{
|
||||
var chunk = resolvable.GetRange(offset, Math.Min(MaxNodesPerServiceCall, resolvable.Count - offset));
|
||||
@@ -1435,6 +1477,59 @@ public class RealOpcUaClient : IOpcUaClient
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #58: reads the <c>DataType</c> attribute of every node in <paramref name="nodeIds"/>
|
||||
/// not already in <see cref="_writeDataTypes"/>, resolves it to its built-in type (walking the
|
||||
/// supertype chain for derived types such as <c>Duration</c> or an enumeration) and caches it.
|
||||
/// Best-effort: a node whose DataType cannot be read is not cached, and its value is written
|
||||
/// unconverted, leaving the server to judge it — exactly the pre-#58 behaviour.
|
||||
/// </summary>
|
||||
private async Task EnsureWriteDataTypesAsync(
|
||||
ISession session, IEnumerable<NodeId> nodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
var missing = nodeIds.Where(n => !_writeDataTypes.ContainsKey(n)).Distinct().ToList();
|
||||
for (var offset = 0; offset < missing.Count; offset += MaxNodesPerServiceCall)
|
||||
{
|
||||
var chunk = missing.GetRange(offset, Math.Min(MaxNodesPerServiceCall, missing.Count - offset));
|
||||
try
|
||||
{
|
||||
var readIds = new ReadValueIdCollection(
|
||||
chunk.Select(n => new ReadValueId { NodeId = n, AttributeId = Attributes.DataType }));
|
||||
var response = await session.ReadAsync(
|
||||
null, 0, TimestampsToReturn.Neither, readIds, cancellationToken).ConfigureAwait(false);
|
||||
if (response.Results is not { } results || results.Count != chunk.Count)
|
||||
continue;
|
||||
|
||||
for (var i = 0; i < chunk.Count; i++)
|
||||
{
|
||||
if (!StatusCode.IsGood(results[i].StatusCode) || results[i].Value is not NodeId dataTypeId)
|
||||
continue;
|
||||
|
||||
var builtIn = TypeInfo.GetBuiltInType(dataTypeId);
|
||||
if (builtIn == BuiltInType.Null)
|
||||
{
|
||||
// Not a namespace-0 built-in id: walk the supertype chain to the built-in
|
||||
// it derives from (vendor subtypes, enumerations).
|
||||
builtIn = await TypeInfo.GetBuiltInTypeAsync(dataTypeId, session.TypeTree, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_writeDataTypes[chunk[i]] = builtIn;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex,
|
||||
"OPC UA DataType read for write coercion failed for {Count} node(s); writing those values unconverted.",
|
||||
chunk.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by the OPC UA SDK when a keep-alive response arrives (or fails).
|
||||
/// When CurrentState is bad, the server is unreachable. The once-only guard is an
|
||||
|
||||
Reference in New Issue
Block a user