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:
@@ -19,9 +19,9 @@ set -euo pipefail
|
||||
# a data-sourced SCALAR write leaves the InstanceActor as its codec-encoded STRING ("True",
|
||||
# "42.5") and RealOpcUaClient wraps it in a plain Variant, so opc-plc answers BadTypeMismatch
|
||||
# (0x80740000) for every non-String node — observed live 2026-09-10 (deferred-work register
|
||||
# row 120). Only List values are decoded before the write. The two String nodes above are
|
||||
# the rig's only writable String scalars, so the flags are the strings "True"/"False";
|
||||
# the reset-echo ordering this gate proves is identical either way.
|
||||
# row 120). Only List values are decoded before the write. Gitea #58 since made RealOpcUaClient
|
||||
# coerce scalar writes to the node DataType, so the Boolean nodes would now work too; the seed
|
||||
# keeps the String nodes because the reset-echo ordering this gate proves is identical either way.
|
||||
# - Instance handshake-demo-1, bound + deployed
|
||||
#
|
||||
# Not idempotent (names must be unique). To re-seed:
|
||||
|
||||
@@ -74,6 +74,7 @@ All protocols produce the same value tuple consumed by Instance Actors. Before t
|
||||
- Session-based connection with endpoint discovery, certificate handling, and configurable security modes. Endpoint discovery is **asynchronous** and bounded by the connection's `OperationTimeoutMs` (the discovery client is built from the connection's own `EndpointConfiguration`) and by the caller's cancellation; a cancelled discovery is never mistaken for "discovery unavailable". The `DataConnectionActor` starts every adapter `ConnectAsync` on the thread pool, so no adapter connect work — whatever runs before its first yielding await — can occupy the actor's dispatcher thread (review 2026-10-21; previously a synchronous discovery against a black-holed endpoint held the thread for the SDK's 120 s default on every reconnect tick).
|
||||
- Subscriptions via OPC UA Monitored Items with data change notifications (1000ms sampling, queue size 10, discard-oldest).
|
||||
- Read/Write via OPC UA Read/Write services with StatusCode-based quality mapping.
|
||||
- **Scalar write coercion (Gitea #58).** The OPC UA Write service does no implicit conversion, and the runtime hands the DCL codec-encoded scalars (`"True"`, `"42.5"`), so `RealOpcUaClient` converts every non-null scalar write value to the target node's built-in `DataType` before wrapping it in a `Variant`. The `DataType` attribute is read once per node per session (batched, cached, cleared on connect/disconnect; derived types resolve via the supertype chain, an enumeration writes as Int32). Conversion (`OpcUaWriteCoercion`) covers Boolean (`true`/`false`/`1`/`0`), SByte–UInt64, Float, Double, String, DateTime (ISO 8601, offset-less taken as UTC) and Guid; every parse is culture-invariant. A value that cannot be represented exactly — overflow, a fractional value to an integer type, an unparseable string — is refused client-side with `BadTypeMismatch` and a message naming the value and target type, never rounded. Arrays/collections, `null` and unmapped target types (structures, `BaseDataType`, abstract `Number`) pass through unchanged, as does any node whose `DataType` read fails. The MxGateway path is unaffected.
|
||||
- Disconnect detection via `Session.KeepAlive` event (see Disconnect Detection Pattern below).
|
||||
|
||||
### MxGateway
|
||||
|
||||
@@ -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
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Opc.Ua;
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #58: the scalar write-coercion table <see cref="OpcUaWriteCoercion"/> that
|
||||
/// <see cref="RealOpcUaClient.WriteValuesAsync"/> applies before wrapping a value in a
|
||||
/// <see cref="Variant"/>. Each success case asserts the exact CLR type (the Variant's built-in
|
||||
/// type follows from it); each failure case asserts no value is produced.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public class OpcUaWriteCoercionTests
|
||||
{
|
||||
public static TheoryData<object, BuiltInType, object> Successes => new()
|
||||
{
|
||||
// Boolean
|
||||
{ "True", BuiltInType.Boolean, true },
|
||||
{ "false", BuiltInType.Boolean, false },
|
||||
{ " TRUE ", BuiltInType.Boolean, true },
|
||||
{ "1", BuiltInType.Boolean, true },
|
||||
{ "0", BuiltInType.Boolean, false },
|
||||
{ 1, BuiltInType.Boolean, true },
|
||||
{ true, BuiltInType.Boolean, true },
|
||||
// Signed integers
|
||||
{ "-128", BuiltInType.SByte, (sbyte)-128 },
|
||||
{ "32767", BuiltInType.Int16, (short)32767 },
|
||||
{ "-42", BuiltInType.Int32, -42 },
|
||||
{ "42.0", BuiltInType.Int32, 42 },
|
||||
{ 7L, BuiltInType.Int32, 7 },
|
||||
{ 3.0, BuiltInType.Int32, 3 },
|
||||
{ "-9223372036854775808", BuiltInType.Int64, long.MinValue },
|
||||
{ "7", BuiltInType.Enumeration, 7 },
|
||||
// Unsigned integers
|
||||
{ "255", BuiltInType.Byte, (byte)255 },
|
||||
{ "65535", BuiltInType.UInt16, (ushort)65535 },
|
||||
{ "4294967295", BuiltInType.UInt32, uint.MaxValue },
|
||||
{ 5, BuiltInType.UInt32, 5u },
|
||||
{ "18446744073709551615", BuiltInType.UInt64, ulong.MaxValue },
|
||||
// Floating point (invariant culture: '.' is the decimal separator)
|
||||
{ "42.5", BuiltInType.Double, 42.5 },
|
||||
{ "-1.5e3", BuiltInType.Double, -1500.0 },
|
||||
{ 42, BuiltInType.Double, 42.0 },
|
||||
{ 1.25f, BuiltInType.Double, 1.25 },
|
||||
{ "Infinity", BuiltInType.Double, double.PositiveInfinity },
|
||||
{ "42.5", BuiltInType.Float, 42.5f },
|
||||
{ 2.5, BuiltInType.Float, 2.5f },
|
||||
// String
|
||||
{ "hello", BuiltInType.String, "hello" },
|
||||
{ 42.5, BuiltInType.String, "42.5" },
|
||||
{ true, BuiltInType.String, "True" },
|
||||
// Unmapped target types pass through untouched
|
||||
{ "abc", BuiltInType.Variant, "abc" },
|
||||
{ "abc", BuiltInType.ExtensionObject, "abc" },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Successes))]
|
||||
public void Coerces_to_the_node_type(object input, BuiltInType target, object expected)
|
||||
{
|
||||
var result = OpcUaWriteCoercion.Coerce(input, target);
|
||||
|
||||
Assert.True(result.Success, result.Error);
|
||||
Assert.Equal(expected.GetType(), result.Value!.GetType());
|
||||
Assert.Equal(expected, result.Value);
|
||||
}
|
||||
|
||||
public static TheoryData<object, BuiltInType> Failures => new()
|
||||
{
|
||||
// Invalid strings
|
||||
{ "yes", BuiltInType.Boolean },
|
||||
{ "2", BuiltInType.Boolean },
|
||||
{ 2, BuiltInType.Boolean },
|
||||
{ "abc", BuiltInType.Int32 },
|
||||
{ "", BuiltInType.Int32 },
|
||||
{ "abc", BuiltInType.Double },
|
||||
{ "not-a-date", BuiltInType.DateTime },
|
||||
{ "zzz", BuiltInType.Guid },
|
||||
// Culture: a comma is not a decimal separator, and thousands separators are refused
|
||||
{ "42,5", BuiltInType.Double },
|
||||
{ "1,000", BuiltInType.Int32 },
|
||||
// Fractional value to an integer type — never rounded
|
||||
{ "42.5", BuiltInType.Int32 },
|
||||
{ 42.5, BuiltInType.Int64 },
|
||||
{ double.NaN, BuiltInType.Int32 },
|
||||
// Overflow
|
||||
{ "128", BuiltInType.SByte },
|
||||
{ "256", BuiltInType.Byte },
|
||||
{ "-1", BuiltInType.Byte },
|
||||
{ "32768", BuiltInType.Int16 },
|
||||
{ "-1", BuiltInType.UInt16 },
|
||||
{ "2147483648", BuiltInType.Int32 },
|
||||
{ 4294967296L, BuiltInType.UInt32 },
|
||||
{ "-1", BuiltInType.UInt32 },
|
||||
{ "9223372036854775808", BuiltInType.Int64 },
|
||||
{ "18446744073709551616", BuiltInType.UInt64 },
|
||||
{ -1, BuiltInType.UInt64 },
|
||||
{ 1e30, BuiltInType.Int64 },
|
||||
{ "1e999", BuiltInType.Double },
|
||||
{ "1e39", BuiltInType.Float },
|
||||
{ 1e39, BuiltInType.Float },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Failures))]
|
||||
public void Rejects_values_that_do_not_fit_the_node_type(object input, BuiltInType target)
|
||||
{
|
||||
var result = OpcUaWriteCoercion.Coerce(input, target);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Null(result.Value);
|
||||
Assert.Contains(target == BuiltInType.Enumeration ? "Int32" : target.ToString(), result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_names_the_value_and_target_type()
|
||||
{
|
||||
var result = OpcUaWriteCoercion.Coerce("300", BuiltInType.Byte);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("\"300\"", result.Error);
|
||||
Assert.Contains("Byte", result.Error);
|
||||
Assert.Contains("out of range", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DateTime_strings_parse_invariantly_as_utc()
|
||||
{
|
||||
var withOffset = OpcUaWriteCoercion.Coerce("2026-11-06T10:00:00+02:00", BuiltInType.DateTime);
|
||||
var noOffset = OpcUaWriteCoercion.Coerce("2026-11-06T08:00:00", BuiltInType.DateTime);
|
||||
|
||||
var expected = new DateTime(2026, 11, 6, 8, 0, 0, DateTimeKind.Utc);
|
||||
Assert.Equal(expected, withOffset.Value);
|
||||
Assert.Equal(DateTimeKind.Utc, ((DateTime)withOffset.Value!).Kind);
|
||||
Assert.Equal(expected, noOffset.Value);
|
||||
Assert.Equal(DateTimeKind.Utc, ((DateTime)noOffset.Value!).Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DateTime_to_string_uses_round_trip_format()
|
||||
{
|
||||
var dt = new DateTime(2026, 11, 6, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
Assert.Equal("2026-11-06T08:00:00.0000000Z", OpcUaWriteCoercion.Coerce(dt, BuiltInType.String).Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parsing_ignores_the_current_culture()
|
||||
{
|
||||
var saved = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
// de-DE uses ',' as the decimal separator; the invariant parse must still read "42.5".
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
Assert.Equal(42.5, OpcUaWriteCoercion.Coerce("42.5", BuiltInType.Double).Value);
|
||||
Assert.Equal("42.5", OpcUaWriteCoercion.Coerce(42.5, BuiltInType.String).Value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = saved;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Guid_strings_parse()
|
||||
{
|
||||
var g = Guid.NewGuid();
|
||||
Assert.Equal(g, OpcUaWriteCoercion.Coerce(g.ToString(), BuiltInType.Guid).Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Arrays_and_null_pass_through_untouched()
|
||||
{
|
||||
var list = new List<int> { 1, 2 };
|
||||
var array = new[] { "1", "2" };
|
||||
|
||||
Assert.Same(list, OpcUaWriteCoercion.Coerce(list, BuiltInType.Double).Value);
|
||||
Assert.Same(array, OpcUaWriteCoercion.Coerce(array, BuiltInType.Int32).Value);
|
||||
Assert.True(OpcUaWriteCoercion.Coerce(null, BuiltInType.Int32).Success);
|
||||
Assert.Null(OpcUaWriteCoercion.Coerce(null, BuiltInType.Int32).Value);
|
||||
Assert.False(OpcUaWriteCoercion.IsCoercibleScalar(list));
|
||||
Assert.True(OpcUaWriteCoercion.IsCoercibleScalar("abc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Coerced_string_becomes_a_typed_variant()
|
||||
{
|
||||
// The #58 failure: new Variant("True") is a String Variant. After coercion it is Boolean.
|
||||
var coerced = OpcUaWriteCoercion.Coerce("True", BuiltInType.Boolean).Value;
|
||||
|
||||
Assert.Equal(BuiltInType.String, new Variant("True").TypeInfo.BuiltInType);
|
||||
Assert.Equal(BuiltInType.Boolean, new Variant(coerced).TypeInfo.BuiltInType);
|
||||
Assert.Equal(ValueRanks.Scalar, new Variant(coerced).TypeInfo.ValueRank);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #58 live round-trip: a codec-encoded String scalar written to a numeric / Boolean
|
||||
/// node of the infra OPC PLC server (<c>opc.tcp://localhost:50000</c>, see
|
||||
/// <c>infra/docker-compose.yml</c> + <c>infra/opcua/nodes.json</c>) is coerced to the node's
|
||||
/// DataType and reads back as that type. Before the fix the server answered
|
||||
/// <c>BadTypeMismatch</c> (0x80740000). Skips when the server is unreachable.
|
||||
/// </summary>
|
||||
[Trait("Category", "RequiresOpcUa")]
|
||||
public class OpcUaWriteCoercionLiveTests
|
||||
{
|
||||
private const string EndpointUrl = "opc.tcp://localhost:50000";
|
||||
|
||||
// Writable, non-simulated nodes from infra/opcua/nodes.json (namespace http://microsoft.com/Opc/OpcPlc/).
|
||||
private const string DoubleNode = "nsu=http://microsoft.com/Opc/OpcPlc/;s=Motor.Speed";
|
||||
private const string BooleanNode = "nsu=http://microsoft.com/Opc/OpcPlc/;s=Motor.Running";
|
||||
private const string UInt32Node = "nsu=http://microsoft.com/Opc/OpcPlc/;s=Motor.FaultCode";
|
||||
|
||||
[SkippableFact]
|
||||
public async Task String_writes_to_typed_nodes_are_coerced_and_read_back_typed()
|
||||
{
|
||||
await using var client = new RealOpcUaClient();
|
||||
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync(EndpointUrl, new OpcUaConnectionOptions(OperationTimeoutMs: 5000), deadline.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Skip.If(true, $"OPC UA test server unavailable: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Two distinct values per node so the read-back cannot pass on a stale value.
|
||||
foreach (var (speed, running, fault) in new[] { ("1234.5", "True", "17"), ("42.25", "False", "0") })
|
||||
{
|
||||
var outcomes = await client.WriteValuesAsync(
|
||||
[(DoubleNode, speed), (BooleanNode, running), (UInt32Node, fault)], deadline.Token);
|
||||
|
||||
Assert.All(outcomes, o =>
|
||||
{
|
||||
Assert.Null(o.Error);
|
||||
Assert.Equal(0u, o.StatusCode);
|
||||
});
|
||||
|
||||
var reads = await client.ReadValuesAsync([DoubleNode, BooleanNode, UInt32Node], deadline.Token);
|
||||
Assert.Equal(double.Parse(speed, System.Globalization.CultureInfo.InvariantCulture),
|
||||
Assert.IsType<double>(reads[0].Value));
|
||||
Assert.Equal(bool.Parse(running), Assert.IsType<bool>(reads[1].Value));
|
||||
Assert.Equal(uint.Parse(fault, System.Globalization.CultureInfo.InvariantCulture),
|
||||
Assert.IsType<uint>(reads[2].Value));
|
||||
}
|
||||
|
||||
// A value that does not fit the node type is refused client-side with a clear error,
|
||||
// and the node keeps its previous value.
|
||||
var bad = await client.WriteValuesAsync([(UInt32Node, "-1"), (DoubleNode, "abc")], deadline.Token);
|
||||
Assert.All(bad, o =>
|
||||
{
|
||||
Assert.Equal(Opc.Ua.StatusCodes.BadTypeMismatch, o.StatusCode);
|
||||
Assert.Contains("Cannot convert", o.Error);
|
||||
});
|
||||
var after = await client.ReadValuesAsync([UInt32Node, DoubleNode], deadline.Token);
|
||||
Assert.Equal(0u, after[0].Value);
|
||||
Assert.Equal(42.25, after[1].Value);
|
||||
|
||||
await client.DisconnectAsync();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user