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