Files
lmxopcua/docs/ReadWriteOperations.md
T

8.2 KiB

Read/Write Operations

Rewritten 2026-07-27 against src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs. The previous revision described a v2-era DriverNodeManager with per-variable OnReadValue hooks, an AuthorizationGate/WriteAuthzPolicy ACL pair and a NodeSourceKind dispatch switch. None of those exist — OnReadValue, WriteAuthzPolicy, _sourceByFullRef, _writeIdempotentByFullRef and IRoleBearer all have zero occurrences in src/. The read path in particular worked nothing like the way it was documented. See deferment.md §3.1 and §7.

The shape of it: push for reads, pull for writes

The live server is OtOpcUaNodeManager, a push-model CustomNodeManager2. This asymmetry is the single most important thing on this page:

  • Reads never reach a driver. There is no Read override and no OnReadValue / OnSimpleReadValue handler anywhere in the node manager. Driver values are pushed in from the Akka actor layer (DriverInstanceActor polls or subscribes, DriverHostActor fans the value to the raw NodeId and every referencing UNS NodeId), and a client Read is served by the OPC UA SDK straight from the cached node value. A client read therefore costs nothing on the wire to the device, and cannot fail with a device error — it returns whatever quality was last pushed.
  • Writes are synchronous pull-through. A client write runs OnWriteValue → role gate → driver.

Everything the old page said about CapabilityInvoker wrapping OPC UA reads was misplaced: the invoker is real, but it lives on the driver-actor side wrapping the poll/subscribe calls. The OpcUaServer project does not reference it at all.

Read path

  1. The SDK resolves the NodeId in the Raw (ns=2) or UNS (ns=3) namespace.
  2. It returns the cached DataValue — value, StatusCode and source timestamp as last pushed.

Status codes reach the client intact (Gitea #519)

The driver's OPC UA status code — sub-code included — is what a client reads. A driver that maps an unreachable device to BadNoCommunication surfaces exactly that, distinguishable from BadTypeMismatch or BadNotSupported.

This was not always true. Until #519 the publish path projected the driver's uint onto a 3-state Good/Uncertain/Bad enum in DriverInstanceActor, and OtOpcUaNodeManager re-expanded Bad to a bare StatusCodes.Bad (0x80000000). Every driver's diagnostic intent survived in logs only — including the 16 status constants #497 had just corrected across six drivers. The value path now carries the raw uint end to end (AttributeValuePublished → AttributeValueUpdate → IOpcUaAddressSpaceSink.WriteValue), and severity classification is a helper (OpcUaStatus.IsGood/IsUncertain/IsBad) rather than a lossy projection.

Writing a status code in engine code: use a named constant on ZB.MOM.WW.OtOpcUa.Commons.OpcUa.OpcUaStatus, never an inline literal. Commons and Runtime are deliberately SDK-free, so nothing but StatusCodeParityTests checks a literal against the name it is commented with — and that guard works by reflection, so it can only see a named constant. 3. No authorization check runs. Not a per-node ACL, not a role check, nothing. Any admitted session — including an Anonymous one — can read every node in the address space. The only gating is the AccessLevels bitmask set at materialization, which is a per-node capability declaration, not a per-user decision.

Authored NodeAcl deny rules have no effect on reads (or on anything else — see docs/security.md § Data-Plane Authorization).

Write path — OnEquipmentTagWrite

The handler is attached in EnsureVariable only when the tag was materialized writable, and re-attached or cleared by UpdateTagAttributes on an in-place edit. A non-writable node has no handler, so the SDK rejects the write before any of this runs.

  1. Role gate. EvaluateEquipmentWriteGate(identity, gatewayWired) requires the session identity to be a RoleCarryingUserIdentity carrying the WriteOperate role. No identity or no role ⇒ BadUserAccessDenied, fail-closed. Gate passed but no write gateway wired (admin-only node, pre-boot) ⇒ BadNotWritable.
    • This is a server-wide check. It takes no node and no realm: a session that may write one tag may write every writable tag on that node.
    • WriteTune and WriteConfigure are never checked. OpcUaDataPlaneRoles declares only WriteOperate and AlarmAck; the classification tiers exist in the permission vocabulary but no code path reads them.
  2. Optimistic local apply. The new value is written to the node so subscribers see it immediately.
  3. Realm-qualified dispatch. RealmOf(node.NodeId) selects Raw or UNS, and the value routes to the backing driver ref through the node write gateway. Both NodeIds for a fanned value resolve to the same driver ref, so a write via either one reaches the same device point.
  4. Write-outcome self-correction. If the device write fails, the optimistic value is reverted on the raw NodeId and every referencing UNS NodeId through the shared fan-out, so a failed write cannot leave a phantom Good value behind. (Galaxy is the exception: its write is fire-and-forget, so it can never surface a write failure.)

OnWriteValue is invoked by the SDK while holding the node-manager Lock, so the handler must not block. Anything asynchronous is dispatched fire-and-forget with the revert wired as a continuation.

Alarm method calls

Part 9 condition methods (Acknowledge / Confirm / AddComment / OneShotShelve / TimedShelve / Unshelve) are gated by a second role check requiring AlarmAck — one role covering all five methods; the operation argument is passed to the router but the gate does not consult it. Scripted alarms go through HandleAlarmCommand, driver-fed native alarms through HandleNativeAlarmAck; both fail closed with BadUserAccessDenied.

HistoryRead

Four overrides — HistoryReadRawModified, HistoryReadProcessed, HistoryReadAtTime, HistoryReadEvents — route to the server-wide IHistorianDataSource (the HistorianGateway-backed reader, or NullHistorianDataSource when ServerHistorian:Enabled=false, which returns GoodNoData). Drivers carry no history capability.

No authorization check runs on any of the four. Unlike OnWriteValue, the SDK does not hold the node-manager Lock while invoking them, so these paths may await.

See docs/Historian.md.

Failure isolation

Exceptions in a driver capability call are logged and converted to a per-node bad status rather than unwinding into the master node manager, so one driver's outage cannot disrupt sibling drivers in the same process.

What is not enforced anywhere

Collected here because the previous revision of this page claimed several of these:

Surface Check
Read, Browse, TranslateBrowsePaths none
CreateMonitoredItems / Subscribe / TransferSubscriptions none
HistoryRead (all four variants) none
Non-Value attribute writes none (the handler is on OnWriteValue)
Value write WriteOperate role, server-wide
Alarm methods AlarmAck role, server-wide
Per-node ACLs (NodeAcl / PermissionTrie) authored and deployed, never evaluated

Key source files

  • src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs — the live node manager; EnsureVariable / WriteValue / OnEquipmentTagWrite / EvaluateEquipmentWriteGate and the four HistoryRead overrides
  • src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/RoleCarryingUserIdentity.cs, Security/OpcUaDataPlaneRoles.cs — how roles reach the gates
  • src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs — materialization; where the write handler is attached per realm
  • src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs — the push side: value fan-out to raw + UNS NodeIds
  • src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs — driver-side resilience pipeline (not on the OPC UA read path)
  • src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ — the permission trie + evaluator, built and unit-tested but with zero production consumers