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:
@@ -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