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:
Joseph Doherty
2026-11-06 16:08:33 -05:00
parent 0e89966da6
commit a5c3de63e9
6 changed files with 619 additions and 11 deletions
@@ -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