Files
lmxopcua/docs/AddressSpace.md
T
Joseph Doherty e08855fb9d
v2-ci / build (push) Successful in 4m52s
v2-ci / unit-tests (push) Failing after 15m58s
docs: source-verified deferment register + correct 17 drifted docs
Adds deferment.md — a source-verified inventory of new-driver status, all 17
open issues, in-code deferrals, open live gates and plan bookkeeping. Every
claim was checked against src/ and git, not against documentation.

Headline finding: three subsystems are authored, persisted and shipped but
never executed —
  * Node ACLs: IPermissionEvaluator/TriePermissionEvaluator have zero
    production consumers and OtOpcUaNodeManager never references them, yet
    ClusterAcls.razor authors NodeAcl rows and ConfigComposer.cs:51 ships them
    in every artifact. An authored deny rule has no effect.
  * IRediscoverable / IHostConnectivityProbe raise into the void (#518/#507);
    nothing ever writes a DriverHostStatus row.
  * DriverTypeRegistry is vestigial, so no factory passes a tier and every
    driver runs Tier A with the Tier-C protections dormant.

Also records the Calculation driver as picker-visible but unauthorable
(DriverConfigModal has no case) — the "registered but unauthorable" class
recurring after the Sql picker defect — and notes that no parity test guards
DriverConfigModal/DeviceModal, which is why it survived review.

Documentation corrections (source-verified):
  * ReadWriteOperations.md claimed "a denied read never hits the driver" via
    four types that do not exist in src/. Bannered + struck through; a security
    review reading that page would have concluded a per-node ACL gate exists.
  * CLAUDE.md: the Change Detection sentence was false (DriverHost consumes
    nothing); the mesh Phase 4 and Phase 5 live gates and the auto-down 1-vs-1
    gate had all PASSED; the ScriptedAlarmState table was already dropped.
  * driver-expansion tracking: Modbus RTU and SQL poll are merged, not
    pending — its command table would have made someone rebuild two shipped
    drivers in a fresh worktree.
  * drivers/README.md: dead DriverTypeRegistry paragraph, retired
    SystemPlatform namespace kind, missing Sql + Calculation rows, missing
    Modbus RTU-over-TCP transport.
  * TwinCAT.md/Galaxy.md promised an address-space rebuild that never happens.
  * Historian.md gained the #491 unproven-value-capture pointer.
  * IncrementalSync.md and AddressSpace.md bannered as wholesale v2-era (the
    rewrite is recorded as still owed, not done here); four v2 status docs
    bannered as historical; three wrong-name-for-a-live-type fixes.

Left deliberately untouched: the secrets NoOpSecretReplicator line, which
makes a security claim and needs the real behaviour identified rather than a
rename.
2026-07-27 17:26:20 -04:00

11 KiB

Address Space

⚠️ Accuracy warning (audited 2026-07-27) — this page is v2-era and contradicts the shipped v3 address space. Corrections, in order of how badly they mislead:

  • There is no "single custom namespace". v3 exposes two: https://zb.com/otopcua/raw (s=<RawPath>) and https://zb.com/otopcua/uns (s=<Area>/<Line>/<Equipment>/<EffectiveName>), registered together at OtOpcUaNodeManager.cs:45. Identity authority is V3NodeIds + AddressSpaceRealm. The retired https://zb.com/otopcua/ns survives only in a comment marking it retired (V3NodeIds.cs:17).
  • Phase7Applier / Phase7Composer / GalaxyTagPlan do not exist, nor do the file paths cited for them. Renamed by 40e8a23e to AddressSpaceApplier / AddressSpaceComposer (src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/); the composition result type is AddressSpaceComposition (AddressSpaceComposer.cs:13).
  • The SystemPlatform namespace kind is retired — Galaxy is a standard Equipment-kind driver.
  • GenericDriverNodeManager is test scaffolding, not a production dispatch path (GenericDriverNodeManager.cs:71).
  • The §Rediscovery section is wrong: nothing consumes OnRediscoveryNeeded (#518), and the driver list is stale (MQTT and MTConnect now raise it too).

For the current design see the "v3 OPC UA Address Space (Batch 4)" section of CLAUDE.md, docs/Raw.md, and docs/Uns.md.

Address-space construction is a two-layer system. The driver-facing layer is the streaming builder: a driver implements ITagDiscovery.DiscoverAsync (src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs) and emits Folder / Variable / AddProperty calls into an IAddressSpaceBuilder as it walks its backend — no buffering of the whole tree. GenericDriverNodeManager (src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs) wraps that builder to capture alarm-condition sinks and routes alarm events from the driver to them. The SDK materialization layer turns the resulting node descriptions into live OPC UA nodes: OpcUaPublishActor drives the write-only IOpcUaAddressSpaceSink, whose production binding SdkAddressSpaceSink (src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs) forwards to OtOpcUaNodeManager (src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs), a CustomNodeManager2 subclass that owns the FolderState / BaseDataVariableState instances. The same code path serves Galaxy object hierarchies, Modbus PLC registers, AB CIP tags, TwinCAT symbols, FOCAS CNC parameters, and OPC UA Client aggregations — Galaxy is one driver of seven, not the driver.

Root folder

OtOpcUaNodeManager.CreateAddressSpace creates a single shared root FolderState (NodeId = OtOpcUa, BrowseName = OtOpcUa, EventNotifier = None) under the standard OPC UA Objects folder, wired with an Organizes reference. Every driver's folders and variables hang beneath this one root; the server is published under a single ApplicationUri = urn:OtOpcUa (the OpcUaApplicationHostOptions.ApplicationUri default) and all nodes live in the server's single custom namespace, not a per-driver urn:OtOpcUa:{DriverInstanceId}. The UNS Area → Line → Equipment folder skeleton under the root is materialised by Phase7Applier.MaterialiseHierarchy (src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs); SystemPlatform (Galaxy) tags are materialised by Phase7Applier.MaterialiseGalaxyTags.

IAddressSpaceBuilder surface

IAddressSpaceBuilder (src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs) offers three calls:

  • Folder(browseName, displayName) — creates a child FolderState and returns a child builder scoped to it.
  • Variable(browseName, displayName, DriverAttributeInfo attributeInfo) — creates a BaseDataVariableState and returns an IVariableHandle the driver keeps for alarm wiring.
  • AddProperty(browseName, DriverDataType, value) — attaches a PropertyState for static metadata (e.g. equipment identification fields).

Drivers drive ordering. Typical pattern: root → folder per equipment → variables per tag. GenericDriverNodeManager.BuildAddressSpaceAsync calls DiscoverAsync once on startup and once per rediscovery cycle, tearing down the previous alarm subscription and clearing its sink registry before each re-walk so a redeploy doesn't double-fire alarm events.

DriverAttributeInfo → OPC UA variable

Each variable carries a DriverAttributeInfo (src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs):

Field Role
FullName driver-side full reference used as the lookup key for Read/Write/Subscribe; also seeds the variable's string NodeId
DriverDataType resolved to a built-in DataTypeIds.* NodeId at materialization time — OtOpcUaNodeManager.ResolveBuiltInDataType maps the data-type name string; each driver first maps its native type into DriverDataType (e.g. Galaxy via Browse/DataTypeMap.Map)
IsArray / ArrayDim declared 1-D-array length carried as metadata; the Galaxy discoverer sets ArrayDim only when the gateway reports a positive dimension
SecurityClass write-authorization tier (SecurityClassification); enforced server-side by the NodePermissions ACL evaluator (TriePermissionEvaluator) mapping each OpcUaOperation to a required permission bit. The Galaxy driver also caches it per full reference (_securityByFullRef) to answer GetSecurityClassification
IsHistorized marks the attribute as feeding historian / HistoryRead
IsAlarm drives the MarkAsAlarmCondition pass (see below)
WriteIdempotent when true the attribute's writes are safe to replay, so the capability invoker may apply Polly retry; defaults false so pulses / acks / counters aren't auto-retried
Source NodeSourceKind discriminator (Driver / Virtual / ScriptedAlarm) that decides which subsystem dispatches the node's Read/Write/Subscribe

The variable is created with StatusCode = BadWaitingForInitialData and a null value until the first Read or ISubscribable.OnDataChange push lands. Note the production SDK sink (OtOpcUaNodeManager.EnsureVariable) currently materialises every variable as ValueRank = Scalar, read-only AccessLevel, and Historizing = false — the IsArray/IsHistorized intent lives in DriverAttributeInfo but is not yet projected onto the SDK node.

CapturingBuilder + alarm sink registration

GenericDriverNodeManager.BuildAddressSpaceAsync wraps the supplied builder in a CapturingBuilder before calling DiscoverAsync. The wrapper observes every Variable() call: when a returned IVariableHandle.MarkAsAlarmCondition(AlarmConditionInfo) fires, the sink is registered in the manager's _alarmSinks dictionary keyed by the variable's FullReference. Subsequent IAlarmSource.OnAlarmEvent pushes are routed to the matching sink by SourceNodeId. This keeps the alarm-wiring protocol declarative — drivers just flag DriverAttributeInfo.IsAlarm = true and the materialization of the OPC UA AlarmConditionState node is handled by the server layer. See docs/AlarmTracking.md.

NodeId scheme

All nodes share the server's single custom namespace (NamespaceIndex); NodeIds are string identifiers, not numeric. The string values come from the source rows / driver references — there is no per-driver namespace prefix:

Node type NodeId (string identifier) Example
Shared root OtOpcUa OtOpcUa
UNS Area / Line / Equipment folder the Config-DB UnsAreaId / UnsLineId / EquipmentId EQ_Press_07
Galaxy tag variable the MXAccess reference (Phase7Applier uses GalaxyTagPlan.MxAccessRef) DelmiaReceiver_001.DownloadPath
Equipment tag variable the driver full reference from DriverAttributeInfo.FullName driver-specific

For Galaxy the variable FullName is the tag_name.AttributeName MXAccess reference; AB CIP uses tag.Name or tag.Name.member for UDT members; the shape is the driver's choice. Browse-path resolution (OPC UA TranslateBrowsePathsToNodeIds) is the canonical way clients map a browse path to one of these flat NodeIds.

Per-driver hierarchy examples

  • Galaxy: GalaxyDriver.DiscoverAsync delegates to GalaxyDiscoverer (src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs), which walks the hierarchy from IGalaxyHierarchySource — one folder per Galaxy object (browse name = contained_name, falling back to tag_name), one variable per dynamic attribute (full reference = tag_name.AttributeName). It copies the gateway-supplied IsAlarm flag through to DriverAttributeInfo and, for alarm-bearing attributes, calls MarkAsAlarmCondition with the five sub-attribute refs built by AlarmRefBuilder.
  • Modbus: streams one folder per device, one variable per register range from ModbusDriverOptions. No alarm surface.
  • AB CIP: AbCipDriver.DiscoverAsync emits an AbCip root, then a folder per configured device. Pre-declared tags become variables under the device folder; UDT (Structure) tags fan out into a sub-folder with one variable per member; when controller browse is enabled, IAbCipTagEnumerator adds discovered tags under a Discovered/ sub-folder. (AbCipTemplateCache caches UDT layouts for the libplctag enumerator.)
  • OPC UA Client: re-exposes a remote server's address space — OpcUaClientDriver.DiscoverAsync browses the upstream from BrowseRoot into a Remote folder (pass 1), then batch-reads DataType/AccessLevel/ValueRank/Historizing per variable before registering them (pass 2).

See docs/v2/driver-specs.md for the per-driver discovery contracts.

Rediscovery

Drivers that implement IRediscoverable fire OnRediscoveryNeeded when their backend signals a change. Galaxy's DeployWatcher raises it when the observed time_of_last_deploy advances; TwinCAT raises it on the ADS symbol-version-changed signal (DeviceSymbolVersionInvalid, error 1809). Core re-runs DiscoverAsync and diffs — see docs/IncrementalSync.md. Drivers that don't implement IRediscoverable (Modbus, S7, OPC UA Client) only change their address space when a new generation is published from the Config DB.

Key source files

  • src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs — orchestration + CapturingBuilder
  • src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs, src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs — OPC UA materialization (write-only sink fed by the actor system)
  • src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs — materialises the UNS folder hierarchy + Galaxy tags into the sink
  • src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs — walks Config-DB Equipment-namespace rows into the builder
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs — builder contract
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs — driver discovery capability
  • src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs — per-attribute descriptor