diff --git a/docker/seed-handshake-demo.sh b/docker/seed-handshake-demo.sh index de14898b..4ebe96bc 100755 --- a/docker/seed-handshake-demo.sh +++ b/docker/seed-handshake-demo.sh @@ -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: diff --git a/docs/requirements/Component-DataConnectionLayer.md b/docs/requirements/Component-DataConnectionLayer.md index dda093fa..e0c166f8 100644 --- a/docs/requirements/Component-DataConnectionLayer.md +++ b/docs/requirements/Component-DataConnectionLayer.md @@ -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 diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaWriteCoercion.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaWriteCoercion.cs new file mode 100644 index 00000000..fdb898a6 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/OpcUaWriteCoercion.cs @@ -0,0 +1,244 @@ +using System.Collections; +using System.Globalization; +using Opc.Ua; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; + +/// +/// Gitea #58: converts a scalar write value to the CLR type that matches the target node's +/// OPC UA built-in DataType before wraps it in a +/// . +/// +/// The runtime hands the DCL codec-encoded scalars (an attribute value of "True" or +/// "42.5"), and new Variant("42.5") is a String Variant, which a conformant +/// server rejects on a Double node with BadTypeMismatch. The OPC UA write service does +/// no implicit conversion, so the client has to send the node's own type. +/// +/// +/// 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, null and target +/// types with no scalar mapping here (structures, abstract Number, BaseDataType, +/// …) pass through unchanged, so the server stays the judge of those. +/// +/// +internal static class OpcUaWriteCoercion +{ + /// The outcome of a coercion attempt. + /// False when the value cannot be represented in the target type. + /// The value to write (the original value when nothing applied). + /// Why the conversion failed; null on success. + 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); + } + + /// + /// True when is a scalar this class may coerce: not null, not a + /// string-less collection. A byte[] is a ByteString scalar in OPC UA, but it is + /// never a codec scalar and is left to the SDK as-is. + /// + internal static bool IsCoercibleScalar(object? value) => + value is not null && (value is string || value is not IEnumerable); + + /// + /// Coerces to the CLR representation of . + /// + 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 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; + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs index b51aa2d8..60ce12c5 100644 --- a/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.DataConnectionLayer/Adapters/RealOpcUaClient.cs @@ -82,6 +82,14 @@ public class RealOpcUaClient : IOpcUaClient private readonly OpcUaGlobalOptions _globalOptions; private readonly ILogger _logger; + /// + /// Gitea #58: built-in type of each written node's DataType attribute, read once per + /// node per session so scalar writes can be coerced to it (). + /// Cleared whenever the session is created or closed — a reconnect may land on a server + /// whose address space changed. + /// + private readonly ConcurrentDictionary _writeDataTypes = new(); + /// /// Initializes a new instance of the RealOpcUaClient class. /// @@ -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; } + /// + /// Gitea #58: reads the DataType attribute of every node in + /// not already in , resolves it to its built-in type (walking the + /// supertype chain for derived types such as Duration 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. + /// + private async Task EnsureWriteDataTypesAsync( + ISession session, IEnumerable 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); + } + } + } + /// /// 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 diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaWriteCoercionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaWriteCoercionTests.cs new file mode 100644 index 00000000..73fb21de --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/OpcUaWriteCoercionTests.cs @@ -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; + +/// +/// Gitea #58: the scalar write-coercion table that +/// applies before wrapping a value in a +/// . 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. +/// +[Trait("Category", "Unit")] +public class OpcUaWriteCoercionTests +{ + public static TheoryData 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 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 { 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); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaWriteCoercionLiveTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaWriteCoercionLiveTests.cs new file mode 100644 index 00000000..d5db6059 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/OpcUaWriteCoercionLiveTests.cs @@ -0,0 +1,71 @@ +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters; + +namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests; + +/// +/// Gitea #58 live round-trip: a codec-encoded String scalar written to a numeric / Boolean +/// node of the infra OPC PLC server (opc.tcp://localhost:50000, see +/// infra/docker-compose.yml + infra/opcua/nodes.json) is coerced to the node's +/// DataType and reads back as that type. Before the fix the server answered +/// BadTypeMismatch (0x80740000). Skips when the server is unreachable. +/// +[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(reads[0].Value)); + Assert.Equal(bool.Parse(running), Assert.IsType(reads[1].Value)); + Assert.Equal(uint.Parse(fault, System.Globalization.CultureInfo.InvariantCulture), + Assert.IsType(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(); + } +}