67059e479f
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
140 lines
8.2 KiB
Markdown
140 lines
8.2 KiB
Markdown
# 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](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](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**
|