Compare commits

...

26 Commits

Author SHA1 Message Date
dohertj2 ec6598ceae Merge pull request 'v3 Batch 4 — dual-namespace address space + raw-only runtime binding (v3.0)' (#472) from v3/batch4-address-space into master
v2-ci / build (push) Successful in 3m59s
v2-ci / unit-tests (push) Failing after 9m25s
2026-07-16 14:38:29 -04:00
Joseph Doherty b4b378c5cd docs(v3): Batch 4 PR description — dual-namespace address space (v3.0)
v2-ci / build (pull_request) Successful in 4m0s
v2-ci / unit-tests (pull_request) Failing after 11m55s
7-leg live-gate evidence + per-wave reviewer verdicts + the live-gate-caught VT
reassert fix + documented follow-ups (confirmed-but-deferred scripted-alarm
redeploy recovery, raw-rename hot-rebind, cross-repo ScadaBridge cutover).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 14:36:30 -04:00
Joseph Doherty e41ffe7655 merge reassert review fixes (M1 skip historian re-record, LOW-3 comment) into v3/batch4-address-space 2026-07-16 14:34:08 -04:00
Joseph Doherty 51022c3952 fix(v3-batch4): reassert skips historian re-record + scripted-alarm redeploy recovery (reassert review M1/M2/LOW-3)
M1 (MEDIUM): the VirtualTag re-assert re-published a stale last value with a
fresh deploy-time timestamp, and VirtualTagHostActor.OnResult recorded it to
the IHistoryWriter for Historize=true plans — every deploy would append an
artificial historian sample (BadInternalError if the last state was Bad) that
never corresponded to a real evaluation. Inert today (NullHistoryWriter) but a
data-quality bug once a VT history sink binds. Fix: EvaluationResult carries an
IsReassert flag (default false), set true in VirtualTagActor.OnReassertValue;
OnResult still PUBLISHES the AttributeValueUpdate (to repair the reset node) but
SKIPS _history.Record when IsReassert. Regression test
VirtualTagHostActorTests.Reassert_of_a_historized_vtag_publishes_but_does_not_re_record_to_the_historian
(fails before — count=2 — passes after: count stays 1).

LOW-3: corrected the ordering comments in VirtualTagActor.OnReassertValue and
VirtualTagHostActor.OnApply. ApplyVirtualTags goes to the VirtualTag HOST, not
the publish actor; the ordering holds because the re-assert reaches the publish
actor via a multi-hop chain (host -> child ReassertValue -> child -> parent
EvaluationResult -> OnResult -> publish actor) and thus lands AFTER
RebuildAddressSpace in the shared publish actor's FIFO mailbox.

M2 (CONFIRMED REAL — reported as scoped follow-up, not fixed here): the same
redeploy-reset race is latent for Part 9 scripted-alarm condition nodes. A
full-rebuild deploy clears + re-materialises them fresh
(OtOpcUaNodeManager.RebuildAddressSpace clears _alarmConditions @2160;
MaterialiseAlarmCondition recreates normal state), but the engine reload does
NOT re-emit an unchanged-active condition: ScriptedAlarmEngine.LoadAsync ->
EvaluatePredicateToStateAsync (@546-552) computes ApplyPredicate(seed, true)
where seed is the persisted state from the DB-backed EfAlarmConditionStateStore,
yielding EmissionKind.None, which ScriptedAlarmHostActor.OnEngineEmission (@292)
filters. Net: an active alarm with static dependencies under-reports until its
next real transition on a full-rebuild deploy. The fix is larger/riskier than
the VT case (touches the Core engine emission contract and must publish ONLY the
OPC UA node state, NOT the alerts topic, to avoid duplicate AVEVA history rows —
the alarm analogue of M1), so per review guidance it is deferred as a scoped
follow-up rather than forced into this commit.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 14:33:05 -04:00
Joseph Doherty 21eb81c915 merge VirtualTag reassert-on-redeploy fix (live-gate: dedup+node-reset race) into v3/batch4-address-space 2026-07-16 14:10:43 -04:00
Joseph Doherty b95efb0b28 fix(v3-batch4): Equipment VirtualTag runtime dependency/publish resolution (live-gate)
Equipment VirtualTags published Bad at runtime whenever their dependency
value was static. Root cause: a deploy re-materialises each VirtualTag's
UNS node to BadWaitingForInitialData (OpcUaPublishActor.HandleRebuild), but
a surviving unchanged-plan VirtualTagActor keeps its value-dedup state, so
its unchanged recompute is suppressed (VirtualTagActor.OnDependencyChanged
value dedup) and the freshly-reset node stays Bad forever. Only masked when
the dependency value changes every poll.

Fix: VirtualTagHostActor tells each surviving (not just-spawned) child to
ReassertValue on every apply; the child re-emits its last value/quality,
bypassing dedup. Ordering is safe — DriverHostActor enqueues the
RebuildAddressSpace (materialise) to the single-threaded publish actor
before the ApplyVirtualTags that triggers the re-assert, so the re-publish
lands on the freshly-materialised node.

Regression test: VirtualTagHostActorTests
.Unchanged_redeploy_reasserts_last_value_so_a_reset_uns_node_recovers
(fails before, passes after). Live-verified on docker-dev: GateVt reads
Good via both the absolute and the {{equip}}/MainPressure script forms.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 14:09:43 -04:00
Joseph Doherty 1badc10445 test(v3-batch4): add Calculation to the driver-probe idempotency key set
Pre-existing stale test surfaced by Batch 4's full gate (Batch 2/3 gates were
live-/run and never ran Host.IntegrationTests): Batch 2 registered CalculationProbe
in DriverFactoryBootstrap (9 probes) but AddOtOpcUaDriverProbes_is_idempotent's
AdminUiDriverTypeKeys still listed 8, so distinctTypes(9) != Length(8). Calculation
is a real DriverTypeNames.Calculation pseudo-driver with a probe; add it and refresh
the now-stale *DriverPage.razor comment (that routed flow retired in Batch 2). Not a
Batch-4 code change — a test-correctness fix.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 13:22:27 -04:00
Joseph Doherty e6607ad5ab merge WP5 (dual-namespace harness/integration tests + address-space docs) into v3/batch4-address-space 2026-07-16 13:13:01 -04:00
Joseph Doherty b8208b3312 test+docs(v3-batch4-wp5): 2-node dual-namespace harness tests + address-space docs
Tests:
- OpcUaServer.IntegrationTests/DualNamespaceAddressSpaceTests.cs (NEW, over-the-wire,
  offline-safe): both namespace URIs registered + distinct; Raw + UNS subtrees browse
  and read; UNS variable Organizes-references its raw node; single-source fan-out parity
  (identical value/quality/timestamp on both NodeIds); HistoryRead via either NodeId ->
  GoodNoData under the shared tagname; WriteOperate gate symmetric across both NodeIds.
- Host.IntegrationTests/EquipmentNamespaceMaterializationTests.cs (extended): full deploy
  -> persisted-artifact -> ParseComposition round-trip carrying both realms, sealing across
  the redundant 2-node cluster (redundancy non-interference). In-memory harness, offline.

Docs (dual-namespace reality):
- CLAUDE.md: new "v3 OPC UA Address Space (Batch 4)" section + Batch-4 testing paragraph.
- docs/Uns.md: address-space projection (two namespaces, Organizes edge, effective-name leaf).
- docs/Historian.md: dual-registration (both NodeIds -> one tagname); updated CLI examples.
- docs/ScriptedAlarms.md + docs/AlarmTracking.md: multi-notifier fan-out, ConditionId=RawPath.
- docs/ScriptEditor.md: dual-namespace clarification (script tag-path semantics unchanged).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 13:11:34 -04:00
Joseph Doherty e959423323 merge WP4 Wave C review hardening (M1 event-delivery test, M2/M3 reconcile+meter, L1/L3) into v3/batch4-address-space 2026-07-16 12:53:11 -04:00
Joseph Doherty 3cf3576c75 fix(v3-batch4-wp4): alarm fan-out hardening — event-delivery test, resolution-failure meter, teardown guards (Wave C review M1/M2/M3/L1/L3)
M1 — over-the-wire event-delivery proof (new NativeAlarmMultiNotifierEventDeliveryTests
in OpcUaServer.IntegrationTests): boots the real server, wires one condition to two
equipment folders, fires ONE transition, and asserts a Server-object subscriber gets
EXACTLY ONE event (the shared-InstanceStateSnapshot queue dedup), plus overlapping
Server + equipment-folder subscribers each get exactly one copy. Captures via the
subscription FastEventCallback keyed by ClientHandle (the per-item Notification/DequeueEvents
path delivers nothing for these conditions in the SDK client — the working capture is the
fast callback).

M2 — resolution-failure signal + path-agreement tripwire (AddressSpaceApplier): when a
native alarm HAS ReferencingEquipmentPaths but NONE resolve to an equipment folder, count
it as a failed node (degraded-apply meter) + LogWarning instead of a silent skip; documented
the DisplayName==Name coupling as a binding invariant. Tests:
Composer_referencing_equipment_paths_resolve_back_to_equipment_ids_in_the_applier (real
composer output → applier inversion) + ..._unresolvable_referencing_equipment_counts_as_failed.

M3 — WireAlarmNotifiers is now a RECONCILE (unwires a folder dropped from the supplied set,
bidirectionally) so a future surgical ChangedRawTags path can't leave a de-referenced
equipment receiving the alarm; binding guard comment added on the classifier's
ChangedRawTags→Rebuild branch. Test: WireAlarmNotifiers_reconciles_a_dropped_folder_out_of_the_notifier_set.

L1 — MaterialiseAlarmCondition's kind-swap drop-and-recreate now calls
UnwireAlarmNotifiers(conditionKey) before discarding the old instance (teardown symmetry).

L3 — BuildEquipmentIdByFolderPath LogWarnings on a duplicate Area/Line/Name collision
(defense-in-depth; UNS uniqueness prevents it).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 12:52:34 -04:00
Joseph Doherty 90e52a4415 fix(v3-batch4): thread ReferencingEquipmentPaths into native AlarmTransitionEvent
Closes the WP3/WP4 boundary seam WP4 flagged: ForwardNativeAlarm (DriverHostActor,
a WP3-owned file WP4 could not edit) built the AlarmTransitionEvent without the
referencing-equipment paths, so the /alerts equipment-list chips WP4 added never
populated in production (would fail live-gate leg 5). The RawTagPlan already carries
ReferencingEquipmentPaths (the Area/Line/Equipment UNS folder paths); this rides them
onto the per-condition alarm meta and into every transition. Coordinator-owned
1-file change (WP3 merged, no parallel agent owns this file this wave).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 12:10:01 -04:00
Joseph Doherty 1e9454582e merge WP4 (multi-notifier native alarms + teardown symmetry) into v3/batch4-address-space 2026-07-16 12:08:33 -04:00
Joseph Doherty 8ebc712eff feat(v3-batch4-wp4): multi-notifier native alarms (single ReportEvent → raw + equipment notifiers) + teardown symmetry
Materialize each native alarm ONCE at the raw tag (ConditionId = RawPath, Raw
realm); wire the single condition as an SDK event notifier of each referencing
equipment's UNS folder so one ReportEvent fans to every root without
re-reporting per root (which would break Server-object dedup + Part 9 ack
correlation).

- New sink method WireAlarmNotifiers(alarmNodeId, alarmRealm,
  notifierFolderNodeIds, notifierFolderRealm) on IOpcUaAddressSpaceSink,
  forwarded through DeferredAddressSpaceSink + SdkAddressSpaceSink +
  NullOpcUaAddressSpaceSink (the forwarding-trap guard); auto-covered by the
  DeferredSinkForwardingReflectionTests realm + forwarding guards + a
  hand-written forward test.
- OtOpcUaNodeManager: the normative AddNotifier(isInverse) pair + idempotent
  EnsureFolderIsEventNotifier per equipment folder; tracked per condition in
  _alarmNotifierWiring. Teardown symmetry: RemoveNotifier(bidirectional:true) on
  RebuildAddressSpace, RemoveAlarmConditionNode, and RemoveEquipmentSubtree so no
  inverse-notifier entry leaks across redeploys.
- AddressSpaceApplier.MaterialiseRawSubtree wires notifiers for each native alarm
  tag, resolving its ReferencingEquipmentPaths (Area/Line/Equipment) to the
  EquipmentId folder NodeIds via BuildEquipmentIdByFolderPath.
- AlarmTransitionEvent gains ReferencingEquipmentPaths (empty default); /alerts
  renders the referencing-equipment list as display metadata.
- Un-skipped + rewrote the native-alarm dark tests
  (DriverHostActorNativeAlarmTests x6, DriverHostActorNativeAlarmAckRoutingTests
  x1) for the v3 raw-condition model; new NodeManagerMultiNotifierAlarmTests
  proves multi-notifier wiring + teardown symmetry (no leaked duplicates after a
  re-trip) + applier wiring test.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 12:07:11 -04:00
Joseph Doherty 9d09523675 merge WP3 Wave B review fixes (H1 realm write-routing, M1/M2/L1/L3, byte-parity) into v3/batch4-address-space 2026-07-16 11:30:57 -04:00
Joseph Doherty 2e0743ad25 fix(v3-batch4-wp3): realm-qualified write routing + dormant discovery guard + self-correction/byte-parity tests (Wave B review H1/M1/M2/L1/L3)
H1 (HIGH): write-routing key now (AddressSpaceRealm, bareId), not bare-only.
A raw s=<RawPath> and a UNS s=<Area/Line/Equip/Eff> can collide as bare
strings; the bare-only key let a colliding raw+UNS pair route to the WRONG
driver ref (last-writer-wins). The realm the node manager resolves (RealmOf)
is now threaded through IOpcUaNodeWriteGateway.WriteAsync -> RouteNodeWrite ->
_driverRefByNodeId keyed by (realm, bareId). New regression test:
Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm.

M1 (MEDIUM): discovered-node injection made coherently DORMANT. HandleDiscoveredNodes
hard-short-circuits (single enforcement point; _discoveredByDriver never
populates so the re-inject tail is inert too), with a clear log pointing at the
/raw browse-commit flow. New pin: Discovered_nodes_are_ignored_dormant_in_v3;
the 16+2 v2 injection scenarios re-pointed to an accurate skip reason
(DiscoveryInjectionDormantV3).

M2 (MEDIUM): realm-qualified dual-node self-correction tests —
Failed_uns_write_reverts_uns_node_and_leaves_raw_node_untouched +
Raw_realm_revert_reverts_raw_node_only (the second fails if the realm is dropped).

L1: removed the = AddressSpaceRealm.Uns defaults from the consequential
node-manager mutation methods (WriteValue/WriteAlarmCondition/MaterialiseAlarmCondition/
EnsureFolder/EnsureVariable/UpdateFolderDisplayName/UpdateTagAttributes/
RaiseNodesAddedModelChange/Remove*/RevertOptimisticWriteIfNeeded) + the
AttributeValueUpdate/AlarmStateUpdate records, so the compiler forces explicit
realm; read-only accessors + internal builders retain their defaults.

L3: fixed the stale VirtualTagHostActor class comment (V3NodeIds.Uns, not the
retired EquipmentNodeIds.Variable).

Also: DeploymentArtifactRawUnsParityTests — Raw/UNS node-set byte-parity
round-trip between AddressSpaceComposer.Compose and DeploymentArtifact.ParseComposition.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 11:30:13 -04:00
Joseph Doherty 77c39bf02d merge WP3 (raw-only binding + UNS fan-out + write routing) into v3/batch4-address-space 2026-07-16 10:58:08 -04:00
Joseph Doherty 3efcf8014b feat(v3-batch4-wp3): raw-only binding + UNS fan-out + write routing
Wave B of Batch 4 — the runtime binding seam for the dual namespace.

Applier (both realms, explicit realm at every sink call site):
- MaterialiseRawSubtree: Raw containers as folders + Raw tags as variables
  keyed by RawPath (native-alarm tag → single Part 9 condition at the RawPath),
  all in AddressSpaceRealm.Raw; historian tagname = override else RawPath.
- MaterialiseUnsReferences: each UNS reference Variable under its equipment
  folder (Uns realm) + an Organizes UNS->Raw edge; inherits writable/array/
  historian tagname from the backing raw tag (both NodeIds -> one tagname).
- FeedHistorizedRefs / ProvisionHistorizedTags now source RAW tags (mux ref
  stays single, keyed by RawPath); ApplyPureRemove tears down raw tags + UNS
  refs in place (raw-container removal falls back to rebuild).

DriverHostActor (dual-NodeId, single-source fan-out):
- _nodeIdByDriverRef value gains a realm (NodeRealmRef); rebuilt from
  RawTags UNION UnsReferenceVariables so one (DriverInstanceId, RawPath) fans
  to the raw NodeId AND every referencing UNS NodeId with identical
  value/quality/timestamp. Write inverse map keyed by the bare id; the
  ns-qualified NodeId the write hook passes is normalised (BareNodeId) so a
  write to either NodeId resolves the same driver ref (-> RawPath write).
- Native raw alarm condition routing is realm-tagged (Raw); AttributeValueUpdate
  + AlarmStateUpdate carry the realm through to the sink.

Retire EquipmentNodeIds -> V3NodeIds.Uns (applier/VirtualTagHostActor) and
RawPaths.Combine (DiscoveredNodeMapper, discovered nodes are Raw now).

DeploymentArtifact.ParseComposition emits the Raw + UNS subtrees byte-parity
with the composer (reconstruct entities -> AddressSpaceComposer.Compose).

Sink surface: removed the transitional `= AddressSpaceRealm.Uns` defaults from
IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink / SdkAddressSpaceSink /
DeferredAddressSpaceSink / NullOpcUaAddressSpaceSink — every call site is now
explicit (realm reordered before the trailing optionals on EnsureVariable +
MaterialiseAlarmCondition). Node-manager convenience methods keep their
defaults (they are not the interface impl; Sdk delegates explicitly).

Tests: rewrote DriverHostActorLiveValueTests (fan-out drift, 1:N) +
DriverHostActorWriteRoutingTests (dual-NodeId raw/uns write routing) to the v3
raw+uns model; new AddressSpaceApplierRawUnsTests; migrated the EquipmentTags
provisioning/feed tests to RawTags; swept EquipmentNodeIds test callers.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 10:56:31 -04:00
Joseph Doherty 8b0b627f81 merge WP2 M1 fix (AddReference Organizes seam) into v3/batch4-address-space 2026-07-16 09:52:58 -04:00
Joseph Doherty e95615cef3 fix(v3-batch4-wp2): AddReference sink seam for Organizes UNS→Raw (Wave A review M1)
Close the Wave-A M1 gap: the sink surface had no way to wire the mandated
cross-tree Organizes reference from each UNS reference Variable to its backing
Raw node, forcing WP3 to reopen the frozen surface. Add a dedicated
realm-qualified AddReference method instead.

- IOpcUaAddressSpaceSink.AddReference(sourceNodeId, sourceRealm, targetNodeId,
  targetRealm, referenceType="Organizes"): realm-qualified both ends; idempotent;
  a missing endpoint is a logged no-op (never throws) so a mid-rebuild race can't
  fault a deploy. Base-interface capability (no surgical sniff, no transitional
  default — WP3 wires it explicitly per UNS reference variable).
- SdkAddressSpaceSink forwards to the node manager; DeferredAddressSpaceSink
  forwards unconditionally (like EnsureFolder); NullOpcUaAddressSpaceSink no-ops.
- OtOpcUaNodeManager.AddReference: resolve both nodes by full ns-qualified key
  (variable/folder/condition via ResolveNodeState), guard both-exist, then wire
  the edge bidirectionally (forward Organizes on source, inverse on target) with a
  ReferenceExists idempotency guard + ClearChangeMasks on mutated sides. Reference
  type resolved by ResolveReferenceType (Organizes default). Added internal
  GetNodeReferences test/diagnostic accessor.
- Reflection guard: the exhaustive-forwarding test + the realm-discriminator guard
  auto-cover AddReference (it has two AddressSpaceRealm params) — no edit needed.
- New SdkAddressSpaceSinkTests: Organizes UNS→Raw edge created bidirectionally +
  idempotent; missing endpoint is a safe no-op.
- All 15 IOpcUaAddressSpaceSink test doubles gain the AddReference no-op so the
  solution still builds.

Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx = 0 errors.
Tests: Commons.Tests 310/310; OpcUaServer.Tests 337 passed / 4 pre-existing skips.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 09:52:30 -04:00
Joseph Doherty 0dbdee003b merge WP2 (node-manager dual-namespace + sink realm surface) into v3/batch4-address-space 2026-07-16 09:34:52 -04:00
Joseph Doherty 4807aa7edd merge WP1 (composer/planner dual-subtree) into v3/batch4-address-space 2026-07-16 09:34:52 -04:00
Joseph Doherty 5534698d70 feat(v3-batch4-wp2): node manager dual-namespace + realm-aware sink surface
Register both v3 namespaces (Raw first, UNS second) on OtOpcUaNodeManager and
thread an AddressSpaceRealm discriminator through every node-naming sink method
so a bare node id is resolved to the correct namespace by realm — never parsed
out of the id string.

- IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink: every node-naming method
  gains `AddressSpaceRealm realm = AddressSpaceRealm.Uns` (transitional default so
  un-migrated WP3 call sites still compile; WP3/Wave B makes them explicit and
  removes the default). Null + SdkAddressSpaceSink impls updated; DeferredAddress-
  SpaceSink forwards realm through every method (the forwarding trap).
- DeferredSinkForwardingReflectionTests: existing exhaustive-forwarding guard
  auto-covers the new signatures; added an explicit guard that every node-naming
  sink method carries an AddressSpaceRealm parameter (RebuildAddressSpace exempt).
- OtOpcUaNodeManager: register RawNamespaceUri + UnsNamespaceUri; realm->namespace
  index via NamespaceIndexForRealm; all node maps (_variables/_folders/
  _alarmConditions/_nativeAlarmNodeIds/_historizedTagnames/_eventNotifierSources)
  re-keyed by the full ns-qualified NodeId string so Raw and UNS nodes sharing a
  bare id stay distinct; _notifierFolders already NodeId-keyed. A historized UNS
  reference node registers the SAME historian tagname as its backing raw node
  (both NodeIds -> one tagname). Inbound-write hook routes the full ns-qualified
  NodeId to the write gateway (realm-aware) and reverts by bare id + realm.
  HistoryRead seams resolve via NodeId directly. DefaultNamespaceUri kept as a
  transitional alias to UnsNamespaceUri for the SubscriptionSurvivalTests.
- Test doubles across Commons.Tests / OpcUaServer.Tests / Runtime.Tests updated to
  the new interface signatures; SdkAddressSpaceSinkTests asserts the UNS namespace
  index for default-realm nodes.

Build: dotnet build ZB.MOM.WW.OtOpcUa.slnx = 0 errors.
Tests: Commons.Tests 310/310; OpcUaServer.Tests 335 passed / 4 pre-existing skips.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 09:33:45 -04:00
Joseph Doherty f4f3e17e3e feat(v3-batch4-wp1): composer/planner emit both Raw + UNS subtrees
Un-darken the address-space composition for the v3 dual namespace.

Composer (AddressSpaceComposer):
- Raw subtree: RawContainerNode (Folder/Driver/Device/TagGroup as
  Object/Folder nodes, keyed s=<RawPath>, parent-before-child) + RawTagPlan
  (raw tag Variables keyed (realm=Raw, s=<RawPath>) carrying DataType /
  writable / historize+historian tagname / array shape). Native-alarm intent
  attaches at the RAW tag (ConditionId = RawPath) with the list of referencing
  equipment UNS paths (one alarm at the raw tag, not one per equipment).
- UNS subtree: UnsReferenceVariable per UnsTagReference, keyed
  (realm=Uns, s=<Area>/<Line>/<Equipment>/<EffectiveName>), carrying its
  backing RawPath (Organizes UNS->Raw target + fan-out) and inheriting
  DataType/AccessLevel from the backing raw tag. Effective name =
  DisplayNameOverride else backing raw Tag.Name.
- All RawPaths flow through one shared RawTopology (RawPathResolver +
  memoised container paths), byte-parity with EquipmentReferenceMap.
- Every new node carries an AddressSpaceRealm (so WP3's applier picks the
  namespace at the call site). Additive/defaulted model changes only — the
  un-migrated AddressSpaceApplier still compiles.

Planner (AddressSpacePlanner.Compute) + AddressSpacePlan:
- Per-realm diff sets: RawContainers + RawTags keyed by RawPath (NodeId) so a
  rename = remove+add; UnsReferenceVariables keyed by the stable
  UnsTagReferenceId so a backing-tag rename re-points (BackingRawPath moves,
  NodeId stable) and a display-name-override edit is UNS-only.
- PINNED: raw-tag rename = remove+add in Raw AND re-point in UNS.

Classifier folds the new sets in (adds->PureAdd, removes->PureRemove,
changed->Rebuild safe default).

Tests: AddressSpaceComposerDualNamespaceTests (7),
AddressSpacePlannerDualNamespaceTests (8, incl. the rename pin),
AddressSpaceChangeClassifierDualNamespaceTests (8); reactivated
EquipmentNamespaceMaterializationTests' Batch-4-pending case, re-authored to
drive the composer over the seeded config (the artifact-decode seam is WP3).

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 09:30:12 -04:00
Joseph Doherty a1a56e22bb feat(v3-batch4): contracts — dual-namespace URIs, V3NodeIds, AddressSpaceRealm
Batch 4 contracts commit (coordinator lands before Wave A fan-out). Purely
additive so wave agents branch from a green base:

- AddressSpaceRealm enum (Raw | Uns) — the explicit realm discriminator that
  travels alongside a node's s= identifier at every sink seam (WP2 adds it to
  IOpcUaAddressSpaceSink / ISurgicalAddressSpaceSink; WP3 threads it).
- V3NodeIds — the two namespace URI constants (https://zb.com/otopcua/raw,
  https://zb.com/otopcua/uns, replacing the single .../ns) + Raw() pass-through
  (s= id == RawPath) + Uns()/UnsChild() slash-joined Area/Line/Equipment[/Eff]
  builders, both sharing RawPaths.Separator + ordinal segment validation.

Placement note: URIs live in V3NodeIds (Commons) rather than beside the retired
OtOpcUaNodeManager.DefaultNamespaceUri so runtime + tests reference them without
depending on OpcUaServer (single-source-of-truth, §5). WP2 rewires the node
manager to register both via V3NodeIds.RawNamespaceUri/UnsNamespaceUri.

EquipmentNodeIds retirement is NOT in this commit: its callers are the applier +
runtime (DriverHostActor/VirtualTagHostActor/DiscoveredNodeMapper), all WP3-owned
(the composer does not use it) — deleting it here would red the base. WP3 sweeps
it in Wave B.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 09:03:29 -04:00
dohertj2 f8f3b82ed6 Merge pull request 'v3 Batch 3 — UNS reference-only Equipment + {{equip}}/<RefName> resolution' (#471) from v3/batch3-uns-rework into master
v2-ci / build (push) Successful in 3m28s
v2-ci / unit-tests (push) Failing after 8m23s
2026-07-16 08:59:01 -04:00
82 changed files with 5684 additions and 1411 deletions
+41 -3
View File
@@ -68,9 +68,45 @@ shared-lib consumption, or per-project commands — update the **OtOpcUa entry i
gRPC session. The gateway owns the COM apartment + STA pump
server-side; the driver speaks `MxCommand` / `MxEvent` protos
exclusively.
3. **OPC UA Server** — Exposes authored equipment tags as variable nodes.
Galaxy tags are bound by `TagConfig.FullName` (`tag_name.AttributeName`);
reads/writes/subscriptions are translated to that reference for MXAccess.
3. **OPC UA Server** — Exposes the deployed tree under **two OPC UA namespaces**
(see below). Galaxy tags are bound by `TagConfig.FullName`
(`tag_name.AttributeName`); reads/writes/subscriptions are translated to that
reference for MXAccess.
### v3 OPC UA Address Space (Batch 4): dual namespace
The server exposes **two OPC UA namespaces** (replacing the single
`https://zb.com/otopcua/ns`), built by `AddressSpaceComposer` /
`AddressSpaceApplier` and served by `OtOpcUaNodeManager` (which registers both
URIs; `V3NodeIds` + `AddressSpaceRealm` are the identity authority):
| Namespace URI | Realm | Subtree | NodeId `s=` scheme |
|---|---|---|---|
| `https://zb.com/otopcua/raw` | `AddressSpaceRealm.Raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) |
| `https://zb.com/otopcua/uns` | `AddressSpaceRealm.Uns` | the UNS tree (Area → Line → Equipment → signal) | slash-joined **`Area/Line/Equipment/EffectiveName`** |
- **Single source, fanned to both.** Every device value has exactly one source —
the raw tag's node. A `UnsTagReference` projects that raw tag into an equipment
as a UNS-namespace variable that carries an **`Organizes` reference to its raw
node** and mirrors it. One driver publish for a RawPath fans (in `DriverHostActor`)
to the raw NodeId AND every referencing UNS NodeId with identical
value/quality/timestamp — the mux key stays single (RawPath).
- **Writes via either NodeId** route to the same driver ref under the same
`WriteOperate` gating (the node-manager write gate is realm-qualified and fails
closed); a failed device write reverts both NodeIds via the shared fan-out
(write-outcome self-correction).
- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and
fan via SDK notifiers to the raw device folder AND every referencing equipment
folder — one `ReportEvent`, one Server-object copy; ack/confirm/shelve route on
`ConditionId = RawPath`.
- **Historian dual-registration.** Both NodeIds register the **same** historian
tagname; the mux `HistorizedTagRef` stays single (RawPath). HistoryRead works
through either NodeId.
The retired `EquipmentNodeIds` (`{equipmentId}/{folderPath}/{name}`) scheme and
the single-namespace `EquipmentTags` materialization path are gone. See
`docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md` +
`docs/plans/2026-07-15-v3-batch4-address-space-plan.md`, and `docs/Uns.md`.
### Key Concept: Tag Name and FullName
@@ -219,6 +255,8 @@ The AdminUI's global **UNS** page (`/uns`) is the single surface for managing th
**v3 (Batch 3): UNS Equipment is reference-only.** The equipment **Tags** tab no longer authors or binds tags — it holds `UnsTagReference` rows pointing at raw tags authored in `/raw`. "+ Add reference" opens the `RawTree` in picker mode, **cluster-scoped** (structurally + server-enforced `tag.cluster == equipment.cluster`); rows show effective name / RawPath / inherited DataType+AccessLevel / display-name override. **Effective-name uniqueness** (references + VirtualTags + ScriptedAlarms share the `{EquipmentId}/{EffectiveName}` NodeId space) is enforced at authoring (`IEffectiveNameGuard`) and at the deploy gate (`DraftValidator.UnsEffectiveNameCollision` — catches rename-induced collisions). Scripts use **`ctx.GetTag("{{equip}}/<RefName>")`** — resolved per-equipment through `UnsTagReference` effective names to the backing RawPath at both compose seams (`AddressSpaceComposer` + `DeploymentArtifact`, via the shared `EquipmentReferenceMap`, byte-parity); an unresolved `<RefName>` is a deploy error (`EquipReferenceUnresolved`) AND a live Monaco diagnostic (`OTSCRIPT_EQUIPREF`) — editor accepts ⇔ publish accepts. `EquipmentScriptPaths.DeriveEquipmentBase` is deleted (the dot-joint `{{equip}}.X` became the slash-joint `{{equip}}/<RefName>`). Raw rename warns when a beneath-it tag is historized-without-override / UNS-referenced / named by a script literal (`RawTreeService` substring scan). `ImportEquipmentModal` dropped the `DriverInstanceId` column. See `docs/Uns.md` + `docs/ScriptEditor.md`.
**v3 (Batch 4 = v3.0): dual-namespace address space.** The OPC UA server exposes **two namespaces**`https://zb.com/otopcua/raw` (Raw device tree, `s=<RawPath>`) + `https://zb.com/otopcua/uns` (UNS tree, `s=<Area>/<Line>/<Equipment>/<EffectiveName>`) — replacing the single `.../ns` and the retired `EquipmentNodeIds` scheme. Every value has ONE source (the raw node's publish); a driver publish for a RawPath fans in `DriverHostActor` to the raw NodeId AND every referencing UNS NodeId with identical value/quality/timestamp, and each UNS variable `Organizes`-references its raw node. Writes route via **either** NodeId to the same driver ref under the same realm-qualified `WriteOperate` gate (failed-write revert works through both); native alarms materialize ONCE at the raw tag (`ConditionId = RawPath`) and fan via SDK `AddNotifier` to the raw device folder + every referencing equipment folder (one `ReportEvent`, one Server-object copy); both NodeIds register the SAME historian tagname (mux `HistorizedTagRef` stays single, keyed by RawPath). Identity authority: `V3NodeIds` + `AddressSpaceRealm` (carried explicitly at every sink seam — never parsed out of the id string). See the "v3 OPC UA Address Space (Batch 4)" section above, `docs/Uns.md`, and `docs/plans/2026-07-15-v3-batch4-address-space-plan.md`.
The `/uns` **TagModal** uses **driver-typed tag-config editors**: it dispatches by the bound driver's `DriverType` to a per-driver editor (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas/OpcUaClient) via `TagConfigEditorMap`, with client-side validation via `TagConfigValidator`; unmapped drivers (only Galaxy) fall back to the generic raw-`TagConfig`-JSON textarea. Each editor is a thin razor shell over a pure `<Driver>TagConfigModel` (`FromJson`/`ToJson`/`Validate`, preserves unknown keys). To add a driver's editor, copy the Modbus template under `Components/Shared/Uns/TagEditors/` + `Uns/TagEditors/`, reusing the driver's enums + camelCase JSON property names, and register it in `TagConfigEditorMap` + `TagConfigValidator`. See `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`.
## Scripting / Script Editor
+27
View File
@@ -24,6 +24,33 @@ condition — the dedup logic prefers the richer driver-native record
because it carries the full operator + raise-time + category metadata
that the value-driven path collapses.
## v3 Batch 4 — multi-notifier delivery (raw + equipment folders)
Under the v3 dual-namespace address space, a native driver alarm is authored on a
**raw tag** (`/raw`) and its Part 9 `AlarmConditionState` materializes **once** at the
raw tag — `ConditionId` = the tag's **RawPath**, parent notifier = the raw device/group
folder (`ns=Raw`). Because equipment references raw tags (v3 reference-only UNS), the
same condition is wired as an **event notifier of every referencing equipment folder**
(`ns=UNS`) via the SDK `AddNotifier` pattern (`OtOpcUaNodeManager.WireAlarmNotifiers`):
`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` +
`EnsureFolderIsEventNotifier(equipFolder)`.
- A **single** `ReportEvent` fans through the SDK notifier graph to the raw device folder,
every referencing equipment folder, and up to the Server object. A subscriber at any one
root receives **exactly one** copy of the transition — the Part 9 shared-`InstanceStateSnapshot`
dedup (`MonitoredItem.QueueEvent``IsEventContainedInQueue`), **never** one copy per root.
Duplicating the `ReportEvent` per root is rejected by design (distinct EventIds would break
Server-object dedup + Part 9 ack correlation).
- **Teardown is symmetric:** the node manager tracks the wired notifier pairs and calls
`RemoveNotifier(..., bidirectional:true)` on rebuild / subtree-removal / reference-removal, so
inverse-notifier entries never leak across redeploys.
- **Ack/confirm/shelve route on `ConditionId = RawPath`** (never `SourceNodeId`) regardless of
which notifier root the operator subscribed at — an ack issued from an equipment-folder
subscription resolves to the same raw condition.
- The `alerts`-topic `AlarmTransitionEvent` carries the (possibly empty) **list of referencing
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
identity RawPath + condition NodeId) with the equipment list as display metadata.
## Galaxy driver path (driver-native)
Restored in PR B.2 of the epic. `GalaxyDriver` implements
+23 -6
View File
@@ -199,6 +199,15 @@ The server supports all four OPC UA HistoryRead variants:
`Historizing=true` and `AccessLevels.HistoryRead` are set at materialization so any compliant
OPC UA client can discover historized capability from the node's attributes.
> **v3 Batch 4 — dual-namespace registration.** A historized raw tag surfaces as **two**
> variable nodes: the Raw-namespace node (`ns=Raw, s=<RawPath>`) and, for each referencing
> equipment, a UNS-namespace node (`ns=UNS, s=<Area>/<Line>/<Equipment>/<EffectiveName>`).
> **Both NodeIds register the SAME historian tagname** (the raw tag's `FullName` /
> `historianTagname`), so HistoryRead against either NodeId resolves to the same tagname and
> returns the same series. The historization intent is single-sourced — the mux's
> `HistorizedTagRef` set stays keyed by RawPath (no doubles), and continuous historization /
> `EnsureTags` provisioning run once per raw tag regardless of how many equipment reference it.
**Equipment-folder event-notifier nodes** serve Event history. Every equipment folder that
owns at least one alarm condition is already an event notifier; the server registers a
`sourceName` (the equipment id) for each such folder and maps event history reads to the
@@ -355,31 +364,39 @@ for the full alarm-historian routing.
The `historyread` command reads historical data from any node. Supply start and end times in
ISO 8601 UTC form. See [docs/Client.CLI.md](Client.CLI.md) for the full flag reference.
> **v3 Batch 4 NodeIds.** A historized tag is readable through **either** of its two NodeIds —
> the Raw-namespace node (`s=<RawPath>`, e.g. `Plant/Modbus/dev1/Speed`) or a referencing
> equipment's UNS-namespace node (`s=<Area>/<Line>/<Equipment>/<EffectiveName>`). Both register
> the same historian tagname, so the returned series is identical. The `ns=N` index is assigned
> at connect time per the namespace URI (`https://zb.com/otopcua/raw` /
> `https://zb.com/otopcua/uns`) — resolve it from the server's `NamespaceArray` rather than
> hard-coding it. The examples below show a Raw-namespace read (`ns=2` illustrative).
```bash
# Raw history for a historized Galaxy tag (last 24 hours by default)
# Raw history for a historized tag via its Raw-namespace NodeId (last 24 hours by default)
otopcua-cli historyread \
-u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z"
# Limit to 100 values
# Same series via a referencing equipment's UNS-namespace NodeId, limited to 100 values
otopcua-cli historyread \
-u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
-n "ns=3;s=filling/line1/station1/Speed" \
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
--max 100
# 1-hour average aggregate
otopcua-cli historyread \
-u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
--aggregate Average --interval 3600000
# Authenticated read (ReadOnly role or higher required)
otopcua-cli historyread \
-u opc.tcp://localhost:4840/OtOpcUa \
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
-U reader -P password
```
+7
View File
@@ -212,6 +212,13 @@ The catalog is scoped per Blazor circuit; each call creates and disposes its own
`DbContext` via the pooled factory (same pattern as `UnsTreeService`). Results
are bounded to 200 entries to keep the completion list responsive on large fleets.
> **v3 Batch 4 (dual namespace) — unchanged for scripts.** A `ctx.GetTag(...)` literal
> still resolves by the tag's `FullName` / **RawPath** — which is precisely the identity
> of the Raw-namespace node (`ns=Raw, s=<RawPath>`). A `{{equip}}/<RefName>` token resolves
> through the equipment's `UnsTagReference` to that same backing RawPath (see below), so
> scripts key off the single value source regardless of the UNS projection. The dual
> namespace changes only the OPC UA address-space surface, not script tag-path semantics.
### Literal gate
`TryGetTagPathLiteral` identifies the tag-path context by climbing from the
+21 -4
View File
@@ -127,9 +127,25 @@ object alongside the usual `"FullName"`:
(unchanged behaviour). `"alarm"` **present** → the tag materialises as a Part 9
`AlarmConditionState` under its equipment folder **instead of** a value variable.
No EF/schema change is required; the intent rides in the schemaless `TagConfig`
blob and is parsed byte-parity in both the compose (`Phase7Composer`) and deploy
blob and is parsed byte-parity in both the compose (`AddressSpaceComposer`) and deploy
(`DeploymentArtifact`) paths.
> **v3 Batch 4 — multi-notifier fan-out.** The `"alarm"` object now rides on a **raw
> tag** authored in `/raw` (referenced into equipment), and the Part 9 condition
> materializes **once at the raw tag** — its `ConditionId` is the tag's **RawPath**, its
> parent notifier is the raw device/group folder. For **each referencing equipment**, the
> node manager (`WireAlarmNotifiers`) wires the SDK `AddNotifier` pattern
> (`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` +
> `EnsureFolderIsEventNotifier(equipFolder)`) so the single condition fans to the raw device
> folder AND every equipment folder. A **single** `ReportEvent` fans to all notifier roots —
> a Server-object subscriber sees **exactly one** copy (the Part 9 shared-snapshot dedup),
> never one-per-root. Teardown is symmetric (`RemoveNotifier(..., bidirectional:true)` on
> rebuild / reference removal) so inverse-notifier entries never leak across redeploys.
> Ack/confirm/shelve route on **`ConditionId = RawPath`** (never `SourceNodeId`), and the
> `/alerts` row lists the referencing equipment paths. See
> [AlarmTracking.md](AlarmTracking.md) and the WP4 section of
> `docs/plans/2026-07-15-v3-batch4-address-space-plan.md`.
### TagConfig alarm fields
| Field | Values | Default |
@@ -208,9 +224,10 @@ native alarm transitions identically. Publication to the `alerts` topic is
the OPC UA condition-node write is ungated on all nodes so a Secondary stays warm
for failover.
The alarm is authored on the `Tags` tab of the equipment page (`/uns/equipment/{id}`)
by editing the tag's raw `TagConfig` JSON to include the `"alarm"` object. No
other configuration is required.
The alarm is authored on the **raw tag** in `/raw` by including the `"alarm"` object in the
tag's `TagConfig` JSON (v3 Batch 4 — equipment no longer authors tags; it references raw
tags). No other configuration is required: any equipment that references the raw tag
automatically receives the alarm at its equipment folder via the multi-notifier fan-out above.
### Native-alarm OPC UA operator operations
+35
View File
@@ -110,6 +110,41 @@ UNS-referenced, or named by a script literal — see [`Raw.md`](Raw.md).
`GalaxyMxGateway` driver in `/raw`, referenced into equipment like any other raw
tag. There is no separate alias concept or `SystemPlatform`-kind namespace.
### OPC UA address-space projection (v3 Batch 4 — dual namespace)
> **v3 (Batch 4):** the server now exposes the address space under **two OPC UA
> namespaces** instead of the old single `https://zb.com/otopcua/ns`:
>
> | Namespace URI | Subtree | NodeId `s=` scheme |
> |---|---|---|
> | `https://zb.com/otopcua/raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) |
> | `https://zb.com/otopcua/uns` | the UNS tree (Area → Line → Equipment → signal) | the slash-joined **`Area/Line/Equipment/EffectiveName`** |
Every device value has **exactly one source** — the raw tag's node in the Raw
namespace. A `UnsTagReference` projects that raw tag into an equipment as a
**UNS-namespace variable** whose NodeId leaf is the reference's **effective name**
(the display-name override else the raw tag's `Name`). The UNS variable does not
bind a driver of its own: it carries an **`Organizes` reference to its backing raw
node** and mirrors it. A single driver publish for a RawPath **fans out** to the
raw NodeId AND every referencing UNS NodeId with identical value / quality /
source-timestamp — the two NodeIds never drift.
- **Reads / subscriptions** work through either NodeId and return the same data.
- **Writes** route through either NodeId to the same backing driver ref under the
**same `WriteOperate` gating** (a UNS write is neither more nor less privileged
than the raw write it fans from); a failed device write reverts both NodeIds via
the shared fan-out.
- **HistoryRead** works through either NodeId and returns the same series — both
NodeIds register the **same historian tagname** (see [`Historian.md`](Historian.md)).
- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and
fan via SDK notifiers to the raw device folder AND every referencing equipment
folder — see [`ScriptedAlarms.md`](ScriptedAlarms.md) / [`AlarmTracking.md`](AlarmTracking.md).
Note the two distinct identity strings: the wire **NodeId** is the path
`Area/Line/Equipment/EffectiveName`, while the **effective-name uniqueness key**
above is `{EquipmentId}/{EffectiveName}` (the logical per-equipment collision
space the guards enforce). They are related but not the same string.
### Virtual tags
A virtual tag is bound to an equipment and driven by a **script** (no driver).
+115
View File
@@ -0,0 +1,115 @@
# v3 Batch 4 — dual-namespace address space + raw-only runtime binding (= v3.0)
Implements `docs/plans/2026-07-15-v3-batch4-address-space-plan.md` (B4-WP1WP5), executed via the
`/v3-batch 4` coordinator: contracts-first fan-out, worktree-isolated Opus agents per wave, a
code-reviewer pass + green build/test after each wave, then the non-negotiable **7-leg v3.0 live gate**
on docker-dev + Client.CLI. **This batch lights up the address space** — v3.0.
## What landed
- **Contracts** (`a1a56e22`) — two namespace URIs (`…/otopcua/raw`, `…/otopcua/uns`, replacing the single
`…/ns`), `V3NodeIds` (raw = `s=<RawPath>`, UNS = `s=<Area>/<Line>/<Equipment>/<EffectiveName>`), and the
`AddressSpaceRealm` (`Raw | Uns`) discriminator carried at every sink seam.
- **Wave A** (2 agents ∥):
- **WP1** — composer/planner un-darkened: emit the **Raw** subtree (Folder→Driver→Device→TagGroup→Tag,
tags as `(realm=Raw, s=RawPath)`) + the **UNS** subtree (each `UnsTagReference` a Variable
`(realm=Uns, s=Area/Line/Equipment/EffectiveName)` carrying its backing RawPath + an `Organizes`
UNS→Raw ref); native-alarm plans at the **raw** tag (ConditionId=RawPath) carrying referencing-equipment
paths; per-realm planner diff (raw rename = remove+add Raw + re-point UNS); reactivated the Batch-1-skipped
`EquipmentNamespaceMaterializationTests`.
- **WP2** — node manager registers both namespaces; `AddressSpaceRealm` on every node-naming sink method
(maps keyed by ns-qualified NodeId so a Raw + UNS node sharing a bare `s=` id stay distinct; historized
UNS node registers the **same** tagname, mux single by RawPath); everything **forwarded through
`DeferredAddressSpaceSink`** + reflection guard extended (the forwarding trap). **M1 fix**: new
`AddReference` sink seam wiring the `Organizes` UNS→Raw edge bidirectionally (idempotent, missing-endpoint
no-op, forwarded + reflection-covered).
- **Wave B** (1 agent — the delicate one):
- **WP3** — applier materializes both realms + wires the Organizes edge; `DriverHostActor` dual-NodeId
registration + single-source fan-out (drift-free — one publish → raw NodeId AND every referencing UNS
NodeId, identical value/quality/timestamp); write routing + failed-write revert through both NodeIds;
`EquipmentNodeIds` retired; transitional realm defaults removed from the sink surface. **Review fixes**:
**H1** realm-qualified `(realm, bareId)` write-routing key (a raw RawPath that coincidentally equals a UNS
path can no longer misroute a write); **M1** coherent-dormant discovery-injection guard; **M2** dual-node
self-correction tests; **L1** node-manager routing defaults removed (~180 call sites); byte-parity
round-trip test.
- **Wave C** (2 agents ∥):
- **WP4** — multi-notifier native alarms: one condition at the raw tag, `AddNotifier`-fanned to the raw
device folder + every referencing equipment folder, **one `ReportEvent`** (exactly one Server-object copy —
proven by an over-the-wire event-delivery test), teardown symmetry (`RemoveNotifier(bidirectional:true)` on
rebuild/condition-removal/subtree-removal + reconcile), ack/confirm/shelve still on ConditionId=RawPath;
`AlarmTransitionEvent` + `/alerts` gain the referencing-equipment list. **Review fixes**: M1 event-delivery
test, M2 resolution-failure meter + composer↔applier path-agreement test, M3 reconcile, L1/L3.
- **WP5** — over-the-wire dual-namespace integration tests (`DualNamespaceAddressSpaceTests`) + 2-node
harness materialization round-trip; docs (`CLAUDE.md`, `docs/Uns.md`, `docs/ScriptEditor.md`,
`docs/Historian.md`, `docs/ScriptedAlarms.md`, `docs/AlarmTracking.md`).
- **Coordinator seam-fix** — threaded `ReferencingEquipmentPaths` into `DriverHostActor.ForwardNativeAlarm`'s
`AlarmTransitionEvent` so `/alerts` chips populate in production (WP3-owned file WP4 couldn't edit).
- **Live-gate bug fix** — the gate caught a genuine prod-inert defect: **Equipment VirtualTags never resolved
live** (a redeploy resets the VT's UNS node to `BadWaitingForInitialData`, but a surviving unchanged-plan
`VirtualTagActor` keeps its value-dedup state and suppresses the re-publish → the reset node stays Bad
forever with a static dependency). Fix: `ReassertValue` on apply re-emits the last value after the node
reset. Regression test fail-before/pass-after; live-verified Good. (Reviewer follow-ups: reassert skips
historian re-record (M1); scripted-alarm redeploy recovery (M2); comment (LOW-3).)
## Reviewer verdict per wave
No HIGH survived any wave. Wave A: forwarding trap verified solid (DispatchProxy guard catches unforwarded
methods), collision-free ns-qualified keying, planner raw-rename-repoints-UNS holds; M1 (Organizes seam) fixed.
Wave B: **H1** write-route realm collision fixed with a regression test; M1/M2/L1 fixed. Wave C: single-ReportEvent
+ teardown symmetry + forwarding trap all confirmed; M1/M2/M3/L1/L3 fixed. VT-fix review: ordering confirmed real,
dedup/02-S13 semantics intact; M1/M2/LOW-3 closed.
## v3.0 live gate (docker-dev `:9200` / `opc.tcp://:4840`+`:4841`, both centrals rebuilt on Batch-4 code)
1. **Browse both namespaces**`ns=2` Raw tree (`pymodbus/plc/HR200`, `s=<RawPath>`) + `ns=3` UNS tree
(`gatearea/gateline/gateequip/MainPressure`, effective names); both materialize `failed=0`.
2. **Single-source fan-out**`MainPressure` (UNS) = `HR200` (Raw) = **1234**, identical value AND timestamp;
Calculation tag `Cval` computes (=1235); the `{{equip}}/MainPressure` VirtualTag reads Good (post-fix).
3. **Writes** — write **4242** via the UNS NodeId → read-back **4242** via the raw NodeId; write **777** via the
raw NodeId; `opc-readonly` rejected **0x801F0000** on the UNS NodeId (role gating symmetric). Failed-write
dual-node revert is unit-covered (`exception_injector` fixture not provisioned on this rig).
4. **History** — HistoryRead via the raw NodeId and the UNS NodeId both return **GoodNoData** under the same
historian tagname (Null source — no gateway on docker-dev); provisioning tally `dispatched=1 … failed=0`.
5. **Alarms (multi-notifier)** — the native alarm materialized as a Part 9 **AlarmCondition** at the raw tag
(ConditionId = RawPath); the **equipment folder** accepts an alarm subscription + ConditionRefresh, proving
it is a live event-notifier wired to the raw condition; the Server object likewise. The transition delivery
(exactly-one Server copy + delivery at both notifiers + ack) is covered by the over-the-wire
`NativeAlarmMultiNotifierEventDeliveryTests` — docker-dev has no `IAlarmSource` driver (only Galaxy is one)
so a live transition can't be tripped on the rig.
6. **Rename cascade** — renamed `HR200``HR200X` + deployed: old raw NodeId gone, new present; the UNS
reference `MainPressure` follows automatically; the `{{equip}}` VirtualTag keeps working and tracks the
renamed tag live (3610→3611 Good); the **absolute** RawPath script literal (`Cval`) now fails **Bad
0x80000000**. (Caveat: a raw-tag rename needs a node recreate/restart to hot-rebind the renamed tag's live
driver value — the pre-existing deploy-THEN-recreate pattern for structural edits.)
7. **Redundancy** — ServiceLevel split **central-1 = 250 / central-2 = 240** (both Good) — the Primary/Secondary
differentiation is intact with the second namespace present; single-emit-per-condition is unit-covered (the
alarm-emit gate is Primary-only).
## Tests
Full solution builds **0 errors**. Commons 306, Configuration 121, OpcUaServer 375/4-skip (+ OpcUaServer.IntegrationTests
dual-namespace + event-delivery), Runtime 377 (+ VT reassert regression), AdminUI 659, Host.IntegrationTests green
(the pre-existing Batch-2 `Calculation`-probe stale test fixed here). The remaining Runtime skips are legacy
`EquipmentTags`-model dark tests + the dormant discovery-injection scenarios (accurate skip reasons).
## Documented follow-ups (non-blocking)
- **Scripted-alarm redeploy recovery (CONFIRMED pre-existing bug, deferred — deserves its own careful fix).**
The scripted-alarm Part 9 condition node has the same redeploy-reset race the VT `ReassertValue` fix closed:
`RebuildAddressSpace` clears `_alarmConditions` and `MaterialiseScriptedAlarms` recreates each condition
fresh/normal, but `ScriptedAlarmEngine.LoadAsync` reloads from the persisted state and yields
`EmissionKind.None` for a still-active condition (dropped by `OnEngineEmission`), so an **active scripted
alarm with static dependencies under-reports as normal after a full-rebuild deploy until its next real
transition** (the known "snapshot-is-one-shot / restart-to-re-deliver" gotcha — NOT a Batch-4 regression).
Deferred because the fix is a Core-engine emission-contract change carrying **double-alarm-history risk**
(a naive re-emit would append a duplicate AVEVA/historian row per deploy — the alarm analogue of the VT M1
historian issue). Proposed shape: a host-driven, node-only `AlarmStateUpdate` re-assert in `OnAlarmsLoaded`
(same safe post-materialise ordering as the VT fix), reusing `_engine.GetState(alarmId)` + `LoadedAlarmIds`,
**never touching the `alerts` topic**.
- Raw-tag rename hot-rebind of the renamed tag's live value without a recreate (today: deploy-THEN-recreate).
- Absolute-path Monaco completion → RawPath (from Batch 3; the `{{equip}}/` completion works).
- Cross-repo (post-merge): ScadaBridge Data-Connection-Layer NodeId/namespace cutover (raw `s=<RawPath>` /
UNS `s=<Area>/<Line>/<Equipment>/<EffectiveName>`, retired `EquipmentNodeIds`) + the umbrella index
`../scadaproj/CLAUDE.md` OtOpcUa entry.
https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
@@ -17,6 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
/// <param name="AlarmTypeName">OPC UA Part 9 condition subtype name — one of <c>LimitAlarm</c> / <c>DiscreteAlarm</c> / <c>OffNormalAlarm</c> / <c>AlarmCondition</c> (the base type, used as the default). The historian feed maps this onto the durable alarm-type column.</param>
/// <param name="Comment">Operator-supplied comment on ack / confirm / comment transitions; <c>null</c> for engine-driven transitions (Activated / Cleared / Shelved / …) that carry no comment.</param>
/// <param name="HistorizeToAveva">When <c>false</c>, the durable historian sink suppresses this transition (the live <c>alerts</c> fan-out is unaffected); <c>null</c> or <c>true</c> historize. <c>null</c> is the cross-version/rolling-restart case: an old-format message missing the field deserializes to <c>null</c> (CLR default for <c>bool?</c>) and is historized (safe default-on), matching the <c>AlarmTypeName</c> null-coalesce in <c>HistorianAdapterActor.Translate</c>. The producer (<c>ScriptedAlarmHostActor</c>) always sets a concrete <c>true</c>/<c>false</c>.</param>
/// <param name="ReferencingEquipmentPaths">v3 Batch 4 (multi-notifier native alarms) — the (possibly empty) list of UNS equipment-folder paths (<c>Area/Line/Equipment</c>) that reference the alarm's backing raw tag. A native alarm is a SINGLE Part 9 condition materialised once at the raw tag (its <see cref="AlarmId"/> is the RawPath); the same condition fans events to every referencing equipment's UNS folder via SDK notifiers, and this list is carried so <c>/alerts</c> shows the one condition row with all its referencing equipment as display metadata. Empty for scripted alarms (they are per-equipment) and for a native alarm whose raw tag no equipment references. Defaults empty so every existing producer + rolling-restart deserialization keeps compiling / working.</param>
public sealed record AlarmTransitionEvent(
string AlarmId,
string EquipmentPath,
@@ -28,4 +29,5 @@ public sealed record AlarmTransitionEvent(
DateTime TimestampUtc,
string AlarmTypeName = "AlarmCondition",
string? Comment = null,
bool? HistorizeToAveva = null);
bool? HistorizeToAveva = null,
IReadOnlyList<string>? ReferencingEquipmentPaths = null);
@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <summary>
/// Which of the two v3 OPC UA namespaces a node lives in. Carried explicitly alongside a
/// node's <c>s=</c> identifier at every address-space sink seam so the namespace is chosen at
/// the call site, never parsed back out of the id string (explicit beats inferred).
/// <see cref="V3NodeIds.NamespaceUri"/> maps each realm to its namespace URI.
/// </summary>
public enum AddressSpaceRealm
{
/// <summary>The device-oriented Raw tree (Folder→Driver→Device→TagGroup→Tag); a node's
/// <c>s=</c> id is its <see cref="ZB.MOM.WW.OtOpcUa.Commons.Types.RawPaths">RawPath</see>.</summary>
Raw,
/// <summary>The equipment-oriented UNS tree (Area→Line→Equipment→signal); a node's <c>s=</c>
/// id is the slash-joined <c>Area/Line/Equipment[/EffectiveName]</c>.</summary>
Uns,
}
@@ -23,30 +23,41 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
_inner = sink ?? NullOpcUaAddressSpaceSink.Instance;
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc);
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> _inner.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName);
// Forward the WP4 multi-notifier wiring to the inner sink. Without this the native-alarm fan-out to the
// referencing equipment folders ships INERT on every driver-role host (actors inject THIS wrapper, not the
// inner SdkAddressSpaceSink) — the F10b / PR#423 forwarding trap the reflection guard exists to catch.
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
=> _inner.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength);
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> _inner.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> _inner.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
/// <inheritdoc />
public void RebuildAddressSpace() => _inner.RebuildAddressSpace();
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId) => _inner.RaiseNodesAddedModelChange(affectedNodeId);
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _inner.RaiseNodesAddedModelChange(affectedNodeId, realm);
/// <inheritdoc />
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
=> _inner.AddReference(sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType);
/// <inheritdoc />
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
@@ -55,9 +66,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
// Without this forward the surgical optimization is inert on every driver-role host, because
// actors inject THIS wrapper, not the inner sink. ALL six args (including the DataType/array-shape
// ones) MUST be forwarded — a partial forward silently drops the shape update on every driver-role host.
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength);
&& surgical.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm);
/// <inheritdoc />
// Forwards to the inner sink when it supports the surgical capability; returns false otherwise —
@@ -65,9 +76,9 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
// sink that isn't surgical — so the caller (AddressSpaceApplier) falls back to a full rebuild.
// Without this forward the rename-refresh optimization is inert on every driver-role host, because
// actors inject THIS wrapper, not the inner sink.
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName);
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName, realm);
// R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
// UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false
@@ -75,14 +86,14 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
// (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is
// inert on every driver-role host — the F10b / PR#423 trap the reflection guard exists to catch.
/// <inheritdoc />
public bool RemoveVariableNode(string variableNodeId)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId);
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId, realm);
/// <inheritdoc />
public bool RemoveAlarmConditionNode(string alarmNodeId)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId);
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId, realm);
/// <inheritdoc />
public bool RemoveEquipmentSubtree(string equipmentNodeId)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId);
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId, realm);
}
@@ -1,31 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <summary>
/// Single source of truth for equipment-namespace OPC UA NodeId strings. The variable NodeId is
/// FOLDER-SCOPED (<c>{parent}/{Name}</c>), NOT the driver-side FullName — a driver wire ref is not
/// unique across identical machines, so FullName-as-NodeId would collide in the sink. Used by the
/// materialiser (AddressSpaceApplier), the VirtualTag publish map, and the driver live-value router so all
/// three agree on the exact NodeId a variable was placed at.
/// </summary>
public static class EquipmentNodeIds
{
/// <summary>The sub-folder NodeId under an equipment for a non-empty FolderPath: <c>{equipmentId}/{folderPath}</c>.</summary>
/// <param name="equipmentId">The owning equipment's NodeId.</param>
/// <param name="folderPath">The tag/vtag FolderPath (must be non-empty for this to be meaningful).</param>
/// <returns>The sub-folder NodeId string.</returns>
public static string SubFolder(string equipmentId, string folderPath) => $"{equipmentId}/{folderPath}";
/// <summary>
/// The folder-scoped variable NodeId: <c>{parent}/{name}</c> where <c>parent = equipmentId</c> when
/// <paramref name="folderPath"/> is null/empty, else <see cref="SubFolder"/>.
/// </summary>
/// <param name="equipmentId">The owning equipment's NodeId.</param>
/// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
/// <param name="name">The tag/vtag Name (the leaf browse segment).</param>
/// <returns>The folder-scoped variable NodeId string.</returns>
public static string Variable(string equipmentId, string? folderPath, string name)
{
var parent = string.IsNullOrWhiteSpace(folderPath) ? equipmentId : SubFolder(equipmentId, folderPath);
return $"{parent}/{name}";
}
}
@@ -13,7 +13,11 @@ public interface IOpcUaAddressSpaceSink
/// <param name="value">The value to write.</param>
/// <param name="quality">The quality status of the value.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc);
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) the <paramref name="nodeId"/> lives in — the sink
/// resolves the namespace index from the realm rather than parsing it out of the id string. WP3's fan-out
/// calls this once per NodeId (raw + every referencing UNS node) with the matching realm.</param>
void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
/// <summary>Write an alarm-condition's full Part 9 state. When a real condition node has been
/// materialised for <paramref name="alarmNodeId"/> via <see cref="MaterialiseAlarmCondition"/>,
@@ -25,7 +29,9 @@ public interface IOpcUaAddressSpaceSink
/// <param name="alarmNodeId">The OPC UA node ID of the alarm (== ScriptedAlarmId for materialised conditions).</param>
/// <param name="state">The full condition state to project onto the node.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc);
/// <param name="realm">The namespace realm <paramref name="alarmNodeId"/> lives in (must match the realm
/// the condition was materialised under).</param>
void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
/// <summary>
/// Materialise a real OPC UA Part 9 alarm-condition node under its equipment folder so clients
@@ -41,7 +47,31 @@ public interface IOpcUaAddressSpaceSink
/// <param name="isNative">When true this is a driver-fed (native, e.g. Galaxy) equipment-tag alarm; when
/// false (default) it is a scripted alarm. The node manager tracks native condition node ids so a later
/// task can route a native condition's inbound Acknowledge to the driver instead of the scripted engine.</param>
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false);
/// <param name="realm">The namespace realm the condition (and its parent folder <paramref name="equipmentNodeId"/>)
/// live in.</param>
void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false);
/// <summary>
/// v3 Batch 4 (multi-notifier native alarms) — wire an already-materialised native alarm condition
/// (<paramref name="alarmNodeId"/>, materialised at the raw tag via <see cref="MaterialiseAlarmCondition"/>
/// with ConditionId = the RawPath) as an event notifier of EACH referencing equipment's UNS folder, so
/// the condition's <b>single</b> <c>ReportEvent</c> fans one event to every referencing equipment root
/// WITHOUT re-reporting per root (distinct EventIds would break Server-object dedup + Part 9 ack
/// correlation). Per folder the sink wires the SDK's bidirectional notifier pattern
/// (<c>alarm.AddNotifier(isInverse:true, folder)</c> + <c>folder.AddNotifier(isInverse:false, alarm)</c>)
/// and promotes the folder to an event notifier. <b>Idempotent</b> (a re-wire of the same pair updates,
/// never duplicates); a <b>missing endpoint is a no-op</b> (logged, never thrown) so a mid-rebuild race
/// can't fault a deploy. The sink tracks each wired pair so a later rebuild / condition-removal /
/// equipment-subtree-removal tears the notifier down bidirectionally (no inverse-notifier entry leaks
/// across redeploys).
/// </summary>
/// <param name="alarmNodeId">The native alarm condition's node id (== the backing tag's RawPath).</param>
/// <param name="alarmRealm">The namespace realm the condition lives in (Raw for native alarms).</param>
/// <param name="notifierFolderNodeIds">The equipment folder node ids (their <c>s=</c> ids) to wire as
/// extra event-notifier roots for this condition. An unknown / not-yet-materialised folder id is skipped.</param>
/// <param name="notifierFolderRealm">The namespace realm the notifier folders live in (Uns for equipment folders).</param>
void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm,
IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm);
/// <summary>
/// Ensure a folder node exists under the given parent. Used by <c>AddressSpaceApplier</c> to
@@ -52,7 +82,8 @@ public interface IOpcUaAddressSpaceSink
/// <param name="folderNodeId">The OPC UA node ID for the folder.</param>
/// <param name="parentNodeId">The parent folder node ID, or null for namespace root.</param>
/// <param name="displayName">The display name for the folder.</param>
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName);
/// <param name="realm">The namespace realm the folder (and its <paramref name="parentNodeId"/>) live in.</param>
void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm);
/// <summary>
/// Ensure a Variable node exists at <paramref name="variableNodeId"/>, parented under
@@ -77,7 +108,10 @@ public interface IOpcUaAddressSpaceSink
/// rank + dimensions carry the array-ness.</param>
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is
/// true; ignored for scalars. Null ⇒ length 0 (unbounded).</param>
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null);
/// <param name="realm">The namespace realm the variable (and its <paramref name="parentFolderNodeId"/>) live
/// in. A historized UNS reference node registers the SAME <paramref name="historianTagname"/> as its backing
/// raw node — both NodeIds map to one tagname — so this call carries the shared tagname per realm.</param>
void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null);
/// <summary>
/// Tear down + repopulate the address space. Called by <c>OpcUaPublishActor</c> after a
@@ -91,7 +125,28 @@ public interface IOpcUaAddressSpaceSink
/// (Part 3 GeneralModelChangeEvent, verb NodeAdded).
/// </summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
void RaiseNodesAddedModelChange(string affectedNodeId);
/// <param name="realm">The namespace realm <paramref name="affectedNodeId"/> lives in.</param>
void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm);
/// <summary>
/// Add an OPC UA reference from an already-materialised source node to an already-materialised
/// target node, both realm-qualified. Used by <c>AddressSpaceApplier</c> to link each UNS reference
/// Variable (source, <see cref="AddressSpaceRealm.Uns"/>) to its backing Raw node (target,
/// <see cref="AddressSpaceRealm.Raw"/>) with an <c>Organizes</c> edge, so the UNS variable
/// browse-resolves to its raw source (and the raw node back via the inverse). The edge is wired
/// bidirectionally (forward on the source, inverse on the target) so browse resolves both ways.
/// <b>Idempotent</b> (an existing edge is not duplicated); a <b>missing endpoint is a no-op</b>
/// (logged, never thrown) so a mid-rebuild race can't fault a deploy.
/// </summary>
/// <param name="sourceNodeId">The source node's <c>s=</c> id (the referencing node — e.g. the UNS variable).</param>
/// <param name="sourceRealm">The namespace realm <paramref name="sourceNodeId"/> lives in.</param>
/// <param name="targetNodeId">The target node's <c>s=</c> id (the referenced node — e.g. the backing Raw node).</param>
/// <param name="targetRealm">The namespace realm <paramref name="targetNodeId"/> lives in.</param>
/// <param name="referenceType">The hierarchical reference type name — <c>Organizes</c> (default),
/// <c>HasComponent</c>, or <c>HasProperty</c>; unknown names fall back to <c>Organizes</c>.</param>
void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm,
string targetNodeId, AddressSpaceRealm targetRealm,
string referenceType = "Organizes");
}
/// <summary>OPC UA status code projection — Good / Uncertain / Bad. Real SDK has finer-grained
@@ -106,23 +161,29 @@ public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
private NullOpcUaAddressSpaceSink() { }
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// <inheritdoc />
public void RebuildAddressSpace() { }
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
/// <inheritdoc />
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
@@ -11,11 +11,17 @@ public interface IOpcUaNodeWriteGateway
{
/// <summary>Route a write of <paramref name="value"/> to the driver backing node
/// <paramref name="nodeId"/>; the returned task resolves with the device-write outcome.</summary>
/// <param name="nodeId">The folder-scoped equipment-variable node id being written.</param>
/// <param name="nodeId">The full ns-qualified node id being written (<c>node.NodeId.ToString()</c>).</param>
/// <param name="value">The value the client wrote.</param>
/// <param name="realm">Which of the two v3 namespaces (<see cref="AddressSpaceRealm.Raw"/> device tree or
/// <see cref="AddressSpaceRealm.Uns"/> equipment tree) <paramref name="nodeId"/> lives in — the node
/// manager resolves it from the node's namespace index (<c>RealmOf</c>). It is REQUIRED for correct
/// routing: a raw <c>s=&lt;RawPath&gt;</c> and a UNS <c>s=&lt;Area/Line/Equip/Eff&gt;</c> can collide as bare
/// strings, so the routing map is keyed by <c>(realm, bareId)</c> — dropping the realm would let an
/// operator write route to the WRONG driver ref.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task resolving to the device-write outcome.</returns>
Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct);
Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct);
}
/// <summary>Outcome of routing an inbound node write to the backing driver.</summary>
@@ -31,6 +37,6 @@ public sealed class NullOpcUaNodeWriteGateway : IOpcUaNodeWriteGateway
public static readonly NullOpcUaNodeWriteGateway Instance = new();
private NullOpcUaNodeWriteGateway() { }
/// <inheritdoc />
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct) =>
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) =>
Task.FromResult(new NodeWriteOutcome(false, "writes unavailable"));
}
@@ -21,8 +21,10 @@ public interface ISurgicalAddressSpaceSink
/// <param name="dataType">The OPC UA built-in data type name (e.g. "Boolean", "Int32", "Float").</param>
/// <param name="isArray">When true the node is a 1-D array (ValueRank=OneDimension); when false scalar.</param>
/// <param name="arrayLength">The declared length of the 1-D array when <paramref name="isArray"/> is true; ignored for scalars.</param>
/// <param name="realm">The namespace realm <paramref name="variableNodeId"/> lives in — the sink resolves the
/// namespace index from the realm rather than parsing it out of the id string.</param>
/// <returns>True when the in-place update was applied; false when the node is missing (caller rebuilds).</returns>
bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength);
bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm);
/// <summary>Update an existing folder node's <c>DisplayName</c> IN PLACE, notifying subscribers
/// (ClearChangeMasks) without a rebuild — used by AddressSpaceApplier to apply a UNS Area / Line
@@ -32,8 +34,9 @@ public interface ISurgicalAddressSpaceSink
/// should rebuild instead).</summary>
/// <param name="folderNodeId">The node id of the folder whose display name to update in place.</param>
/// <param name="displayName">The new display name to apply.</param>
/// <param name="realm">The namespace realm <paramref name="folderNodeId"/> lives in.</param>
/// <returns>True when the in-place update was applied; false when the folder is missing (caller rebuilds).</returns>
bool UpdateFolderDisplayName(string folderNodeId, string displayName);
bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm);
/// <summary>Remove ONE existing value-variable node IN PLACE (detach from its parent, drop it from the
/// predefined-node set, drop any historized-tagname registration), then raise a Part 3
@@ -43,8 +46,9 @@ public interface ISurgicalAddressSpaceSink
/// re-subscription attempts get BadNodeIdUnknown from the SDK. Returns false when the node id is unknown
/// (the node-manager maps drifted from the plan — caller falls back to a full rebuild).</summary>
/// <param name="variableNodeId">The folder-scoped node id of the variable to remove in place.</param>
/// <param name="realm">The namespace realm <paramref name="variableNodeId"/> lives in.</param>
/// <returns>True when the node was removed; false when the id is unknown (caller rebuilds).</returns>
bool RemoveVariableNode(string variableNodeId);
bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm);
/// <summary>Remove ONE existing Part 9 alarm-condition node IN PLACE (detach, drop from the
/// predefined-node set, clear the native-alarm flag), then raise NodeDeleted outside the lock. The
@@ -52,8 +56,9 @@ public interface ISurgicalAddressSpaceSink
/// stops replaying on ConditionRefresh. Returns false when the condition id is unknown (caller
/// rebuilds).</summary>
/// <param name="alarmNodeId">The alarm condition node id (== ScriptedAlarmId, or a native alarm tag's folder-scoped id).</param>
/// <param name="realm">The namespace realm <paramref name="alarmNodeId"/> lives in.</param>
/// <returns>True when the condition was removed; false when the id is unknown (caller rebuilds).</returns>
bool RemoveAlarmConditionNode(string alarmNodeId);
bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm);
/// <summary>Remove an equipment folder AND its entire descendant subtree (sub-folders, value variables,
/// condition nodes) IN PLACE: per-descendant map/registration cleanup (variables, historized tagnames,
@@ -62,6 +67,7 @@ public interface ISurgicalAddressSpaceSink
/// the equipment folder outside the lock. Returns false when the folder id is unknown (caller
/// rebuilds).</summary>
/// <param name="equipmentNodeId">The equipment folder node id whose entire subtree to remove.</param>
/// <param name="realm">The namespace realm <paramref name="equipmentNodeId"/> lives in.</param>
/// <returns>True when the subtree was removed; false when the id is unknown (caller rebuilds).</returns>
bool RemoveEquipmentSubtree(string equipmentNodeId);
bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm);
}
@@ -0,0 +1,110 @@
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
/// <summary>
/// v3 dual-namespace NodeId + namespace-URI authority. Two OPC UA namespaces expose the same
/// underlying values under two identity schemes:
/// <list type="bullet">
/// <item><b>Raw</b> (<see cref="RawNamespaceUri"/>): the device-oriented tree
/// Folder→Driver→Device→TagGroup→Tag. A node's <c>s=</c> identifier IS its
/// <see cref="RawPaths">RawPath</see> — folders/drivers/devices/groups included (each keys
/// on its own RawPath prefix).</item>
/// <item><b>Uns</b> (<see cref="UnsNamespaceUri"/>): the equipment-oriented tree
/// Area→Line→Equipment→signal. A node's <c>s=</c> identifier is the slash-joined
/// <c>Area/Line/Equipment[/EffectiveName]</c>.</item>
/// </list>
/// These replace the single <c>https://zb.com/otopcua/ns</c> namespace and the retired
/// <c>EquipmentNodeIds</c> ({equipmentId}/{folderPath}/{name}) scheme. Realm travels alongside
/// the identifier as an <see cref="AddressSpaceRealm"/> at every sink seam — the namespace is
/// never parsed back out of the id string. Both schemes share <see cref="RawPaths.Separator"/>
/// and its ordinal, case-sensitive segment charset so identity is enforced in one place.
/// </summary>
public static class V3NodeIds
{
/// <summary>The Raw namespace URI (device-oriented subtree).</summary>
public const string RawNamespaceUri = "https://zb.com/otopcua/raw";
/// <summary>The UNS namespace URI (equipment-oriented subtree).</summary>
public const string UnsNamespaceUri = "https://zb.com/otopcua/uns";
/// <summary>The namespace URI for a realm.</summary>
/// <param name="realm">The address-space realm.</param>
/// <returns>The realm's namespace URI.</returns>
/// <exception cref="ArgumentOutOfRangeException">Unknown realm.</exception>
public static string NamespaceUri(AddressSpaceRealm realm) => realm switch
{
AddressSpaceRealm.Raw => RawNamespaceUri,
AddressSpaceRealm.Uns => UnsNamespaceUri,
_ => throw new ArgumentOutOfRangeException(nameof(realm), realm, "Unknown address-space realm."),
};
// ----- Raw realm -----
/// <summary>
/// The Raw-namespace <c>s=</c> identifier for a raw node — identical to its
/// <see cref="RawPaths">RawPath</see> (folders, drivers, devices, groups, and tags all key
/// on their own RawPath). A pass-through seam so call sites read intent rather than a bare
/// string; the argument is expected to already be a RawPaths-built path.
/// </summary>
/// <param name="rawPath">The (already-built) RawPath.</param>
/// <returns>The Raw-namespace <c>s=</c> identifier (== <paramref name="rawPath"/>).</returns>
public static string Raw(string rawPath)
{
ArgumentException.ThrowIfNullOrEmpty(rawPath);
return rawPath;
}
// ----- Uns realm -----
/// <summary>
/// The UNS-namespace <c>s=</c> identifier for a folder or variable, slash-joined from
/// ordered segments (Area, then Line, then Equipment, then an optional EffectiveName).
/// Every segment is validated via <see cref="RawPaths.ValidateSegment"/> (no embedded
/// separator, no leading/trailing whitespace) so a UNS id round-trips like a RawPath.
/// </summary>
/// <param name="segments">The ordered, non-empty segments (Area → leaf).</param>
/// <returns>The slash-joined UNS <c>s=</c> identifier.</returns>
/// <exception cref="ArgumentException">A segment is invalid, or the sequence is empty.</exception>
public static string Uns(params string[] segments) => UnsFromSegments(segments);
/// <summary>Build a UNS <c>s=</c> identifier from ordered segments. See <see cref="Uns(string[])"/>.</summary>
/// <param name="segments">The ordered, non-empty segments (Area → leaf).</param>
/// <returns>The slash-joined UNS <c>s=</c> identifier.</returns>
public static string Uns(IEnumerable<string> segments) => UnsFromSegments(segments);
/// <summary>
/// Append a child segment (a Line under an Area, an Equipment under a Line, an
/// EffectiveName under an Equipment) to an already-built UNS path.
/// </summary>
/// <param name="parentUnsPath">The parent UNS path (assumed already valid).</param>
/// <param name="childSegment">The child segment to append.</param>
/// <returns>The combined UNS <c>s=</c> identifier.</returns>
/// <exception cref="ArgumentException">The child segment is invalid, or the parent is blank.</exception>
public static string UnsChild(string parentUnsPath, string childSegment)
{
ArgumentException.ThrowIfNullOrEmpty(parentUnsPath);
var error = RawPaths.ValidateSegment(childSegment);
if (error is not null)
throw new ArgumentException($"Invalid UNS NodeId segment ('{childSegment}'): {error}", nameof(childSegment));
return parentUnsPath + RawPaths.Separator + childSegment;
}
private static string UnsFromSegments(IEnumerable<string> segments)
{
ArgumentNullException.ThrowIfNull(segments);
var list = segments as IReadOnlyList<string> ?? segments.ToList();
if (list.Count == 0)
throw new ArgumentException("A UNS NodeId must have at least one segment.", nameof(segments));
for (var i = 0; i < list.Count; i++)
{
var error = RawPaths.ValidateSegment(list[i]);
if (error is not null)
throw new ArgumentException($"Invalid UNS NodeId segment at index {i} ('{list[i]}'): {error}", nameof(segments));
}
return string.Join(RawPaths.Separator, list);
}
}
@@ -64,7 +64,22 @@ else
<tr>
<td><span class="mono small">@e.TimestampUtc.ToString("HH:mm:ss.fff")</span></td>
<td><span class="mono">@e.AlarmId</span><div class="text-muted small">@e.AlarmName</div></td>
<td><span class="mono small">@e.EquipmentPath</span></td>
<td>
<span class="mono small">@e.EquipmentPath</span>
@* v3 Batch 4 (multi-notifier native alarms): one condition row carries the list
of referencing equipment (Area/Line/Equipment) as display metadata. Shown only
when the producer populated it (native alarms whose raw tag ≥1 equipment
references); scripted alarms + unreferenced native alarms leave it empty. *@
@if (e.ReferencingEquipmentPaths is { Count: > 0 } refs)
{
<div class="text-muted small" title="Referencing equipment">
@foreach (var p in refs)
{
<span class="chip chip-idle" style="font-size:0.72rem; margin:1px">@p</span>
}
</div>
}
</td>
<td><span class="chip @KindChipClass(e.TransitionKind)">@e.TransitionKind</span></td>
<td class="num">@e.Severity</td>
<td>@e.User</td>
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -96,29 +97,33 @@ public sealed class AddressSpaceApplier
var failedNodes = 0;
foreach (var eq in plan.RemovedEquipment)
{
if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts)) failedNodes++;
if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
removedCount++;
}
foreach (var alarm in plan.RemovedAlarms)
{
if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts)) failedNodes++;
if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
removedCount++;
}
// Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write
// before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes
// is accurate on a removed-tag-only deploy, which now reaches the rebuild path below.
removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count;
// is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. v3 Batch 4:
// removed raw containers/tags + UNS reference variables are likewise real removals.
removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count;
var changedCount =
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
plan.ChangedEquipmentTags.Count +
plan.ChangedEquipmentVirtualTags.Count +
plan.ChangedRawContainers.Count + plan.ChangedRawTags.Count + plan.ChangedUnsReferenceVariables.Count +
// A UNS Area/Line rename is an in-place change to an existing folder node.
plan.RenamedFolders.Count;
var addedCount =
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
plan.AddedEquipmentTags.Count +
plan.AddedEquipmentVirtualTags.Count;
plan.AddedEquipmentVirtualTags.Count +
plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count;
// R2-07 03/P1 — classify the plan and route each delta class to its MINIMAL mutation instead of
// rebuilding the world for any topology change. AddressSpaceChangeClassifier is a pure policy over
@@ -172,7 +177,7 @@ public sealed class AddressSpaceApplier
foreach (var rename in renamedFolders)
{
bool ok;
try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName); }
try { ok = surgical.UpdateFolderDisplayName(rename.FolderNodeId, rename.NewDisplayName, AddressSpaceRealm.Uns); }
catch (Exception ex)
{
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateFolderDisplayName threw for {Node}", rename.FolderNodeId);
@@ -185,13 +190,13 @@ public sealed class AddressSpaceApplier
// Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags
// would so the in-place update matches what a rebuild would have produced. Array tags are
// forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays).
var nodeId = EquipmentNodeIds.Variable(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name);
var nodeId = UnsEquipmentVar(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name);
var writable = d.Current.Writable && !d.Current.IsArray;
var historian = d.Current.IsHistorized
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname)
: null;
bool ok;
try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength); }
try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Uns); }
catch (Exception ex)
{
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId);
@@ -263,77 +268,108 @@ public sealed class AddressSpaceApplier
// Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment.
foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId)))
{
var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name);
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
if (tag.Alarm is not null)
{
// Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap
// closed), then remove the condition in place.
if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts)) failedNodes++;
if (!SafeRemoveAlarmCondition(surgical, nodeId)) return false;
if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++;
if (!SafeRemoveAlarmCondition(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
}
else
{
// Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal.
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts);
if (!SafeRemoveVariable(surgical, nodeId)) return false;
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
}
}
// Removed VirtualTags (always plain value variables) for surviving equipment.
foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId)))
{
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name);
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts);
if (!SafeRemoveVariable(surgical, nodeId)) return false;
var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name);
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false;
}
// Removed scripted alarms for surviving equipment — the terminal RemovedConditionState was already
// written by the top-of-Apply removal block; here we tear the condition node down in place.
foreach (var alarm in plan.RemovedAlarms.Where(a => NotSubsumed(a.EquipmentId)))
{
if (!SafeRemoveAlarmCondition(surgical, alarm.ScriptedAlarmId)) return false;
if (!SafeRemoveAlarmCondition(surgical, alarm.ScriptedAlarmId, AddressSpaceRealm.Uns)) return false;
}
// Removed equipment — one subtree teardown each (subsumes their child tags/vtags/alarms). The
// top-of-Apply block already wrote the equipment id's terminal condition state.
foreach (var eq in plan.RemovedEquipment)
{
if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId)) return false;
if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId, AddressSpaceRealm.Uns)) return false;
}
// v3 Batch 4 — removed UNS reference variables (Uns realm, plain value nodes): terminal Bad then
// in-place remove. A backing-raw rename is a re-point (Changed, → rebuild), NOT a removal, so a UNS
// ref only reaches here on a genuine dereference (the equipment dropped the reference).
foreach (var v in plan.RemovedUnsReferenceVariables)
{
SafeWriteValue(v.NodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Uns);
if (!SafeRemoveVariable(surgical, v.NodeId, AddressSpaceRealm.Uns)) return false;
}
// v3 Batch 4 — removed RAW tags (Raw realm): an alarm-bearing raw tag is a condition node (terminal
// RemovedConditionState + RemoveAlarmConditionNode); a plain raw value tag is a variable (terminal Bad
// + RemoveVariableNode).
foreach (var t in plan.RemovedRawTags)
{
if (t.Alarm is not null)
{
if (!SafeWriteAlarmCondition(t.NodeId, RemovedConditionState, ts, AddressSpaceRealm.Raw)) failedNodes++;
if (!SafeRemoveAlarmCondition(surgical, t.NodeId, AddressSpaceRealm.Raw)) return false;
}
else
{
SafeWriteValue(t.NodeId, null, OpcUaQuality.Bad, ts, AddressSpaceRealm.Raw);
if (!SafeRemoveVariable(surgical, t.NodeId, AddressSpaceRealm.Raw)) return false;
}
}
// Removed raw CONTAINER nodes (Folder/Driver/Device/TagGroup) have no surgical folder-remove on the
// sink surface, so any container removal falls back to a full rebuild (the ratchet). Evaluated LAST so
// the cheap variable/condition removals above still run in place when only tags were removed.
if (plan.RemovedRawContainers.Count > 0) return false;
return true;
}
/// <summary>Remove a value-variable node in place, treating a throw as a false (⇒ rebuild fallback).</summary>
/// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns>
private bool SafeRemoveVariable(ISurgicalAddressSpaceSink surgical, string nodeId)
private bool SafeRemoveVariable(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm)
{
try { return surgical.RemoveVariableNode(nodeId); }
try { return surgical.RemoveVariableNode(nodeId, realm); }
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveVariableNode threw for {Node}", nodeId); return false; }
}
/// <summary>Remove an alarm-condition node in place, treating a throw as a false (⇒ rebuild fallback).</summary>
/// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns>
private bool SafeRemoveAlarmCondition(ISurgicalAddressSpaceSink surgical, string nodeId)
private bool SafeRemoveAlarmCondition(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm)
{
try { return surgical.RemoveAlarmConditionNode(nodeId); }
try { return surgical.RemoveAlarmConditionNode(nodeId, realm); }
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveAlarmConditionNode threw for {Node}", nodeId); return false; }
}
/// <summary>Remove an equipment subtree in place, treating a throw as a false (⇒ rebuild fallback).</summary>
/// <returns><c>true</c> when the subtree was removed; <c>false</c> when unknown or the sink threw.</returns>
private bool SafeRemoveEquipmentSubtree(ISurgicalAddressSpaceSink surgical, string nodeId)
private bool SafeRemoveEquipmentSubtree(ISurgicalAddressSpaceSink surgical, string nodeId, AddressSpaceRealm realm)
{
try { return surgical.RemoveEquipmentSubtree(nodeId); }
try { return surgical.RemoveEquipmentSubtree(nodeId, realm); }
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveEquipmentSubtree threw for {Node}", nodeId); return false; }
}
/// <summary>Write a value, swallowing (and Warning-logging) any sink fault — used for the terminal Bad
/// on a removed variable so an in-flight MonitoredItem observes the removal.</summary>
/// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
private bool SafeWriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts)
private bool SafeWriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm)
{
try { _sink.WriteValue(nodeId, value, quality, ts); return true; }
try { _sink.WriteValue(nodeId, value, quality, ts, realm); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteValue threw for {Node}", nodeId); return false; }
}
@@ -373,7 +409,22 @@ public sealed class AddressSpaceApplier
/// <param name="folderPath">The tag/vtag FolderPath, or null/empty for "directly under the equipment".</param>
/// <returns>The materialise-parent node id.</returns>
private static string MaterialiseParent(string equipmentId, string? folderPath) =>
string.IsNullOrWhiteSpace(folderPath) ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, folderPath);
string.IsNullOrWhiteSpace(folderPath) ? equipmentId : UnsSubFolder(equipmentId, folderPath);
// ----- v3 UNS-equipment NodeId helpers (replace the retired EquipmentNodeIds) -----
// Equipment-owned UNS nodes (per-equipment VirtualTags, the vestigial equipment-tag path, and the
// discovered-node graft) keep their EquipmentId-anchored slash-path NodeId ({equipmentId}/{folderPath}/{name}),
// now expressed through the v3 identity authority V3NodeIds.Uns so the retired EquipmentNodeIds type can go.
// These live in the UNS realm. FolderPath may be multi-segment ("a/b") — each segment is validated
// by V3NodeIds.Uns like a RawPath. (UnsTagReference variables use the composer's Area/Line/Equipment/Name
// NodeId directly and are NOT built here.)
private static string UnsSubFolder(string equipmentId, string folderPath) =>
V3NodeIds.Uns(new[] { equipmentId }.Concat(folderPath.Split(RawPaths.Separator)));
private static string UnsEquipmentVar(string equipmentId, string? folderPath, string name) =>
string.IsNullOrWhiteSpace(folderPath)
? V3NodeIds.Uns(equipmentId, name)
: V3NodeIds.Uns(new[] { equipmentId }.Concat(folderPath.Split(RawPaths.Separator)).Append(name));
/// <summary>
/// Announce a Part 3 <c>GeneralModelChangeEvent(NodeAdded)</c> per distinct affected parent from
@@ -389,7 +440,7 @@ public sealed class AddressSpaceApplier
{
foreach (var id in ComputeAddAnnouncements(plan))
{
try { _sink.RaiseNodesAddedModelChange(id); }
try { _sink.RaiseNodesAddedModelChange(id, AddressSpaceRealm.Uns); }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: RaiseNodesAddedModelChange threw for {Node}", id); }
}
}
@@ -407,11 +458,11 @@ public sealed class AddressSpaceApplier
try
{
List<HistorianTagProvisionRequest>? requests = null;
foreach (var tag in plan.AddedEquipmentTags)
// v3 Batch 4: historized value tags are RAW tags (added-raw-tag delta). Native-alarm tags
// materialise as Part 9 conditions (never historized value variables), so mirror the materialiser
// and skip them.
foreach (var tag in plan.AddedRawTags)
{
// Only historized value variables are provisioned. Native-alarm tags materialise as
// Part 9 condition nodes (never historized value variables) — the materialiser resolves
// a historian tagname only for the non-alarm branch, so mirror that and skip them.
if (!tag.IsHistorized || tag.Alarm is not null) continue;
// Parse the driver-agnostic data type from the tag's DataType string. An unparseable
@@ -424,9 +475,9 @@ public sealed class AddressSpaceApplier
continue;
}
// Resolve the historian name EXACTLY as MaterialiseEquipmentTags does: a null/blank
// override falls back to the driver-side FullName.
var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname;
// Resolve the historian name EXACTLY as MaterialiseRawSubtree does: a null/blank override
// falls back to the RawPath (== NodeId).
var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname;
(requests ??= new List<HistorianTagProvisionRequest>()).Add(
new HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: tag.Name));
}
@@ -493,22 +544,26 @@ public sealed class AddressSpaceApplier
List<HistorizedTagRef>? added = null;
List<HistorizedTagRef>? removed = null;
// Added historized value variables → new interest.
foreach (var tag in plan.AddedEquipmentTags)
// v3 Batch 4: historized value tags are RAW tags (keyed by RawPath). The mux HistorizedTagRef
// stays SINGLE, keyed by RawPath — a UNS reference node registers the same historian tagname in the
// node manager but does NOT add a second mux ref (no double historization). So this feed emits raw
// refs only.
// Added historized raw value tags → new interest.
foreach (var tag in plan.AddedRawTags)
{
if (HistorizedRef(tag) is { } r) (added ??= new List<HistorizedTagRef>()).Add(r);
}
// Removed historized value variables → drop interest.
foreach (var tag in plan.RemovedEquipmentTags)
// Removed historized raw value tags → drop interest.
foreach (var tag in plan.RemovedRawTags)
{
if (HistorizedRef(tag) is { } r) (removed ??= new List<HistorizedTagRef>()).Add(r);
}
// Changed tags: the historized ref may have flipped on/off or been renamed (override/FullName
// change). Compare previous-vs-current resolved ref PAIRS (record equality compares both the
// mux ref and the historian name) — an unchanged pair is a no-op.
foreach (var d in plan.ChangedEquipmentTags)
// Changed raw tags: the historized ref may have flipped on/off or the historian override changed
// (a rename is remove+add, handled above). Compare previous-vs-current resolved ref PAIRS — an
// unchanged pair is a no-op.
foreach (var d in plan.ChangedRawTags)
{
var prev = HistorizedRef(d.Previous);
var cur = HistorizedRef(d.Current);
@@ -540,13 +595,15 @@ public sealed class AddressSpaceApplier
/// two diverge ONLY when an override is set. Returns <c>null</c> when the tag is not a historized
/// value variable (not historized, or a native-alarm condition node).
/// </summary>
/// <param name="tag">The equipment tag to resolve a historized ref for.</param>
/// <param name="tag">The raw tag to resolve a historized ref for.</param>
/// <returns>The resolved historized ref pair, or <c>null</c> when the tag is not a historized value variable.</returns>
private static HistorizedTagRef? HistorizedRef(EquipmentTagPlan tag) =>
private static HistorizedTagRef? HistorizedRef(RawTagPlan tag) =>
tag.IsHistorized && tag.Alarm is null
// v3: the RawPath (== NodeId) is BOTH the mux ref (the driver fans values by RawPath) and the
// default historian tagname; an override diverges only the historian name.
? new HistorizedTagRef(
tag.FullName,
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname)
tag.NodeId,
string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname)
: null;
/// <summary>Rebuild the sink's address space, swallowing (and Error-logging) any fault.
@@ -585,17 +642,17 @@ public sealed class AddressSpaceApplier
var failed = 0;
foreach (var area in composition.UnsAreas)
{
if (!SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName)) failed++;
if (!SafeEnsureFolder(area.UnsAreaId, parentNodeId: null, displayName: area.DisplayName, realm: AddressSpaceRealm.Uns)) failed++;
}
foreach (var line in composition.UnsLines)
{
if (!SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName)) failed++;
if (!SafeEnsureFolder(line.UnsLineId, parentNodeId: line.UnsAreaId, displayName: line.DisplayName, realm: AddressSpaceRealm.Uns)) failed++;
}
foreach (var equipment in composition.EquipmentNodes)
{
// Equipment with no UnsLineId (legacy / dev rows) hang under the root.
var parent = string.IsNullOrWhiteSpace(equipment.UnsLineId) ? null : equipment.UnsLineId;
if (!SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName)) failed++;
if (!SafeEnsureFolder(equipment.EquipmentId, parentNodeId: parent, displayName: equipment.DisplayName, realm: AddressSpaceRealm.Uns)) failed++;
}
_logger.LogInformation(
@@ -604,6 +661,153 @@ public sealed class AddressSpaceApplier
return failed;
}
/// <summary>
/// v3 Batch 4 — materialise the <b>Raw</b> device-oriented subtree (<c>ns=Raw</c>): the container
/// nodes (Folder→Driver→Device→TagGroup, each an <c>Object</c>/<c>Folder</c>) parents-before-children,
/// then the tag Variable nodes keyed <c>s=&lt;RawPath&gt;</c>. A native-alarm tag (<see cref="RawTagPlan.Alarm"/>
/// non-null) materialises as a Part 9 condition node at its RawPath (ConditionId = RawPath, parent = its
/// device/group folder) instead of a value variable — the single condition instance; WP4 wires the extra
/// per-equipment notifiers. A historized value tag resolves its effective historian tagname HERE
/// (override else the RawPath). Array tags are forced read-only (the driver write path can't handle
/// arrays). Every sink call passes <see cref="AddressSpaceRealm.Raw"/> EXPLICITLY — the driver binds
/// values to these raw NodeIds, and every referencing UNS node fans out from them. Idempotent.
/// </summary>
/// <param name="composition">The composition carrying the Raw subtree.</param>
/// <returns>The count of swallowed sink failures in this pass.</returns>
public int MaterialiseRawSubtree(AddressSpaceComposition composition)
{
ArgumentNullException.ThrowIfNull(composition);
if (composition.RawContainers.Count == 0 && composition.RawTags.Count == 0) return 0;
var failed = 0;
// Containers first — the composer sorts them by NodeId (ordinal) so a parent's RawPath precedes each
// child's; EnsureFolder is idempotent, so a re-apply is cheap.
foreach (var c in composition.RawContainers)
{
if (!SafeEnsureFolder(c.NodeId, c.ParentNodeId, c.DisplayName, AddressSpaceRealm.Raw)) failed++;
}
// v3 Batch 4 WP4 — reverse map (equipment UNS folder PATH → EquipmentId) so a native alarm's
// ReferencingEquipmentPaths (Area/Line/Equipment name paths, from the composer) resolve to the
// EquipmentId the equipment folders were materialised under by MaterialiseHierarchy. Built lazily only
// when at least one raw tag carries an alarm (the common no-alarm deploy pays nothing).
IReadOnlyDictionary<string, string>? equipIdByFolderPath = null;
foreach (var t in composition.RawTags)
{
if (t.Alarm is not null)
{
// Native alarm tag → a single Part 9 condition node at the RawPath (ConditionId = RawPath).
// Parent is its device/group folder. The single condition instance is materialised ONCE here.
if (!SafeMaterialiseAlarmCondition(t.NodeId, t.ParentNodeId ?? string.Empty, t.Name, t.Alarm.AlarmType, t.Alarm.Severity, isNative: true, AddressSpaceRealm.Raw))
{
failed++;
}
else if (t.ReferencingEquipmentPaths.Count > 0)
{
// WP4 multi-notifier fan-out: wire the SINGLE condition as an event notifier of each
// referencing equipment's UNS folder, so one ReportEvent reaches every referencing equipment
// (never re-reported per root). Resolve the folder PATHS to their EquipmentId folder NodeIds
// (the id scheme MaterialiseHierarchy created the equipment folders under).
equipIdByFolderPath ??= BuildEquipmentIdByFolderPath(composition);
var equipFolderNodeIds = t.ReferencingEquipmentPaths
.Select(p => equipIdByFolderPath!.GetValueOrDefault(p))
.Where(id => !string.IsNullOrEmpty(id))
.Select(id => id!)
.Distinct(StringComparer.Ordinal)
.ToList();
if (equipFolderNodeIds.Count == 0)
{
// Wave C review M2: the alarm HAS referencing equipment but NONE of its paths resolved
// to an EquipmentId folder — the native fan-out to those equipment is silently absent.
// Surface it (Warning + a failed-node tally so the degraded-apply meter fires) instead
// of swallowing zero operator signal. Today this only happens on a broken UNS ancestry /
// an invalid segment (the composer would have dropped the equipment too); the latent
// risk is a future editable Area/Line display-name feature breaking the path↔id coupling
// (see BuildEquipmentIdByFolderPath + AddressSpaceComposerPathParityTests).
_logger.LogWarning(
"AddressSpaceApplier: native alarm {Node} has {Count} referencing equipment path(s) but NONE resolved to an equipment folder — the alarm's multi-notifier fan-out to those equipment is absent (paths={Paths})",
t.NodeId, t.ReferencingEquipmentPaths.Count, string.Join(", ", t.ReferencingEquipmentPaths));
failed++;
}
else if (!SafeWireAlarmNotifiers(t.NodeId, AddressSpaceRealm.Raw, equipFolderNodeIds, AddressSpaceRealm.Uns))
{
failed++;
}
}
}
else
{
// A historized tag materialises Historizing + HistoryRead; the effective historian tagname is
// the override else the RawPath (== NodeId). Array writes are out of scope → force read-only.
string? historianTagname = t.IsHistorized
? (string.IsNullOrWhiteSpace(t.HistorianTagname) ? t.NodeId : t.HistorianTagname)
: null;
var writable = t.Writable && !t.IsArray;
if (!SafeEnsureVariable(t.NodeId, t.ParentNodeId, t.Name, t.DataType, writable, AddressSpaceRealm.Raw, historianTagname, t.IsArray, t.ArrayLength)) failed++;
}
}
_logger.LogInformation(
"AddressSpaceApplier: raw subtree materialised (containers={Containers}, tags={Tags}, failed={Failed})",
composition.RawContainers.Count, composition.RawTags.Count, failed);
return failed;
}
/// <summary>
/// v3 Batch 4 — materialise the <b>UNS</b> reference Variable nodes (<c>ns=UNS</c>): one per
/// <see cref="UnsReferenceVariable"/>, keyed <c>s=&lt;Area&gt;/&lt;Line&gt;/&lt;Equipment&gt;/&lt;EffectiveName&gt;</c>,
/// parented under its equipment folder (the logical <see cref="UnsReferenceVariable.EquipmentId"/> node
/// <see cref="MaterialiseHierarchy"/> already created), THEN an <c>Organizes</c> reference
/// UNS→Raw to its backing raw node. The UNS node inherits DataType / writable / array shape / historian
/// tagname from its backing RAW tag (looked up by <see cref="UnsReferenceVariable.BackingRawPath"/>), so a
/// historized raw tag's UNS reference registers the SAME tagname (both NodeIds → one tagname → HistoryRead
/// works against either). The UNS node binds NO driver — the driver publish path fans values to it (WP3
/// runtime binding). Every sink call passes <see cref="AddressSpaceRealm.Uns"/> / the Organizes edge
/// passes both realms EXPLICITLY. Idempotent.
/// </summary>
/// <param name="composition">The composition carrying the UNS reference variables + Raw tags (for inherited shape).</param>
/// <returns>The count of swallowed sink failures in this pass.</returns>
public int MaterialiseUnsReferences(AddressSpaceComposition composition)
{
ArgumentNullException.ThrowIfNull(composition);
if (composition.UnsReferenceVariables.Count == 0) return 0;
// Backing raw tag shape/historian by RawPath — a UNS reference inherits these so its node matches the
// raw node it mirrors (a historized raw tag registers the SAME tagname on the UNS node for HistoryRead).
var rawByPath = new Dictionary<string, RawTagPlan>(StringComparer.Ordinal);
foreach (var t in composition.RawTags) rawByPath[t.NodeId] = t;
var failed = 0;
foreach (var v in composition.UnsReferenceVariables)
{
var backing = rawByPath.GetValueOrDefault(v.BackingRawPath);
var isArray = backing?.IsArray ?? false;
uint? arrayLength = backing?.ArrayLength;
// A UNS reference to a historized raw tag registers the SAME effective historian tagname (override
// else the backing RawPath) so HistoryRead resolves against the UNS NodeId too. The mux ref stays
// single (keyed by RawPath) — FeedHistorizedRefs emits raw refs only.
string? historianTagname = backing is { IsHistorized: true }
? (string.IsNullOrWhiteSpace(backing.HistorianTagname) ? backing.NodeId : backing.HistorianTagname)
: null;
var writable = v.Writable && !isArray;
if (!SafeEnsureVariable(v.NodeId, v.EquipmentId, v.EffectiveName, v.DataType, writable, AddressSpaceRealm.Uns, historianTagname, isArray, arrayLength))
{
failed++;
continue; // node not created — skip the Organizes edge (a missing endpoint would no-op anyway)
}
// Organizes UNS→Raw so the linkage is browsable. Both endpoints are realm-qualified; a missing
// endpoint is a logged no-op in the sink (never throws), so this can't fault a deploy.
try { _sink.AddReference(v.NodeId, AddressSpaceRealm.Uns, v.BackingRawPath, AddressSpaceRealm.Raw); }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: AddReference (Organizes UNS->Raw) threw for {Node}", v.NodeId); failed++; }
}
_logger.LogInformation(
"AddressSpaceApplier: UNS reference variables materialised (refs={Refs}, failed={Failed})",
composition.UnsReferenceVariables.Count, failed);
return failed;
}
/// <summary>
/// Materialise Equipment-namespace tags from a composition snapshot.
/// For each <see cref="EquipmentTagPlan"/>,
@@ -635,9 +839,9 @@ public sealed class AddressSpaceApplier
foreach (var tag in composition.EquipmentTags)
{
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
var folderNodeId = EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath);
var folderNodeId = UnsSubFolder(tag.EquipmentId, tag.FolderPath);
if (!foldersCreated.Add(folderNodeId)) continue;
if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath)) failed++;
if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath, realm: AddressSpaceRealm.Uns)) failed++;
}
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), NOT the raw FullName — a driver
@@ -650,13 +854,13 @@ public sealed class AddressSpaceApplier
{
var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
? tag.EquipmentId
: EquipmentNodeIds.SubFolder(tag.EquipmentId, tag.FolderPath);
var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name);
: UnsSubFolder(tag.EquipmentId, tag.FolderPath);
var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name);
if (tag.Alarm is not null)
{
// Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path),
// NOT a value variable. Parent is the sub-folder when set, else the equipment folder.
if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true)) failed++;
if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true, realm: AddressSpaceRealm.Uns)) failed++;
}
else
{
@@ -670,7 +874,7 @@ public sealed class AddressSpaceApplier
// even if authored ReadWrite, so a client write cannot reach the driver write path which
// does not handle arrays (e.g. S7 BoxValueForWrite would crash).
var writable = tag.Writable && !tag.IsArray;
if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, historianTagname, tag.IsArray, tag.ArrayLength)) failed++;
if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, AddressSpaceRealm.Uns, historianTagname, tag.IsArray, tag.ArrayLength)) failed++;
}
}
@@ -708,17 +912,17 @@ public sealed class AddressSpaceApplier
var failed = 0;
// Parent-first: a child folder's parent must exist before it. Ordering by '/' count == depth.
foreach (var f in folders.OrderBy(f => f.NodeId.Count(c => c == '/')))
if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName)) failed++;
if (!SafeEnsureFolder(f.NodeId, f.ParentNodeId, f.DisplayName, AddressSpaceRealm.Raw)) failed++;
foreach (var v in variables)
{
// Mirror MaterialiseEquipmentTags: arrays forced read-only (the driver write path can't handle arrays).
var writable = v.Writable && !v.IsArray;
if (!SafeEnsureVariable(v.NodeId, v.ParentNodeId, v.DisplayName, v.DataType, writable,
historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++;
AddressSpaceRealm.Raw, historianTagname: null, isArray: v.IsArray, arrayLength: v.ArrayLength)) failed++;
}
_sink.RaiseNodesAddedModelChange(equipmentRootNodeId);
_sink.RaiseNodesAddedModelChange(equipmentRootNodeId, AddressSpaceRealm.Raw);
_logger.LogInformation(
"AddressSpaceApplier: discovered nodes materialised under {Equipment} (folders={Folders}, vars={Vars}, failed={Failed})",
@@ -754,9 +958,9 @@ public sealed class AddressSpaceApplier
foreach (var v in composition.EquipmentVirtualTags)
{
if (string.IsNullOrWhiteSpace(v.FolderPath)) continue;
var folderNodeId = EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath);
var folderNodeId = UnsSubFolder(v.EquipmentId, v.FolderPath);
if (!foldersCreated.Add(folderNodeId)) continue;
if (!SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath)) failed++;
if (!SafeEnsureFolder(folderNodeId, parentNodeId: v.EquipmentId, displayName: v.FolderPath, realm: AddressSpaceRealm.Uns)) failed++;
}
// Variables: NodeId is FOLDER-SCOPED ("<parent>/<Name>"), mirroring the equipment-tag pass.
@@ -769,10 +973,10 @@ public sealed class AddressSpaceApplier
{
var parent = string.IsNullOrWhiteSpace(v.FolderPath)
? v.EquipmentId
: EquipmentNodeIds.SubFolder(v.EquipmentId, v.FolderPath);
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name);
: UnsSubFolder(v.EquipmentId, v.FolderPath);
var nodeId = UnsEquipmentVar(v.EquipmentId, v.FolderPath, v.Name);
// VirtualTags are computed outputs — read-only nodes (no inbound write).
if (!SafeEnsureVariable(nodeId, parent, v.Name, v.DataType, writable: false)) failed++;
if (!SafeEnsureVariable(nodeId, parent, v.Name, v.DataType, writable: false, realm: AddressSpaceRealm.Uns)) failed++;
}
_logger.LogInformation(
@@ -805,7 +1009,7 @@ public sealed class AddressSpaceApplier
foreach (var alarm in composition.EquipmentScriptedAlarms)
{
if (!alarm.Enabled) continue;
if (!SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false)) failed++;
if (!SafeMaterialiseAlarmCondition(alarm.ScriptedAlarmId, alarm.EquipmentId, alarm.Name, alarm.AlarmType, alarm.Severity, isNative: false, realm: AddressSpaceRealm.Uns)) failed++;
materialised++;
}
@@ -822,9 +1026,9 @@ public sealed class AddressSpaceApplier
/// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node
/// count so a swallowed materialise failure is operator-visible (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the folder was ensured; <c>false</c> when the sink threw.</returns>
private bool SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName)
private bool SafeEnsureFolder(string nodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
{
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName); return true; }
try { _sink.EnsureFolder(nodeId, parentNodeId, displayName, realm); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureFolder threw for {Node}", nodeId); return false; }
}
@@ -832,9 +1036,9 @@ public sealed class AddressSpaceApplier
/// on success, <c>false</c> when the sink threw — callers tally the false into their pass's failed-node
/// count (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the variable was ensured; <c>false</c> when the sink threw.</returns>
private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
{
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength); return true; }
try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; }
}
@@ -859,9 +1063,9 @@ public sealed class AddressSpaceApplier
/// Returns <c>true</c> on success, <c>false</c> when the sink threw — the removal pass tallies the
/// false into <see cref="AddressSpaceApplyOutcome.FailedNodes"/> (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
private bool SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts)
private bool SafeWriteAlarmCondition(string nodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm)
{
try { _sink.WriteAlarmCondition(nodeId, state, ts); return true; }
try { _sink.WriteAlarmCondition(nodeId, state, ts, realm); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteAlarmCondition threw for {Node}", nodeId); return false; }
}
@@ -869,11 +1073,70 @@ public sealed class AddressSpaceApplier
/// Returns <c>true</c> on success, <c>false</c> when the sink threw — callers tally the false into
/// their pass's failed-node count (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the condition was materialised; <c>false</c> when the sink threw.</returns>
private bool SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative)
private bool SafeMaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative, AddressSpaceRealm realm)
{
try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative); return true; }
try { _sink.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: MaterialiseAlarmCondition threw for {Node}", alarmNodeId); return false; }
}
/// <summary>Wire a native alarm condition's extra equipment-folder notifiers (WP4 multi-notifier),
/// swallowing (and Warning-logging) any sink fault. Returns <c>true</c> on success, <c>false</c> when the
/// sink threw — callers tally the false into their pass's failed-node count (archreview 01/S-1).</summary>
/// <returns><c>true</c> when the notifiers were wired; <c>false</c> when the sink threw.</returns>
private bool SafeWireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
{
try { _sink.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm); return true; }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WireAlarmNotifiers threw for {Node}", alarmNodeId); return false; }
}
/// <summary>
/// v3 Batch 4 WP4 — build the reverse map <c>equipment UNS folder PATH (Area/Line/Equipment) →
/// EquipmentId</c>. The composer emits a native alarm's <see cref="RawTagPlan.ReferencingEquipmentPaths"/>
/// as name paths (<c>V3NodeIds.Uns(areaName, lineName, equipName)</c>), but the equipment folders were
/// materialised under their logical <c>EquipmentId</c> by <see cref="MaterialiseHierarchy"/>, so the
/// multi-notifier wiring must translate path → id. This inverts the composer's own equipment-folder-path
/// construction EXACTLY — <b>path↔id coupling invariant:</b> the composer builds the path from the
/// Area/Line/Equipment <c>Name</c>; this inverts it via the projected <c>DisplayName</c>, which the
/// composer sets to that same <c>Name</c> at every level (verified by
/// <c>AddressSpaceComposerPathParityTests</c>). A future editable Area/Line display-name feature that
/// lets <c>DisplayName != Name</c> would silently break this inversion — that test is the tripwire.
/// An invalid segment throws in <see cref="V3NodeIds.Uns"/> and is skipped (the same drop the composer
/// applies), so an entry the composer produced always resolves here.
/// </summary>
/// <param name="composition">The composition carrying the UNS topology + equipment nodes.</param>
/// <returns>A map from each resolvable equipment folder path to its EquipmentId.</returns>
private IReadOnlyDictionary<string, string> BuildEquipmentIdByFolderPath(AddressSpaceComposition composition)
{
var areaName = composition.UnsAreas
.GroupBy(a => a.UnsAreaId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DisplayName, StringComparer.Ordinal);
var lineByid = composition.UnsLines
.GroupBy(l => l.UnsLineId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => (g.First().UnsAreaId, g.First().DisplayName), StringComparer.Ordinal);
var map = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var e in composition.EquipmentNodes)
{
if (string.IsNullOrWhiteSpace(e.UnsLineId)) continue;
if (!lineByid.TryGetValue(e.UnsLineId, out var line)) continue;
if (!areaName.TryGetValue(line.UnsAreaId, out var aName)) continue;
string path;
try { path = V3NodeIds.Uns(aName, line.DisplayName, e.DisplayName); }
catch (ArgumentException) { continue; /* invalid segment — dropped, mirroring the composer */ }
// Wave C review L3: two equipment sharing an identical Area/Line/Name collapse to one id
// (last-write-wins). UNS uniqueness prevents this, so it is defense-in-depth: warn (mirroring the
// composer's own last-write-wins spot) rather than silently pick one — a collision here would send
// the alarm's notifier to the wrong equipment folder.
if (map.TryGetValue(path, out var existing) && !string.Equals(existing, e.EquipmentId, StringComparison.Ordinal))
{
_logger.LogWarning(
"AddressSpaceApplier: two equipment share the UNS folder path {Path} ({Existing} vs {New}) — the native-alarm notifier map collapses them (last-write-wins); UNS uniqueness should prevent this",
path, existing, e.EquipmentId);
}
map[path] = e.EquipmentId;
}
return map;
}
}
/// <summary>Summary of one apply pass. Useful for tests + audit-log entries on the deploy path.
@@ -53,10 +53,24 @@ public static class AddressSpaceChangeClassifier
// 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment /
// ChangedAlarms are always node-affecting; a changed tag is node-affecting UNLESS it is
// surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. Evaluated
// BEFORE the add/remove split so a non-surgical change alongside pure adds still rebuilds.
// surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. The v3
// Batch-4 Raw/UNS Changed sets (container re-shape, raw-tag attribute/alarm edit, UNS re-point /
// display-name-override) route to Rebuild as the safe default — WP3's applier owns any surgical
// in-place refinement for them. Evaluated BEFORE the add/remove split so a non-surgical change
// alongside pure adds still rebuilds.
//
// WP4 BINDING GUARD (Wave C review M3): ChangedRawTags → Rebuild is what keeps native-alarm notifier
// wiring correct today. A native alarm's set of referencing equipment lives in
// RawTagPlan.ReferencingEquipmentPaths (part of its record equality), so ADDING/DROPPING a reference
// makes the raw tag a ChangedRawTag → full rebuild → OtOpcUaNodeManager clears + re-wires the notifiers
// cleanly. ANY future surgical (non-rebuild) ChangedRawTags path MUST also re-wire/unwire the alarm
// notifiers (WireAlarmNotifiers is a reconcile — pass the tag's COMPLETE current referencing set — plus
// an explicit unwire on removal), else a de-referenced equipment keeps receiving the alarm's events.
if (plan.ChangedEquipment.Count > 0 ||
plan.ChangedAlarms.Count > 0 ||
plan.ChangedRawContainers.Count > 0 ||
plan.ChangedRawTags.Count > 0 ||
plan.ChangedUnsReferenceVariables.Count > 0 ||
plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) ||
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d)))
{
@@ -68,10 +82,14 @@ public static class AddressSpaceChangeClassifier
// add/remove split below and leave a driver-only plan classified AttributeOnly.
var hasAdds =
plan.AddedEquipment.Count + plan.AddedAlarms.Count +
plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count > 0;
plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count +
plan.AddedRawContainers.Count + plan.AddedRawTags.Count +
plan.AddedUnsReferenceVariables.Count > 0;
var hasRemoves =
plan.RemovedEquipment.Count + plan.RemovedAlarms.Count +
plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count > 0;
plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count +
plan.RemovedUnsReferenceVariables.Count > 0;
// 3 — additions AND removals.
if (hasAdds && hasRemoves) return AddressSpaceChangeKind.AddRemoveMix;
@@ -1,4 +1,5 @@
using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
@@ -53,8 +54,163 @@ public sealed record AddressSpaceComposition(
/// constructor + call site keeps compiling unchanged.
/// </summary>
public IReadOnlyList<EquipmentScriptedAlarmPlan> EquipmentScriptedAlarms { get; init; } = Array.Empty<EquipmentScriptedAlarmPlan>();
// ----- v3 Batch 4 dual-namespace address space (un-darkened) -----
/// <summary>
/// The Raw subtree's <b>container</b> nodes (Folder → Driver → Device → TagGroup) as
/// <c>Object</c>/<c>Folder</c> nodes in the <see cref="AddressSpaceRealm.Raw"/> namespace. Each
/// carries its <c>s=&lt;RawPath&gt;</c> NodeId and its parent NodeId (<see langword="null"/> = cluster
/// root) so <c>AddressSpaceApplier</c> can materialise parents-before-children. Sorted by NodeId
/// (ordinal, so a parent precedes each child). Init-only, defaults empty so every existing constructor
/// + call site keeps compiling unchanged.
/// </summary>
public IReadOnlyList<RawContainerNode> RawContainers { get; init; } = Array.Empty<RawContainerNode>();
/// <summary>
/// The Raw subtree's <b>tag</b> Variable nodes — one per raw <see cref="Tag"/> — keyed
/// <c>(realm=Raw, s=&lt;RawPath&gt;)</c>. The RawPath IS the node identity + the single value source
/// the driver binds; every referencing UNS variable fans out from it. Carries DataType / writable /
/// historize + array shape, the optional native-alarm intent (attached at the RAW tag —
/// ConditionId = RawPath), and the list of referencing equipment UNS folder paths (so WP4 wires the
/// multi-notifier alarm). Init-only, defaults empty.
/// </summary>
public IReadOnlyList<RawTagPlan> RawTags { get; init; } = Array.Empty<RawTagPlan>();
/// <summary>
/// The UNS subtree's reference Variable nodes — one per <see cref="UnsTagReference"/> — keyed
/// <c>(realm=Uns, s=&lt;Area&gt;/&lt;Line&gt;/&lt;Equipment&gt;/&lt;EffectiveName&gt;)</c>. Each carries
/// its backing raw tag's RawPath (the <c>Organizes</c> UNS→Raw target + the fan-out source) plus the
/// inherited DataType / writable from that raw tag. UNS nodes never bind a driver directly — they fan
/// out from the raw node. Init-only, defaults empty.
/// </summary>
public IReadOnlyList<UnsReferenceVariable> UnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceVariable>();
}
/// <summary>Which Raw-tree container a <see cref="RawContainerNode"/> is (all are <c>Object</c>/<c>Folder</c>
/// nodes; the kind lets the applier pick the OPC UA type + reference).</summary>
public enum RawNodeKind
{
/// <summary>A driver-organizing <see cref="RawFolder"/>.</summary>
Folder,
/// <summary>A <see cref="DriverInstance"/>.</summary>
Driver,
/// <summary>A <see cref="Device"/>.</summary>
Device,
/// <summary>A tag-organizing <see cref="TagGroup"/>.</summary>
TagGroup,
}
/// <summary>
/// One Raw-subtree container node (Folder / Driver / Device / TagGroup). <see cref="NodeId"/> is its
/// <c>RawPath</c> (== the <c>ns=Raw</c> <c>s=</c> id); <see cref="ParentNodeId"/> is the parent container's
/// RawPath (<see langword="null"/> = directly under the cluster root). <see cref="Realm"/> is always
/// <see cref="AddressSpaceRealm.Raw"/> — carried explicitly so the applier chooses the namespace at the
/// call site rather than inferring it.
/// </summary>
/// <param name="NodeId">The container's RawPath (its <c>ns=Raw</c> <c>s=</c> identifier).</param>
/// <param name="ParentNodeId">The parent container's RawPath, or <see langword="null"/> for a cluster-root node.</param>
/// <param name="DisplayName">The friendly browse name (the container's leaf <c>Name</c>).</param>
/// <param name="Kind">Which container this is.</param>
/// <param name="Realm">The address-space realm (always <see cref="AddressSpaceRealm.Raw"/>).</param>
public sealed record RawContainerNode(
string NodeId,
string? ParentNodeId,
string DisplayName,
RawNodeKind Kind,
AddressSpaceRealm Realm = AddressSpaceRealm.Raw);
/// <summary>
/// One Raw-subtree tag Variable node. <see cref="NodeId"/> is the tag's <c>RawPath</c> — the single
/// identity string at every seam (the <c>ns=Raw</c> <c>s=</c> id, the driver fan-out/write key, the
/// historian default tagname, the native-alarm <c>ConditionId</c>). <see cref="ParentNodeId"/> is the
/// owning TagGroup's RawPath (or the Device's when the tag sits directly under the device).
/// <see cref="Writable"/> mirrors <c>Tag.AccessLevel == ReadWrite</c>. <see cref="Alarm"/> /
/// <see cref="IsHistorized"/> / <see cref="HistorianTagname"/> / <see cref="IsArray"/> /
/// <see cref="ArrayLength"/> are parsed from <c>Tag.TagConfig</c> via
/// <see cref="TagConfigIntent"/> (the single byte-parity authority). <see cref="ReferencingEquipmentPaths"/>
/// is the (possibly empty) set of UNS equipment-folder paths (<c>Area/Line/Equipment</c>) that reference
/// this tag — WP4 wires one notifier per path so the raw tag's single alarm condition fans to every
/// referencing equipment. <see cref="Realm"/> is always <see cref="AddressSpaceRealm.Raw"/>.
/// A null <see cref="HistorianTagname"/> means the historian tagname defaults to the RawPath (resolved
/// later, not here).
/// </summary>
public sealed record RawTagPlan(
string TagId,
string NodeId,
string? ParentNodeId,
string DriverInstanceId,
string Name,
string DataType,
bool Writable,
EquipmentTagAlarmInfo? Alarm,
IReadOnlyList<string> ReferencingEquipmentPaths,
bool IsHistorized = false,
string? HistorianTagname = null,
bool IsArray = false,
uint? ArrayLength = null,
AddressSpaceRealm Realm = AddressSpaceRealm.Raw)
{
/// <inheritdoc />
// Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an
// interface-typed list) BY REFERENCE, which would flag every raw tag as "changed" on every parse
// (fresh list instances). Compare it element-wise so a no-op redeploy diffs empty (mirrors
// EquipmentVirtualTagPlan).
public bool Equals(RawTagPlan? other) =>
other is not null &&
TagId == other.TagId &&
NodeId == other.NodeId &&
ParentNodeId == other.ParentNodeId &&
DriverInstanceId == other.DriverInstanceId &&
Name == other.Name &&
DataType == other.DataType &&
Writable == other.Writable &&
EqualityComparer<EquipmentTagAlarmInfo?>.Default.Equals(Alarm, other.Alarm) &&
IsHistorized == other.IsHistorized &&
HistorianTagname == other.HistorianTagname &&
IsArray == other.IsArray &&
ArrayLength == other.ArrayLength &&
Realm == other.Realm &&
ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal);
/// <inheritdoc />
public override int GetHashCode()
{
var hash = new HashCode();
hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId);
hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm);
hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength);
hash.Add(Realm);
foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal);
return hash.ToHashCode();
}
}
/// <summary>
/// One UNS-subtree reference Variable node projected from a <see cref="UnsTagReference"/>.
/// <see cref="NodeId"/> is the slash-joined UNS path <c>Area/Line/Equipment/EffectiveName</c> (the
/// <c>ns=UNS</c> <c>s=</c> id); <see cref="EffectiveName"/> is <c>DisplayNameOverride</c> else the backing
/// raw tag's <c>Name</c>. <see cref="BackingRawPath"/> is the backing raw tag's RawPath — the
/// <c>Organizes</c> UNS→Raw reference target AND the fan-out value source (the UNS node binds no driver;
/// it mirrors the raw node). <see cref="DataType"/> / <see cref="Writable"/> are inherited from the
/// backing raw tag. <see cref="UnsTagReferenceId"/> is the stable diff identity (a display-name-override
/// edit keeps the same id while the NodeId/effective-name changes; a backing-tag rename keeps the same id
/// + NodeId while <see cref="BackingRawPath"/> re-points). <see cref="Realm"/> is always
/// <see cref="AddressSpaceRealm.Uns"/>.
/// </summary>
public sealed record UnsReferenceVariable(
string UnsTagReferenceId,
string EquipmentId,
string NodeId,
string EffectiveName,
string BackingRawPath,
string DataType,
bool Writable,
AddressSpaceRealm Realm = AddressSpaceRealm.Uns);
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
@@ -301,12 +457,17 @@ public static class AddressSpaceComposer
/// <summary>
/// Composes the address space build plan from the v3 configuration entities.
/// <para><b>v3 DARK address space (Batch 1):</b> equipment-tag variable nodes do NOT materialize —
/// <c>EquipmentTags</c> is deliberately empty (the raw + UNS variable nodes are lit up in Batch 4's
/// dual namespace). The composer carries the UNS folder hierarchy (Area/Line/Equipment) +
/// per-equipment VirtualTags + ScriptedAlarms only. Equipment no longer binds a driver/device, so
/// <see cref="EquipmentNode"/>'s driver/device hooks are always null. This is the pure reference
/// mirror of <c>DeploymentArtifact.ParseComposition</c> (byte-parity target for the corpus tests).</para>
/// <para><b>v3 dual namespace (Batch 4):</b> the composer emits BOTH subtrees. The
/// <see cref="AddressSpaceComposition.RawContainers"/> + <see cref="AddressSpaceComposition.RawTags"/>
/// light up the device-oriented <c>ns=Raw</c> tree (Folder→Driver→Device→TagGroup→Tag, each tag keyed
/// <c>s=&lt;RawPath&gt;</c>); the <see cref="AddressSpaceComposition.UnsReferenceVariables"/> light up
/// the equipment-oriented <c>ns=UNS</c> tree (one Variable per <see cref="UnsTagReference"/>, keyed
/// <c>s=&lt;Area&gt;/&lt;Line&gt;/&lt;Equipment&gt;/&lt;EffectiveName&gt;</c>, carrying its backing RawPath
/// for the <c>Organizes</c> reference + fan-out). Native-alarm intents attach at the RAW tag
/// (ConditionId = RawPath) with the list of referencing equipment paths. The legacy
/// <see cref="AddressSpaceComposition.EquipmentTags"/> set stays EMPTY (the retired
/// equipment-namespace tag path); UNS folders + per-equipment VirtualTags + ScriptedAlarms are
/// unchanged.</para>
/// </summary>
/// <param name="unsAreas">The UNS areas.</param>
/// <param name="unsLines">The UNS lines.</param>
@@ -366,15 +527,19 @@ public static class AddressSpaceComposer
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
.ToList();
// v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out).
// v3: the retired equipment-namespace tag path stays empty — raw + UNS variable nodes now carry
// every value (see RawTags / UnsReferenceVariables below).
var equipmentTags = Array.Empty<EquipmentTagPlan>();
// Per-equipment {{equip}}/<RefName> reference map (effectiveName → backing-tag RawPath), built
// from the equipment's UnsTagReferences + the raw tags + the raw topology via the shared
// RawPathResolver — the same identity authority the artifact-decode mirror + the draft validator
// use, so the three agree byte-for-byte on the resolved RawPath.
var referenceMapByEquip = BuildEquipmentReferenceMap(
unsTagReferences, tags, rawFolders, driverInstances, devices, tagGroups);
// The shared raw topology (RawPathResolver over the folder/driver/device/group ancestry + the
// container RawPath maps). One authority for the Raw subtree emit, the UNS backing RawPaths, and the
// {{equip}} reference map — so the three agree byte-for-byte on every RawPath.
var topology = RawTopology.Build(rawFolders, driverInstances, devices, tagGroups);
// Per-equipment {{equip}}/<RefName> reference map (effectiveName → backing-tag RawPath), built via
// the shared EquipmentReferenceMap over the SAME resolver — the same identity authority the
// artifact-decode mirror + the draft validator use.
var referenceMapByEquip = BuildEquipmentReferenceMap(unsTagReferences, tags, topology.Resolver);
var emptyRefMap = (IReadOnlyDictionary<string, string>)
new Dictionary<string, string>(StringComparer.Ordinal);
@@ -451,45 +616,38 @@ public static class AddressSpaceComposer
Enabled: a.Enabled));
}
// Raw subtree (device-oriented) + UNS subtree (equipment-oriented) — the Batch-4 dual namespace.
var (rawContainers, rawTags) = BuildRawSubtree(
rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences,
unsAreas, unsLines, equipment, topology);
var unsReferenceVariables = BuildUnsReferenceVariables(
unsTagReferences, tags, unsAreas, unsLines, equipment, topology);
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
{
EquipmentTags = equipmentTags,
EquipmentVirtualTags = equipmentVirtualTags,
EquipmentScriptedAlarms = equipmentScriptedAlarms,
RawContainers = rawContainers,
RawTags = rawTags,
UnsReferenceVariables = unsReferenceVariables,
};
}
/// <summary>Build the per-equipment <c>{{equip}}/&lt;RefName&gt;</c> reference map from the v3 entities
/// via the shared <see cref="EquipmentReferenceMap"/> + <see cref="RawPathResolver"/> — the entity-side
/// mirror of <c>DeploymentArtifact.BuildEquipmentReferenceMap</c> (JSON side). Empty when no references
/// are supplied (every test / earlier caller that omits the reference data).</summary>
/// via the shared <see cref="EquipmentReferenceMap"/> over the SHARED <paramref name="resolver"/> — the
/// entity-side mirror of <c>DeploymentArtifact.BuildEquipmentReferenceMap</c> (JSON side). Empty when no
/// references are supplied (every test / earlier caller that omits the reference data).</summary>
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(
IReadOnlyList<UnsTagReference>? unsTagReferences,
IReadOnlyList<Tag>? tags,
IReadOnlyList<RawFolder>? rawFolders,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<Device>? devices,
IReadOnlyList<TagGroup>? tagGroups)
RawPathResolver resolver)
{
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
var tagRows = tags ?? Array.Empty<Tag>();
if (refs.Count == 0 || tagRows.Count == 0)
return new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
var folders = (rawFolders ?? Array.Empty<RawFolder>())
.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
var drivers = driverInstances
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
var deviceMap = (devices ?? Array.Empty<Device>())
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
var groups = (tagGroups ?? Array.Empty<TagGroup>())
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
var resolver = new RawPathResolver(folders, drivers, deviceMap, groups);
var tagsById = tagRows
.GroupBy(t => t.TagId, StringComparer.Ordinal)
.ToDictionary(
@@ -503,4 +661,363 @@ public static class AddressSpaceComposer
tagsById,
resolver);
}
/// <summary>
/// Emit the Raw subtree: the container nodes (Folder→Driver→Device→TagGroup) as
/// <c>Object</c>/<c>Folder</c> nodes, and the tag Variable nodes keyed <c>(realm=Raw, s=RawPath)</c>.
/// A node whose RawPath cannot be resolved (broken/unknown ancestry, or an invalid segment) is
/// SKIPPED rather than faulting the whole compose (mirrors <see cref="RawPathResolver"/>'s
/// null-not-throw policy). Both lists are sorted by NodeId ordinal so a parent precedes each child.
/// </summary>
private static (IReadOnlyList<RawContainerNode> Containers, IReadOnlyList<RawTagPlan> Tags) BuildRawSubtree(
IReadOnlyList<RawFolder>? rawFolders,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<Device>? devices,
IReadOnlyList<TagGroup>? tagGroups,
IReadOnlyList<Tag>? tags,
IReadOnlyList<UnsTagReference>? unsTagReferences,
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment,
RawTopology topology)
{
var containers = new List<RawContainerNode>();
foreach (var f in rawFolders ?? Array.Empty<RawFolder>())
{
if (!topology.FolderPaths.TryGetValue(f.RawFolderId, out var path)) continue;
var parent = f.ParentRawFolderId is not null
? topology.FolderPaths.GetValueOrDefault(f.ParentRawFolderId)
: null;
containers.Add(new RawContainerNode(path, parent, f.Name, RawNodeKind.Folder));
}
foreach (var d in driverInstances)
{
if (!topology.DriverPaths.TryGetValue(d.DriverInstanceId, out var path)) continue;
var parent = d.RawFolderId is not null
? topology.FolderPaths.GetValueOrDefault(d.RawFolderId)
: null;
containers.Add(new RawContainerNode(path, parent, d.Name, RawNodeKind.Driver));
}
foreach (var dev in devices ?? Array.Empty<Device>())
{
if (!topology.DevicePaths.TryGetValue(dev.DeviceId, out var path)) continue;
var parent = topology.DriverPaths.GetValueOrDefault(dev.DriverInstanceId);
containers.Add(new RawContainerNode(path, parent, dev.Name, RawNodeKind.Device));
}
foreach (var g in tagGroups ?? Array.Empty<TagGroup>())
{
if (!topology.GroupPaths.TryGetValue(g.TagGroupId, out var path)) continue;
var parent = g.ParentTagGroupId is not null
? topology.GroupPaths.GetValueOrDefault(g.ParentTagGroupId)
: topology.DevicePaths.GetValueOrDefault(g.DeviceId);
containers.Add(new RawContainerNode(path, parent, g.Name, RawNodeKind.TagGroup));
}
// TagId → the set of referencing equipment UNS folder paths (Area/Line/Equipment), for the
// native-alarm multi-notifier. Distinct + ordinal-sorted for determinism.
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
var rawTags = new List<RawTagPlan>();
foreach (var t in tags ?? Array.Empty<Tag>())
{
var nodeId = topology.Resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
if (nodeId is null) continue;
var parent = t.TagGroupId is not null
? topology.GroupPaths.GetValueOrDefault(t.TagGroupId)
: topology.DevicePaths.GetValueOrDefault(t.DeviceId);
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
var intent = TagConfigIntent.Parse(t.TagConfig);
var alarm = intent.Alarm is { } a
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
: null;
var refEquipPaths = equipPathByTag.GetValueOrDefault(t.TagId, Array.Empty<string>());
rawTags.Add(new RawTagPlan(
TagId: t.TagId,
NodeId: nodeId,
ParentNodeId: parent,
DriverInstanceId: driverInstanceId,
Name: t.Name,
DataType: t.DataType,
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
Alarm: alarm,
ReferencingEquipmentPaths: refEquipPaths,
IsHistorized: intent.IsHistorized,
HistorianTagname: intent.HistorianTagname,
IsArray: intent.IsArray,
ArrayLength: intent.ArrayLength));
}
containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
rawTags.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
return (containers, rawTags);
}
/// <summary>
/// Emit the UNS reference Variables: one per <see cref="UnsTagReference"/> whose backing raw tag +
/// equipment path resolve, keyed <c>(realm=Uns, s=Area/Line/Equipment/EffectiveName)</c> and carrying
/// the backing RawPath (Organizes target + fan-out) plus the inherited DataType / writable. A
/// reference whose backing tag is unknown, whose RawPath is unresolvable, or whose UNS path segments
/// are invalid is SKIPPED. Sorted by NodeId ordinal.
/// </summary>
private static IReadOnlyList<UnsReferenceVariable> BuildUnsReferenceVariables(
IReadOnlyList<UnsTagReference>? unsTagReferences,
IReadOnlyList<Tag>? tags,
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment,
RawTopology topology)
{
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>();
var tagsById = (tags ?? Array.Empty<Tag>())
.GroupBy(t => t.TagId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
var equipFolderPath = BuildEquipmentFolderPaths(unsAreas, unsLines, equipment);
var vars = new List<UnsReferenceVariable>();
foreach (var r in refs.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal))
{
if (!tagsById.TryGetValue(r.TagId, out var tag)) continue;
if (!equipFolderPath.TryGetValue(r.EquipmentId, out var equipPath)) continue;
var rawPath = topology.Resolver.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name);
if (rawPath is null) continue;
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride!;
string nodeId;
try
{
nodeId = V3NodeIds.UnsChild(equipPath, effectiveName);
}
catch (ArgumentException)
{
continue; // invalid effective-name segment — dropped (deploy gate rejects invalid names)
}
vars.Add(new UnsReferenceVariable(
UnsTagReferenceId: r.UnsTagReferenceId,
EquipmentId: r.EquipmentId,
NodeId: nodeId,
EffectiveName: effectiveName,
BackingRawPath: rawPath,
DataType: tag.DataType,
Writable: tag.AccessLevel == TagAccessLevel.ReadWrite));
}
vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
return vars;
}
/// <summary>
/// <c>EquipmentId → UNS folder path</c> (<c>Area/Line/Equipment</c>, slash-joined via
/// <see cref="V3NodeIds"/>) for every equipment whose Area/Line ancestry resolves + whose three
/// segments are valid. Equipment with a broken ancestry or an invalid segment is absent.
/// </summary>
private static IReadOnlyDictionary<string, string> BuildEquipmentFolderPaths(
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment)
{
var areaName = unsAreas
.GroupBy(a => a.UnsAreaId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
var line = unsLines
.GroupBy(l => l.UnsLineId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => (g.First().UnsAreaId, g.First().Name), StringComparer.Ordinal);
var byEquip = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var e in equipment)
{
if (!line.TryGetValue(e.UnsLineId, out var ln)) continue;
if (!areaName.TryGetValue(ln.UnsAreaId, out var aName)) continue;
try
{
byEquip[e.EquipmentId] = V3NodeIds.Uns(aName, ln.Name, e.Name);
}
catch (ArgumentException)
{
// invalid Area/Line/Equipment segment — dropped (deploy gate rejects invalid names).
}
}
return byEquip;
}
/// <summary>
/// <c>TagId → distinct, ordinal-sorted list of referencing equipment UNS folder paths</c>
/// (<c>Area/Line/Equipment</c>). Drives the native-alarm multi-notifier (WP4): the raw tag's single
/// alarm condition fans one event to each referencing equipment's UNS folder. A tag with no
/// resolvable reference is absent (⇒ an empty list at the call site).
/// </summary>
private static IReadOnlyDictionary<string, IReadOnlyList<string>> BuildReferencingEquipmentPathsByTag(
IReadOnlyList<UnsTagReference>? unsTagReferences,
IReadOnlyList<UnsArea> unsAreas,
IReadOnlyList<UnsLine> unsLines,
IReadOnlyList<Equipment> equipment)
{
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
if (refs.Count == 0) return new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
var equipFolderPath = BuildEquipmentFolderPaths(unsAreas, unsLines, equipment);
var byTag = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
foreach (var r in refs)
{
if (!equipFolderPath.TryGetValue(r.EquipmentId, out var equipPath)) continue;
if (!byTag.TryGetValue(r.TagId, out var set))
byTag[r.TagId] = set = new SortedSet<string>(StringComparer.Ordinal);
set.Add(equipPath);
}
return byTag.ToDictionary(
kv => kv.Key,
kv => (IReadOnlyList<string>)kv.Value.ToList(),
StringComparer.Ordinal);
}
/// <summary>
/// The shared Raw-tree topology: a <see cref="RawPathResolver"/> over the folder/driver/device/group
/// ancestry plus the resolved container RawPath maps (memoised) + the device→driver id map. Built
/// once per compose and reused for the Raw subtree, the UNS backing RawPaths, and the <c>{{equip}}</c>
/// reference map so every RawPath is produced by the single authority.
/// </summary>
private sealed class RawTopology
{
public required RawPathResolver Resolver { get; init; }
/// <summary><c>RawFolderId → RawPath</c> (absent when the ancestry is broken).</summary>
public required IReadOnlyDictionary<string, string> FolderPaths { get; init; }
/// <summary><c>DriverInstanceId → RawPath</c> (absent when the ancestry is broken).</summary>
public required IReadOnlyDictionary<string, string> DriverPaths { get; init; }
/// <summary><c>DeviceId → RawPath</c> (absent when the ancestry is broken).</summary>
public required IReadOnlyDictionary<string, string> DevicePaths { get; init; }
/// <summary><c>TagGroupId → RawPath</c> (absent when the ancestry is broken).</summary>
public required IReadOnlyDictionary<string, string> GroupPaths { get; init; }
/// <summary><c>DeviceId → DriverInstanceId</c> — the raw tag's owning driver (for RawTagPlan).</summary>
public required IReadOnlyDictionary<string, string> DriverIdByDevice { get; init; }
public static RawTopology Build(
IReadOnlyList<RawFolder>? rawFolders,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<Device>? devices,
IReadOnlyList<TagGroup>? tagGroups)
{
var folderRows = (rawFolders ?? Array.Empty<RawFolder>())
.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
var driverRows = driverInstances
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
var deviceRows = (devices ?? Array.Empty<Device>())
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
var groupRows = (tagGroups ?? Array.Empty<TagGroup>())
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
var resolver = new RawPathResolver(folderRows, driverRows, deviceRows, groupRows);
// Container RawPaths — reuse the resolver for driver prefix + memoise folder/device/group chains.
var folderPaths = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var id in folderRows.Keys)
ResolveFolder(id, folderRows, folderPaths, 0);
var driverPaths = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var id in driverRows.Keys)
{
var p = resolver.TryBuildDriverPrefix(id);
if (p is not null) driverPaths[id] = p;
}
var devicePaths = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var (id, dev) in deviceRows)
{
if (!driverPaths.TryGetValue(dev.DriverInstanceId, out var driverPrefix)) continue;
try { devicePaths[id] = RawPaths.Combine(driverPrefix, dev.Name); }
catch (ArgumentException) { /* invalid device name — dropped */ }
}
var groupPaths = new Dictionary<string, string>(StringComparer.Ordinal);
var groupDeviceById = (tagGroups ?? Array.Empty<TagGroup>())
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DeviceId, StringComparer.Ordinal);
foreach (var id in groupRows.Keys)
ResolveGroup(id, groupRows, groupDeviceById, devicePaths, groupPaths, 0);
var driverIdByDevice = deviceRows.ToDictionary(kv => kv.Key, kv => kv.Value.DriverInstanceId, StringComparer.Ordinal);
return new RawTopology
{
Resolver = resolver,
FolderPaths = folderPaths,
DriverPaths = driverPaths,
DevicePaths = devicePaths,
GroupPaths = groupPaths,
DriverIdByDevice = driverIdByDevice,
};
}
private const int MaxDepth = 256;
private static string? ResolveFolder(
string id,
IReadOnlyDictionary<string, (string? ParentId, string Name)> rows,
Dictionary<string, string> memo,
int depth)
{
if (depth > MaxDepth) return null;
if (memo.TryGetValue(id, out var cached)) return cached;
if (!rows.TryGetValue(id, out var row)) return null;
var parentPath = row.ParentId is null ? null : ResolveFolder(row.ParentId, rows, memo, depth + 1);
if (row.ParentId is not null && parentPath is null) return null; // broken parent chain
try
{
var path = RawPaths.Combine(parentPath, row.Name);
memo[id] = path;
return path;
}
catch (ArgumentException) { return null; }
}
private static string? ResolveGroup(
string id,
IReadOnlyDictionary<string, (string? ParentId, string Name)> rows,
IReadOnlyDictionary<string, string> groupDeviceById,
IReadOnlyDictionary<string, string> devicePaths,
Dictionary<string, string> memo,
int depth)
{
if (depth > MaxDepth) return null;
if (memo.TryGetValue(id, out var cached)) return cached;
if (!rows.TryGetValue(id, out var row)) return null;
string? parentPath;
if (row.ParentId is not null)
{
parentPath = ResolveGroup(row.ParentId, rows, groupDeviceById, devicePaths, memo, depth + 1);
if (parentPath is null) return null; // broken parent chain
}
else
{
// Root group: parent is the owning device's RawPath.
if (!groupDeviceById.TryGetValue(id, out var deviceId)
|| !devicePaths.TryGetValue(deviceId, out parentPath))
return null;
}
try
{
var path = RawPaths.Combine(parentPath, row.Name);
memo[id] = path;
return path;
}
catch (ArgumentException) { return null; }
}
}
}
@@ -69,6 +69,46 @@ public sealed record AddressSpacePlan(
/// </summary>
public IReadOnlyList<FolderRename> RenamedFolders { get; init; } = Array.Empty<FolderRename>();
/// <summary>
/// v3 Batch 4 — the Raw subtree's <b>container</b> diff (Folder/Driver/Device/TagGroup nodes),
/// keyed by <see cref="RawContainerNode.NodeId"/> (the RawPath). A rename of any container changes
/// its RawPath (and every descendant's), so it manifests as remove(old)+add(new) — an OPC UA NodeId
/// is immutable identity. Init-only, defaulting empty, so existing construction sites compile
/// unchanged (consistent with the EquipmentTag diff sets above).
/// </summary>
public IReadOnlyList<RawContainerNode> AddedRawContainers { get; init; } = Array.Empty<RawContainerNode>();
/// <inheritdoc cref="AddedRawContainers"/>
public IReadOnlyList<RawContainerNode> RemovedRawContainers { get; init; } = Array.Empty<RawContainerNode>();
/// <inheritdoc cref="AddedRawContainers"/>
public IReadOnlyList<RawContainerDelta> ChangedRawContainers { get; init; } = Array.Empty<RawContainerDelta>();
/// <summary>
/// v3 Batch 4 — the Raw subtree's <b>tag</b> diff, keyed by <see cref="RawTagPlan.NodeId"/> (the
/// RawPath). A raw tag <b>rename</b> changes its RawPath ⇒ remove(old NodeId)+add(new NodeId) here
/// (the immutable-NodeId rule); an attribute-only edit (historize / writable / array / alarm) keeps
/// the same NodeId ⇒ a <see cref="RawTagDelta"/>. Init-only, defaulting empty.
/// </summary>
public IReadOnlyList<RawTagPlan> AddedRawTags { get; init; } = Array.Empty<RawTagPlan>();
/// <inheritdoc cref="AddedRawTags"/>
public IReadOnlyList<RawTagPlan> RemovedRawTags { get; init; } = Array.Empty<RawTagPlan>();
/// <inheritdoc cref="AddedRawTags"/>
public IReadOnlyList<RawTagDelta> ChangedRawTags { get; init; } = Array.Empty<RawTagDelta>();
/// <summary>
/// v3 Batch 4 — the UNS subtree's reference-Variable diff, keyed by
/// <see cref="UnsReferenceVariable.UnsTagReferenceId"/> (the stable row id, NOT the NodeId). Keying on
/// the row id is what makes a backing-raw-tag rename a <b>re-point</b> (same reference row, same
/// effective name ⇒ same UNS NodeId, but <see cref="UnsReferenceVariable.BackingRawPath"/> — the
/// <c>Organizes</c> target + fan-out source — moves) rather than a remove+add; and a
/// display-name-override edit a <see cref="UnsReferenceDelta"/> (same row id, NodeId + effective name
/// change) with NO raw-side change. Init-only, defaulting empty.
/// </summary>
public IReadOnlyList<UnsReferenceVariable> AddedUnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceVariable>();
/// <inheritdoc cref="AddedUnsReferenceVariables"/>
public IReadOnlyList<UnsReferenceVariable> RemovedUnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceVariable>();
/// <inheritdoc cref="AddedUnsReferenceVariables"/>
public IReadOnlyList<UnsReferenceDelta> ChangedUnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceDelta>();
/// <summary>Gets a value indicating whether the composition plan contains no changes.</summary>
public bool IsEmpty =>
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
@@ -76,6 +116,9 @@ public sealed record AddressSpacePlan(
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 &&
AddedRawContainers.Count == 0 && RemovedRawContainers.Count == 0 && ChangedRawContainers.Count == 0 &&
AddedRawTags.Count == 0 && RemovedRawTags.Count == 0 && ChangedRawTags.Count == 0 &&
AddedUnsReferenceVariables.Count == 0 && RemovedUnsReferenceVariables.Count == 0 && ChangedUnsReferenceVariables.Count == 0 &&
RenamedFolders.Count == 0;
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
@@ -84,6 +127,16 @@ public sealed record AddressSpacePlan(
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
/// <summary>A Raw container node present in both snapshots (same RawPath) with ≥1 differing field.</summary>
public sealed record RawContainerDelta(RawContainerNode Previous, RawContainerNode Current);
/// <summary>A Raw tag present in both snapshots (same RawPath) with ≥1 differing attribute (historize /
/// writable / array / alarm / referencing-equipment set). A rename is NOT a delta — it is remove+add.</summary>
public sealed record RawTagDelta(RawTagPlan Previous, RawTagPlan Current);
/// <summary>A UNS reference variable present in both snapshots (same UnsTagReferenceId) with ≥1 differing
/// field — a backing-tag re-point (<c>BackingRawPath</c> moved) or a display-name-override edit (NodeId +
/// effective name changed).</summary>
public sealed record UnsReferenceDelta(UnsReferenceVariable Previous, UnsReferenceVariable Current);
/// <summary>One renamed UNS Area / Line folder: the stable folder
/// <paramref name="FolderNodeId"/> (the area's <c>UnsAreaId</c> or line's <c>UnsLineId</c>, the exact
/// NodeId <c>MaterialiseHierarchy</c> placed the folder at) and the <paramref name="NewDisplayName"/>
@@ -139,6 +192,32 @@ public static class AddressSpacePlanner
t => t.VirtualTagId,
(a, b) => new AddressSpacePlan.EquipmentVirtualTagDelta(a, b));
// Raw subtree — containers keyed by RawPath (NodeId). A rename changes the RawPath, so it surfaces
// as remove(old)+add(new) here (an OPC UA NodeId is immutable identity). Attribute-only container
// changes (none today — a container carries only DisplayName == leaf name, which lives in the
// RawPath) fall to Changed, kept for completeness / future fields.
var (addedRawContainers, removedRawContainers, changedRawContainers) = DiffById(
previous.RawContainers, next.RawContainers,
c => c.NodeId,
(a, b) => new AddressSpacePlan.RawContainerDelta(a, b));
// Raw subtree — tags keyed by RawPath (NodeId). A rename ⇒ remove(old NodeId)+add(new NodeId); an
// attribute-only edit (historize / writable / array / alarm) ⇒ Changed. RawTagPlan overrides record
// equality to compare ReferencingEquipmentPaths element-wise so a no-op redeploy diffs empty.
var (addedRawTags, removedRawTags, changedRawTags) = DiffById(
previous.RawTags, next.RawTags,
t => t.NodeId,
(a, b) => new AddressSpacePlan.RawTagDelta(a, b));
// UNS subtree — reference variables keyed by the STABLE UnsTagReferenceId (NOT the NodeId). Keying on
// the row id is what makes a backing-raw-tag rename a re-point (same row + effective name ⇒ same UNS
// NodeId, but BackingRawPath moves) rather than a remove+add, and a display-name-override edit a
// Changed delta (same row id, NodeId + effective name changed) with no raw-side entry.
var (addedUnsRefs, removedUnsRefs, changedUnsRefs) = DiffById(
previous.UnsReferenceVariables, next.UnsReferenceVariables,
v => v.UnsTagReferenceId,
(a, b) => new AddressSpacePlan.UnsReferenceDelta(a, b));
// UNS Area / Line renames: a folder whose stable id is unchanged but whose
// DisplayName differs. Diffed by stable id (UnsAreaId / UnsLineId) so an Area/Line whose ONLY
// change is its friendly name is no longer a silent no-op at the IsEmpty gate. The folder NodeId
@@ -159,6 +238,15 @@ public static class AddressSpacePlanner
AddedEquipmentVirtualTags = addedVTags,
RemovedEquipmentVirtualTags = removedVTags,
ChangedEquipmentVirtualTags = changedVTags,
AddedRawContainers = addedRawContainers,
RemovedRawContainers = removedRawContainers,
ChangedRawContainers = changedRawContainers,
AddedRawTags = addedRawTags,
RemovedRawTags = removedRawTags,
ChangedRawTags = changedRawTags,
AddedUnsReferenceVariables = addedUnsRefs,
RemovedUnsReferenceVariables = removedUnsRefs,
ChangedUnsReferenceVariables = changedUnsRefs,
RenamedFolders = renamedFolders,
};
}
File diff suppressed because it is too large Load Diff
@@ -21,48 +21,56 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
}
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc);
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _nodeManager.WriteValue(nodeId, value, quality, sourceTimestampUtc, realm);
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc);
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative);
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> _nodeManager.MaterialiseAlarmCondition(alarmNodeId, equipmentNodeId, displayName, alarmType, severity, realm, isNative);
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName);
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
=> _nodeManager.WireAlarmNotifiers(alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, historianTagname, isArray, arrayLength);
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> _nodeManager.EnsureFolder(folderNodeId, parentNodeId, displayName, realm);
/// <inheritdoc />
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
=> _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength);
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> _nodeManager.EnsureVariable(variableNodeId, parentFolderNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength);
/// <inheritdoc />
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
=> _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName);
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm)
=> _nodeManager.UpdateTagAttributes(variableNodeId, writable, historianTagname, dataType, isArray, arrayLength, realm);
/// <inheritdoc />
public bool RemoveVariableNode(string variableNodeId)
=> _nodeManager.RemoveVariableNode(variableNodeId);
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm)
=> _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName, realm);
/// <inheritdoc />
public bool RemoveAlarmConditionNode(string alarmNodeId)
=> _nodeManager.RemoveAlarmConditionNode(alarmNodeId);
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm)
=> _nodeManager.RemoveVariableNode(variableNodeId, realm);
/// <inheritdoc />
public bool RemoveEquipmentSubtree(string equipmentNodeId)
=> _nodeManager.RemoveEquipmentSubtree(equipmentNodeId);
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm)
=> _nodeManager.RemoveAlarmConditionNode(alarmNodeId, realm);
/// <inheritdoc />
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm)
=> _nodeManager.RemoveEquipmentSubtree(equipmentNodeId, realm);
/// <inheritdoc />
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId);
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => _nodeManager.RaiseNodesAddedModelChange(affectedNodeId, realm);
/// <inheritdoc />
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
=> _nodeManager.AddReference(sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType);
}
@@ -43,7 +43,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway
}
/// <inheritdoc />
public async Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, CancellationToken ct)
public async Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
{
var driverHost = _resolveDriverHost();
if (driverHost is null)
@@ -55,7 +55,7 @@ public sealed class ActorNodeWriteGateway : IOpcUaNodeWriteGateway
try
{
var result = await driverHost.Ask<DriverHostActor.NodeWriteResult>(
new DriverHostActor.RouteNodeWrite(nodeId, value), _askTimeout, ct).ConfigureAwait(false);
new DriverHostActor.RouteNodeWrite(nodeId, value, realm), _askTimeout, ct).ConfigureAwait(false);
if (!result.Success)
_logger.LogWarning("Operator write to {NodeId} rejected: {Reason}", nodeId, result.Reason);
return new NodeWriteOutcome(result.Success, result.Reason);
@@ -1,6 +1,7 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -253,6 +254,142 @@ public static class DeploymentArtifact
return EquipmentReferenceMap.Build(references, tagsById, resolver);
}
/// <summary>
/// v3 Batch 4 — reconstruct the raw/UNS <b>entities</b> from the artifact JSON and drive the pure
/// <see cref="AddressSpaceComposer.Compose"/>, returning ONLY the three dual-namespace lists
/// (<see cref="AddressSpaceComposition.RawContainers"/> / <see cref="AddressSpaceComposition.RawTags"/> /
/// <see cref="AddressSpaceComposition.UnsReferenceVariables"/>). Reusing the composer verbatim is what
/// guarantees BYTE-PARITY between the live-edit compose seam and this artifact-decode seam — the same
/// <c>RawPathResolver</c> / <c>RawPaths</c> / <c>TagConfigIntent</c> / <c>V3NodeIds</c> authorities
/// produce every RawPath, UNS NodeId, and Organizes backing path. Enums serialize numerically in the
/// artifact (no <c>JsonStringEnumConverter</c>), so <c>AccessLevel</c> is read as an int.
/// </summary>
/// <param name="root">The artifact root element.</param>
/// <returns>The Raw containers, Raw tags, and UNS reference variables.</returns>
private static (IReadOnlyList<RawContainerNode> Containers, IReadOnlyList<RawTagPlan> Tags, IReadOnlyList<UnsReferenceVariable> UnsRefs)
BuildRawAndUnsSubtrees(JsonElement root)
{
var rawFolders = new List<RawFolder>();
foreach (var el in EnumerateArray(root, "RawFolders"))
{
var id = ReadString(el, "RawFolderId");
if (string.IsNullOrWhiteSpace(id)) continue;
rawFolders.Add(new RawFolder
{
RawFolderId = id!, ParentRawFolderId = ReadNullableString(el, "ParentRawFolderId"),
Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty,
});
}
var driverInstances = new List<DriverInstance>();
foreach (var el in EnumerateArray(root, "DriverInstances"))
{
var id = ReadString(el, "DriverInstanceId");
if (string.IsNullOrWhiteSpace(id)) continue;
driverInstances.Add(new DriverInstance
{
DriverInstanceId = id!, RawFolderId = ReadNullableString(el, "RawFolderId"),
Name = ReadString(el, "Name") ?? id!, DriverType = ReadString(el, "DriverType") ?? string.Empty,
DriverConfig = ReadString(el, "DriverConfig") ?? "{}", ClusterId = ReadString(el, "ClusterId") ?? string.Empty,
});
}
var devices = new List<Device>();
foreach (var el in EnumerateArray(root, "Devices"))
{
var id = ReadString(el, "DeviceId");
var di = ReadString(el, "DriverInstanceId");
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue;
devices.Add(new Device
{
DeviceId = id!, DriverInstanceId = di!, Name = ReadString(el, "Name") ?? id!,
DeviceConfig = ReadString(el, "DeviceConfig") ?? "{}",
});
}
var tagGroups = new List<TagGroup>();
foreach (var el in EnumerateArray(root, "TagGroups"))
{
var id = ReadString(el, "TagGroupId");
if (string.IsNullOrWhiteSpace(id)) continue;
tagGroups.Add(new TagGroup
{
TagGroupId = id!, ParentTagGroupId = ReadNullableString(el, "ParentTagGroupId"),
DeviceId = ReadString(el, "DeviceId") ?? string.Empty, Name = ReadString(el, "Name") ?? id!,
});
}
var tags = new List<Tag>();
foreach (var el in EnumerateArray(root, "Tags"))
{
var id = ReadString(el, "TagId");
var deviceId = ReadString(el, "DeviceId");
var name = ReadString(el, "Name");
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name)) continue;
var accessLevel = el.TryGetProperty("AccessLevel", out var alEl) && alEl.ValueKind == JsonValueKind.Number
? (TagAccessLevel)alEl.GetInt32() : TagAccessLevel.Read;
var tagConfig = el.TryGetProperty("TagConfig", out var tcEl) && tcEl.ValueKind == JsonValueKind.String
? tcEl.GetString() ?? "{}" : "{}";
tags.Add(new Tag
{
TagId = id!, DeviceId = deviceId!, TagGroupId = ReadNullableString(el, "TagGroupId"),
Name = name!, DataType = ReadString(el, "DataType") ?? "Float",
AccessLevel = accessLevel, TagConfig = tagConfig,
});
}
var unsTagReferences = new List<UnsTagReference>();
foreach (var el in EnumerateArray(root, "UnsTagReferences"))
{
var id = ReadString(el, "UnsTagReferenceId");
var equipmentId = ReadString(el, "EquipmentId");
var tagId = ReadString(el, "TagId");
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(tagId)) continue;
unsTagReferences.Add(new UnsTagReference
{
UnsTagReferenceId = id!, EquipmentId = equipmentId!, TagId = tagId!,
DisplayNameOverride = ReadNullableString(el, "DisplayNameOverride"),
});
}
var unsAreas = new List<UnsArea>();
foreach (var el in EnumerateArray(root, "UnsAreas"))
{
var id = ReadString(el, "UnsAreaId");
if (string.IsNullOrWhiteSpace(id)) continue;
unsAreas.Add(new UnsArea { UnsAreaId = id!, Name = ReadString(el, "Name") ?? id!, ClusterId = ReadString(el, "ClusterId") ?? string.Empty });
}
var unsLines = new List<UnsLine>();
foreach (var el in EnumerateArray(root, "UnsLines"))
{
var id = ReadString(el, "UnsLineId");
if (string.IsNullOrWhiteSpace(id)) continue;
unsLines.Add(new UnsLine { UnsLineId = id!, UnsAreaId = ReadString(el, "UnsAreaId") ?? string.Empty, Name = ReadString(el, "Name") ?? id! });
}
var equipment = new List<Equipment>();
foreach (var el in EnumerateArray(root, "Equipment"))
{
var id = ReadString(el, "EquipmentId");
if (string.IsNullOrWhiteSpace(id)) continue;
equipment.Add(new Equipment
{
EquipmentId = id!, UnsLineId = ReadString(el, "UnsLineId") ?? string.Empty,
Name = ReadString(el, "Name") ?? id!,
// MachineCode is a required member but is not used by the raw/UNS compose path (it drives no
// NodeId); read it when present, else a placeholder so the object initializer is satisfied.
MachineCode = ReadString(el, "MachineCode") ?? id!,
});
}
var composition = AddressSpaceComposer.Compose(
unsAreas, unsLines, equipment, driverInstances, Array.Empty<ScriptedAlarm>(),
unsTagReferences: unsTagReferences, tags: tags,
rawFolders: rawFolders, devices: devices, tagGroups: tagGroups);
return (composition.RawContainers, composition.RawTags, composition.UnsReferenceVariables);
}
/// <summary>Build the <c>scriptId → SourceCode</c> map from the artifact's <c>Scripts</c> array (mirrors
/// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts.</summary>
private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root)
@@ -482,11 +619,21 @@ public static class DeploymentArtifact
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, referenceMapByEquip);
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root, referenceMapByEquip);
// v3 Batch 4 — the Raw device subtree + UNS reference variables. Built by reconstructing the raw/UNS
// entities from the artifact JSON and driving the SAME pure AddressSpaceComposer the live-edit side
// uses, so the artifact-decode seam is BYTE-PARITY with the composer (identical RawPaths, identical
// UNS NodeIds, identical Organizes backing paths). Only the three dual-namespace lists are taken from
// the composer result; the (already byte-parity) folder/vtag/alarm plans above are kept as-is.
var (rawContainers, rawTags, unsReferenceVariables) = BuildRawAndUnsSubtrees(root);
return new AddressSpaceComposition(areas, lines, equipment, drivers, alarms)
{
EquipmentTags = equipmentTags,
EquipmentVirtualTags = equipmentVirtualTags,
EquipmentScriptedAlarms = equipmentScriptedAlarms,
RawContainers = rawContainers,
RawTags = rawTags,
UnsReferenceVariables = unsReferenceVariables,
};
}
catch (JsonException)
@@ -548,6 +695,13 @@ public static class DeploymentArtifact
EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
EquipmentScriptedAlarms = full.EquipmentScriptedAlarms.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(),
// v3 Batch 4: raw tags scope by their owning driver's cluster; UNS references by their equipment's
// cluster. Raw CONTAINER nodes carry no driver id, so they are kept in full — an out-of-cluster
// folder/driver/device/group node materialises only as an empty browse folder (harmless), while every
// raw VALUE + UNS reference node is correctly cluster-scoped.
RawContainers = full.RawContainers,
RawTags = full.RawTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
UnsReferenceVariables = full.UnsReferenceVariables.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
};
}
@@ -1,4 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
@@ -36,8 +37,12 @@ public static class DiscoveredNodeMapper
/// </param>
/// <returns>The folders, variables, and routing map to apply against the OPC UA address space.</returns>
public static DiscoveredInjectionPlan Map(
string equipmentId, IReadOnlyList<DiscoveredNode> nodes, IReadOnlySet<string> authoredRefs)
string rootNodeId, IReadOnlyList<DiscoveredNode> nodes, IReadOnlySet<string> authoredRefs)
{
// v3 Batch 4: discovered nodes graft onto the RAW device subtree — a discovered node's NodeId is
// <rootDevicePath>/<folder…>/<name> (slash-joined RawPath), NOT the retired equipment-scoped
// {equipmentId}/{folderPath}/{name}. Root-relative combine (the root is an already-built RawPath).
static string Combine(string root, string tail) => root + RawPaths.SeparatorString + tail;
var kept = nodes.Where(n => !authoredRefs.Contains(n.FullReference)).ToList();
// Device-folder collapse: when every kept node shares one identical index-1 segment (the single
@@ -64,20 +69,20 @@ public static class DiscoveredNodeMapper
for (var i = 0; i < segs.Count; i++)
{
var folderPath = string.Join('/', segs.Take(i + 1));
var nodeId = EquipmentNodeIds.SubFolder(equipmentId, folderPath);
var nodeId = Combine(rootNodeId, folderPath);
if (folders.ContainsKey(nodeId)) continue;
var parent = i == 0 ? equipmentId : EquipmentNodeIds.SubFolder(equipmentId, string.Join('/', segs.Take(i)));
var parent = i == 0 ? rootNodeId : Combine(rootNodeId, string.Join('/', segs.Take(i)));
folders[nodeId] = new DiscoveredFolder(nodeId, parent, segs[i]);
}
var varFolderPath = string.Join('/', segs);
var varNodeId = EquipmentNodeIds.Variable(equipmentId, varFolderPath, n.BrowseName);
// Mirror AddressSpaceApplier.MaterialiseEquipmentTags: a folder-less variable parents directly
// at the equipment (SubFolder("", ...) would yield a trailing-slash "EQ-1/" that mismatches the
// EquipmentNodeIds.Variable NodeId, which guards IsNullOrWhiteSpace).
var varNodeId = string.IsNullOrEmpty(varFolderPath)
? Combine(rootNodeId, n.BrowseName)
: Combine(rootNodeId, varFolderPath + RawPaths.SeparatorString + n.BrowseName);
// A folder-less variable parents directly at the root device node.
var varParent = string.IsNullOrEmpty(varFolderPath)
? equipmentId
: EquipmentNodeIds.SubFolder(equipmentId, varFolderPath);
? rootNodeId
: Combine(rootNodeId, varFolderPath);
variables.Add(new DiscoveredVariable(
varNodeId, varParent, n.DisplayName, ToBuiltinTypeString(n.DataType), n.Writable, n.IsArray, n.ArrayDim));
routing[n.FullReference] = varNodeId;
@@ -111,34 +111,46 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// spawn so a restart's respawn never collides with the still-terminating old child.
private long _childSpawnGeneration;
/// <summary>
/// Driver live-value routing map: <c>(DriverInstanceId, FullName) → folder-scoped equipment
/// NodeId(s)</c>. Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the
/// composition's <c>EquipmentTags</c> (mirroring <c>VirtualTagHostActor._nodeIdByVtag</c>), and
/// resolved in <see cref="ForwardToMux"/> so a driver value published by wire-ref FullName lands
/// on the variable's actual folder-scoped NodeId. A set because the same driver ref can back
/// several equipment variables (e.g. identical machines sharing a register), and the per-apply
/// rebuild dedups by NodeId.
/// </summary>
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _nodeIdByDriverRef = new();
/// <summary>A materialised NodeId together with the v3 <see cref="AddressSpaceRealm"/> it lives in, so the
/// driver value fan-out posts each update to the sink with the right namespace. The same driver ref fans
/// to its raw node (<see cref="AddressSpaceRealm.Raw"/>) and to every referencing UNS node
/// (<see cref="AddressSpaceRealm.Uns"/>).</summary>
private readonly record struct NodeRealmRef(string NodeId, AddressSpaceRealm Realm);
/// <summary>
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>folder-scoped equipment NodeId →
/// (DriverInstanceId, FullName)</c>. Rebuilt every apply by <see cref="PushDesiredSubscriptions"/>
/// from the same <c>EquipmentTags</c> pass, and resolved by <see cref="HandleRouteNodeWrite"/> so an
/// inbound operator write targeting an equipment variable's NodeId is forwarded to the owning
/// driver child as a write of its wire-ref <c>FullName</c>. Each NodeId maps to exactly one driver
/// ref (a variable is backed by a single driver attribute), so this is a flat 1:1 map (the forward
/// map fans out 1:N because one ref can back several variables).
/// Driver live-value routing map: <c>(DriverInstanceId, RawPath) → materialised NodeId(s) (realm-tagged)</c>.
/// Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the composition's <c>RawTags</c>
/// <c>UnsReferenceVariables</c>, and resolved in <see cref="ForwardToMux"/> so a driver value
/// published by wire-ref RawPath fans to its raw node AND every referencing UNS node with identical
/// value/quality/timestamp — the single-source-fan-out (no independent buffer, so no drift). A set
/// because one driver ref can back the raw node plus N UNS references; the per-apply rebuild dedups.
/// </summary>
private readonly Dictionary<string, (string DriverInstanceId, string RawPath)> _driverRefByNodeId =
new(StringComparer.Ordinal);
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _nodeIdByDriverRef = new();
/// <summary>(DriverInstanceId, FullName = alarm ConditionId / AlarmFullReference) → folder-scoped condition NodeId(s).
/// Built from EquipmentTags whose plan carries Alarm, alongside the value maps; resolves a native
/// alarm transition to the materialised Part 9 condition node(s). Alarm tags are conditions, not
/// value variables, so they are kept OUT of the value maps + value-subscription set.</summary>
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<string>> _alarmNodeIdByDriverRef = new();
/// <summary>
/// Inverse of <see cref="_nodeIdByDriverRef"/>: <c>(Realm, BARE <c>s=</c> id) → (DriverInstanceId,
/// RawPath)</c>. v3 Batch 4: keyed by BOTH the raw NodeId (<see cref="AddressSpaceRealm.Raw"/>) AND every
/// referencing UNS NodeId (<see cref="AddressSpaceRealm.Uns"/>) — all mapping to the same driver ref
/// (the single value source). Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the
/// composition's <c>RawTags</c> <c>UnsReferenceVariables</c>, and resolved by
/// <see cref="HandleRouteNodeWrite"/> so an inbound operator write to EITHER NodeId is forwarded to the
/// owning driver child as a write of its wire-ref RawPath.
/// <para><b>The realm is part of the key</b> (Wave B review H1): a raw <c>s=&lt;RawPath&gt;</c> and a UNS
/// <c>s=&lt;Area/Line/Equip/Eff&gt;</c> can collide as bare strings (folder/driver/device names and
/// Area/Line/Equip names are independent), so a bare-only key would let a colliding raw + UNS pair route
/// to the WRONG driver ref (last-writer-wins). The node manager's write hook passes the full
/// ns-qualified id PLUS the realm it resolved (<c>RealmOf</c>); <see cref="HandleRouteNodeWrite"/>
/// normalises the id to the bare form and looks up <c>(realm, bareId)</c>, preserving exactly the
/// namespace disambiguation the ns-qualified id carries.</para>
/// </summary>
private readonly Dictionary<(AddressSpaceRealm Realm, string NodeId), (string DriverInstanceId, string RawPath)> _driverRefByNodeId = new();
/// <summary>(DriverInstanceId, RawPath = alarm ConditionId) → materialised condition NodeId(s) (realm-tagged).
/// v3 Batch 4: a native alarm is a single condition at the RawPath (Raw realm); WP4 adds the referencing
/// equipment notifier NodeIds. Resolves a native alarm transition to the materialised Part 9 condition
/// node(s). Alarm tags are conditions, not value variables, so they are kept OUT of the value maps +
/// value-subscription set.</summary>
private readonly Dictionary<(string DriverInstanceId, string RawPath), HashSet<NodeRealmRef>> _alarmNodeIdByDriverRef = new();
/// <summary>
/// Inverse of <see cref="_alarmNodeIdByDriverRef"/>: <c>folder-scoped condition NodeId →
@@ -159,7 +171,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// <c>TagConfig.alarm.historizeToAveva</c>; it is threaded onto the transition so the
/// HistorianAdapterActor's <c>is not false</c> gate suppresses the durable AVEVA row only on an
/// explicit false (mirroring the scripted-alarm opt-out).</summary>
private readonly Dictionary<string, (string EquipmentId, string Name, string AlarmType, bool? HistorizeToAveva)> _alarmMetaByNodeId =
private readonly Dictionary<string, (string EquipmentId, string Name, string AlarmType, bool? HistorizeToAveva, IReadOnlyList<string> ReferencingEquipmentPaths)> _alarmMetaByNodeId =
new(StringComparer.Ordinal);
/// <summary>Derives a full Part 9 condition snapshot from each native alarm transition delta,
@@ -232,7 +244,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// </summary>
/// <param name="NodeId">The folder-scoped equipment-variable NodeId the operator wrote to.</param>
/// <param name="Value">The value to write (the driver coerces it to the attribute's data type).</param>
public sealed record RouteNodeWrite(string NodeId, object? Value);
public sealed record RouteNodeWrite(string NodeId, object? Value, AddressSpaceRealm Realm);
/// <summary>Reply to <see cref="RouteNodeWrite"/>: the outcome of forwarding the write to the driver
/// (or a gate/lookup failure that never reached the driver).</summary>
@@ -620,13 +632,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// equipment variables (identical machines sharing a register), hence the fan-out.
if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.FullReference), out var nodeIds))
{
foreach (var nodeId in nodeIds)
// v3 Batch 4 single-source fan-out: one driver publish for a RawPath lands on the raw NodeId AND
// every referencing UNS NodeId, each with its realm, carrying IDENTICAL value/quality/timestamp.
foreach (var n in nodeIds)
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueUpdate(
nodeId, msg.Value, msg.Quality, msg.TimestampUtc));
n.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, n.Realm));
}
else
{
_log.Debug("DriverHost {Node}: no equipment-tag NodeId for ({Driver},{Ref}) — value dropped",
_log.Debug("DriverHost {Node}: no bound NodeId for ({Driver},{Ref}) — value dropped",
_localNode, msg.DriverInstanceId, msg.FullReference);
}
}
@@ -643,6 +657,21 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// </summary>
private void HandleDiscoveredNodes(DriverInstanceActor.DiscoveredNodesReady msg)
{
// v3 Batch 4 (review M1): discovered-node INJECTION is DORMANT in v3 and hard-guarded here. In v2 a
// driver-connected FixedTree was grafted under an equipment folder (equipment bound a driver); v3
// retired that binding — equipment references raw tags via UnsTagReference, and discovered raw tags are
// authored explicitly through the Batch-2 /raw browse-commit flow, NOT injected at runtime. The
// downstream mapper/materialiser were half-migrated to the Raw tree but are still rooted at an
// equipment id, so letting this fire would materialise incoherent nodes. Short-circuit BEFORE any
// caching/routing so _discoveredByDriver stays empty (the redeploy re-inject tail is therefore inert
// too) and there is exactly one enforcement point. Re-migrating injection onto the raw device subtree
// is a separate follow-up.
_log.Debug(
"DriverHost {Node}: discovered-node injection is dormant in v3 (DiscoveredNodesReady from {Driver} ignored; discovered raw tags are authored via /raw browse-commit)",
_localNode, msg.DriverInstanceId);
return;
#pragma warning disable CS0162 // Unreachable code — retained for the raw-subtree re-migration follow-up.
if (_lastComposition is null)
{
_log.Debug("DriverHost {Node}: DiscoveredNodesReady from {Driver} before any composition applied — ignored",
@@ -717,6 +746,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_discoveredByDriver[msg.DriverInstanceId] = newPlans;
ApplyDiscoveredPlansForDriver(msg.DriverInstanceId, newPlans);
#pragma warning restore CS0162
}
/// <summary>
@@ -918,9 +948,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
{
var key = (driverId, driverRef);
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
_nodeIdByDriverRef[key] = set = new HashSet<string>(StringComparer.Ordinal);
set.Add(nodeId);
_driverRefByNodeId[nodeId] = key;
_nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
// v3 Batch 4: discovered (FixedTree) nodes graft onto the RAW device subtree, so they route
// through the Raw realm.
set.Add(new NodeRealmRef(nodeId, AddressSpaceRealm.Raw));
_driverRefByNodeId[(AddressSpaceRealm.Raw, nodeId)] = key;
}
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.MaterialiseDiscoveredNodes(
equipmentId, plan.Folders, plan.Variables));
@@ -1005,17 +1037,19 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
_localNode, msg.DriverInstanceId, msg.Args.ConditionId);
}
foreach (var nodeId in nodeIds)
foreach (var n in nodeIds)
{
var nodeId = n.NodeId;
var snapshot = _nativeAlarmProjector.Project(nodeId, msg.Args);
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AlarmStateUpdate(
nodeId, snapshot, msg.Args.SourceTimestampUtc));
nodeId, snapshot, msg.Args.SourceTimestampUtc, n.Realm));
// Only the Primary publishes the cluster-wide alerts transition (see the gate above).
if (!serviceAlertsAsPrimary) continue;
var meta = _alarmMetaByNodeId.TryGetValue(nodeId, out var m)
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null);
? m : (EquipmentId: nodeId, Name: nodeId, AlarmType: "AlarmCondition", HistorizeToAveva: (bool?)null,
ReferencingEquipmentPaths: (IReadOnlyList<string>)Array.Empty<string>());
_mediator.Tell(new Publish(ScriptedAlarmHostActor.AlertsTopic, new AlarmTransitionEvent(
AlarmId: nodeId,
EquipmentPath: meta.EquipmentId,
@@ -1035,7 +1069,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// historize). The HistorianAdapterActor gate (historizeToAveva is not false) historizes null +
// true and suppresses the durable AVEVA row only on an explicit false — the same posture as the
// scripted-alarm opt-out. null here rides through unchanged (the gate treats it as default-on).
HistorizeToAveva: meta.HistorizeToAveva)));
HistorizeToAveva: meta.HistorizeToAveva,
// WP4: the Area/Line/Equipment UNS paths that reference this raw condition — the /alerts row
// renders them as the equipment list (empty for a raw condition no equipment references yet).
ReferencingEquipmentPaths: meta.ReferencingEquipmentPaths)));
}
}
@@ -1087,9 +1124,15 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
return;
}
if (!_driverRefByNodeId.TryGetValue(msg.NodeId, out var target))
// v3 Batch 4 (review H1): the node manager's write hook passes the FULL ns-qualified NodeId string
// (node.NodeId.ToString(), e.g. "ns=3;s=<id>") PLUS the realm it resolved from the namespace index.
// The routing map is keyed by (realm, BARE s= identifier). Normalise the id to its bare form and look
// up (realm, bareId) — the realm disambiguates a raw RawPath from a UNS path that happen to share a
// bare string, so a write always routes to its OWN driver ref (never a colliding sibling's).
var bareNodeId = BareNodeId(msg.NodeId);
if (!_driverRefByNodeId.TryGetValue((msg.Realm, bareNodeId), out var target))
{
Sender.Tell(new NodeWriteResult(false, $"no driver mapping for node {msg.NodeId}"));
Sender.Tell(new NodeWriteResult(false, $"no driver mapping for node {msg.NodeId} ({msg.Realm})"));
return;
}
@@ -1115,6 +1158,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
.PipeTo(replyTo);
}
/// <summary>
/// Normalise an OPC UA NodeId string to its BARE <c>s=</c> identifier. The node manager's inbound-write
/// hook passes the full ns-qualified form (<c>NodeId.ToString()</c> == <c>"ns=&lt;N&gt;;s=&lt;id&gt;"</c>);
/// the routing maps are keyed by the bare id (the RawPath / UNS path). The SDK format always prefixes
/// <c>"ns=N;s="</c> for a string identifier in a non-zero namespace, so the first <c>";s="</c> is the
/// delimiter (an id may itself contain <c>";s="</c> later — <c>IndexOf</c> takes the FIRST, which is the
/// namespace delimiter). A bare id (no prefix) passes through unchanged.
/// </summary>
/// <param name="nodeId">The (possibly ns-qualified) NodeId string.</param>
/// <returns>The bare <c>s=</c> identifier.</returns>
internal static string BareNodeId(string nodeId)
{
if (string.IsNullOrEmpty(nodeId)) return nodeId;
var i = nodeId.IndexOf(";s=", StringComparison.Ordinal);
if (i >= 0) return nodeId[(i + 3)..];
if (nodeId.StartsWith("s=", StringComparison.Ordinal)) return nodeId[2..];
return nodeId;
}
/// <summary>
/// Routes an inbound native-condition acknowledge (the host Tells this from the OPC UA node-manager
/// side when a client Acknowledges a NATIVE Part 9 condition) to the owning driver child. Mirrors
@@ -1456,89 +1518,89 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
return;
}
// Value-subscription set: alarm-bearing tags are Part 9 conditions, not value variables, so they
// are excluded — the driver must not value-subscribe an alarm attribute (it is fed via the native
// alarm event stream, routed by ForwardNativeAlarm).
var refsByDriver = composition.EquipmentTags
// v3 Batch 4: the driver subscribes + publishes by RawPath (the wire-ref == RawPath == RawTagPlan.NodeId
// per the v3 identity contract). Value-subscription set = the non-alarm raw tags' RawPaths; alarm-bearing
// raw tags are Part 9 conditions, fed via the native alarm event stream (ForwardNativeAlarm), so they are
// excluded from the value set.
var refsByDriver = composition.RawTags
.Where(t => t.Alarm is null)
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<string>)g.Select(t => t.FullName)
g => (IReadOnlyList<string>)g.Select(t => t.NodeId)
.Distinct(StringComparer.Ordinal)
.ToArray(),
StringComparer.Ordinal);
// Native-alarm subscription set: the alarm-bearing tags' FullNames (= the driver's
// ConditionId/AlarmFullReference). An IAlarmSource driver suppresses OnAlarmEvent until at least one
// alarm subscription exists (e.g. GalaxyDriver gates its central feed on _alarmSubscriptions), so the
// instance actor must SubscribeAlarmsAsync these refs to un-gate the feed. Routing stays by
// ConditionId in ForwardNativeAlarm; this set just opens (and scopes) the subscription.
var alarmRefsByDriver = composition.EquipmentTags
// Native-alarm subscription set: the alarm-bearing raw tags' RawPaths (= the driver's ConditionId).
// An IAlarmSource driver suppresses OnAlarmEvent until at least one alarm subscription exists, so the
// instance actor must SubscribeAlarmsAsync these refs to un-gate the feed. Routing stays by ConditionId
// in ForwardNativeAlarm; this set just opens (and scopes) the subscription.
var alarmRefsByDriver = composition.RawTags
.Where(t => t.Alarm is not null)
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<string>)g.Select(t => t.FullName)
g => (IReadOnlyList<string>)g.Select(t => t.NodeId)
.Distinct(StringComparer.Ordinal)
.ToArray(),
StringComparer.Ordinal);
// Rebuild the driver live-value routing map from the SAME EquipmentTags pass (mirrors
// VirtualTagHostActor._nodeIdByVtag): map each tag's (DriverInstanceId, FullName) wire-ref to
// the folder-scoped equipment NodeId the materialiser placed its variable at, so ForwardToMux
// can land driver values on the right node. Clear-and-repopulate every apply so renames
// (Name/FolderPath/EquipmentId changes) and removals are reflected.
// Referencing UNS nodes by backing RawPath — each raw value tag fans out to its own raw NodeId PLUS
// every UNS reference that projects it (dual-NodeId registration against the SAME driver ref).
var unsRefsByRawPath = new Dictionary<string, List<UnsReferenceVariable>>(StringComparer.Ordinal);
foreach (var v in composition.UnsReferenceVariables)
{
if (!unsRefsByRawPath.TryGetValue(v.BackingRawPath, out var list))
unsRefsByRawPath[v.BackingRawPath] = list = new List<UnsReferenceVariable>();
list.Add(v);
}
// Rebuild the driver live-value + write routing maps from the RawTags UnsReferenceVariables pass.
// Clear-and-repopulate every apply so renames/removals/re-points are reflected.
_nodeIdByDriverRef.Clear();
// Inverse map for the inbound operator-write path (NodeId → (DriverInstanceId, FullName)): an
// operator writes to the variable's folder-scoped NodeId, but the driver writes by its wire-ref
// FullName. Cleared + repopulated from the SAME EquipmentTags pass so renames/removals are
// reflected. Each NodeId maps to exactly one driver ref (a variable is backed by a single driver
// attribute), so last-writer-wins on the rare duplicate is harmless.
_driverRefByNodeId.Clear();
// Alarm condition routing map: (DriverInstanceId, FullName = alarm ConditionId/AlarmFullReference) → folder-scoped
// condition NodeId(s). Built from the SAME EquipmentTags pass (alarm-bearing tags only) so
// ForwardNativeAlarm can land a native transition on the right condition node. Clear-and-rebuild
// every apply; the projector is Clear()'d too so stale per-condition state never leaks across
// redeploys (renames/removals/address-space rebuilds).
_alarmNodeIdByDriverRef.Clear();
// Inverse alarm map for the inbound native-condition ack path (condition NodeId → (DriverInstanceId,
// FullName)): an OPC UA client acknowledges the condition's folder-scoped NodeId, but the driver
// acknowledges by its wire-ref FullName (= ConditionId). Cleared + repopulated from the SAME
// alarm-bearing EquipmentTags pass so renames/removals are reflected. Each condition NodeId maps to
// exactly one driver ref (a condition is backed by a single driver alarm), so last-writer-wins on the
// rare duplicate is harmless.
_driverRefByAlarmNodeId.Clear();
// Per-condition metadata (EquipmentId / Name / OPC UA alarm type) for the alerts fan-out, built in
// the SAME alarm branch as the node map so a redeploy can't leave it out of sync. Cleared alongside it.
_alarmMetaByNodeId.Clear();
_nativeAlarmProjector.Clear();
foreach (var t in composition.EquipmentTags)
foreach (var t in composition.RawTags)
{
var key = (t.DriverInstanceId, t.FullName);
var nodeId = EquipmentNodeIds.Variable(t.EquipmentId, t.FolderPath, t.Name);
var key = (t.DriverInstanceId, t.NodeId); // (DriverInstanceId, RawPath) — RawPath IS the wire-ref
if (t.Alarm is not null)
{
// Alarm tags are conditions, not value variables: route them ONLY into the alarm map and
// keep them OUT of the value maps + value-subscription set (so they don't get both a value
// variable AND a condition).
// Native alarm → a single Part 9 condition at the RawPath (Raw realm). WP4 adds the referencing
// equipment notifier NodeIds; WP3 wires the single raw condition.
if (!_alarmNodeIdByDriverRef.TryGetValue(key, out var aset))
_alarmNodeIdByDriverRef[key] = aset = new HashSet<string>(StringComparer.Ordinal);
aset.Add(nodeId);
// Inverse 1:1 map for the inbound native-condition ack path: the materialised condition
// NodeId routes back to the owning (DriverInstanceId, FullName=ConditionId) so an OPC UA
// acknowledge of this condition reaches the right driver child.
_driverRefByAlarmNodeId[nodeId] = key;
// Capture the per-condition metadata the alerts fan-out (ForwardNativeAlarm) needs to build
// the AlarmTransitionEvent: the equipment path, the operator-visible alarm name, and the
// OPC UA Part 9 subtype. Keyed by the condition NodeId (the projection's own key).
_alarmMetaByNodeId[nodeId] = (t.EquipmentId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva);
_alarmNodeIdByDriverRef[key] = aset = new HashSet<NodeRealmRef>();
aset.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
// Inverse 1:1 map for the inbound native-condition ack path (keyed by BARE condition NodeId).
_driverRefByAlarmNodeId[t.NodeId] = key;
// Per-condition metadata for the alerts fan-out. WP4 refines /alerts identity to the referencing
// equipment paths; WP3 keys the display off the RawPath. The referencing-equipment paths (the
// Area/Line/Equipment UNS folder paths carried on the RawTagPlan) ride onto every transition so
// the /alerts row lists which equipment reference this raw condition.
_alarmMetaByNodeId[t.NodeId] = (t.NodeId, t.Name, t.Alarm.AlarmType, t.Alarm.HistorizeToAveva, t.ReferencingEquipmentPaths);
continue;
}
// Value tag: register the RAW NodeId (Raw realm) AND every referencing UNS NodeId (Uns realm)
// against the SAME driver ref — the single value source fans to all of them.
if (!_nodeIdByDriverRef.TryGetValue(key, out var set))
_nodeIdByDriverRef[key] = set = new HashSet<string>(StringComparer.Ordinal);
set.Add(nodeId);
_driverRefByNodeId[nodeId] = key;
_nodeIdByDriverRef[key] = set = new HashSet<NodeRealmRef>();
set.Add(new NodeRealmRef(t.NodeId, AddressSpaceRealm.Raw));
_driverRefByNodeId[(AddressSpaceRealm.Raw, t.NodeId)] = key;
if (unsRefsByRawPath.TryGetValue(t.NodeId, out var refs))
{
foreach (var v in refs)
{
set.Add(new NodeRealmRef(v.NodeId, AddressSpaceRealm.Uns));
// A UNS write resolves to the same driver ref — keyed under the Uns realm so it can never
// collide with a raw NodeId that shares its bare string.
_driverRefByNodeId[(AddressSpaceRealm.Uns, v.NodeId)] = key;
}
}
}
// Snapshot the cached (FixedTree-discovered) driver set BEFORE the bulk loop, while _discoveredByDriver
@@ -39,16 +39,25 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// for its cached status. Private singleton — the actor pumps this for itself, no external sender.</summary>
private sealed class HealthTick { public static readonly HealthTick Instance = new(); private HealthTick() { } }
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc);
/// <summary>A driver/vtag value update for a single materialised Variable node. v3 Batch 4: carries the
/// node's <see cref="AddressSpaceRealm"/> so the sink resolves the right namespace — the driver fan-out
/// posts one of these per registered NodeId (the raw node in <see cref="AddressSpaceRealm.Raw"/> and each
/// referencing UNS node in <see cref="AddressSpaceRealm.Uns"/>) with identical value/quality/timestamp.
/// <see cref="Realm"/> defaults to <see cref="AddressSpaceRealm.Uns"/> so pre-v3 callers (VirtualTag
/// equipment nodes) keep compiling; the driver fan-out passes it EXPLICITLY per node.</summary>
public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc, AddressSpaceRealm Realm);
/// <summary>Carries the full Part 9 condition state for a scripted alarm to the sink. The
/// <paramref name="State"/> snapshot is the Commons projection the Runtime host maps from the engine's
/// Core <c>AlarmConditionState</c> + severity/message — the actor stays decoupled from
/// <c>Core.ScriptedAlarms</c>.</summary>
/// <param name="AlarmNodeId">The alarm node id (== ScriptedAlarmId for materialised conditions).</param>
/// <param name="AlarmNodeId">The alarm node id (== ScriptedAlarmId for scripted conditions; == RawPath for
/// v3 native raw conditions).</param>
/// <param name="State">The full condition state to project onto the node.</param>
/// <param name="TimestampUtc">The source timestamp of the transition in UTC.</param>
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc);
/// <param name="Realm">The namespace realm the condition lives in — <see cref="AddressSpaceRealm.Uns"/> for
/// scripted alarms (default), <see cref="AddressSpaceRealm.Raw"/> for v3 native raw conditions.</param>
public sealed record AlarmStateUpdate(string AlarmNodeId, AlarmConditionSnapshot State, DateTime TimestampUtc, AddressSpaceRealm Realm);
/// <summary>
/// Triggers an address-space rebuild. <paramref name="DeploymentId"/> is the deployment
/// just applied by the host; the rebuild loads THAT artifact so materialisation matches the
@@ -263,7 +272,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
{
try
{
_sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc);
_sink.WriteValue(msg.NodeId, msg.Value, msg.Quality, msg.TimestampUtc, msg.Realm);
Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "value"));
}
@@ -277,7 +286,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
{
try
{
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc);
_sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc, msg.Realm);
Interlocked.Increment(ref _writes);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair<string, object?>("kind", "alarm"));
}
@@ -340,6 +349,14 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
// Warnings + an optimistic Info line; now it surfaces at Error + a dedicated meter.
var failedNodes = outcome.FailedNodes;
failedNodes += _applier.MaterialiseHierarchy(composition);
// v3 Batch 4 — the Raw device subtree (ns=Raw): containers (Folder→Driver→Device→TagGroup) then
// tag Variables keyed by RawPath. Materialised BEFORE the UNS references so each UNS Organizes→Raw
// edge finds its raw target. The driver binds live values to these raw NodeIds.
failedNodes += _applier.MaterialiseRawSubtree(composition);
// v3 Batch 4 — the UNS reference Variables (ns=UNS): each projects a raw tag under its equipment
// folder (created by MaterialiseHierarchy) with an Organizes→Raw edge; values fan out from the raw
// node. Runs AFTER both the equipment folders AND the raw subtree exist.
failedNodes += _applier.MaterialiseUnsReferences(composition);
// T14 — scripted alarms get their own pass right after the hierarchy so the equipment
// folders they parent under already exist. Materialises real Part 9 AlarmConditionState
// nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled
@@ -297,10 +297,12 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
// Bridge to OPC UA: project the FULL Part 9 condition state (enabled/active/acked/confirmed/
// shelving/severity/message) onto the materialised condition node via the Commons snapshot.
// e.AlarmId is the materialised condition's NodeId (T14 aligned it to the ScriptedAlarmId).
// Scripted alarms are per-equipment condition nodes in the UNS realm.
_publishActor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
AlarmNodeId: e.AlarmId,
State: ToSnapshot(e),
TimestampUtc: e.TimestampUtc));
TimestampUtc: e.TimestampUtc,
Realm: AddressSpaceRealm.Uns));
// Publish the transition to the cluster `alerts` topic — the single historization + live
// fan-out path. The mediator was cached on the ACTOR thread in PreStart; we only Tell it here.
@@ -31,12 +31,24 @@ public sealed class VirtualTagActor : ReceiveActor
public sealed record DependencyValueChanged(string TagId, object? Value, DateTime TimestampUtc);
/// <summary>Re-assert the last emitted value/quality to the parent bridge, bypassing the value dedup.
/// Sent by <see cref="VirtualTagHostActor"/> on every apply (deploy) to a surviving child: a deploy
/// re-materialises the child's published UNS node to <c>BadWaitingForInitialData</c>, but an
/// unchanged-plan child keeps its dedup state, so a static-dependency recompute is suppressed and the
/// node would stay Bad forever. Re-asserting the last state recovers it. Singleton — carries no data.</summary>
public sealed class ReassertValue { public static readonly ReassertValue Instance = new(); private ReassertValue() { } }
/// <summary>Result emitted to the parent (the host bridge). <paramref name="Quality"/> carries the
/// OPC UA quality the node should read: <see cref="OpcUaQuality.Good"/> for a fresh computed value,
/// <see cref="OpcUaQuality.Bad"/> when the script failed/timed out (02/S13) — in which case
/// <paramref name="Value"/> is the last-known value (or null if none). Defaulting to Good keeps
/// every existing construction site source-compatible.</summary>
public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, OpcUaQuality Quality = OpcUaQuality.Good);
/// <param name="IsReassert"><c>true</c> when this result is a deploy-time re-assertion of the last
/// value/quality (see <see cref="ReassertValue"/>) rather than a fresh evaluation. The host still
/// publishes it (to repair the reset OPC UA node) but must NOT re-record it to the historian — it is a
/// stale value stamped with a fresh deploy-time timestamp, never a real evaluation sample. Defaults to
/// <c>false</c> so every existing construction site stays source-compatible.</param>
public sealed record EvaluationResult(string VirtualTagId, object? Value, DateTime TimestampUtc, CorrelationId Correlation, OpcUaQuality Quality = OpcUaQuality.Good, bool IsReassert = false);
private readonly string _virtualTagId;
private readonly string _scriptId;
@@ -105,6 +117,7 @@ public sealed class VirtualTagActor : ReceiveActor
_mux = mux;
Receive<DependencyValueChanged>(OnDependencyChanged);
Receive<ReassertValue>(_ => OnReassertValue());
}
/// <inheritdoc />
@@ -122,6 +135,32 @@ public sealed class VirtualTagActor : ReceiveActor
_mux?.Tell(new DependencyMuxActor.UnregisterInterest(Self));
}
/// <summary>Re-emit the last emitted value/quality to the parent bridge, bypassing the value dedup, so a
/// deploy that re-materialised our published UNS node to <c>BadWaitingForInitialData</c> recovers even when
/// the recomputed value is unchanged (permanent Bad otherwise, with a static dependency). No-op until the
/// child has emitted at least once — the first dependency arrival publishes the initial value normally.
/// <para>
/// The result is flagged <see cref="EvaluationResult.IsReassert"/> so the host publishes it (repairing
/// the reset node) but does NOT re-record it to the historian — it is a stale value stamped with a
/// fresh deploy-time timestamp, not a real evaluation sample (M1).
/// </para>
/// <para>
/// Ordering (why the re-publish lands on the freshly-materialised node, not the pre-reset one): the
/// reset and this re-publish converge on the SAME single-threaded <c>OpcUaPublishActor</c>. On a deploy
/// <c>DriverHostActor</c> enqueues <c>RebuildAddressSpace</c> (the reset) to that actor FIRST, then sends
/// <c>ApplyVirtualTags</c> to the VirtualTag HOST; the host tells this child <c>ReassertValue</c>, and
/// only then does this method Tell the host an <c>EvaluationResult</c> which the host bridges onward to
/// the publish actor. That multi-hop chain guarantees the re-publish arrives AFTER <c>RebuildAddressSpace</c>
/// in the publish actor's FIFO mailbox, so the materialise runs before the write.
/// </para></summary>
private void OnReassertValue()
{
if (!_hasLastValue && !_lastPublishedBad) return;
var quality = _lastPublishedBad ? OpcUaQuality.Bad : OpcUaQuality.Good;
Context.Parent.Tell(new EvaluationResult(
_virtualTagId, _lastValue, DateTime.UtcNow, CorrelationId.NewId(), quality, IsReassert: true));
}
private void OnDependencyChanged(DependencyValueChanged msg)
{
_dependencies[msg.TagId] = msg.Value;
@@ -19,9 +19,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
/// already-materialised Variable node (currently BadWaitingForInitialData) reflects the value.
///
/// <para>
/// The published NodeId is computed by the shared <see cref="EquipmentNodeIds.Variable"/> —
/// the single source of truth <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> also
/// materialises against — so the value always lands on a NodeId that exists.
/// The published NodeId is computed via <see cref="V3NodeIds.Uns(string[])"/> (the equipment-anchored
/// slash path <c>{EquipmentId}[/{FolderPath}]/{Name}</c>) — byte-identical to the NodeId
/// <c>AddressSpaceApplier.MaterialiseEquipmentVirtualTags</c> materialises against — so the value
/// always lands on a NodeId that exists.
/// </para>
/// </summary>
public sealed class VirtualTagHostActor : ReceiveActor
@@ -149,6 +150,9 @@ public sealed class VirtualTagHostActor : ReceiveActor
// recreated here from the new plan, adopting the edited Expression/DependencyRefs. Children
// whose plan was unchanged still have a live entry and are skipped, keeping their mux
// subscriptions and last-value dedup state.
// Track the just-spawned vtags so the re-assert pass below skips them: a fresh child has no last
// value to re-assert and publishes its initial value on the first dependency arrival anyway.
var newlySpawned = new HashSet<string>(StringComparer.Ordinal);
foreach (var p in msg.Plans)
{
if (_children.ContainsKey(p.VirtualTagId)) continue;
@@ -165,6 +169,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
mux: _mux));
Context.Watch(child);
_children[p.VirtualTagId] = child;
newlySpawned.Add(p.VirtualTagId);
_log.Debug("VirtualTagHost: spawned child for vtag {VirtualTagId}", p.VirtualTagId);
}
@@ -176,6 +181,24 @@ public sealed class VirtualTagHostActor : ReceiveActor
_planByVtag[p.VirtualTagId] = p;
}
// Re-assert last values on surviving (not just-spawned) children. Every apply coincides with a
// deploy, and on a deploy DriverHostActor enqueues RebuildAddressSpace — which re-materialises each
// VirtualTag's UNS node to BadWaitingForInitialData — to the single-threaded OpcUaPublishActor FIRST,
// then sends ApplyVirtualTags to THIS host. A surviving child keeps its value-dedup state, so an
// unchanged recompute is suppressed and its freshly-reset node would stay Bad forever (permanent with
// a static dependency). Telling each survivor to ReassertValue makes it re-publish its last state; that
// re-publish reaches the publish actor via a multi-hop chain (host → child ReassertValue → child →
// parent EvaluationResult → OnResult → publish actor), so its AttributeValueUpdate lands AFTER
// RebuildAddressSpace in the publish actor's FIFO mailbox — the materialise runs before the write, and
// the node recovers. (The ordering does NOT come from ApplyVirtualTags itself reaching the publish
// actor — it doesn't; it goes to this host.) Fresh children are skipped (nothing to re-assert; their
// first dependency arrival publishes normally).
foreach (var (vtagId, child) in _children)
{
if (newlySpawned.Contains(vtagId)) continue;
child.Tell(VirtualTagActor.ReassertValue.Instance);
}
_log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})",
desired.Count, _children.Count);
}
@@ -192,15 +215,20 @@ public sealed class VirtualTagHostActor : ReceiveActor
// 02/S13: the child now expresses quality — a Good fresh value or a Bad degradation (script
// failure/timeout) carrying the last-known value. Bridge result.Quality through verbatim; the
// sink maps it to StatusCodes.Good/Bad on the node so a broken script no longer freezes Good.
// VirtualTags materialise as equipment nodes in the UNS realm — publish there.
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
nodeId, result.Value, result.Quality, result.TimestampUtc));
nodeId, result.Value, result.Quality, result.TimestampUtc, AddressSpaceRealm.Uns));
// Historize iff the plan opted in. Reuses _planByVtag (kept in lock-step with _children), so
// no parallel map. The historian path key is the SAME folder-scoped NodeId we just published
// to. For a computed value source == server, so both timestamps are the evaluation time. Bad
// results record BadInternalError (0x80020000u) — the dormant VirtualTagEngine's Bad-snapshot
// status — so the historian trend can distinguish a degraded sample from a Good one.
if (_planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize)
// Historize iff the plan opted in — but NEVER for a re-assert (M1). A re-assert re-publishes a
// stale last value stamped with a fresh deploy-time timestamp purely to repair the reset OPC UA
// node; recording it would append an artificial historian sample that never corresponded to a real
// evaluation (and, if the last state was Bad, a fabricated BadInternalError point). Reuses
// _planByVtag (kept in lock-step with _children), so no parallel map. The historian path key is the
// SAME folder-scoped NodeId we just published to. For a computed value source == server, so both
// timestamps are the evaluation time. Bad results record BadInternalError (0x80020000u) — the
// dormant VirtualTagEngine's Bad-snapshot status — so the historian trend can distinguish a degraded
// sample from a Good one.
if (!result.IsReassert && _planByVtag.TryGetValue(result.VirtualTagId, out var plan) && plan.Historize)
{
var status = result.Quality == OpcUaQuality.Good ? 0u : 0x80020000u /* BadInternalError — dormant-engine parity */;
_history.Record(nodeId, new DataValueSnapshot(
@@ -223,9 +251,13 @@ public sealed class VirtualTagHostActor : ReceiveActor
}
}
/// <summary>Folder-scoped NodeId for a VirtualTag plan. The formula now lives in the shared
/// <see cref="EquipmentNodeIds"/> (the single source of truth that <c>AddressSpaceApplier</c> also
/// materialises against), so the published value always lands on the NodeId that was materialised.</summary>
/// <summary>UNS-realm NodeId for a VirtualTag plan — the equipment-anchored slash path
/// <c>{EquipmentId}[/{FolderPath}]/{Name}</c>, expressed through the v3 identity authority
/// <see cref="V3NodeIds.Uns(string[])"/>. Byte-identical to the applier's equipment-VirtualTag materialise
/// pass (its private <c>UnsEquipmentVar</c>), so the published value always lands on the materialised
/// NodeId. FolderPath is empty for VirtualTags today, but a multi-segment path is split into segments.</summary>
private static string NodeIdFor(EquipmentVirtualTagPlan p) =>
EquipmentNodeIds.Variable(p.EquipmentId, p.FolderPath, p.Name);
string.IsNullOrWhiteSpace(p.FolderPath)
? V3NodeIds.Uns(p.EquipmentId, p.Name)
: V3NodeIds.Uns(new[] { p.EquipmentId }.Concat(p.FolderPath.Split('/')).Append(p.Name));
}
@@ -19,7 +19,7 @@ public class DeferredAddressSpaceSinkTests
{
var sink = new DeferredAddressSpaceSink();
// Must not throw.
sink.WriteValue("ns=2;s=x", 42, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue("ns=2;s=x", 42, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
}
[Fact]
@@ -34,7 +34,7 @@ public class DeferredAddressSpaceSinkTests
{
var sink = new DeferredAddressSpaceSink();
sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
dataType: "Boolean", isArray: false, arrayLength: null).ShouldBeFalse();
dataType: "Boolean", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse();
}
// ---------- after SetSink — operations are forwarded ----------
@@ -46,7 +46,7 @@ public class DeferredAddressSpaceSinkTests
var sink = new DeferredAddressSpaceSink();
sink.SetSink(inner);
sink.WriteValue("ns=2;s=x", 99, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue("ns=2;s=x", 99, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
inner.WriteValueCalled.ShouldBeTrue();
}
@@ -63,6 +63,25 @@ public class DeferredAddressSpaceSinkTests
inner.RebuildCalled.ShouldBeTrue();
}
[Fact]
public void After_SetSink_WireAlarmNotifiers_is_forwarded_with_all_args()
{
// v3 Batch 4 WP4: without this forward the native-alarm multi-notifier fan-out ships INERT on every
// driver-role host (actors inject THIS wrapper, not the inner sink) — the F10b / PR#423 trap class.
var inner = new SpySink();
var sink = new DeferredAddressSpaceSink();
sink.SetSink(inner);
sink.WireAlarmNotifiers("Plant/Modbus/dev1/temp_hi", AddressSpaceRealm.Raw,
new[] { "EQ-1", "EQ-2" }, AddressSpaceRealm.Uns);
inner.WireAlarmNotifiersArgs.ShouldNotBeNull();
inner.WireAlarmNotifiersArgs!.Value.AlarmNodeId.ShouldBe("Plant/Modbus/dev1/temp_hi");
inner.WireAlarmNotifiersArgs.Value.AlarmRealm.ShouldBe(AddressSpaceRealm.Raw);
inner.WireAlarmNotifiersArgs.Value.Folders.ShouldBe(new[] { "EQ-1", "EQ-2" });
inner.WireAlarmNotifiersArgs.Value.FolderRealm.ShouldBe(AddressSpaceRealm.Uns);
}
// ---------- ISurgicalAddressSpaceSink forwarding ----------
[Fact]
@@ -73,7 +92,7 @@ public class DeferredAddressSpaceSinkTests
sink.SetSink(new SpySink());
sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
dataType: "Int32", isArray: false, arrayLength: null).ShouldBeFalse();
dataType: "Int32", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse();
}
[Fact]
@@ -84,7 +103,7 @@ public class DeferredAddressSpaceSinkTests
sink.SetSink(surgical);
var result = sink.UpdateTagAttributes("ns=2;s=x", writable: true, historianTagname: null,
dataType: "Float", isArray: false, arrayLength: null);
dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns);
result.ShouldBeTrue();
surgical.UpdateCalled.ShouldBeTrue();
@@ -97,7 +116,7 @@ public class DeferredAddressSpaceSinkTests
var sink = new DeferredAddressSpaceSink();
sink.SetSink(new SpySink());
sink.UpdateFolderDisplayName("area-1", "Plant South").ShouldBeFalse();
sink.UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns).ShouldBeFalse();
}
[Fact]
@@ -108,7 +127,7 @@ public class DeferredAddressSpaceSinkTests
var sink = new DeferredAddressSpaceSink();
sink.SetSink(surgical);
var result = sink.UpdateFolderDisplayName("area-1", "Plant South");
var result = sink.UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns);
result.ShouldBeTrue();
surgical.FolderRenameCalled.ShouldBeTrue();
@@ -122,9 +141,9 @@ public class DeferredAddressSpaceSinkTests
var sink = new DeferredAddressSpaceSink();
sink.SetSink(new SpySink());
sink.RemoveVariableNode("v-1").ShouldBeFalse();
sink.RemoveAlarmConditionNode("a-1").ShouldBeFalse();
sink.RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
}
[Fact]
@@ -134,9 +153,9 @@ public class DeferredAddressSpaceSinkTests
var sink = new DeferredAddressSpaceSink();
sink.SetSink(surgical);
sink.RemoveVariableNode("v-1").ShouldBeTrue();
sink.RemoveAlarmConditionNode("a-1").ShouldBeTrue();
sink.RemoveEquipmentSubtree("eq-1").ShouldBeTrue();
sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeTrue();
sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeTrue();
sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeTrue();
surgical.RemoveVariableCalled.ShouldBeTrue();
surgical.RemoveAlarmCalled.ShouldBeTrue();
@@ -147,9 +166,9 @@ public class DeferredAddressSpaceSinkTests
public void Before_SetSink_remove_members_return_false()
{
var sink = new DeferredAddressSpaceSink();
sink.RemoveVariableNode("v-1").ShouldBeFalse();
sink.RemoveAlarmConditionNode("a-1").ShouldBeFalse();
sink.RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
sink.RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
sink.RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
sink.RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
}
// ---------- SetSink(null) reverts to null sink ----------
@@ -163,7 +182,7 @@ public class DeferredAddressSpaceSinkTests
sink.SetSink(null); // revert
sink.UpdateTagAttributes("ns=2;s=x", writable: false, historianTagname: null,
dataType: "Boolean", isArray: false, arrayLength: null).ShouldBeFalse();
dataType: "Boolean", isArray: false, arrayLength: null, AddressSpaceRealm.Uns).ShouldBeFalse();
}
[Fact]
@@ -174,7 +193,7 @@ public class DeferredAddressSpaceSinkTests
sink.SetSink(inner);
sink.SetSink(null); // revert
sink.WriteValue("ns=2;s=x", 1, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue("ns=2;s=x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
inner.WriteValueCalled.ShouldBeFalse("write should be no-op after reverting to null sink");
}
@@ -185,16 +204,20 @@ public class DeferredAddressSpaceSinkTests
{
public bool WriteValueCalled { get; private set; }
public bool RebuildCalled { get; private set; }
public (string AlarmNodeId, AddressSpaceRealm AlarmRealm, IReadOnlyList<string> Folders, AddressSpaceRealm FolderRealm)? WireAlarmNotifiersArgs { get; private set; }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> WriteValueCalled = true;
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void RebuildAddressSpace() => RebuildCalled = true;
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
=> WireAlarmNotifiersArgs = (alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm);
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
private sealed class SpySurgicalSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
@@ -202,21 +225,23 @@ public class DeferredAddressSpaceSinkTests
public bool UpdateCalled { get; private set; }
public bool FolderRenameCalled { get; private set; }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
UpdateCalled = true;
return true;
}
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
FolderRenameCalled = true;
return true;
@@ -226,8 +251,8 @@ public class DeferredAddressSpaceSinkTests
public bool RemoveAlarmCalled { get; private set; }
public bool RemoveSubtreeCalled { get; private set; }
public bool RemoveVariableNode(string variableNodeId) { RemoveVariableCalled = true; return true; }
public bool RemoveAlarmConditionNode(string alarmNodeId) { RemoveAlarmCalled = true; return true; }
public bool RemoveEquipmentSubtree(string equipmentNodeId) { RemoveSubtreeCalled = true; return true; }
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveVariableCalled = true; return true; }
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveAlarmCalled = true; return true; }
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveSubtreeCalled = true; return true; }
}
}
@@ -74,6 +74,24 @@ public class DeferredSinkForwardingReflectionTests
}
}
[Fact]
public void Every_node_naming_sink_method_carries_a_realm_discriminator()
{
// v3 B4-WP2 dual-namespace invariant: a node id alone is no longer globally unique across the two
// namespaces (Raw vs UNS), so every sink method that NAMES a node must carry an AddressSpaceRealm
// discriminator — the sink resolves the namespace from the realm rather than parsing the id string.
// RebuildAddressSpace is the sole node-naming-free method (it clears BOTH realms). This guard locks in
// the realm surface so a future method can't be added that names a node without a realm.
foreach (var method in ForwardingInterfaces.SelectMany(i => i.GetMethods()))
{
if (method.Name == nameof(IOpcUaAddressSpaceSink.RebuildAddressSpace)) continue;
method.GetParameters().Any(p => p.ParameterType == typeof(AddressSpaceRealm)).ShouldBeTrue(
$"{method.DeclaringType!.Name}.{method.Name} names a node but has no AddressSpaceRealm parameter — " +
"a bare node id is ambiguous across the Raw and UNS namespaces (B4-WP2). Add the realm discriminator.");
}
}
[Fact]
public void Every_IServiceLevelPublisher_method_reaches_the_inner_publisher()
{
@@ -1,28 +0,0 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.OpcUa;
public class EquipmentNodeIdsTests
{
[Fact]
public void Variable_with_no_folder_is_equipment_slash_name()
=> EquipmentNodeIds.Variable("eq-1", "", "speed").ShouldBe("eq-1/speed");
[Fact]
public void Variable_with_null_folder_is_equipment_slash_name()
=> EquipmentNodeIds.Variable("eq-1", null, "speed").ShouldBe("eq-1/speed");
[Fact]
public void Variable_with_folder_is_equipment_slash_folder_slash_name()
=> EquipmentNodeIds.Variable("eq-1", "registers", "speed").ShouldBe("eq-1/registers/speed");
[Fact]
public void Variable_with_whitespace_folder_is_equipment_slash_name()
=> EquipmentNodeIds.Variable("eq-1", " ", "speed").ShouldBe("eq-1/speed");
[Fact]
public void SubFolder_is_equipment_slash_folder()
=> EquipmentNodeIds.SubFolder("eq-1", "registers").ShouldBe("eq-1/registers");
}
@@ -9,7 +9,7 @@ public class NullOpcUaNodeWriteGatewayTests
[Fact]
public async Task NullGateway_returns_writes_unavailable()
{
var outcome = await NullOpcUaNodeWriteGateway.Instance.WriteAsync("ns=2;s=x", 1, TestContext.Current.CancellationToken);
var outcome = await NullOpcUaNodeWriteGateway.Instance.WriteAsync("ns=2;s=x", 1, AddressSpaceRealm.Uns, TestContext.Current.CancellationToken);
outcome.Success.ShouldBeFalse();
outcome.Reason.ShouldBe("writes unavailable");
}
@@ -17,8 +17,10 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
public sealed class DriverProbeRegistrationTests
{
// The canonical "all drivers" set — one entry per AdminUI typed driver page's DriverTypeKey.
// Keep in sync with the DriverTypeKey constants in
// src/Server/.../Components/Pages/Clusters/Drivers/*DriverPage.razor.
// Keep in sync with the IDriverProbe registrations in
// src/Server/.../Host/Drivers/DriverFactoryBootstrap.AddOtOpcUaDriverProbes (the routed
// *DriverPage.razor flow was retired in v3 Batch 2 — the driver-type keys now live in the /raw
// modals, but the probe set is the authority for what the AdminUI can Test-connect).
private static readonly string[] AdminUiDriverTypeKeys =
[
"Modbus",
@@ -29,6 +31,7 @@ public sealed class DriverProbeRegistrationTests
"Focas", // page key; probe reports "FOCAS" — must resolve case-insensitively
"OpcUaClient",
"GalaxyMxGateway",
"Calculation", // v3 Batch 2 pseudo-driver; CalculationProbe registered in DriverFactoryBootstrap
];
[Fact]
@@ -4,10 +4,12 @@ using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
@@ -15,15 +17,23 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// End-to-end deploy of a UNS folder hierarchy through the <b>real</b> <c>ConfigComposer</c>: seed a
/// 1-area / 1-line / 1-equipment UNS plus a v3 raw-tag chain (RawFolder → DriverInstance → Device →
/// Tag), <c>StartDeployment</c>, then assert the deployment's persisted artifact decodes (via
/// Tag) + a <see cref="UnsTagReference"/> projecting the raw tag into the equipment, <c>StartDeployment</c>,
/// then assert the deployment's persisted artifact decodes (via
/// <see cref="DeploymentArtifact.ParseComposition"/>).
/// <para>
/// <b>v3 DARK address space (Batch 1):</b> the composer + artifact deliberately emit an EMPTY
/// equipment-tag plan set — raw-tag variable nodes are lit up in Batch 4's dual-namespace UNS↔Raw
/// fan-out. So the live assertion here is the DARK contract: the Area/Line/Equipment folder nodes
/// decode with their friendly UNS names, and <c>EquipmentTags</c> is empty EVEN THOUGH a raw tag was
/// seeded. The full equipment-signal materialization (raw tag → EquipmentTag with FullName) is
/// preserved as a Batch-4-pending Skipped test below (the plan explicitly says keep it, don't delete).
/// <b>v3 dual namespace (Batch 4):</b> the <c>AddressSpaceComposer</c> un-darkens BOTH subtrees. The
/// <see cref="Deploying_a_uns_hierarchy_carries_folder_nodes_and_leaves_equipment_tags_dark"/> case pins the
/// artifact-decode invariant that the retired equipment-namespace <c>EquipmentTags</c> set stays EMPTY
/// (values now flow through the raw + UNS variable nodes). The
/// <see cref="Composing_the_seeded_config_emits_the_raw_tag_and_uns_reference_variables"/> case drives the
/// real <c>AddressSpaceComposer</c> over the SAME seeded config (loaded back from the deploy DB) and pins
/// the Batch-4 dual-tree: the seeded raw tag surfaces as a Raw-realm Variable keyed by its RawPath, and the
/// UnsTagReference surfaces as a UNS-realm Variable carrying that RawPath (the Organizes target + fan-out).
/// <para><i>Note:</i> the composer is what WP1 lights up. WP3 migrated the artifact-decode seam
/// (<c>DeploymentArtifact.ParseComposition</c>) to carry both realms too, so
/// <see cref="Deployed_artifact_round_trips_both_namespaces_and_seals_on_the_cluster"/> (added in WP5) pins
/// the dual tree through the FULL deploy → persisted-artifact → decode round-trip a driver node applies, and
/// additionally asserts the dual-namespace deploy seals across the redundant 2-node cluster.</para>
/// </para>
/// <para>
/// The OPC UA address-space browse is exercised separately against a real SDK node manager in
@@ -33,12 +43,6 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// </summary>
public sealed class EquipmentNamespaceMaterializationTests
{
private const string Batch4Pending =
"v3 dark address space: equipment-tag variable plans (raw tag → EquipmentTag with FullName) do " +
"not materialize until Batch 4 (dual-namespace UNS↔Raw fan-out). The composer + artifact emit an " +
"empty equipment-tag plan set this batch; re-enable this full-materialization assertion (re-authored " +
"against the UnsTagReference fan-out) when Batch 4 lights the raw/UNS variable nodes.";
private static CancellationToken Ct => TestContext.Current.CancellationToken;
// Equipment.EquipmentId must equal DraftValidator.DeriveEquipmentId(EquipmentUuid) — the
@@ -95,12 +99,62 @@ public sealed class EquipmentNamespaceMaterializationTests
composition.EquipmentTags.ShouldBeEmpty();
}
/// <summary>BATCH-4-PENDING: the full equipment-signal materialization — the seeded raw tag decodes
/// (via the real ConfigComposer → ParseComposition) into an EquipmentTag carrying its FullName. Dark
/// until Batch 4's UNS↔Raw fan-out; preserved (not deleted) so re-enabling is a one-line change once
/// the equipment-tag plan set is lit up.</summary>
[Fact(Skip = Batch4Pending)]
public async Task Deploying_an_equipment_namespace_carries_the_signal_into_the_artifact()
/// <summary>BATCH-4 dual namespace: the real <see cref="AddressSpaceComposer"/> over the seeded config
/// (loaded back from the deploy DB) emits BOTH subtrees — the seeded raw tag as a Raw-realm Variable
/// keyed by its RawPath, and the UnsTagReference as a UNS-realm Variable carrying that RawPath (the
/// Organizes UNS→Raw target + fan-out source) and inheriting the raw tag's DataType. Drives the composer
/// directly (not the artifact-decode round-trip, which WP3 migrates).</summary>
[Fact]
public async Task Composing_the_seeded_config_emits_the_raw_tag_and_uns_reference_variables()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
await harness.SeedDefaultClusterAsync("c1");
await SeedUnsHierarchyAsync(harness);
await using var db = await CreateDbAsync(harness);
var composition = AddressSpaceComposer.Compose(
await db.UnsAreas.AsNoTracking().ToListAsync(Ct),
await db.UnsLines.AsNoTracking().ToListAsync(Ct),
await db.Equipment.AsNoTracking().ToListAsync(Ct),
await db.DriverInstances.AsNoTracking().ToListAsync(Ct),
await db.ScriptedAlarms.AsNoTracking().ToListAsync(Ct),
unsTagReferences: await db.UnsTagReferences.AsNoTracking().ToListAsync(Ct),
tags: await db.Tags.AsNoTracking().ToListAsync(Ct),
rawFolders: await db.RawFolders.AsNoTracking().ToListAsync(Ct),
devices: await db.Devices.AsNoTracking().ToListAsync(Ct),
tagGroups: await db.TagGroups.AsNoTracking().ToListAsync(Ct));
// Raw subtree: the seeded raw tag is a Raw-realm Variable keyed by its RawPath (Plant/Modbus/dev-1/Speed).
var rawTag = composition.RawTags.ShouldHaveSingleItem();
rawTag.TagId.ShouldBe("tag-speed");
rawTag.Realm.ShouldBe(AddressSpaceRealm.Raw);
rawTag.NodeId.ShouldBe("Plant/Modbus/dev-1/Speed");
rawTag.DataType.ShouldBe("Float");
// UNS subtree: the UnsTagReference is a UNS-realm Variable carrying the backing RawPath + inherited type.
var unsVar = composition.UnsReferenceVariables.ShouldHaveSingleItem();
unsVar.Realm.ShouldBe(AddressSpaceRealm.Uns);
unsVar.EquipmentId.ShouldBe(EquipmentId);
unsVar.NodeId.ShouldBe("filling/line-1/station-1/Speed");
unsVar.EffectiveName.ShouldBe("Speed");
unsVar.BackingRawPath.ShouldBe("Plant/Modbus/dev-1/Speed"); // Organizes UNS→Raw + fan-out
unsVar.DataType.ShouldBe("Float");
// The retired equipment-namespace tag path stays empty (values flow through raw + UNS now).
composition.EquipmentTags.ShouldBeEmpty();
}
/// <summary>BATCH-4 harness round-trip + redundancy: deploying the seeded dual-namespace config through the
/// REAL deploy pipeline (AdminOperations → ConfigPublishCoordinator → BOTH DriverHostActors) seals on the
/// 2-node cluster, AND the persisted artifact round-trips BOTH realms via
/// <see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/> — the raw tag as a Raw-realm
/// Variable keyed by its RawPath, and the UnsTagReference as a UNS-realm Variable carrying that RawPath (the
/// Organizes/fan-out backing path). This is the harness-level dual-namespace materialization proof: the
/// second namespace neither breaks redundant sealing nor is lost across artifact serialization. (WP3 migrated
/// the artifact-decode seam to carry both realms, so — unlike the composer-direct test above — this exercises
/// the full deploy → persisted-artifact → decode path a driver node actually applies.)</summary>
[Fact]
public async Task Deployed_artifact_round_trips_both_namespaces_and_seals_on_the_cluster()
{
await using var harness = await TwoNodeClusterHarness.StartAsync();
await harness.SeedDefaultClusterAsync("c1");
@@ -113,29 +167,51 @@ public sealed class EquipmentNamespaceMaterializationTests
result.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, $"Deploy not accepted: {result.Message}");
var deploymentId = result.DeploymentId!.Value.Value;
// Redundancy non-interference: the dual-namespace deploy seals after BOTH DriverHostActors apply — the
// second namespace does not break the redundant seal path (the ServiceLevel / primary-gate machinery
// is orthogonal to the address-space namespace count; see FailoverDuringDeploy / PrimaryGateFailover).
var artifact = Array.Empty<byte>();
await WaitForAsync(async () =>
{
await using var db = await CreateDbAsync(harness);
var d = await db.Deployments.AsNoTracking()
.FirstOrDefaultAsync(x => x.DeploymentId == deploymentId, Ct);
if (d is { ArtifactBlob.Length: > 0 })
if (d is { Status: DeploymentStatus.Sealed, ArtifactBlob.Length: > 0 })
{
artifact = d.ArtifactBlob;
return true;
}
return false;
}, TimeSpan.FromSeconds(15));
}, TimeSpan.FromSeconds(20));
await using (var db = await CreateDbAsync(harness))
{
var nodeStates = await db.NodeDeploymentStates.AsNoTracking()
.Where(s => s.DeploymentId == deploymentId)
.ToListAsync(Ct);
nodeStates.Count.ShouldBe(2, "both cluster nodes record a per-node deployment state");
nodeStates.ShouldAllBe(s => s.Status == NodeDeploymentStatus.Applied);
}
// Round-trip the PERSISTED artifact through the artifact-decode seam and pin the dual tree.
var composition = DeploymentArtifact.ParseComposition(artifact);
// Batch 4: the raw tag surfaces as an EquipmentTag with FullName pulled from TagConfig.
var tag = composition.EquipmentTags.ShouldHaveSingleItem();
tag.TagId.ShouldBe("tag-speed");
tag.EquipmentId.ShouldBe(EquipmentId);
tag.Name.ShouldBe("Speed");
tag.DataType.ShouldBe("Float");
tag.FullName.ShouldBe("40001");
// Raw subtree: the seeded raw tag survives as a Raw-realm Variable keyed by its RawPath.
var rawTag = composition.RawTags.ShouldHaveSingleItem();
rawTag.TagId.ShouldBe("tag-speed");
rawTag.Realm.ShouldBe(AddressSpaceRealm.Raw);
rawTag.NodeId.ShouldBe("Plant/Modbus/dev-1/Speed");
// UNS subtree: the UnsTagReference survives as a UNS-realm Variable carrying the backing RawPath (the
// Organizes UNS→Raw target + the fan-out source).
var unsVar = composition.UnsReferenceVariables.ShouldHaveSingleItem();
unsVar.Realm.ShouldBe(AddressSpaceRealm.Uns);
unsVar.EquipmentId.ShouldBe(EquipmentId);
unsVar.NodeId.ShouldBe("filling/line-1/station-1/Speed");
unsVar.BackingRawPath.ShouldBe("Plant/Modbus/dev-1/Speed");
// The retired equipment-namespace tag path stays empty (values flow through raw + UNS now).
composition.EquipmentTags.ShouldBeEmpty();
}
private static async Task SeedUnsHierarchyAsync(TwoNodeClusterHarness harness)
@@ -170,6 +246,11 @@ public sealed class EquipmentNamespaceMaterializationTests
Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"FullName\":\"40001\"}",
});
// v3 Batch 4: the equipment projects the raw tag by reference (reference-only UNS).
db.UnsTagReferences.Add(new UnsTagReference
{
UnsTagReferenceId = "ref-speed", EquipmentId = EquipmentId, TagId = "tag-speed",
});
await db.SaveChangesAsync(Ct);
}
@@ -2,6 +2,7 @@ using Akka.Actor;
using Akka.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Runtime;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
@@ -68,7 +69,7 @@ public sealed class PrimaryGateFailoverTests
private static async Task<DriverHostActor.NodeWriteResult> RouteProbeWriteAsync(IActorRef driverHost)
=> await driverHost.Ask<DriverHostActor.NodeWriteResult>(
new DriverHostActor.RouteNodeWrite(UnmappedProbeNode, 0.0), TimeSpan.FromSeconds(10));
new DriverHostActor.RouteNodeWrite(UnmappedProbeNode, 0.0, AddressSpaceRealm.Uns), TimeSpan.FromSeconds(10));
private static bool IsGateReject(DriverHostActor.NodeWriteResult r)
=> !r.Success && r.Reason is not null && r.Reason.StartsWith("not primary", StringComparison.Ordinal);
@@ -0,0 +1,326 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// v3 Batch 4 (B4-WP5) — the over-the-wire proof of the dual-namespace address space. Boots the real
/// <see cref="OtOpcUaSdkServer"/> (exactly as <c>SubscriptionSurvivalTests</c> / the multi-notifier
/// alarm test do), materialises a raw device folder + a raw tag Variable (<c>ns=Raw, s=&lt;RawPath&gt;</c>)
/// and an equipment folder + a UNS reference Variable (<c>ns=UNS, s=&lt;Area/Line/Equip/Eff&gt;</c>) through
/// the PRODUCTION <see cref="SdkAddressSpaceSink"/>, wires the <c>Organizes</c> UNS→Raw edge, then drives a
/// real OPC UA client to assert:
/// <list type="number">
/// <item>BOTH namespace URIs (<see cref="V3NodeIds.RawNamespaceUri"/> +
/// <see cref="V3NodeIds.UnsNamespaceUri"/>) are registered + distinct (they replaced the single
/// <c>.../ns</c>).</item>
/// <item>Both subtrees browse + read: the raw Variable at its RawPath NodeId and the UNS Variable at its
/// equipment-path NodeId.</item>
/// <item>The UNS Variable <c>Organizes</c>-references its backing raw node.</item>
/// <item>Single-source fan-out parity: one driver publish (modelled as a
/// <see cref="SdkAddressSpaceSink.WriteValue"/> to each NodeId with identical value/quality/timestamp)
/// is read back IDENTICALLY on both NodeIds.</item>
/// <item>HistoryRead via EITHER NodeId returns <c>GoodNoData</c> under the shared historian tagname (the
/// <c>NullHistorianDataSource</c> default — the offline parity path).</item>
/// <item>The <c>WriteOperate</c> gate is symmetric across both NodeIds: an anonymous client write is
/// rejected identically on the raw and the UNS NodeId (fail-closed at the role gate — the POSITIVE
/// realm-qualified routing path is unit-covered in <c>DriverHostActorWriteRoutingTests</c> /
/// <c>NodeManagerWriteRevertTests</c>, which drive a role-carrying identity the node manager
/// directly).</item>
/// </list>
/// Heavy in-process server+client integration — runs in the serial integration pass, and is fully
/// offline-safe (no Docker / SQL / gateway; the historian read defaults to the Null source).
/// </summary>
public sealed class DualNamespaceAddressSpaceTests
{
private const string ServerUri = "urn:OtOpcUa.DualNamespaceAddressSpace";
// Raw device tree (ns=Raw): Folder → tag Variable, both keyed by RawPath.
private const string RawDeviceFolder = "Plant/Modbus/dev1";
private const string RawTagPath = "Plant/Modbus/dev1/Speed";
// UNS equipment tree (ns=UNS): equipment Folder → reference Variable keyed by the equipment path.
private const string EquipFolder = "filling/line1/station1";
private const string UnsVarPath = "filling/line1/station1/Speed";
// Both NodeIds register the SAME historian tagname (the raw tag's RawPath).
private const string HistorianTagname = RawTagPath;
[Fact]
public async Task Both_namespaces_registered_and_both_subtrees_browse_read_and_organize()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-browse-{Guid.NewGuid():N}");
var port = AllocateFreePort();
var ct = TestContext.Current.CancellationToken;
try
{
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct);
await using var _ = host;
var sink = new SdkAddressSpaceSink(server.NodeManager!);
MaterialiseDualTree(sink);
// One driver publish per NodeId (the fan-out the DriverHostActor performs) — identical payload.
var ts = DateTime.UtcNow;
sink.WriteValue(RawTagPath, 42.5f, OpcUaQuality.Good, ts, AddressSpaceRealm.Raw);
sink.WriteValue(UnsVarPath, 42.5f, OpcUaQuality.Good, ts, AddressSpaceRealm.Uns);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
// (1) both namespaces registered + distinct.
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
rawNs.ShouldBeGreaterThan((ushort)0, "the Raw namespace must be registered");
unsNs.ShouldBeGreaterThan((ushort)0, "the UNS namespace must be registered");
rawNs.ShouldNotBe(unsNs, "the two namespaces must have distinct indices");
var rawNodeId = new NodeId(RawTagPath, rawNs);
var unsNodeId = new NodeId(UnsVarPath, unsNs);
// (2) both subtrees browse + read.
var rawValue = await session.ReadValueAsync(rawNodeId, ct);
var unsValue = await session.ReadValueAsync(unsNodeId, ct);
StatusCode.IsGood(rawValue.StatusCode).ShouldBeTrue("raw node reads Good");
StatusCode.IsGood(unsValue.StatusCode).ShouldBeTrue("uns node reads Good");
Convert.ToSingle(rawValue.Value).ShouldBe(42.5f);
Convert.ToSingle(unsValue.Value).ShouldBe(42.5f);
// (3) the UNS Variable Organizes-references its backing raw node.
var (_, refs) = await BrowseForwardAsync(session, unsNodeId, ReferenceTypeIds.Organizes);
var organizesRaw = refs.Any(r =>
r.ReferenceTypeId == ReferenceTypeIds.Organizes &&
r.NodeId.NamespaceIndex == rawNs &&
r.NodeId.ToString().Contains(RawTagPath, StringComparison.Ordinal));
organizesRaw.ShouldBeTrue(
$"the UNS variable ({UnsVarPath}) must Organizes-reference its raw node ({RawTagPath}); " +
$"forward Organizes refs = [{string.Join(", ", refs.Select(r => r.NodeId))}]");
}
finally
{
SafeDelete(pkiRoot + "-srv");
}
}
[Fact]
public async Task Single_source_fans_to_both_nodeids_with_identical_value_quality_and_timestamp()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-fanout-{Guid.NewGuid():N}");
var port = AllocateFreePort();
var ct = TestContext.Current.CancellationToken;
try
{
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Fanout", ct);
await using var _ = host;
var sink = new SdkAddressSpaceSink(server.NodeManager!);
MaterialiseDualTree(sink);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
// The single source (one RawPath) fans to the raw NodeId AND every referencing UNS NodeId with
// identical value/quality/timestamp — the fan-out drift invariant.
var ts = new DateTime(2026, 7, 16, 8, 30, 0, DateTimeKind.Utc);
sink.WriteValue(RawTagPath, 123.75f, OpcUaQuality.Good, ts, AddressSpaceRealm.Raw);
sink.WriteValue(UnsVarPath, 123.75f, OpcUaQuality.Good, ts, AddressSpaceRealm.Uns);
var rawValue = await session.ReadValueAsync(new NodeId(RawTagPath, rawNs), ct);
var unsValue = await session.ReadValueAsync(new NodeId(UnsVarPath, unsNs), ct);
Convert.ToSingle(unsValue.Value).ShouldBe(Convert.ToSingle(rawValue.Value));
unsValue.StatusCode.ShouldBe(rawValue.StatusCode);
unsValue.SourceTimestamp.ShouldBe(rawValue.SourceTimestamp);
unsValue.SourceTimestamp.ShouldBe(ts);
}
finally
{
SafeDelete(pkiRoot + "-srv");
}
}
[Fact]
public async Task HistoryRead_via_either_nodeid_returns_GoodNoData_under_the_shared_tagname()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-hist-{Guid.NewGuid():N}");
var port = AllocateFreePort();
var ct = TestContext.Current.CancellationToken;
try
{
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".History", ct);
await using var _ = host;
var sink = new SdkAddressSpaceSink(server.NodeManager!);
MaterialiseDualTree(sink); // both variables historized under the SAME tagname (HistorianTagname)
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
var rawStatus = await HistoryReadRawStatusAsync(session, new NodeId(RawTagPath, rawNs), ct);
var unsStatus = await HistoryReadRawStatusAsync(session, new NodeId(UnsVarPath, unsNs), ct);
// NullHistorianDataSource default: a historized node's HistoryRead surfaces GoodNoData (never an
// error) — identical for the raw and the UNS NodeId under the one registered historian tagname.
rawStatus.ShouldBe((StatusCode)StatusCodes.GoodNoData, "raw NodeId HistoryRead");
unsStatus.ShouldBe((StatusCode)StatusCodes.GoodNoData, "uns NodeId HistoryRead");
}
finally
{
SafeDelete(pkiRoot + "-srv");
}
}
[Fact]
public async Task WriteOperate_gate_is_symmetric_across_both_nodeids()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-write-{Guid.NewGuid():N}");
var port = AllocateFreePort();
var ct = TestContext.Current.CancellationToken;
try
{
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Write", ct);
await using var _ = host;
var sink = new SdkAddressSpaceSink(server.NodeManager!);
MaterialiseDualTree(sink); // both variables writable
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
var rawWrite = await WriteValueStatusAsync(session, new NodeId(RawTagPath, rawNs), 7.0f, ct);
var unsWrite = await WriteValueStatusAsync(session, new NodeId(UnsVarPath, unsNs), 7.0f, ct);
// The anonymous session carries no WriteOperate role, so the node manager's fail-closed write gate
// rejects BOTH writes identically — the gate applies uniformly to the raw and the UNS NodeId (a
// UNS write is neither more nor less privileged than the raw write it fans from).
rawWrite.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "raw NodeId write (no WriteOperate role)");
unsWrite.ShouldBe(rawWrite, "uns NodeId write rejected identically to the raw NodeId");
}
finally
{
SafeDelete(pkiRoot + "-srv");
}
}
/// <summary>Materialise the raw device folder + raw tag Variable, the equipment folder + UNS reference
/// Variable (both historized under the SAME tagname, both writable), and the Organizes UNS→Raw edge.</summary>
private static void MaterialiseDualTree(SdkAddressSpaceSink sink)
{
sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
sink.EnsureVariable(RawTagPath, RawDeviceFolder, "Speed", "Float", writable: true,
realm: AddressSpaceRealm.Raw, historianTagname: HistorianTagname);
sink.EnsureFolder(EquipFolder, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
sink.EnsureVariable(UnsVarPath, EquipFolder, "Speed", "Float", writable: true,
realm: AddressSpaceRealm.Uns, historianTagname: HistorianTagname);
// Organizes UNS→Raw: the UNS variable references its backing raw node (the fan-out source).
sink.AddReference(UnsVarPath, AddressSpaceRealm.Uns, RawTagPath, AddressSpaceRealm.Raw, "Organizes");
}
private static async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)>
BrowseForwardAsync(ISession session, NodeId node, NodeId referenceType)
{
// Match the SDK overload the codebase uses (DefaultSessionAdapter.BrowseAsync) — no trailing ct.
var (_, cp, refs) = await session.BrowseAsync(
null, null, node, 0u, BrowseDirection.Forward, referenceType,
includeSubtypes: true, nodeClassMask: 0u);
return (cp, refs ?? new ReferenceDescriptionCollection());
}
private static async Task<StatusCode> HistoryReadRawStatusAsync(ISession session, NodeId node, CancellationToken ct)
{
var details = new ReadRawModifiedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
NumValuesPerNode = 10,
IsReadModified = false,
ReturnBounds = false,
};
var nodesToRead = new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } };
var response = await session.HistoryReadAsync(
null, new ExtensionObject(details), TimestampsToReturn.Source, false, nodesToRead, ct);
response.Results.ShouldNotBeNull();
response.Results.Count.ShouldBe(1);
return response.Results[0].StatusCode;
}
private static async Task<StatusCode> WriteValueStatusAsync(ISession session, NodeId node, object value, CancellationToken ct)
{
var writeCollection = new WriteValueCollection
{
new WriteValue { NodeId = node, AttributeId = Attributes.Value, Value = new DataValue(new Variant(value)) },
};
var response = await session.WriteAsync(null, writeCollection, ct);
response.Results.ShouldNotBeNull();
response.Results.Count.ShouldBe(1);
return response.Results[0];
}
private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync(
int port, string pkiRoot, string appUri, CancellationToken ct)
{
var options = new OpcUaApplicationHostOptions
{
ApplicationName = appUri,
ApplicationUri = appUri,
OpcUaPort = port,
PublicHostname = "127.0.0.1",
PkiStoreRoot = pkiRoot,
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
AutoAcceptUntrustedClientCertificates = true,
};
var server = new OtOpcUaSdkServer();
var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
await host.StartAsync(server, ct);
return (server, host);
}
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
{
ApplicationName = "OtOpcUa.DualNamespaceClient",
ApplicationUri = $"urn:OtOpcUa.DualNamespaceClient.{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
sessionName: "DualNamespaceAddressSpaceTests", sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
}
private static int AllocateFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
private static void SafeDelete(string dir)
{
if (Directory.Exists(dir))
{
try { Directory.Delete(dir, recursive: true); }
catch { /* best-effort */ }
}
}
}
@@ -0,0 +1,282 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// v3 Batch 4 (B4-WP4) — the HEADLINE over-the-wire proof for the multi-notifier native alarm (Wave C
/// review M1): a SINGLE native transition on a condition wired to MULTIPLE notifier roots (its raw
/// device-folder parent + N referencing equipment folders) delivers <b>exactly one</b> event copy to a
/// Server-object event subscriber — the Part 9 shared-<c>InstanceStateSnapshot</c> dedup
/// (<c>MonitoredItem.QueueEvent</c> → <c>IsEventContainedInQueue</c>), NOT one copy per root. This is the
/// regression guard the design's "duplicate-report fallback rejected" decision rests on, and it is exactly
/// the multi-root topology (2 equipment folders → 2 notifier paths up to Server) where a per-root
/// re-report would surface as double delivery.
///
/// <para>Boots the real <see cref="OtOpcUaSdkServer"/> exactly as <c>SubscriptionSurvivalTests</c> does,
/// materialises the raw condition + two referencing equipment folders + the notifier wiring through the
/// production <see cref="SdkAddressSpaceSink"/>, opens a real client subscription with event monitored
/// items (captured via the subscription <see cref="Subscription.FastEventCallback"/>, keyed by
/// <c>ClientHandle</c>), fires ONE transition via <see cref="SdkAddressSpaceSink.WriteAlarmCondition"/>,
/// and counts the delivered events. Heavy in-process server+client integration — runs in the serial
/// integration pass, not the lightweight unit sweep.</para>
/// </summary>
public sealed class NativeAlarmMultiNotifierEventDeliveryTests
{
private const string ServerUri = "urn:OtOpcUa.MultiNotifierEventDelivery";
// The raw device-oriented topology (ns=Raw) + two referencing equipment folders (ns=UNS).
private const string RawDeviceFolder = "Plant/Modbus/dev1";
private const string RawAlarmPath = "Plant/Modbus/dev1/temp_hi";
private const string Equip1 = "EQ-filling-line1-station1";
private const string Equip2 = "EQ-filling-line2-station2";
private const string AlarmMessage = "over-temperature (multi-notifier test)";
// Index of the Message select clause in the event filter (see BuildEventFilter): [0]=EventId, [1]=Message.
private const int MessageFieldIndex = 1;
/// <summary>One native transition on a condition wired to two equipment folders delivers EXACTLY ONE event
/// to a Server-object subscriber (not one per notifier root) — the shared-snapshot queue dedup.</summary>
[Fact]
public async Task Single_transition_delivers_exactly_one_event_at_the_Server_object()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multinotif-evt-{Guid.NewGuid():N}");
var port = AllocateFreePort();
var ct = TestContext.Current.CancellationToken;
try
{
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct);
await using var _ = host;
var sink = new SdkAddressSpaceSink(server.NodeManager!);
WireSingleConditionToTwoEquipment(sink);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var collector = new EventCollector();
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
subscription.FastEventCallback = collector.OnEvents;
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
var serverItem = AddEventItem(subscription, ObjectIds.Server);
await subscription.ApplyChangesAsync(ct);
// Fire ONE genuine transition (inactive+acked → active+unacked). No ConditionRefresh is issued, so
// the ONLY event is this transition — a per-root re-report would show up as a second copy.
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() => collector.CountFor(serverItem.ClientHandle) >= 1, TimeSpan.FromSeconds(5));
// Settle past the publishing interval so any (erroneous) duplicate would have arrived.
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
collector.CountFor(serverItem.ClientHandle).ShouldBe(1,
"the single condition must deliver exactly ONE event to the Server object despite two notifier " +
"roots (2 equipment folders) — a per-root re-report would deliver 2.");
await subscription.DeleteAsync(true, ct);
}
finally
{
SafeDelete(pkiRoot + "-srv");
}
}
/// <summary>Overlapping subscriptions: event monitored items at the Server object AND an equipment folder
/// each receive EXACTLY ONE copy of the one transition — the multi-root topology does not multiply delivery
/// to any single subscriber, and the equipment-folder subscriber (ns=UNS) sees the native alarm without
/// touching ns=Raw.</summary>
[Fact]
public async Task Overlapping_folder_and_server_subscriptions_each_receive_exactly_one_copy()
{
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-multinotif-ovl-{Guid.NewGuid():N}");
var port = AllocateFreePort();
var ct = TestContext.Current.CancellationToken;
try
{
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Overlap", ct);
await using var _ = host;
var sink = new SdkAddressSpaceSink(server.NodeManager!);
WireSingleConditionToTwoEquipment(sink);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
unsNs.ShouldBeGreaterThan((ushort)0);
var collector = new EventCollector();
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
subscription.FastEventCallback = collector.OnEvents;
session.AddSubscription(subscription);
await subscription.CreateAsync(ct);
var serverItem = AddEventItem(subscription, ObjectIds.Server);
var equipItem = AddEventItem(subscription, new NodeId(Equip1, unsNs));
await subscription.ApplyChangesAsync(ct);
sink.WriteAlarmCondition(RawAlarmPath, ActiveSnapshot(), DateTime.UtcNow, AddressSpaceRealm.Raw);
await WaitUntilAsync(() =>
collector.CountFor(serverItem.ClientHandle) >= 1 &&
collector.CountFor(equipItem.ClientHandle) >= 1,
TimeSpan.FromSeconds(5));
await Task.Delay(TimeSpan.FromMilliseconds(700), ct);
// Each subscriber gets exactly one copy — the shared snapshot is deduped per monitored item; the
// multi-root topology never multiplies delivery.
collector.CountFor(serverItem.ClientHandle).ShouldBe(1, "Server-object subscriber");
collector.CountFor(equipItem.ClientHandle).ShouldBe(1, "equipment-folder subscriber (ns=UNS) sees the native alarm");
await subscription.DeleteAsync(true, ct);
}
finally
{
SafeDelete(pkiRoot + "-srv");
}
}
/// <summary>Materialise the raw device folder + the single native condition at its RawPath + two referencing
/// equipment folders (UNS), then wire the condition as an event notifier of both equipment folders.</summary>
private static void WireSingleConditionToTwoEquipment(SdkAddressSpaceSink sink)
{
sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
sink.MaterialiseAlarmCondition(RawAlarmPath, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, AddressSpaceRealm.Raw, isNative: true);
sink.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
sink.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns);
sink.WireAlarmNotifiers(RawAlarmPath, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
}
private static AlarmConditionSnapshot ActiveSnapshot() =>
new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true,
Shelving: AlarmShelvingKind.Unshelved, Severity: 700, Message: AlarmMessage);
private static MonitoredItem AddEventItem(Subscription subscription, NodeId source)
{
var item = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = source,
AttributeId = Attributes.EventNotifier,
Filter = BuildEventFilter(),
QueueSize = 100,
SamplingInterval = 0,
};
subscription.AddItem(item);
return item;
}
/// <summary>An EventFilter selecting [0]=EventId (dedup identity) and [1]=Message (our discriminator).</summary>
private static EventFilter BuildEventFilter()
{
var filter = new EventFilter();
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.EventId);
filter.AddSelectClause(ObjectTypes.BaseEventType, BrowseNames.Message);
return filter;
}
/// <summary>Collects events delivered to a subscription's <see cref="Subscription.FastEventCallback"/>,
/// tallying per monitored-item ClientHandle and filtering to our unique alarm Message (so unrelated server
/// events / refresh brackets never count).</summary>
private sealed class EventCollector
{
private readonly object _gate = new();
private readonly Dictionary<uint, int> _byHandle = new();
public void OnEvents(Subscription subscription, EventNotificationList notification, IList<string> stringTable)
{
lock (_gate)
{
foreach (var e in notification.Events)
{
if (e.EventFields.Count > MessageFieldIndex &&
e.EventFields[MessageFieldIndex].Value is LocalizedText lt &&
lt.Text == AlarmMessage)
{
_byHandle[e.ClientHandle] = _byHandle.GetValueOrDefault(e.ClientHandle) + 1;
}
}
}
}
public int CountFor(uint clientHandle)
{
lock (_gate) return _byHandle.GetValueOrDefault(clientHandle);
}
}
private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync(
int port, string pkiRoot, string appUri, CancellationToken ct)
{
var options = new OpcUaApplicationHostOptions
{
ApplicationName = appUri,
ApplicationUri = appUri,
OpcUaPort = port,
PublicHostname = "127.0.0.1",
PkiStoreRoot = pkiRoot,
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
AutoAcceptUntrustedClientCertificates = true,
};
var server = new OtOpcUaSdkServer();
var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
await host.StartAsync(server, ct);
return (server, host);
}
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
{
var appConfig = new ApplicationConfiguration
{
ApplicationName = "OtOpcUa.MultiNotifierEventDeliveryClient",
ApplicationUri = $"urn:OtOpcUa.MultiNotifierEventDeliveryClient.{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
};
await appConfig.ValidateAsync(ApplicationType.Client, ct);
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = await CoreClientUtils.SelectEndpointAsync(
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
sessionName: "MultiNotifierEventDeliveryTests", sessionTimeout: 60_000,
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(50);
}
condition().ShouldBeTrue("condition not met within timeout");
}
private static int AllocateFreePort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
private static void SafeDelete(string dir)
{
if (Directory.Exists(dir))
{
try { Directory.Delete(dir, recursive: true); }
catch { /* best-effort */ }
}
}
}
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
@@ -65,8 +66,8 @@ public sealed class SubscriptionSurvivalTests
new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null));
applier.MaterialiseHierarchy(withA);
applier.MaterialiseEquipmentTags(withA);
var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A");
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
// --- Open a client session + subscription + monitored item on A. ---
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
@@ -115,7 +116,7 @@ public sealed class SubscriptionSurvivalTests
// --- A's monitored item must still be alive: a new write to A is delivered. ---
lock (gate) received.Clear();
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5));
lock (gate)
@@ -125,8 +126,8 @@ public sealed class SubscriptionSurvivalTests
}
// --- B is browsable + readable (the add landed). ---
var nodeIdB = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B"), ns);
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var bValue = await session.ReadValueAsync(nodeIdB, ct);
bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown);
bValue.Value.ShouldBe(7);
@@ -173,10 +174,10 @@ public sealed class SubscriptionSurvivalTests
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
applier.MaterialiseHierarchy(withAB);
applier.MaterialiseEquipmentTags(withAB);
var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A");
var nodeIdBString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B");
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
var nodeIdBString = V3NodeIds.Uns("eq-1", "B");
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
@@ -212,7 +213,7 @@ public sealed class SubscriptionSurvivalTests
await WaitUntilAsync(() => { lock (gate) return receivedB.Any(v => StatusCode.IsBad(v.StatusCode)); }, TimeSpan.FromSeconds(5));
// A stays alive: a fresh write to A is delivered.
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
// B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path
@@ -261,8 +262,8 @@ public sealed class SubscriptionSurvivalTests
new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null));
applier.MaterialiseHierarchy(withAB);
applier.MaterialiseEquipmentTags(withAB);
var nodeIdAString = Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "A");
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
var nodeIdAString = V3NodeIds.Uns("eq-1", "A");
sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri);
@@ -295,18 +296,18 @@ public sealed class SubscriptionSurvivalTests
applier.AnnounceAddedNodes(plan);
// A stays alive.
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5));
// B is gone. (1.5.378 node-cache read throws for an unknown node — see above.)
var nodeIdB = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "B"), ns);
var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns);
var bReadEx = await Should.ThrowAsync<ServiceResultException>(
async () => await session.ReadValueAsync(nodeIdB, ct));
bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown);
// C is browsable + readable.
var nodeIdC = new NodeId(Commons.OpcUa.EquipmentNodeIds.Variable("eq-1", "", "C"), ns);
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow);
var nodeIdC = new NodeId(V3NodeIds.Uns("eq-1", "C"), ns);
sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var cRead = await session.ReadValueAsync(nodeIdC, ct);
cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown);
cRead.Value.ShouldBe(9);
@@ -161,24 +161,24 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
public bool ThrowOnAlarmWrite { get; init; }
public bool ThrowOnMaterialiseAlarm { get; init; }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
if (ThrowOnAlarmWrite) throw new InvalidOperationException("simulated WriteAlarmCondition fault");
}
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
{
if (ThrowOnMaterialiseAlarm) throw new InvalidOperationException("simulated MaterialiseAlarmCondition fault");
}
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
if (ThrowOnEnsureFolder) throw new InvalidOperationException("simulated EnsureFolder fault");
}
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
{
if (ThrowOnEnsureVariable) throw new InvalidOperationException("simulated EnsureVariable fault");
}
@@ -188,6 +188,8 @@ public sealed class AddressSpaceApplierFailureSurfaceTests
if (ThrowOnRebuild) throw new InvalidOperationException("simulated RebuildAddressSpace fault");
}
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
}
@@ -307,24 +307,24 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality value.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>Records an alarm condition write (stub implementation for testing).</summary>
/// <param name="alarmNodeId">The node ID of the alarm condition.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>Materialises an alarm condition (stub implementation for testing).</summary>
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
/// <param name="equipmentNodeId">The equipment folder node ID.</param>
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
/// <summary>Records a folder creation request.</summary>
/// <param name="folderNodeId">The node ID of the folder.</param>
/// <param name="parentNodeId">The node ID of the parent folder, or null for root.</param>
/// <param name="displayName">The display name of the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> _calls.Enqueue((folderNodeId, parentNodeId, displayName));
/// <summary>Ensures a variable exists (stub implementation for testing).</summary>
/// <param name="variableNodeId">The node ID of the variable.</param>
@@ -333,10 +333,12 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// <summary>Rebuilds the address space (stub implementation for testing).</summary>
public void RebuildAddressSpace() { }
/// <summary>Announces a NodeAdded model-change (stub implementation for testing).</summary>
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
}
@@ -292,8 +292,8 @@ public sealed class AddressSpaceApplierProvisioningTests
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
{
RemovedEquipmentTags = new[] { removedTag },
ChangedEquipmentTags = new[] { new AddressSpacePlan.EquipmentTagDelta(prev, cur) },
RemovedRawTags = new[] { removedTag },
ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(prev, cur) },
};
applier.Apply(plan);
@@ -324,15 +324,20 @@ public sealed class AddressSpaceApplierProvisioningTests
outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no rebuild; Apply still completed + provisioned
}
private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: fullName,
Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: historianName);
// v3 Batch 4: historized value tags are RAW tags — the mux ref + historian default are the RawPath
// (== NodeId). The `fullName` param plays the role of the RawPath here (kept as a param name so the
// existing assertions — HistorizedTagRef("ref"/"40001", …) — read unchanged).
private static RawTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
=> new("tag-" + displayName, NodeId: fullName, ParentNodeId: null, DriverInstanceId: "drv", Name: displayName,
DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
IsHistorized: true, HistorianTagname: historianName);
private static EquipmentTagPlan NonHistorizedTag(string displayName, string dataType)
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: "ref",
Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null);
private static RawTagPlan NonHistorizedTag(string displayName, string dataType)
=> new("tag-" + displayName, NodeId: "ref", ParentNodeId: null, DriverInstanceId: "drv", Name: displayName,
DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
IsHistorized: false, HistorianTagname: null);
private static AddressSpacePlan PlanWithAddedTags(params EquipmentTagPlan[] tags) => new(
private static AddressSpacePlan PlanWithAddedTags(params RawTagPlan[] tags) => new(
AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: Array.Empty<EquipmentNode>(),
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
@@ -343,6 +348,6 @@ public sealed class AddressSpaceApplierProvisioningTests
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
{
AddedEquipmentTags = tags,
AddedRawTags = tags,
};
}
@@ -0,0 +1,308 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// v3 Batch 4 (B4-WP3) — the applier's dual-realm materialise passes.
/// <see cref="AddressSpaceApplier.MaterialiseRawSubtree"/> lights up the <c>ns=Raw</c> device tree
/// (containers as folders + tags as variables, each keyed by its RawPath, all in
/// <see cref="AddressSpaceRealm.Raw"/>); <see cref="AddressSpaceApplier.MaterialiseUnsReferences"/> lights
/// up the <c>ns=UNS</c> reference variables (each under its equipment folder in
/// <see cref="AddressSpaceRealm.Uns"/>) and wires an <c>Organizes</c> reference UNS→Raw. A historized raw
/// tag's UNS reference inherits the SAME historian tagname (both NodeIds → one tagname). Every call carries
/// its realm EXPLICITLY.
/// </summary>
public sealed class AddressSpaceApplierRawUnsTests
{
private static AddressSpaceApplier NewApplier(RealmRecordingSink sink) =>
new(sink, NullLogger<AddressSpaceApplier>.Instance);
[Fact]
public void MaterialiseRawSubtree_creates_raw_containers_and_tags_in_raw_realm()
{
var sink = new RealmRecordingSink();
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
RawContainers = new[]
{
new RawContainerNode("Plant", null, "Plant", RawNodeKind.Folder),
new RawContainerNode("Plant/Modbus", "Plant", "Modbus", RawNodeKind.Driver),
new RawContainerNode("Plant/Modbus/dev1", "Plant/Modbus", "dev1", RawNodeKind.Device),
},
RawTags = new[]
{
new RawTagPlan("t1", "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1", "drv-1", "speed",
"Float", Writable: true, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>()),
},
};
NewApplier(sink).MaterialiseRawSubtree(composition);
// Containers are folders in the Raw realm, parents-before-children.
sink.Folders.Select(f => (f.NodeId, f.Realm)).ShouldBe(new[]
{
("Plant", AddressSpaceRealm.Raw),
("Plant/Modbus", AddressSpaceRealm.Raw),
("Plant/Modbus/dev1", AddressSpaceRealm.Raw),
});
// The tag is a variable keyed by its RawPath, in the Raw realm, parented under its group/device.
var v = sink.Variables.ShouldHaveSingleItem();
v.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
v.Parent.ShouldBe("Plant/Modbus/dev1");
v.Realm.ShouldBe(AddressSpaceRealm.Raw);
v.Writable.ShouldBeTrue();
v.HistorianTagname.ShouldBeNull();
}
[Fact]
public void MaterialiseRawSubtree_resolves_historian_tagname_default_and_override()
{
var sink = new RealmRecordingSink();
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
RawTags = new[]
{
// Historized, no override → historian tagname defaults to the RawPath.
new RawTagPlan("t1", "Plant/A/dev/def", "Plant/A/dev", "drv", "def", "Float",
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(), IsHistorized: true),
// Historized with override → the override wins.
new RawTagPlan("t2", "Plant/A/dev/ovr", "Plant/A/dev", "drv", "ovr", "Float",
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(), IsHistorized: true, HistorianTagname: "WW.Override"),
},
};
NewApplier(sink).MaterialiseRawSubtree(composition);
var byId = sink.Variables.ToDictionary(v => v.NodeId);
byId["Plant/A/dev/def"].HistorianTagname.ShouldBe("Plant/A/dev/def"); // default = RawPath
byId["Plant/A/dev/ovr"].HistorianTagname.ShouldBe("WW.Override");
}
[Fact]
public void MaterialiseRawSubtree_alarm_tag_materialises_a_raw_condition_not_a_variable()
{
var sink = new RealmRecordingSink();
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
RawTags = new[]
{
new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
ReferencingEquipmentPaths: Array.Empty<string>()),
},
};
NewApplier(sink).MaterialiseRawSubtree(composition);
sink.Variables.ShouldBeEmpty(); // an alarm tag is a condition, not a value variable
var c = sink.Conditions.ShouldHaveSingleItem();
c.NodeId.ShouldBe("Plant/A/dev/OverTemp"); // ConditionId == RawPath
c.Parent.ShouldBe("Plant/A/dev");
c.Realm.ShouldBe(AddressSpaceRealm.Raw);
c.IsNative.ShouldBeTrue();
// No referencing equipment ⇒ no multi-notifier wiring.
sink.NotifierWirings.ShouldBeEmpty();
}
[Fact]
public void MaterialiseRawSubtree_alarm_tag_wires_a_notifier_per_referencing_equipment_folder()
{
var sink = new RealmRecordingSink();
// UNS topology so the alarm's referencing-equipment PATHS (Area/Line/Equipment) resolve to the
// EquipmentId the equipment folders were materialised under. Two equipment reference the one alarm tag.
var composition = new AddressSpaceComposition(
new[] { new UnsAreaProjection("a1", "filling") },
new[] { new UnsLineProjection("l1", "a1", "line1"), new UnsLineProjection("l2", "a1", "line2") },
new[]
{
new EquipmentNode("EQ-1", "station1", "l1"),
new EquipmentNode("EQ-2", "station2", "l2"),
},
Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>())
{
RawTags = new[]
{
new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
// The composer emits Area/Line/Equipment NAME paths here.
ReferencingEquipmentPaths: new[] { "filling/line1/station1", "filling/line2/station2" }),
},
};
NewApplier(sink).MaterialiseRawSubtree(composition);
// The single condition is materialised ONCE at the RawPath (Raw realm)...
var c = sink.Conditions.ShouldHaveSingleItem();
c.NodeId.ShouldBe("Plant/A/dev/OverTemp");
c.Realm.ShouldBe(AddressSpaceRealm.Raw);
// ...and wired as a notifier of BOTH referencing equipment folders — resolved from the name paths to the
// logical EquipmentId folder NodeIds, in the Uns realm. ONE wiring call (single condition), not per-root.
var w = sink.NotifierWirings.ShouldHaveSingleItem();
w.AlarmNodeId.ShouldBe("Plant/A/dev/OverTemp");
w.AlarmRealm.ShouldBe(AddressSpaceRealm.Raw);
w.FolderRealm.ShouldBe(AddressSpaceRealm.Uns);
w.FolderNodeIds.ShouldBe(new[] { "EQ-1", "EQ-2" }, ignoreOrder: true);
}
/// <summary>
/// Wave C review M2 — composer↔applier PATH-AGREEMENT (the tripwire for the latent DisplayName==Name
/// coupling). The composer builds a native alarm's <c>ReferencingEquipmentPaths</c> from the
/// Area/Line/Equipment <c>Name</c>s; the applier's <c>BuildEquipmentIdByFolderPath</c> inverts those
/// paths back to the <c>EquipmentId</c> via the projected DisplayName. Feed a REAL composer output
/// (not a hand-built plan) through <see cref="AddressSpaceApplier.MaterialiseRawSubtree"/> and assert
/// the notifier wired to exactly the backing equipment's id — proving the two path constructions agree
/// end-to-end. If a future editable Area/Line display-name feature made <c>DisplayName != Name</c>, this
/// inversion (and thus the native-alarm fan-out) would break with no deploy error; this test trips first.
/// </summary>
[Fact]
public void Composer_referencing_equipment_paths_resolve_back_to_equipment_ids_in_the_applier()
{
// Real config: a raw alarm tag (Speed) referenced by one equipment (eq-1 under Area "filling" /
// Line "line-1" / Equipment "station-1").
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" };
var driver = new DriverInstance
{
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}",
};
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" };
var speed = new Tag
{
TagId = "t-speed", DeviceId = "dev-1", Name = "Speed", DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}",
};
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" };
var refSpeed = new UnsTagReference { UnsTagReferenceId = "ref-speed", EquipmentId = "eq-1", TagId = "t-speed" };
var composition = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(),
unsTagReferences: new[] { refSpeed },
tags: new[] { speed },
rawFolders: new[] { folder },
devices: new[] { device },
tagGroups: Array.Empty<TagGroup>());
// The composer emitted the alarm tag's referencing-equipment path from the Area/Line/Equipment Names.
var alarmTag = composition.RawTags.Single(t => t.Alarm is not null);
alarmTag.ReferencingEquipmentPaths.ShouldBe(new[] { V3NodeIds.Uns("filling", "line-1", "station-1") });
// The applier inverts that path back to the EquipmentId folder and wires the notifier there.
var sink = new RealmRecordingSink();
NewApplier(sink).MaterialiseRawSubtree(composition);
var w = sink.NotifierWirings.ShouldHaveSingleItem();
w.AlarmNodeId.ShouldBe(alarmTag.NodeId);
w.FolderNodeIds.ShouldBe(new[] { "eq-1" }); // resolved path → EquipmentId
}
/// <summary>Wave C review M2 — when a native alarm HAS referencing equipment paths but NONE resolve to an
/// equipment folder (broken ancestry / the latent coupling breaking), the applier surfaces it as a failed
/// node (so the degraded-apply meter fires) instead of silently skipping — no notifier is wired, but the
/// condition itself is still materialised.</summary>
[Fact]
public void MaterialiseRawSubtree_alarm_with_unresolvable_referencing_equipment_counts_as_failed()
{
var sink = new RealmRecordingSink();
// No equipment nodes in the composition → the referencing path can't resolve to an EquipmentId folder.
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
RawTags = new[]
{
new RawTagPlan("t1", "Plant/A/dev/OverTemp", "Plant/A/dev", "drv", "OverTemp", "Boolean",
Writable: false, Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700),
ReferencingEquipmentPaths: new[] { "filling/line1/ghost" }),
},
};
var failed = NewApplier(sink).MaterialiseRawSubtree(composition);
failed.ShouldBeGreaterThanOrEqualTo(1); // resolution failure surfaced (not a silent skip)
sink.NotifierWirings.ShouldBeEmpty(); // nothing wired
sink.Conditions.ShouldHaveSingleItem(); // the condition itself still materialised
}
[Fact]
public void MaterialiseUnsReferences_creates_uns_variable_and_organizes_edge_inheriting_historian()
{
var sink = new RealmRecordingSink();
var composition = new AddressSpaceComposition(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
RawTags = new[]
{
// Backing raw tag: historized with an override → the UNS ref must register the SAME tagname.
new RawTagPlan("t1", "Plant/A/dev/speed", "Plant/A/dev", "drv", "speed", "Float",
Writable: true, Alarm: null, ReferencingEquipmentPaths: new[] { "filling/line1/station1" },
IsHistorized: true, HistorianTagname: "WW.Speed"),
},
UnsReferenceVariables = new[]
{
new UnsReferenceVariable("r1", "EQ-1", "filling/line1/station1/speed", "speed",
"Plant/A/dev/speed", "Float", Writable: true),
},
};
NewApplier(sink).MaterialiseUnsReferences(composition);
// The UNS reference is a variable in the Uns realm, parented under its equipment folder (EquipmentId).
var v = sink.Variables.ShouldHaveSingleItem();
v.NodeId.ShouldBe("filling/line1/station1/speed");
v.Parent.ShouldBe("EQ-1");
v.Realm.ShouldBe(AddressSpaceRealm.Uns);
v.Writable.ShouldBeTrue();
v.HistorianTagname.ShouldBe("WW.Speed"); // inherited from the backing historized raw tag
// An Organizes reference UNS→Raw links the two.
var r = sink.References.ShouldHaveSingleItem();
r.Source.ShouldBe("filling/line1/station1/speed");
r.SourceRealm.ShouldBe(AddressSpaceRealm.Uns);
r.Target.ShouldBe("Plant/A/dev/speed");
r.TargetRealm.ShouldBe(AddressSpaceRealm.Raw);
r.ReferenceType.ShouldBe("Organizes");
}
/// <summary>A capturing sink that records (nodeId, realm) for every materialise + the Organizes edges.</summary>
private sealed class RealmRecordingSink : IOpcUaAddressSpaceSink
{
public List<(string NodeId, string? Parent, AddressSpaceRealm Realm)> Folders { get; } = new();
public List<(string NodeId, string? Parent, bool Writable, string? HistorianTagname, AddressSpaceRealm Realm)> Variables { get; } = new();
public List<(string NodeId, string? Parent, bool IsNative, AddressSpaceRealm Realm)> Conditions { get; } = new();
public List<(string Source, AddressSpaceRealm SourceRealm, string Target, AddressSpaceRealm TargetRealm, string ReferenceType)> References { get; } = new();
public List<(string AlarmNodeId, AddressSpaceRealm AlarmRealm, IReadOnlyList<string> FolderNodeIds, AddressSpaceRealm FolderRealm)> NotifierWirings { get; } = new();
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm)
=> Folders.Add((folderNodeId, parentNodeId, realm));
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> Variables.Add((variableNodeId, parentFolderNodeId, writable, historianTagname, realm));
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> Conditions.Add((alarmNodeId, equipmentNodeId, isNative, realm));
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm)
=> NotifierWirings.Add((alarmNodeId, alarmRealm, notifierFolderNodeIds, notifierFolderRealm));
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes")
=> References.Add((sourceNodeId, sourceRealm, targetNodeId, targetRealm, referenceType));
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
}
}
@@ -148,7 +148,7 @@ public sealed class AddressSpaceApplierTests
// A ReadWrite plan threads Writable: true through the applier to the sink (the node is created CurrentReadWrite).
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Speed", "eq-1", "Speed", "Float", true));
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (null/empty FolderPath).
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
}
/// <summary>Verifies a FolderPath on an equipment tag becomes a sub-folder UNDER the equipment
@@ -175,7 +175,7 @@ public sealed class AddressSpaceApplierTests
// A Read plan threads Writable: false (the node stays CurrentRead).
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics/Temp", "eq-1/Diagnostics", "Temp", "Float", false));
// Parity: the materialiser's NodeId is the shared EquipmentNodeIds formula (with FolderPath).
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp"));
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp"));
}
/// <summary>Regression for the FullName-as-NodeId collision: two identical machines exposing the
@@ -228,13 +228,13 @@ public sealed class AddressSpaceApplierTests
applier.MaterialiseEquipmentTags(composition);
// The plain tag drove EnsureVariable at its folder-scoped NodeId, and NOT a condition.
var plainNodeId = EquipmentNodeIds.Variable("eq-1", "", "Speed");
var plainNodeId = V3NodeIds.Uns("eq-1", "Speed");
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe((plainNodeId, "eq-1", "Speed", "Float", true));
sink.AlarmConditionCalls.ShouldNotContain(c => c.AlarmNodeId == plainNodeId);
// The alarm tag drove MaterialiseAlarmCondition (folder-scoped NodeId, equipment parent,
// matching display/type/severity) and did NOT drive EnsureVariable.
var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp");
var alarmNodeId = V3NodeIds.Uns("eq-1", "OverTemp");
// A native equipment-tag alarm: the call-site threads isNative: true.
sink.AlarmConditionCalls.ShouldHaveSingleItem()
.ShouldBe((alarmNodeId, "eq-1", "OverTemp", "OffNormalAlarm", 700, true));
@@ -264,7 +264,7 @@ public sealed class AddressSpaceApplierTests
// The sub-folder is still created for an alarm tag with a FolderPath.
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/Diagnostics", "eq-1", "Diagnostics"));
// Condition is parented to the sub-folder, with the folder-scoped NodeId. No value variable.
var alarmNodeId = EquipmentNodeIds.Variable("eq-1", "Diagnostics", "OverTemp");
var alarmNodeId = V3NodeIds.Uns("eq-1", "Diagnostics", "OverTemp");
// A native equipment-tag alarm (with a FolderPath): the call-site still threads isNative: true.
sink.AlarmConditionCalls.ShouldHaveSingleItem()
.ShouldBe((alarmNodeId, "eq-1/Diagnostics", "OverTemp", "OffNormalAlarm", 500, true));
@@ -300,9 +300,9 @@ public sealed class AddressSpaceApplierTests
applier.MaterialiseEquipmentTags(composition);
var byNode = sink.HistorianCalls.ToDictionary(c => c.NodeId, c => c.HistorianTagname);
byNode[EquipmentNodeIds.Variable("eq-1", "", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName
byNode[EquipmentNodeIds.Variable("eq-1", "", "BOverride")].ShouldBe("WW.Override"); // override verbatim
byNode[EquipmentNodeIds.Variable("eq-1", "", "CPlain")].ShouldBeNull(); // not historized ⇒ null
byNode[V3NodeIds.Uns("eq-1", "ADefault")].ShouldBe("T.A"); // default ⇒ FullName
byNode[V3NodeIds.Uns("eq-1", "BOverride")].ShouldBe("WW.Override"); // override verbatim
byNode[V3NodeIds.Uns("eq-1", "CPlain")].ShouldBeNull(); // not historized ⇒ null
}
/// <summary>Phase C Task 2 — a historized tag whose override is blank/whitespace still falls back to
@@ -326,7 +326,7 @@ public sealed class AddressSpaceApplierTests
applier.MaterialiseEquipmentTags(composition);
var call = sink.HistorianCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
call.HistorianTagname.ShouldBe("40001");
}
@@ -353,7 +353,7 @@ public sealed class AddressSpaceApplierTests
applier.MaterialiseEquipmentTags(composition);
var call = sink.ArrayCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Buffer"));
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Buffer"));
call.IsArray.ShouldBeTrue();
call.ArrayLength.ShouldBe(16u);
}
@@ -380,7 +380,7 @@ public sealed class AddressSpaceApplierTests
applier.MaterialiseEquipmentTags(composition);
var call = sink.ArrayCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
call.IsArray.ShouldBeFalse();
call.ArrayLength.ShouldBeNull();
}
@@ -471,11 +471,11 @@ public sealed class AddressSpaceApplierTests
// VirtualTags are computed outputs — always read-only (Writable: false).
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64", false));
// Parity: the vtag materialiser's NodeId is the shared EquipmentNodeIds formula.
sink.VariableCalls.Single().NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "speed-rpm"));
sink.VariableCalls.Single().NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "speed-rpm"));
}
/// <summary>Golden/parity guard: the materialiser's Variable NodeId for BOTH the equipment-tag and
/// the equipment-VirtualTag pass is byte-identical to <see cref="EquipmentNodeIds.Variable"/> — the
/// the equipment-VirtualTag pass is byte-identical to <c>V3NodeIds.Uns</c> — the
/// single source of truth AddressSpaceApplier + VirtualTagHostActor both point at. Covers null/empty
/// FolderPath (directly under equipment) and a non-empty FolderPath (sub-folder scoped). This test
/// LOCKS the formula against drift: any change to the materialiser NodeId that diverges from the
@@ -507,10 +507,10 @@ public sealed class AddressSpaceApplierTests
applier.MaterialiseEquipmentVirtualTags(composition);
var nodeIds = sink.VariableCalls.Select(v => v.NodeId).ToList();
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-1", "Diagnostics", "Temp"));
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-2", "", "Efficiency"));
nodeIds.ShouldContain(EquipmentNodeIds.Variable("eq-2", "Calc", "Avg"));
nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Speed"));
nodeIds.ShouldContain(V3NodeIds.Uns("eq-1", "Diagnostics", "Temp"));
nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Efficiency"));
nodeIds.ShouldContain(V3NodeIds.Uns("eq-2", "Calc", "Avg"));
}
/// <summary>Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables
@@ -760,7 +760,7 @@ public sealed class AddressSpaceApplierTests
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
call.Writable.ShouldBeTrue();
}
@@ -815,7 +815,7 @@ public sealed class AddressSpaceApplierTests
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "Speed");
var nodeId = V3NodeIds.Uns("eq-1", "Speed");
// Terminal Bad written to the removed variable BEFORE the removal.
sink.ValueWrites.ShouldContain((nodeId, OpcUaQuality.Bad));
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId));
@@ -842,7 +842,7 @@ public sealed class AddressSpaceApplierTests
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "OverTemp");
var nodeId = V3NodeIds.Uns("eq-1", "OverTemp");
// Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node.
var write = sink.AlarmWrites.ShouldHaveSingleItem();
write.NodeId.ShouldBe(nodeId);
@@ -953,7 +953,7 @@ public sealed class AddressSpaceApplierTests
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
// The removed node is torn down in place; the add is left to the publish actor's materialise passes.
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", EquipmentNodeIds.Variable("eq-1", "", "Old")));
sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Old")));
outcome.AddedNodes.ShouldBe(1);
outcome.RemovedNodes.ShouldBe(1);
}
@@ -967,7 +967,7 @@ public sealed class AddressSpaceApplierTests
var sink = new RecordingSink();
var applier = new AddressSpaceApplier(sink, NullLogger<AddressSpaceApplier>.Instance);
var nodeId = EquipmentNodeIds.Variable("eq-1", "", "Slot");
var nodeId = V3NodeIds.Uns("eq-1", "Slot");
var plan = EmptyPlan with
{
// Different TagId, but both resolve to the SAME folder-scoped NodeId (eq-1/Slot).
@@ -1457,8 +1457,8 @@ public sealed class AddressSpaceApplierTests
sink.RebuildCalls.ShouldBe(0);
outcome.RemovedNodes.ShouldBe(2); // both removals counted
sink.RemoveCalls.Count.ShouldBe(2);
sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Speed")));
sink.RemoveCalls.ShouldContain(("var", EquipmentNodeIds.Variable("eq-1", "", "Efficiency")));
sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Speed")));
sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Efficiency")));
}
// ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) -----
@@ -1490,7 +1490,7 @@ public sealed class AddressSpaceApplierTests
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
call.Writable.ShouldBeTrue(); // the NEW Writable value
call.Historian.ShouldBeNull(); // not historized
outcome.ChangedNodes.ShouldBe(1);
@@ -1584,7 +1584,7 @@ public sealed class AddressSpaceApplierTests
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0); // NO rebuild — subscriptions preserved
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed"));
call.DataType.ShouldBe("Int32"); // the NEW DataType
call.Writable.ShouldBeTrue(); // the NEW Writable, applied in the same call
call.IsArray.ShouldBeFalse();
@@ -1781,8 +1781,8 @@ public sealed class AddressSpaceApplierTests
sink.SurgicalCalls.Count.ShouldBe(2);
// Both expected node ids must appear (order is not guaranteed).
var nodeId1 = EquipmentNodeIds.Variable("eq-1", "", "Speed");
var nodeId2 = EquipmentNodeIds.Variable("eq-1", "", "Pressure");
var nodeId1 = V3NodeIds.Uns("eq-1", "Speed");
var nodeId2 = V3NodeIds.Uns("eq-1", "Pressure");
sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId1 && c.Writable);
sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId2 && c.Writable);
@@ -2040,7 +2040,7 @@ public sealed class AddressSpaceApplierTests
},
};
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { EquipmentNodeIds.SubFolder("eq-1", "Diag") });
applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { V3NodeIds.Uns("eq-1", "Diag") });
}
/// <summary>An added scripted alarm announces its equipment folder (where its condition node parents).</summary>
@@ -2101,7 +2101,7 @@ public sealed class AddressSpaceApplierTests
applier.AnnounceAddedNodes(plan);
sink.ModelChangeCalls.OrderBy(x => x).ShouldBe(
new[] { "eq-1", EquipmentNodeIds.SubFolder("eq-1", "Diag"), "line-7" }.OrderBy(x => x));
new[] { "eq-1", V3NodeIds.Uns("eq-1", "Diag"), "line-7" }.OrderBy(x => x));
}
private static AddressSpaceComposition CompositionWithAreas(params UnsAreaProjection[] areas) =>
@@ -2170,7 +2170,7 @@ public sealed class AddressSpaceApplierTests
/// <param name="dataType">The new OPC UA data type name to apply in place.</param>
/// <param name="isArray">The new array-ness of the node.</param>
/// <param name="arrayLength">The new 1-D array length when <paramref name="isArray"/> is true.</param>
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
SurgicalQueue.Enqueue((variableNodeId, writable, historianTagname, dataType, isArray, arrayLength));
return SurgicalReturns;
@@ -2187,7 +2187,7 @@ public sealed class AddressSpaceApplierTests
/// <summary>Records a surgical in-place folder display-name update; returns <see cref="FolderRenameReturns"/>.</summary>
/// <param name="folderNodeId">The folder node ID to update in place.</param>
/// <param name="displayName">The new display name to apply.</param>
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
FolderRenameQueue.Enqueue((folderNodeId, displayName));
return FolderRenameReturns;
@@ -2202,21 +2202,21 @@ public sealed class AddressSpaceApplierTests
public bool RemoveReturns { get; init; } = true;
/// <summary>Records a surgical variable-node removal; returns <see cref="RemoveReturns"/>.</summary>
public bool RemoveVariableNode(string variableNodeId)
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
RemoveQueue.Enqueue(("var", variableNodeId));
return RemoveReturns;
}
/// <summary>Records a surgical alarm-condition-node removal; returns <see cref="RemoveReturns"/>.</summary>
public bool RemoveAlarmConditionNode(string alarmNodeId)
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
RemoveQueue.Enqueue(("alarm", alarmNodeId));
return RemoveReturns;
}
/// <summary>Records a surgical equipment-subtree removal; returns <see cref="RemoveReturns"/>.</summary>
public bool RemoveEquipmentSubtree(string equipmentNodeId)
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
RemoveQueue.Enqueue(("equipment", equipmentNodeId));
return RemoveReturns;
@@ -2262,13 +2262,13 @@ public sealed class AddressSpaceApplierTests
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> ValueWriteQueue.Enqueue((nodeId, quality));
/// <summary>Records an alarm condition write call.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> AlarmQueue.Enqueue((alarmNodeId, state));
/// <summary>Records an alarm-condition materialise call.</summary>
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
@@ -2276,13 +2276,13 @@ public sealed class AddressSpaceApplierTests
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> AlarmConditionQueue.Enqueue((alarmNodeId, equipmentNodeId, displayName, alarmType, severity, isNative));
/// <summary>Records a folder creation call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
/// <summary>Records a variable creation call.</summary>
/// <param name="variableNodeId">The variable node ID.</param>
@@ -2291,7 +2291,7 @@ public sealed class AddressSpaceApplierTests
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
{
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
HistorianQueue.Enqueue((variableNodeId, historianTagname));
@@ -2306,7 +2306,9 @@ public sealed class AddressSpaceApplierTests
public List<string> ModelChangeCalls => ModelChangeQueue.ToList();
/// <summary>Records a NodeAdded model-change announcement.</summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId) => ModelChangeQueue.Enqueue(affectedNodeId);
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId);
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
/// <summary>A recording sink that does NOT implement <see cref="ISurgicalAddressSpaceSink"/> — used to
@@ -2318,19 +2320,21 @@ public sealed class AddressSpaceApplierTests
public int RebuildCalls;
/// <summary>Records a value write (no-op in this sink).</summary>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op alarm condition write call.</summary>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op alarm-condition materialise call.</summary>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
/// <summary>No-op folder creation call.</summary>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op variable creation call.</summary>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>No-op NodeAdded model-change announcement.</summary>
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
private sealed class ThrowingSink : IOpcUaAddressSpaceSink
@@ -2345,13 +2349,13 @@ public sealed class AddressSpaceApplierTests
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>Throws an exception if configured to do so.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
/// <exception cref="InvalidOperationException">Thrown when configured to throw on alarm write.</exception>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
if (_throwOnAlarmWrite) throw new InvalidOperationException("simulated sink fault");
}
@@ -2361,12 +2365,12 @@ public sealed class AddressSpaceApplierTests
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
/// <summary>No-op folder creation call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent folder node ID, if any.</param>
/// <param name="displayName">The display name for the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op variable creation call.</summary>
/// <param name="variableNodeId">The variable node ID.</param>
/// <param name="parentFolderNodeId">The parent folder node ID, if any.</param>
@@ -2374,10 +2378,12 @@ public sealed class AddressSpaceApplierTests
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// <summary>No-op rebuild address space call.</summary>
public void RebuildAddressSpace() { }
/// <summary>No-op NodeAdded model-change announcement.</summary>
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
}
@@ -0,0 +1,103 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// v3 Batch 4 (B4-WP1) — the classifier folds the Raw + UNS subtree diff sets into its routing policy:
/// raw/uns adds count as adds (PureAdd), removes as removes (PureRemove), and any Changed raw/uns delta
/// routes to Rebuild (the safe default — WP3's applier owns surgical refinement).
/// </summary>
public sealed class AddressSpaceChangeClassifierDualNamespaceTests
{
private static readonly RawContainerNode Folder = new("Plant", null, "Plant", RawNodeKind.Folder);
private static RawTagPlan RawTag(string nodeId) =>
new("t-1", nodeId, "Plant/Modbus1/PLC-A", "drv-1", "Speed", "Float",
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>());
private static UnsReferenceVariable UnsRef(string nodeId) =>
new("ref-1", "eq-1", nodeId, "Speed", "Plant/Modbus1/PLC-A/Speed", "Float", Writable: false);
private static AddressSpacePlan Empty() => new(
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<AddressSpacePlan.EquipmentDelta>(),
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<AddressSpacePlan.DriverDelta>(),
Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<AddressSpacePlan.AlarmDelta>());
[Fact]
public void Added_raw_tag_only_is_PureAdd()
{
var plan = Empty() with { AddedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") } };
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
}
[Fact]
public void Added_raw_container_only_is_PureAdd()
{
var plan = Empty() with { AddedRawContainers = new[] { Folder } };
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
}
[Fact]
public void Added_uns_reference_only_is_PureAdd()
{
var plan = Empty() with { AddedUnsReferenceVariables = new[] { UnsRef("filling/line-1/station-1/Speed") } };
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
}
[Fact]
public void Removed_raw_tag_only_is_PureRemove()
{
var plan = Empty() with { RemovedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") } };
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove);
}
[Fact]
public void Removed_uns_reference_only_is_PureRemove()
{
var plan = Empty() with { RemovedUnsReferenceVariables = new[] { UnsRef("filling/line-1/station-1/Speed") } };
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove);
}
[Fact]
public void Raw_rename_add_and_remove_is_AddRemoveMix()
{
var plan = Empty() with
{
RemovedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") },
AddedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Velocity") },
};
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AddRemoveMix);
}
[Fact]
public void Changed_raw_tag_is_Rebuild()
{
var plan = Empty() with
{
ChangedRawTags = new[]
{
new AddressSpacePlan.RawTagDelta(
RawTag("Plant/Modbus1/PLC-A/Speed"),
RawTag("Plant/Modbus1/PLC-A/Speed") with { IsHistorized = true }),
},
};
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
}
[Fact]
public void Uns_reference_repoint_is_Rebuild()
{
var plan = Empty() with
{
ChangedUnsReferenceVariables = new[]
{
new AddressSpacePlan.UnsReferenceDelta(
UnsRef("filling/line-1/station-1/Speed"),
UnsRef("filling/line-1/station-1/Speed") with { BackingRawPath = "Plant/Modbus1/PLC-A/Velocity" }),
},
};
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
}
}
@@ -0,0 +1,208 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// v3 Batch 4 (B4-WP1) — the composer un-darkens BOTH subtrees. Verifies the Raw subtree
/// (Folder→Driver→Device→TagGroup container nodes + raw-tag Variables keyed <c>s=&lt;RawPath&gt;</c>,
/// realm <see cref="AddressSpaceRealm.Raw"/>) and the UNS subtree (one Variable per
/// <see cref="UnsTagReference"/> keyed <c>s=&lt;Area&gt;/&lt;Line&gt;/&lt;Equipment&gt;/&lt;EffectiveName&gt;</c>,
/// realm <see cref="AddressSpaceRealm.Uns"/>, carrying its backing RawPath for the Organizes ref +
/// fan-out). Native-alarm intents attach at the RAW tag and carry the referencing equipment paths.
/// </summary>
public sealed class AddressSpaceComposerDualNamespaceTests
{
// A raw chain: RawFolder "Plant" → Driver "Modbus1" → Device "PLC-A" → Group "Motors" → tags.
// Speed sits under the group; Status directly under the device; Levels is an array tag.
private static AddressSpaceComposition ComposeFullFixture()
{
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" };
var driver = new DriverInstance
{
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}",
};
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" };
var group = new TagGroup { TagGroupId = "g1", DeviceId = "dev-1", Name = "Motors" };
var speed = new Tag
{
TagId = "t-speed", DeviceId = "dev-1", TagGroupId = "g1", Name = "Speed",
DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite,
TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"speed_hist\"," +
"\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":700}}",
};
var status = new Tag
{
TagId = "t-status", DeviceId = "dev-1", Name = "Status",
DataType = "Boolean", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
};
var levels = new Tag
{
TagId = "t-levels", DeviceId = "dev-1", Name = "Levels",
DataType = "Int32", AccessLevel = TagAccessLevel.Read,
TagConfig = "{\"isArray\":true,\"arrayLength\":8}",
};
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" };
var refSpeed = new UnsTagReference { UnsTagReferenceId = "ref-speed", EquipmentId = "eq-1", TagId = "t-speed" };
var refStatus = new UnsTagReference
{
UnsTagReferenceId = "ref-status", EquipmentId = "eq-1", TagId = "t-status",
DisplayNameOverride = "MachineStatus",
};
return AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(),
unsTagReferences: new[] { refSpeed, refStatus },
tags: new[] { speed, status, levels },
rawFolders: new[] { folder },
devices: new[] { device },
tagGroups: new[] { group });
}
/// <summary>The Raw subtree emits Folder/Driver/Device/TagGroup container nodes keyed by RawPath, each
/// realm=Raw, with parents-before-children ordering + correct parent NodeIds.</summary>
[Fact]
public void Raw_container_nodes_materialise_with_rawpath_nodeids_and_parents()
{
var c = ComposeFullFixture();
c.RawContainers.ShouldAllBe(n => n.Realm == AddressSpaceRealm.Raw);
var folder = c.RawContainers.Single(n => n.Kind == RawNodeKind.Folder);
folder.NodeId.ShouldBe("Plant");
folder.ParentNodeId.ShouldBeNull(); // cluster root
folder.DisplayName.ShouldBe("Plant");
var driver = c.RawContainers.Single(n => n.Kind == RawNodeKind.Driver);
driver.NodeId.ShouldBe("Plant/Modbus1");
driver.ParentNodeId.ShouldBe("Plant");
var device = c.RawContainers.Single(n => n.Kind == RawNodeKind.Device);
device.NodeId.ShouldBe("Plant/Modbus1/PLC-A");
device.ParentNodeId.ShouldBe("Plant/Modbus1");
var group = c.RawContainers.Single(n => n.Kind == RawNodeKind.TagGroup);
group.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors");
group.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A");
// Sorted by NodeId ordinal ⇒ a parent always precedes each of its children.
c.RawContainers.Select(n => n.NodeId)
.ShouldBe(new[] { "Plant", "Plant/Modbus1", "Plant/Modbus1/PLC-A", "Plant/Modbus1/PLC-A/Motors" });
}
/// <summary>Raw tags are Variables keyed <c>s=&lt;RawPath&gt;</c> (realm=Raw), carrying DataType /
/// writable / historize + historian tagname / array shape, and hang under their group (or device).</summary>
[Fact]
public void Raw_tags_materialise_as_variables_keyed_by_rawpath_with_carried_attributes()
{
var c = ComposeFullFixture();
c.RawTags.ShouldAllBe(t => t.Realm == AddressSpaceRealm.Raw);
var speed = c.RawTags.Single(t => t.TagId == "t-speed");
speed.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors/Speed");
speed.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors");
speed.DriverInstanceId.ShouldBe("drv-modbus");
speed.DataType.ShouldBe("Float");
speed.Writable.ShouldBeTrue();
speed.IsHistorized.ShouldBeTrue();
speed.HistorianTagname.ShouldBe("speed_hist");
var status = c.RawTags.Single(t => t.TagId == "t-status");
status.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Status"); // no group ⇒ under the device
status.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A");
status.Writable.ShouldBeFalse();
status.IsHistorized.ShouldBeFalse();
var levels = c.RawTags.Single(t => t.TagId == "t-levels");
levels.IsArray.ShouldBeTrue();
levels.ArrayLength.ShouldBe(8u);
}
/// <summary>A native-alarm intent attaches at the RAW tag (ConditionId = RawPath) and carries the list
/// of referencing equipment UNS folder paths — one alarm at the raw tag, not one per equipment.</summary>
[Fact]
public void Native_alarm_attaches_at_raw_tag_with_referencing_equipment_paths()
{
var c = ComposeFullFixture();
var speed = c.RawTags.Single(t => t.TagId == "t-speed");
speed.Alarm.ShouldNotBeNull();
speed.Alarm!.AlarmType.ShouldBe("LimitAlarm");
speed.Alarm.Severity.ShouldBe(700);
speed.ReferencingEquipmentPaths.ShouldBe(new[] { "filling/line-1/station-1" });
// A non-alarm tag carries no condition + its own referencing-equipment list.
var status = c.RawTags.Single(t => t.TagId == "t-status");
status.Alarm.ShouldBeNull();
status.ReferencingEquipmentPaths.ShouldBe(new[] { "filling/line-1/station-1" });
// An unreferenced tag has an empty referencing-equipment set.
var levels = c.RawTags.Single(t => t.TagId == "t-levels");
levels.ReferencingEquipmentPaths.ShouldBeEmpty();
}
/// <summary>Each UnsTagReference emits a UNS Variable keyed
/// <c>s=&lt;Area&gt;/&lt;Line&gt;/&lt;Equipment&gt;/&lt;EffectiveName&gt;</c> (realm=Uns), carrying its backing
/// RawPath (the Organizes target + fan-out) and inheriting DataType + writable from the raw tag. Effective
/// name = DisplayNameOverride else the backing raw tag's Name.</summary>
[Fact]
public void Uns_reference_variables_carry_backing_rawpath_and_inherit_type_and_access()
{
var c = ComposeFullFixture();
c.UnsReferenceVariables.ShouldAllBe(v => v.Realm == AddressSpaceRealm.Uns);
var speedRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-speed");
speedRef.EffectiveName.ShouldBe("Speed"); // no override ⇒ backing raw tag Name
speedRef.NodeId.ShouldBe("filling/line-1/station-1/Speed");
speedRef.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Motors/Speed"); // Organizes UNS→Raw + fan-out
speedRef.DataType.ShouldBe("Float");
speedRef.Writable.ShouldBeTrue(); // inherited from the ReadWrite raw tag
var statusRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-status");
statusRef.EffectiveName.ShouldBe("MachineStatus"); // display-name override wins
statusRef.NodeId.ShouldBe("filling/line-1/station-1/MachineStatus");
statusRef.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Status");
statusRef.DataType.ShouldBe("Boolean");
statusRef.Writable.ShouldBeFalse(); // inherited from the Read raw tag
}
/// <summary>A composition with no raw/UNS inputs leaves all three new subtree sets empty (the legacy
/// convenience overloads + earlier callers keep working).</summary>
[Fact]
public void No_raw_or_uns_inputs_leaves_both_subtrees_empty()
{
var c = AddressSpaceComposer.Compose(
equipment: Array.Empty<Equipment>(),
driverInstances: Array.Empty<DriverInstance>(),
scriptedAlarms: Array.Empty<ScriptedAlarm>());
c.RawContainers.ShouldBeEmpty();
c.RawTags.ShouldBeEmpty();
c.UnsReferenceVariables.ShouldBeEmpty();
}
/// <summary>The Raw + UNS emit is deterministic — repeated calls produce element-identical output
/// (RawTagPlan/UnsReferenceVariable compare by value, so ShouldBe is a content comparison).</summary>
[Fact]
public void Dual_subtree_emit_is_deterministic()
{
var a = ComposeFullFixture();
var b = ComposeFullFixture();
a.RawContainers.ShouldBe(b.RawContainers);
a.RawTags.ShouldBe(b.RawTags);
a.UnsReferenceVariables.ShouldBe(b.UnsReferenceVariables);
}
}
@@ -0,0 +1,234 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// v3 Batch 4 (B4-WP1) — the planner diffs per realm. Raw containers + raw tags diff by RawPath
/// (NodeId), so a rename is remove(old)+add(new); UNS reference variables diff by the stable
/// <c>UnsTagReferenceId</c>, so a backing-tag rename is a re-point (BackingRawPath moves, NodeId stable)
/// and a display-name-override edit is a UNS-only change with no raw delta.
/// <para><b>The pinned invariant:</b> a raw-tag rename = remove+add in Raw AND re-point in UNS.</para>
/// </summary>
public sealed class AddressSpacePlannerDualNamespaceTests
{
private const string RawFolderName = "Plant";
private const string DriverName = "Modbus1";
private const string DeviceName = "PLC-A";
// Compose a fixture with one raw tag (given name) under Plant/Modbus1/PLC-A and one UNS reference to it
// (with an optional display-name override so the effective name can be held stable across a raw rename).
private static AddressSpaceComposition Compose(string tagName, string? displayOverride)
{
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = RawFolderName };
var driver = new DriverInstance
{
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
Name = DriverName, DriverType = "Modbus", DriverConfig = "{}",
};
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = DeviceName, DeviceConfig = "{}" };
var tag = new Tag
{
TagId = "t-speed", DeviceId = "dev-1", Name = tagName,
DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}",
};
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" };
var reference = new UnsTagReference
{
UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "t-speed",
DisplayNameOverride = displayOverride,
};
return AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(),
unsTagReferences: new[] { reference },
tags: new[] { tag },
rawFolders: new[] { folder },
devices: new[] { device });
}
/// <summary>A newly-added raw tag surfaces in AddedRawTags (keyed by RawPath); the plan is non-empty.</summary>
[Fact]
public void Added_raw_tag_goes_to_AddedRawTags()
{
var prev = Compose("Speed", displayOverride: null);
var next = Compose("Speed", displayOverride: null);
// prev without the tag: strip RawTags/UNS by composing an empty raw side.
var empty = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>());
var plan = AddressSpacePlanner.Compute(empty, next);
plan.IsEmpty.ShouldBeFalse();
plan.AddedRawTags.Select(t => t.NodeId).ShouldContain("Plant/Modbus1/PLC-A/Speed");
plan.RemovedRawTags.ShouldBeEmpty();
_ = prev;
}
/// <summary>A disappeared raw tag surfaces in RemovedRawTags.</summary>
[Fact]
public void Removed_raw_tag_goes_to_RemovedRawTags()
{
var prev = Compose("Speed", displayOverride: null);
var empty = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>());
var plan = AddressSpacePlanner.Compute(prev, empty);
plan.RemovedRawTags.Select(t => t.NodeId).ShouldContain("Plant/Modbus1/PLC-A/Speed");
plan.AddedRawTags.ShouldBeEmpty();
}
/// <summary>A newly-added UNS reference surfaces in AddedUnsReferenceVariables.</summary>
[Fact]
public void Added_uns_reference_goes_to_AddedUnsReferenceVariables()
{
var empty = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>());
var next = Compose("Speed", displayOverride: null);
var plan = AddressSpacePlanner.Compute(empty, next);
plan.AddedUnsReferenceVariables.Single().UnsTagReferenceId.ShouldBe("ref-1");
plan.RemovedUnsReferenceVariables.ShouldBeEmpty();
}
/// <summary>A disappeared UNS reference surfaces in RemovedUnsReferenceVariables.</summary>
[Fact]
public void Removed_uns_reference_goes_to_RemovedUnsReferenceVariables()
{
var prev = Compose("Speed", displayOverride: null);
var empty = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>());
var plan = AddressSpacePlanner.Compute(prev, empty);
plan.RemovedUnsReferenceVariables.Single().UnsTagReferenceId.ShouldBe("ref-1");
plan.AddedUnsReferenceVariables.ShouldBeEmpty();
}
/// <summary><b>THE PIN:</b> a raw-tag rename = remove+add in Raw AND re-point in UNS. With a display-name
/// override holding the effective name stable, the reference row is unchanged (same UnsTagReferenceId,
/// same UNS NodeId) but its backing NodeId moved ⇒ the UNS variable's Organizes target (BackingRawPath)
/// re-points via a ChangedUnsReferenceVariables delta.</summary>
[Fact]
public void Raw_rename_is_remove_add_in_raw_and_repoint_in_uns()
{
var prev = Compose("Speed", displayOverride: "Spd");
var next = Compose("Velocity", displayOverride: "Spd"); // raw tag renamed; effective name held stable
var plan = AddressSpacePlanner.Compute(prev, next);
// Raw realm: remove the old RawPath, add the new one (immutable NodeId ⇒ not a Changed delta).
plan.RemovedRawTags.Select(t => t.NodeId).ShouldBe(new[] { "Plant/Modbus1/PLC-A/Speed" });
plan.AddedRawTags.Select(t => t.NodeId).ShouldBe(new[] { "Plant/Modbus1/PLC-A/Velocity" });
plan.ChangedRawTags.ShouldBeEmpty();
// UNS realm: the reference row is unchanged in identity + NodeId, but re-points to the new RawPath.
plan.AddedUnsReferenceVariables.ShouldBeEmpty();
plan.RemovedUnsReferenceVariables.ShouldBeEmpty();
var repoint = plan.ChangedUnsReferenceVariables.ShouldHaveSingleItem();
repoint.Previous.UnsTagReferenceId.ShouldBe("ref-1");
repoint.Current.UnsTagReferenceId.ShouldBe("ref-1");
repoint.Previous.NodeId.ShouldBe(repoint.Current.NodeId); // effective name stable ⇒ NodeId stable
repoint.Previous.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Speed");
repoint.Current.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Velocity");
// The raw container topology (Folder/Driver/Device) is untouched by a tag rename.
plan.AddedRawContainers.ShouldBeEmpty();
plan.RemovedRawContainers.ShouldBeEmpty();
plan.ChangedRawContainers.ShouldBeEmpty();
}
/// <summary>A display-name-override change is a UNS-only change (no raw delta): same reference row id,
/// the effective name + UNS NodeId move, the backing RawPath is unchanged.</summary>
[Fact]
public void Display_name_override_change_is_uns_only_no_raw_change()
{
var prev = Compose("Speed", displayOverride: "Spd");
var next = Compose("Speed", displayOverride: "Speedy"); // only the override changed
var plan = AddressSpacePlanner.Compute(prev, next);
// No raw-side change whatsoever.
plan.AddedRawTags.ShouldBeEmpty();
plan.RemovedRawTags.ShouldBeEmpty();
plan.ChangedRawTags.ShouldBeEmpty();
plan.AddedRawContainers.ShouldBeEmpty();
plan.RemovedRawContainers.ShouldBeEmpty();
plan.ChangedRawContainers.ShouldBeEmpty();
// UNS: same reference row id, effective name + NodeId changed, backing RawPath unchanged.
var delta = plan.ChangedUnsReferenceVariables.ShouldHaveSingleItem();
delta.Previous.UnsTagReferenceId.ShouldBe("ref-1");
delta.Previous.EffectiveName.ShouldBe("Spd");
delta.Current.EffectiveName.ShouldBe("Speedy");
delta.Previous.NodeId.ShouldNotBe(delta.Current.NodeId);
delta.Previous.BackingRawPath.ShouldBe(delta.Current.BackingRawPath);
plan.AddedUnsReferenceVariables.ShouldBeEmpty();
plan.RemovedUnsReferenceVariables.ShouldBeEmpty();
}
/// <summary>An attribute-only raw-tag edit (historize toggle) keeps the same RawPath ⇒ a
/// ChangedRawTags delta (not remove+add).</summary>
[Fact]
public void Raw_tag_attribute_edit_keeps_nodeid_and_routes_to_ChangedRawTags()
{
var prev = Compose("Speed", displayOverride: null);
// next: same identity + name, but toggle historize on the raw tag.
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = RawFolderName };
var driver = new DriverInstance
{
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
Name = DriverName, DriverType = "Modbus", DriverConfig = "{}",
};
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = DeviceName, DeviceConfig = "{}" };
var tag = new Tag
{
TagId = "t-speed", DeviceId = "dev-1", Name = "Speed",
DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"isHistorized\":true}",
};
var next = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
new[] { driver }, Array.Empty<ScriptedAlarm>(),
tags: new[] { tag }, rawFolders: new[] { folder }, devices: new[] { device });
var prevRawOnly = AddressSpaceComposer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
new[] { driver }, Array.Empty<ScriptedAlarm>(),
tags: new[] { new Tag { TagId = "t-speed", DeviceId = "dev-1", Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}" } },
rawFolders: new[] { folder }, devices: new[] { device });
var plan = AddressSpacePlanner.Compute(prevRawOnly, next);
plan.AddedRawTags.ShouldBeEmpty();
plan.RemovedRawTags.ShouldBeEmpty();
var changed = plan.ChangedRawTags.ShouldHaveSingleItem();
changed.Previous.NodeId.ShouldBe(changed.Current.NodeId);
changed.Previous.IsHistorized.ShouldBeFalse();
changed.Current.IsHistorized.ShouldBeTrue();
_ = prev;
}
/// <summary>Identical compositions diff to an empty plan across both realms (fresh list instances must
/// not spuriously flag RawTags/UnsReferenceVariables as changed).</summary>
[Fact]
public void Identical_dual_namespace_compositions_diff_to_empty()
{
var prev = Compose("Speed", displayOverride: "Spd");
var next = Compose("Speed", displayOverride: "Spd");
var plan = AddressSpacePlanner.Compute(prev, next);
plan.IsEmpty.ShouldBeTrue();
}
}
@@ -34,8 +34,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-1");
condition.ShouldNotBeNull();
condition!.OnAcknowledge.ShouldNotBeNull();
@@ -66,8 +66,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2");
nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-2", "eq-2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-2");
condition.ShouldNotBeNull();
@@ -91,8 +91,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3");
nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-3", "eq-3", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-3");
condition.ShouldNotBeNull();
@@ -115,8 +115,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C");
nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-c", parentNodeId: null, displayName: "Equipment C", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-c", "eq-c", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-c");
condition.ShouldNotBeNull();
condition!.OnConfirm.ShouldNotBeNull();
@@ -142,8 +142,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC");
nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-ac", parentNodeId: null, displayName: "Equipment AC", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-ac", "eq-ac", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ac");
condition.ShouldNotBeNull();
condition!.OnAddComment.ShouldNotBeNull();
@@ -168,8 +168,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1");
nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-s1", parentNodeId: null, displayName: "Equipment S1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-s1", "eq-s1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-s1");
condition.ShouldNotBeNull();
condition!.OnShelve.ShouldNotBeNull();
@@ -196,8 +196,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2");
nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-s2", parentNodeId: null, displayName: "Equipment S2", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-s2", "eq-s2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-s2");
condition.ShouldNotBeNull();
@@ -228,8 +228,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3");
nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-s3", parentNodeId: null, displayName: "Equipment S3", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-s3", "eq-s3", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-s3");
condition.ShouldNotBeNull();
@@ -254,8 +254,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4");
nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-s4", parentNodeId: null, displayName: "Equipment S4", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-s4", "eq-s4", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-s4");
condition.ShouldNotBeNull();
@@ -287,8 +287,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU");
nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-tu", parentNodeId: null, displayName: "Equipment TU", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-tu", "eq-tu", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-tu");
condition.ShouldNotBeNull();
condition!.OnTimedUnshelve.ShouldNotBeNull();
@@ -322,8 +322,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ed1", parentNodeId: null, displayName: "Equipment ED1");
nm.MaterialiseAlarmCondition("alm-ed1", "eq-ed1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
nm.EnsureFolder("eq-ed1", parentNodeId: null, displayName: "Equipment ED1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-ed1", "eq-ed1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ed1");
condition.ShouldNotBeNull();
condition!.OnEnableDisable.ShouldNotBeNull();
@@ -351,8 +351,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ed2", parentNodeId: null, displayName: "Equipment ED2");
nm.MaterialiseAlarmCondition("alm-ed2", "eq-ed2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
nm.EnsureFolder("eq-ed2", parentNodeId: null, displayName: "Equipment ED2", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-ed2", "eq-ed2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ed2");
condition.ShouldNotBeNull();
condition!.OnEnableDisable.ShouldNotBeNull();
@@ -379,8 +379,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ed3", parentNodeId: null, displayName: "Equipment ED3");
nm.MaterialiseAlarmCondition("alm-ed3", "eq-ed3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
nm.EnsureFolder("eq-ed3", parentNodeId: null, displayName: "Equipment ED3", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-ed3", "eq-ed3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ed3");
condition.ShouldNotBeNull();
@@ -405,8 +405,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var captured = new List<AlarmCommand>();
nm.AlarmCommandRouter = captured.Add;
nm.EnsureFolder("eq-ed4", parentNodeId: null, displayName: "Equipment ED4");
nm.MaterialiseAlarmCondition("alm-ed4", "eq-ed4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
nm.EnsureFolder("eq-ed4", parentNodeId: null, displayName: "Equipment ED4", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-ed4", "eq-ed4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ed4");
condition.ShouldNotBeNull();
condition!.OnEnableDisable.ShouldNotBeNull();
@@ -436,8 +436,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
nm.AlarmCommandRouter = scripted.Add;
nm.NativeAlarmAckRouter = native.Add;
nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1");
nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
nm.EnsureFolder("eq-nak1", parentNodeId: null, displayName: "Equipment NAK1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-nak1", "eq-nak1", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-nak1");
condition.ShouldNotBeNull();
condition!.OnAcknowledge.ShouldNotBeNull();
@@ -469,8 +469,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
nm.AlarmCommandRouter = scripted.Add;
nm.NativeAlarmAckRouter = native.Add;
nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2");
nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false);
nm.EnsureFolder("eq-nak2", parentNodeId: null, displayName: "Equipment NAK2", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-nak2", "eq-nak2", "HighTemp", "OffNormalAlarm", severity: 700, isNative: false, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-nak2");
condition.ShouldNotBeNull();
condition!.OnAcknowledge.ShouldNotBeNull();
@@ -503,8 +503,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
nm.AlarmCommandRouter = scripted.Add;
nm.NativeAlarmAckRouter = native.Add;
nm.EnsureFolder("eq-nak4", parentNodeId: null, displayName: "Equipment NAK4");
nm.MaterialiseAlarmCondition("alm-nak4", "eq-nak4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
nm.EnsureFolder("eq-nak4", parentNodeId: null, displayName: "Equipment NAK4", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-nak4", "eq-nak4", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-nak4");
condition.ShouldNotBeNull();
@@ -531,8 +531,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
nm.AlarmCommandRouter = scripted.Add;
nm.NativeAlarmAckRouter = native.Add;
nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3");
nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true);
nm.EnsureFolder("eq-nak3", parentNodeId: null, displayName: "Equipment NAK3", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-nak3", "eq-nak3", "HighTemp", "OffNormalAlarm", severity: 700, isNative: true, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-nak3");
condition.ShouldNotBeNull();
@@ -555,8 +555,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var nm = server.NodeManager!;
// No router set (default null).
nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR");
nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder("eq-nr", parentNodeId: null, displayName: "Equipment NR", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-nr", "eq-nr", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-nr");
condition.ShouldNotBeNull();
@@ -576,8 +576,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true);
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a1").ShouldBeTrue();
@@ -591,8 +591,8 @@ public sealed class AlarmCommandRouterTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("a2", "eq", "d", "OffNormalAlarm", 700, isNative: false);
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a2", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a2").ShouldBeFalse();
@@ -611,15 +611,15 @@ public sealed class AlarmCommandRouterTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true);
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a1").ShouldBeTrue();
// RebuildAddressSpace clears the folder set too, so the equipment folder must be re-ensured
// before the same id can be re-materialised (ResolveParentFolder needs the parent back).
nm.RebuildAddressSpace();
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false);
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a1").ShouldBeFalse();
@@ -636,13 +636,13 @@ public sealed class AlarmCommandRouterTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false);
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a1").ShouldBeFalse();
nm.RebuildAddressSpace();
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true);
nm.EnsureFolder("eq", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("a1", "eq", "d", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
nm.IsNativeAlarmNode("a1").ShouldBeTrue();
@@ -14,8 +14,8 @@ public sealed class DeferredAddressSpaceSinkTests
var deferred = new DeferredAddressSpaceSink();
// No throw, no observable side effect.
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
deferred.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow);
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.RebuildAddressSpace();
}
@@ -27,10 +27,10 @@ public sealed class DeferredAddressSpaceSinkTests
var inner = new RecordingSink();
deferred.SetSink(inner);
deferred.WriteValue("x", 42, OpcUaQuality.Good, DateTime.UtcNow);
deferred.WriteAlarmCondition("a-1", Snapshot(active: true), DateTime.UtcNow);
deferred.WriteValue("x", 42, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.WriteAlarmCondition("a-1", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.RebuildAddressSpace();
deferred.RaiseNodesAddedModelChange("eq-1");
deferred.RaiseNodesAddedModelChange("eq-1", AddressSpaceRealm.Uns);
inner.Calls.ShouldBe(new[] { "WV:x", "WA:a-1", "RB", "NA:eq-1" });
}
@@ -42,11 +42,11 @@ public sealed class DeferredAddressSpaceSinkTests
var deferred = new DeferredAddressSpaceSink();
var inner = new RecordingSink();
deferred.SetSink(inner);
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
deferred.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
inner.Calls.Count.ShouldBe(1);
deferred.SetSink(null);
deferred.WriteValue("y", 2, OpcUaQuality.Good, DateTime.UtcNow); // dropped
deferred.WriteValue("y", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); // dropped
inner.Calls.Count.ShouldBe(1);
}
@@ -59,10 +59,10 @@ public sealed class DeferredAddressSpaceSinkTests
var second = new RecordingSink();
deferred.SetSink(first);
deferred.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow);
deferred.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
deferred.SetSink(second);
deferred.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow);
deferred.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
first.Calls.Single().ShouldBe("WV:a");
second.Calls.Single().ShouldBe("WV:b");
@@ -77,7 +77,7 @@ public sealed class DeferredAddressSpaceSinkTests
var inner = new RecordingSink();
deferred.SetSink(inner);
deferred.EnsureVariable("v-1", null, "MyVar", "Float", writable: false, historianTagname: "MyTag.PV");
deferred.EnsureVariable("v-1", null, "MyVar", "Float", writable: false, historianTagname: "MyTag.PV", realm: AddressSpaceRealm.Uns);
var call = inner.HistorianCalls.ShouldHaveSingleItem();
call.NodeId.ShouldBe("v-1");
@@ -96,7 +96,7 @@ public sealed class DeferredAddressSpaceSinkTests
deferred.SetSink(inner);
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: "MyTag.PV",
dataType: "Int32", isArray: true, arrayLength: 8u)
dataType: "Int32", isArray: true, arrayLength: 8u, AddressSpaceRealm.Uns)
.ShouldBeTrue();
var call = inner.SurgicalCalls.ShouldHaveSingleItem();
@@ -119,7 +119,7 @@ public sealed class DeferredAddressSpaceSinkTests
deferred.SetSink(new SurgicalRecordingSink { Result = false });
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: false, historianTagname: null,
dataType: "Float", isArray: false, arrayLength: null)
dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns)
.ShouldBeFalse();
}
@@ -131,12 +131,12 @@ public sealed class DeferredAddressSpaceSinkTests
{
var deferred = new DeferredAddressSpaceSink(); // default inner = null sink (not surgical)
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null,
dataType: "Float", isArray: false, arrayLength: null)
dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns)
.ShouldBeFalse();
deferred.SetSink(new RecordingSink()); // a non-surgical inner
((ISurgicalAddressSpaceSink)deferred).UpdateTagAttributes("v-1", writable: true, historianTagname: null,
dataType: "Float", isArray: false, arrayLength: null)
dataType: "Float", isArray: false, arrayLength: null, AddressSpaceRealm.Uns)
.ShouldBeFalse();
}
@@ -151,7 +151,7 @@ public sealed class DeferredAddressSpaceSinkTests
var inner = new SurgicalRecordingSink { Result = true };
deferred.SetSink(inner);
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "Plant South")
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "Plant South", AddressSpaceRealm.Uns)
.ShouldBeTrue();
var call = inner.FolderRenameCalls.ShouldHaveSingleItem();
@@ -167,11 +167,11 @@ public sealed class DeferredAddressSpaceSinkTests
var deferred = new DeferredAddressSpaceSink();
// Inner reports the folder missing ⇒ forward returns false.
deferred.SetSink(new SurgicalRecordingSink { Result = false });
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X").ShouldBeFalse();
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X", AddressSpaceRealm.Uns).ShouldBeFalse();
// Non-surgical inner (the null sink before swap-in) ⇒ false.
deferred.SetSink(null);
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X").ShouldBeFalse();
((ISurgicalAddressSpaceSink)deferred).UpdateFolderDisplayName("area-1", "X", AddressSpaceRealm.Uns).ShouldBeFalse();
}
/// <summary>R2-07 Phase 2 — the three surgical remove members forward to a surgical inner with
@@ -184,9 +184,9 @@ public sealed class DeferredAddressSpaceSinkTests
var inner = new SurgicalRecordingSink { Result = true };
deferred.SetSink(inner);
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("eq-1/A").ShouldBeTrue();
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("alm-1").ShouldBeTrue();
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeTrue();
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("eq-1/A", AddressSpaceRealm.Uns).ShouldBeTrue();
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("alm-1", AddressSpaceRealm.Uns).ShouldBeTrue();
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeTrue();
inner.RemoveCalls.ShouldBe(new[] { ("var", "eq-1/A"), ("alarm", "alm-1"), ("equipment", "eq-1") });
}
@@ -198,14 +198,14 @@ public sealed class DeferredAddressSpaceSinkTests
{
var deferred = new DeferredAddressSpaceSink();
deferred.SetSink(new SurgicalRecordingSink { Result = false });
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1").ShouldBeFalse();
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1").ShouldBeFalse();
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
deferred.SetSink(null);
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1").ShouldBeFalse();
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1").ShouldBeFalse();
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1").ShouldBeFalse();
((ISurgicalAddressSpaceSink)deferred).RemoveVariableNode("v-1", AddressSpaceRealm.Uns).ShouldBeFalse();
((ISurgicalAddressSpaceSink)deferred).RemoveAlarmConditionNode("a-1", AddressSpaceRealm.Uns).ShouldBeFalse();
((ISurgicalAddressSpaceSink)deferred).RemoveEquipmentSubtree("eq-1", AddressSpaceRealm.Uns).ShouldBeFalse();
}
/// <summary>Builds a minimal <see cref="AlarmConditionSnapshot"/> for the forwarding tests (the
@@ -228,19 +228,19 @@ public sealed class DeferredAddressSpaceSinkTests
public List<(string NodeId, string? HistorianTagname)> HistorianCalls => HistorianQueue.ToList();
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> CallQueue.Enqueue($"WV:{nodeId}");
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> CallQueue.Enqueue($"WA:{alarmNodeId}");
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> CallQueue.Enqueue($"MA:{alarmNodeId}");
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> CallQueue.Enqueue($"EF:{folderNodeId}");
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
{
CallQueue.Enqueue($"EV:{variableNodeId}");
HistorianQueue.Enqueue((variableNodeId, historianTagname));
@@ -248,7 +248,9 @@ public sealed class DeferredAddressSpaceSinkTests
/// <inheritdoc />
public void RebuildAddressSpace() => CallQueue.Enqueue("RB");
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId) => CallQueue.Enqueue($"NA:{affectedNodeId}");
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => CallQueue.Enqueue($"NA:{affectedNodeId}");
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
private sealed class SurgicalRecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
@@ -261,14 +263,14 @@ public sealed class DeferredAddressSpaceSinkTests
public List<(string FolderNodeId, string DisplayName)> FolderRenameCalls { get; } = new();
/// <inheritdoc />
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
SurgicalCalls.Add((variableNodeId, writable, historianTagname, dataType, isArray, arrayLength));
return Result;
}
/// <inheritdoc />
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
FolderRenameCalls.Add((folderNodeId, displayName));
return Result;
@@ -278,25 +280,27 @@ public sealed class DeferredAddressSpaceSinkTests
public List<(string Kind, string NodeId)> RemoveCalls { get; } = new();
/// <inheritdoc />
public bool RemoveVariableNode(string variableNodeId) { RemoveCalls.Add(("var", variableNodeId)); return Result; }
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveCalls.Add(("var", variableNodeId)); return Result; }
/// <inheritdoc />
public bool RemoveAlarmConditionNode(string alarmNodeId) { RemoveCalls.Add(("alarm", alarmNodeId)); return Result; }
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveCalls.Add(("alarm", alarmNodeId)); return Result; }
/// <inheritdoc />
public bool RemoveEquipmentSubtree(string equipmentNodeId) { RemoveCalls.Add(("equipment", equipmentNodeId)); return Result; }
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { RemoveCalls.Add(("equipment", equipmentNodeId)); return Result; }
/// <inheritdoc />
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <inheritdoc />
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
/// <inheritdoc />
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <inheritdoc />
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// <inheritdoc />
public void RebuildAddressSpace() { }
/// <inheritdoc />
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
}
@@ -1,4 +1,5 @@
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
@@ -27,14 +28,14 @@ public sealed class NodeManagerAlarmIdempotentMaterialiseTests : IDisposable
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
var first = nm.TryGetAlarmCondition("alm-1");
first.ShouldNotBeNull();
// Same id + same kind ⇒ skip-if-present: the existing instance is kept.
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
var second = nm.TryGetAlarmCondition("alm-1");
second.ShouldBeSameAs(first);
@@ -50,14 +51,14 @@ public sealed class NodeManagerAlarmIdempotentMaterialiseTests : IDisposable
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
var scripted = nm.TryGetAlarmCondition("alm-1");
nm.IsNativeAlarmNode("alm-1").ShouldBeFalse();
// Same id but the OTHER kind ⇒ recreate (a different instance) and the native flag is now set.
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: true);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
var native = nm.TryGetAlarmCondition("alm-1");
native.ShouldNotBeSameAs(scripted);
@@ -75,16 +76,16 @@ public sealed class NodeManagerAlarmIdempotentMaterialiseTests : IDisposable
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
var before = nm.TryGetAlarmCondition("alm-1");
nm.RebuildAddressSpace();
nm.TryGetAlarmCondition("alm-1").ShouldBeNull();
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false);
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, isNative: false, realm: AddressSpaceRealm.Uns);
var after = nm.TryGetAlarmCondition("alm-1");
after.ShouldNotBeNull();
@@ -32,7 +32,7 @@ public sealed class NodeManagerArrayTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/arr", parentFolderNodeId: null, displayName: "arr", dataType: "Int32",
writable: false, historianTagname: null, isArray: true, arrayLength: 8);
writable: false, historianTagname: null, isArray: true, arrayLength: 8, realm: AddressSpaceRealm.Uns);
var variable = nm.TryGetVariable("eq-1/arr");
variable.ShouldNotBeNull();
@@ -52,7 +52,7 @@ public sealed class NodeManagerArrayTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/arr-unfixed", parentFolderNodeId: null, displayName: "arr-unfixed", dataType: "Int32",
writable: false, historianTagname: null, isArray: true, arrayLength: null);
writable: false, historianTagname: null, isArray: true, arrayLength: null, realm: AddressSpaceRealm.Uns);
var variable = nm.TryGetVariable("eq-1/arr-unfixed");
variable.ShouldNotBeNull();
@@ -72,7 +72,7 @@ public sealed class NodeManagerArrayTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/scalar", parentFolderNodeId: null, displayName: "scalar", dataType: "Int32",
writable: false);
writable: false, realm: AddressSpaceRealm.Uns);
var variable = nm.TryGetVariable("eq-1/scalar");
variable.ShouldNotBeNull();
@@ -91,10 +91,10 @@ public sealed class NodeManagerArrayTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/arrwrite", parentFolderNodeId: null, displayName: "arrwrite", dataType: "Int32",
writable: false, historianTagname: null, isArray: true, arrayLength: 3);
writable: false, historianTagname: null, isArray: true, arrayLength: 3, realm: AddressSpaceRealm.Uns);
var payload = new[] { 1, 2, 3 };
nm.WriteValue("eq-1/arrwrite", payload, OpcUaQuality.Good, DateTime.UtcNow);
nm.WriteValue("eq-1/arrwrite", payload, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var variable = nm.TryGetVariable("eq-1/arrwrite");
variable.ShouldNotBeNull();
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Opc.Ua;
using Shouldly;
using Xunit;
@@ -29,7 +30,7 @@ public sealed class NodeManagerHistorizeTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
writable: false, historianTagname: "WW.Tag");
writable: false, historianTagname: "WW.Tag", realm: AddressSpaceRealm.Uns);
var variable = nm.TryGetVariable("eq-1/temp");
variable.ShouldNotBeNull();
@@ -56,9 +57,9 @@ public sealed class NodeManagerHistorizeTests : IDisposable
// Explicit null and the defaulted-param form both mean "not historized".
nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32",
writable: false, historianTagname: null);
writable: false, historianTagname: null, realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/plain2", parentFolderNodeId: null, displayName: "Plain2", dataType: "Int32",
writable: false);
writable: false, realm: AddressSpaceRealm.Uns);
foreach (var nodeId in new[] { "eq-1/plain", "eq-1/plain2" })
{
@@ -83,7 +84,7 @@ public sealed class NodeManagerHistorizeTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/setpoint", parentFolderNodeId: null, displayName: "Setpoint", dataType: "Float",
writable: true, historianTagname: "WW.Setpoint");
writable: true, historianTagname: "WW.Setpoint", realm: AddressSpaceRealm.Uns);
var variable = nm.TryGetVariable("eq-1/setpoint");
variable.ShouldNotBeNull();
@@ -109,7 +110,7 @@ public sealed class NodeManagerHistorizeTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
writable: false, historianTagname: "WW.Tag");
writable: false, historianTagname: "WW.Tag", realm: AddressSpaceRealm.Uns);
nm.TryGetHistorizedTagname("eq-1/temp", out _).ShouldBeTrue();
nm.RebuildAddressSpace();
@@ -1,4 +1,5 @@
using System.Diagnostics;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Server;
@@ -228,7 +229,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
{
var key = $"eq/tag{i}";
nm.EnsureVariable(key, parentFolderNodeId: null, displayName: $"Tag{i}", dataType: "Float",
writable: false, historianTagname: $"WW.Tag{i}");
writable: false, historianTagname: $"WW.Tag{i}", realm: AddressSpaceRealm.Uns);
ids[i] = nm.TryGetVariable(key)!.NodeId;
}
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
@@ -42,8 +43,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
nm.HistorianDataSource = fake;
const string equipmentId = "eq-evt";
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("alarm-1", equipmentId, "HighTemp", "OffNormalAlarm", severity: 700);
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alarm-1", equipmentId, "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
var evtTime = new DateTime(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc);
@@ -107,8 +108,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
nm.HistorianDataSource = fake;
const string equipmentId = "eq-unbounded";
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("alarm-0", equipmentId, "Cond", "OffNormalAlarm", severity: 600);
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alarm-0", equipmentId, "Cond", "OffNormalAlarm", severity: 600, realm: AddressSpaceRealm.Uns);
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
fake.EventsResult = new HistoricalEventsResult(
@@ -143,8 +144,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
nm.HistorianDataSource = fake;
const string equipmentId = "eq-unsupported";
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("alarm-2", equipmentId, "Cond", "OffNormalAlarm", severity: 500);
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alarm-2", equipmentId, "Cond", "OffNormalAlarm", severity: 500, realm: AddressSpaceRealm.Uns);
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
fake.EventsResult = new HistoricalEventsResult(
@@ -187,8 +188,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
nm.HistorianDataSource = fake;
const string equipmentId = "eq-empty";
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("alarm-3", equipmentId, "Cond", "OffNormalAlarm", severity: 300);
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alarm-3", equipmentId, "Cond", "OffNormalAlarm", severity: 300, realm: AddressSpaceRealm.Uns);
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
var details = new ReadEventDetails
@@ -221,8 +222,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
// Materialise the alarm while the source is still the Null default — the folder is promoted to
// SubscribeToEvents but DOES NOT get the HistoryRead bit / source registration.
const string equipmentId = "eq-nosrc";
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200);
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200, realm: AddressSpaceRealm.Uns);
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
// Wire a real source AFTER promotion — it must NOT retroactively make the folder a source.
@@ -271,7 +272,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
// A historized variable node — has AccessLevel.HistoryRead (variable-history reads) but
// EventNotifier=None (no event-notifier bit). The SDK base rejects it before our override runs.
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
writable: false, historianTagname: "WW.Temp");
writable: false, historianTagname: "WW.Temp", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId;
var details = new ReadEventDetails
@@ -303,8 +304,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
nm.HistorianDataSource = fake;
const string equipmentId = "eq-boom";
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
nm.MaterialiseAlarmCondition("alarm-5", equipmentId, "Cond", "OffNormalAlarm", severity: 900);
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alarm-5", equipmentId, "Cond", "OffNormalAlarm", severity: 900, realm: AddressSpaceRealm.Uns);
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
var details = new ReadEventDetails
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
@@ -42,7 +43,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
nm.EnsureVariable("eq-1/pv", parentFolderNodeId: null, displayName: "PV", dataType: "Double",
writable: false, historianTagname: "WW.PV");
writable: false, historianTagname: "WW.PV", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/pv")!.NodeId;
var collected = new List<double>();
@@ -84,7 +85,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 100, stepSeconds: 1));
nm.EnsureVariable("eq-1/exact", parentFolderNodeId: null, displayName: "Exact", dataType: "Double",
writable: false, historianTagname: "WW.Exact");
writable: false, historianTagname: "WW.Exact", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/exact")!.NodeId;
var (r1, _, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null);
@@ -123,7 +124,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
nm.EnsureVariable("eq-1/tie", parentFolderNodeId: null, displayName: "Tie", dataType: "Double",
writable: false, historianTagname: "WW.Tie");
writable: false, historianTagname: "WW.Tie", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/tie")!.NodeId;
var collected = new List<double>();
@@ -167,7 +168,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
nm.EnsureVariable("eq-1/burst", parentFolderNodeId: null, displayName: "Burst", dataType: "Double",
writable: false, historianTagname: "WW.Burst");
writable: false, historianTagname: "WW.Burst", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/burst")!.NodeId;
var collected = new List<double>();
@@ -211,7 +212,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
nm.EnsureVariable("eq-1/absurd", parentFolderNodeId: null, displayName: "Absurd", dataType: "Double",
writable: false, historianTagname: "WW.Absurd");
writable: false, historianTagname: "WW.Absurd", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/absurd")!.NodeId;
// Page 1: a full page of the first 2 ties, with a continuation point.
@@ -241,7 +242,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1));
nm.EnsureVariable("eq-1/all", parentFolderNodeId: null, displayName: "All", dataType: "Double",
writable: false, historianTagname: "WW.All");
writable: false, historianTagname: "WW.All", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/all")!.NodeId;
var (r, e, cp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 0, inboundCp: null);
@@ -265,7 +266,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/bad-cp", parentFolderNodeId: null, displayName: "BadCp", dataType: "Double",
writable: false, historianTagname: "WW.BadCp");
writable: false, historianTagname: "WW.BadCp", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/bad-cp")!.NodeId;
fake.ResetReadCount();
@@ -291,7 +292,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/rel", parentFolderNodeId: null, displayName: "Rel", dataType: "Double",
writable: false, historianTagname: "WW.Rel");
writable: false, historianTagname: "WW.Rel", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/rel")!.NodeId;
// Page 1 — get a CP.
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
@@ -39,7 +40,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
writable: false, historianTagname: "WW.Temp");
writable: false, historianTagname: "WW.Temp", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId;
var src = DateTime.UtcNow.AddSeconds(-5);
@@ -91,7 +92,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
// Node id "eq-9/flow" but a DISTINCT historian tagname "Plant.Flow.PV".
nm.EnsureVariable("eq-9/flow", parentFolderNodeId: null, displayName: "Flow", dataType: "Double",
writable: false, historianTagname: "Plant.Flow.PV");
writable: false, historianTagname: "Plant.Flow.PV", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-9/flow")!.NodeId;
var details = new ReadRawModifiedDetails
@@ -123,7 +124,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/empty", parentFolderNodeId: null, displayName: "Empty", dataType: "Float",
writable: false, historianTagname: "WW.Empty");
writable: false, historianTagname: "WW.Empty", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/empty")!.NodeId;
var details = new ReadRawModifiedDetails
@@ -156,7 +157,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
// Plain (non-historized) variable — no HistoryRead access bit.
nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32",
writable: false, historianTagname: null);
writable: false, historianTagname: null, realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/plain")!.NodeId;
var details = new ReadRawModifiedDetails
@@ -185,7 +186,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/mod", parentFolderNodeId: null, displayName: "Mod", dataType: "Float",
writable: false, historianTagname: "WW.Mod");
writable: false, historianTagname: "WW.Mod", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/mod")!.NodeId;
var details = new ReadRawModifiedDetails
@@ -215,7 +216,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/avg", parentFolderNodeId: null, displayName: "Avg", dataType: "Float",
writable: false, historianTagname: "WW.Avg");
writable: false, historianTagname: "WW.Avg", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/avg")!.NodeId;
var details = new ReadProcessedDetails
@@ -247,7 +248,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/sd", parentFolderNodeId: null, displayName: "Sd", dataType: "Float",
writable: false, historianTagname: "WW.Sd");
writable: false, historianTagname: "WW.Sd", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/sd")!.NodeId;
var details = new ReadProcessedDetails
@@ -277,7 +278,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/at", parentFolderNodeId: null, displayName: "At", dataType: "Float",
writable: false, historianTagname: "WW.At");
writable: false, historianTagname: "WW.At", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/at")!.NodeId;
var t1 = DateTime.UtcNow.AddMinutes(-2);
@@ -309,9 +310,9 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
// Materialise two distinct historized variables under separate equipment folders.
nm.EnsureVariable("eqA/good", parentFolderNodeId: null, displayName: "Good", dataType: "Float",
writable: false, historianTagname: "A.PV");
writable: false, historianTagname: "A.PV", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eqB/bad", parentFolderNodeId: null, displayName: "Bad", dataType: "Float",
writable: false, historianTagname: "B.PV");
writable: false, historianTagname: "B.PV", realm: AddressSpaceRealm.Uns);
var goodNodeId = nm.TryGetVariable("eqA/good")!.NodeId;
var badNodeId = nm.TryGetVariable("eqB/bad")!.NodeId;
@@ -368,7 +369,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/boom", parentFolderNodeId: null, displayName: "Boom", dataType: "Float",
writable: false, historianTagname: "WW.Boom");
writable: false, historianTagname: "WW.Boom", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/boom")!.NodeId;
var details = new ReadRawModifiedDetails
@@ -43,8 +43,8 @@ public sealed class NodeManagerModelChangeOnAddTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureFolder("eq-7", parentNodeId: null, displayName: "Equipment 7");
nm.EnsureVariable("eq-7/speed", parentFolderNodeId: "eq-7", displayName: "Speed", dataType: "Float", writable: false);
nm.EnsureFolder("eq-7", parentNodeId: null, displayName: "Equipment 7", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-7/speed", parentFolderNodeId: "eq-7", displayName: "Speed", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
var parent = nm.TryGetFolder("eq-7")!;
var e = nm.BuildNodesAddedModelChange("eq-7");
@@ -95,13 +95,13 @@ public sealed class NodeManagerModelChangeOnAddTests : IDisposable
var nm = server.NodeManager!;
// Before any nodes exist under the parent — must not throw.
Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9"));
Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9", realm: AddressSpaceRealm.Uns));
nm.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9");
nm.EnsureVariable("eq-9/temp", parentFolderNodeId: "eq-9", displayName: "Temp", dataType: "Float", writable: false);
nm.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-9/temp", parentFolderNodeId: "eq-9", displayName: "Temp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
// After the nodes are materialised — still must not throw.
Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9"));
Should.NotThrow(() => nm.RaiseNodesAddedModelChange("eq-9", realm: AddressSpaceRealm.Uns));
await host.DisposeAsync();
}
@@ -0,0 +1,238 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// v3 Batch 4 (B4-WP4) — the native-alarm <b>multi-notifier</b> wiring +
/// <b>teardown symmetry</b> on <see cref="OtOpcUaNodeManager"/>. A native alarm is a SINGLE Part 9
/// condition materialised once at its raw tag (ConditionId = RawPath, Raw realm);
/// <see cref="OtOpcUaNodeManager.WireAlarmNotifiers"/> wires that one condition as an event notifier of
/// EACH referencing equipment's UNS folder so one <c>ReportEvent</c> fans one event to every referencing
/// equipment (never re-reported per root). The obligation these tests lock in: the wiring is idempotent,
/// a missing folder is a safe skip, and every teardown path (condition-removal / equipment-subtree-removal
/// / full rebuild + re-wire) removes the notifier pair BIDIRECTIONALLY so no inverse-notifier entry leaks
/// across redeploys (exactly N notifiers after a re-trip, not 2N).
/// </summary>
public sealed class NodeManagerMultiNotifierAlarmTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private const string RawAlarm = "Plant/Modbus/dev1/temp_hi";
private const string RawDeviceFolder = "Plant/Modbus/dev1";
private const string Equip1 = "EQ-filling-line1-station1";
private const string Equip2 = "EQ-filling-line2-station2";
private readonly string _pkiRoot = Path.Combine(
Path.GetTempPath(),
$"otopcua-multi-notifier-{Guid.NewGuid():N}");
/// <summary>Materialise the raw device folder + the single native condition + two equipment folders, then
/// wire the condition as a notifier of both. Returns the node manager.</summary>
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm)> BootWithTwoEquipmentAsync()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
// Raw device folder (the condition's HasComponent parent) + the single native condition at the RawPath.
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
nm.MaterialiseAlarmCondition(RawAlarm, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Raw);
// Two referencing equipment folders (UNS realm).
nm.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
nm.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns);
return (host, nm);
}
/// <summary>The single condition is wired as a notifier of EACH referencing equipment folder: the condition
/// carries one inverse-notifier entry per folder, each folder carries the inverse back to the condition, and
/// each folder becomes a root (Server-object) event notifier.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task WireAlarmNotifiers_wires_the_single_condition_to_each_equipment_folder()
{
var (host, nm) = await BootWithTwoEquipmentAsync();
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
// One inverse-notifier entry per equipment folder on the single condition.
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
// Each equipment folder holds the inverse entry back to the condition + is a root notifier.
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
nm.IsRootNotifier(Equip1, AddressSpaceRealm.Uns).ShouldBeTrue();
nm.IsRootNotifier(Equip2, AddressSpaceRealm.Uns).ShouldBeTrue();
await host.DisposeAsync();
}
/// <summary>Re-wiring the SAME pairs (an idempotent re-apply of the raw subtree pass) does not duplicate the
/// notifier entries — the SDK dedups by node reference and the tracking dedups its list.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task WireAlarmNotifiers_is_idempotent_no_duplicate_on_re_wire()
{
var (host, nm) = await BootWithTwoEquipmentAsync();
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
await host.DisposeAsync();
}
/// <summary>A referencing equipment folder that is not (yet) materialised is skipped (no throw); the present
/// folders are still wired. A missing condition is likewise a no-op.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task WireAlarmNotifiers_missing_folder_or_condition_is_a_safe_skip()
{
var (host, nm) = await BootWithTwoEquipmentAsync();
// "EQ-missing" was never materialised — it is skipped; Equip1 is still wired.
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, "EQ-missing" }, AddressSpaceRealm.Uns);
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1);
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
// An unmaterialised condition id is a no-op (never throws).
Should.NotThrow(() =>
nm.WireAlarmNotifiers("Plant/Modbus/dev1/does_not_exist", AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns));
await host.DisposeAsync();
}
/// <summary>Removing the condition in place (surgical raw-alarm-tag removal) tears the notifier pairs down
/// BIDIRECTIONALLY: the surviving equipment folders lose their inverse-notifier entry back to the removed
/// condition (no dangling reference).</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task RemoveAlarmConditionNode_unwires_notifiers_from_surviving_folders()
{
var (host, nm) = await BootWithTwoEquipmentAsync();
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
nm.RemoveAlarmConditionNode(RawAlarm, AddressSpaceRealm.Raw).ShouldBeTrue();
// Condition gone; the surviving equipment folders no longer reference it (bidirectional teardown).
nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldBeNull();
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(0);
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(0);
await host.DisposeAsync();
}
/// <summary>Removing one referencing equipment's subtree unwires ONLY that folder from the surviving raw
/// condition (which lives in the Raw realm and is untouched by a UNS subtree removal): the condition drops
/// exactly one notifier and keeps the other.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task RemoveEquipmentSubtree_unwires_only_that_folder_from_surviving_condition()
{
var (host, nm) = await BootWithTwoEquipmentAsync();
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
nm.RemoveEquipmentSubtree(Equip1, AddressSpaceRealm.Uns).ShouldBeTrue();
// The raw condition SURVIVES (Raw realm) and now notifies only the remaining equipment folder.
nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldNotBeNull();
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1);
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
nm.IsRootNotifier(Equip2, AddressSpaceRealm.Uns).ShouldBeTrue();
await host.DisposeAsync();
}
/// <summary>Teardown-symmetry / "exactly one copy after a re-trip": a full rebuild + re-materialise +
/// re-wire leaves EXACTLY the same notifier count (2), not a doubled/leaked set — the rebuild unwired the
/// prior notifier pairs bidirectionally before dropping the nodes.</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task Rebuild_then_rewire_has_no_leaked_notifier_duplicates()
{
var (host, nm) = await BootWithTwoEquipmentAsync();
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
// Redeploy (the full-rebuild path).
nm.RebuildAddressSpace();
nm.TryGetAlarmCondition(RawAlarm, AddressSpaceRealm.Raw).ShouldBeNull();
// Re-materialise the whole thing + re-wire (what the applier does every apply).
nm.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
nm.MaterialiseAlarmCondition(RawAlarm, RawDeviceFolder, "temp_hi", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Raw);
nm.EnsureFolder(Equip1, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
nm.EnsureFolder(Equip2, parentNodeId: null, displayName: "station2", realm: AddressSpaceRealm.Uns);
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
// Exactly 2 again — no leaked inverse-notifier entries carried across the rebuild.
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(1);
await host.DisposeAsync();
}
/// <summary>Wave C review M3 — <see cref="OtOpcUaNodeManager.WireAlarmNotifiers"/> is a RECONCILE: re-wiring
/// with a SHRUNK folder set unwires (bidirectionally) the dropped equipment folder, so a de-referenced
/// equipment stops receiving the alarm. (Guards a future surgical ChangedRawTags path; today the shrink
/// arrives as a full rebuild.)</summary>
[Trait("Category", "Unit")]
[Fact]
public async Task WireAlarmNotifiers_reconciles_a_dropped_folder_out_of_the_notifier_set()
{
var (host, nm) = await BootWithTwoEquipmentAsync();
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1, Equip2 }, AddressSpaceRealm.Uns);
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(2);
// Re-wire with only Equip1 (Equip2 de-referenced) — reconcile unwires Equip2 bidirectionally.
nm.WireAlarmNotifiers(RawAlarm, AddressSpaceRealm.Raw, new[] { Equip1 }, AddressSpaceRealm.Uns);
nm.AlarmNotifierCount(RawAlarm, AddressSpaceRealm.Raw).ShouldBe(1);
nm.FolderNotifierCount(Equip1, AddressSpaceRealm.Uns).ShouldBe(1);
nm.FolderNotifierCount(Equip2, AddressSpaceRealm.Uns).ShouldBe(0); // no dangling inverse to the condition
await host.DisposeAsync();
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
{
var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.MultiNotifierTest",
ApplicationUri = $"urn:OtOpcUa.MultiNotifierTest:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
Microsoft.Extensions.Logging.Abstractions.NullLogger<OpcUaApplicationHost>.Instance);
var server = new OtOpcUaSdkServer();
await host.StartAsync(server, Ct);
return (host, server);
}
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
/// <summary>Cleans up the PKI root directory.</summary>
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch { /* best-effort cleanup */ }
}
}
}
@@ -38,7 +38,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
try
{
var ex = Should.Throw<InvalidOperationException>(() =>
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment"));
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns));
ex.Message.ShouldContain("address space has not been created");
}
finally
@@ -55,7 +55,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
{
Should.Throw<InvalidOperationException>(() =>
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp",
dataType: "Float", writable: false));
dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns));
}
finally
{
@@ -70,7 +70,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
try
{
Should.Throw<InvalidOperationException>(() =>
nm.WriteValue("eq-1/temp", 1.0, OpcUaQuality.Good, DateTime.UtcNow));
nm.WriteValue("eq-1/temp", 1.0, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns));
}
finally
{
@@ -85,7 +85,7 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
try
{
Should.Throw<InvalidOperationException>(() =>
nm.MaterialiseAlarmCondition("alarm-1", "eq-1", "Cond", "OffNormalAlarm", severity: 500));
nm.MaterialiseAlarmCondition("alarm-1", "eq-1", "Cond", "OffNormalAlarm", severity: 500, realm: AddressSpaceRealm.Uns));
}
finally
{
@@ -1,4 +1,5 @@
using Opc.Ua;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Shouldly;
using Xunit;
@@ -31,13 +32,13 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A");
nm.EnsureVariable("eq-1/B", "eq-1", "B", "Float", writable: false);
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/B", "eq-1", "B", "Float", writable: false, realm: AddressSpaceRealm.Uns);
var countBefore = nm.VariableCount;
nm.TryGetHistorizedTagname("eq-1/A", out _).ShouldBeTrue();
nm.RemoveVariableNode("eq-1/A").ShouldBeTrue();
nm.RemoveVariableNode("eq-1/A", realm: AddressSpaceRealm.Uns).ShouldBeTrue();
nm.TryGetVariable("eq-1/A").ShouldBeNull();
nm.VariableCount.ShouldBe(countBefore - 1);
@@ -56,7 +57,7 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.RemoveVariableNode("eq-1/nope").ShouldBeFalse();
nm.RemoveVariableNode("eq-1/nope", realm: AddressSpaceRealm.Uns).ShouldBeFalse();
await host.DisposeAsync();
}
@@ -68,8 +69,8 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false);
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, realm: AddressSpaceRealm.Uns);
var e = nm.BuildNodesRemovedModelChange("eq-1/A");
@@ -92,17 +93,17 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true);
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldNotBeNull();
nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeTrue();
nm.RemoveAlarmConditionNode("eq-1/OverTemp").ShouldBeTrue();
nm.RemoveAlarmConditionNode("eq-1/OverTemp", realm: AddressSpaceRealm.Uns).ShouldBeTrue();
nm.TryGetAlarmCondition("eq-1/OverTemp").ShouldBeNull();
nm.IsNativeAlarmNode("eq-1/OverTemp").ShouldBeFalse();
nm.RemoveAlarmConditionNode("eq-1/OverTemp").ShouldBeFalse(); // already gone ⇒ false
nm.RemoveAlarmConditionNode("eq-1/OverTemp", realm: AddressSpaceRealm.Uns).ShouldBeFalse(); // already gone ⇒ false
await host.DisposeAsync();
}
@@ -114,10 +115,10 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 500, isNative: false);
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 500, isNative: false, realm: AddressSpaceRealm.Uns);
nm.RemoveAlarmConditionNode("alm-1").ShouldBeTrue();
nm.RemoveAlarmConditionNode("alm-1", realm: AddressSpaceRealm.Uns).ShouldBeTrue();
nm.TryGetAlarmCondition("alm-1").ShouldBeNull();
await host.DisposeAsync();
@@ -136,17 +137,17 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
var nm = server.NodeManager!;
// Target equipment eq-1 with a sub-folder, two variables (one historized), and a native condition.
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
nm.EnsureFolder("eq-1/Diag", parentNodeId: "eq-1", displayName: "Diag");
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A");
nm.EnsureVariable("eq-1/Diag/T", "eq-1/Diag", "T", "Float", writable: false);
nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true);
nm.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", realm: AddressSpaceRealm.Uns);
nm.EnsureFolder("eq-1/Diag", parentNodeId: "eq-1", displayName: "Diag", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/A", "eq-1", "A", "Float", writable: false, historianTagname: "Hist.A", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-1/Diag/T", "eq-1/Diag", "T", "Float", writable: false, realm: AddressSpaceRealm.Uns);
nm.MaterialiseAlarmCondition("eq-1/OverTemp", "eq-1", "OverTemp", "OffNormalAlarm", 700, isNative: true, realm: AddressSpaceRealm.Uns);
// Sibling equipment eq-2 that must survive untouched.
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2");
nm.EnsureVariable("eq-2/S", "eq-2", "S", "Float", writable: false);
nm.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", realm: AddressSpaceRealm.Uns);
nm.EnsureVariable("eq-2/S", "eq-2", "S", "Float", writable: false, realm: AddressSpaceRealm.Uns);
nm.RemoveEquipmentSubtree("eq-1").ShouldBeTrue();
nm.RemoveEquipmentSubtree("eq-1", realm: AddressSpaceRealm.Uns).ShouldBeTrue();
// Every eq-1 descendant is gone from every map.
nm.TryGetFolder("eq-1").ShouldBeNull();
@@ -163,7 +164,7 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
// Re-materialising an alarm under eq-2 still works (the notifier machinery was not corrupted by the
// eq-1 demotion) — proves no orphaned root-notifier ref broke the event path.
Should.NotThrow(() => nm.MaterialiseAlarmCondition("eq-2/Alm", "eq-2", "Alm", "OffNormalAlarm", 300, isNative: false));
Should.NotThrow(() => nm.MaterialiseAlarmCondition("eq-2/Alm", "eq-2", "Alm", "OffNormalAlarm", 300, isNative: false, realm: AddressSpaceRealm.Uns));
await host.DisposeAsync();
}
@@ -176,7 +177,7 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.RemoveEquipmentSubtree("eq-nope").ShouldBeFalse();
nm.RemoveEquipmentSubtree("eq-nope", realm: AddressSpaceRealm.Uns).ShouldBeFalse();
await host.DisposeAsync();
}
@@ -44,13 +44,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false);
nm.WriteValue("eq-1/sp", 3.5f, OpcUaQuality.Good, DateTime.UtcNow);
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 3.5f, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
node.DataType.ShouldBe(DataTypeIds.Float); // arrange guard
var applied = nm.UpdateTagAttributes("eq-1/sp", writable: false, historianTagname: null,
dataType: "Int32", isArray: false, arrayLength: null);
dataType: "Int32", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns);
applied.ShouldBeTrue();
node.DataType.ShouldBe(DataTypeIds.Int32); // swapped in place
@@ -69,13 +69,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false);
nm.WriteValue("eq-1/buf", (short)42, OpcUaQuality.Good, DateTime.UtcNow);
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", (short)42, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/buf")!;
node.ValueRank.ShouldBe(ValueRanks.Scalar); // arrange guard
var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null,
dataType: "Int16", isArray: true, arrayLength: 8u);
dataType: "Int16", isArray: true, arrayLength: 8u, realm: AddressSpaceRealm.Uns);
applied.ShouldBeTrue();
node.ValueRank.ShouldBe(ValueRanks.OneDimension);
@@ -96,13 +96,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16",
writable: false, historianTagname: null, isArray: true, arrayLength: 4u);
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow);
writable: false, historianTagname: null, isArray: true, arrayLength: 4u, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/buf")!;
node.ArrayDimensions![0].ShouldBe(4u); // arrange guard
var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null,
dataType: "Int16", isArray: true, arrayLength: 8u);
dataType: "Int16", isArray: true, arrayLength: 8u, realm: AddressSpaceRealm.Uns);
applied.ShouldBeTrue();
node.ArrayDimensions[0].ShouldBe(8u);
@@ -121,13 +121,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16",
writable: false, historianTagname: null, isArray: true, arrayLength: 4u);
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow);
writable: false, historianTagname: null, isArray: true, arrayLength: 4u, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/buf", new short[] { 1, 2, 3, 4 }, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/buf")!;
node.ValueRank.ShouldBe(ValueRanks.OneDimension); // arrange guard
var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null,
dataType: "Int16", isArray: false, arrayLength: null);
dataType: "Int16", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns);
applied.ShouldBeTrue();
node.ValueRank.ShouldBe(ValueRanks.Scalar);
@@ -146,11 +146,11 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false);
nm.EnsureVariable("eq-1/buf", parentFolderNodeId: null, displayName: "Buf", dataType: "Int16", writable: false, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/buf")!;
var applied = nm.UpdateTagAttributes("eq-1/buf", writable: false, historianTagname: null,
dataType: "Int16", isArray: true, arrayLength: null);
dataType: "Int16", isArray: true, arrayLength: null, realm: AddressSpaceRealm.Uns);
applied.ShouldBeTrue();
node.ValueRank.ShouldBe(ValueRanks.OneDimension);
@@ -171,13 +171,13 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false);
nm.WriteValue("eq-1/sp", 7.0f, OpcUaQuality.Good, DateTime.UtcNow);
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7.0f, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
// Same DataType ("Float") + same scalar shape — only Writable flips false → true.
var applied = nm.UpdateTagAttributes("eq-1/sp", writable: true, historianTagname: null,
dataType: "Float", isArray: false, arrayLength: null);
dataType: "Float", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns);
applied.ShouldBeTrue();
node.Value.ShouldBe(7.0f); // value preserved (NOT reset)
@@ -199,7 +199,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
bool result = true;
Should.NotThrow(() => result = nm.UpdateTagAttributes("eq-1/gone", writable: false, historianTagname: null,
dataType: "Int32", isArray: false, arrayLength: null));
dataType: "Int32", isArray: false, arrayLength: null, realm: AddressSpaceRealm.Uns));
result.ShouldBeFalse();
await host.DisposeAsync();
@@ -215,7 +215,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false);
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
var e = nm.BuildNodeShapeChangedEvent(node);
@@ -50,9 +50,9 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true);
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
// Prior state = Good 7; the SDK then optimistically applied 42 (the write the device will reject).
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
node.Value = 42; // simulate the SDK's optimistic apply of the rejected write
@@ -62,7 +62,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
optimisticValue: 42,
priorValue: 7,
priorStatus: StatusCodes.Good,
clientUserId: "op");
clientUserId: "op", realm: AddressSpaceRealm.Uns);
node.Value.ShouldBe(7); // settled back to prior value
node.StatusCode.ShouldBe((StatusCode)StatusCodes.Good); // settled back to prior status (NOT stuck Bad)
@@ -78,15 +78,15 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow);
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
node.Value = 42;
nm.RevertOptimisticWriteIfNeeded(
"eq-1/sp",
outcome: new NodeWriteOutcome(true, null),
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op");
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns);
node.Value.ShouldBe(42); // success ⇒ optimistic value stands
@@ -101,15 +101,15 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow);
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
node.Value = 99; // a fresh poll already republished the confirmed register value (NOT the optimistic 42)
nm.RevertOptimisticWriteIfNeeded(
"eq-1/sp",
outcome: new NodeWriteOutcome(false, "device rejected"),
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op");
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns);
node.Value.ShouldBe(99); // poll value preserved — not reverted to 7
@@ -127,7 +127,71 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
Should.NotThrow(() => nm.RevertOptimisticWriteIfNeeded(
"eq-1/gone",
outcome: new NodeWriteOutcome(false, "device rejected"),
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"));
optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns));
await host.DisposeAsync();
}
// ─────────────────── v3 Batch 4 (review M2) — realm-qualified dual-node self-correction ───────────────────
/// <summary>(M2a) A failed write driven through a UNS NodeId reverts ONLY the UNS node back to its prior
/// value; the raw node that shares the SAME bare id (but lives in the Raw realm) is NEVER touched and keeps
/// its driver value. The revert routes by (realm, bareId), so it can't clobber the sibling node.</summary>
[Fact]
public async Task Failed_uns_write_reverts_uns_node_and_leaves_raw_node_untouched()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
// Same bare id "A/B/C/D" in BOTH realms — the raw device node and the UNS reference node.
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Raw", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Raw);
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Uns", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
var now = DateTime.UtcNow;
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Raw); // raw driver value
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Uns); // fanned UNS value
var raw = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Raw)!;
var uns = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Uns)!;
uns.Value = 42; // the SDK optimistically applied the client's UNS write
nm.RevertOptimisticWriteIfNeeded(
"A/B/C/D", outcome: new NodeWriteOutcome(false, "device rejected"),
optimisticValue: 42, priorValue: 10, priorStatus: StatusCodes.Good, clientUserId: "op",
realm: AddressSpaceRealm.Uns);
uns.Value.ShouldBe(10); // UNS node reverted to prior
raw.Value.ShouldBe(10); // raw node NEVER changed (realm-qualified revert)
await host.DisposeAsync();
}
/// <summary>(M2b) A raw-realm revert reverts the RAW node and leaves the same-bare-id UNS node untouched.
/// This locks the realm arg: were it omitted (defaulting to Uns) the revert would target the UNS node — whose
/// value is not the optimistic one, so the still-holds-optimistic guard skips it — leaving the raw node stuck
/// at the rejected value and FAILING the raw-reverts assertion below.</summary>
[Fact]
public async Task Raw_realm_revert_reverts_raw_node_only()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Raw", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Raw);
nm.EnsureVariable("A/B/C/D", parentFolderNodeId: null, displayName: "Uns", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
var now = DateTime.UtcNow;
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Raw);
nm.WriteValue("A/B/C/D", 10, OpcUaQuality.Good, now, AddressSpaceRealm.Uns);
var raw = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Raw)!;
var uns = nm.TryGetVariable("A/B/C/D", AddressSpaceRealm.Uns)!;
raw.Value = 42; // optimistic write to the RAW node
nm.RevertOptimisticWriteIfNeeded(
"A/B/C/D", outcome: new NodeWriteOutcome(false, "device rejected"),
optimisticValue: 42, priorValue: 10, priorStatus: StatusCodes.Good, clientUserId: "op",
realm: AddressSpaceRealm.Raw);
raw.Value.ShouldBe(10); // raw reverted (would stay 42 if the realm were dropped to Uns)
uns.Value.ShouldBe(10); // UNS node untouched
await host.DisposeAsync();
}
@@ -143,8 +207,8 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow);
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
var audit = nm.BuildWriteFailureAuditEvent(
@@ -174,8 +238,8 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow);
nm.EnsureVariable("eq-1/sp", parentFolderNodeId: null, displayName: "Sp", dataType: "Int32", writable: true, realm: AddressSpaceRealm.Uns);
nm.WriteValue("eq-1/sp", 7, OpcUaQuality.Good, DateTime.UtcNow, realm: AddressSpaceRealm.Uns);
var node = nm.TryGetVariable("eq-1/sp")!;
var audit = nm.BuildWriteFailureAuditEvent(
@@ -27,9 +27,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow);
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 23.1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/pressure", 100, OpcUaQuality.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(2);
@@ -44,14 +44,75 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("eq-1/temp", 22.5, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(2);
await host.DisposeAsync();
}
/// <summary>B4-WP2 M1: AddReference wires the cross-tree Organizes edge from a UNS reference variable
/// (source, realm=Uns) to its backing Raw node (target, realm=Raw), bidirectionally, so browse resolves
/// both ways — and is idempotent.</summary>
[Fact]
public async Task AddReference_wires_Organizes_edge_from_UNS_variable_to_Raw_node()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
// Backing Raw node (device tree) and the UNS reference variable (equipment tree).
sink.EnsureVariable("MAIN/modbus/dev/grp/temp", parentFolderNodeId: null, displayName: "Temp",
dataType: "Float", writable: false, realm: AddressSpaceRealm.Raw);
sink.EnsureVariable("Area/Line/Eq/Temperature", parentFolderNodeId: null, displayName: "Temperature",
dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns,
"MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw);
var unsVar = nm.TryGetVariable("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns);
var rawVar = nm.TryGetVariable("MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw);
unsVar.ShouldNotBeNull();
rawVar.ShouldNotBeNull();
// Forward Organizes on the UNS variable → Raw node; inverse (OrganizedBy) on the Raw node.
unsVar.ReferenceExists(ReferenceTypeIds.Organizes, isInverse: false, rawVar.NodeId).ShouldBeTrue();
rawVar.ReferenceExists(ReferenceTypeIds.Organizes, isInverse: true, unsVar.NodeId).ShouldBeTrue();
// Idempotent: a second identical call does not duplicate the edge. The UNS variable's only
// FORWARD Organizes ref is the one we added (its parent link manifests as an inverse ref).
sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns,
"MAIN/modbus/dev/grp/temp", AddressSpaceRealm.Raw);
nm.GetNodeReferences("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns)
.Count(r => r.ReferenceTypeId == ReferenceTypeIds.Organizes && !r.IsInverse).ShouldBe(1);
await host.DisposeAsync();
}
/// <summary>B4-WP2 M1: a missing endpoint makes AddReference a safe no-op (never throws, no edge added).</summary>
[Fact]
public async Task AddReference_with_missing_endpoint_is_a_safe_no_op()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
sink.EnsureVariable("Area/Line/Eq/Temperature", parentFolderNodeId: null, displayName: "Temperature",
dataType: "Float", writable: false, realm: AddressSpaceRealm.Uns);
// Target does not exist ⇒ no-op, no throw.
Should.NotThrow(() => sink.AddReference("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns,
"MAIN/does/not/exist", AddressSpaceRealm.Raw));
var unsVar = nm.TryGetVariable("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns);
unsVar.ShouldNotBeNull();
nm.GetNodeReferences("Area/Line/Eq/Temperature", AddressSpaceRealm.Uns)
.Any(r => r.ReferenceTypeId == ReferenceTypeIds.Organizes && !r.IsInverse).ShouldBeFalse();
await host.DisposeAsync();
}
/// <summary>Verifies that RebuildAddressSpace clears all registered variables.</summary>
[Fact]
public async Task RebuildAddressSpace_clears_all_registered_variables()
@@ -59,16 +120,16 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var (host, server) = await BootAsync();
var sink = new SdkAddressSpaceSink(server.NodeManager!);
sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow);
sink.WriteValue("a", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteValue("b", 2, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager!.VariableCount.ShouldBe(3);
sink.RebuildAddressSpace();
server.NodeManager.VariableCount.ShouldBe(0);
// After rebuild, subsequent writes start fresh.
sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteValue("a", 99, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
server.NodeManager.VariableCount.ShouldBe(1);
await host.DisposeAsync();
@@ -81,8 +142,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// Sanity check that the F10 fallback still works — production callers default to
// NullOpcUaAddressSpaceSink when no SDK NodeManager is wired.
var sink = NullOpcUaAddressSpaceSink.Instance;
sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow);
sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow);
sink.WriteValue("x", 1, OpcUaQuality.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteAlarmCondition("a", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.RebuildAddressSpace();
await Task.CompletedTask;
}
@@ -100,10 +161,10 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var sink = new SdkAddressSpaceSink(nm);
// Equipment folder must exist first (MaterialiseHierarchy owns this in production).
sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1");
sink.EnsureFolder("eq-1", parentNodeId: null, displayName: "Equipment 1", AddressSpaceRealm.Uns);
// Materialise the condition. NodeId == alarm node id (the ScriptedAlarmId) so WriteAlarmCondition targets it.
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
nm.AlarmConditionCount.ShouldBe(1);
@@ -111,11 +172,14 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
condition.ShouldNotBeNull();
// It is a REAL Part 9 alarm condition (subtype mapped from "OffNormalAlarm").
condition.ShouldBeOfType<OffNormalAlarmState>();
condition.NodeId.ShouldBe(new NodeId("alm-1", nm.NamespaceIndex));
// v3 dual-namespace: the sink's transitional default realm is UNS, so default-realm nodes live in the
// UNS namespace (index 3), not the Raw namespace nm.NamespaceIndex (index 2) reports.
var unsNs = nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns);
condition.NodeId.ShouldBe(new NodeId("alm-1", unsNs));
// Reachable under the equipment folder: the parent is the eq-1 folder (HasComponent child).
condition.Parent.ShouldNotBeNull();
condition.Parent!.NodeId.ShouldBe(new NodeId("eq-1", nm.NamespaceIndex));
condition.Parent!.NodeId.ShouldBe(new NodeId("eq-1", unsNs));
// Initial state set by MaterialiseAlarmCondition: enabled, inactive, acked, retain=false.
condition.EnabledState.Id.Value.ShouldBeTrue();
@@ -137,7 +201,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// WriteAlarmCondition now targets the real condition (not the bool[2] placeholder): no extra
// BaseDataVariable is minted for the alarm id.
sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken
condition.ActiveState.Id.Value.ShouldBeTrue();
@@ -145,7 +209,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain
// Idempotent re-materialise (e.g. redeploy): still exactly one condition node for the id.
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700);
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
nm.AlarmConditionCount.ShouldBe(1);
// RebuildAddressSpace clears the alarm dict too.
@@ -164,8 +228,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9");
sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500);
sink.EnsureFolder("eq-9", parentNodeId: null, displayName: "Equipment 9", AddressSpaceRealm.Uns);
sink.MaterialiseAlarmCondition("alm-x", "eq-9", "GenericAlarm", "LimitAlarm", severity: 500, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-x");
condition.ShouldNotBeNull();
@@ -186,8 +250,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2");
sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300);
sink.EnsureFolder("eq-2", parentNodeId: null, displayName: "Equipment 2", AddressSpaceRealm.Uns);
sink.MaterialiseAlarmCondition("alm-rich", "eq-2", "HighPressure", "OffNormalAlarm", severity: 300, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-rich");
condition.ShouldNotBeNull();
@@ -203,7 +267,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
Shelving: AlarmShelvingKind.Timed,
Severity: 850,
Message: "Pressure above limit"),
DateTime.UtcNow);
DateTime.UtcNow, AddressSpaceRealm.Uns);
condition.ActiveState.Id.Value.ShouldBeTrue();
condition.AckedState.Id.Value.ShouldBeFalse();
@@ -232,8 +296,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3");
sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200);
sink.EnsureFolder("eq-3", parentNodeId: null, displayName: "Equipment 3", AddressSpaceRealm.Uns);
sink.MaterialiseAlarmCondition("alm-base", "eq-3", "Generic", "AlarmCondition", severity: 200, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-base");
condition.ShouldNotBeNull();
@@ -254,7 +318,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
Shelving: AlarmShelvingKind.OneShot,
Severity: 500,
Message: "still works"),
DateTime.UtcNow));
DateTime.UtcNow, AddressSpaceRealm.Uns));
// Mandatory state still projected despite the missing optional child.
condition.ActiveState.Id.Value.ShouldBeTrue();
@@ -282,8 +346,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt");
sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700);
sink.EnsureFolder("eq-evt", parentNodeId: null, displayName: "Equipment Evt", AddressSpaceRealm.Uns);
sink.MaterialiseAlarmCondition("alm-evt", "eq-evt", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-evt");
condition.ShouldNotBeNull();
@@ -293,7 +357,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var materialiseEventId = (byte[]?)condition!.EventId.Value?.Clone();
// First engine-driven transition → fires an event with a fresh EventId.
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow);
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
var firstEventId = (byte[]?)condition.EventId.Value?.Clone();
firstEventId.ShouldNotBeNull();
@@ -305,7 +369,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
}
// Second transition → DIFFERENT EventId (fresh per event, so T17 ack-correlation is unambiguous).
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow);
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
var secondEventId = (byte[]?)condition.EventId.Value?.Clone();
secondEventId.ShouldNotBeNull();
@@ -331,8 +395,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2");
sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700);
sink.EnsureFolder("eq-evt2", parentNodeId: null, displayName: "Equipment Evt2", AddressSpaceRealm.Uns);
sink.MaterialiseAlarmCondition("alm-evt2", "eq-evt2", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-evt2");
condition.ShouldNotBeNull();
@@ -340,9 +404,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// Drive several transitions; each fires an event AND projects state. State must survive firing.
Should.NotThrow(() =>
{
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow);
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow);
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: true, acknowledged: true, message: "acked"), DateTime.UtcNow, AddressSpaceRealm.Uns);
sink.WriteAlarmCondition("alm-evt2", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns);
});
// Final projected state is intact after the last firing.
@@ -370,14 +434,14 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("eq-ack", parentNodeId: null, displayName: "Equipment Ack");
sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700);
sink.EnsureFolder("eq-ack", parentNodeId: null, displayName: "Equipment Ack", AddressSpaceRealm.Uns);
sink.MaterialiseAlarmCondition("alm-ack", "eq-ack", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-ack");
condition.ShouldNotBeNull();
// Drive the alarm active+unacked through the engine path (a genuine transition → fires).
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
condition!.ActiveState.Id.Value.ShouldBeTrue();
condition.AckedState.Id.Value.ShouldBeFalse();
@@ -398,7 +462,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// === Engine re-projects the SAME acked transition through WriteAlarmCondition (would-be E3) ===
// Snapshot equals the node's current state (active, acked, message "active") ⇒ delta-gate sees
// no change ⇒ NO event fires.
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow);
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
var afterReProject = (byte[]?)condition.EventId.Value?.Clone();
// EventId is UNCHANGED ⇒ ReportConditionEvent did NOT run ⇒ E3 suppressed.
@@ -422,8 +486,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
var nm = server.NodeManager!;
var sink = new SdkAddressSpaceSink(nm);
sink.EnsureFolder("eq-delta", parentNodeId: null, displayName: "Equipment Delta");
sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700);
sink.EnsureFolder("eq-delta", parentNodeId: null, displayName: "Equipment Delta", AddressSpaceRealm.Uns);
sink.MaterialiseAlarmCondition("alm-delta", "eq-delta", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
var condition = nm.TryGetAlarmCondition("alm-delta");
condition.ShouldNotBeNull();
@@ -432,19 +496,19 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
// Genuine transition: snapshot (active, unacked) differs from the materialise state
// (inactive, acked) ⇒ delta ⇒ fires exactly one event (EventId changes).
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
var afterFirst = (byte[]?)condition.EventId.Value?.Clone();
afterFirst.ShouldNotBeNull();
afterFirst!.ShouldNotBe(beforeFirst); // fired
// Identical re-projection: snapshot now EQUALS the node's current state ⇒ no delta ⇒ 0 more
// events (EventId unchanged from the first fire).
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow);
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
var afterSecond = (byte[]?)condition.EventId.Value?.Clone();
afterSecond.ShouldBe(afterFirst); // suppressed
// A FURTHER genuine transition (clear) differs again ⇒ fires once more.
sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow);
sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns);
var afterThird = (byte[]?)condition.EventId.Value?.Clone();
afterThird.ShouldNotBe(afterSecond); // fired
@@ -1,6 +1,7 @@
using Akka.Actor;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
@@ -24,7 +25,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase
var probe = CreateTestProbe();
var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => probe.Ref, NullLogger.Instance);
var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None);
var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None);
var routed = probe.ExpectMsg<DriverHostActor.RouteNodeWrite>(TimeSpan.FromSeconds(5));
routed.NodeId.ShouldBe("eq-1/speed");
@@ -43,7 +44,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase
var probe = CreateTestProbe();
var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => probe.Ref, NullLogger.Instance);
var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None);
var writeTask = gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None);
probe.ExpectMsg<DriverHostActor.RouteNodeWrite>(TimeSpan.FromSeconds(5));
probe.Reply(new DriverHostActor.NodeWriteResult(false, "not primary"));
@@ -63,7 +64,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase
resolveDriverHost: () => probe.Ref, NullLogger.Instance,
askTimeout: TimeSpan.FromMilliseconds(200));
var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None);
var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None);
outcome.Success.ShouldBeFalse();
outcome.Reason.ShouldNotBeNull();
@@ -77,7 +78,7 @@ public sealed class ActorNodeWriteGatewayTests : RuntimeActorTestBase
{
var gateway = new ActorNodeWriteGateway(resolveDriverHost: () => null, NullLogger.Instance);
var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, CancellationToken.None);
var outcome = await gateway.WriteAsync("eq-1/speed", 123.0, AddressSpaceRealm.Uns, CancellationToken.None);
outcome.Success.ShouldBeFalse();
outcome.Reason.ShouldBe("writes unavailable");
@@ -18,6 +18,18 @@ internal static class DarkAddressSpaceReasons
"empty equipment-tag plan set this batch, so this parity has nothing to compare. Raw-tag parse " +
"intent is covered by TagConfigCorpusParityTests (RawTagEntry round-trip) + TagConfigIntentTests.";
/// <summary>Discovered-node INJECTION is dormant in v3 (Wave-B review M1). The v2 path grafted a driver's
/// FixedTree under an equipment folder (equipment bound a driver); v3 retired that binding and discovered
/// raw tags are authored explicitly via the Batch-2 <c>/raw</c> browse-commit flow. <c>HandleDiscoveredNodes</c>
/// hard-short-circuits, so these v2 injection scenarios have no live path to assert; re-migrating injection
/// onto the raw device subtree is a separate follow-up. The dormant guard itself is pinned by
/// <c>DriverHostActorDiscoveryTests.Discovered_nodes_are_ignored_dormant_in_v3</c>.</summary>
public const string DiscoveryInjectionDormantV3 =
"v3: discovered-node injection is DORMANT (Wave-B review M1) — equipment no longer binds a driver, so the " +
"v2 FixedTree-under-equipment graft has no live path. Discovered raw tags are authored via the /raw " +
"browse-commit flow. HandleDiscoveredNodes short-circuits (pinned by Discovered_nodes_are_ignored_dormant_in_v3); " +
"re-migrating injection onto the raw device subtree is a separate follow-up.";
/// <summary>Equipment↔device host binding is architecturally retired in v3.</summary>
public const string EquipmentDeviceBindingRetired =
"v3: equipment no longer binds a driver/device (EquipmentNode has no DriverInstanceId/DeviceId/" +
@@ -0,0 +1,84 @@
using System.Text.Json;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// v3 Batch 4 (Wave-B review recommendation) — the artifact-decode seam
/// (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>) and the live compose seam
/// (<see cref="AddressSpaceComposer.Compose"/>) must produce BYTE-IDENTICAL <b>Raw</b> and <b>UNS</b> node
/// sets for the same seeded config. This is the guard that a future artifact-decode drift can't silently
/// diverge the deployed dual-namespace address space from what was authored/validated. The existing
/// <c>DeploymentArtifactEquipRefParityTests</c> covers <c>{{equip}}</c> script-path parity; this covers the
/// RawContainers / RawTags / UnsReferenceVariables sets (RawPaths, UNS NodeIds, Organizes backing paths,
/// writable + historian + array shape).
/// </summary>
public sealed class DeploymentArtifactRawUnsParityTests
{
[Fact]
public void Raw_and_uns_node_sets_are_byte_parity_between_compose_and_artifact_decode()
{
// Entity-side config: folder → driver → device → group → 2 tags (one historized+writable+override,
// one plain read-only), an area/line/equipment, and a UNS reference with a display-name override.
var folder = new RawFolder { RawFolderId = "fld-1", ClusterId = "c1", Name = "Cell1", ParentRawFolderId = null };
var driver = new DriverInstance { DriverInstanceId = "drv-1", ClusterId = "c1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", RawFolderId = "fld-1" };
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" };
var group = new TagGroup { TagGroupId = "grp-1", DeviceId = "dev-1", Name = "Fast", ParentTagGroupId = null };
var speed = new Tag
{
TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float",
AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}",
};
var run = new Tag
{
TagId = "tag-run", DeviceId = "dev-1", TagGroupId = null, Name = "Run", DataType = "Boolean",
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
};
var area = new UnsArea { UnsAreaId = "a1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" };
var equip = new Equipment { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" };
var reference = new UnsTagReference { UnsTagReferenceId = "ref-1", EquipmentId = "EQ-1", TagId = "tag-speed", DisplayNameOverride = "MotorSpeed" };
var composed = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
new[] { driver }, Array.Empty<ScriptedAlarm>(),
unsTagReferences: new[] { reference }, tags: new[] { speed, run },
rawFolders: new[] { folder }, devices: new[] { device }, tagGroups: new[] { group });
// Artifact JSON — the same shape ConfigComposer serializes (enums numeric: ReadWrite = 1, Read = 0).
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "fld-1", ParentRawFolderId = (string?)null, Name = "Cell1", ClusterId = "c1" } },
DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "fld-1", ClusterId = "c1" } },
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "Dev1", DeviceConfig = "{}" } },
TagGroups = new[] { new { TagGroupId = "grp-1", ParentTagGroupId = (string?)null, DeviceId = "dev-1", Name = "Fast" } },
Tags = new object[]
{
new { TagId = "tag-speed", DeviceId = "dev-1", TagGroupId = "grp-1", Name = "Speed", DataType = "Float", AccessLevel = 1, TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"WW.Speed\"}" },
new { TagId = "tag-run", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Run", DataType = "Boolean", AccessLevel = 0, TagConfig = "{}" },
},
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } },
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" } },
UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "EQ-1", TagId = "tag-speed", DisplayNameOverride = "MotorSpeed" } },
});
var decoded = DeploymentArtifact.ParseComposition(blob);
// Sanity: the sets are non-empty (both raw tags + the projected UNS variable materialised).
composed.RawTags.Count.ShouldBe(2);
composed.UnsReferenceVariables.Count.ShouldBe(1);
composed.UnsReferenceVariables[0].NodeId.ShouldBe("filling/line1/station1/MotorSpeed");
composed.UnsReferenceVariables[0].BackingRawPath.ShouldBe("Cell1/Modbus/Dev1/Fast/Speed");
// Byte-parity: the three dual-namespace node sets are record-equal between the two seams.
decoded.RawContainers.ShouldBe(composed.RawContainers);
decoded.RawTags.ShouldBe(composed.RawTags);
decoded.UnsReferenceVariables.ShouldBe(composed.UnsReferenceVariables);
}
}
@@ -108,7 +108,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
/// <see cref="OpcUaQuality.Good"/> — proving the live value routed end-to-end and (in production)
/// overwrote the BadWaitingForInitialData seed.
/// </summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Discovered_node_materialises_at_equipment_and_polled_value_flows_Good()
{
var db = NewInMemoryDbFactory();
@@ -154,7 +154,7 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
/// (re-materialised after the rebuild) at the same NodeId, and a subsequent published value STILL
/// <c>WriteValue</c>s Good there (the routing map was rebuilt, not left empty by the Clear()).
/// </summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Discovered_node_and_value_survive_a_redeploy()
{
var db = NewInMemoryDbFactory();
@@ -324,27 +324,29 @@ public sealed class DiscoveryInjectionEndToEndTests : RuntimeActorTestBase
public int RebuildCalls;
/// <summary>Records a live-value write.</summary>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> _values.Enqueue((nodeId, value, quality, sourceTimestampUtc));
/// <summary>No-op: alarm writes are not exercised by this suite.</summary>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>No-op: alarm materialise is not exercised by this suite.</summary>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
/// <summary>Records an EnsureFolder call.</summary>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> _folders.Enqueue((folderNodeId, parentNodeId, displayName));
/// <summary>Records an EnsureVariable call.</summary>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> _variables.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
/// <summary>Records a raw rebuild (the apply-time fallback when no dbFactory is wired).</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>Records a NodeAdded model-change announcement.</summary>
public void RaiseNodesAddedModelChange(string affectedNodeId) => _modelChanges.Enqueue(affectedNodeId);
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _modelChanges.Enqueue(affectedNodeId);
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
}
@@ -49,12 +49,39 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
private static readonly DateTime Ts = new(2026, 6, 26, 10, 0, 0, DateTimeKind.Utc);
/// <summary>v3 Batch 4 (review M1) — the ONE live dormancy pin: discovered-node injection is short-circuited.
/// A driver reports a FixedTree via <see cref="DriverInstanceActor.DiscoveredNodesReady"/>, but the host must
/// NOT materialise anything (no <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> reaches the publish
/// side) — discovered raw tags are authored via the <c>/raw</c> browse-commit flow, not injected at runtime.
/// The skipped v2 injection scenarios below are the counterpart of this guard.</summary>
[Fact]
public void Discovered_nodes_are_ignored_dormant_in_v3()
{
var db = NewInMemoryDbFactory();
var factory = new SubscribingDriverFactory("Modbus");
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "EQ-1", Driver: "d1", FullName: "40001", Folder: (string?)null, Name: "speed"));
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId, factory);
actor.Tell(new DriverInstanceActor.DiscoveredNodesReady("d1", new[]
{
new DiscoveredNode(
FolderPathSegments: new[] { "FOCAS", "Identity" },
BrowseName: "Model", DisplayName: "Model", FullReference: "ft-ref-1",
DataType: DriverDataType.Float64, IsArray: false, ArrayDim: null,
Writable: false, IsHistorized: false),
}));
// Dormant: no discovered nodes are materialised (the guard short-circuits before any route/materialise).
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>A driver's discovered FixedTree (refs differing from the authored tag) is grafted under the
/// bound equipment: (a) the publish side receives <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/>
/// rooted at the equipment NodeId; (b) the driver re-subscribes the UNION of the authored ref + the
/// FixedTree refs; (c) a value published for a FixedTree ref now routes to its mapped NodeId (proving the
/// live-value routing map was extended).</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void DiscoveredNodes_materialise_extend_routing_and_merge_subscription()
{
var db = NewInMemoryDbFactory();
@@ -121,7 +148,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> rooted at its NodeId and the driver
/// subscribes the discovered refs (no authored ref exists to union). Previously this was skipped with
/// "no equipment/authored tags".</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Tag_less_equipment_resolved_via_EquipmentNode_grafts_discovered_nodes()
{
var db = NewInMemoryDbFactory();
@@ -198,7 +225,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// equipment), (b) the union subscription carries BOTH devices' refs, and (c) a value for each device's ref
/// routes to the right equipment's node (proving BOTH inner-map entries cached + keyed correctly). The "H1"
/// vs stored "h1" wrinkle proves the SHARED <c>DeviceConfigIntent.NormalizeHost</c> match.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Multi_device_driver_partitions_fixed_tree_by_device_host_under_matching_equipment()
{
var db = NewInMemoryDbFactory();
@@ -238,10 +265,10 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
// collapse regression — which would re-introduce the host folder (e.g. EQ-A/FOCAS/H1/Identity/Model) —
// fails here. Belt-and-suspenders: the raw discovered "H1" segment is also checked case-SENSITIVELY
// (a lowercase-only "h1" check would miss a leaked raw "H1").
eqANodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-A", "FOCAS/Identity", "Model"));
eqANodeId.ShouldBe(V3NodeIds.Uns("EQ-A", "FOCAS", "Identity", "Model"));
eqANodeId.ShouldNotContain("H1");
eqANodeId.ShouldNotContain("h1");
eqBNodeId.ShouldBe(EquipmentNodeIds.Variable("EQ-B", "FOCAS/Status", "Run"));
eqBNodeId.ShouldBe(V3NodeIds.Uns("EQ-B", "FOCAS", "Status", "Run"));
eqBNodeId.ShouldNotContain("h2");
// (b) The driver subscribes the UNION of both devices' FixedTree refs (tag-less ⇒ no authored refs).
@@ -264,7 +291,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// NO matching equipment is warn-skipped (NOT mis-grafted), while the matched partitions still graft. Here
/// d1 fans out to EQ-A("h1") + EQ-B("h2"), but a third discovered partition "h3" matches no equipment: only
/// EQ-A + EQ-B materialise (no third), and the unmatched ref routes nowhere (no crash).</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Multi_device_unmatched_device_host_is_warn_skipped_matched_still_graft()
{
var db = NewInMemoryDbFactory();
@@ -305,7 +332,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// logged at Debug, which the suite's <c>loglevel = WARNING</c> HOCON suppresses at source, so EventFilter
/// observes the dedup as "zero further matching warnings". (The matched EQ-A/EQ-B partitions still graft on
/// pass 1; pass 2's matched routing is short-circuited by <c>PlansRoutingEqual</c>.)</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Repeated_unmatched_device_host_partition_warns_once_then_is_quiet()
{
var db = NewInMemoryDbFactory();
@@ -378,7 +405,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// <summary>Dedup: a discovered node whose <c>FullReference</c> equals an authored equipment tag's
/// FullName is NOT injected (it would shadow the authored node) — only the genuinely-new FixedTree refs
/// are materialised.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Discovered_node_shadowing_an_authored_ref_is_not_injected()
{
var db = NewInMemoryDbFactory();
@@ -413,7 +440,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// re-discovers each ~2s pass) is a no-op — it materialises ONCE and does not force the child to
/// re-subscribe. A GROWN set, however, DOES re-apply (materialise again + re-subscribe), so a stabilising
/// FixedTree still converges.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Repeated_identical_discovery_does_not_reapply_but_a_grown_set_does()
{
var db = NewInMemoryDbFactory();
@@ -469,7 +496,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// FixedTree routes + materialised nodes would be lost until the driver reconnects. Asserts the cached
/// plan is (a) re-materialised under EQ-1 after the rebuild, (b) still present in the child's
/// post-redeploy subscription, and (c) still routes a published value to its mapped NodeId.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Discovered_nodes_survive_a_redeploy_rebuild()
{
var db = NewInMemoryDbFactory();
@@ -536,7 +563,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// (its authored tags were removed) must DROP the cached discovered plan rather than re-graft it onto a
/// stale equipment. After such a redeploy the host re-applies the authored rebuild but does NOT re-tell
/// <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> for the now-unresolved driver.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Discovered_nodes_dropped_when_equipment_no_longer_resolves()
{
var db = NewInMemoryDbFactory();
@@ -578,7 +605,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// drops the entry. After the redeploy NO <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> is
/// re-told. (Complements <see cref="Discovered_nodes_dropped_when_equipment_no_longer_resolves"/>, which
/// covers the Count==0 branch; this covers the rebind/StartsWith branch.)</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Discovered_nodes_dropped_when_driver_rebound_to_a_different_equipment()
{
var db = NewInMemoryDbFactory();
@@ -627,7 +654,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// single Once pass — impossible without the trigger, since the child stays Connected so nothing else re-kicks
/// discovery) AND a fresh <see cref="OpcUaPublishActor.MaterialiseDiscoveredNodes"/> re-grafts the FixedTree
/// under the new equipment EQ-2.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Config_unchanged_rebind_re_triggers_discovery_on_the_child()
{
var db = NewInMemoryDbFactory();
@@ -706,7 +733,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// guard — so they would NOT catch a future regression that sets <c>droppedAny</c> on a non-drop path). The
/// regression is observable here: a spurious trigger would advance <c>DiscoverCount</c> past the single Once
/// pass.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void No_drop_redeploy_does_not_re_trigger_discovery()
{
var db = NewInMemoryDbFactory();
@@ -757,7 +784,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// SubscribeAsync — no de-dup in <see cref="DriverInstanceActor"/>): the count rises by EXACTLY 1 across the
/// redeploy and the final subscribed set is the union. (Pre-task: the bulk loop ALSO sent the authored-only
/// set first ⇒ the count rose by 2 and the set transiently dropped "ft-ref-1".)</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Cached_driver_survivor_redeploy_sends_exactly_one_union_subscription()
{
var db = NewInMemoryDbFactory();
@@ -823,7 +850,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// via <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only, NOT the dropped "ft-ref-1"). (It also
/// gets a <see cref="DriverInstanceActor.TriggerRediscovery"/> — a different message type the non-discovery
/// child no-ops, so it adds no subscribe.) Guards that the bulk-skip didn't reduce this path to ZERO sends.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Cached_driver_fully_dropped_redeploy_sends_exactly_one_authored_only_fallback()
{
var db = NewInMemoryDbFactory();
@@ -885,7 +912,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// authored-only <see cref="DriverInstanceActor.SetDesiredSubscriptions"/> per redeploy (the re-inject tail
/// never runs for it). Guards that the skip didn't accidentally suppress (or double) a non-cached driver's
/// send. Observed via <c>SubscribeCount</c> (+1) and the subscribed set ("40001" only).</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Non_cached_driver_redeploy_sends_exactly_one_authored_only_subscription()
{
var db = NewInMemoryDbFactory();
@@ -934,7 +961,7 @@ public sealed class DriverHostActorDiscoveryTests : RuntimeActorTestBase
/// the Connected handler routes to <c>Unsubscribe</c> (dropping the stale FixedTree handle) — NOT a subscribe.
/// Proven by <c>SubscribeCount</c> staying FLAT across the redeploy (no spurious subscribe), closing the
/// SubscribeCount-proxy blind spot for the empty-set fallback.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
[Fact(Skip = DarkAddressSpaceReasons.DiscoveryInjectionDormantV3)]
public void Cached_tag_less_driver_fully_dropped_redeploy_sends_empty_fallback_without_subscribing()
{
var db = NewInMemoryDbFactory();
@@ -18,20 +18,20 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Verifies the equipment-tag <b>live-value routing</b> wired into <see cref="DriverHostActor"/>:
/// a driver publishes a value keyed by its wire-ref <c>FullName</c>, but the equipment variable was
/// materialised under a FOLDER-SCOPED NodeId (<c>{equipmentId}/{folderPath}/{name}</c>). After an
/// apply, the host's <c>_nodeIdByDriverRef</c> map resolves <c>(DriverInstanceId, FullName)</c> to
/// that folder-scoped NodeId, so <c>ForwardToMux</c> lands the value on the right node (and still
/// forwards the raw value to the dependency mux for VirtualTag inputs).
///
/// v3 Batch 4 (B4-WP3) — the driver <b>single-source fan-out</b> wired into
/// <see cref="DriverHostActor"/>. The driver publishes a value keyed by its wire-ref (== the tag's
/// <c>RawPath</c>); the host's <c>_nodeIdByDriverRef</c> map — rebuilt each apply from the composition's
/// <c>RawTags</c> <c>UnsReferenceVariables</c> resolves <c>(DriverInstanceId, RawPath)</c> to the RAW
/// NodeId AND every referencing UNS NodeId, so <c>ForwardToMux</c> lands ONE publish on the raw node
/// (<see cref="AddressSpaceRealm.Raw"/>) plus every UNS node (<see cref="AddressSpaceRealm.Uns"/>) with
/// IDENTICAL value/quality/timestamp (no independent buffer ⇒ no drift). The raw value is still forwarded
/// to the dependency mux (VirtualTag inputs), keyed by the RawPath.
/// <para>
/// Drives a real apply through the existing harness: the seeded artifact carries the
/// <c>Namespaces</c> / <c>DriverInstances</c> / <c>Tags</c> arrays that
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags, and a
/// <c>DispatchDeployment</c> triggers the <c>ApplyAndAck → PushDesiredSubscriptions</c> pass
/// that builds the map. The OPC UA sink and the dependency mux are injected as
/// <see cref="Akka.TestKit.TestProbe"/>s.
/// Drives a real apply through the existing harness: the seeded artifact carries the v3 raw-tag chain
/// (RawFolder → DriverInstance(RawFolderId) → Device → Tag) + an optional UnsTagReference projecting
/// each raw tag into an equipment. <c>DispatchDeployment</c> triggers the
/// <c>ApplyAndAck → PushDesiredSubscriptions</c> pass that builds the map; the sink and mux are
/// injected as <see cref="Akka.TestKit.TestProbe"/>s.
/// </para>
/// </summary>
public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
@@ -40,55 +40,95 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly DateTime Ts = new(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc);
/// <summary>A driver value published by FullName lands on the equipment variable's folder-scoped
/// NodeId (here <c>eq-1/speed</c>, no sub-folder), carrying the value/quality/timestamp.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Driver_value_routes_to_folder_scoped_equipment_NodeId()
/// <summary>A driver value published by RawPath fans to the RAW node AND the referencing UNS node, each
/// carrying identical value/quality/timestamp — the fan-out drift guard.</summary>
[Fact]
public void Driver_value_fans_out_to_raw_and_uns_NodeIds_with_identical_value()
{
var db = NewInMemoryDbFactory();
// One equipment tag: eq-1, drv-1, FullName "40001", no folder, Name "speed".
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId);
// The driver publishes by its wire-ref RawPath.
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "40001", 42.0, OpcUaQuality.Good, Ts));
"drv-1", "Plant/Modbus/dev1/speed", 42.0, OpcUaQuality.Good, Ts));
var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
update.NodeId.ShouldBe("eq-1/speed");
update.Value.ShouldBe(42.0);
update.Quality.ShouldBe(OpcUaQuality.Good);
update.TimestampUtc.ShouldBe(Ts);
var a = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
var b = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
var byId = new[] { a, b }.ToDictionary(u => u.NodeId);
// The raw value is still forwarded to the dependency mux (VirtualTag inputs, keyed by FullName).
byId.Keys.ShouldBe(new[] { "Plant/Modbus/dev1/speed", "filling/line1/station1/speed" }, ignoreOrder: true);
// Realm travels with each update so the sink resolves the right namespace.
byId["Plant/Modbus/dev1/speed"].Realm.ShouldBe(AddressSpaceRealm.Raw);
byId["filling/line1/station1/speed"].Realm.ShouldBe(AddressSpaceRealm.Uns);
// Fan-out drift guard: identical value + quality + timestamp on BOTH node states.
foreach (var u in byId.Values)
{
u.Value.ShouldBe(42.0);
u.Quality.ShouldBe(OpcUaQuality.Good);
u.TimestampUtc.ShouldBe(Ts);
}
// The raw publish still reaches the dependency mux (VirtualTag inputs), keyed by RawPath.
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
.FullReference.ShouldBe("40001");
.FullReference.ShouldBe("Plant/Modbus/dev1/speed");
}
/// <summary>The same driver ref backing two equipments fans out: a single publish produces one
/// <see cref="OpcUaPublishActor.AttributeValueUpdate"/> per equipment variable NodeId.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Same_ref_on_two_equipments_fans_out_to_both_NodeIds()
/// <summary>A raw tag with NO UNS reference fans to ONLY its raw NodeId (Raw realm).</summary>
[Fact]
public void Unreferenced_raw_tag_fans_to_raw_node_only()
{
var db = NewInMemoryDbFactory();
// Same (drv-1, "40001") wire-ref backs eq-1/speed AND eq-2/speed (identical machines).
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"),
(Equip: "eq-2", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
unsRefs: Array.Empty<(string, string, string, string, string?)>());
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "40001", 7.0, OpcUaQuality.Good, Ts));
"drv-1", "Plant/Modbus/dev1/speed", 5.0, OpcUaQuality.Good, Ts));
// One publish → two updates. Assert the SET of NodeIds (order is not contractual).
var first = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
var second = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
new[] { first.NodeId, second.NodeId }.ShouldBe(
new[] { "eq-1/speed", "eq-2/speed" }, ignoreOrder: true);
first.Value.ShouldBe(7.0);
second.Value.ShouldBe(7.0);
var only = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
only.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
only.Realm.ShouldBe(AddressSpaceRealm.Raw);
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>A raw tag referenced by TWO equipments fans to the raw node PLUS both UNS nodes (3 updates,
/// identical value) — the 1:N fan-out.</summary>
[Fact]
public void Raw_tag_referenced_by_two_equipments_fans_to_raw_plus_both_uns()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
unsRefs: new[]
{
(Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null),
(Area: "filling", Line: "line1", Equip: "station2", BackingTag: "speed", Effective: (string?)null),
});
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "Plant/Modbus/dev1/speed", 7.0, OpcUaQuality.Good, Ts));
var updates = new[]
{
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
};
updates.Select(u => u.NodeId).ShouldBe(new[]
{
"Plant/Modbus/dev1/speed",
"filling/line1/station1/speed",
"filling/line1/station2/speed",
}, ignoreOrder: true);
updates.ShouldAllBe(u => (double)u.Value! == 7.0);
}
/// <summary>A value for a ref not in the composition is NOT pushed to the OPC UA sink (no matching
@@ -97,25 +137,23 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
public void Unmatched_ref_is_dropped_from_sink_but_still_forwarded_to_mux()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var deploymentId = SeedV3Deployment(db, RevA,
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
unsRefs: Array.Empty<(string, string, string, string, string?)>());
var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "59999", 99.0, OpcUaQuality.Good, Ts));
"drv-1", "Plant/Modbus/dev1/nope", 99.0, OpcUaQuality.Good, Ts));
// No equipment-tag NodeId for ("drv-1","59999") → nothing reaches the sink.
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
// The raw publish still reaches the dependency mux.
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
.FullReference.ShouldBe("59999");
.FullReference.ShouldBe("Plant/Modbus/dev1/nope");
}
/// <summary>Spawns the host with publish + mux probes, dispatches the deployment, and waits for the
/// Applied ACK so the apply (and thus the map build in PushDesiredSubscriptions) has completed
/// before the test publishes a value. A VirtualTag-host probe is injected so the real host isn't
/// spawned (it would consume the mux-forwarded publishes otherwise — see SpawnVirtualTagHost).</summary>
/// Applied ACK so the map build in PushDesiredSubscriptions has completed before the test publishes a
/// value. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Mux) SpawnHostAndApply(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
{
@@ -134,47 +172,68 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
// RebuildAddressSpace also lands on the publish probe during apply; drain it so the test's
// ExpectMsg<AttributeValueUpdate> assertions see only value updates.
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
return (actor, publish, mux);
}
/// <summary>
/// Seeds a Sealed deployment whose artifact carries the minimal arrays
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags:
/// <c>Namespaces</c> (one Equipment-kind ns), <c>DriverInstances</c> (each driver bound to that
/// ns), and <c>Tags</c> (each with a non-null EquipmentId + a TagConfig blob carrying FullName).
/// </summary>
private static DeploymentId SeedDeploymentWithEquipmentTags(
/// <summary>Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
/// DriverInstance(RawFolderId) → Device → Tag) plus optional UnsTagReferences projecting a raw tag into an
/// Area/Line/Equipment. Each raw tag's RawPath is <c>Plant/{DriverName}/{Device}/{Tag}</c>; each UNS
/// reference's NodeId is <c>{Area}/{Line}/{Equip}/{Effective ?? backing tag Name}</c>. Enums serialize
/// numerically (AccessLevel: ReadWrite = 1).</summary>
private static DeploymentId SeedV3Deployment(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
(string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags,
(string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs)
{
var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();
var drivers = rawTags
.Select(t => (t.Driver, t.DriverName))
.Distinct()
.Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false })
.ToArray();
var devices = rawTags
.Select(t => (t.Driver, t.Device))
.Distinct()
.Select(d => new { DeviceId = $"{d.Driver}:{d.Device}", DriverInstanceId = d.Driver, Name = d.Device, DeviceConfig = "{}" })
.ToArray();
var tags = rawTags.Select(t => new
{
TagId = t.Tag, // tag id == name for test simplicity (unique per driver here)
DeviceId = $"{t.Driver}:{t.Device}",
TagGroupId = (string?)null,
Name = t.Tag,
DataType = t.DataType,
AccessLevel = t.Writable ? 1 : 0, // TagAccessLevel.ReadWrite = 1
TagConfig = "{}",
}).ToArray();
// UNS topology: one area/line per distinct (Area, Line); one equipment per (Area, Line, Equip).
var areas = unsRefs.Select(r => r.Area).Distinct(StringComparer.Ordinal)
.Select(a => new { UnsAreaId = $"a-{a}", Name = a, ClusterId = "c1" }).ToArray();
var lines = unsRefs.Select(r => (r.Area, r.Line)).Distinct()
.Select(l => new { UnsLineId = $"l-{l.Area}-{l.Line}", UnsAreaId = $"a-{l.Area}", Name = l.Line }).ToArray();
var equipment = unsRefs.Select(r => (r.Area, r.Line, r.Equip)).Distinct()
.Select(e => new { EquipmentId = $"EQ-{e.Area}-{e.Line}-{e.Equip}", UnsLineId = $"l-{e.Area}-{e.Line}", Name = e.Equip, MachineCode = e.Equip }).ToArray();
var unsTagReferences = unsRefs.Select((r, i) => new
{
UnsTagReferenceId = $"ref-{i}",
EquipmentId = $"EQ-{r.Area}-{r.Line}-{r.Equip}",
TagId = r.BackingTag,
DisplayNameOverride = r.Effective,
}).ToArray();
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
},
DriverInstances = driverIds.Select(d => new
{
DriverInstanceId = d,
NamespaceId = "ns-eq",
}).ToArray(),
Tags = tags.Select((t, i) => new
{
TagId = $"tag-{i}",
EquipmentId = t.Equip,
DriverInstanceId = t.Driver,
Name = t.Name,
FolderPath = t.Folder,
DataType = "Double",
TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
}).ToArray(),
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = drivers,
Devices = devices,
TagGroups = Array.Empty<object>(),
Tags = tags,
UnsAreas = areas,
UnsLines = lines,
Equipment = equipment,
UnsTagReferences = unsTagReferences,
});
var id = DeploymentId.NewId();
@@ -19,21 +19,22 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Verifies the inbound native-condition <b>acknowledge</b> routing wired into
/// <see cref="DriverHostActor"/> (H6d): an OPC UA client Acknowledges a NATIVE condition, the
/// node manager invokes <c>NativeAlarmAckRouter</c>, and the host (NEXT task) Tells a
/// <see cref="DriverHostActor.RouteNativeAlarmAck"/> in. The host resolves the condition NodeId →
/// owning <c>(DriverInstanceId, FullName)</c> via the <c>_driverRefByAlarmNodeId</c> inverse map
/// (built alongside the alarm forward map in <c>PushDesiredSubscriptions</c>), applies the SAME
/// v3 Batch 4 (B4-WP4) — the inbound native-condition <b>acknowledge</b> routing wired into
/// <see cref="DriverHostActor"/> re-expressed for the v3 raw-condition model. An OPC UA client Acknowledges
/// a NATIVE condition (materialised at the raw tag, ConditionId = its RawPath); the node manager invokes
/// <c>NativeAlarmAckRouter</c> and the host receives a <see cref="DriverHostActor.RouteNativeAlarmAck"/>.
/// The host resolves the condition NodeId (== the RawPath) → owning <c>(DriverInstanceId, RawPath)</c> via
/// the <c>_driverRefByAlarmNodeId</c> inverse map (built alongside the alarm forward map in
/// <c>PushDesiredSubscriptions</c> from the alarm-bearing <c>composition.RawTags</c>), applies the SAME
/// primary gate the inbound write path uses, and routes to the owning driver child's
/// <see cref="IAlarmSource.AcknowledgeAsync"/> carrying the principal.
///
/// <para>
/// Mirrors <c>DriverHostActorWriteRoutingTests</c>: a real apply through the existing harness
/// spawns a real (non-stubbed) <see cref="DriverInstanceActor"/> child backed by a recording
/// <see cref="IAlarmSource"/> driver, so the inverse map is populated authentically and the
/// forwarded acknowledge request can be observed. The seeded tag carries an <c>alarm</c> object so
/// it materialises as a Part 9 condition (folder-scoped condition NodeId), not a value variable.
/// Mirrors <c>DriverHostActorLiveValueTests</c>: a real apply through the harness spawns a real
/// (non-stubbed) <see cref="DriverInstanceActor"/> child backed by a recording <see cref="IAlarmSource"/>
/// driver (DriverType "GalaxyMxGateway", Enabled), so the inverse map is populated authentically and the
/// forwarded acknowledge request can be observed. The seeded raw tag carries an <c>alarm</c> object so it
/// materialises as a Part 9 condition at its RawPath, not a value variable.
/// </para>
/// </summary>
public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTestBase
@@ -42,31 +43,31 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
/// <summary>On the PRIMARY (role unknown ⇒ Primary), a RouteNativeAlarmAck for a mapped condition NodeId
/// forwards exactly one <see cref="AlarmAcknowledgeRequest"/> to the owning driver's
/// <see cref="IAlarmSource.AcknowledgeAsync"/>, with <c>ConditionId == FullName</c>, the operator
/// principal, and the comment.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
// The v3 RawPath for the seeded alarm tag: RawFolder "Plant" / DriverName "gw" / Device "dev1" / Tag.
private const string AlarmRawPath = "Plant/gw/dev1/temp_hi";
/// <summary>On the PRIMARY (role unknown ⇒ Primary), a RouteNativeAlarmAck for a mapped raw condition NodeId
/// (== the RawPath) forwards exactly one <see cref="AlarmAcknowledgeRequest"/> to the owning driver's
/// <see cref="IAlarmSource.AcknowledgeAsync"/>, correlated on the RawPath, with the operator principal + the
/// comment.</summary>
[Fact]
public void RouteNativeAlarmAck_routes_to_driver_AcknowledgeAsync_with_principal()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
// One alarm-bearing equipment tag: eq-1, drv-1, FullName "Temp.HiHi", no folder, Name "temp_hi"
// → condition NodeId "eq-1/temp_hi".
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var actor = SpawnHostAndApply(db, deploymentId, recorder);
// Local role unknown ⇒ treated as Primary ⇒ ack allowed (default-allow semantics).
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", "cmt", "alice"));
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice"));
// The driver received exactly one acknowledge, correlated on its wire-ref FullName, with principal.
// The driver received exactly one acknowledge, correlated on its wire-ref RawPath, with principal.
AwaitAssert(() =>
{
recorder.Acks.Count.ShouldBe(1);
recorder.Acks[0].ConditionId.ShouldBe("Temp.HiHi");
recorder.Acks[0].SourceNodeId.ShouldBe("Temp.HiHi");
recorder.Acks[0].ConditionId.ShouldBe(AlarmRawPath);
recorder.Acks[0].SourceNodeId.ShouldBe(AlarmRawPath);
recorder.Acks[0].Comment.ShouldBe("cmt");
recorder.Acks[0].OperatorUser.ShouldBe("alice");
}, duration: Timeout);
@@ -79,27 +80,24 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var actor = SpawnHostAndApply(db, deploymentId, recorder);
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/does-not-exist", "cmt", "alice"));
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("Plant/gw/dev1/does-not-exist", "cmt", "alice"));
// Give the (fire-and-forget) handler time to run; the unmapped node must produce no ack.
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
}
/// <summary>On a SECONDARY node the ack is gated off (same primary gate as the inbound write path): the
/// driver's <see cref="IAlarmSource.AcknowledgeAsync"/> is NOT called — a secondary keeps its address
/// space warm but must not push commands to the shared upstream alarm system.</summary>
/// driver's <see cref="IAlarmSource.AcknowledgeAsync"/> is NOT called.</summary>
[Fact]
public void RouteNativeAlarmAck_on_non_primary_is_dropped()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingAlarmDriverFactory("GalaxyMxGateway");
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var actor = SpawnHostAndApply(db, deploymentId, recorder);
@@ -112,7 +110,7 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
},
CorrelationId.NewId()));
actor.Tell(new DriverHostActor.RouteNativeAlarmAck("eq-1/temp_hi", "cmt", "alice"));
actor.Tell(new DriverHostActor.RouteNativeAlarmAck(AlarmRawPath, "cmt", "alice"));
// No ack reached the driver — the gate short-circuited before the inverse-map lookup.
AwaitAssert(() => recorder.Acks.ShouldBeEmpty(), duration: TimeSpan.FromMilliseconds(800));
@@ -137,51 +135,48 @@ public sealed class DriverHostActorNativeAlarmAckRoutingTests : RuntimeActorTest
}
/// <summary>
/// Seeds a Sealed deployment whose artifact carries one alarm-bearing equipment tag: the tag's
/// <c>TagConfig</c> carries both a <c>FullName</c> and an <c>alarm</c> object so
/// <c>DeploymentArtifact.ExtractTagAlarm</c> projects a non-null <c>EquipmentTagAlarmInfo</c> —
/// making the tag a condition (folder-scoped condition NodeId) rather than a value variable. The
/// <c>DriverInstances</c> row carries a non-Windows-only <c>DriverType</c> ("GalaxyMxGateway") + an
/// Enabled flag so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned.
/// Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
/// DriverInstance(RawFolderId, Name "gw", DriverType "GalaxyMxGateway", Enabled) → Device "dev1" → Tag)
/// with the tag's <c>TagConfig</c> carrying an <c>alarm</c> object so the composer projects a non-null
/// <c>EquipmentTagAlarmInfo</c> — making the raw tag a Part 9 condition at its RawPath. The non-Windows
/// <c>DriverType</c> + Enabled flag spawn a REAL <see cref="DriverInstanceActor"/> child.
/// </summary>
private static DeploymentId SeedDeploymentWithAlarmTag(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
string Equip, string Driver, string FullName, string? Folder, string Name)
private static DeploymentId SeedV3AlarmDeployment(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev, string Driver, string Tag)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
},
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
new
{
DriverInstanceRowId = Guid.NewGuid(),
DriverInstanceId = Driver,
Name = Driver,
RawFolderId = "rf-plant",
Name = "gw",
DriverType = "GalaxyMxGateway", // not Windows-only ⇒ a real child is spawned (not stubbed)
Enabled = true,
DriverConfig = "{}",
NamespaceId = "ns-eq",
ClusterId = "c1",
},
},
Devices = new[]
{
new { DeviceId = $"{Driver}:dev1", DriverInstanceId = Driver, Name = "dev1", DeviceConfig = "{}" },
},
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new
{
TagId = "tag-0",
EquipmentId = Equip,
DriverInstanceId = Driver,
Name,
FolderPath = Folder,
DeviceId = $"{Driver}:dev1",
TagGroupId = (string?)null,
Name = Tag,
DataType = "Boolean",
TagConfig = JsonSerializer.Serialize(new
{
FullName,
alarm = new { alarmType = "OffNormalAlarm", severity = 700 },
}),
AccessLevel = 0, // TagAccessLevel.Read
TagConfig = JsonSerializer.Serialize(new { alarm = new { alarmType = "OffNormalAlarm", severity = 700 } }),
},
},
});
@@ -24,22 +24,20 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Verifies the equipment-tag <b>native-alarm routing</b> wired into <see cref="DriverHostActor"/>
/// (Phase B WS-4c, the LIVE-CONDITION half): a driver child publishes a native alarm transition as
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/> keyed by the alarm source's
/// <c>SourceNodeId</c> (the equipment tag's wire-ref <c>FullName</c>), but the materialised condition
/// lives at a FOLDER-SCOPED NodeId (<c>{equipmentId}/{folderPath}/{name}</c>). After an apply, the
/// host's <c>_alarmNodeIdByDriverRef</c> map (built only from alarm-bearing EquipmentTags) resolves
/// <c>(DriverInstanceId, SourceNodeId)</c> to that NodeId, the <c>NativeAlarmProjector</c> projects
/// the transition into a full <c>AlarmConditionSnapshot</c>, and <c>ForwardNativeAlarm</c> Tells the
/// publish actor an <see cref="OpcUaPublishActor.AlarmStateUpdate"/> — the same message scripted
/// alarms use.
/// v3 Batch 4 (B4-WP4) — the equipment-tag <b>native-alarm routing</b> wired into
/// <see cref="DriverHostActor"/> re-expressed for the v3 raw-condition model. A native alarm is a SINGLE
/// Part 9 condition materialised at the RAW tag (ConditionId = its RawPath, Raw realm) — NOT a folder-scoped
/// equipment-tag NodeId. After an apply, the host's <c>_alarmNodeIdByDriverRef</c> map (built from the
/// alarm-bearing <c>composition.RawTags</c>) resolves <c>(DriverInstanceId, RawPath)</c> to the raw
/// condition NodeId, the <c>NativeAlarmProjector</c> projects the transition into a full
/// <c>AlarmConditionSnapshot</c>, and <c>ForwardNativeAlarm</c> Tells the publish actor a single
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> (Raw realm) — the fan-out to referencing equipment
/// happens at the SDK-notifier level (WP4 node-manager wiring), NOT by re-reporting per root.
///
/// <para>
/// Mirrors the value-routing harness in <c>DriverHostActorLiveValueTests</c>: the seeded artifact
/// carries the <c>Namespaces</c> / <c>DriverInstances</c> / <c>Tags</c> arrays, with each alarm
/// tag's <c>TagConfig</c> carrying an <c>alarm</c> object so
/// <c>DeploymentArtifact.ExtractTagAlarm</c> projects a non-null
/// Mirrors the v3 value-routing harness in <c>DriverHostActorLiveValueTests</c>: the seeded artifact
/// carries the v3 raw-tag chain (RawFolder → DriverInstance(RawFolderId) → Device → Tag) with the alarm
/// tag's <c>TagConfig</c> carrying an <c>alarm</c> object so the composer projects a non-null
/// <c>EquipmentTagAlarmInfo</c>. The OPC UA sink + dependency mux are injected as TestProbes.
/// </para>
/// </summary>
@@ -49,25 +47,26 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly DateTime Ts = new(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc);
/// <summary>A native alarm RAISE whose <c>ConditionId</c> equals the alarm tag's <c>FullName</c> lands on
/// the condition's folder-scoped NodeId (here <c>eq-1/temp_hi</c>) as an
// The v3 RawPath for the seeded alarm tag: RawFolder "Plant" / DriverName "Modbus" / Device "dev1" / Tag.
private const string AlarmRawPath = "Plant/Modbus/dev1/temp_hi";
/// <summary>A native alarm RAISE whose <c>ConditionId</c> equals the alarm tag's RawPath (the v3 wire-ref)
/// lands on the RAW condition NodeId (== the RawPath, Raw realm) as an
/// <see cref="OpcUaPublishActor.AlarmStateUpdate"/> with <c>State.Active == true</c>. The event carries a
/// production-shaped <c>SourceNodeId</c> (the bare owning object, distinct from <c>ConditionId</c>) so the
/// lookup is proven to key on <c>ConditionId</c>, not <c>SourceNodeId</c>.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Native_alarm_raise_routes_to_folder_scoped_condition_NodeId_active()
/// distinct <c>SourceNodeId</c> so the lookup is proven to key on <c>ConditionId</c>, not
/// <c>SourceNodeId</c>.</summary>
[Fact]
public void Native_alarm_raise_routes_to_raw_condition_NodeId_active()
{
var db = NewInMemoryDbFactory();
// One alarm-bearing equipment tag: eq-1, drv-1, FullName "Temp.HiHi", no folder, Name "temp_hi".
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key)
ConditionId: AlarmRawPath, // the v3 wire-ref RawPath = the lookup key
AlarmType: "OffNormalAlarm",
Message: "temperature high",
Severity: AlarmSeverity.High,
@@ -75,7 +74,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
Kind: AlarmTransitionKind.Raise)));
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
update.AlarmNodeId.ShouldBe("eq-1/temp_hi");
update.AlarmNodeId.ShouldBe(AlarmRawPath);
update.Realm.ShouldBe(AddressSpaceRealm.Raw);
update.State.Active.ShouldBeTrue();
update.State.Acknowledged.ShouldBeFalse();
update.TimestampUtc.ShouldBe(Ts);
@@ -87,35 +87,31 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
public void Unknown_alarm_ref_produces_no_AlarmStateUpdate()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp", // owning object exists, but the condition ref below is unmapped
ConditionId: "NoSuch.HiHi", // dotted ref not in the alarm map ⇒ drop
SourceNodeId: "Temp",
ConditionId: "Plant/Modbus/dev1/no_such", // unmapped RawPath ⇒ drop
AlarmType: "OffNormalAlarm",
Message: "nope",
Severity: AlarmSeverity.Low,
SourceTimestampUtc: Ts,
Kind: AlarmTransitionKind.Raise)));
// No alarm-condition NodeId for ("drv-1","NoSuch.Alarm") → nothing reaches the sink.
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>Primary (default/unset role) fan-out (Phase B WS-5): a native alarm RAISE on a known ref
/// publishes exactly one <see cref="AlarmTransitionEvent"/> to the cluster <c>alerts</c> topic with
/// <c>AlarmId</c> = the folder-scoped condition NodeId, alongside the (ungated) OPC UA condition
/// update. No <see cref="RedundancyStateChanged"/> is sent, so the cached role is unknown ⇒ emit.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
/// <summary>Primary (default/unset role) fan-out: a native alarm RAISE on a known ref publishes exactly one
/// <see cref="AlarmTransitionEvent"/> to the cluster <c>alerts</c> topic with <c>AlarmId</c> = the raw
/// condition NodeId (RawPath), alongside the (ungated) OPC UA condition update.</summary>
[Fact]
public void Native_alarm_publishes_AlarmTransitionEvent_to_alerts_when_primary()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
@@ -124,8 +120,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key)
SourceNodeId: "Temp",
ConditionId: AlarmRawPath,
AlarmType: "OffNormalAlarm",
Message: "temperature high",
Severity: AlarmSeverity.High,
@@ -134,38 +130,31 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
// The OPC UA condition update is UNGATED — it must arrive.
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
update.AlarmNodeId.ShouldBe("eq-1/temp_hi");
update.AlarmNodeId.ShouldBe(AlarmRawPath);
// Role unknown ⇒ default-emit: exactly one AlarmTransitionEvent on the alerts topic.
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
evt.AlarmId.ShouldBe("eq-1/temp_hi"); // the folder-scoped condition NodeId
evt.EquipmentPath.ShouldBe("eq-1"); // from the alarm-bearing tag's EquipmentId
evt.AlarmName.ShouldBe("temp_hi"); // from the tag's Name
evt.AlarmId.ShouldBe(AlarmRawPath); // the raw condition NodeId (v3: ConditionId == RawPath)
evt.EquipmentPath.ShouldBe(AlarmRawPath); // v3: the alarm meta keys the display off the RawPath
evt.AlarmName.ShouldBe("temp_hi"); // from the raw tag's Name
evt.TransitionKind.ShouldBe("Activated"); // native Kind → canonical EmissionKind vocabulary (Raise → Activated)
evt.AlarmTypeName.ShouldBe("OffNormalAlarm"); // the tag's alarm AlarmType
evt.Severity.ShouldBe(700); // AlarmSeverity.High → projector 700
evt.Message.ShouldBe("temperature high");
evt.User.ShouldBe(string.Empty); // no operator comment ⇒ device-origin (empty user)
// This tag's TagConfig.alarm carries no historizeToAveva key ⇒ null ⇒ the HistorianAdapterActor
// gate (historizeToAveva is not false) still historizes (default-on). Only an explicit false
// suppresses the durable AVEVA row — see Native_alarm_historizeToAveva_false_threads_through.
evt.HistorizeToAveva.ShouldBeNull();
evt.HistorizeToAveva.ShouldBeNull(); // absent historizeToAveva key ⇒ null ⇒ default-on
alerts.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // exactly one
}
/// <summary>Native-alarm HistorizeToAveva opt-out (Task 3): a tag whose <c>TagConfig.alarm</c> carries
/// <summary>Native-alarm HistorizeToAveva opt-out: a tag whose <c>TagConfig.alarm</c> carries
/// <c>historizeToAveva: false</c> publishes its <see cref="AlarmTransitionEvent"/> with
/// <c>HistorizeToAveva == false</c>, so the runtime's <c>HistorianAdapterActor</c> gate
/// (<c>historizeToAveva is not false</c>) suppresses the durable AVEVA write — the same opt-out the
/// scripted-alarm plan flag drives. The live <c>/alerts</c> fan-out is unaffected (the transition still
/// publishes; only the durable row is gated downstream).</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
/// <c>HistorizeToAveva == false</c>, so the runtime's <c>HistorianAdapterActor</c> gate suppresses the
/// durable AVEVA write. The live <c>/alerts</c> fan-out is unaffected.</summary>
[Fact]
public void Native_alarm_historizeToAveva_false_threads_through()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi",
historizeToAveva: false);
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", historizeToAveva: false);
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
@@ -175,7 +164,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp",
ConditionId: "Temp.HiHi",
ConditionId: AlarmRawPath,
AlarmType: "OffNormalAlarm",
Message: "temperature high",
Severity: AlarmSeverity.High,
@@ -183,21 +172,17 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
Kind: AlarmTransitionKind.Raise)));
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
evt.AlarmId.ShouldBe("eq-1/temp_hi");
// The explicit opt-out rides onto the transition ⇒ the historian gate suppresses the durable row.
evt.AlarmId.ShouldBe(AlarmRawPath);
evt.HistorizeToAveva.ShouldBe(false);
}
/// <summary>Native-alarm HistorizeToAveva opt-IN (Task 3): an explicit <c>historizeToAveva: true</c>
/// rides through as <c>true</c> (distinct from the absent ⇒ null default-on case) so an operator who
/// deliberately opts in is recorded as such on the transition.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
/// <summary>Native-alarm HistorizeToAveva opt-IN: an explicit <c>historizeToAveva: true</c> rides through as
/// <c>true</c> (distinct from the absent ⇒ null default-on case).</summary>
[Fact]
public void Native_alarm_historizeToAveva_true_threads_through()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi",
historizeToAveva: true);
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi", historizeToAveva: true);
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
@@ -207,7 +192,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp",
ConditionId: "Temp.HiHi",
ConditionId: AlarmRawPath,
AlarmType: "OffNormalAlarm",
Message: "temperature high",
Severity: AlarmSeverity.High,
@@ -218,16 +203,14 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
evt.HistorizeToAveva.ShouldBe(true);
}
/// <summary>Secondary suppression (Phase B WS-5): when the cached local role is Secondary the host
/// MUST still write the local OPC UA condition node (ungated — keeps the standby's address space warm
/// for failover) but MUST NOT publish the cluster-wide <c>alerts</c> transition (the Primary publishes
/// the single fleet-wide copy).</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
/// <summary>Secondary suppression: when the cached local role is Secondary the host MUST still write the
/// local OPC UA condition node (ungated — keeps the standby warm) but MUST NOT publish the cluster-wide
/// <c>alerts</c> transition (the Primary publishes the single fleet-wide copy).</summary>
[Fact]
public void Secondary_node_suppresses_alerts_publish_but_still_updates_condition()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
@@ -239,8 +222,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp", // bare owning object (SourceObjectReference) — NOT the lookup key
ConditionId: "Temp.HiHi", // dotted alarm full-reference = the authored FullName (the lookup key)
SourceNodeId: "Temp",
ConditionId: AlarmRawPath,
AlarmType: "OffNormalAlarm",
Message: "temperature high",
Severity: AlarmSeverity.High,
@@ -249,7 +232,7 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
// The OPC UA condition update is UNGATED — it must still arrive on the secondary.
var update = publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
update.AlarmNodeId.ShouldBe("eq-1/temp_hi");
update.AlarmNodeId.ShouldBe(AlarmRawPath);
update.State.Active.ShouldBeTrue();
// The cluster-wide alerts publish is gated off on the secondary.
@@ -257,27 +240,23 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
}
/// <summary>A native alarm whose <c>AlarmEventArgs.OperatorComment</c> is set flows through
/// <c>DriverHostActor.ForwardNativeAlarm</c> into the published <see cref="AlarmTransitionEvent"/>:
/// <c>Comment</c> carries the operator string and <c>User</c> is <c>"device"</c> (a non-null comment
/// signals the upstream alarm system provided an operator origin, but without a specific user identity).
/// </summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
/// <c>ForwardNativeAlarm</c> into the published <see cref="AlarmTransitionEvent"/>: <c>Comment</c> carries
/// the operator string and <c>User</c> is <c>"device"</c>.</summary>
[Fact]
public void Native_alarm_operator_comment_flows_to_transition_event()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithAlarmTag(db, RevA,
Equip: "eq-1", Driver: "drv-1", FullName: "Temp.HiHi", Folder: null, Name: "temp_hi");
var deploymentId = SeedV3AlarmDeployment(db, RevA, Driver: "drv-1", Tag: "temp_hi");
var alerts = CreateTestProbe();
SubscribeToAlerts(alerts);
var (actor, publish) = SpawnHostAndApply(db, deploymentId);
// Send an alarm whose OperatorComment is set — simulates an upstream acknowledge-with-comment.
actor.Tell(new DriverInstanceActor.AttributeAlarmPublished("drv-1", new AlarmEventArgs(
new StubAlarmHandle(),
SourceNodeId: "Temp",
ConditionId: "Temp.HiHi",
ConditionId: AlarmRawPath,
AlarmType: "OffNormalAlarm",
Message: "investigating",
Severity: AlarmSeverity.High,
@@ -285,18 +264,14 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
Kind: AlarmTransitionKind.Acknowledge,
OperatorComment: "investigating")));
// OPC UA condition update is ungated — drain it.
publish.ExpectMsg<OpcUaPublishActor.AlarmStateUpdate>(TimeSpan.FromSeconds(5));
// The published AlarmTransitionEvent must carry the comment + the "device" user marker.
var evt = alerts.ExpectMsg<AlarmTransitionEvent>(TimeSpan.FromSeconds(5));
evt.Comment.ShouldBe("investigating");
evt.User.ShouldBe("device");
}
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.
/// The Subscribe is sent FROM the probe so the SubscribeAck returns to it. Mirrors the
/// ScriptedAlarmHostActor test harness.</summary>
/// <summary>Subscribe <paramref name="probe"/> to the <c>alerts</c> DPS topic and wait for the ack.</summary>
private void SubscribeToAlerts(TestProbe probe)
{
DistributedPubSub.Get(Sys).Mediator.Tell(
@@ -304,9 +279,8 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
probe.ExpectMsg<SubscribeAck>(TimeSpan.FromSeconds(5));
}
/// <summary>Tell the host a <see cref="RedundancyStateChanged"/> snapshot marking the host's own
/// node (<see cref="TestNode"/>, which equals the host's <c>_localNode</c>) with <paramref name="role"/>
/// so the alerts-publish gate observes the local role.</summary>
/// <summary>Tell the host a <see cref="RedundancyStateChanged"/> snapshot marking the host's own node
/// (<see cref="TestNode"/>) with <paramref name="role"/> so the alerts-publish gate observes it.</summary>
private static void TellRedundancyRole(IActorRef host, RedundancyRole role)
{
host.Tell(new RedundancyStateChanged(
@@ -322,10 +296,10 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
CorrelationId.NewId()));
}
/// <summary>Spawns the host with a publish probe, dispatches the deployment, and waits for the Applied
/// ACK so the apply (and thus the alarm-map build in PushDesiredSubscriptions) has completed before the
/// test publishes an alarm. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
private (IActorRef Actor, Akka.TestKit.TestProbe Publish) SpawnHostAndApply(
/// <summary>Spawns the host with a publish probe, dispatches the deployment, and waits for the Applied ACK
/// so the apply (and thus the alarm-map build in PushDesiredSubscriptions) has completed before the test
/// publishes an alarm. A VirtualTag-host probe is injected so the real host isn't spawned.</summary>
private (IActorRef Actor, TestProbe Publish) SpawnHostAndApply(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
{
var coordinator = CreateTestProbe();
@@ -352,47 +326,46 @@ public sealed class DriverHostActorNativeAlarmTests : RuntimeActorTestBase
}
/// <summary>
/// Seeds a Sealed deployment whose artifact carries one alarm-bearing equipment tag: the tag's
/// <c>TagConfig</c> carries both a <c>FullName</c> and an <c>alarm</c> object
/// (<c>alarmType</c> + <c>severity</c>) so <c>DeploymentArtifact.ExtractTagAlarm</c> projects a
/// non-null <c>EquipmentTagAlarmInfo</c> (here <c>OffNormalAlarm</c> / 700) — making the tag a
/// condition rather than a value variable.
/// Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
/// DriverInstance(RawFolderId, Name "Modbus") → Device "dev1" → Tag) with the tag's <c>TagConfig</c>
/// carrying an <c>alarm</c> object so the composer projects a non-null <c>EquipmentTagAlarmInfo</c>
/// (<c>OffNormalAlarm</c> / 700) — making the raw tag a Part 9 condition at its RawPath rather than a
/// value variable. Enums serialize numerically. No UNS reference is needed for these routing/emit tests
/// (the SDK-notifier fan-out to referencing equipment is covered by the node-manager tests).
/// </summary>
private static DeploymentId SeedDeploymentWithAlarmTag(
private static DeploymentId SeedV3AlarmDeployment(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
string Equip, string Driver, string FullName, string? Folder, string Name,
bool? historizeToAveva = null)
string Driver, string Tag, bool? historizeToAveva = null)
{
// historizeToAveva absent (null) ⇒ omit the key entirely so the absent ⇒ historize default path is
// exercised; a concrete true/false writes the bool into the alarm object so the native path threads it.
// historizeToAveva absent (null) ⇒ omit the key so the absent ⇒ historize default path is exercised;
// a concrete true/false writes the bool into the alarm object so the native path threads it.
object alarm = historizeToAveva is { } h
? new { alarmType = "OffNormalAlarm", severity = 700, historizeToAveva = h }
: new { alarmType = "OffNormalAlarm", severity = 700 };
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
},
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
new { DriverInstanceId = Driver, NamespaceId = "ns-eq" },
new { DriverInstanceId = Driver, RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false },
},
Devices = new[]
{
new { DeviceId = $"{Driver}:dev1", DriverInstanceId = Driver, Name = "dev1", DeviceConfig = "{}" },
},
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new
{
TagId = "tag-0",
EquipmentId = Equip,
DriverInstanceId = Driver,
Name,
FolderPath = Folder,
DeviceId = $"{Driver}:dev1",
TagGroupId = (string?)null,
Name = Tag,
DataType = "Boolean",
TagConfig = JsonSerializer.Serialize(new
{
FullName,
alarm,
}),
AccessLevel = 0, // TagAccessLevel.Read
TagConfig = JsonSerializer.Serialize(new { alarm }),
},
},
});
@@ -6,6 +6,7 @@ using Akka.Cluster.Tools.PublishSubscribe;
using Akka.TestKit;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
@@ -51,7 +52,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
var actor = SpawnWriteHost(db, dep, recorder, driverMemberCount: 2);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse();
@@ -75,7 +76,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
AwaitAssert(() =>
{
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0, AddressSpaceRealm.Uns), asker.Ref);
var res = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
res.Reason.ShouldNotBe("not primary");
res.Reason.ShouldNotBe("not primary (role unknown)");
@@ -100,7 +101,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
AwaitAssert(() =>
{
var asker1 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 1.0), asker1.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 1.0, AddressSpaceRealm.Uns), asker1.Ref);
var res = asker1.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
res.Reason.ShouldNotBe("not primary");
res.Success.ShouldBeTrue(res.Reason);
@@ -109,7 +110,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
// Secondary snapshot ⇒ denied with the steady-state reason.
TellRole(actor, RedundancyRole.Secondary);
var asker2 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 2.0), asker2.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 2.0, AddressSpaceRealm.Uns), asker2.Ref);
var denied = asker2.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
denied.Success.ShouldBeFalse();
denied.Reason.ShouldBe("not primary");
@@ -126,7 +127,7 @@ public sealed class DriverHostActorPrimaryGateTests : RuntimeActorTestBase
var actor = SpawnWriteHost(db, dep, factory, driverMemberCount: 2);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 9.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 9.0, AddressSpaceRealm.Uns), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeFalse();
AwaitAssert(() =>
@@ -3,6 +3,7 @@ using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using Microsoft.EntityFrameworkCore;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
@@ -87,7 +88,7 @@ public sealed class DriverHostActorProbeResultDropTests : RuntimeActorTestBase
// drained its mailbox past the OpcUaProbeResult message, so any dead-letter from an unhandled
// OpcUaProbeResult would already be on the EventStream before we assert.
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-x/speed", 0.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-x/speed", 0.0, AddressSpaceRealm.Uns), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
// Assert no dead-letter carrying an OpcUaProbeResult was published.
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
@@ -20,22 +21,17 @@ using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Verifies the inbound operator-write routing wired into <see cref="DriverHostActor"/>: a
/// <see cref="DriverHostActor.RouteNodeWrite"/> for a materialised equipment-variable NodeId resolves
/// the <c>NodeId → (DriverInstanceId, FullName)</c> reverse map (built alongside the forward map in
/// <c>PushDesiredSubscriptions</c>), gates on this node being the driver PRIMARY (reusing the same
/// <c>RedundancyStateChanged</c> signal the alarm-emit gate uses), forwards a
/// <see cref="DriverInstanceActor.WriteAttribute"/> carrying the driver-side <c>FullName</c> to the
/// right driver child, and replies a <see cref="DriverHostActor.NodeWriteResult"/> to the asker.
///
/// v3 Batch 4 (B4-WP3) — inbound operator-write routing through EITHER NodeId. A
/// <see cref="DriverHostActor.RouteNodeWrite"/> carries the FULL ns-qualified NodeId string (the node
/// manager's write hook passes <c>node.NodeId.ToString()</c>); the host normalises it to the bare id and
/// resolves the <c>NodeId → (DriverInstanceId, RawPath)</c> reverse map — populated in
/// <c>PushDesiredSubscriptions</c> for BOTH the raw NodeId AND every referencing UNS NodeId — gates on the
/// driver PRIMARY, and forwards a <see cref="DriverInstanceActor.WriteAttribute"/> carrying the tag's
/// <c>RawPath</c> to the driver child. So a write to the raw node and a write to the referencing UNS node
/// both reach the SAME driver point (they share the driver ref).
/// <para>
/// Drives a real apply through the existing harness (same artifact shape as
/// <c>DriverHostActorLiveValueTests</c>) so the reverse map is populated authentically and a real
/// (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned. The child is backed by a
/// recording <see cref="IWritable"/> driver so the test can observe the forwarded write and assert
/// the no-write case on the secondary. There is no test seam to inject a <c>TestProbe</c> as a
/// driver child, so this end-to-end approach (recording driver) is the closest faithful test the
/// harness allows.
/// Drives a real apply (Enabled Modbus driver ⇒ a real <see cref="DriverInstanceActor"/> child backed
/// by a recording driver), then routes writes and asserts the forwarded wire-ref + the primary gate.
/// </para>
/// </summary>
public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
@@ -44,48 +40,68 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
/// <summary>On the PRIMARY, a RouteNodeWrite for a mapped NodeId forwards the driver-side FullName to
/// the right driver child (observed via the recording driver) and replies NodeWriteResult(true).</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Primary_routes_write_to_driver_by_full_name_and_replies_success()
// The single seeded raw tag + its UNS reference.
private const string RawPath = "Plant/Modbus/dev1/speed";
private const string UnsNodeId = "filling/line1/station1/speed";
/// <summary>On the PRIMARY, a RouteNodeWrite for the ns-qualified UNS NodeId resolves to the raw tag's
/// driver ref and forwards the write keyed by the tag's RawPath (dual-NodeId write routing).</summary>
[Fact]
public void Primary_routes_write_via_uns_nodeid_to_driver_by_rawpath()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
// One equipment tag: eq-1, drv-1, FullName "40001", no folder, Name "speed" → NodeId "eq-1/speed".
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var deploymentId = SeedV3Deployment(db, RevA);
var actor = SpawnHostAndApply(db, deploymentId, recorder);
// Local role unknown ⇒ treated as Primary ⇒ write allowed (default-emit semantics).
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
// The node manager passes the FULL ns-qualified id; the host must normalise + resolve it.
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeTrue();
// The driver received exactly the write, keyed by its wire-ref FullName (not the NodeId).
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() =>
{
recorder.Writes.Count.ShouldBe(1);
recorder.Writes[0].FullReference.ShouldBe("40001");
recorder.Writes[0].FullReference.ShouldBe(RawPath); // the driver writes by RawPath, not the UNS id
recorder.Writes[0].Value.ShouldBe(123.0);
}, duration: Timeout);
}
/// <summary>On a SECONDARY, RouteNodeWrite replies NodeWriteResult(false, "not primary") and the
/// driver receives NO write — the primary gate fires before the reverse-map lookup.</summary>
/// <summary>On the PRIMARY, a RouteNodeWrite for the ns-qualified RAW NodeId resolves to the same driver
/// ref and forwards the write keyed by the RawPath.</summary>
[Fact]
public void Primary_routes_write_via_raw_nodeid_to_driver_by_rawpath()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
var deploymentId = SeedV3Deployment(db, RevA);
var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=2;s={RawPath}", 456.0, AddressSpaceRealm.Raw), asker.Ref);
asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() =>
{
recorder.Writes.Count.ShouldBe(1);
recorder.Writes[0].FullReference.ShouldBe(RawPath);
recorder.Writes[0].Value.ShouldBe(456.0);
}, duration: Timeout);
}
/// <summary>On a SECONDARY, RouteNodeWrite (via the UNS NodeId) replies "not primary" and the driver
/// receives NO write — the primary gate fires before the reverse-map lookup.</summary>
[Fact]
public void Secondary_rejects_write_and_does_not_forward_to_driver()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var deploymentId = SeedV3Deployment(db, RevA);
var actor = SpawnHostAndApply(db, deploymentId, recorder);
// Force this node Secondary so the primary gate rejects.
actor.Tell(new RedundancyStateChanged(
new[]
{
@@ -95,29 +111,26 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
CorrelationId.NewId()));
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse();
result.Reason.ShouldBe("not primary");
// No write reached the driver — the gate short-circuited before the reverse-map lookup.
recorder.Writes.ShouldBeEmpty();
}
/// <summary>An unknown NodeId (no reverse-map entry) replies NodeWriteResult(false) and writes nothing.</summary>
/// <summary>An unknown NodeId (no reverse-map entry) replies failure and writes nothing.</summary>
[Fact]
public void Unknown_node_id_replies_failure()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
var deploymentId = SeedDeploymentWithEquipmentTags(db, RevA,
(Equip: "eq-1", Driver: "drv-1", FullName: "40001", Folder: null, Name: "speed"));
var deploymentId = SeedV3Deployment(db, RevA);
var actor = SpawnHostAndApply(db, deploymentId, recorder);
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/does-not-exist", 123.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=filling/line1/station1/does-not-exist", 1.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeFalse();
@@ -125,49 +138,11 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
recorder.Writes.ShouldBeEmpty();
}
/// <summary>A protocol TagConfig blob with no <c>FullName</c> key routes by the equipment NodeId, and
/// the forwarded wire-ref is the raw blob verbatim. <c>ExtractTagFullName</c> falls back to the raw
/// blob string when no top-level <c>FullName</c> property is present, so the reverse map keys on
/// <c>(DriverInstanceId, &lt;raw-blob&gt;)</c> and the driver receives that exact string as its
/// <c>WriteRequest.FullReference</c> — not a FullName value extracted from the blob.</summary>
[Fact(Skip = DarkAddressSpaceReasons.EquipmentTagsDarkBatch4)]
public void Primary_routes_write_for_raw_protocol_blob_tag()
{
var db = NewInMemoryDbFactory();
var recorder = new RecordingDriverFactory("Modbus");
// Seed the tag with a genuine protocol blob that has NO FullName key (pure Modbus wire config).
// ExtractTagFullName falls back to returning the raw blob string verbatim, so that string IS the
// wire-ref the reverse map keys on and the driver receives as WriteRequest.FullReference.
var (deploymentId, rawBlobString) = SeedDeploymentWithRawBlobTag(db, RevA,
equip: "eq-2", driver: "drv-2", name: "torque");
var actor = SpawnHostAndApply(db, deploymentId, recorder);
// Local role unknown ⇒ treated as Primary ⇒ write allowed.
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-2/torque", 456.0), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout);
result.Success.ShouldBeTrue();
// The forwarded wire-ref is the raw blob string verbatim — ExtractTagFullName fell back because
// there is no top-level "FullName" key in the blob.
AwaitAssert(() =>
{
recorder.Writes.Count.ShouldBe(1);
recorder.Writes[0].FullReference.ShouldBe(rawBlobString);
recorder.Writes[0].Value.ShouldBe(456.0);
}, duration: Timeout);
}
/// <summary>A RouteNodeWrite arriving while the host is Stale (config DB unreachable) must fast-fail
/// with an immediate negative NodeWriteResult (reason mentions "stale") instead of dead-lettering into
/// the node-manager's bounded-Ask timeout. Drives the host into Stale via a DB factory whose
/// CreateDbContext throws on bootstrap (the same fall-through to <c>Become(Stale)</c> production uses).</summary>
/// <summary>A RouteNodeWrite arriving while the host is Stale (config DB unreachable) fast-fails with an
/// immediate negative reply mentioning "stale".</summary>
[Fact]
public void Stale_host_fast_fails_route_node_write()
{
// A factory that always throws on CreateDbContext ⇒ Bootstrap's try fails ⇒ Become(Stale).
var db = new ThrowingDbFactory();
var coordinator = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
@@ -175,7 +150,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
localRoles: new HashSet<string> { "driver" }));
var asker = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("eq-1/speed", 123.0), asker.Ref);
actor.Tell(new DriverHostActor.RouteNodeWrite($"ns=3;s={UnsNodeId}", 123.0, AddressSpaceRealm.Uns), asker.Ref);
var result = asker.ExpectMsg<DriverHostActor.NodeWriteResult>(TimeSpan.FromSeconds(2));
result.Success.ShouldBeFalse();
@@ -183,10 +158,49 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
result.Reason!.ShouldContain("stale");
}
/// <summary>Spawns the host with the recording driver factory, dispatches the deployment, and waits
/// for the Applied ACK so the apply (and thus the reverse-map build in PushDesiredSubscriptions) has
/// completed before the test routes a write. No OPC UA / mux probes are wired — this test exercises
/// only the write path, which doesn't depend on the publish actor.</summary>
/// <summary>
/// Review H1 regression — a raw <c>RawPath</c> and a UNS path that share a BARE <c>s=</c> id but back
/// DIFFERENT driver instances must each route to their OWN driver. Seeds raw tag <c>A/B/C/D</c> on
/// <c>drv-1</c> and a UnsTagReference whose UNS NodeId is ALSO <c>A/B/C/D</c> (Area A / Line B / Equip C
/// / effective name D) but which backs a raw tag <c>X/Y/Z/W</c> on <c>drv-2</c>. A bare-only routing key
/// would collide (last-writer-wins → wrong device); the <c>(realm, bareId)</c> key keeps them distinct.
/// </summary>
[Fact]
public void Colliding_raw_and_uns_bare_ids_route_to_their_own_driver_by_realm()
{
var db = NewInMemoryDbFactory();
var factory = new PerInstanceRecordingDriverFactory("Modbus");
var deploymentId = SeedCollisionDeployment(db, RevA);
var actor = SpawnHostAndApply(db, deploymentId, factory);
// Write to the RAW node A/B/C/D → drv-1, forwarded by the raw tag's RawPath A/B/C/D.
var asker1 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=2;s=A/B/C/D", 111.0, AddressSpaceRealm.Raw), asker1.Ref);
asker1.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
// Write to the UNS node A/B/C/D → drv-2, forwarded by the BACKING raw tag's RawPath X/Y/Z/W.
var asker2 = CreateTestProbe();
actor.Tell(new DriverHostActor.RouteNodeWrite("ns=3;s=A/B/C/D", 222.0, AddressSpaceRealm.Uns), asker2.Ref);
asker2.ExpectMsg<DriverHostActor.NodeWriteResult>(Timeout).Success.ShouldBeTrue();
AwaitAssert(() =>
{
// drv-1 got ONLY the raw write (A/B/C/D, 111); drv-2 got ONLY the UNS-routed write (X/Y/Z/W, 222).
var w1 = factory.WritesFor("drv-1");
var w2 = factory.WritesFor("drv-2");
w1.Count.ShouldBe(1);
w1[0].FullReference.ShouldBe("A/B/C/D");
w1[0].Value.ShouldBe(111.0);
w2.Count.ShouldBe(1);
w2[0].FullReference.ShouldBe("X/Y/Z/W"); // the backing RawPath, NOT the shared bare id
w2[0].Value.ShouldBe(222.0);
}, duration: Timeout);
}
/// <summary>Spawns the host with the recording driver factory, dispatches the deployment, and waits for
/// the Applied ACK so the reverse-map build in PushDesiredSubscriptions has completed before routing a
/// write. No OPC UA / mux probes are wired — the write path doesn't depend on the publish actor.</summary>
private IActorRef SpawnHostAndApply(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, IDriverFactory factory)
{
@@ -198,49 +212,32 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
return actor;
}
/// <summary>
/// Seeds a Sealed deployment whose artifact carries the minimal arrays
/// <c>DeploymentArtifact.BuildEquipmentTagPlans</c> needs to project equipment tags. Mirrors
/// <c>DriverHostActorLiveValueTests.SeedDeploymentWithEquipmentTags</c> but also carries a
/// <c>DriverInstances</c> row with a non-Windows-only <c>DriverType</c> ("Modbus") + Enabled flag
/// so a REAL (non-stubbed) <see cref="DriverInstanceActor"/> child is spawned for the write path.
/// </summary>
private static DeploymentId SeedDeploymentWithEquipmentTags(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
params (string Equip, string Driver, string FullName, string? Folder, string Name)[] tags)
/// <summary>Seeds a Sealed v3 deployment: RawFolder "Plant" → DriverInstance "drv-1" (Modbus, ENABLED so a
/// real child spawns) → Device "dev1" → Tag "speed" (RawPath <c>Plant/Modbus/dev1/speed</c>, ReadWrite),
/// projected into equipment <c>filling/line1/station1</c> by a UnsTagReference (UNS NodeId
/// <c>filling/line1/station1/speed</c>).</summary>
private static DeploymentId SeedV3Deployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
{
var driverIds = tags.Select(t => t.Driver).Distinct(StringComparer.Ordinal).ToArray();
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
},
DriverInstances = driverIds.Select(d => new
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
TagGroups = Array.Empty<object>(),
Tags = new[]
{
DriverInstanceRowId = Guid.NewGuid(),
DriverInstanceId = d,
Name = d,
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
Enabled = true,
DriverConfig = "{}",
NamespaceId = "ns-eq",
}).ToArray(),
Tags = tags.Select((t, i) => new
{
TagId = $"tag-{i}",
EquipmentId = t.Equip,
DriverInstanceId = t.Driver,
Name = t.Name,
FolderPath = t.Folder,
DataType = "Double",
TagConfig = JsonSerializer.Serialize(new { FullName = t.FullName }),
}).ToArray(),
new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "speed", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
},
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "filling", ClusterId = "c1" } },
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "line1" } },
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "station1", MachineCode = "M1" } },
UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-speed", DisplayNameOverride = (string?)null } },
});
var id = DeploymentId.NewId();
@@ -258,77 +255,54 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
return id;
}
/// <summary>
/// Seeds a single-tag Sealed deployment whose tag's <c>TagConfig</c> is a genuine protocol-driver
/// blob with <strong>no <c>FullName</c> key</strong> (pure Modbus wire config:
/// <c>{"region":"HoldingRegister","address":200,"dataType":"UInt16"}</c>). Because
/// <c>ExtractTagFullName</c> finds no top-level <c>FullName</c> property, it falls back to
/// returning the raw blob string verbatim — that raw string becomes the
/// <c>(DriverInstanceId, &lt;raw-blob&gt;)</c> reverse-map key, and the driver receives it as
/// <c>WriteRequest.FullReference</c>. Returns both the <see cref="DeploymentId"/> and the exact
/// raw blob string so the caller can assert the forwarded wire-ref precisely.
/// </summary>
private static (DeploymentId DeploymentId, string RawBlobString) SeedDeploymentWithRawBlobTag(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
string equip, string driver, string name)
/// <summary>Seeds the review-H1 collision config: raw tag <c>A/B/C/D</c> on drv-1, and a UnsTagReference
/// (Area A / Line B / Equip C, effective name D → UNS NodeId <c>A/B/C/D</c>) backing raw tag <c>X/Y/Z/W</c>
/// on drv-2. Both drivers ENABLED (Modbus) so both children spawn.</summary>
private static DeploymentId SeedCollisionDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
{
// Serialize the blob with NO FullName field — ExtractTagFullName will fall back to this verbatim.
var rawBlobString = JsonSerializer.Serialize(
new { region = "HoldingRegister", address = 200, dataType = "UInt16" });
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
RawFolders = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment = 0
new { RawFolderId = "rf-a", ParentRawFolderId = (string?)null, Name = "A", ClusterId = "c1" },
new { RawFolderId = "rf-x", ParentRawFolderId = (string?)null, Name = "X", ClusterId = "c1" },
},
DriverInstances = new[]
{
new
{
DriverInstanceRowId = Guid.NewGuid(),
DriverInstanceId = driver,
Name = driver,
DriverType = "Modbus", // not Windows-only ⇒ a real child is spawned (not stubbed)
Enabled = true,
DriverConfig = "{}",
NamespaceId = "ns-eq",
},
new { DriverInstanceId = "drv-1", RawFolderId = "rf-a", Name = "B", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
new { DriverInstanceId = "drv-2", RawFolderId = "rf-x", Name = "Y", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
},
Devices = new[]
{
new { DeviceId = "dev-c", DriverInstanceId = "drv-1", Name = "C", DeviceConfig = "{}" },
new { DeviceId = "dev-z", DriverInstanceId = "drv-2", Name = "Z", DeviceConfig = "{}" },
},
TagGroups = Array.Empty<object>(),
Tags = new[]
{
new
{
TagId = "tag-raw",
EquipmentId = equip,
DriverInstanceId = driver,
Name = name,
FolderPath = (string?)null,
DataType = "Double",
// Pure protocol blob: no FullName key. ExtractTagFullName falls back to raw blob.
TagConfig = rawBlobString,
},
new { TagId = "t-d", DeviceId = "dev-c", TagGroupId = (string?)null, Name = "D", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
new { TagId = "t-w", DeviceId = "dev-z", TagGroupId = (string?)null, Name = "W", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
},
UnsAreas = new[] { new { UnsAreaId = "a1", Name = "A", ClusterId = "c1" } },
UnsLines = new[] { new { UnsLineId = "l1", UnsAreaId = "a1", Name = "B" } },
Equipment = new[] { new { EquipmentId = "EQ-1", UnsLineId = "l1", Name = "C", MachineCode = "M1" } },
// Effective name "D" (override) → UNS NodeId A/B/C/D, backing raw tag t-w (X/Y/Z/W) on drv-2.
UnsTagReferences = new[] { new { UnsTagReferenceId = "r1", EquipmentId = "EQ-1", TagId = "t-w", DisplayNameOverride = "D" } },
});
var id = DeploymentId.NewId();
using var ctx = db.CreateDbContext();
ctx.Deployments.Add(new Deployment
{
DeploymentId = id.Value,
RevisionHash = rev.Value,
Status = DeploymentStatus.Sealed,
CreatedBy = "test",
SealedAtUtc = DateTime.UtcNow,
ArtifactBlob = artifact,
DeploymentId = id.Value, RevisionHash = rev.Value, Status = DeploymentStatus.Sealed,
CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact,
});
ctx.SaveChanges();
return (id, rawBlobString);
return id;
}
/// <summary>An <see cref="IDbContextFactory{TContext}"/> whose <c>CreateDbContext</c> always throws,
/// driving <see cref="DriverHostActor"/>'s bootstrap into the <c>catch</c> ⇒ <c>Become(Stale)</c> path
/// so a write can be routed at a Stale host.</summary>
/// <summary>An <see cref="IDbContextFactory{T}"/> whose CreateDbContext always throws — drives the host
/// into the Stale path.</summary>
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
{
/// <inheritdoc />
@@ -336,15 +310,49 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
throw new InvalidOperationException("config DB unreachable (test stub)");
}
/// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type, whose
/// recorded write list is exposed for assertions.</summary>
/// <summary>Factory producing a SEPARATE <see cref="RecordingDriver"/> per driver instance id, so a test
/// with two drivers can assert which instance received a given write (used by the H1 collision test).</summary>
private sealed class PerInstanceRecordingDriverFactory : IDriverFactory
{
private readonly string _supportedType;
private readonly Dictionary<string, RecordingDriver> _byId = new(StringComparer.Ordinal);
private readonly object _lock = new();
public PerInstanceRecordingDriverFactory(string supportedType) { _supportedType = supportedType; }
/// <summary>The writes the given driver instance received.</summary>
public IReadOnlyList<WriteRequest> WritesFor(string driverInstanceId)
{
lock (_lock) return _byId.TryGetValue(driverInstanceId, out var d) ? d.Writes : Array.Empty<WriteRequest>();
}
/// <inheritdoc />
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson)
{
if (!string.Equals(driverType, _supportedType, StringComparison.Ordinal)) return null;
lock (_lock)
{
if (!_byId.TryGetValue(driverInstanceId, out var d))
{
d = new RecordingDriver();
d.Bind(driverInstanceId, driverType);
_byId[driverInstanceId] = d;
}
return d;
}
}
/// <inheritdoc />
public IReadOnlyCollection<string> SupportedTypes => new[] { _supportedType };
}
/// <summary>Factory producing a single <see cref="RecordingDriver"/> for the supported type.</summary>
private sealed class RecordingDriverFactory : IDriverFactory
{
private readonly string _supportedType;
private readonly RecordingDriver _driver = new();
public RecordingDriverFactory(string supportedType) { _supportedType = supportedType; }
/// <summary>The writes the spawned driver received (thread-safe — WriteAsync runs off the actor thread).</summary>
/// <summary>The writes the spawned driver received.</summary>
public IReadOnlyList<WriteRequest> Writes => _driver.Writes;
/// <inheritdoc />
@@ -389,7 +397,7 @@ public sealed class DriverHostActorWriteRoutingTests : RuntimeActorTestBase
{
foreach (var w in writes) _writes.Enqueue(w);
return Task.FromResult<IReadOnlyList<WriteResult>>(
writes.Select(_ => new WriteResult(0u)).ToArray()); // 0x0 = Good
writes.Select(_ => new WriteResult(0u)).ToArray());
}
}
}
@@ -175,15 +175,19 @@ public sealed class AddHistorianProvisioningTests
prov.Seen[0].DataType.ShouldBe(DriverDataType.Float32);
}
private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: fullName,
Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: historianName);
// v3 Batch 4: historized value tags are RAW tags (the applier provisions from AddedRawTags). NodeId plays
// the RawPath role (== mux ref + historian default).
private static RawTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref")
=> new("tag-" + displayName, NodeId: fullName, ParentNodeId: null, DriverInstanceId: "drv", Name: displayName,
DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
IsHistorized: true, HistorianTagname: historianName);
private static EquipmentTagPlan NonHistorizedTag(string displayName, string dataType)
=> new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: "ref",
Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null);
private static RawTagPlan NonHistorizedTag(string displayName, string dataType)
=> new("tag-" + displayName, NodeId: "ref", ParentNodeId: null, DriverInstanceId: "drv", Name: displayName,
DataType: dataType, Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>(),
IsHistorized: false, HistorianTagname: null);
private static AddressSpacePlan PlanWithAddedTags(params EquipmentTagPlan[] tags) => new(
private static AddressSpacePlan PlanWithAddedTags(params RawTagPlan[] tags) => new(
AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: Array.Empty<EquipmentNode>(),
ChangedEquipment: Array.Empty<AddressSpacePlan.EquipmentDelta>(),
@@ -194,6 +198,6 @@ public sealed class AddHistorianProvisioningTests
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<AddressSpacePlan.AlarmDelta>())
{
AddedEquipmentTags = tags,
AddedRawTags = tags,
};
}
@@ -51,7 +51,7 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
NodeId: "ns=2;s=tag-1",
Value: 42,
Quality: OpcUaQuality.Good,
TimestampUtc: DateTime.UtcNow));
TimestampUtc: DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
AwaitAssertion(() =>
{
@@ -194,24 +194,24 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// <param name="value">The value being written.</param>
/// <param name="quality">The OPC UA quality status.</param>
/// <param name="sourceTimestampUtc">The source timestamp in UTC.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) => Writes++;
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Writes++;
/// <summary>Records an alarm condition write.</summary>
/// <param name="alarmNodeId">The alarm node identifier.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="occurredUtc">The time the alarm occurred in UTC.</param>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc) => Writes++;
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Writes++;
/// <summary>Materialises an alarm condition (stub implementation).</summary>
/// <param name="alarmNodeId">The alarm node identifier.</param>
/// <param name="equipmentNodeId">The equipment folder node identifier.</param>
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
/// <summary>Ensures folder exists (stub implementation).</summary>
/// <param name="folderNodeId">The folder node identifier.</param>
/// <param name="parentNodeId">The parent folder node identifier.</param>
/// <param name="displayName">The display name for the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
/// <summary>Ensures variable exists (stub implementation).</summary>
/// <param name="variableNodeId">The variable node identifier.</param>
/// <param name="parentFolderNodeId">The parent folder node identifier.</param>
@@ -219,10 +219,12 @@ public sealed class OtOpcUaTelemetryHookTests : RuntimeActorTestBase
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
/// <summary>Rebuilds address space (recorded via span).</summary>
public void RebuildAddressSpace() { /* recorded via span */ }
/// <summary>Announces a NodeAdded model-change (stub implementation).</summary>
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
}
@@ -118,25 +118,29 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase
/// <summary>A sink whose RebuildAddressSpace throws (drives the applier's SafeRebuild catch → RebuildFailed).</summary>
private sealed class ThrowOnRebuildSink : IOpcUaAddressSpaceSink
{
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void RebuildAddressSpace() => throw new InvalidOperationException("simulated rebuild fault");
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
/// <summary>A no-op sink — a clean apply (no failures).</summary>
private sealed class NoopSink : IOpcUaAddressSpaceSink
{
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId) { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
/// <summary>Listens to a single instrument by name on the central meter and tallies value + tags.</summary>
@@ -437,13 +437,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// <param name="value">The value to write.</param>
/// <param name="quality">The OPC UA quality code.</param>
/// <param name="ts">The timestamp of the write.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts)
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> Calls.Enqueue($"WV:{nodeId}");
/// <summary>Records an alarm condition write call.</summary>
/// <param name="alarmNodeId">The alarm node ID.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="ts">The timestamp of the state change.</param>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts)
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> Calls.Enqueue($"WA:{alarmNodeId}");
/// <summary>Records a materialise-alarm-condition call.</summary>
/// <param name="alarmNodeId">The alarm node ID (== ScriptedAlarmId).</param>
@@ -451,13 +451,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false)
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false)
=> Calls.Enqueue($"MA:{alarmNodeId}");
/// <summary>Records a folder ensure call.</summary>
/// <param name="folderNodeId">The folder node ID.</param>
/// <param name="parentNodeId">The parent node ID, or null if this is a root folder.</param>
/// <param name="displayName">The display name of the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
=> Calls.Enqueue($"EF:{folderNodeId}");
/// <summary>Records a variable ensure call.</summary>
/// <param name="variableNodeId">The variable node ID.</param>
@@ -466,15 +466,17 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null)
=> Calls.Enqueue($"EV:{variableNodeId}");
/// <summary>Records a rebuild address space call.</summary>
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
/// <summary>Records a NodeAdded model-change announcement.</summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId) => Calls.Enqueue($"NA:{affectedNodeId}");
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => Calls.Enqueue($"NA:{affectedNodeId}");
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
/// <summary>Records a surgical in-place tag-attribute update (always succeeds in this recording sink).</summary>
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength)
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
Calls.Enqueue($"UT:{variableNodeId}");
return true;
@@ -482,25 +484,25 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// <summary>Records a surgical in-place folder display-name update (always succeeds in this recording sink).</summary>
/// <param name="folderNodeId">The folder node ID to update in place.</param>
/// <param name="displayName">The new display name to apply.</param>
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
FolderRenameQueue.Enqueue((folderNodeId, displayName));
return true;
}
/// <summary>Records a surgical variable-node removal (always succeeds in this recording sink).</summary>
public bool RemoveVariableNode(string variableNodeId)
public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
Calls.Enqueue($"RV:{variableNodeId}");
return true;
}
/// <summary>Records a surgical alarm-condition-node removal (always succeeds in this recording sink).</summary>
public bool RemoveAlarmConditionNode(string alarmNodeId)
public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
Calls.Enqueue($"RA:{alarmNodeId}");
return true;
}
/// <summary>Records a surgical equipment-subtree removal (always succeeds in this recording sink).</summary>
public bool RemoveEquipmentSubtree(string equipmentNodeId)
public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
Calls.Enqueue($"RE:{equipmentNodeId}");
return true;
@@ -20,8 +20,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
public void Accepts_message_contracts_without_pinned_dispatcher_in_tests()
{
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests());
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow));
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=Tag1", 42.0, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("ns=2;s=Alarm1", Snapshot(active: true), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
actor.Tell(new OpcUaPublishActor.ServiceLevelChanged(240));
@@ -43,8 +43,8 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
var sink = new RecordingSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaQuality.Good, DateTime.UtcNow));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaQuality.Uncertain, DateTime.UtcNow));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T1", 3.14, OpcUaQuality.Good, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate("ns=2;s=T2", "abc", OpcUaQuality.Uncertain, DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
AwaitAssert(() =>
{
@@ -64,7 +64,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
actor.Tell(new OpcUaPublishActor.AlarmStateUpdate(
"ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow));
"ns=2;s=A1", Snapshot(active: true, acknowledged: false, severity: 700), DateTime.UtcNow, Realm: AddressSpaceRealm.Uns));
AwaitAssert(() =>
{
@@ -606,14 +606,14 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
/// <param name="value">The attribute value.</param>
/// <param name="quality">The OPC UA quality code.</param>
/// <param name="ts">The timestamp of the update.</param>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts) =>
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
ValueQueue.Enqueue((nodeId, value, quality, ts));
/// <summary>Records an alarm condition update.</summary>
/// <param name="alarmNodeId">The OPC UA alarm node identifier.</param>
/// <param name="state">The full condition state snapshot.</param>
/// <param name="ts">The timestamp of the update.</param>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts) =>
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
AlarmQueue.Enqueue((alarmNodeId, state, ts));
/// <summary>Materialises an alarm condition (no-op in test).</summary>
@@ -622,13 +622,13 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
/// <param name="displayName">The condition display name.</param>
/// <param name="alarmType">The domain alarm type.</param>
/// <param name="severity">The domain severity.</param>
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
/// <summary>Records a folder ensure call.</summary>
/// <param name="folderNodeId">The OPC UA folder node identifier.</param>
/// <param name="parentNodeId">The parent folder node identifier, or null for root.</param>
/// <param name="displayName">The display name of the folder.</param>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) =>
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) =>
FolderQueue.Enqueue((folderNodeId, parentNodeId, displayName));
/// <summary>Records a variable ensure call.</summary>
@@ -638,7 +638,7 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
/// <param name="dataType">The OPC UA built-in type name.</param>
/// <param name="writable">Whether the node is created read/write.</param>
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) =>
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) =>
VariableQueue.Enqueue((variableNodeId, parentFolderNodeId, displayName, dataType, writable));
/// <summary>Records a rebuild call.</summary>
@@ -646,7 +646,9 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
/// <summary>Records a NodeAdded model-change announcement.</summary>
/// <param name="affectedNodeId">The node under which discovered nodes were added.</param>
public void RaiseNodesAddedModelChange(string affectedNodeId) => ModelChangeQueue.Enqueue(affectedNodeId);
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => ModelChangeQueue.Enqueue(affectedNodeId);
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
/// <summary>Test implementation of IServiceLevelPublisher that records publishes.</summary>
@@ -138,7 +138,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
}
/// <summary>Activation path: with the alarm loaded, pushing a value above the threshold drives an
/// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true) and an
/// Inactive→Active transition — the host publishes an AlarmStateUpdate(Active=true, Realm: AddressSpaceRealm.Uns) and an
/// AlarmTransitionEvent("Activated") on the alerts topic.</summary>
[Fact]
public void Dependency_change_above_threshold_activates_alarm()
@@ -180,7 +180,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
}
/// <summary>Clear path: after activating, pushing a value below the threshold drives Active→Inactive
/// — AlarmStateUpdate(Active=false) + AlarmTransitionEvent("Cleared").</summary>
/// — AlarmStateUpdate(Active=false, Realm: AddressSpaceRealm.Uns) + AlarmTransitionEvent("Cleared").</summary>
[Fact]
public void Dependency_change_below_threshold_clears_alarm()
{
@@ -279,7 +279,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
/// <summary>Command path: an AlarmCommand("Acknowledge") for an alarm this host owns (and that is
/// currently active+unacknowledged) drives the engine's AcknowledgeAsync — observed via the resulting
/// AlarmStateUpdate(Acknowledged=true) and an AlarmTransitionEvent("Acknowledged") on the alerts topic
/// AlarmStateUpdate(Acknowledged=true, Realm: AddressSpaceRealm.Uns) and an AlarmTransitionEvent("Acknowledged") on the alerts topic
/// carrying the command's User (the user threads through AcknowledgeAsync → LastAckUser → evt.User).</summary>
[Fact]
public void AlarmCommand_acknowledge_drives_engine_with_mapped_args()
@@ -573,7 +573,7 @@ public sealed class ScriptedAlarmHostActorTests : RuntimeActorTestBase
/// <summary>Inbound command ungated by role (T1): the alerts-publish gate must NOT affect inbound
/// command processing. Under a Secondary role, an AlarmCommand("Acknowledge") for an owned, active
/// alarm still drives the engine — observed via the resulting AlarmStateUpdate(Acknowledged=true)
/// alarm still drives the engine — observed via the resulting AlarmStateUpdate(Acknowledged=true, Realm: AddressSpaceRealm.Uns)
/// (the OPC UA node write is ungated so the secondary's engine state + address space stay consistent).</summary>
[Fact]
public void Inbound_AlarmCommand_is_processed_regardless_of_role()
@@ -476,6 +476,104 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
captured.Value.StatusCode.ShouldBe(0x80020000u); // BadInternalError — dormant-engine parity
}
/// <summary>
/// v3 Batch 4 live-gate regression (RED on the pre-fix tree): after a deploy re-materialises a
/// VirtualTag's UNS node to BadWaitingForInitialData, a surviving (unchanged-plan) child must
/// re-assert its last value so the node recovers — otherwise a static-dependency VirtualTag stays Bad
/// forever because its unchanged recompute is dedup-suppressed. Drives the REAL host + real child:
/// apply a plan, capture the spawned child via the mux probe, feed one dependency change to publish a
/// Good value (which the child then dedups), then re-apply the SAME plan (the redeploy). The fix makes
/// the survivor re-publish its last value; on the unfixed tree the second ExpectMsg times out.
/// </summary>
[Fact]
public void Unchanged_redeploy_reasserts_last_value_so_a_reset_uns_node_recovers()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new ConstOkEvaluator(7.0)));
var plan = new[] { Plan("vt-1", "eq-1", "speed-rpm") };
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
// Capture the spawned child from the RegisterInterest sender so we can drive a dependency change
// through the real evaluate → parent-Tell → bridge path.
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
var child = mux.LastSender;
// First dependency change → Ok(7) → Good publish; the child now holds 7 as its dedup'd last value.
child.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
var first = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
first.Value.ShouldBe(7.0);
first.Quality.ShouldBe(OpcUaQuality.Good);
// The redeploy re-materialises the node to BadWaitingForInitialData and re-applies the UNCHANGED
// plan — the child survives with its dedup state. The fix re-asserts the last value; without it the
// node would stay Bad (an unchanged recompute of 7 is dedup-suppressed) and this ExpectMsg times out.
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
var reasserted = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
reasserted.NodeId.ShouldBe("eq-1/speed-rpm");
reasserted.Value.ShouldBe(7.0);
reasserted.Quality.ShouldBe(OpcUaQuality.Good);
// No child churn on the unchanged redeploy — the re-assert must not respawn or re-register.
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>
/// M1 (reassert review): a re-assert must PUBLISH (to repair the reset OPC UA node) but must NOT
/// re-record to the historian. Otherwise every deploy appends an artificial historian sample carrying a
/// stale value with a fresh deploy-time timestamp — a data-quality bug the moment a VT history sink is
/// bound. Historize=true survivor: first real evaluation records exactly once; the redeploy's re-assert
/// publishes a second time but the historian count stays 1.
/// </summary>
[Fact]
public void Reassert_of_a_historized_vtag_publishes_but_does_not_re_record_to_the_historian()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var writer = new CapturingHistoryWriter();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new ConstOkEvaluator(7.0), writer));
var plan = new[] { PlanH("vt-1", "eq-1", "speed-rpm", historize: true) };
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
var child = mux.LastSender;
// First real evaluation → Good publish + exactly one historian record.
child.Tell(new VirtualTagActor.DependencyValueChanged("a", 1, DateTime.UtcNow));
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
AwaitAssert(() => writer.Calls.Count.ShouldBe(1));
// Redeploy (unchanged plan) → the survivor re-asserts: a SECOND publish repairs the reset node.
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
// The publish landing means the reassert's OnResult turn is complete (Record runs on the same turn,
// right after the Tell) — so a second Record would already be visible. It must NOT be: still 1.
writer.Calls.Count.ShouldBe(1);
}
/// <summary>Evaluator that always returns <c>Ok(value)</c> — a static-output VirtualTag whose recompute
/// is dedup-suppressed after the first publish, so the re-assert path is the only way its reset node
/// recovers.</summary>
private sealed class ConstOkEvaluator : IVirtualTagEvaluator
{
private readonly object _value;
/// <summary>Creates an evaluator that always returns <paramref name="value"/>.</summary>
/// <param name="value">The constant value to return.</param>
public ConstOkEvaluator(object value) => _value = value;
/// <summary>Returns <c>Ok(value)</c> regardless of inputs.</summary>
/// <param name="id">The tag identifier.</param>
/// <param name="expr">The expression string.</param>
/// <param name="deps">The dependency values.</param>
/// <returns>A successful result carrying the fixed value.</returns>
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
=> VirtualTagEvalResult.Ok(_value);
}
/// <summary>Evaluator that returns <c>Ok(42.0)</c> on its first call and <c>Failure</c> thereafter —
/// the S13 "was working, now broken" shape. Interlocked counter so it is safe across the actor's
/// evaluation thread.</summary>